diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..353e722 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "npm" + directory: "/explain" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 125dc9f..5c6d4e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: branches: [main] + # Default types miss title-only edits. Label changes must also re-run the + # version suggestion because the prerelease channel comes from a label. + types: [opened, edited, reopened, synchronize, labeled, unlabeled] schedule: # Weekly cargo-audit sweep for advisories disclosed after dependencies land. # Use an off-peak minute rather than :00 or :30. @@ -24,9 +27,10 @@ jobs: run: cargo build - name: Run tests - # This crate has no library target, so --lib would fail. --bins keeps - # the live SQL Server test in its dedicated job below. - run: cargo test --bins + # This crate has no library target, so --lib would fail. Select the + # fixture-only conformance target while keeping live_db in its + # dedicated SQL Server job below. + run: cargo test --bins --test conformance - name: Clippy run: cargo clippy --all-targets -- -D warnings @@ -34,6 +38,57 @@ jobs: - name: Check formatting run: cargo fmt --all -- --check + explain-package: + name: EXPLAIN parser package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Installs the pnpm version pinned by "packageManager" in + # explain/package.json. Corepack is not used because the copy bundled + # with Node 22.13 carries stale npm registry signing keys and fails + # with "Cannot find matching keyid" when it resolves pnpm. + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: explain/package.json + + - name: Setup Node + uses: actions/setup-node@v5 + with: + # pnpm 11.23 requires Node 22.13 or newer. + node-version: "22.13" + + - name: Install dependencies + working-directory: explain + run: pnpm install --frozen-lockfile + + - name: Typecheck + working-directory: explain + run: pnpm typecheck + + - name: Test + working-directory: explain + run: pnpm test + + - name: Build + working-directory: explain + run: pnpm build + + validate-manifest: + name: Validate .tabularium manifest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # registry.tabularis.dev serves the driver-kind schema including the + # additive explain_parsers field from core PR #688, so the whole + # manifest is validated against the live validator. + - name: Validate manifest against the live registry schema + run: | + npx --yes @tabularium/cli validate .tabularium \ + --registry https://registry.tabularis.dev --kind driver + live-db-integration: name: Live SQL Server integration runs-on: ubuntu-latest @@ -73,6 +128,228 @@ jobs: SQLSERVER_TEST_DATABASE: tabularis_test run: cargo test --test live_db -- --test-threads=1 + pr-title: + name: PR title (Conventional Commits) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + version-suggestion: + name: Version suggestion + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + # Classify the PR title's Conventional Commits type + breaking-change + # flag into a version-bump class. Requires the prerelease:* label to + # know which channel (alpha/beta/rc/stable) to suggest — see README's + # "Contributing: PR Titles & Versioning" for the full convention. + - name: Classify PR title and resolve prerelease channel + id: classify + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_LABELS: ${{ toJson(github.event.pull_request.labels) }} + run: | + PATTERN='^([a-zA-Z]+)(\(([^)]+)\))?(!)?: (.+)$' + if [[ "$PR_TITLE" =~ $PATTERN ]]; then + TYPE="${BASH_REMATCH[1]}" + BANG="${BASH_REMATCH[4]}" + else + echo "::error::PR title does not match Conventional Commits format (type: subject) — cannot classify." + exit 1 + fi + + BREAKING=false + [ -n "$BANG" ] && BREAKING=true + if echo "$PR_BODY" | grep -qiE "^BREAKING[ -]CHANGE:"; then + BREAKING=true + fi + + case "$TYPE" in + feat) CLASS="minor" ;; + fix|refactor|perf) CLASS="patch" ;; + docs|style|chore|test|ci|build) CLASS="none" ;; + *) CLASS="none" ;; + esac + [ "$BREAKING" = true ] && CLASS="major" + + CHANNEL=$(echo "$PR_LABELS" | jq -r '[.[] | select(.name | startswith("prerelease:")) | .name][0] // ""' | sed 's/^prerelease://') + if [ -z "$CHANNEL" ]; then + echo "::error::No prerelease:alpha|beta|rc|stable label found on this PR. Add one so the version suggestion knows which channel to target — see README's 'Contributing: PR Titles & Versioning'." + exit 1 + fi + case "$CHANNEL" in + alpha|beta|rc|stable) ;; + *) echo "::error::Unrecognized prerelease label value '$CHANNEL' — expected alpha, beta, rc, or stable."; exit 1 ;; + esac + + { + echo "type=$TYPE" + echo "breaking=$BREAKING" + echo "class=$CLASS" + echo "channel=$CHANNEL" + } >> "$GITHUB_OUTPUT" + + - name: Resolve baseline version + id: baseline + run: | + git fetch origin main --tags --quiet + TAG=$(git -C . describe --tags --abbrev=0 origin/main 2>/dev/null || true) + if [ -n "$TAG" ]; then + BASELINE="${TAG#v}" + else + BASELINE=$(git show origin/main:.tabularium | jq -r .version) + fi + echo "version=$BASELINE" >> "$GITHUB_OUTPUT" + + - name: Compute suggestion, manage comment + uses: actions/github-script@v9 + with: + script: | + const classification = "${{ steps.classify.outputs.class }}"; + const channel = "${{ steps.classify.outputs.channel }}"; + const type = "${{ steps.classify.outputs.type }}"; + const breaking = "${{ steps.classify.outputs.breaking }}" === "true"; + const baselineStr = "${{ steps.baseline.outputs.version }}"; + const marker = "`, + }); + } + // Otherwise: never suggested anything, or already said "none" — stay silent. + return; + } + + if (previous && previousClassification === currentClassification) { + // Meaningful classification hasn't changed since the last comment. + return; + } + + async function minimizePrevious() { + if (!previous) return; + // REST comment objects expose node_id directly — no separate + // lookup needed to get the GraphQL node id. + await github.graphql( + `mutation($id: ID!) { minimizeComment(input: { subjectId: $id, classifier: OUTDATED }) { clientMutationId } }`, + { id: previous.node_id } + ); + } + + const suggested = computeNextVersion(baselineStr, classification, channel); + const tag = `v${suggested}`; + + await minimizePrevious(); + + const breakingNote = breaking ? " (breaking change)" : ""; + const body = [ + `### Version suggestion`, + ``, + `Based on this PR's title (\`${type}\`${breakingNote}) and the \`prerelease:${channel}\` label:`, + ``, + `| | |`, + `|---|---|`, + `| Current | \`${baselineStr}\` |`, + `| Suggested next tag | \`${tag}\` |`, + ``, + `This is informational only — no tag or release is created automatically yet.`, + ``, + `${marker} classification=${currentClassification} -->`, + ].join("\n"); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + + markdownlint: + name: Markdown lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Run markdownlint + run: npx --yes markdownlint-cli "**/*.md" + audit: name: Security audit runs-on: ubuntu-latest diff --git a/.github/workflows/publish-explain.yml b/.github/workflows/publish-explain.yml new file mode 100644 index 0000000..6f050ef --- /dev/null +++ b/.github/workflows/publish-explain.yml @@ -0,0 +1,59 @@ +name: Publish EXPLAIN parser + +on: + push: + tags: + - "explain-v*" + +permissions: + contents: read + id-token: write + +jobs: + publish: + name: Publish npm package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # See ci.yml: pnpm comes from the pinned "packageManager" field, not + # from the Corepack copy bundled with Node. + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: explain/package.json + + - name: Setup Node + uses: actions/setup-node@v5 + with: + # pnpm 11.23 requires Node 22.13 or newer. + node-version: "22.13" + registry-url: "https://registry.npmjs.org" + + - name: Check tag matches package and plugin versions + run: | + TAG_VERSION="${GITHUB_REF_NAME#explain-v}" + PACKAGE_VERSION=$(jq -r .version explain/package.json) + PLUGIN_VERSION=$(jq -r .version .tabularium) + if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match explain/package.json ($PACKAGE_VERSION)" + exit 1 + fi + if [ "$PACKAGE_VERSION" != "$PLUGIN_VERSION" ]; then + echo "::error::EXPLAIN package version ($PACKAGE_VERSION) does not match plugin version ($PLUGIN_VERSION)" + exit 1 + fi + + - name: Install, typecheck, test and build + working-directory: explain + run: | + pnpm install --frozen-lockfile + pnpm typecheck + pnpm test + pnpm build + + - name: Publish + working-directory: explain + run: pnpm publish --access public --provenance --no-git-checks + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41d0285..63a0a7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,25 @@ on: - "v*" jobs: + validate: + name: Validate release version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Check tag matches manifest version + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + TABULARIUM_VERSION=$(jq -r .version .tabularium) + + if [ "$TAG_VERSION" != "$TABULARIUM_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match .tabularium version ($TABULARIUM_VERSION)" + exit 1 + fi + build: name: ${{ matrix.platform-label }} + needs: validate runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -41,7 +58,7 @@ jobs: binary-suffix: ".exe" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -58,11 +75,30 @@ jobs: echo "present=false" >> "$GITHUB_OUTPUT" fi + - name: Check for EXPLAIN parser + id: explain + shell: bash + run: | + if [ -f explain/package.json ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + fi + + # See ci.yml: pnpm comes from the pinned "packageManager" field, not + # from the Corepack copy bundled with Node. + - name: Setup pnpm + if: steps.explain.outputs.present == 'true' + uses: pnpm/action-setup@v4 + with: + package_json_file: explain/package.json + - name: Setup Node - if: steps.ui.outputs.present == 'true' + if: steps.ui.outputs.present == 'true' || steps.explain.outputs.present == 'true' uses: actions/setup-node@v5 with: - node-version: "20" + # pnpm 11.23 requires Node 22.13 or newer. + node-version: "22.13" - name: Build UI if: steps.ui.outputs.present == 'true' @@ -72,6 +108,14 @@ jobs: npm install --no-audit --no-fund npm run build + - name: Build EXPLAIN parser + if: steps.explain.outputs.present == 'true' + shell: bash + working-directory: explain + run: | + pnpm install --frozen-lockfile + pnpm run build + - name: Install cross (linux-arm64 only) if: matrix.cross run: cargo install cross --locked @@ -98,6 +142,10 @@ jobs: mkdir -p "$STAGE/ui/dist" cp ui/dist/index.js "$STAGE/ui/dist/" fi + if [ -f explain/dist/index.iife.js ]; then + mkdir -p "$STAGE/explain/dist" + cp explain/dist/index.iife.js "$STAGE/explain/dist/" + fi (cd "$STAGE" && zip -r ../sqlserver-plugin-${{ matrix.platform-label }}.zip .) - name: Package (windows) @@ -115,6 +163,10 @@ jobs: New-Item -ItemType Directory -Force -Path "$stage\ui\dist" | Out-Null Copy-Item "ui\dist\index.js" "$stage\ui\dist" } + if (Test-Path "explain\dist\index.iife.js") { + New-Item -ItemType Directory -Force -Path "$stage\explain\dist" | Out-Null + Copy-Item "explain\dist\index.iife.js" "$stage\explain\dist" + } Compress-Archive -Path "$stage\*" -DestinationPath "sqlserver-plugin-${{ matrix.platform-label }}.zip" - name: Stash artifact @@ -132,7 +184,7 @@ jobs: contents: write steps: - name: Checkout (for the .tabularium manifest asset) - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Download all build artifacts uses: actions/download-artifact@v5 @@ -140,6 +192,17 @@ jobs: path: artifacts merge-multiple: true + - name: Detect prerelease from tag + id: meta + env: + TAG_NAME: ${{ github.ref_name }} + run: | + if [[ "$TAG_NAME" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + - name: Publish release uses: softprops/action-gh-release@v2 with: @@ -148,3 +211,5 @@ jobs: files: | artifacts/*.zip .tabularium + prerelease: ${{ steps.meta.outputs.prerelease == 'true' }} + make_latest: ${{ steps.meta.outputs.prerelease == 'false' }} diff --git a/.gitignore b/.gitignore index a338ac4..a66c687 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ dist/ *.zip ui/node_modules/ ui/dist/ +explain/node_modules/ .DS_Store diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..f0dfe2e --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,8 @@ +{ + "default": true, + "MD013": false, + "MD024": { "siblings_only": true }, + "MD033": false, + "MD041": false, + "MD060": false +} diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..297fdef --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,2 @@ +target/ +node_modules/ diff --git a/.tabularium b/.tabularium index aa5ff9b..f53801a 100644 --- a/.tabularium +++ b/.tabularium @@ -1,9 +1,73 @@ { - "$schema": "https://tabularis.dev/schemas/plugin-manifest.json", - "id": "sqlserver", - "name": "SQL Server", - "version": "0.1.0", - "description": "Microsoft SQL Server driver for Tabularis", + "$schema": "https://registry.tabularis.dev/manifest.schema.json?kind=driver", + "name": "sqlserver", + "version": "1.0.0-beta.1", + "description": "Full-featured Microsoft SQL Server driver for Tabularis with schema browsing, query execution, visual plans, type-aware row editing, DDL, routines, triggers, BLOBs, and database-user management.", + "category": "database", + "tags": ["driver", "sqlserver", "mssql", "sql", "relational", "database-driver"], + "license": "Apache-2.0", + "icon": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/sqlserver-icon.svg", + "color": "#CC2927", + "screenshots": [ + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/01-fresh-install.png", + "caption": "Database Manager on first launch", + "alt": "Tabularis Database Manager showing no active connections" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/02-database-picker.png", + "caption": "SQL Server listed in the database picker", + "alt": "Choose a database dialog showing SQL Server installed under SQL and Relational categories" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/03-connection-form.png", + "caption": "Connection configuration form", + "alt": "SQL Server connection form with connection string, host, port, username, password, and database fields filled in" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/04-test-connection-success.png", + "caption": "Successful connection test", + "alt": "SQL Server connection form showing a green Connection successful message" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/05-connections-list.png", + "caption": "Saved connection in the Database Manager", + "alt": "Database Manager showing one saved SQL Server connection card" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/06-schema-browser.png", + "caption": "Multi-schema browsing with tables, views, routines, and triggers", + "alt": "Sidebar schema tree showing SQL Server schemas with tables, views, routines, and triggers" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/07-table-data.png", + "caption": "Data grid with filtering and sorting", + "alt": "SQL Server customer table data grid filtered to the West region" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/08-visual-explain.png", + "caption": "Visual EXPLAIN for a SQL Server SHOWPLAN", + "alt": "Visual EXPLAIN graph showing SQL Server SELECT, hash match, clustered index scan, and index seek operators" + } + ], + "readme": "README.md", + "homepage": "https://github.com/TabularisDB/tabularis-sqlserver-plugin", + "documentation_url": "https://github.com/TabularisDB/tabularis-sqlserver-plugin#readme", + "min_runtime_version": "0.23.0", + "support": { + "issues_url": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/issues" + }, + "kind": "driver", + "engine": "sqlserver", + "explain_parsers": [ + { + "engine": "sqlserver", + "format": "sqlserver-showplan-xml", + "label": "SQL Server SHOWPLAN XML", + "module": "explain/dist/index.iife.js" + } + ], + "paradigms": ["relational"], "default_port": 1433, "default_username": "sa", "executable": "sqlserver-plugin", @@ -13,6 +77,7 @@ "routines": true, "routine_management": true, "triggers": true, + "user_management": true, "file_based": false, "folder_based": false, "no_connection_required": false, @@ -31,6 +96,59 @@ "supports_ssl": true, "explain": true }, + "type_mappings": { + "TIMESTAMP": "DATETIME2", + "BOOLEAN": "BIT", + "TEXT": "NVARCHAR(MAX)", + "BLOB": "VARBINARY(MAX)", + "SERIAL": "INT IDENTITY(1,1)", + "UUID": "UNIQUEIDENTIFIER", + "JSON": "NVARCHAR(MAX)" + }, + "settings": [ + { + "key": "max_pool_size", + "label": "Maximum Pool Size", + "type": "number", + "default": 10, + "description": "Maximum number of SQL Server sessions in each connection pool." + }, + { + "key": "connect_timeout_seconds", + "label": "Connection Timeout (seconds)", + "type": "number", + "default": 15, + "description": "Maximum time allowed to establish and authenticate a new SQL Server session." + }, + { + "key": "query_timeout_seconds", + "label": "Query Timeout (seconds)", + "type": "number", + "default": 0, + "description": "Maximum query duration; 0 disables the query timeout." + }, + { + "key": "application_name", + "label": "Application Name", + "type": "string", + "default": "Tabularis", + "description": "Application name reported for SQL Server sessions." + }, + { + "key": "trust_server_certificate", + "label": "Trust Server Certificate", + "type": "boolean", + "default": false, + "description": "Accept a self-signed server certificate without validation. Use only for trusted development servers." + }, + { + "key": "pool_idle_eviction_minutes", + "label": "Pool Idle Eviction (minutes)", + "type": "number", + "default": 10, + "description": "Interval for evicting connection pools that have no checked-out sessions." + } + ], "data_types": [ { "name": "TINYINT", @@ -100,53 +218,53 @@ }, { "name": "CHAR", - "category": "text", + "category": "string", "requires_length": true, "requires_precision": false, "default_length": "1" }, { "name": "VARCHAR", - "category": "text", + "category": "string", "requires_length": true, "requires_precision": false, "default_length": "255" }, { "name": "VARCHAR(MAX)", - "category": "text", + "category": "string", "requires_length": false, "requires_precision": false }, { "name": "TEXT", - "category": "text", + "category": "string", "requires_length": false, "requires_precision": false }, { "name": "NCHAR", - "category": "text", + "category": "string", "requires_length": true, "requires_precision": false, "default_length": "1" }, { "name": "NVARCHAR", - "category": "text", + "category": "string", "requires_length": true, "requires_precision": false, "default_length": "255" }, { "name": "NVARCHAR(MAX)", - "category": "text", + "category": "string", "requires_length": false, "requires_precision": false }, { "name": "NTEXT", - "category": "text", + "category": "string", "requires_length": false, "requires_precision": false }, @@ -178,37 +296,37 @@ }, { "name": "DATE", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, { "name": "TIME", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, { "name": "DATETIME", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, { "name": "DATETIME2", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, { "name": "SMALLDATETIME", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, { "name": "DATETIMEOFFSET", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, diff --git a/CHANGELOG.md b/CHANGELOG.md index b23c9c9..55b497e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,18 +7,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +The release candidate version is `1.0.0-beta.1`; it has not yet been tagged or +published. Publication is tracked in +[issue #4](https://github.com/TabularisDB/tabularis-sqlserver-plugin/issues/4). + +### Breaking + +- Visual EXPLAIN now returns raw `sqlserver-showplan-xml` for the plugin-owned + TypeScript parser instead of parsing SHOWPLAN in the Rust process. This + requires Tabularis 0.23.0 or later, the first intended runtime with raw + plugin EXPLAIN output and plugin parser-bundle loading. + ### Changed -- Replaced the `tiberius` TDS client with Microsoft's `mssql-tds` implementation through `mssql-tiberius-bridge`, preserving the plugin's user-facing connection and query behaviour +- Replaced the `tiberius` TDS client with Microsoft's `mssql-tds` + implementation through `mssql-tiberius-bridge`, preserving result-set + metadata, affected-row reporting, session recovery, pagination, and static + and runtime execution-plan capture. +- Adopted the `1.0.0-beta.N` prerelease line for the completed driver instead + of retaining the scaffold's `0.1.0` version. Pull-request + `prerelease:alpha`, `prerelease:beta`, `prerelease:rc`, and + `prerelease:stable` labels drive version suggestions. +- Aligned paging with host lookahead semantics, made totals explicitly + on-demand, and capped each statement at 10,000 retained rows across result + sets. ### Added -- Automated live SQL Server 2022 JSON-RPC integration tests for TLS, DDL, CRUD, result-set metadata, affected rows, identity recovery, pagination, error recovery, execution plans, and startup scripts -- Initial SQL Server driver with `deadpool` pooling, TLS modes, session reset, and startup scripts -- Schema, table, column, PK/FK, index, view, routine, and trigger introspection -- Query execution with pagination, CTE/DML classification, multiple result sets, and accurate affected rows (incl. DML `OUTPUT`) -- INSERT/UPDATE/DELETE with composite primary keys and safe `IDENTITY_INSERT` recovery -- Table/view/index/foreign-key DDL and safe `ALTER COLUMN` generation -- Procedure/function management, typed `OUT`/`INOUT` variables, and table-valued functions -- Static and runtime execution plans through `SHOWPLAN_XML` / `STATISTICS XML`, parsed into the visual-plan model -- JavaScript-safe `BIGINT` extraction and broad SQL Server type handling +- URL and ADO.NET/ODBC connection strings with deterministic reconciliation + against discrete connection fields and normalized pool keys. +- Raw BLOB export and bounded MIME-sniffed previews for SQL Server binary + types, including composite-primary-key lookup and oversized-value guards. +- Manifest-backed initialization settings for pool sizing, connection and + query timeouts, TDS application identity, certificate trust, and idle pool + eviction. +- SQL-authenticated login and database-user lifecycle management, privilege + catalogs, direct and inherited grant reporting, and transactional privilege + changes. +- Registry-grade manifest metadata, SQL Server branding and screenshots, + native type mappings, synchronized data-type declarations, and release + workflows for five desktop platforms. +- A browser-safe SQL Server SHOWPLAN parser built as both the plugin IIFE and + the independently publishable `@tabularis/explain-sqlserver` package. +- CI checks for formatting, Clippy, unit and live SQL Server 2022 tests, + manifest and Markdown validation, Conventional Commit pull-request titles, + version suggestions, dependency updates, RustSec advisories, release + tag/version agreement, and the TypeScript parser package. +- Host-model conformance fixtures for every implemented RPC and a live type + matrix for all 37 manifest-advertised SQL Server types. + +### Fixed + +- Hardened identifier quoting and separated identifier, bound-value and + explicit raw-SQL boundaries throughout CRUD, DDL, routine, trigger and user + management. +- Preserved exact numeric, temporal, BLOB, UDT and `SQL_VARIANT` values across + reads and row edits; concurrency-token types remain read-only. +- Added structured SQL Server error categories, credential redaction and safe + replacement of timed-out, failed, transactional or dead pooled sessions. +- Reset SHOWPLAN, `IDENTITY_INSERT`, startup-script and temporary-table state + before pooled session reuse. + +### Performance + +- Added bounded request and response queues, explicit idle-pool closing and + regression coverage for pool identity, million-row truncation and concurrent + responsiveness. + +[Unreleased]: https://github.com/TabularisDB/tabularis-sqlserver-plugin/compare/main...HEAD diff --git a/CLAUDE.md b/CLAUDE.md index 63b6ab4..145f2cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co A [Tabularis](https://github.com/TabularisDB/tabularis) driver plugin, written in Rust, that lets Tabularis connect to Microsoft SQL Server. Tabularis launches the compiled binary as a subprocess and talks to it over stdio using JSON-RPC (one JSON object per line in, one JSON object per line out). The plugin has no server of its own and no persistent state beyond an in-process connection-pool cache. -Full plugin contract (required RPC methods, manifest schema) lives in the upstream guide: `https://github.com/TabularisDB/tabularis/blob/main/plugins/PLUGIN_GUIDE.md`. +Full plugin contract (required RPC methods, manifest schema) lives in the upstream guide: `https://github.com/TabularisDB/tabularis/blob/main/plugins/PLUGIN_GUIDE.md`. The frozen contract for plugin-owned EXPLAIN parser work is [`docs/explain-architecture.md`](docs/explain-architecture.md). ## Commands @@ -29,21 +29,24 @@ Run a single test: `cargo test `. ## Architecture -``` +```text src/ main.rs # tokio entrypoint: stdin reader → worker pool → stdout writer rpc.rs # JSON-RPC dispatch + response/param helpers models.rs # serde shapes mirroring the Tabularis host's models common.rs # query classification + JS-safe integer helpers - pool_manager.rs # per-connection-key deadpool cache + connection.rs # URL/keyword connection strings and canonical reconciliation + settings.rs # forgiving process settings received by initialize + pool_manager.rs # canonical-key deadpool cache and idle eviction handlers/ # thin JSON adapters, one module per RPC area driver/ # SQL Server logic ops.rs # one free function per host RPC method - pool.rs # Microsoft mssql-tds client via bridge + deadpool Manager (TLS modes, startup scripts) - introspection.rs, helpers.rs, ddl/, routines/, triggers/, types.rs, version.rs - extract/ # row → JSON value extraction (incl. temporal types) - explain.rs # SHOWPLAN_XML / STATISTICS XML capture - showplan.rs # SHOWPLAN XML → visual-plan JSON (plugins return parsed plans) + pool.rs # Microsoft mssql-tds bridge + deadpool Manager, TLS and reset + error.rs # categorized errors, discard policy and credential redaction + blob.rs, users.rs, introspection.rs, helpers.rs, ddl/, routines/, triggers/, types.rs + extract/ # row → JSON value extraction, including temporal types + explain.rs # raw SHOWPLAN_XML / STATISTICS XML capture + explain/ # TypeScript SHOWPLAN parser, plugin IIFE and npm package ``` Key invariants: @@ -51,4 +54,4 @@ Key invariants: - JSON emitted by handlers must deserialize into the host's model structs — `models.rs` mirrors the host's serde shapes; don't change field names or nullability casually. - `.tabularium` `data_types` mirrors `driver/types.rs::get_data_types()`; keep them in sync. - `update_record`/`delete_record` receive a `pk_map` (composite PKs supported); ordering is normalized by sorting column names. -- Unlike built-in drivers, a plugin's `explain_query` result passes through to the frontend untouched — hence the in-process SHOWPLAN parser. +- `explain_query` returns raw `sqlserver-showplan-xml`; compatible hosts recognize that shape as raw EXPLAIN output and dispatch its payload to the plugin-owned TypeScript parser registered from `explain/dist/index.iife.js`. Keep the raw shape, manifest declaration, parser bundle and runtime version floor aligned with `docs/explain-architecture.md`. diff --git a/Cargo.lock b/Cargo.lock index fc61a48..40d6957 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,6 +217,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -382,6 +393,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foreign-types" version = "0.3.2" @@ -586,6 +603,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "infer" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +dependencies = [ + "cfb", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1052,12 +1078,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "roxmltree" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" - [[package]] name = "rust_decimal" version = "1.42.1" @@ -1246,16 +1266,15 @@ dependencies = [ [[package]] name = "sqlserver-plugin" -version = "0.1.0" +version = "1.0.0-beta.1" dependencies = [ "base64", "chrono", "deadpool", + "infer", "mssql-tds-preview", "mssql-tiberius-bridge", "once_cell", - "roxmltree", - "rust_decimal", "serde", "serde_json", "tokio", @@ -1590,6 +1609,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index fbf12ce..e66b2ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlserver-plugin" -version = "0.1.0" +version = "1.0.0-beta.1" edition = "2021" description = "Tabularis driver plugin for Microsoft SQL Server" license = "Apache-2.0" @@ -10,6 +10,7 @@ publish = false base64 = "0.22" chrono = "0.4" deadpool = "0.12" +infer = "0.22" # SQL Server driver — Microsoft's mssql-tds protocol implementation behind a # tiberius-compatible API, pooled with deadpool. Keep the exact preview pin: # preview releases may change bridge semantics without a stable-version signal. @@ -22,8 +23,6 @@ mssql-tiberius-bridge = "=0.1.0-preview.3" # authentication is disabled because this plugin supports SQL auth only. mssql-tds-preview = { version = "=0.1.0-preview.1", default-features = false } once_cell = "1" -roxmltree = "0.20" -rust_decimal = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } diff --git a/README.md b/README.md index d6cf602..eda5087 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@
- + Tabularis logo + SQL Server plugin icon
# tabularis-sqlserver-plugin

-![](https://img.shields.io/github/release/tabularisDB/tabularis-sqlserver-plugin.svg?style=flat) -![](https://img.shields.io/github/downloads/tabularisDB/tabularis-sqlserver-plugin/total.svg?style=flat) +![Release](https://img.shields.io/github/release/TabularisDB/tabularis-sqlserver-plugin.svg?style=flat) +![Downloads](https://img.shields.io/github/downloads/TabularisDB/tabularis-sqlserver-plugin/total.svg?style=flat) ![Build & Release](https://github.com/tabularisDB/tabularis-sqlserver-plugin/workflows/Release/badge.svg) [![Discord](https://img.shields.io/discord/1502944695808950282?color=5865F2&logo=discord&logoColor=white)](https://discord.com/invite/K2hmhfHRSt) @@ -15,21 +16,32 @@ A [Microsoft SQL Server](https://www.microsoft.com/sql-server) plugin for [Tabularis](https://github.com/TabularisDB/tabularis), the lightweight database management tool. -This plugin enables Tabularis to connect to SQL Server instances, providing schema introspection, query execution, full CRUD, DDL, trigger and stored-routine management, and visual execution plans through a JSON-RPC 2.0 over stdio interface. It is written in Rust on top of Microsoft's [`mssql-tds`](https://github.com/microsoft/mssql-rust) protocol implementation (via [`mssql-tiberius-bridge`](https://crates.io/crates/mssql-tiberius-bridge)) with [`deadpool`](https://crates.io/crates/deadpool) connection pooling. +This plugin enables Tabularis to connect to SQL Server instances, providing schema introspection, query execution, full CRUD, DDL, trigger and stored-routine management, BLOB handling, database-user management, and visual execution plans through a JSON-RPC 2.0 over stdio interface. It is written in Rust on top of Microsoft's [`mssql-tds`](https://github.com/microsoft/mssql-rust) protocol implementation (via [`mssql-tiberius-bridge`](https://crates.io/crates/mssql-tiberius-bridge)) with [`deadpool`](https://crates.io/crates/deadpool) connection pooling. -The client was swapped to Microsoft's protocol implementation to align the plugin with the actively developed upstream SQL Server stack while the bridge preserves the API the driver uses. This is an internal transport change: connection settings and user-facing behaviour are unchanged, and existing users do not need to migrate anything. +> **Requires Tabularis v0.23.0 or later.** This plugin relies on raw plugin +> EXPLAIN output and plugin-provided parser bundle loading targeted for that +> release. Do not publish this candidate before a compatible host is available. -**Discord** - [Join our discord server](https://discord.com/invite/K2hmhfHRSt) and chat with the maintainers. +**Discord** — [Join our Discord server](https://discord.com/invite/K2hmhfHRSt) and chat with the maintainers. ## Table of Contents - [Features](#features) +- [Screenshots](#screenshots) - [Connection Configuration](#connection-configuration) +- [Plugin Settings](#plugin-settings) +- [Query Execution Semantics](#query-execution-semantics) - [Supported Data Types](#supported-data-types) +- [Database Users and Privileges](#database-users-and-privileges) +- [Visual EXPLAIN](#visual-explain) - [Installation](#installation) +- [How It Works](#how-it-works) +- [Supported Operations](#supported-operations) - [Known Limitations](#known-limitations) - [Building from Source](#building-from-source) - [Development](#development) +- [Contributing](#contributing) +- [Changelog](#changelog) - [Credits](#credits) - [License](#license) @@ -42,20 +54,68 @@ The client was swapped to Microsoft's protocol implementation to align the plugi - INSERT/UPDATE/DELETE with composite primary keys and safe `IDENTITY_INSERT` recovery - Table/view/index/foreign-key DDL and safe `ALTER COLUMN` generation - Trigger creation, editing, and removal +- SQL-authenticated database-user, login, role, and privilege management - Procedure/function management, typed `OUT`/`INOUT` variables, and table-valued functions - Static and runtime execution plans through `SHOWPLAN_XML` / `STATISTICS XML`, rendered in Tabularis's Visual EXPLAIN - JavaScript-safe `BIGINT` extraction and broad SQL Server type handling +- Release workflow targets for Linux x86_64 and ARM64, macOS x86_64 and Apple Silicon, and Windows x86_64 + +## Screenshots + + + + + + + + + + +
SQL Server listed in the database picker
SQL Server in the database picker
SQL Server connection configuration form
Connection configuration
SQL Server schema browser with tables, views, routines, and triggers
Multi-schema browsing
Visual EXPLAIN graph of a SQL Server SHOWPLAN
Visual EXPLAIN for SHOWPLAN
## Connection Configuration -| Parameter | Default | Notes | -|-----------|---------|-------| -| Host | `localhost` | | -| Port | `1433` | | -| Username | `sa` | SQL authentication only | -| Password | — | | -| Database | — | The database the pool connects to | -| Startup script | — | SQL run on every new pooled connection (e.g. `SET` options) | +| Parameter | Default | Required | Description | +| --- | --- | --- | --- | +| `host` | `localhost` | Yes unless using `connection_string` | SQL Server hostname or IP address | +| `port` | `1433` | No | TDS port | +| `database` | — | Yes unless using `connection_string` | Database the pool connects to | +| `username` | `sa` | Yes unless using `connection_string` | SQL-authenticated login | +| `password` | — | If required by the server | Login password; redacted from connection errors | +| `ssl_mode` | `prefer` | No | `disable`, `prefer`, `require`, or `verify-full` | +| `ssl_ca` | — | No | Rejected; strict TLS uses the system trust store | +| `ssl_cert` / `ssl_key` | — | No | Rejected; client-certificate authentication is not supported | +| `connection_string` | — | No | `sqlserver://…` URL or ADO.NET/ODBC keyword syntax | +| `startup_script` | — | No | SQL run on every new pooled connection, such as session `SET` options | + +### Connection strings + +The connection string accepts either URL syntax: + +```text +sqlserver://sa:p%40ssword@localhost:1433/master?Encrypt=true&TrustServerCertificate=true +``` + +or ADO.NET/ODBC keyword syntax. Keyword names are case-insensitive, common +aliases (`Data Source`, `Initial Catalog`, `UID`, and `PWD`) are accepted, and +braces preserve semicolons inside values: + +```text +Server=tcp:localhost,1433;Database=master;User Id=sa;Password={p;assword};Encrypt=true;TrustServerCertificate=true; +``` + +A connection string may be combined with discrete fields. Values explicitly +present in the string are authoritative, while discrete fields fill only +fields the string omits. Repeating the same value is allowed; contradictory +values are rejected with an error that identifies the discrete and +connection-string values instead of silently choosing one. Password values +are redacted in contradiction errors. + +`Encrypt=false` maps to `ssl_mode=disable`; encrypted connections with +`TrustServerCertificate=true` map to `require`; encrypted connections that +verify the certificate map to `verify-full`. Custom CA and client-certificate +keywords are rejected under the same limitations as their discrete-field +counterparts. ### TLS modes @@ -71,40 +131,227 @@ The standard Tabularis `ssl_mode` values map onto the TDS encryption policy: Custom CA files and client certificates are rejected explicitly; strict verification uses the system trust store. +## Plugin Settings + +Tabularis sends these process-wide settings through `initialize` when the +plugin starts: + +| Setting | Default | Effect | +|---------|---------|--------| +| `max_pool_size` | `10` | Maximum physical SQL Server sessions in each connection pool | +| `connect_timeout_seconds` | `15` | Maximum time to establish and authenticate a new session | +| `query_timeout_seconds` | `0` | Maximum query duration in seconds; `0` disables the timeout | +| `application_name` | `Tabularis` | TDS application name visible to DBAs in SQL Server session metadata | +| `trust_server_certificate` | `false` | Forces acceptance of a self-signed certificate without validation; use only for trusted development servers | +| `pool_idle_eviction_minutes` | `10` | Interval for removing pools with no checked-out sessions | + +Malformed values produce a warning in the plugin log and fall back to the +default; unknown settings are ignored for forward compatibility. Settings are +snapshotted when a pool is created. Changing a setting takes effect on the next +connection after the plugin is restarted, not on live pooled sessions. + +`trust_server_certificate` is an explicit escape hatch for self-signed +certificates in a verifying TLS mode. The `prefer` and `require` modes already +accept the server certificate as described above. + +## Query Execution Semantics + +Tabularis sends `limit` and `page` when result paging is enabled. The plugin +adds pagination only to one top-level `SELECT` or `VALUES` statement, including +a CTE whose final operation is a `SELECT`. DML, `EXEC`, `SELECT ... INTO`, and +multi-statement SQL run without pagination metadata. `execute_query` and every +statement in `execute_query_batch` use this same classification and execution +path. + +For a paginated query the plugin requests `page_size + 1` rows. It normally +returns at most `page_size`, sets `has_more` when the lookahead row exists, and +sets `truncated` to the same value because that lookahead row was omitted. A +statement-wide safety ceiling retains at most 10,000 rows across all result +sets, even when `limit` is absent or larger; crossing it also sets `truncated` +and, for paginated queries, `has_more`. This bounds the plugin's single-line +JSON response instead of buffering arbitrary row counts in process memory. +`pagination.total_rows` remains `null`: normal page fetches never run a hidden +count query. The Tabularis **Count rows** action obtains a total separately by +running a `SELECT COUNT(*)` wrapper with pagination disabled. That count can +scan the full query result on a large table, but its cost is incurred only when +the host explicitly requests it. + +SQL Server requires `ORDER BY` with `OFFSET ... FETCH`. If the query has no +top-level `ORDER BY`, the plugin injects `ORDER BY (SELECT NULL)` so paging +still works. This deliberately does **not** promise stable page boundaries: +rows can move between or repeat across pages because SQL Server may choose any +order. Add a deterministic `ORDER BY`, ideally ending in a unique key, whenever +page-to-page stability matters. An `ORDER BY` inside a subquery or `OVER(...)` +does not order the outer result and therefore does not prevent this injection. + +When one SQL statement produces multiple result sets, the first occupies the +normal `columns` and `rows` fields and only real subsequent result sets appear +in `additional_results`. Batch-RPC statements remain separate batch entries. +The private `@@ROWCOUNT` result set used to recover SQL Server DML affected-row +counts is always removed and never appears in `additional_results`. + ## Supported Data Types All common SQL Server types are supported for column creation and value extraction, including exact/approximate numerics (`TINYINT` … `BIGINT`, `DECIMAL`, `MONEY`, `FLOAT`), strings (`CHAR`/`VARCHAR`/`NVARCHAR` incl. `MAX`, `TEXT`/`NTEXT`), binary (`BINARY`/`VARBINARY`/`IMAGE`), date/time (`DATE`, `TIME`, `DATETIME`, `DATETIME2`, `SMALLDATETIME`, `DATETIMEOFFSET`), `BIT`, `UNIQUEIDENTIFIER`, `XML`, `SQL_VARIANT`, `ROWVERSION`, `HIERARCHYID`, and spatial (`GEOGRAPHY`, `GEOMETRY`). -`BIGINT` values outside JavaScript's safe integer range are delivered as strings so they round-trip without precision loss. +Generic DDL types emitted by Tabularis map to SQL Server-native spellings. In +particular, generic `TIMESTAMP` maps to `DATETIME2`; SQL Server's own +`TIMESTAMP` type remains a deprecated `ROWVERSION` synonym, not a date/time. + +`BIGINT` values outside JavaScript's safe integer range and all exact +`DECIMAL`, `NUMERIC`, `MONEY`, and `SMALLMONEY` values are delivered as +strings so they round-trip without precision loss. `TIME(7)`, `DATETIME2(7)`, +and `DATETIMEOFFSET(7)` preserve 100-nanosecond precision; legacy `DATETIME` +is rendered at SQL Server's `.000`, `.003`, or `.007` second granularity. +`SQL_VARIANT` is emitted using the JSON representation of its contained value. + +Binary values in query grids use the host BLOB wire shape +`BLOB:::` and the same shape is accepted by insert and +update. SQL Server CLR UDTs (`HIERARCHYID`, `GEOGRAPHY`, and `GEOMETRY`) are +losslessly displayed in that opaque binary shape because the TDS bridge does +not expose their type-specific value APIs. Protocol clients can write those +columns with the explicit raw row-edit shape +`{"value":"","is_raw":true}`; ordinary values remain bound +parameters. `ROWVERSION` and its deprecated `TIMESTAMP` synonym are read-only, +server-generated eight-byte values: omit them on insert and do not update +them. + +The pinned client can decode the newer native TDS `JSON` and `VECTOR` wire +types, and unit tests protect those paths. They are not advertised for column +creation while SQL Server 2022 is the plugin's live-test and release baseline; +JSON documents remain supported through `NVARCHAR(MAX)` on that server. + +### Binary export and preview + +`BINARY`, `VARBINARY` including `VARBINARY(MAX)`, and legacy `IMAGE` values can +be exported as raw files or previewed with MIME detection. `NULL` returns a +clear error instead of creating an empty file. `ROWVERSION` and its deprecated +`TIMESTAMP` synonym are excluded because they are server-generated concurrency +tokens, not user BLOB data. + +BLOB previews are bounded by `max_blob_size` (100 MiB when the host does not +provide a value). SQL Server checks `DATALENGTH` before returning the bytes; an +oversized value produces an error with the actual and configured sizes and can +still be exported directly to a file without passing through base64 or a +JSON-RPC response. + +## Database Users and Privileges + +For this plugin a **database user** means a database-scoped SQL user mapped to +a server-scoped SQL login. In Tabularis's account display, `user` is the +principal in the connected database and the host-shaped field after `@` is the +mapped login name; it is not a network host. Windows, Azure AD, certificate, +contained, orphaned, and login-less users are intentionally not listed or +managed. Creating an account creates the login first and then its mapped user; +dropping it drops the user first and then the login. SQL Server's own ownership +checks are preserved, so a user that owns a schema or object must have that +ownership transferred before it can be dropped. + +The host protocol's three MySQL-named scope shapes map to SQL Server as follows: + +| Host wire scope | SQL Server scope | +|-----------------|------------------| +| `database = null`, `table = null` | Connected database | +| `database = schema`, `table = null` | Schema | +| `database = schema`, `table = object` | Object | + +The privilege catalog follows the same mapping: its `global` entries are the +extra database-only permissions, `database` entries are permissions shared by +database and schema scopes, and `table` entries are object permissions. +Tabularis computes a requested checkbox diff, and the plugin checks the current +direct permissions again before applying only the required `GRANT` or `REVOKE` +statements in a transaction. + +The parsed checkbox view contains direct grants only. The raw grants view also +labels role memberships, permissions inherited through roles, grants with +grant option, and direct `DENY` entries, so inherited rights are never shown as +if they were direct grants. Because SQL Server `DENY` overrides `GRANT`, the +plugin refuses to alter a denied permission; remove that `DENY` explicitly in +SQL before managing the permission through Tabularis. + +## Visual EXPLAIN + +The Rust process safely captures estimated `SHOWPLAN_XML` or runtime +`STATISTICS XML`, restores the session option, and returns the untouched XML as +raw format `sqlserver-showplan-xml`. It does not parse plan trees itself. +Tabularis v0.23.0 or later reads `explain/dist/index.iife.js` from the installed +plugin and registers that isolated TypeScript parser with +`@tabularis/explain`. + +The same source builds the independently versioned +`@tabularis/explain-sqlserver` ESM package for browser and Node consumers such +as [explain.tabularis.dev](https://explain.tabularis.dev). This keeps SQL Server +semantics owned by this plugin while letting renderer improvements apply +without another Rust implementation. The complete parser and wire contract is +in [`docs/explain-architecture.md`](docs/explain-architecture.md). ## Installation ### Automatic (via Tabularis) -Open **Settings → Plugins** in Tabularis and install *SQL Server* from the plugin registry. +After the first release is published and registered, open **Settings → Plugins** +in Tabularis and install *SQL Server* from the plugin registry. Publication +status is tracked in [issue #4](https://github.com/TabularisDB/tabularis-sqlserver-plugin/issues/4). ### Manual Installation +Once release assets are available: + 1. Download the ZIP for your platform from the [releases page](https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases). 2. Extract it into the Tabularis plugins directory: - **Linux:** `~/.local/share/tabularis/plugins/sqlserver/` - - **macOS:** `~/Library/Application Support/com.debba.tabularis/plugins/sqlserver/` - - **Windows:** `%APPDATA%\debba\tabularis\data\plugins\sqlserver\` + - **macOS:** `~/Library/Application Support/tabularis/plugins/sqlserver/` + - **Windows:** `%APPDATA%\tabularis\plugins\sqlserver\` 3. On Linux/macOS, make the binary executable: `chmod +x sqlserver-plugin` 4. Restart Tabularis — *SQL Server* appears in the connection picker. +## How It Works + +The plugin is a standalone Rust binary that communicates with Tabularis through +**newline-delimited JSON-RPC 2.0 over stdio**: + +1. Tabularis starts `sqlserver-plugin` as a child process and calls + `initialize` with the manifest-backed process settings. +2. The plugin normalizes the discrete connection fields or connection string, + then reuses a matching in-process `deadpool` pool. +3. New sessions connect through Microsoft's `mssql-tds` implementation, run + the optional startup script, and are reset with `sp_reset_connection` + before reuse. +4. Requests and responses stay on stdin and stdout; diagnostics go to stderr. + The plugin opens no listening port and keeps no persistent state. + +Pool identity includes every connection and TLS field that changes session +behaviour, plus the startup script. The idle-eviction task removes unused +pools at the configured interval, and the courtesy `shutdown` RPC drains all +remaining pools. + +## Supported Operations + +| Method group | Operations | +| --- | --- | +| Lifecycle and connection | `initialize`, `shutdown`, `ping`, `test_connection`, database discovery | +| Schema metadata | Schemas, tables, columns, keys, indexes, views, routines, triggers, snapshots, and batch metadata | +| Query execution | Paginated queries, session-preserving batches, affected rows, and Visual EXPLAIN | +| Row editing | Insert, update, and delete with composite primary keys and type-aware values | +| DDL | Table, column, index, foreign-key, view, routine, and trigger generation or lifecycle operations | +| BLOBs | Raw file export and bounded MIME-sniffed data-URL preview | +| Security | SQL login and mapped database-user lifecycle, password changes, privilege catalog, grants, roles, and inherited rights | + ## Known Limitations - SQL authentication only; Azure AD and Windows Integrated Authentication are follow-up work. - Primary-key membership changes are disabled: the single-column alteration API cannot safely preserve composite PKs and referencing foreign keys. - Custom CA files are rejected explicitly; strict verification uses the system trust store. +- SQL Server has indexed views, not materialized views. Indexed views are maintained synchronously and have no refresh operation, so `get_materialized_views`, `get_materialized_view_columns`, `get_materialized_view_definition`, and `refresh_materialized_view` deliberately return `-32601` rather than pretending the features are equivalent. +- All host RPC methods outside those four materialized-view operations are implemented, including the courtesy `shutdown` method even though the current host terminates the process directly. Truly unknown JSON-RPC methods return `-32601` with an error naming both the method and the SQL Server plugin. ## Building from Source ### Prerequisites - Rust (stable, see `rust-toolchain.toml`) -- [`just`](https://github.com/casey/just) (optional, wraps the common cargo invocations) +- Node.js 22.13 or newer and pnpm 11 for building the bundled Visual EXPLAIN parser +- [`just`](https://github.com/casey/just) (optional, wraps the common build and test commands) ### Build @@ -116,7 +363,7 @@ just release # release build (what the GitHub Actions workflow ships) ### Install Locally ```bash -just dev-install # build + copy binary and manifest into the Tabularis plugins dir +just dev-install # build + copy the binary, manifest and optional bundles just uninstall # remove the installed plugin ``` @@ -128,6 +375,7 @@ Unit tests need no database: ```bash just test +just test-explain just lint just fmt ``` @@ -150,8 +398,48 @@ just repl ```bash just run-sqlserver # SQL Server 2022 in Docker (sa / Str0ng!Passw0rd) just seed-sqlserver # create and seed the tabularis_test database +just stop-sqlserver # stop and remove the container ``` +The live JSON-RPC integration suite uses the same container: + +```bash +SQLSERVER_PLUGIN_BIN="$PWD/target/debug/sqlserver-plugin" \ +SQLSERVER_TEST_HOST=127.0.0.1 \ +SQLSERVER_TEST_PASSWORD='Str0ng!Passw0rd' \ +cargo test --test live_db -- --test-threads=1 +``` + +## Contributing + +Pull-request titles must follow [Conventional Commits](https://www.conventionalcommits.org/): +`type: subject`, `type(scope): subject`, or `type!: subject` for a breaking +change. Add a `BREAKING CHANGE:` footer to the PR description when the title +cannot communicate the full impact. + +Every PR must have exactly one `prerelease:alpha`, `prerelease:beta`, +`prerelease:rc`, or `prerelease:stable` label. CI uses the title and that label +to suggest the next version and release channel; there is no default channel, +so a missing or ambiguous label fails the version-suggestion check. + +| PR title type | Version impact | +| --- | --- | +| `feat` | minor | +| `fix`, `refactor`, `perf` | patch | +| `docs`, `style`, `chore`, `test`, `ci`, `build` | none | +| any type with `!` or a `BREAKING CHANGE:` footer | major | + +Before opening a PR, run: + +```bash +just fmt +just lint +just test +npx markdownlint-cli "**/*.md" +``` + +## [Changelog](./CHANGELOG.md) + ## Credits The SQL Server driver implementation was contributed by [Fabio Malpezzi](https://github.com/FabioMalpezzi), originally developed as a built-in Tabularis driver and adapted here to the plugin architecture. diff --git a/assets/screenshots/01-fresh-install.png b/assets/screenshots/01-fresh-install.png new file mode 100644 index 0000000..c881022 Binary files /dev/null and b/assets/screenshots/01-fresh-install.png differ diff --git a/assets/screenshots/02-database-picker.png b/assets/screenshots/02-database-picker.png new file mode 100644 index 0000000..30f51a5 Binary files /dev/null and b/assets/screenshots/02-database-picker.png differ diff --git a/assets/screenshots/03-connection-form.png b/assets/screenshots/03-connection-form.png new file mode 100644 index 0000000..022b7ff Binary files /dev/null and b/assets/screenshots/03-connection-form.png differ diff --git a/assets/screenshots/04-test-connection-success.png b/assets/screenshots/04-test-connection-success.png new file mode 100644 index 0000000..942b068 Binary files /dev/null and b/assets/screenshots/04-test-connection-success.png differ diff --git a/assets/screenshots/05-connections-list.png b/assets/screenshots/05-connections-list.png new file mode 100644 index 0000000..648d74d Binary files /dev/null and b/assets/screenshots/05-connections-list.png differ diff --git a/assets/screenshots/06-schema-browser.png b/assets/screenshots/06-schema-browser.png new file mode 100644 index 0000000..97020dd Binary files /dev/null and b/assets/screenshots/06-schema-browser.png differ diff --git a/assets/screenshots/07-table-data.png b/assets/screenshots/07-table-data.png new file mode 100644 index 0000000..097b303 Binary files /dev/null and b/assets/screenshots/07-table-data.png differ diff --git a/assets/screenshots/08-visual-explain.png b/assets/screenshots/08-visual-explain.png new file mode 100644 index 0000000..7cd37fc Binary files /dev/null and b/assets/screenshots/08-visual-explain.png differ diff --git a/docs/completeness.md b/docs/completeness.md new file mode 100644 index 0000000..a6ae18c --- /dev/null +++ b/docs/completeness.md @@ -0,0 +1,112 @@ +# Plugin completeness + +This document records the SQL Server plugin's release-candidate state against +the Tabularis host protocol and registry. The implementation work is complete; +publication and cross-repository rollout remain tracked in +[issue #4](https://github.com/TabularisDB/tabularis-sqlserver-plugin/issues/4). + +## Host protocol + +The plugin supports connection testing, schema introspection, query execution, +CRUD, DDL, views, routines, triggers, BLOB handling, database users and +privileges, and Visual EXPLAIN. In particular: + +- `initialize` applies forgiving process settings for pool sizing, connection + and query timeouts, the TDS application name, certificate trust, and idle + pool eviction. Unknown keys are ignored and malformed values use defaults. +- `save_blob_to_file` and `fetch_blob_as_data_url` support raw export and + MIME-sniffed preview for `BINARY`, `VARBINARY` including `VARBINARY(MAX)`, + and legacy `IMAGE` values. Composite primary keys are parameterized and + normalized in deterministic column order. +- All eight database-user and privilege methods manage mapped SQL + login/database-user pairs, direct and inherited grants, and DENY-safe + privilege changes. +- `shutdown` is implemented as a courtesy method that closes and removes every + cached pool, although the current host normally terminates the subprocess. +- `explain_query` returns raw `sqlserver-showplan-xml`; Tabularis loads the + plugin-owned TypeScript parser declared in `.tabularium`. + +The only deliberate protocol exclusions are the four materialized-view +methods. SQL Server indexed views are synchronously maintained views with +clustered indexes, not refreshable materialized views, so those methods return +a reasoned `-32601` instead of misrepresenting their lifecycle. A host-method +coverage test requires every host RPC to be dispatched or included in that +reasoned table. Truly unknown methods return `-32601` naming the method and +plugin. + +## Host model conformance + +`tests/conformance.rs` carries verbatim response-model definitions from +Tabularis host commit `ba0463d3b861ec8fad110126c67e3fc12bac9839` and checks a +live-captured fixture for every implemented RPC. Regenerate all 54 responses +with `python3 tests/capture_conformance.py` whenever the host models or RPC +surface changes. + +The conformance sync found two additive host fields that had been missing from +the plugin wire models. SQL Server column introspection now emits +`is_generated` from `sys.columns.is_computed`, including the schema snapshot +and batch path. Index metadata emits `is_expression: false` because SQL Server +does not support arbitrary expression index keys. Parameterized character +types retain `character_maximum_length`; absent lengths and defaults remain +omitted and deserialize through the host's optional fields. + +## BLOB policy + +`NULL` binary values return an explicit error and never become an empty file. +`ROWVERSION` and its deprecated `TIMESTAMP` synonym are not offered as BLOBs: +their eight bytes are server-generated concurrency tokens rather than user +file data. Direct RPC attempts against those types return an explanatory +error. + +Preview requests accept the same top-level `max_blob_size` byte ceiling used +by BLOB write paths. The query checks `DATALENGTH` and omits the binary value +from the SQL result when it exceeds the ceiling, so an oversized +`VARBINARY(MAX)` is neither transferred over TDS nor base64-encoded into the +JSON-RPC line. The error reports the actual and configured sizes and suggests +file export, which remains unbounded. For compatibility with hosts that omit +the field, the plugin uses Tabularis's 100 MiB default. + +## Connection parameters + +Discrete host, port, username, password and database fields work alongside URL +and ADO.NET/ODBC `connection_string` forms. Explicit string values are +reconciled against discrete fields, passwords are redacted in conflicts, and +equivalent forms normalize to the same pool key. TLS modes, startup scripts and +connection ids are included in pool identity where they affect sessions. + +SQL authentication remains the supported authentication mechanism. Azure AD, +Windows Integrated Authentication, custom CA files and client certificates are +outside the completion scope and documented as limitations. + +## Manifest, settings and packaging + +`.tabularium` carries registry metadata, SQL Server branding, the runtime +floor, generic-to-native type mappings, process settings, capabilities and the +`explain_parsers` declaration from core PR #688. The currently deployed live +registry schema predates that additive field and rejects it; schema deployment +and a clean live validation are publication prerequisites in issue #4. A unit +test keeps all 37 `data_types` synchronized with +`driver/types.rs::get_data_types()`. + +The release workflow enforces tag/version equality and builds five platform +archives. Each archive stages the binary, manifest, screenshots and +`explain/dist/index.iife.js`; `.tabularium` is also staged as a standalone +release asset. The independent `explain-v*` workflow validates and publishes +`@tabularis/explain-sqlserver`. + +CI covers Rust build, tests, Clippy and formatting; SQL Server 2022 integration; +manifest and Markdown validation; the TypeScript parser package; Conventional +Commit titles and version suggestions; and RustSec advisories. Until the live +schema is deployed, CI validates deployed fields with the live endpoint and the +pending `explain_parsers` declaration against its exact frozen contract. +Dependabot tracks Cargo, Actions and npm dependencies. + +## Distribution status + +The code and workflows are release-ready, but no plugin tag, GitHub release or +npm package has been published yet. The core parser-loader PR and standalone +site PR are also still open. Consequently no registry PR has been opened and +issue #2 remains open. Issue #4 contains the ordered publication, artifact +inspection, real-desktop validation, registry submission, site deployment and +issue-close checklist; it prevents incomplete or 404-backed registry metadata +from being submitted. diff --git a/docs/dependencies.md b/docs/dependencies.md index 561abff..e8bc1f1 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -20,8 +20,10 @@ release-critical dependency because both crates are preview releases. [published manifest](https://docs.rs/crate/mssql-tiberius-bridge/0.1.0-preview.3/source/Cargo.toml.orig). MIT is compatible with this plugin's Apache-2.0 licence. The upstream repository and published crate do not currently include a standalone licence - text, so the declaration is the licence evidence and the omission should be - corrected upstream before a public binary release. + text, so the declaration is the licence evidence. Resolving that omission + and the release archive's third-party-notice policy before public binary + distribution is tracked in + [issue #4](https://github.com/TabularisDB/tabularis-sqlserver-plugin/issues/4). - **Release cadence:** all five published previews arrived in a ten-day burst: preview.1 on 2026-05-08, preview.2 and preview.3 on 2026-05-10, preview.4 later on 2026-05-10, and @@ -117,8 +119,10 @@ date above. also preserves headers for zero-row result sets. - [Bridge #88](https://github.com/saurabh500/mssql-tiberius-bridge/issues/88) says cancellation safety under `tokio::time::timeout` has not been audited. - The plugin currently does not cancel in-flight client futures with a Tokio - timeout. This must be resolved before adding such a timeout. + The plugin now applies its configured query timeout with Tokio and marks the + connection non-recyclable on timeout, so no later request receives a stream + with unread packets. The live suite verifies timeout categorization and + replacement-session recovery. Re-audit this boundary on every bridge update. - [Bridge #89](https://github.com/saurabh500/mssql-tiberius-bridge/issues/89) tracks the unverified encryption-off handshake. It is relevant to the plugin's `ssl_mode=disable` mapping and must be included in TLS live tests. @@ -159,9 +163,10 @@ When considering preview.4 or any later release: If the bridge is abandoned or develops a blocking correctness, security, or reliability bug that cannot be fixed promptly, the fallback is the stable -`tiberius 0.12` implementation that this branch replaced. It remains one -revert away in the parent of client-swap commit -[`f2afb7b`](https://github.com/TabularisDB/tabularis-sqlserver-plugin/commit/f2afb7b). +`tiberius 0.12` implementation that this branch replaced. It remains +available in the parent of client-swap commit +[`f2afb7b`](https://github.com/TabularisDB/tabularis-sqlserver-plugin/commit/f2afb7b) +and can be restored with an explicit rollback. Restore that implementation rather than carrying an indefinite private fork of both preview crates. @@ -204,7 +209,7 @@ is selected, not LGPL. This inventory is based on package manifests as resolved by Cargo, including platform-specific and lockfile-only entries. Release packaging must retain the applicable third-party notices; this review does not replace that packaging -step. +step. The unresolved archive-policy work is tracked in issue #4. ## RustSec audit diff --git a/docs/explain-architecture.md b/docs/explain-architecture.md new file mode 100644 index 0000000..f6772f9 --- /dev/null +++ b/docs/explain-architecture.md @@ -0,0 +1,522 @@ +# Plugin-owned EXPLAIN parsers — frozen contract + +This document is the implementation contract for `SS-031` through `SS-036`. +It was frozen by `SS-030` on 2026-08-30 after checking Tabularis core commit +`9e6975aa5ef1d9667c0d7a27488b55adfe3cf584` and SQL Server plugin commit +`1718b3149f5e982109062c2ef40682252fbd9fb6`. + +Statements in §1 describe that baseline and include source anchors. The later +sections are normative decisions for the implementation tasks; they do not +claim that the code exists at the frozen commits. + +## Implementation status + +The contract is implemented on the release-candidate branches. Core PR +[TabularisDB/tabularis#688](https://github.com/TabularisDB/tabularis/pull/688) +contains the registry, raw plugin protocol, manifest plumbing, author guide and +isolated desktop loader. This repository contains the TypeScript parser, ESM +package, IIFE and raw Rust handoff; `.tabularium` requires Tabularis 0.23.0. +The standalone site integration is in +[TabularisDB/explain-plan#2](https://github.com/TabularisDB/explain-plan/pull/2). + +The cross-repository PRs are not merged, Tabularis 0.23.0 and +`@tabularis/explain` 0.2.0 are not published, and the SQL Server npm package is +not published. Those distribution prerequisites and the required real-desktop +check are tracked in +[issue #4](https://github.com/TabularisDB/tabularis-sqlserver-plugin/issues/4). +This status note does not alter the frozen normative contract below. + +## 1. Verified baseline + +The current split is real, but several details in the initial design needed +correction. + +| Claim | Verified source | +| --- | --- | +| Raw built-in output has five closed format literals and is dispatched by an exhaustive `switch`. | `tabularis/packages/explain/src/raw.ts:22-27` and `:62-74` | +| Standalone source parsing has a separate four-entry parser array, a closed three-engine union and a second dispatch path. | `tabularis/packages/explain/src/parsers/source.ts:17-54` and `:72-160` | +| The only format-related switches under `packages/explain/src` are the raw-format switch and the source engine switch. | `raw.ts:63` and `parsers/source.ts:112` at the frozen core commit | +| Plugin `explain_query` output is always wrapped as `Plan`. | `tabularis/src-tauri/src/plugins/driver.rs:800-816` | +| The Rust host already has serializable `RawExplainOutput` and tagged `ExplainQueryOutput` models; `original_query` is currently required. | `tabularis/src-tauri/src/models.rs:526-548` | +| SQL Server captures estimated or runtime XML, then parses it in process. | `src/driver/explain.rs:11-50` and `src/driver/ops.rs:379-392` | +| The Rust SHOWPLAN parser uses the first `RelOp`, respects nested-operator ownership and aggregates runtime counters by thread. | `src/driver/showplan.rs:12-178`, especially `:105-138` | +| The existing parser stores `EstimatedTotalSubtreeCost` directly as `total_cost`; it does not subtract child cost or use `AvgRowSize`. | `src/driver/showplan.rs:165-168` | +| `read_plugin_file` accepts nested relative UTF-8 text paths and rejects paths containing `..` or beginning with `/` or `\`. | `tabularis/src-tauri/src/plugins/commands.rs:402-419` | +| UI IIFEs are actually read and evaluated in `PluginSlotProvider`; `pluginModuleLoader.ts` is a separate dynamic-loader abstraction and is not the production IIFE evaluator. | `tabularis/src/contexts/PluginSlotProvider.tsx:62-137` and `src/utils/pluginModuleLoader.ts:14-75` | +| The frontend already has an enabled-plugin manifest effect where parser loading can be attached. | `tabularis/src/contexts/PluginSlotProvider.tsx:140-192` | +| Runtime manifests pass `ui_extensions` through Rust and TypeScript models, even though the local legacy schema does not declare that field. | `tabularis/src-tauri/src/plugins/manager.rs:31-69`, `src-tauri/src/drivers/driver_trait.rs:210-263` and `src/types/plugins.ts:73-117` | +| The local manifest schema has `additionalProperties: false` and declares neither `ui_extensions` nor `explain_parsers`. | `tabularis/plugins/manifest.schema.json:1-259` | + +`read_plugin_file` is sufficient for the parser bundle because JavaScript is +UTF-8 text and `explain/dist/index.iife.js` is a valid nested relative path. +Its validation is lexical, not canonical: the current command does not prove +that a symlink target remains below the plugin directory. This contract does +not overstate that guarantee. + +Opening only `RawExplainFormat` while retaining the `switch` in `raw.ts` would +make `parseRawPayload` non-exhaustive. The registry replaces that switch. +Supporting third-party source detection also requires opening +`ExplainEngine` and `ExplainSourceFormat`; the initial design omitted those +two type changes. No other current switch needs changing. + +The current Rust parser already implements the per-thread aggregation credited +to the closed core PR #560: sum `ActualRows` and `ActualExecutions`, and take +the maximum `ActualElapsedms`. Neither issue #2 nor PR #560 specifies +subtracting child subtree costs or mapping `AvgRowSize`; those were erroneous +claims in the initial design and are not part of this contract. + +## 2. Goals and ownership + +A SQL Server plan has one parser implementation in this repository, written in +TypeScript. It is built into: + +1. an IIFE shipped in each plugin archive for the desktop; and +2. an ESM npm package consumed by the standalone visualizer. + +SQL Server parsing remains owned and released by the SQL Server plugin. Core +`@tabularis/explain` gains only an engine-neutral parser registry. This differs +from issue #2's original proposal, which would put SQL Server parsing directly +in the core package. + +This split allows any third-party plugin to supply a parser without waiting for +a core release, while the npm artifact makes the same parser available where +there is no plugin process. Renderer-only changes already apply to parsed +plugin plans today; the concrete problems solved here are duplicated parser +implementations, parser/model evolution tied to a Rust binary, and the +standalone site's inability to reach that binary. + +## 3. `@tabularis/explain` registry (`SS-031`) + +### 3.1 Public API + +Add `packages/explain/src/registry.ts` and export these symbols from the +package root: + +```ts +export interface RegisteredExplainParser { + /** Canonical engine id, for example "sqlserver". */ + readonly engine: string; + /** Globally unique wire-format tag. */ + readonly format: string; + /** Human label for format pickers. */ + readonly label?: string; + /** Parse the raw payload or throw an Error. */ + parse(payload: string): ExplainPlan; + /** Cheap, side-effect-free source detection. */ + sniff?(payload: string): boolean; +} + +export function registerExplainParser( + parser: RegisteredExplainParser, +): void; +export function unregisterExplainParser(format: string): void; +export function getExplainParser( + format: string, +): RegisteredExplainParser | null; +export function listExplainParsers(): readonly RegisteredExplainParser[]; +``` + +The existing public types become open while preserving literal autocomplete: + +```ts +export type BuiltinRawExplainFormat = + | "postgres-json" + | "mysql-json" + | "mysql-analyze-text" + | "mysql-tabular-rows" + | "sqlite-eqp-rows"; +export type RawExplainFormat = + | BuiltinRawExplainFormat + | (string & {}); + +export type BuiltinExplainEngine = "postgres" | "mysql" | "sqlite"; +export type ExplainEngine = BuiltinExplainEngine | (string & {}); + +export type BuiltinExplainSourceFormat = + | "postgres-json" + | "postgres-text" + | "mysql-json" + | "mysql-text"; +export type ExplainSourceFormat = + | BuiltinExplainSourceFormat + | (string & {}); +``` + +### 3.2 Built-ins and mutation rules + +The effective registry has an immutable built-in baseline and a mutable +registration overlay. The baseline contains all existing dispatch tags, not +only the five raw wire tags: + +- `postgres-json` +- `postgres-text` +- `mysql-json` +- `mysql-text` +- `mysql-analyze-text` +- `mysql-tabular-rows` +- `sqlite-eqp-rows` + +Aliases that share a parser remain separate format entries. Dispatch for raw +host output and standalone source parsing therefore reaches the same effective +registry. + +Registration rules are exact: + +- `engine` and `format` must be non-empty after trimming and `parse` must be a + function. Invalid registrations throw `TypeError` before mutating state. +- A new custom format is appended in registration order. +- Registering an already effective format installs or replaces its overlay and + emits exactly one `console.warn` for that call: + `EXPLAIN parser format '' is already registered; replacing it.` +- Replacement keeps the format's existing position. This makes an in-place + plugin upgrade deterministic. +- `unregisterExplainParser` removes only the mutable overlay. It is a no-op for + an absent overlay; removing an override reveals the immutable built-in. +- `listExplainParsers` returns an immutable snapshot of effective entries: + built-in order first, followed by custom registration order. A built-in + override occupies the built-in's original position. +- Parser exceptions propagate unchanged to the caller. + +These rules prevent tests or plugin unloads from accidentally deleting core +parsers while still allowing a deliberate override. + +### 3.3 Dispatch, source detection and exact errors + +`parseRawExplain` looks up `raw.format` in the registry and invokes its +`parse`. If there is no entry, it throws exactly: + +```text +No EXPLAIN parser registered for format '' (engine ''). Import the parser package for '' before parsing. +``` + +It then stamps `driver` and `original_query` exactly as it does today. + +`parsers/source.ts` also uses the registry for final parser dispatch. Detection +preserves the current built-in behavior before consulting custom sniffers: + +- With a built-in engine hint, existing Postgres, MySQL and SQLite decisions + and error text remain unchanged. +- With a custom engine hint, consider effective parsers whose `engine` matches + case-insensitively and whose `sniff` returns true, in registration order. +- Without a hint, run the existing Postgres detection first. If it does not + match, run custom sniffers in registration order. +- A throwing sniffer is treated as `false`; detection continues. Parsing is + not attempted during sniffing. +- Because the historical unhinted JSON heuristic chooses Postgres, a custom + JSON format should be parsed with an engine hint unless it can be + distinguished before that heuristic in a future, separately reviewed + change. + +`explainEngineFromDriverName` retains the current built-in aliases first. It +then returns the canonical `engine` of the first registered parser whose +engine equals the trimmed driver name case-insensitively. Unknown names still +return `null`. + +Built-in behavior must remain byte-for-byte compatible when no mutable parsers +are registered. Tests must cover raw dispatch through a custom parser, +replacement and its one warning, unregister and built-in restoration, the +exact unknown-format error, custom source detection with and without an engine +hint, a throwing sniffer, engine lookup, and all existing raw/source fixtures. + +### 3.4 Import graph + +The initial claim that `registry.ts` could import only `types.ts` while also +seeding built-ins was inconsistent. Use this acyclic graph instead: + +```text +raw.ts ───────────────┐ +parsers/source.ts ────┼──> registry.ts ──> parsers/builtins.ts + │ │ + │ ├──> parsers/postgres.ts + │ ├──> parsers/mysql.ts + │ └──> parsers/sqlite.ts + └────────────────────────────> types.ts +``` + +`parsers/builtins.ts` owns row-payload JSON adapters now local to `raw.ts`. +Leaf parsers and `types.ts` must not import `raw.ts`, `source.ts` or the +registry. This graph has no cycle. + +## 4. Raw plugin protocol (`SS-032`) + +A plugin may return either its historical parsed-plan object or this raw +object from the JSON-RPC `explain_query` method: + +```ts +interface PluginRawExplainOutput { + engine: string; + format: string; + payload: string; + original_query?: string | null; +} +``` + +`RpcDriver::explain_query` performs structural detection on the JSON value: + +1. If `engine`, `format` and `payload` are all strings, construct + `RawExplainOutput` and return `ExplainQueryOutput::Raw`. +2. Preserve a string `original_query`. If it is absent or `null`, fill it from + the `query` argument supplied to the host. +3. If the three required strings identify a raw object but + `original_query` is present with another type, return + `Plugin raw EXPLAIN field 'original_query' must be a string or null`. +4. Additional fields are ignored. +5. If any required field is absent or is not a string, preserve the complete + old fallback: return `ExplainQueryOutput::Plan { plan: res }` unchanged. + +Detection is structural, not based on plan fields, XML contents or format +names. Tests must cover all branches, including a parsed plan that happens to +contain `engine` or `format` but not all three required strings. + +Compatibility is intentionally asymmetric: + +- old plugin plus new host remains a `Plan` and works unchanged; +- new plugin plus old host is wrapped as a plan object and cannot render; +- therefore `SS-035` must raise the plugin's runtime floor to the first + Tabularis release containing both raw-plugin support and bundle loading. + +`plugins/PLUGIN_GUIDE.md` documents both result shapes. This task does not add +SQL Server-specific knowledge to the Rust host. + +## 5. Manifest contract (`SS-033` and `SS-034`) + +The optional additive field is: + +```json +"explain_parsers": [ + { + "engine": "sqlserver", + "format": "sqlserver-showplan-xml", + "label": "SQL Server SHOWPLAN XML", + "module": "explain/dist/index.iife.js" + } +] +``` + +Each item requires non-empty string `engine`, `format` and `module`; `label` is +an optional non-empty string. Unknown item properties are rejected. A plugin +without the field behaves exactly as it does today. + +`SS-033` adds this shape to all core surfaces that currently carry +`ui_extensions`: + +- `plugins/manifest.schema.json` for the legacy/runtime manifest; +- `plugins/tabularium-extensions.schema.json` for the live merged registry + schema used by `.tabularium`; +- Rust `ConfigManifest` and `PluginManifest`, including every constructor; +- frontend `PluginManifest` types. + +`SS-034` adds the field to this plugin's `.tabularium` file. The IIFE filename +is intentionally different from the ESM package entry. Existing provisional +plugin tooling that copies `explain/dist/index.js` must be corrected in +`SS-034` to package `index.iife.js` as well as the npm files where appropriate. + +The `module` value is passed to `read_plugin_file`. It is relative to the +installed plugin directory and must satisfy that command's existing path +rules. No network import or arbitrary absolute path is allowed. + +## 6. Desktop bundle and loading (`SS-033` and `SS-034`) + +### 6.1 Artifact convention + +| Property | UI extension today | EXPLAIN parser contract | +| --- | --- | --- | +| Format | IIFE | IIFE | +| Output variable | `__tabularis_plugin__` | `__tabularis_explain_parser__` | +| External host API | `__TABULARIS_API__` | `__TABULARIS_EXPLAIN__` | +| Disk command | `read_plugin_file` | `read_plugin_file` | +| Evaluator | `PluginSlotProvider` | new `pluginExplainLoader.ts` | +| Trigger | enabled-plugin manifest effect | same enabled-plugin manifest effect | + +The parser IIFE externalizes `@tabularis/explain` to +`__TABULARIS_EXPLAIN__`. The desktop passes the imported package namespace as +a `new Function` parameter, just as the UI loader passes React and the plugin +API. It returns the value assigned to `__tabularis_explain_parser__`. + +Do not make one entry point both self-register and return a parser; that would +register it twice. The package uses separate thin entries over one parser: + +```text +explain/src/showplan.ts parser implementation +explain/src/parser.ts parser descriptor +explain/src/index.ts ESM: registers descriptor, exports direct API +explain/src/iife.ts IIFE: default-exports descriptor, no registration +``` + +Build outputs are `dist/index.js`, `dist/index.d.ts` and +`dist/index.iife.js`. + +### 6.2 Loader behavior + +The IIFE default export may be one `RegisteredExplainParser` or an array. The +loader groups manifest entries by module so it reads and evaluates a file once. +For every manifest declaration it finds exactly one exported descriptor with +the same `engine` and `format`, validates non-empty strings and a callable +`parse`, applies the manifest label when supplied, and registers it. Undeclared +exports and invalid or missing matches are warned and skipped. + +Plugin ids are processed in sorted order and manifest entries in manifest +order, making collision replacement deterministic. When the enabled set +changes, the provider unregisters formats loaded by its previous pass before +loading the new set. Built-in parsers reappear automatically because registry +unregistration removes only overlays. + +Each module read/evaluation is isolated. If reading or evaluating a bundle +throws, log exactly this prefix with the error as a separate argument, skip +that module, and continue: + +```text +[PluginExplain] Failed to load module "" for plugin "": +``` + +An invalid descriptor warns and skips only that descriptor. A parser's own +exception during a later parse is not swallowed; existing Visual EXPLAIN error +handling displays it. One broken plugin must not prevent other parser bundles +or built-ins from loading. + +The current frontend trigger is the enabled-plugin manifest effect in +`PluginSlotProvider`, not a nonexistent JavaScript callback from Rust driver +registration. `SS-033` extends that lifecycle (or a sibling provider sharing +it) after `get_plugin_manifest` succeeds. Startup plugin loading completes in +Tauri setup before the frontend runs, and hot enable/install completes its +backend load before updating `activeExternalDrivers`, so this is the available +driver-load synchronization point. + +## 7. npm package (`SS-034`) + +The package is `@tabularis/explain-sqlserver`. Its version tracks the plugin +version. Its relevant metadata is: + +```jsonc +{ + "name": "@tabularis/explain-sqlserver", + "type": "module", + "sideEffects": ["./dist/index.js"], + "peerDependencies": { + "@tabularis/explain": "^0.2.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } +} +``` + +`@tabularis/explain` 0.2.0 is the first version with the registry. The XML +parser's chosen library is a normal runtime dependency of this package, not a +core dependency. + +Both usage forms are supported: + +```ts +import "@tabularis/explain-sqlserver"; +import { parseShowplanXml } from "@tabularis/explain-sqlserver"; +``` + +The first form must survive tree shaking, hence the explicit `sideEffects` +metadata. The package's ESM entry registers once on evaluation and exports the +parser and descriptor. The IIFE entry does not self-register. + +Publishing is independent from the Rust release and is triggered by an +`explain-v*` tag. A Rust release does not force an npm publish, nor vice versa. + +## 8. SQL Server parser semantics (`SS-034`) + +The initial TypeScript port is behavior-compatible with +`src/driver/showplan.rs`: + +- namespace-insensitive XML parsing and the first `RelOp` as root; +- direct child operators without crossing nested `RelOp` ownership; +- `PhysicalOp`, falling back to `LogicalOp`, then `Unknown`; +- ids prefixed with `sqlserver-`, including deterministic fallback ids; +- the first owned `Object@Table` with square brackets removed as `relation`; +- the first owned `ScalarOperator@ScalarString` as `filter`; +- logical operations containing `join` as `join_type` and in + `extra.logical_operation`; +- `EstimateRows` as `plan_rows` and `EstimatedTotalSubtreeCost` directly as + `total_cost`; +- sum per-thread `ActualRows` and `ActualExecutions`, maximum per-thread + `ActualElapsedms`; +- root elapsed time as `execution_time_ms`, root actual rows deciding + `has_analyze_data`, and the original XML as `raw_output`; +- `planning_time_ms`, startup costs, buffers, index/hash conditions and fields + not listed above remain `null`; +- a multi-statement document uses its first `RelOp`, matching the Rust parser; +- malformed XML and a document without `RelOp` retain the current error + prefixes. + +There is no child-subtree cost subtraction and no `AvgRowSize` mapping in this +port. Missing-index data is not synthesized into the shared model; its fixture +proves that such a real document remains parseable and preserves raw output. +Any semantic expansion is a later, separately tested change. + +Real SQL Server 2022 fixtures under `explain/tests/fixtures/` cover at least a +trivial scan, an index seek with key lookup, a parallel hash join with multiple +`RunTimeCountersPerThread` elements, `STATISTICS XML`, a missing-index +suggestion and a multi-statement batch. Fixtures are captured documents, not +hand-authored XML. Tests compare the TypeScript result with committed expected +plans and explicitly assert the aggregation and first-statement behavior. + +The registered descriptor is: + +```ts +{ + engine: "sqlserver", + format: "sqlserver-showplan-xml", + label: "SQL Server SHOWPLAN XML", + parse: parseShowplanXml, + sniff: (payload) => /<(?:\w+:)?ShowPlanXML(?:\s|>)/.test(payload.slice(0, 4096)), +} +``` + +The production parser still validates the full XML; sniffing is only a cheap +selection heuristic. + +## 9. Plugin handoff and version floor (`SS-035`) + +After core `SS-031` through `SS-033` and plugin `SS-034` are available, the +plugin returns: + +```json +{ + "engine": "sqlserver", + "format": "sqlserver-showplan-xml", + "payload": "...", + "original_query": "SELECT ..." +} +``` + +`src/driver/explain.rs` remains responsible only for safe SHOWPLAN capture and +session cleanup. `src/driver/showplan.rs` and its call from `ops.rs` are then +removed. + +`min_runtime_version` and the prepared registry entry's +`min_tabularis_version` must name the first released Tabularis version that +contains all three core tasks. Do not guess that version before the core +release is assigned. This floor and the raw handoff land together. + +## 10. Ordering and blast radius + +```text +SS-030 freeze this contract + │ + ├── SS-031 registry and open types ─┐ + ├── SS-032 raw output from plugin drivers ├─ core PR + ├── SS-033 manifest plumbing and desktop loader ─┘ + │ + ├── SS-034 SQL Server parser package and IIFE ─┐ + ├── SS-035 plugin returns raw SHOWPLAN XML ─┘ plugin PR + │ + └── SS-036 standalone site imports npm package site PR +``` + +`SS-031` is behavior-preserving without mutable registrations. `SS-032` and +`SS-033` are inert for manifests without `explain_parsers`. `SS-035` is the +compatibility boundary and cannot land without the runtime floor and packaged +IIFE. + +The seam is engine-neutral. Future first- or third-party plugins can ship their +own parser bundles and npm packages without adding engine code to Tabularis +core. diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..9b5d9be --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,171 @@ +# Performance baseline + +This is a diagnostic baseline, not a performance target or guarantee. Results +will vary with SQL Server placement, TLS, schema size, query shape, and host +load. Repeat the same procedure when changing the TDS bridge, pool, runtime, or +worker topology. + +## Environment and method + +Measurements were taken on 2026-09-02 from release-candidate commit +`45a90b9`, using an optimized `cargo build --release` binary and a local Docker +container over `127.0.0.1`: + +- Linux 5.4.0 x86-64, 8 logical CPUs, 31.2 GiB RAM; +- Rust 1.98.0; +- SQL Server 2022 Developer, 16.0.4265.3 (CU26); +- `mcr.microsoft.com/mssql/server:2022-latest`, image + `sha256:90488d58c6a5c19f24ff716e14330b85b5b26ee54b44a36ea24e6206533e7edd`; +- TLS required with the development certificate trusted; +- default pool size 10, four JSON-RPC workers, and a 16 MiB Tokio thread stack. + +The harness drove newline-delimited JSON-RPC over the plugin's real stdin and +stdout. Timings use a monotonic clock around complete request/response pairs. +RSS and virtual size came from `/proc//status`. Cold latency is 20 fresh +processes. Steady latency is 100 sequential `SELECT CAST(1 AS INT)` calls after +one warm-up. Worker comparisons rebuild only `WORKER_POOL_SIZE`, warm the +available worker and pool paths with one request group, then report 20 groups +of eight simultaneous `get_tables` calls. Reported p95 values use the nearest +observed sample rather than interpolation. + +## Results + +### Query latency + +| Measurement | Median | p95 | +| --- | ---: | ---: | +| First query with a cold process and pool | 15.07 ms | 18.80 ms | +| Steady-state query on a warm pool | 6.42 ms | 6.95 ms | + +The steady path includes deadpool checkout and the session reset performed on +reuse. It is therefore a plugin round-trip baseline, not raw server execution +time. + +### Concurrent metadata and worker count + +| JSON-RPC workers | Eight `get_tables` calls median | p95 | +| ---: | ---: | ---: | +| 1 | 70.59 ms | 74.94 ms | +| 2 | 41.55 ms | 44.83 ms | +| **4 — default** | **26.45 ms** | **29.63 ms** | +| 8 | 19.21 ms | 21.02 ms | + +Four workers reduced the median group time by 63% relative to one. Eight +workers improved another 27%, but can create twice as many simultaneous TDS +sessions and showed diminishing absolute returns. Four remains the default; +SQL connection concurrency remains independently configurable through +`max_pool_size`. + +### Idle pools and eviction + +A release process used 5,868 KiB RSS before opening a connection and 15,180 +KiB after one idle connection in each of `master` and three scratch databases: +a 9,312 KiB high-water increase for four pools. A separate run measured 5,260 +KiB from process start to one pool and roughly 1.3 MiB for each of the next +three. These figures include lazily touched runtime, TLS, and allocator pages, +so they are not a per-session allocation formula. Virtual size remained +829,648 KiB before and after opening pools; most of it is reserved address +space, including runtime stacks, rather than resident memory. + +Eviction was checked with `pool_idle_eviction_minutes` set to 1 and a unique +`application_name`. Four pools were opened against four databases and released +back to deadpool. This server-side query initially returned 4 and returned 0 +at 60.0 seconds without another plugin request: + +```sql +SELECT COUNT(*) +FROM sys.dm_exec_sessions +WHERE program_name = N'Tabularis SS-045 eviction'; +``` + +The cleanup path now explicitly closes every fully idle deadpool before +removing it from the cache. Checked-out pools survive that pass and are +considered again on the next interval. + +### Pool identity + +Unit and live tests verified all three key properties using pool identity and +SQL Server `@@SPID` values: + +- repeated identical parameters reused the same pool and physical session; +- changing only `database` produced a different pool and session, even with + the same `connection_id`; +- URL connection-string parameters reused the same session as equivalent + discrete parameters. + +Connection strings are resolved to canonical fields before key construction, +so syntax does not create duplicate pools. + +### Large results + +JSON-RPC returns one JSON line and cannot stream rows incrementally. The old +collector retained every row when `limit` was absent, allowing a million-row +query to grow until the process or host ran out of memory. The release +candidate applies a hard budget of 10,000 retained rows across a statement's +result sets. On the next +row it sets `truncated: true`, closes the remaining TDS stream, and keeps the +pooled session reusable. An explicit page limit cannot bypass this safety +budget. Result-bearing DML drains excess `OUTPUT` rows without retaining them +so it can still read the trailing affected-row sentinel. + +The million-row live fixture now returned 10,000 rows with `truncated: true` in +379.10 ms. The response was 69,017 bytes and plugin RSS rose by 5,340 KiB at +peak from a warm baseline. A follow-up query on the same pool succeeded. The +ceiling bounds row accumulation; an individual SQL value can still be large, +so callers should continue to paginate and use the dedicated bounded BLOB +preview RPC where applicable. + +### Queue backpressure and responsiveness + +Both the request and response channels now hold at most 64 messages. Including +four active workers and the reader and writer payloads, at most 134 payloads +are queued or active inside the dispatcher. Bounding only the input queue was +insufficient: a slow stdout consumer could previously move all completed +responses into an unbounded output channel. + +In the release measurement, a two-second `WAITFOR` followed by 200 `ping` +requests produced a ping response after 8.55 ms rather than waiting for the +slow query. All 201 responses arrived with matching ids. RSS rose from 11,588 +to a sampled peak of 19,304 KiB while sessions and worker paths warmed, a +7,716 KiB increase. The live regression test repeats the 200-request burst; +the fixed channel capacities provide the actual memory backpressure rather +than relying on that one observed RSS value. + +### Worker stack + +The earlier debug probe overflowed Tokio's 2 MiB default stack and completed +at 4 MiB. For this task, the optimized binary was rebuilt with a 4 MiB stack +and the full 25-test live suite passed in 21.88 seconds, including type +extraction, metadata, DDL, errors, EXPLAIN, BLOBs, million-row cancellation, +and concurrent requests. This establishes that 4 MiB worked for this Linux +release build; it does not prove safety for debug binaries or every target. + +The configured 16 MiB stack is retained as a four-times margin over the +smallest observed successful size. Tokio reserves this virtual address range +per runtime thread but commits resident pages on demand. Reduce it only after +the preview TDS client changes or equivalent debug and release stress coverage +is green on all release platforms. + +## Reproduction checks + +The committed automated checks are: + +```bash +cargo test --bins pool_manager::tests -- --test-threads=1 +SQLSERVER_PLUGIN_BIN="$PWD/target/debug/sqlserver-plugin" \ + cargo test --test live_db \ + million_row_query_is_bounded_and_marks_truncation -- --test-threads=1 +SQLSERVER_PLUGIN_BIN="$PWD/target/debug/sqlserver-plugin" \ + cargo test --test live_db \ + request_burst_is_bounded_and_slow_query_does_not_block_ping \ + -- --test-threads=1 +SQLSERVER_PLUGIN_BIN="$PWD/target/debug/sqlserver-plugin" \ + cargo test --test live_db \ + pool_keys_reuse_identical_and_equivalent_forms_but_separate_databases \ + -- --test-threads=1 +``` + +The one-minute DMV eviction timing and worker-count comparison are intentionally +recorded measurements rather than always-on CI tests. Making every CI run wait +for a wall-clock eviction interval would add a minute while testing timer +accuracy more than cleanup behavior. diff --git a/docs/registry-entry.json b/docs/registry-entry.json new file mode 100644 index 0000000..e273fb4 --- /dev/null +++ b/docs/registry-entry.json @@ -0,0 +1,21 @@ +{ + "id": "sqlserver", + "name": "Microsoft SQL Server", + "description": "Full-featured Microsoft SQL Server driver for Tabularis with schema browsing, query execution, visual plans, type-aware row editing, DDL, routines, triggers, BLOBs, and database-user management.", + "author": "Andrea Debernardi ", + "homepage": "https://github.com/TabularisDB/tabularis-sqlserver-plugin", + "latest_version": "1.0.0-beta.1", + "releases": [ + { + "version": "1.0.0-beta.1", + "min_tabularis_version": "0.23.0", + "assets": { + "linux-x64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-linux-x64.zip", + "linux-arm64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-linux-arm64.zip", + "darwin-x64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-darwin-x64.zip", + "darwin-arm64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-darwin-arm64.zip", + "win-x64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-win-x64.zip" + } + } + ] +} diff --git a/docs/sql-audit.md b/docs/sql-audit.md new file mode 100644 index 0000000..8148830 --- /dev/null +++ b/docs/sql-audit.md @@ -0,0 +1,188 @@ +# SQL construction and identifier audit + +This audit covers the release-candidate `src/driver/` tree and was refreshed +during `SS-046`. It classifies every production `format!` call that emits SQL +or an SQL fragment. The original audit found one unsafe API boundary: +`build_insert_sql` accepted an already-rendered table target. It now accepts +`schema` and `table` separately and applies `qualify` itself. The directly +executed view, index, foreign-key, user, and login statements also have pure +builders so their quoting can be regression-tested without a database +connection. Later raw row-edit support is included below as an explicit SQL +expression boundary rather than being mistaken for an ordinary value. + +## Classification rules + +| Code | Class | Required handling | +| --- | --- | --- | +| I | Identifier | Always pass the original identifier through `bracket_quote` or `qualify`. Identifiers cannot be bound as TDS values. | +| L | Literal value | Bind with `@Pn` whenever the RPC and SQL grammar allow it. Otherwise use a dedicated escaping helper. | +| K | Fixed syntax | Hard-coded text, numeric values, generated parameter markers, or a keyword selected from a closed allowlist. | +| S | SQL source | An explicit SQL expression, definition, or query supplied through an SQL-editing API. It is not reclassified as an identifier or literal. | + +`S` is intentionally separate from scalar values. Escaping an SQL definition +as a literal would change the operation rather than make it safer. The raw SQL +boundaries are listed below and are never used for identifier-shaped fields. + +## `format!` call-site inventory + +Line anchors identify the audited call, not an API stability promise. +Non-SQL formatting (errors, version labels, temporal/JSON rendering, BLOB wire +encoding, and tests) is excluded. + +### `helpers.rs` + +| Source | SQL produced | Interpolations | Result | +| --- | --- | --- | --- | +| `helpers.rs:62` | Multipart object reference | schema I via `bracket_quote`; object I via `bracket_quote` | Safe | +| `helpers.rs:74` | Affected-row `SELECT` | expression K from two private constants; result alias I/K constant | Safe | +| `helpers.rs:80` | DML plus row-count sentinel | sql S from query execution; sentinel K | Intentional SQL boundary | +| `helpers.rs:107` | `@Pn` marker | ordinal K integer | Safe | +| `helpers.rs:128` | `INSERT` | target I from `qualify`; columns I from `bracket_quote`; expressions are bound-marker K or explicitly marked S | Safe identifiers; intentional raw row-edit boundary | +| `helpers.rs:146` | Identity-insert batch | target I; insert may contain explicit S; row-count select K | Safe within documented raw boundary | +| `helpers.rs:162` | Plain insert plus row-count select | insert may contain explicit S; select K | Safe within documented raw boundary | +| `helpers.rs:274` | Primary-key predicate | column I via `bracket_quote`; ordinal K integer | Safe; values are bound | +| `helpers.rs:288` | Composite-key `DELETE` | table I via `qualify`; predicate internally built | Safe; values are bound | +| `helpers.rs:317` | Composite-key `UPDATE` | table and column I; value expression is bound-marker K or explicitly marked S | Safe within documented raw boundary | +| `helpers.rs:327` | Column definition head | column I via `bracket_quote`; data type S from the DDL editor/model | Intentional reviewed DDL source | +| `helpers.rs:337` | Column default | default S from the DDL editor/model | Intentional reviewed DDL source | +| `helpers.rs:387` | Paginated query | query S; optional order clause K; offset/fetch K integers | Intentional query boundary | + +### `ddl/mod.rs` + +| Source | SQL produced | Interpolations | Result | +| --- | --- | --- | --- | +| `ddl/mod.rs:31` | Multipart name passed to `sp_rename` | schema, table, old column I via `bracket_quote` | Safe intermediate | +| `ddl/mod.rs:37` | `sp_rename` invocation | old multipart name L and new name L via `escape_single_quoted`; object kind K | Safe; returned script has no parameter channel | +| `ddl/mod.rs:45` | `ALTER COLUMN` | table I via `qualify`; column I via `bracket_quote`; type S; nullability K | Intentional reviewed DDL source | +| `ddl/mod.rs:60` | Add default constraint | table I; generated constraint I; default S; column I | Intentional reviewed DDL source | +| `ddl/mod.rs:72` | Generated default-constraint name | prefix K; table and column I inputs | Safe intermediate; quoted at use | +| `ddl/mod.rs:80` | Truncated constraint name | head I input; hash K hexadecimal | Safe intermediate; quoted at use | +| `ddl/mod.rs:84` | Find and drop default constraint | object name L and column name L escaped; table I; discovered constraint I via server `QUOTENAME` | Safe; returned script has no parameter channel | +| `ddl/mod.rs:94` | Object name for `OBJECT_ID` | schema and table I via `bracket_quote`, then L via `escape_single_quoted` | Safe nested literal | +| `ddl/mod.rs:130` | Add foreign key | table, constraint, columns, referenced table I via quoting helpers | Safe | +| `ddl/mod.rs:139` | `ON DELETE` action | action K from four-value allowlist | Safe | +| `ddl/mod.rs:142` | `ON UPDATE` action | action K from four-value allowlist | Safe | + +### `blob.rs` + +| Source | SQL produced | Interpolations | Result | +| --- | --- | --- | --- | +| `blob.rs:56` | Size-limited BLOB lookup | column I via `bracket_quote`; table I via `qualify`; predicate internally built | Safe; size and keys are bound | +| `blob.rs:65` | Full BLOB lookup | column I via `bracket_quote`; table I via `qualify`; predicate internally built | Safe; keys are bound | + +### `ops.rs` + +| Source | SQL produced | Interpolations | Result | +| --- | --- | --- | --- | +| `ops.rs:117` | `CREATE VIEW` | view I via `qualify`; definition S from view editor | Intentional SQL-definition boundary | +| `ops.rs:125` | `ALTER VIEW` | view I via `qualify`; definition S from view editor | Intentional SQL-definition boundary | +| `ops.rs:129` | `DROP VIEW` | view I via `qualify` | Safe; executed directly | +| `ops.rs:451` | Insert expression marker | ordinal K from the bound-parameter count | Safe | +| `ops.rs:606` | Table primary-key clause | columns I, each already bracket-quoted | Safe | +| `ops.rs:610` | `CREATE TABLE` | table I via `qualify`; definitions internally built | Safe identifiers; reviewed type/default source | +| `ops.rs:622` | `ADD COLUMN` | table I via `qualify`; definition internally built | Safe identifiers; reviewed type/default source | +| `ops.rs:654` | `CREATE INDEX` | uniqueness K boolean; index, table, columns I via quoting helpers | Safe; returned for review | +| `ops.rs:678` | `DROP INDEX` | index I via `bracket_quote`; table I via `qualify` | Safe; executed directly | +| `ops.rs:704` | Drop foreign-key constraint | table I via `qualify`; constraint I via `bracket_quote` | Safe; executed directly | + +### `routines/mod.rs` + +| Source | SQL produced | Interpolations | Result | +| --- | --- | --- | --- | +| `routines/mod.rs:8` | Routine argument literal | value L via `escape_single_quoted` | Safe; builder returns SQL and cannot return bindings | +| `routines/mod.rs:28` | Table-valued function call | target I via `qualify`; values L or explicit S from arguments | Safe within documented raw boundary | +| `routines/mod.rs:30` | Scalar function call | target I via `qualify`; values L or explicit S from arguments | Safe within documented raw boundary | +| `routines/mod.rs:46` | Named argument marker | validated ASCII parameter name K | Safe | +| `routines/mod.rs:58` | Output variable | index K integer | Safe | +| `routines/mod.rs:59` | Output declaration | variable K; type S from server metadata; initial value L or explicit S | Safe within metadata/raw boundary | +| `routines/mod.rs:63` | Output assignment | binding and variable K built internally | Safe | +| `routines/mod.rs:64` | Output projection | variable K; alias I via `bracket_quote` | Safe | +| `routines/mod.rs:66` | Input assignment | binding K; value L or explicit S | Safe within documented raw boundary | +| `routines/mod.rs:71` | Procedure call without arguments | target I via `qualify` | Safe | +| `routines/mod.rs:73` | Procedure call with arguments | target I; assignments internally built | Safe within documented raw boundary | +| `routines/mod.rs:76` | Output `SELECT` | projections internally built | Safe | +| `routines/mod.rs:84` | Function template | schema I via `bracket_quote`; remainder K | Safe | +| `routines/mod.rs:88` | Procedure template | schema I via `bracket_quote`; remainder K | Safe | +| `routines/mod.rs:104` | Convert create definition to alter | definition S accepted only with the `CREATE` prefix | Intentional routine-editor boundary | +| `routines/mod.rs:115` | Drop routine | object kind K from function/procedure branch; routine I via `qualify` | Safe; executed directly | + +`RoutineCallArg.is_raw` is the routine argument-value bypass. `false` renders a +Unicode string literal with embedded quotes doubled; `None` renders fixed +`NULL`. `true` preserves the value as an SQL expression so callers can enter +values such as `SYSDATETIME()` or `DEFAULT`. This method returns editable SQL +to the host and does not execute it. The hostile-value test proves that the +bypass occurs only when the wire flag is explicitly true. + +### `triggers/mod.rs` and `explain.rs` + +| Source | SQL produced | Interpolations | Result | +| --- | --- | --- | --- | +| `triggers/mod.rs:50` | `DROP TRIGGER` | trigger I via `qualify` | Safe; executed directly | +| `explain.rs:21` | Enable plan capture | option K from `SHOWPLAN_XML` or `STATISTICS XML` branch | Safe | +| `explain.rs:32` | Disable plan capture | option K from the same closed branch | Safe | + +`create_trigger` accepts a complete trigger definition from the trigger SQL +editor and sends it unchanged. It has no `format!` site and is an intentional +SQL-definition boundary. + +### `users.rs` + +| Source | SQL produced | Interpolations | Result | +| --- | --- | --- | --- | +| `users.rs:211` | Database permission target | database I via `bracket_quote` | Safe | +| `users.rs:212` | Schema permission target | schema I via `bracket_quote` | Safe | +| `users.rs:213` | Object permission target | schema and object I via `bracket_quote` | Safe | +| `users.rs:282` | Login password literal | password L with apostrophes doubled | Safe grammar-required literal | +| `users.rs:286` | `CREATE LOGIN` | login I via `bracket_quote`; password escaped L; options K | Safe | +| `users.rs:294` | `CREATE USER` | user and login I via `bracket_quote` | Safe | +| `users.rs:302` | `DROP USER` | user I via `bracket_quote` | Safe; executed directly | +| `users.rs:306` | `DROP LOGIN` | login I via `bracket_quote` | Safe; executed directly | +| `users.rs:310` | `ALTER LOGIN` password | login I; password escaped L | Safe | +| `users.rs:572` | Displayed database permission target | database I via `bracket_quote` | Safe | +| `users.rs:575` | Displayed schema permission target | schema I via `bracket_quote` | Safe | +| `users.rs:577` | Displayed object permission target | schema and object I via `bracket_quote` | Safe | +| `users.rs:593` | Displayed grant/deny SQL | verb K from state; privilege K from server catalog; target built safely; user I; suffix K | Safe | +| `users.rs:620` | Displayed role-membership SQL | role and user I via `bracket_quote`; remainder K | Safe | +| `users.rs:628` | Inherited-grant display line | role I; permission SQL internally built | Safe, display only | +| `users.rs:704` | Applied grant/revoke statement | verb and preposition K boolean branches; privilege K from scope allowlist; target safe; user I | Safe; executed transactionally | + +SQL Server's `CREATE LOGIN` and `ALTER LOGIN` grammar requires the password in +the statement's `PASSWORD = 'password'` clause; it does not accept a TDS value +parameter in that grammar position. Those two statements therefore use the +small `password_literal` escape routine, and errors are redacted. Account +existence checks, user permission queries, and role queries bind user/login +literals with `@P1` and `@P2`. + +## Parameter binding and static-query review + +All ordinary scalar data uses TDS parameters: + +- insert, update, delete, and BLOB key/value paths use + `value_to_sql_param` and generated `@Pn` markers; +- BLOB preview size is bound as `@P1`; +- every interpolated metadata search in `introspection.rs` is instead a static + query with bound parameters; +- account existence, permissions, roles, schemas, tables, routines, views, + indexes, and trigger listing use bound parameters. + +Escaping remains only where binding is impossible or there is no binding +channel: generated DDL scripts returned to the host, routine-call SQL returned +to the host, and login password DDL. Identifiers are never treated as values; +SQL Server does not permit parameter markers in identifier positions. + +Complete SQL text is accepted only by APIs whose purpose is editing or running +SQL: `execute_query`, batch execution, view definitions, trigger definitions, +routine edit scripts, startup scripts, EXPLAIN's input query, column type and +default expressions, routine arguments explicitly marked `is_raw`, and row +edits explicitly shaped as `{ "value": "", "is_raw": true }`. +These boundaries do not weaken identifier handling elsewhere. + +## Regression coverage + +`src/driver/sql_audit_tests.rs` passes identifiers containing `]`, embedded +double and single quotes, Unicode, leading digits, and the reserved word +`order` through the pure CRUD, DDL, view, routine, trigger, drop, and account +statement builders. `tests/live_db.rs` creates the table literally named +`[weird"name]]`, with columns `order` and `9Δ"value]`, then creates, inserts, +updates, selects, deletes, and drops it through the JSON-RPC boundary against +SQL Server 2022. diff --git a/explain/LICENSE b/explain/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/explain/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/explain/README.md b/explain/README.md new file mode 100644 index 0000000..008a546 --- /dev/null +++ b/explain/README.md @@ -0,0 +1,71 @@ +# `@tabularis/explain-sqlserver` + +SQL Server `SHOWPLAN_XML` and `STATISTICS XML` parser for Tabularis Visual +EXPLAIN. This package is maintained and versioned with the +[Tabularis SQL Server plugin](https://github.com/TabularisDB/tabularis-sqlserver-plugin). + +## Install + +```bash +pnpm add @tabularis/explain @tabularis/explain-sqlserver +``` + +`@tabularis/explain` 0.2.0 or newer is required because that release introduced +the parser registry. + +## Register on import + +Import the package for its registration side effect before parsing SQL Server +raw output through `@tabularis/explain`: + +```ts +import "@tabularis/explain-sqlserver"; +import { parseRawExplain } from "@tabularis/explain"; + +const plan = parseRawExplain({ + engine: "sqlserver", + format: "sqlserver-showplan-xml", + payload: showplanXml, + original_query: "SELECT * FROM dbo.orders", +}); +``` + +The package declares `dist/index.js` as side-effectful so bundlers retain the +registration import. + +## Parse directly + +The parser is also exported for callers that already have a SHOWPLAN document: + +```ts +import { parseShowplanXml } from "@tabularis/explain-sqlserver"; + +const plan = parseShowplanXml(showplanXml); +``` + +Direct parsing leaves `original_query` empty. The registry's raw-output path +adds the original query supplied by the caller. + +## Plugin bundle + +The build also produces `dist/index.iife.js`. Tabularis loads that file from +the installed SQL Server plugin, evaluates it with the +`__TABULARIS_EXPLAIN__` host API, and reads the parser descriptor from +`__tabularis_explain_parser__`. The IIFE does not register itself; the desktop +loader validates it against the plugin manifest and performs registration. + +## XML support + +The parser has no runtime dependencies and uses no Node built-ins. Its small +XML reader validates element nesting, quoted attributes, entities, comments, +CDATA and processing instructions while treating XML namespace prefixes as +irrelevant to SHOWPLAN element names. This keeps the same source usable in a +browser tab, the desktop IIFE, and server-side JavaScript. + +The golden fixtures under `tests/fixtures/` were captured from SQL Server 2022. +Their expected plans preserve the output of the former Rust parser at the +TypeScript handoff boundary; the Rust parser was removed after parity was +verified. They cover a scan, an index seek with key lookup, a parallel hash +join, `STATISTICS XML`, a missing-index recommendation, and a multi-statement +batch. Update an expected plan only with a separately reviewed parser-semantic +change. diff --git a/explain/package.json b/explain/package.json new file mode 100644 index 0000000..59be7e6 --- /dev/null +++ b/explain/package.json @@ -0,0 +1,61 @@ +{ + "name": "@tabularis/explain-sqlserver", + "version": "1.0.0-beta.1", + "description": "SQL Server SHOWPLAN XML parser for Tabularis Visual EXPLAIN", + "license": "Apache-2.0", + "homepage": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/tree/main/explain", + "repository": { + "type": "git", + "url": "https://github.com/TabularisDB/tabularis-sqlserver-plugin.git", + "directory": "explain" + }, + "bugs": { + "url": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/issues" + }, + "keywords": [ + "explain", + "showplan", + "sqlserver", + "query-plan", + "tabularis" + ], + "type": "module", + "sideEffects": [ + "./dist/index.js" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit", + "test": "pnpm run build && vitest run", + "prepublishOnly": "pnpm run clean && pnpm run typecheck && pnpm run test" + }, + "peerDependencies": { + "@tabularis/explain": "^0.2.0" + }, + "devDependencies": { + "@tabularis/explain": "file:tests/host", + "@types/node": "^22.0.0", + "tsup": "^8.3.5", + "typescript": "~5.9.3", + "vitest": "^3.2.4" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=20" + }, + "packageManager": "pnpm@11.23.0" +} diff --git a/explain/pnpm-lock.yaml b/explain/pnpm-lock.yaml new file mode 100644 index 0000000..dbad825 --- /dev/null +++ b/explain/pnpm-lock.yaml @@ -0,0 +1,1591 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@tabularis/explain': + specifier: file:tests/host + version: file:tests/host + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.26)(typescript@5.9.3) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@22.20.1) + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-android-arm-eabi@4.63.1': + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.1': + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.1': + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.1': + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.1': + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.1': + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.1': + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.1': + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.1': + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.1': + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.1': + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.1': + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.1': + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.1': + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} + cpu: [x64] + os: [win32] + + '@tabularis/explain@file:tests/host': + resolution: {directory: tests/host, type: directory} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rollup@4.63.1: + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rollup/rollup-android-arm-eabi@4.63.1': + optional: true + + '@rollup/rollup-android-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-x64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.1': + optional: true + + '@tabularis/explain@file:tests/host': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@22.20.1) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn@8.18.0: {} + + any-promise@1.3.0: {} + + assertion-error@2.0.1: {} + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.63.1 + + fsevents@2.3.3: + optional: true + + joycon@3.1.1: {} + + js-tokens@9.0.1: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.18: {} + + object-assign@4.1.1: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.26): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.26 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + rollup@4.63.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.1 + '@rollup/rollup-android-arm64': 4.63.1 + '@rollup/rollup-darwin-arm64': 4.63.1 + '@rollup/rollup-darwin-x64': 4.63.1 + '@rollup/rollup-freebsd-arm64': 4.63.1 + '@rollup/rollup-freebsd-x64': 4.63.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 + '@rollup/rollup-linux-arm-musleabihf': 4.63.1 + '@rollup/rollup-linux-arm64-gnu': 4.63.1 + '@rollup/rollup-linux-arm64-musl': 4.63.1 + '@rollup/rollup-linux-loong64-gnu': 4.63.1 + '@rollup/rollup-linux-loong64-musl': 4.63.1 + '@rollup/rollup-linux-ppc64-gnu': 4.63.1 + '@rollup/rollup-linux-ppc64-musl': 4.63.1 + '@rollup/rollup-linux-riscv64-gnu': 4.63.1 + '@rollup/rollup-linux-riscv64-musl': 4.63.1 + '@rollup/rollup-linux-s390x-gnu': 4.63.1 + '@rollup/rollup-linux-x64-gnu': 4.63.1 + '@rollup/rollup-linux-x64-musl': 4.63.1 + '@rollup/rollup-openbsd-x64': 4.63.1 + '@rollup/rollup-openharmony-arm64': 4.63.1 + '@rollup/rollup-win32-arm64-msvc': 4.63.1 + '@rollup/rollup-win32-ia32-msvc': 4.63.1 + '@rollup/rollup-win32-x64-gnu': 4.63.1 + '@rollup/rollup-win32-x64-msvc': 4.63.1 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(postcss@8.5.26)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.26) + resolve-from: 5.0.0 + rollup: 4.63.1 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.26 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@6.21.0: {} + + vite-node@3.2.4(@types/node@22.20.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@22.20.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@22.20.1): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.26 + rollup: 4.63.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + + vitest@3.2.7(@types/node@22.20.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@22.20.1) + vite-node: 3.2.4(@types/node@22.20.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/explain/pnpm-workspace.yaml b/explain/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/explain/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/explain/src/iife.ts b/explain/src/iife.ts new file mode 100644 index 0000000..63f10b2 --- /dev/null +++ b/explain/src/iife.ts @@ -0,0 +1,5 @@ +import { sqlServerExplainParser } from "./parser"; + +// The desktop loader registers this descriptor after matching it to the +// plugin manifest. This entry intentionally has no registration side effect. +export default sqlServerExplainParser; diff --git a/explain/src/index.ts b/explain/src/index.ts new file mode 100644 index 0000000..6e6849f --- /dev/null +++ b/explain/src/index.ts @@ -0,0 +1,8 @@ +import { registerExplainParser } from "@tabularis/explain"; + +import { sqlServerExplainParser } from "./parser"; + +registerExplainParser(sqlServerExplainParser); + +export { sqlServerExplainParser } from "./parser"; +export { parseShowplanXml } from "./showplan"; diff --git a/explain/src/parser.ts b/explain/src/parser.ts new file mode 100644 index 0000000..9c5a49e --- /dev/null +++ b/explain/src/parser.ts @@ -0,0 +1,13 @@ +import type { RegisteredExplainParser } from "@tabularis/explain"; + +import { parseShowplanXml } from "./showplan"; + +/** SQL Server parser descriptor consumed by both package and plugin loaders. */ +export const sqlServerExplainParser: RegisteredExplainParser = { + engine: "sqlserver", + format: "sqlserver-showplan-xml", + label: "SQL Server SHOWPLAN XML", + parse: parseShowplanXml, + sniff: (payload) => + /<(?:\w+:)?ShowPlanXML(?:\s|>)/.test(payload.slice(0, 4096)), +}; diff --git a/explain/src/showplan.ts b/explain/src/showplan.ts new file mode 100644 index 0000000..421432c --- /dev/null +++ b/explain/src/showplan.ts @@ -0,0 +1,354 @@ +import type { ExplainNode, ExplainPlan } from "@tabularis/explain"; + +interface XmlElement { + readonly qualifiedName: string; + readonly name: string; + readonly attributes: ReadonlyMap; + readonly children: XmlElement[]; +} + +class XmlParseError extends Error {} + +function localName(qualifiedName: string): string { + const separator = qualifiedName.lastIndexOf(":"); + return separator === -1 ? qualifiedName : qualifiedName.slice(separator + 1); +} + +function decodeXmlEntities(value: string): string { + let decoded = ""; + let position = 0; + while (position < value.length) { + const ampersand = value.indexOf("&", position); + if (ampersand === -1) return decoded + value.slice(position); + decoded += value.slice(position, ampersand); + + const semicolon = value.indexOf(";", ampersand + 1); + if (semicolon === -1) throw new XmlParseError("unterminated XML entity"); + const entity = value.slice(ampersand + 1, semicolon); + switch (entity) { + case "amp": + decoded += "&"; + break; + case "lt": + decoded += "<"; + break; + case "gt": + decoded += ">"; + break; + case "quot": + decoded += '"'; + break; + case "apos": + decoded += "'"; + break; + default: { + const numeric = /^#x[0-9a-f]+$/i.test(entity) + ? Number.parseInt(entity.slice(2), 16) + : /^#[0-9]+$/.test(entity) + ? Number.parseInt(entity.slice(1), 10) + : Number.NaN; + if ( + !Number.isInteger(numeric) || + numeric <= 0 || + numeric > 0x10ffff || + (numeric >= 0xd800 && numeric <= 0xdfff) + ) { + throw new XmlParseError(`invalid XML entity &${entity};`); + } + decoded += String.fromCodePoint(numeric); + } + } + position = semicolon + 1; + } + return decoded; +} + +function findTagEnd(xml: string, start: number): number { + let quote: string | null = null; + for (let index = start; index < xml.length; index += 1) { + const character = xml[index]; + if (quote !== null) { + if (character === quote) quote = null; + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === ">") { + return index; + } + } + throw new XmlParseError("unterminated XML tag"); +} + +function parseStartTag(source: string): { + qualifiedName: string; + attributes: Map; + selfClosing: boolean; +} { + let end = source.length; + while (end > 0 && /\s/.test(source[end - 1] ?? "")) end -= 1; + const selfClosing = source[end - 1] === "/"; + if (selfClosing) { + end -= 1; + while (end > 0 && /\s/.test(source[end - 1] ?? "")) end -= 1; + } + + let index = 0; + while (index < end && /\s/.test(source[index] ?? "")) index += 1; + const nameStart = index; + while (index < end && !/[\s/=]/.test(source[index] ?? "")) index += 1; + const qualifiedName = source.slice(nameStart, index); + if (!/^[A-Za-z_][\w.:-]*$/.test(qualifiedName)) { + throw new XmlParseError(`invalid element name '${qualifiedName}'`); + } + + const attributes = new Map(); + while (index < end) { + while (index < end && /\s/.test(source[index] ?? "")) index += 1; + if (index >= end) break; + + const attributeStart = index; + while (index < end && !/[\s=]/.test(source[index] ?? "")) index += 1; + const attributeName = source.slice(attributeStart, index); + if (!/^[A-Za-z_][\w.:-]*$/.test(attributeName)) { + throw new XmlParseError(`invalid attribute name '${attributeName}'`); + } + while (index < end && /\s/.test(source[index] ?? "")) index += 1; + if (source[index] !== "=") { + throw new XmlParseError(`attribute '${attributeName}' has no value`); + } + index += 1; + while (index < end && /\s/.test(source[index] ?? "")) index += 1; + + const quote = source[index]; + if (quote !== '"' && quote !== "'") { + throw new XmlParseError(`attribute '${attributeName}' is not quoted`); + } + index += 1; + const valueStart = index; + while (index < end && source[index] !== quote) index += 1; + if (index >= end) { + throw new XmlParseError(`unterminated attribute '${attributeName}'`); + } + if (attributes.has(attributeName)) { + throw new XmlParseError(`duplicate attribute '${attributeName}'`); + } + const rawValue = source.slice(valueStart, index); + if (rawValue.includes("<")) { + throw new XmlParseError(`attribute '${attributeName}' contains '<'`); + } + attributes.set(localName(attributeName), decodeXmlEntities(rawValue)); + index += 1; + } + + return { qualifiedName, attributes, selfClosing }; +} + +/** + * Parse the XML subset used by SQL Server SHOWPLAN without Node built-ins. + * The parser validates tag nesting and attributes and is shared by browser and + * server-side consumers, avoiding a runtime XML dependency in either bundle. + */ +function parseXml(xml: string): XmlElement { + let root: XmlElement | null = null; + let position = 0; + const stack: XmlElement[] = []; + + while (position < xml.length) { + const tagStart = xml.indexOf("<", position); + const textEnd = tagStart === -1 ? xml.length : tagStart; + const text = xml.slice(position, textEnd); + if (stack.length === 0 && text.trim() !== "") { + throw new XmlParseError("text outside the document element"); + } + decodeXmlEntities(text); + if (tagStart === -1) break; + + if (xml.startsWith("", tagStart + 4); + if (end === -1) throw new XmlParseError("unterminated XML comment"); + if (xml.slice(tagStart + 4, end).includes("--")) { + throw new XmlParseError("invalid '--' inside XML comment"); + } + position = end + 3; + continue; + } + if (xml.startsWith("", tagStart + 9); + if (end === -1) throw new XmlParseError("unterminated CDATA section"); + position = end + 3; + continue; + } + if (xml.startsWith("", tagStart + 2); + if (end === -1) throw new XmlParseError("unterminated processing instruction"); + position = end + 2; + continue; + } + if (xml.startsWith(" { + for (const child of element.children) { + if (child.name === "RelOp") operators.push(child); + else visit(child); + } + }; + visit(operator); + return operators; +} + +function attributeNumber(element: XmlElement, name: string): number | null { + const text = element.attributes.get(name); + if (text === undefined || text.trim() === "") return null; + const value = Number(text); + return Number.isFinite(value) ? value : null; +} + +function runtimeMetrics(operator: XmlElement): { + actualRows: number | null; + actualTimeMs: number | null; + actualLoops: number | null; +} { + const runtime = ownedDescendant(operator, "RunTimeInformation"); + if (runtime === null) { + return { actualRows: null, actualTimeMs: null, actualLoops: null }; + } + const counters = runtime.children.filter( + (child) => child.name === "RunTimeCountersPerThread", + ); + if (counters.length === 0) { + return { actualRows: null, actualTimeMs: null, actualLoops: null }; + } + const metric = (counter: XmlElement, name: string): number => + attributeNumber(counter, name) ?? 0; + + return { + actualRows: counters.reduce((sum, counter) => sum + metric(counter, "ActualRows"), 0), + actualTimeMs: Math.max(...counters.map((counter) => metric(counter, "ActualElapsedms"))), + actualLoops: counters.reduce( + (sum, counter) => sum + metric(counter, "ActualExecutions"), + 0, + ), + }; +} + +function parseOperator(operator: XmlElement, fallbackId: number): ExplainNode { + const physical = + operator.attributes.get("PhysicalOp") ?? + operator.attributes.get("LogicalOp") ?? + "Unknown"; + const logical = operator.attributes.get("LogicalOp") ?? physical; + const runtime = runtimeMetrics(operator); + const object = ownedDescendant(operator, "Object"); + const scalar = ownedDescendant(operator, "ScalarOperator"); + const children = childOperators(operator).map((child, index) => + parseOperator(child, fallbackId * 10 + index + 1), + ); + + return { + id: `sqlserver-${operator.attributes.get("NodeId") ?? String(fallbackId)}`, + node_type: physical, + relation: object?.attributes.get("Table")?.replace(/[\[\]]/g, "") ?? null, + startup_cost: null, + total_cost: attributeNumber(operator, "EstimatedTotalSubtreeCost"), + plan_rows: attributeNumber(operator, "EstimateRows"), + actual_rows: runtime.actualRows, + actual_time_ms: runtime.actualTimeMs, + actual_loops: runtime.actualLoops, + buffers_hit: null, + buffers_read: null, + filter: scalar?.attributes.get("ScalarString") ?? null, + index_condition: null, + join_type: logical.toLowerCase().includes("join") ? logical : null, + hash_condition: null, + extra: { logical_operation: logical }, + children, + }; +} + +/** Parse SQL Server SHOWPLAN XML into the shared Tabularis visual-plan model. */ +export function parseShowplanXml(xml: string): ExplainPlan { + let document: XmlElement; + try { + document = parseXml(xml); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to parse SQL Server SHOWPLAN_XML: ${message}`); + } + + const operator = firstDescendant(document, "RelOp"); + if (operator === null) { + throw new Error("SQL Server SHOWPLAN_XML does not contain a RelOp"); + } + const root = parseOperator(operator, 0); + + return { + root, + planning_time_ms: null, + execution_time_ms: root.actual_time_ms, + original_query: "", + driver: "sqlserver", + has_analyze_data: root.actual_rows !== null, + raw_output: xml, + }; +} diff --git a/explain/tests/fixtures/expected/index-seek-key-lookup.json b/explain/tests/fixtures/expected/index-seek-key-lookup.json new file mode 100644 index 0000000..1df77f2 --- /dev/null +++ b/explain/tests/fixtures/expected/index-seek-key-lookup.json @@ -0,0 +1,72 @@ +{ + "driver": "sqlserver", + "execution_time_ms": null, + "has_analyze_data": false, + "original_query": "", + "planning_time_ms": null, + "raw_output": "", + "root": { + "actual_loops": null, + "actual_rows": null, + "actual_time_ms": null, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": null, + "actual_rows": null, + "actual_time_ms": null, + "buffers_hit": null, + "buffers_read": null, + "children": [], + "extra": { + "logical_operation": "Index Seek" + }, + "filter": "(999)", + "hash_condition": null, + "id": "sqlserver-1", + "index_condition": null, + "join_type": null, + "node_type": "Index Seek", + "plan_rows": 1.0, + "relation": "ss034_lookup", + "startup_cost": null, + "total_cost": 0.0032831 + }, + { + "actual_loops": null, + "actual_rows": null, + "actual_time_ms": null, + "buffers_hit": null, + "buffers_read": null, + "children": [], + "extra": { + "logical_operation": "Clustered Index Seek" + }, + "filter": "[tabularis_test].[dbo].[ss034_lookup].[id]", + "hash_condition": null, + "id": "sqlserver-3", + "index_condition": null, + "join_type": null, + "node_type": "Clustered Index Seek", + "plan_rows": 1.0, + "relation": "ss034_lookup", + "startup_cost": null, + "total_cost": 0.0032831 + } + ], + "extra": { + "logical_operation": "Inner Join" + }, + "filter": null, + "hash_condition": null, + "id": "sqlserver-0", + "index_condition": null, + "join_type": "Inner Join", + "node_type": "Nested Loops", + "plan_rows": 1.0, + "relation": null, + "startup_cost": null, + "total_cost": 0.00657038 + } +} diff --git a/explain/tests/fixtures/expected/missing-index.json b/explain/tests/fixtures/expected/missing-index.json new file mode 100644 index 0000000..87fae6b --- /dev/null +++ b/explain/tests/fixtures/expected/missing-index.json @@ -0,0 +1,51 @@ +{ + "driver": "sqlserver", + "execution_time_ms": null, + "has_analyze_data": false, + "original_query": "", + "planning_time_ms": null, + "raw_output": "", + "root": { + "actual_loops": null, + "actual_rows": null, + "actual_time_ms": null, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": null, + "actual_rows": null, + "actual_time_ms": null, + "buffers_hit": null, + "buffers_read": null, + "children": [], + "extra": { + "logical_operation": "Table Scan" + }, + "filter": "[tabularis_test].[dbo].[ss034_missing].[lookup_value]=CONVERT_IMPLICIT(int,[@1],0)", + "hash_condition": null, + "id": "sqlserver-1", + "index_condition": null, + "join_type": null, + "node_type": "Table Scan", + "plan_rows": 50.0, + "relation": "ss034_missing", + "startup_cost": null, + "total_cost": 19.6333 + } + ], + "extra": { + "logical_operation": "Gather Streams" + }, + "filter": null, + "hash_condition": null, + "id": "sqlserver-0", + "index_condition": null, + "join_type": null, + "node_type": "Parallelism", + "plan_rows": 50.0, + "relation": null, + "startup_cost": null, + "total_cost": 19.7222 + } +} diff --git a/explain/tests/fixtures/expected/multi-statement.json b/explain/tests/fixtures/expected/multi-statement.json new file mode 100644 index 0000000..3efa8e0 --- /dev/null +++ b/explain/tests/fixtures/expected/multi-statement.json @@ -0,0 +1,29 @@ +{ + "driver": "sqlserver", + "execution_time_ms": null, + "has_analyze_data": false, + "original_query": "", + "planning_time_ms": null, + "raw_output": "", + "root": { + "actual_loops": null, + "actual_rows": null, + "actual_time_ms": null, + "buffers_hit": null, + "buffers_read": null, + "children": [], + "extra": { + "logical_operation": "Table Scan" + }, + "filter": "[tabularis_test].[dbo].[ss034_small].[id]=CONVERT_IMPLICIT(int,[@1],0)", + "hash_condition": null, + "id": "sqlserver-0", + "index_condition": null, + "join_type": null, + "node_type": "Table Scan", + "plan_rows": 1.0, + "relation": "ss034_small", + "startup_cost": null, + "total_cost": 0.0032853 + } +} diff --git a/explain/tests/fixtures/expected/parallel-hash-join.json b/explain/tests/fixtures/expected/parallel-hash-join.json new file mode 100644 index 0000000..22399f2 --- /dev/null +++ b/explain/tests/fixtures/expected/parallel-hash-join.json @@ -0,0 +1,204 @@ +{ + "driver": "sqlserver", + "execution_time_ms": 36.0, + "has_analyze_data": true, + "original_query": "", + "planning_time_ms": null, + "raw_output": "", + "root": { + "actual_loops": 1.0, + "actual_rows": 1.0, + "actual_time_ms": 36.0, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": 4.0, + "actual_rows": 1.0, + "actual_time_ms": 0.0, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": 4.0, + "actual_rows": 1.0, + "actual_time_ms": 1.0, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": 4.0, + "actual_rows": 900000.0, + "actual_time_ms": 1.0, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": 4.0, + "actual_rows": 900000.0, + "actual_time_ms": 16.0, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": 4.0, + "actual_rows": 300000.0, + "actual_time_ms": 0.0, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": 4.0, + "actual_rows": 300000.0, + "actual_time_ms": 9.0, + "buffers_hit": null, + "buffers_read": null, + "children": [], + "extra": { + "logical_operation": "Table Scan" + }, + "filter": null, + "hash_condition": null, + "id": "sqlserver-6", + "index_condition": null, + "join_type": null, + "node_type": "Table Scan", + "plan_rows": 300000.0, + "relation": "ss034_big_a", + "startup_cost": null, + "total_cost": 0.552331 + } + ], + "extra": { + "logical_operation": "Compute Scalar" + }, + "filter": "CONVERT(bigint,[tabularis_test].[dbo].[ss034_big_a].[payload] as [a].[payload],0)", + "hash_condition": null, + "id": "sqlserver-5", + "index_condition": null, + "join_type": null, + "node_type": "Compute Scalar", + "plan_rows": 300000.0, + "relation": null, + "startup_cost": null, + "total_cost": 0.553081 + }, + { + "actual_loops": 4.0, + "actual_rows": 300000.0, + "actual_time_ms": 0.0, + "buffers_hit": null, + "buffers_read": null, + "children": [ + { + "actual_loops": 4.0, + "actual_rows": 300000.0, + "actual_time_ms": 8.0, + "buffers_hit": null, + "buffers_read": null, + "children": [], + "extra": { + "logical_operation": "Table Scan" + }, + "filter": null, + "hash_condition": null, + "id": "sqlserver-8", + "index_condition": null, + "join_type": null, + "node_type": "Table Scan", + "plan_rows": 300000.0, + "relation": "ss034_big_b", + "startup_cost": null, + "total_cost": 0.552331 + } + ], + "extra": { + "logical_operation": "Compute Scalar" + }, + "filter": "CONVERT_IMPLICIT(bigint,[tabularis_test].[dbo].[ss034_big_b].[payload] as [b].[payload],0)", + "hash_condition": null, + "id": "sqlserver-7", + "index_condition": null, + "join_type": null, + "node_type": "Compute Scalar", + "plan_rows": 300000.0, + "relation": null, + "startup_cost": null, + "total_cost": 0.553081 + } + ], + "extra": { + "logical_operation": "Inner Join" + }, + "filter": null, + "hash_condition": null, + "id": "sqlserver-4", + "index_condition": null, + "join_type": "Inner Join", + "node_type": "Hash Match", + "plan_rows": 900000.0, + "relation": null, + "startup_cost": null, + "total_cost": 1.31514 + } + ], + "extra": { + "logical_operation": "Compute Scalar" + }, + "filter": "[Expr1005]+[Expr1006]", + "hash_condition": null, + "id": "sqlserver-3", + "index_condition": null, + "join_type": null, + "node_type": "Compute Scalar", + "plan_rows": 900000.0, + "relation": null, + "startup_cost": null, + "total_cost": 1.31739 + } + ], + "extra": { + "logical_operation": "Aggregate" + }, + "filter": "COUNT_BIG([Expr1012])", + "hash_condition": null, + "id": "sqlserver-2", + "index_condition": null, + "join_type": null, + "node_type": "Hash Match", + "plan_rows": 1.0, + "relation": null, + "startup_cost": null, + "total_cost": 1.32817 + } + ], + "extra": { + "logical_operation": "Compute Scalar" + }, + "filter": "CASE WHEN [Expr1013]=(0) THEN NULL ELSE [Expr1014] END", + "hash_condition": null, + "id": "sqlserver-1", + "index_condition": null, + "join_type": null, + "node_type": "Compute Scalar", + "plan_rows": 1.0, + "relation": null, + "startup_cost": null, + "total_cost": 1.32817 + } + ], + "extra": { + "logical_operation": "Gather Streams" + }, + "filter": null, + "hash_condition": null, + "id": "sqlserver-0", + "index_condition": null, + "join_type": null, + "node_type": "Parallelism", + "plan_rows": 1.0, + "relation": null, + "startup_cost": null, + "total_cost": 1.35667 + } +} diff --git a/explain/tests/fixtures/expected/statistics-xml.json b/explain/tests/fixtures/expected/statistics-xml.json new file mode 100644 index 0000000..760a351 --- /dev/null +++ b/explain/tests/fixtures/expected/statistics-xml.json @@ -0,0 +1,29 @@ +{ + "driver": "sqlserver", + "execution_time_ms": 0.0, + "has_analyze_data": true, + "original_query": "", + "planning_time_ms": null, + "raw_output": "", + "root": { + "actual_loops": 1.0, + "actual_rows": 2.0, + "actual_time_ms": 0.0, + "buffers_hit": null, + "buffers_read": null, + "children": [], + "extra": { + "logical_operation": "Table Scan" + }, + "filter": "[tabularis_test].[dbo].[ss034_small].[id]>=CONVERT_IMPLICIT(int,[@1],0)", + "hash_condition": null, + "id": "sqlserver-0", + "index_condition": null, + "join_type": null, + "node_type": "Table Scan", + "plan_rows": 2.0, + "relation": "ss034_small", + "startup_cost": null, + "total_cost": 0.0032853 + } +} diff --git a/explain/tests/fixtures/expected/trivial-scan.json b/explain/tests/fixtures/expected/trivial-scan.json new file mode 100644 index 0000000..5bd42c3 --- /dev/null +++ b/explain/tests/fixtures/expected/trivial-scan.json @@ -0,0 +1,29 @@ +{ + "driver": "sqlserver", + "execution_time_ms": null, + "has_analyze_data": false, + "original_query": "", + "planning_time_ms": null, + "raw_output": "", + "root": { + "actual_loops": null, + "actual_rows": null, + "actual_time_ms": null, + "buffers_hit": null, + "buffers_read": null, + "children": [], + "extra": { + "logical_operation": "Table Scan" + }, + "filter": null, + "hash_condition": null, + "id": "sqlserver-0", + "index_condition": null, + "join_type": null, + "node_type": "Table Scan", + "plan_rows": 3.0, + "relation": "ss034_small", + "startup_cost": null, + "total_cost": 0.0032853 + } +} diff --git a/explain/tests/fixtures/index-seek-key-lookup.xml b/explain/tests/fixtures/index-seek-key-lookup.xml new file mode 100644 index 0000000..e0b8118 --- /dev/null +++ b/explain/tests/fixtures/index-seek-key-lookup.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/explain/tests/fixtures/missing-index.xml b/explain/tests/fixtures/missing-index.xml new file mode 100644 index 0000000..49fd764 --- /dev/null +++ b/explain/tests/fixtures/missing-index.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/explain/tests/fixtures/multi-statement.xml b/explain/tests/fixtures/multi-statement.xml new file mode 100644 index 0000000..8a38e1f --- /dev/null +++ b/explain/tests/fixtures/multi-statement.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/explain/tests/fixtures/parallel-hash-join.xml b/explain/tests/fixtures/parallel-hash-join.xml new file mode 100644 index 0000000..d78458a --- /dev/null +++ b/explain/tests/fixtures/parallel-hash-join.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/explain/tests/fixtures/statistics-xml.xml b/explain/tests/fixtures/statistics-xml.xml new file mode 100644 index 0000000..8a35cd5 --- /dev/null +++ b/explain/tests/fixtures/statistics-xml.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/explain/tests/fixtures/trivial-scan.xml b/explain/tests/fixtures/trivial-scan.xml new file mode 100644 index 0000000..c85f8ef --- /dev/null +++ b/explain/tests/fixtures/trivial-scan.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/explain/tests/host/index.d.ts b/explain/tests/host/index.d.ts new file mode 100644 index 0000000..047f79e --- /dev/null +++ b/explain/tests/host/index.d.ts @@ -0,0 +1,40 @@ +export interface ExplainNode { + id: string; + node_type: string; + relation: string | null; + startup_cost: number | null; + total_cost: number | null; + plan_rows: number | null; + actual_rows: number | null; + actual_time_ms: number | null; + actual_loops: number | null; + buffers_hit: number | null; + buffers_read: number | null; + filter: string | null; + index_condition: string | null; + join_type: string | null; + hash_condition: string | null; + extra: Record; + children: ExplainNode[]; +} + +export interface ExplainPlan { + root: ExplainNode; + planning_time_ms: number | null; + execution_time_ms: number | null; + original_query: string; + driver: string; + has_analyze_data: boolean; + raw_output: string | null; +} + +export interface RegisteredExplainParser { + readonly engine: string; + readonly format: string; + readonly label?: string; + parse(payload: string): ExplainPlan; + sniff?(payload: string): boolean; +} + +export const registrations: RegisteredExplainParser[]; +export function registerExplainParser(parser: RegisteredExplainParser): void; diff --git a/explain/tests/host/index.js b/explain/tests/host/index.js new file mode 100644 index 0000000..b2b4ce3 --- /dev/null +++ b/explain/tests/host/index.js @@ -0,0 +1,5 @@ +export const registrations = []; + +export function registerExplainParser(parser) { + registrations.push(parser); +} diff --git a/explain/tests/host/package.json b/explain/tests/host/package.json new file mode 100644 index 0000000..7f4b8d9 --- /dev/null +++ b/explain/tests/host/package.json @@ -0,0 +1,11 @@ +{ + "name": "@tabularis/explain", + "version": "0.2.0", + "type": "module", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./index.js" + } + } +} diff --git a/explain/tests/package.test.ts b/explain/tests/package.test.ts new file mode 100644 index 0000000..adc2d31 --- /dev/null +++ b/explain/tests/package.test.ts @@ -0,0 +1,50 @@ +import { readFile } from "node:fs/promises"; + +import { + registrations, + type RegisteredExplainParser, +} from "@tabularis/explain"; +import { describe, expect, it } from "vitest"; + +import { + parseShowplanXml, + sqlServerExplainParser, +} from "../src/index"; + +const fixtureUrl = new URL("./fixtures/trivial-scan.xml", import.meta.url); + +describe("package entry points", () => { + it("registers on ESM import and exports the direct parser API", async () => { + expect(registrations).toEqual([sqlServerExplainParser]); + expect(sqlServerExplainParser).toMatchObject({ + engine: "sqlserver", + format: "sqlserver-showplan-xml", + label: "SQL Server SHOWPLAN XML", + parse: parseShowplanXml, + }); + expect(sqlServerExplainParser.sniff?.(await readFile(fixtureUrl, "utf8"))).toBe(true); + expect(sqlServerExplainParser.sniff?.("")).toBe(false); + }); + + it("builds an isolated IIFE descriptor without self-registration", async () => { + const source = await readFile( + new URL("../dist/index.iife.js", import.meta.url), + "utf8", + ); + const registrationsBeforeEvaluation = registrations.length; + const evaluate = new Function( + "__TABULARIS_EXPLAIN__", + `${source}\nreturn typeof __tabularis_explain_parser__ !== "undefined" ? __tabularis_explain_parser__ : null;`, + ); + const raw = evaluate({}) as Record; + const descriptor = (raw.default ?? raw) as RegisteredExplainParser; + + expect(descriptor).toMatchObject({ + engine: "sqlserver", + format: "sqlserver-showplan-xml", + label: "SQL Server SHOWPLAN XML", + }); + expect(descriptor.parse(await readFile(fixtureUrl, "utf8")).driver).toBe("sqlserver"); + expect(registrations).toHaveLength(registrationsBeforeEvaluation); + }); +}); diff --git a/explain/tests/showplan.test.ts b/explain/tests/showplan.test.ts new file mode 100644 index 0000000..ecc64ef --- /dev/null +++ b/explain/tests/showplan.test.ts @@ -0,0 +1,89 @@ +import { readFile } from "node:fs/promises"; + +import type { ExplainNode, ExplainPlan } from "@tabularis/explain"; +import { describe, expect, it } from "vitest"; + +import { parseShowplanXml } from "../src/showplan"; + +const fixtureNames = [ + "trivial-scan", + "index-seek-key-lookup", + "parallel-hash-join", + "statistics-xml", + "missing-index", + "multi-statement", +] as const; +const fixtureDirectory = new URL("./fixtures/", import.meta.url); + +async function readFixture(name: string): Promise { + return readFile(new URL(`${name}.xml`, fixtureDirectory), "utf8"); +} + +function flatten(node: ExplainNode): ExplainNode[] { + return [node, ...node.children.flatMap(flatten)]; +} + +describe("parseShowplanXml", () => { + it.each(fixtureNames)("matches the committed SHOWPLAN golden for %s", async (name) => { + const xml = await readFixture(name); + const expected = JSON.parse( + await readFile(new URL(`expected/${name}.json`, fixtureDirectory), "utf8"), + ) as ExplainPlan; + + expect(parseShowplanXml(xml)).toEqual(expected); + }); + + it("sums rows and executions and takes maximum elapsed time across threads", async () => { + const plan = parseShowplanXml(await readFixture("parallel-hash-join")); + const hashJoin = flatten(plan.root).find((node) => node.id === "sqlserver-4"); + + expect(hashJoin).toMatchObject({ + node_type: "Hash Match", + join_type: "Inner Join", + actual_rows: 900_000, + actual_loops: 4, + actual_time_ms: 16, + }); + expect(plan.execution_time_ms).toBe(36); + expect(plan.has_analyze_data).toBe(true); + }); + + it("uses only the first statement's first operator", async () => { + const plan = parseShowplanXml(await readFixture("multi-statement")); + + expect(plan.root).toMatchObject({ + id: "sqlserver-0", + node_type: "Table Scan", + relation: "ss034_small", + }); + expect(plan.root.children).toEqual([]); + }); + + it("keeps missing-index documents parseable without synthesizing model fields", async () => { + const xml = await readFixture("missing-index"); + const plan = parseShowplanXml(xml); + + expect(xml).toContain(""); + expect(plan.raw_output).toBe(xml); + expect(plan.root.extra).toEqual({ logical_operation: "Gather Streams" }); + }); + + it("is namespace-insensitive and assigns deterministic fallback ids", () => { + const xml = + ''; + const plan = parseShowplanXml(xml); + + expect(plan.root.id).toBe("sqlserver-0"); + expect(plan.root.children[0]?.id).toBe("sqlserver-1"); + expect(plan.root.children[0]?.node_type).toBe("Table Scan"); + }); + + it("retains the established SHOWPLAN error prefixes", () => { + expect(() => parseShowplanXml("")).toThrowError( + /^Failed to parse SQL Server SHOWPLAN_XML:/, + ); + expect(() => parseShowplanXml("")).toThrowError( + "SQL Server SHOWPLAN_XML does not contain a RelOp", + ); + }); +}); diff --git a/explain/tsconfig.json b/explain/tsconfig.json new file mode 100644 index 0000000..e20274f --- /dev/null +++ b/explain/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "esModuleInterop": true, + "isolatedModules": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "noEmit": true, + "types": ["vitest/globals"] + }, + "include": ["src/**/*", "tests/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/explain/tsup.config.ts b/explain/tsup.config.ts new file mode 100644 index 0000000..39d8d46 --- /dev/null +++ b/explain/tsup.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "tsup"; + +const common = { + platform: "browser" as const, + target: "es2022" as const, + sourcemap: false, + minify: false, + splitting: false, + treeshake: true, + external: ["@tabularis/explain"], +}; + +export default defineConfig([ + { + ...common, + entry: { index: "src/index.ts" }, + format: ["esm"], + dts: true, + clean: true, + }, + { + ...common, + entry: { "index.iife": "src/iife.ts" }, + format: ["iife"], + globalName: "__tabularis_explain_parser__", + dts: false, + clean: false, + outExtension: () => ({ js: ".js" }), + }, +]); diff --git a/justfile b/justfile index c8df0de..4a788aa 100644 --- a/justfile +++ b/justfile @@ -1,39 +1,56 @@ set shell := ["bash", "-cu"] set windows-shell := ["powershell.exe", "-NoLogo", "-NoProfile", "-Command"] -# Run SQL Server 2022 via Docker (accept the EULA, set a strong SA password) +# Run SQL Server 2022 via Docker (accept the EULA, set a strong SA password). run-sqlserver: - docker run -d --name sqlserver-dev -p 1433:1433 \ - -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Str0ng!Passw0rd" \ - mcr.microsoft.com/mssql/server:2022-latest + docker run -d --name sqlserver-dev -p 1433:1433 \ + -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Str0ng!Passw0rd" \ + mcr.microsoft.com/mssql/server:2022-latest -# Seed a test database into the local SQL Server container +# Stop and remove the local SQL Server container. +stop-sqlserver: + docker rm -f sqlserver-dev + +# Seed a test database into the local SQL Server container. CREATE DATABASE +# must finish in its own batch before sqlcmd selects the new database. seed-sqlserver: - docker exec sqlserver-dev /opt/mssql-tools18/bin/sqlcmd \ - -S localhost -U sa -P "Str0ng!Passw0rd" -C -Q \ - "IF DB_ID('tabularis_test') IS NULL CREATE DATABASE tabularis_test; \ - USE tabularis_test; \ - IF OBJECT_ID('dbo.users') IS NULL BEGIN \ - CREATE TABLE dbo.users (id INT IDENTITY(1,1) PRIMARY KEY, name NVARCHAR(100) NOT NULL, email NVARCHAR(255) NOT NULL); \ - INSERT INTO dbo.users (name, email) VALUES (N'Alice', N'alice@example.com'), (N'Bob', N'bob@example.com'); \ - END" + docker exec sqlserver-dev /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P "Str0ng!Passw0rd" -C -Q \ + "IF DB_ID('tabularis_test') IS NULL CREATE DATABASE tabularis_test;" + docker exec sqlserver-dev /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P "Str0ng!Passw0rd" -C -d tabularis_test -Q \ + "IF OBJECT_ID('dbo.users') IS NULL BEGIN \ + CREATE TABLE dbo.users (id INT IDENTITY(1,1) PRIMARY KEY, name NVARCHAR(100) NOT NULL, email NVARCHAR(255) NOT NULL); \ + INSERT INTO dbo.users (name, email) VALUES (N'Alice', N'alice@example.com'), (N'Bob', N'bob@example.com'); \ + END" # --------------------------------------------------------------------------- -# Cross-platform recipes (only shell-agnostic tooling — cargo, npm). +# Cross-platform recipes (only shell-agnostic tooling — cargo, npm, pnpm). # --------------------------------------------------------------------------- -# Build the plugin binary in debug mode (plus UI if present). -build: build-ui +# Build the plugin binary and its optional JavaScript artifacts. +build: build-ui build-explain cargo build # Build for release (what the GitHub Actions workflow ships). -release: build-ui +release: build-ui build-explain cargo build --release -# Run unit tests only. This crate is binary-only, so --lib would fail; --bins -# also keeps tests/live_db.rs out of the default run. +# Build and test the browser-safe SQL Server SHOWPLAN parser package. +build-explain: + pnpm --dir explain install --frozen-lockfile + pnpm --dir explain build + +test-explain: + pnpm --dir explain install --frozen-lockfile + pnpm --dir explain typecheck + pnpm --dir explain test + +# Run unit tests and recorded host-model conformance. This crate is binary-only, +# so --lib would fail; selecting conformance explicitly keeps tests/live_db.rs +# out of the default run. test: - cargo test --bins + cargo test --bins --test conformance # Launch the local REPL that simulates Tabularis JSON-RPC calls over stdio. repl: @@ -49,6 +66,15 @@ fmt: # --------------------------------------------------------------------------- # Platform-specific recipes (file operations + plugin-dir conventions). +# +# Host source: tabularis/src-tauri/src/plugins/manager.rs loads the directory +# returned by tabularis/src-tauri/src/plugins/installer.rs::get_plugins_dir, +# which appends `plugins` to tabularis/src-tauri/src/paths.rs::get_app_data_dir. +# paths.rs uses ProjectDirs("", "", "tabularis") and removes the directories +# crate's Windows `data` leaf. The resulting roots are +# ${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins on Linux, +# $HOME/Library/Application Support/tabularis/plugins on macOS, and +# %APPDATA%\tabularis\plugins on Windows. # --------------------------------------------------------------------------- # Build the UI extension if present (no-op otherwise). @@ -61,67 +87,80 @@ build-ui: [windows] build-ui: - if (Test-Path ui/package.json) { - Write-Host "Building UI extension..." - Push-Location ui - try { - npm install --no-audit --no-fund - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - npm run build - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } finally { - Pop-Location - } + if (Test-Path "ui\package.json") { \ + Write-Host "Building UI extension..."; \ + Push-Location ui; \ + try { \ + npm install --no-audit --no-fund; \ + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; \ + npm run build; \ + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; \ + } finally { \ + Pop-Location; \ + }; \ } -# Build + copy binary, manifest and (if present) UI bundle into Tabularis's plugin folder. +# Build + copy binary, manifest and optional bundles into Tabularis's plugin folder. [linux] dev-install: build - mkdir -p ~/.local/share/tabularis/plugins/sqlserver - cp target/debug/sqlserver-plugin ~/.local/share/tabularis/plugins/sqlserver/ - cp .tabularium ~/.local/share/tabularis/plugins/sqlserver/ + mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver" + cp target/debug/sqlserver-plugin "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/" + cp .tabularium "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/" @if [ -f ui/dist/index.js ]; then \ - mkdir -p ~/.local/share/tabularis/plugins/sqlserver/ui/dist; \ - cp ui/dist/index.js ~/.local/share/tabularis/plugins/sqlserver/ui/dist/; \ + mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/ui/dist"; \ + cp ui/dist/index.js "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/ui/dist/"; \ fi - @echo "Installed to ~/.local/share/tabularis/plugins/sqlserver" + @if [ -f explain/dist/index.iife.js ]; then \ + mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/explain/dist"; \ + cp explain/dist/index.iife.js "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/explain/dist/"; \ + fi + @echo "Installed to ${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver" @echo "Restart Tabularis (or toggle the plugin in Settings) to pick up changes." [macos] dev-install: build - mkdir -p "$HOME/Library/Application Support/com.debba.tabularis/plugins/sqlserver" + mkdir -p "$HOME/Library/Application Support/tabularis/plugins/sqlserver" cp target/debug/sqlserver-plugin "$HOME/Library/Application Support/tabularis/plugins/sqlserver/" cp .tabularium "$HOME/Library/Application Support/tabularis/plugins/sqlserver/" @if [ -f ui/dist/index.js ]; then \ mkdir -p "$HOME/Library/Application Support/tabularis/plugins/sqlserver/ui/dist"; \ cp ui/dist/index.js "$HOME/Library/Application Support/tabularis/plugins/sqlserver/ui/dist/"; \ fi - @echo "Installed to ~/Library/Application Support/com.debba.tabularis/plugins/sqlserver" + @if [ -f explain/dist/index.iife.js ]; then \ + mkdir -p "$HOME/Library/Application Support/tabularis/plugins/sqlserver/explain/dist"; \ + cp explain/dist/index.iife.js "$HOME/Library/Application Support/tabularis/plugins/sqlserver/explain/dist/"; \ + fi + @echo "Installed to ~/Library/Application Support/tabularis/plugins/sqlserver" @echo "Restart Tabularis (or toggle the plugin in Settings) to pick up changes." +# Each recipe line runs in a fresh shell, so this must be one logical command. [windows] dev-install: build - $dest = Join-Path $env:APPDATA "debba\tabularis\data\plugins\sqlserver" - New-Item -ItemType Directory -Force -Path $dest | Out-Null - Copy-Item "target\debug\sqlserver-plugin.exe" $dest - Copy-Item ".tabularium" $dest - if (Test-Path "ui\dist\index.js") { - New-Item -ItemType Directory -Force -Path (Join-Path $dest "ui\dist") | Out-Null - Copy-Item "ui\dist\index.js" (Join-Path $dest "ui\dist") - } - Write-Host "Installed to $dest" + $dest = Join-Path $env:APPDATA "tabularis\plugins\sqlserver"; \ + New-Item -ItemType Directory -Force -Path $dest | Out-Null; \ + Copy-Item "target\debug\sqlserver-plugin.exe" $dest; \ + Copy-Item ".tabularium" $dest; \ + if (Test-Path "ui\dist\index.js") { \ + New-Item -ItemType Directory -Force -Path (Join-Path $dest "ui\dist") | Out-Null; \ + Copy-Item "ui\dist\index.js" (Join-Path $dest "ui\dist"); \ + }; \ + if (Test-Path "explain\dist\index.iife.js") { \ + New-Item -ItemType Directory -Force -Path (Join-Path $dest "explain\dist") | Out-Null; \ + Copy-Item "explain\dist\index.iife.js" (Join-Path $dest "explain\dist"); \ + }; \ + Write-Host "Installed to $dest"; \ Write-Host "Restart Tabularis (or toggle the plugin in Settings) to pick up changes." -# Remove the installed plugin. +# Remove the installed plugin from the same host-defined directory. [linux] uninstall: - rm -rf ~/.local/share/tabularis/plugins/sqlserver + rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver" [macos] uninstall: - rm -rf "$HOME/Library/Application Support/com.debba.tabularis/plugins/sqlserver" + rm -rf "$HOME/Library/Application Support/tabularis/plugins/sqlserver" [windows] uninstall: - $dest = Join-Path $env:APPDATA "debba\tabularis\data\plugins\sqlserver" + $dest = Join-Path $env:APPDATA "tabularis\plugins\sqlserver"; \ if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } diff --git a/sqlserver-icon.svg b/sqlserver-icon.svg new file mode 100644 index 0000000..f8fecd6 --- /dev/null +++ b/sqlserver-icon.svg @@ -0,0 +1,7 @@ + + Microsoft SQL Server + White database cylinder on a SQL Server red rounded square + + + + diff --git a/src/connection.rs b/src/connection.rs new file mode 100644 index 0000000..8667c77 --- /dev/null +++ b/src/connection.rs @@ -0,0 +1,810 @@ +//! SQL Server connection-string parsing and reconciliation. +//! +//! Tabularis may send discrete connection fields, a connection string, or a +//! mixture of both. Connection-string values are authoritative, while +//! discrete values fill fields omitted by the string. Supplying two different +//! values for the same field is rejected instead of choosing one silently. + +use crate::models::{ConnectionParams, DatabaseSelection}; + +const CUSTOM_CA_ERROR: &str = + "SQL Server custom CA files are not supported; use verify-full with the system trust store"; + +#[derive(Debug, Default, PartialEq)] +struct ParsedConnectionString { + host: Option, + port: Option, + username: Option, + password: Option, + database: Option, + ssl_mode: Option, + ssl_ca: Option, + ssl_cert: Option, + ssl_key: Option, + encrypt: Option, + trust_server_certificate: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EncryptSetting { + Disabled, + Enabled, + Strict, +} + +/// Return canonical connection fields suitable for both config construction +/// and pool-cache keying. +pub fn resolve_connection_params(params: &ConnectionParams) -> Result { + let mut resolved = params.clone(); + if resolved.driver.trim().is_empty() { + resolved.driver = "sqlserver".into(); + } + resolved.ssl_mode = non_empty(resolved.ssl_mode.take()) + .map(|mode| normalize_ssl_mode(&mode)) + .transpose()?; + + let Some(connection_string) = params + .connection_string + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(resolved); + }; + + let parsed = ParsedConnectionString::parse(connection_string)?; + reconcile_string( + "host", + &mut resolved.host, + parsed.host, + |left, right| left.eq_ignore_ascii_case(right), + false, + )?; + reconcile_value("port", &mut resolved.port, parsed.port)?; + reconcile_string( + "username", + &mut resolved.username, + parsed.username, + str::eq, + false, + )?; + reconcile_string( + "password", + &mut resolved.password, + parsed.password, + str::eq, + true, + )?; + + if let Some(database) = parsed.database { + let discrete = resolved.database.primary().trim(); + if !discrete.is_empty() && discrete != database { + return Err(contradiction("database", discrete, &database, false)); + } + resolved.database = DatabaseSelection::Single(database); + } + + reconcile_string( + "ssl_mode", + &mut resolved.ssl_mode, + parsed.ssl_mode, + str::eq, + false, + )?; + reconcile_string( + "ssl_ca", + &mut resolved.ssl_ca, + parsed.ssl_ca, + str::eq, + false, + )?; + reconcile_string( + "ssl_cert", + &mut resolved.ssl_cert, + parsed.ssl_cert, + str::eq, + false, + )?; + reconcile_string( + "ssl_key", + &mut resolved.ssl_key, + parsed.ssl_key, + str::eq, + false, + )?; + + Ok(resolved) +} + +impl ParsedConnectionString { + fn parse(input: &str) -> Result { + let mut parsed = if starts_with_ignore_ascii_case(input, "sqlserver://") { + Self::parse_url(input)? + } else if input.contains("://") { + return Err( + "invalid SQL Server connection string: URL scheme must be sqlserver".to_string(), + ); + } else { + Self::parse_keywords(input)? + }; + parsed.finish_tls()?; + Ok(parsed) + } + + fn parse_url(input: &str) -> Result { + let rest = &input["sqlserver://".len()..]; + if rest.contains('#') { + return Err( + "invalid SQL Server URL connection string: fragments are not supported".into(), + ); + } + let (authority_and_path, query) = rest.split_once('?').unwrap_or((rest, "")); + let (authority, path) = authority_and_path + .split_once('/') + .map_or((authority_and_path, None), |(authority, path)| { + (authority, Some(path)) + }); + if authority.is_empty() { + return Err("invalid SQL Server URL connection string: host is missing".into()); + } + + let mut parsed = Self::default(); + let host_port = if let Some((userinfo, host_port)) = authority.rsplit_once('@') { + if userinfo.is_empty() { + return Err("invalid SQL Server URL connection string: username is empty".into()); + } + let (username, password) = userinfo + .split_once(':') + .map_or((userinfo, None), |(username, password)| { + (username, Some(password)) + }); + parsed.username = Some(percent_decode(username, false)?); + if let Some(password) = password { + parsed.password = Some(percent_decode(password, false).map_err(|_| { + "invalid percent encoding in SQL Server URL password".to_string() + })?); + } + host_port + } else { + authority + }; + let (host, port) = parse_url_host_port(host_port)?; + parsed.host = Some(percent_decode(&host, false)?); + parsed.port = port; + + if let Some(path) = path.filter(|path| !path.is_empty()) { + if path.contains('/') { + return Err( + "invalid SQL Server URL connection string: database must be one path segment" + .into(), + ); + } + parsed.database = Some(percent_decode(path, false)?); + } + + if !query.is_empty() { + for pair in query.split('&') { + if pair.is_empty() { + continue; + } + let (key, value) = pair.split_once('=').ok_or_else(|| { + format!( + "invalid SQL Server URL connection string: query parameter '{pair}' has no value" + ) + })?; + parsed.apply_keyword(&percent_decode(key, true)?, percent_decode(value, true)?)?; + } + } + Ok(parsed) + } + + fn parse_keywords(input: &str) -> Result { + let pairs = parse_keyword_pairs(input)?; + if pairs.is_empty() { + return Err("invalid SQL Server keyword connection string: no key/value pairs".into()); + } + let mut parsed = Self::default(); + for (key, value) in pairs { + parsed.apply_keyword(&key, value)?; + } + Ok(parsed) + } + + fn apply_keyword(&mut self, key: &str, value: String) -> Result<(), String> { + let canonical = canonical_key(key); + match canonical.as_str() { + "server" | "datasource" | "address" | "addr" | "networkaddress" | "host" => { + let (host, port) = parse_server_value(&value)?; + set_string(&mut self.host, host, "server")?; + if let Some(port) = port { + set_value(&mut self.port, port, "port")?; + } + } + "port" => { + let port = parse_port(&value)?; + set_value(&mut self.port, port, "port")?; + } + "database" | "initialcatalog" => { + set_string(&mut self.database, value, "database")?; + } + "userid" | "uid" | "user" | "username" => { + set_string(&mut self.username, value, "username")?; + } + "password" | "pwd" => set_sensitive_string(&mut self.password, value, "password")?, + "encrypt" => { + let encrypt = parse_encrypt(&value)?; + set_value(&mut self.encrypt, encrypt, "Encrypt")?; + } + "trustservercertificate" => { + let trust = parse_bool("TrustServerCertificate", &value)?; + set_value( + &mut self.trust_server_certificate, + trust, + "TrustServerCertificate", + )?; + } + "sslmode" => { + let mode = normalize_ssl_mode(&value)?; + set_string(&mut self.ssl_mode, mode, "ssl_mode")?; + } + "sslca" | "cafile" | "truststore" | "servercertificate" => { + set_string(&mut self.ssl_ca, value, "ssl_ca")?; + } + "sslcert" | "clientcertificate" => { + set_string(&mut self.ssl_cert, value, "ssl_cert")?; + } + "sslkey" | "clientkey" => set_string(&mut self.ssl_key, value, "ssl_key")?, + "integratedsecurity" | "trustedconnection" => { + if parse_bool(key, &value)? { + return Err( + "SQL Server Integrated Authentication is not supported; use User Id and Password" + .into(), + ); + } + } + "authentication" => { + if !value.eq_ignore_ascii_case("SqlPassword") + && !value.eq_ignore_ascii_case("NotSpecified") + { + return Err(format!( + "SQL Server authentication mode '{value}' is not supported; use SqlPassword" + )); + } + } + // These common client-side options do not change the server, + // credentials, database, or TLS identity represented by a pool. + "driver" + | "applicationname" + | "connecttimeout" + | "connectiontimeout" + | "timeout" + | "multipleactiveresultsets" + | "marsconnection" + | "persistsecurityinfo" + | "pooling" => {} + _ => { + return Err(format!( + "unsupported SQL Server connection string keyword '{key}'" + )); + } + } + Ok(()) + } + + fn finish_tls(&mut self) -> Result<(), String> { + let from_keywords = match (self.encrypt, self.trust_server_certificate) { + (Some(EncryptSetting::Disabled), _) => Some("disable"), + (Some(EncryptSetting::Enabled), Some(true)) => Some("require"), + (Some(EncryptSetting::Enabled), _) => Some("verify-full"), + (Some(EncryptSetting::Strict), Some(true)) => { + return Err( + "invalid SQL Server TLS settings: Encrypt=Strict contradicts TrustServerCertificate=true" + .into(), + ); + } + (Some(EncryptSetting::Strict), _) => Some("verify-full"), + (None, Some(true)) => Some("prefer"), + (None, Some(false)) => Some("verify-full"), + (None, None) => None, + }; + if let Some(mode) = from_keywords { + set_string(&mut self.ssl_mode, mode.to_string(), "ssl_mode")?; + } + Ok(()) + } +} + +fn parse_keyword_pairs(input: &str) -> Result, String> { + let bytes = input.as_bytes(); + let mut index = 0; + let mut pairs = Vec::new(); + + while index < bytes.len() { + while index < bytes.len() && (bytes[index] == b';' || bytes[index].is_ascii_whitespace()) { + index += 1; + } + if index == bytes.len() { + break; + } + + let key_start = index; + while index < bytes.len() && bytes[index] != b'=' && bytes[index] != b';' { + index += 1; + } + if index == bytes.len() || bytes[index] != b'=' { + let segment = input[key_start..index].trim(); + return Err(format!( + "invalid SQL Server keyword connection string: '{segment}' has no '='" + )); + } + let key = input[key_start..index].trim(); + if key.is_empty() { + return Err("invalid SQL Server keyword connection string: empty keyword".into()); + } + index += 1; + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + + let value = if index < bytes.len() && bytes[index] == b'{' { + index += 1; + let mut value = String::new(); + let mut closed = false; + while index < bytes.len() { + if bytes[index] == b'}' { + if index + 1 < bytes.len() && bytes[index + 1] == b'}' { + value.push('}'); + index += 2; + } else { + index += 1; + closed = true; + break; + } + } else { + let character = input[index..] + .chars() + .next() + .expect("index is within the input"); + value.push(character); + index += character.len_utf8(); + } + } + if !closed { + return Err(format!( + "invalid SQL Server keyword connection string: unclosed braced value for '{key}'" + )); + } + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + if index < bytes.len() && bytes[index] != b';' { + return Err(format!( + "invalid SQL Server keyword connection string: unexpected text after braced value for '{key}'" + )); + } + value + } else { + let value_start = index; + while index < bytes.len() && bytes[index] != b';' { + index += 1; + } + input[value_start..index].trim().to_string() + }; + pairs.push((key.to_string(), value)); + if index < bytes.len() { + index += 1; + } + } + + Ok(pairs) +} + +fn parse_url_host_port(value: &str) -> Result<(String, Option), String> { + if let Some(rest) = value.strip_prefix('[') { + let closing = rest.find(']').ok_or_else(|| { + "invalid SQL Server URL connection string: unclosed IPv6 host".to_string() + })?; + let host = &rest[..closing]; + if host.is_empty() { + return Err("invalid SQL Server URL connection string: host is empty".into()); + } + let suffix = &rest[closing + 1..]; + let port = if suffix.is_empty() { + None + } else { + let raw = suffix.strip_prefix(':').ok_or_else(|| { + "invalid SQL Server URL connection string: unexpected text after host".to_string() + })?; + Some(parse_port(raw)?) + }; + return Ok((host.to_string(), port)); + } + + if value.matches(':').count() > 1 { + return Err( + "invalid SQL Server URL connection string: IPv6 hosts must use brackets".into(), + ); + } + let (host, port) = value + .rsplit_once(':') + .map_or((value, None), |(host, port)| (host, Some(port))); + if host.is_empty() { + return Err("invalid SQL Server URL connection string: host is empty".into()); + } + Ok((host.to_string(), port.map(parse_port).transpose()?)) +} + +fn parse_server_value(value: &str) -> Result<(String, Option), String> { + let value = value.trim(); + let value = if starts_with_ignore_ascii_case(value, "tcp:") { + &value[4..] + } else { + value + }; + if value.is_empty() { + return Err("invalid SQL Server connection string: Server is empty".into()); + } + if value.contains('\\') { + return Err( + "SQL Server named instances are not supported; specify Server=host,port".into(), + ); + } + let (host, port) = value + .rsplit_once(',') + .map_or((value, None), |(host, port)| (host.trim(), Some(port))); + if host.is_empty() { + return Err("invalid SQL Server connection string: Server host is empty".into()); + } + Ok((host.to_string(), port.map(parse_port).transpose()?)) +} + +fn parse_port(value: &str) -> Result { + value.trim().parse::().map_err(|_| { + format!( + "invalid SQL Server connection string port '{}'", + value.trim() + ) + }) +} + +fn parse_encrypt(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "true" | "yes" | "on" | "1" | "mandatory" => Ok(EncryptSetting::Enabled), + "false" | "no" | "off" | "0" | "optional" => Ok(EncryptSetting::Disabled), + "strict" => Ok(EncryptSetting::Strict), + _ => Err(format!( + "invalid Encrypt value '{value}'; expected true, false, optional, mandatory, or strict" + )), + } +} + +fn parse_bool(key: &str, value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "true" | "yes" | "on" | "1" => Ok(true), + "false" | "no" | "off" | "0" => Ok(false), + _ => Err(format!( + "invalid {key} value '{value}'; expected true or false" + )), + } +} + +fn normalize_ssl_mode(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "disable" | "disabled" => Ok("disable".into()), + "prefer" | "preferred" => Ok("prefer".into()), + "require" | "required" => Ok("require".into()), + "verify-full" | "verify_identity" => Ok("verify-full".into()), + "verify-ca" | "verify_ca" => Ok("verify-ca".into()), + _ => Err(format!("unsupported SQL Server ssl_mode '{value}'")), + } +} + +fn percent_decode(input: &str, plus_as_space: bool) -> Result { + let bytes = input.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + if index + 2 >= bytes.len() { + return Err(format!( + "invalid percent encoding in SQL Server URL component '{input}'" + )); + } + let high = hex_value(bytes[index + 1]); + let low = hex_value(bytes[index + 2]); + let (Some(high), Some(low)) = (high, low) else { + return Err(format!( + "invalid percent encoding in SQL Server URL component '{input}'" + )); + }; + decoded.push((high << 4) | low); + index += 3; + } + b'+' if plus_as_space => { + decoded.push(b' '); + index += 1; + } + value => { + decoded.push(value); + index += 1; + } + } + } + String::from_utf8(decoded) + .map_err(|_| "SQL Server URL contains percent-encoded non-UTF-8 data".to_string()) +} + +fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +fn canonical_key(key: &str) -> String { + key.chars() + .filter(|character| !character.is_ascii_whitespace() && !matches!(character, '_' | '-')) + .flat_map(char::to_lowercase) + .collect() +} + +fn starts_with_ignore_ascii_case(value: &str, prefix: &str) -> bool { + value + .get(..prefix.len()) + .is_some_and(|start| start.eq_ignore_ascii_case(prefix)) +} + +fn set_string(slot: &mut Option, value: String, field: &str) -> Result<(), String> { + if let Some(existing) = slot { + if existing != &value { + return Err(format!( + "SQL Server connection string specifies conflicting {field} values '{existing}' and '{value}'" + )); + } + } else { + *slot = Some(value); + } + Ok(()) +} + +fn set_sensitive_string( + slot: &mut Option, + value: String, + field: &str, +) -> Result<(), String> { + if slot.as_ref().is_some_and(|existing| existing != &value) { + return Err(format!( + "SQL Server connection string specifies conflicting {field} values '' and ''" + )); + } + if slot.is_none() { + *slot = Some(value); + } + Ok(()) +} + +fn set_value(slot: &mut Option, value: T, field: &str) -> Result<(), String> +where + T: Copy + PartialEq + std::fmt::Debug, +{ + if let Some(existing) = slot { + if existing != &value { + return Err(format!( + "SQL Server connection string specifies conflicting {field} values '{existing:?}' and '{value:?}'" + )); + } + } else { + *slot = Some(value); + } + Ok(()) +} + +fn reconcile_string( + field: &str, + discrete: &mut Option, + from_string: Option, + equals: impl Fn(&str, &str) -> bool, + sensitive: bool, +) -> Result<(), String> { + let Some(from_string) = from_string else { + *discrete = non_empty(discrete.take()); + return Ok(()); + }; + if let Some(value) = discrete.as_deref().map(str::trim).filter(|v| !v.is_empty()) { + if !equals(value, &from_string) { + return Err(contradiction(field, value, &from_string, sensitive)); + } + } + *discrete = Some(from_string); + Ok(()) +} + +fn reconcile_value( + field: &str, + discrete: &mut Option, + from_string: Option, +) -> Result<(), String> +where + T: Copy + PartialEq + std::fmt::Display, +{ + if let Some(from_string) = from_string { + if let Some(discrete) = discrete { + if *discrete != from_string { + return Err(contradiction( + field, + &discrete.to_string(), + &from_string.to_string(), + false, + )); + } + } + *discrete = Some(from_string); + } + Ok(()) +} + +fn contradiction(field: &str, discrete: &str, from_string: &str, sensitive: bool) -> String { + let (discrete, from_string) = if sensitive { + ("", "") + } else { + (discrete, from_string) + }; + format!( + "connection parameter '{field}' contradicts the connection string: discrete value '{discrete}', connection-string value '{from_string}'" + ) +} + +fn non_empty(value: Option) -> Option { + value.filter(|value| !value.trim().is_empty()) +} + +pub fn custom_ca_error() -> &'static str { + CUSTOM_CA_ERROR +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params(connection_string: &str) -> ConnectionParams { + ConnectionParams { + connection_string: Some(connection_string.into()), + ..Default::default() + } + } + + #[test] + fn parses_url_with_percent_encoded_credentials_and_tls_query() { + let resolved = resolve_connection_params(¶ms( + "sqlserver://user:p%40ss%3Aword@db.example:1444/catalog%20name?Encrypt=true&TrustServerCertificate=true", + )) + .unwrap(); + + assert_eq!(resolved.driver, "sqlserver"); + assert_eq!(resolved.host.as_deref(), Some("db.example")); + assert_eq!(resolved.port, Some(1444)); + assert_eq!(resolved.username.as_deref(), Some("user")); + assert_eq!(resolved.password.as_deref(), Some("p@ss:word")); + assert_eq!(resolved.database.primary(), "catalog name"); + assert_eq!(resolved.ssl_mode.as_deref(), Some("require")); + } + + #[test] + fn url_allows_omitted_port_and_database_and_uses_discrete_fallbacks() { + let mut input = params("sqlserver://url-user:url-password@db.example"); + input.port = Some(1433); + input.database = DatabaseSelection::Single("fallback_db".into()); + input.ssl_mode = Some("required".into()); + + let resolved = resolve_connection_params(&input).unwrap(); + assert_eq!(resolved.host.as_deref(), Some("db.example")); + assert_eq!(resolved.port, Some(1433)); + assert_eq!(resolved.database.primary(), "fallback_db"); + assert_eq!(resolved.ssl_mode.as_deref(), Some("require")); + } + + #[test] + fn parses_case_insensitive_keyword_aliases_and_braced_semicolon() { + let resolved = resolve_connection_params(¶ms( + "Data Source=tcp:db.example,1444;Initial Catalog=app;UID=sa;PWD={p;a}}ss};Encrypt=YES;Trust Server Certificate=TRUE;", + )) + .unwrap(); + + assert_eq!(resolved.host.as_deref(), Some("db.example")); + assert_eq!(resolved.port, Some(1444)); + assert_eq!(resolved.database.primary(), "app"); + assert_eq!(resolved.username.as_deref(), Some("sa")); + assert_eq!(resolved.password.as_deref(), Some("p;a}ss")); + assert_eq!(resolved.ssl_mode.as_deref(), Some("require")); + } + + #[test] + fn tls_keywords_map_to_canonical_ssl_modes() { + let cases = [ + ("Encrypt=false", "disable"), + ("Encrypt=true;TrustServerCertificate=true", "require"), + ("Encrypt=true;TrustServerCertificate=false", "verify-full"), + ("Encrypt=strict", "verify-full"), + ("TrustServerCertificate=true", "prefer"), + ]; + for (connection_string, expected) in cases { + let mut input = params(connection_string); + input.host = Some("localhost".into()); + let resolved = resolve_connection_params(&input).unwrap(); + assert_eq!(resolved.ssl_mode.as_deref(), Some(expected)); + } + } + + #[test] + fn equal_discrete_values_are_accepted_and_missing_values_fill_in() { + let mut input = params("Server=db.example,1433;Database=app;User Id=sa;Password=secret"); + input.host = Some("DB.EXAMPLE".into()); + input.port = Some(1433); + input.username = Some("sa".into()); + input.password = Some("secret".into()); + input.ssl_mode = Some("require".into()); + + let resolved = resolve_connection_params(&input).unwrap(); + assert_eq!(resolved.database.primary(), "app"); + assert_eq!(resolved.ssl_mode.as_deref(), Some("require")); + } + + #[test] + fn contradictory_values_name_both_sources() { + let mut input = params("Server=from-string;Database=app"); + input.host = Some("from-discrete".into()); + + let error = resolve_connection_params(&input).unwrap_err(); + assert!(error.contains("host")); + assert!(error.contains("from-discrete")); + assert!(error.contains("from-string")); + } + + #[test] + fn password_parse_errors_never_echo_credentials() { + for (connection_string, secret) in [ + ( + "Server=localhost;Password=FirstSecret!;Password=SecondSecret!", + "FirstSecret!", + ), + ( + "sqlserver://sa:Malformed%ZZSecret@localhost/master", + "Malformed", + ), + ] { + let error = resolve_connection_params(¶ms(connection_string)).unwrap_err(); + assert!(!error.contains(secret), "{error}"); + assert!(!error.contains(connection_string), "{error}"); + } + } + + #[test] + fn malformed_connection_strings_are_rejected() { + for connection_string in [ + "not-a-connection-string", + "postgres://sa:secret@localhost/master", + "sqlserver://sa:bad%ZZ@localhost/master", + "sqlserver://sa:secret@localhost:not-a-port/master", + "Server=localhost;Password={unclosed", + "Server=localhost;Encrypt=perhaps", + "Server=localhost;UnknownSetting=true", + ] { + assert!( + resolve_connection_params(¶ms(connection_string)).is_err(), + "expected malformed input to fail: {connection_string}" + ); + } + } + + #[test] + fn custom_ca_keyword_is_preserved_for_the_config_rejection_path() { + let resolved = + resolve_connection_params(¶ms("Server=localhost;SslCa=/tmp/custom-ca.pem")) + .unwrap(); + assert_eq!(resolved.ssl_ca.as_deref(), Some("/tmp/custom-ca.pem")); + assert_eq!(custom_ca_error(), CUSTOM_CA_ERROR); + } +} diff --git a/src/driver/blob.rs b/src/driver/blob.rs new file mode 100644 index 0000000..2b13386 --- /dev/null +++ b/src/driver/blob.rs @@ -0,0 +1,192 @@ +//! Binary-column export and bounded preview support. +//! +//! SQL Server's `binary`, `varbinary`, and legacy `image` types are user BLOB +//! data. `rowversion` and its deprecated `timestamp` synonym are deliberately +//! rejected: their eight bytes are generated by SQL Server as concurrency +//! tokens, not user-owned file content. + +use base64::Engine as _; +use mssql_tiberius_bridge::ToSql; + +use crate::driver::helpers::{bracket_quote, build_pk_where_clause, qualify, value_to_sql_param}; +use crate::driver::{acquire, introspection}; +use crate::models::{ConnectionParams, PkMap}; + +/// Matches Tabularis' host-side default. Newer hosts may forward their +/// configured `max_blob_size` with the preview request; older hosts omit it. +pub const DEFAULT_MAX_BLOB_SIZE: u64 = 100 * 1024 * 1024; + +pub async fn fetch_blob_bytes( + params: &ConnectionParams, + table: &str, + col_name: &str, + pk_map: &PkMap, + schema: Option<&str>, + max_preview_size: Option, +) -> Result, String> { + let mut conn = acquire(params).await?; + let columns = introspection::get_columns(&mut conn, table, schema).await?; + let column = columns + .iter() + .find(|column| column.name == col_name) + .ok_or_else(|| { + format!( + "SQL Server BLOB column {}.{} was not found", + qualify(schema, table), + bracket_quote(col_name) + ) + })?; + validate_blob_data_type(&column.data_type)?; + + let mut primary_keys: Vec<_> = pk_map.iter().collect(); + primary_keys.sort_by_key(|&(column, _)| column); + let pk_columns: Vec = primary_keys + .iter() + .map(|(column, _)| (*column).clone()) + .collect(); + let first_pk_marker = if max_preview_size.is_some() { 2 } else { 1 }; + let predicate = build_pk_where_clause(&pk_columns, first_pk_marker).ok_or_else(|| { + "SQL Server: BLOB lookup requires at least one primary-key column".to_string() + })?; + + let mut owned_params: Vec> = Vec::with_capacity(primary_keys.len() + 1); + let sql = if let Some(max_size) = max_preview_size { + let sql_limit = i64::try_from(max_size).unwrap_or(i64::MAX); + owned_params.push(Box::new(sql_limit)); + format!( + "SELECT CAST(DATALENGTH({column}) AS BIGINT), \ + CASE WHEN CAST(DATALENGTH({column}) AS BIGINT) <= @P1 \ + THEN CONVERT(VARBINARY(MAX), {column}) END \ + FROM {table} WHERE {predicate}", + column = bracket_quote(col_name), + table = qualify(schema, table), + ) + } else { + format!( + "SELECT CONVERT(VARBINARY(MAX), {}) FROM {} WHERE {}", + bracket_quote(col_name), + qualify(schema, table), + predicate, + ) + }; + + for (_, value) in primary_keys { + owned_params.push(value_to_sql_param(value)?); + } + let bound: Vec<&dyn ToSql> = owned_params.iter().map(|value| value.as_ref()).collect(); + let rows = conn + .query(sql, &bound) + .await + .map_err(|error| format!("Failed to fetch SQL Server BLOB: {error}"))? + .into_first_result(); + let row = rows + .first() + .ok_or_else(|| "SQL Server BLOB row was not found".to_string())?; + + if let Some(max_size) = max_preview_size { + let size = row + .try_get::(0) + .map_err(|error| format!("Failed to read SQL Server BLOB size: {error}"))? + .ok_or_else(|| "SQL Server BLOB value is NULL".to_string())?; + let size = u64::try_from(size) + .map_err(|_| format!("SQL Server returned an invalid BLOB size: {size}"))?; + ensure_preview_size(size, max_size)?; + return row + .try_get::<&[u8], _>(1) + .map_err(|error| format!("Failed to read SQL Server BLOB value: {error}"))? + .map(<[u8]>::to_vec) + .ok_or_else(|| "SQL Server BLOB value is NULL".to_string()); + } + + row.try_get::<&[u8], _>(0) + .map_err(|error| format!("Failed to read SQL Server BLOB value: {error}"))? + .map(<[u8]>::to_vec) + .ok_or_else(|| "SQL Server BLOB value is NULL".to_string()) +} + +pub fn encode_blob_full(data: &[u8], max_preview_size: u64) -> Result { + ensure_preview_size(data.len() as u64, max_preview_size)?; + let mime_type = infer::get(data) + .map(|kind| kind.mime_type()) + .unwrap_or("application/octet-stream"); + let encoded = base64::engine::general_purpose::STANDARD.encode(data); + Ok(format!("BLOB:{}:{mime_type}:{encoded}", data.len())) +} + +/// Decode the internal BLOB value sent back by the host during row editing. +/// Returns `Ok(None)` for ordinary strings so callers can bind them as text. +pub fn decode_blob_wire(value: &str) -> Result>, String> { + if !value.starts_with("BLOB:") { + return Ok(None); + } + let mut fields = value.splitn(4, ':'); + let _prefix = fields.next(); + let declared_size = fields + .next() + .and_then(|size| size.parse::().ok()) + .ok_or_else(|| "SQL Server BLOB value has an invalid size".to_string())?; + let _mime_type = fields + .next() + .filter(|mime| !mime.is_empty()) + .ok_or_else(|| "SQL Server BLOB value has an empty MIME type".to_string())?; + let payload = fields + .next() + .ok_or_else(|| "SQL Server BLOB value has no base64 payload".to_string())?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(payload) + .map_err(|error| format!("SQL Server BLOB value has invalid base64: {error}"))?; + if bytes.len() != declared_size { + return Err(format!( + "SQL Server BLOB value declares {declared_size} bytes but contains {} bytes", + bytes.len() + )); + } + Ok(Some(bytes)) +} + +pub fn validate_writable_file_path(file_path: &str) -> Result<(), String> { + if file_path.trim().is_empty() { + return Err("file_path must not be empty".to_string()); + } + let path = std::path::Path::new(file_path); + if path.is_dir() { + return Err(format!( + "file_path '{file_path}' is a directory, not a file" + )); + } + match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() && !parent.is_dir() => Err(format!( + "file_path '{file_path}': parent directory '{}' does not exist", + parent.display() + )), + _ => Ok(()), + } +} + +fn ensure_preview_size(size: u64, max_preview_size: u64) -> Result<(), String> { + if size > max_preview_size { + return Err(format!( + "SQL Server BLOB preview is {size} bytes, exceeding max_blob_size of \ + {max_preview_size} bytes; export the value to a file instead" + )); + } + Ok(()) +} + +fn validate_blob_data_type(data_type: &str) -> Result<(), String> { + let normalized = data_type.trim().to_ascii_uppercase(); + let base_type = normalized.split('(').next().unwrap_or(normalized.as_str()); + match base_type { + "BINARY" | "VARBINARY" | "IMAGE" => Ok(()), + "ROWVERSION" | "TIMESTAMP" => Err(format!( + "SQL Server {base_type} is a server-generated concurrency token, not user BLOB data" + )), + _ => Err(format!( + "SQL Server column type {data_type} is not supported for BLOB export or preview" + )), + } +} + +#[cfg(test)] +#[path = "blob/tests.rs"] +mod tests; diff --git a/src/driver/blob/tests.rs b/src/driver/blob/tests.rs new file mode 100644 index 0000000..bab110c --- /dev/null +++ b/src/driver/blob/tests.rs @@ -0,0 +1,69 @@ +use super::*; + +#[test] +fn wire_format_contains_size_mime_and_base64() { + let bytes = [0xCA, 0xFE, 0xBA, 0xBE]; + assert_eq!( + encode_blob_full(&bytes, 4).unwrap(), + "BLOB:4:application/octet-stream:yv66vg==" + ); +} + +#[test] +fn wire_format_decodes_for_row_editing_and_checks_declared_size() { + assert_eq!( + decode_blob_wire("BLOB:4:application/octet-stream:yv66vg==").unwrap(), + Some(vec![0xCA, 0xFE, 0xBA, 0xBE]) + ); + assert_eq!(decode_blob_wire("ordinary text").unwrap(), None); + assert!(decode_blob_wire("BLOB:3:application/octet-stream:yv66vg==") + .unwrap_err() + .contains("declares 3 bytes")); +} + +#[test] +fn wire_format_sniffs_png_magic_bytes() { + let png_signature = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + let wire = encode_blob_full(&png_signature, 8).unwrap(); + assert!(wire.starts_with("BLOB:8:image/png:")); +} + +#[test] +fn preview_size_ceiling_accepts_exact_limit_and_rejects_larger_value() { + assert!(encode_blob_full(&[1, 2, 3, 4], 4).is_ok()); + + let error = encode_blob_full(&[1, 2, 3, 4], 3).unwrap_err(); + assert!(error.contains("4 bytes")); + assert!(error.contains("max_blob_size of 3 bytes")); +} + +#[test] +fn binary_varbinary_and_image_are_user_blob_types() { + for data_type in ["binary(8)", "VARBINARY(MAX)", "image"] { + assert!( + validate_blob_data_type(data_type).is_ok(), + "rejected {data_type}" + ); + } +} + +#[test] +fn rowversion_and_timestamp_are_not_offered_as_blobs() { + for data_type in ["ROWVERSION", "timestamp"] { + let error = validate_blob_data_type(data_type).unwrap_err(); + assert!(error.contains("concurrency token")); + } +} + +#[test] +fn writable_path_validation_rejects_empty_directory_and_missing_parent() { + assert!(validate_writable_file_path("").is_err()); + assert!(validate_writable_file_path("/tmp").is_err()); + assert!(validate_writable_file_path("/this-directory-must-not-exist-ss012/out.bin").is_err()); +} + +#[test] +fn writable_path_validation_accepts_existing_parent_and_bare_filename() { + assert!(validate_writable_file_path("/tmp/ss012-output.bin").is_ok()); + assert!(validate_writable_file_path("ss012-output.bin").is_ok()); +} diff --git a/src/driver/error.rs b/src/driver/error.rs new file mode 100644 index 0000000..a68eac2 --- /dev/null +++ b/src/driver/error.rs @@ -0,0 +1,259 @@ +//! User-facing SQL Server error formatting and credential redaction. + +use deadpool::managed::{PoolError, TimeoutType}; +use mssql_tds::error::{Error as TdsError, SqlErrorInfo}; +use mssql_tiberius_bridge::Error as BridgeError; + +use crate::models::ConnectionParams; + +/// Format a bridge error without losing SQL Server's structured error token. +pub fn format_bridge_error(error: &BridgeError, ssl_mode: Option<&str>) -> String { + match error { + BridgeError::Tds(error) => format_tds_error(error, ssl_mode), + BridgeError::Conversion(message) + if message + .trim_start() + .to_ascii_lowercase() + .starts_with("query timed out") => + { + format!("SQL Server timeout: {message}") + } + BridgeError::ColumnNotFound(_) + | BridgeError::ColumnIndexOutOfBounds { .. } + | BridgeError::Conversion(_) => format!("SQL Server data conversion failure: {error}"), + BridgeError::Pool(message) => format!("SQL Server connection-pool failure: {message}"), + } +} + +/// Format errors from the underlying Microsoft TDS client. +pub fn format_tds_error(error: &TdsError, ssl_mode: Option<&str>) -> String { + match error { + TdsError::SqlServerError { errors } => format_server_errors(errors), + TdsError::TlsError(_) + | TdsError::TlsHandshakeError { .. } + | TdsError::CertificateNotFound { .. } + | TdsError::InvalidCertificateFormat { .. } + | TdsError::CertificateExpired + | TdsError::CertificateMismatch + | TdsError::CertificateFileIoError { .. } + | TdsError::NoServerCertificate => format_tls_error(error, ssl_mode), + TdsError::TimeoutError(_) => format!("SQL Server timeout: {error}"), + TdsError::Io(_) + | TdsError::ConnectionError(_) + | TdsError::ConnectionClosed(_) + | TdsError::Redirection { .. } + | TdsError::SessionRecoveryFailed { .. } + | TdsError::SessionNotRecoverable(_) + | TdsError::ReconnectionValidationFailed(_) => { + format!("SQL Server connection failure: {error}") + } + TdsError::Security(_) => format!("SQL Server authentication failure: {error}"), + TdsError::ProtocolError(_) + | TdsError::OperationCancelledError(_) + | TdsError::UsageError(_) + | TdsError::ImplementationError(_) + | TdsError::UnimplementedFeature { .. } + | TdsError::TypeConversionError(_) + | TdsError::UnsupportedEncoding { .. } + | TdsError::BulkCopyError(_) => format!("SQL Server driver failure: {error}"), + } +} + +/// Format a deadpool checkout failure, preserving backend error structure. +pub fn format_pool_error(error: &PoolError, ssl_mode: Option<&str>) -> String { + match error { + PoolError::Backend(error) => format_bridge_error(error, ssl_mode), + PoolError::Timeout(kind) => { + let operation = match kind { + TimeoutType::Wait => "waiting for a pooled connection", + TimeoutType::Create => "opening a connection", + TimeoutType::Recycle => "resetting a pooled connection", + }; + format!("SQL Server timeout while {operation}") + } + PoolError::Closed => "SQL Server connection-pool failure: pool is closed".to_string(), + PoolError::NoRuntimeSpecified => { + "SQL Server connection-pool failure: no async runtime is configured".to_string() + } + PoolError::PostCreateHook(_) => { + "SQL Server connection-pool failure: post-create validation failed".to_string() + } + } +} + +/// Remove connection secrets defensively before an error crosses JSON-RPC. +pub fn redact_connection_secrets(message: String, params: &ConnectionParams) -> String { + let mut redacted = message; + for secret in [ + params.password.as_deref(), + params.connection_string.as_deref(), + ] + .into_iter() + .flatten() + .filter(|secret| !secret.is_empty()) + { + redacted = redacted.replace(secret, ""); + } + redacted +} + +/// A server, timeout, or transport failure can leave unread TDS packets +/// behind. Such a connection must be discarded instead of reset and reused. +pub fn bridge_error_requires_discard(error: &BridgeError) -> bool { + match error { + BridgeError::Conversion(message) => message + .trim_start() + .to_ascii_lowercase() + .starts_with("query timed out"), + BridgeError::Tds(error) => matches!( + error, + TdsError::Io(_) + | TdsError::ConnectionError(_) + | TdsError::SqlServerError { .. } + | TdsError::ConnectionClosed(_) + | TdsError::ProtocolError(_) + | TdsError::TlsError(_) + | TdsError::TlsHandshakeError { .. } + | TdsError::TimeoutError(_) + | TdsError::OperationCancelledError(_) + | TdsError::SessionRecoveryFailed { .. } + | TdsError::SessionNotRecoverable(_) + | TdsError::ReconnectionValidationFailed(_) + ), + _ => false, + } +} + +fn format_server_errors(errors: &[SqlErrorInfo]) -> String { + if errors.is_empty() { + return "SQL Server error: no server error details were returned".to_string(); + } + errors + .iter() + .map(format_server_error) + .collect::>() + .join("; ") +} + +fn format_server_error(error: &SqlErrorInfo) -> String { + let mut details = vec![ + server_error_category(error.number).to_string(), + format!("severity {}", error.class), + format!("state {}", error.state), + ]; + if let Some(procedure) = error.proc_name.as_deref().filter(|name| !name.is_empty()) { + details.push(format!("procedure '{procedure}'")); + } + if let Some(line) = error.line_number.filter(|line| *line > 0) { + details.push(format!("line {line}")); + } + format!( + "SQL Server error {}: {} [{}]", + error.number, + error.message.trim(), + details.join("; ") + ) +} + +fn server_error_category(number: u32) -> &'static str { + match number { + 1205 => "deadlock victim", + 18452 | 18456 | 4060 => "authentication failure", + 229 | 230 | 262 | 300 | 916 => "permission denial", + 515 | 547 | 1505 | 2601 | 2627 => "constraint violation", + 1222 => "timeout", + 102 | 105 | 156 => "syntax error", + _ => "statement failure", + } +} + +fn format_tls_error(error: &TdsError, ssl_mode: Option<&str>) -> String { + let ssl_mode = ssl_mode.unwrap_or("prefer"); + let advice = match ssl_mode { + "verify-full" | "verify_identity" => { + "install a certificate trusted by the system trust store, or try ssl_mode 'require' for a self-signed development server" + } + "disable" | "disabled" => { + "try ssl_mode 'require' if the server requires encryption" + } + _ => { + "check the server TLS configuration, or try ssl_mode 'disable' only on a trusted development network" + } + }; + format!( + "SQL Server TLS negotiation failure with ssl_mode '{ssl_mode}': {error}. To recover, {advice}." + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::DatabaseSelection; + + fn server_error(number: u32, line: i32) -> BridgeError { + BridgeError::Tds(TdsError::from_sql_error(SqlErrorInfo { + message: "fixture message".into(), + state: 2, + class: 16, + number, + server_name: Some("fixture-server".into()), + proc_name: Some("fixture_proc".into()), + line_number: Some(line), + })) + } + + #[test] + fn structured_server_error_leads_with_number_text_and_keeps_details() { + let message = format_bridge_error(&server_error(102, 7), None); + assert!(message.starts_with("SQL Server error 102: fixture message")); + assert!(message.contains("syntax error")); + assert!(message.contains("severity 16")); + assert!(message.contains("state 2")); + assert!(message.contains("procedure 'fixture_proc'")); + assert!(message.contains("line 7")); + } + + #[test] + fn server_error_categories_are_distinct() { + for (number, category) in [ + (18456, "authentication failure"), + (229, "permission denial"), + (2627, "constraint violation"), + (1205, "deadlock victim"), + (1222, "timeout"), + (102, "syntax error"), + ] { + assert!( + format_bridge_error(&server_error(number, 1), None).contains(category), + "error {number}" + ); + } + } + + #[test] + fn tls_error_names_mode_and_action() { + let error = BridgeError::Tds(TdsError::CertificateMismatch); + let message = format_bridge_error(&error, Some("verify-full")); + assert!(message.contains("TLS negotiation failure")); + assert!(message.contains("ssl_mode 'verify-full'")); + assert!(message.contains("ssl_mode 'require'")); + } + + #[test] + fn redaction_removes_password_and_connection_string() { + let connection_string = "Server=db;User Id=sa;Password=NeverExposeThis!;Encrypt=true"; + let params = ConnectionParams { + password: Some("NeverExposeThis!".into()), + connection_string: Some(connection_string.into()), + database: DatabaseSelection::Single("master".into()), + ..Default::default() + }; + let message = redact_connection_secrets( + format!("failed with NeverExposeThis! from {connection_string}"), + ¶ms, + ); + assert!(!message.contains("NeverExposeThis!")); + assert!(!message.contains(connection_string)); + assert!(message.contains("")); + } +} diff --git a/src/driver/extract/mod.rs b/src/driver/extract/mod.rs index 06545df..fb04f7d 100644 --- a/src/driver/extract/mod.rs +++ b/src/driver/extract/mod.rs @@ -15,7 +15,6 @@ use mssql_tds::datatypes::sql_json::SqlJson; use mssql_tds::datatypes::sql_vector::SqlVector; use mssql_tds::datatypes::sqldatatypes::TdsDataType; use mssql_tiberius_bridge::{ColumnType, Row}; -use rust_decimal::Decimal; use serde_json::Value; use uuid::Uuid; @@ -30,6 +29,11 @@ pub fn normalized_column_type(tds_type: TdsDataType, byte_length: usize) -> Colu TdsDataType::BigChar => ColumnType::Char, TdsDataType::BigBinary => ColumnType::Binary, TdsDataType::DateTimeN if byte_length == 4 => ColumnType::Datetime4, + // SQL Server's hierarchyid, geography, and geometry arrive as UDT + // payloads. The bridge decodes those payloads into ColumnValues::Bytes + // but has no UDT ColumnType, so expose their lossless serialized form + // through the same BLOB wire representation as binary columns. + TdsDataType::Udt => ColumnType::BigVarBin, other => ColumnType::from_tds_with_length(other, byte_length), } } @@ -89,12 +93,23 @@ pub fn extract_value_as(row: &Row, idx: usize, column_type: ColumnType) -> Resul }, // Temporal - ColumnType::Datetime | ColumnType::Datetime4 => { - match row.try_get::(idx) { - Ok(Some(v)) => Value::String(temporal::format_datetime(&v)), - _ => Value::Null, + ColumnType::Datetime => match row.raw_value(idx) { + Some(ColumnValues::DateTime(value)) => { + temporal::format_legacy_datetime(value.days, value.time) + .map(Value::String) + .ok_or_else(|| "SQL Server returned an invalid DATETIME value".to_string())? } - } + Some(ColumnValues::Null) | None => Value::Null, + Some(other) => { + return Err(format!( + "SQL Server returned {other:?} for a DATETIME column" + )) + } + }, + ColumnType::Datetime4 => match row.try_get::(idx) { + Ok(Some(v)) => Value::String(temporal::format_datetime(&v)), + _ => Value::Null, + }, ColumnType::Datetime2 => match row.try_get::(idx) { Ok(Some(v)) => Value::String(temporal::format_datetime(&v)), _ => Value::Null, @@ -136,8 +151,8 @@ pub fn extract_value_as(row: &Row, idx: usize, column_type: ColumnType) -> Resul _ => Value::Null, }, - // sql_variant remains a best-effort textual fallback. - ColumnType::Ssvariant => read_string(row, idx), + // sql_variant carries the contained value's concrete TDS variant. + ColumnType::Ssvariant => return read_variant(row, idx), }; Ok(value) } @@ -166,21 +181,15 @@ fn read_numeric_as_string(row: &Row, idx: usize) -> Result { } fn numeric_value_to_json(value: &ColumnValues) -> Result { - let decimal = match value { + let raw = match value { ColumnValues::Null => return Ok(Value::Null), - ColumnValues::Decimal(parts) | ColumnValues::Numeric(parts) => { - let raw = parts.to_string(); - raw.parse::().map_err(|error| { - format!( - "SQL Server decimal({}, {}) value {raw} exceeds rust_decimal's exact range: {error}", - parts.precision, parts.scale - ) - })? - } - ColumnValues::SmallMoney(money) => Decimal::new(i64::from(money.int_val), 4), + // DecimalParts already preserves all 38 SQL Server digits. Parsing it + // through rust_decimal first imposed an unrelated 29-digit ceiling. + ColumnValues::Decimal(parts) | ColumnValues::Numeric(parts) => parts.to_string(), + ColumnValues::SmallMoney(money) => fixed_scale_4(i64::from(money.int_val)), ColumnValues::Money(money) => { let raw = (i64::from(money.msb_part) << 32) | i64::from(money.lsb_part as u32); - Decimal::new(raw, 4) + fixed_scale_4(raw) } other => { return Err(format!( @@ -188,9 +197,84 @@ fn numeric_value_to_json(value: &ColumnValues) -> Result { )) } }; - Ok(Value::String(normalize_decimal_string( - &decimal.to_string(), - ))) + Ok(Value::String(normalize_decimal_string(&raw))) +} + +fn fixed_scale_4(raw: i64) -> String { + let raw = i128::from(raw); + let sign = if raw < 0 { "-" } else { "" }; + let magnitude = raw.abs(); + format!("{sign}{}.{:04}", magnitude / 10_000, magnitude % 10_000) +} + +fn read_variant(row: &Row, idx: usize) -> Result { + let value = row + .raw_value(idx) + .ok_or_else(|| format!("SQL Server sql_variant column index {idx} is out of bounds"))?; + let json = match value { + ColumnValues::Null => Value::Null, + ColumnValues::TinyInt(value) => Value::from(*value), + ColumnValues::SmallInt(value) => Value::from(*value), + ColumnValues::Int(value) => Value::from(*value), + ColumnValues::BigInt(value) => i64_to_json(*value), + ColumnValues::Real(value) => f64_to_json(f64::from(*value)), + ColumnValues::Float(value) => f64_to_json(*value), + ColumnValues::Decimal(_) + | ColumnValues::Numeric(_) + | ColumnValues::SmallMoney(_) + | ColumnValues::Money(_) => return numeric_value_to_json(value), + ColumnValues::Bit(value) => Value::Bool(*value), + ColumnValues::String(value) => Value::String(value.to_utf8_string()), + ColumnValues::Bytes(value) => binary_to_json(value), + ColumnValues::Uuid(value) => Value::String(value.to_string()), + ColumnValues::Xml(value) => Value::String(value.as_string()), + ColumnValues::Json(value) => json_to_json(value)?, + ColumnValues::Vector(value) => vector_to_json(value), + ColumnValues::DateTime(value) => temporal::format_legacy_datetime(value.days, value.time) + .map(Value::String) + .ok_or_else(|| "SQL Server returned an invalid sql_variant DATETIME".to_string())?, + ColumnValues::SmallDateTime(_) => match row.try_get::(idx) { + Ok(Some(value)) => Value::String(temporal::format_datetime(&value)), + error => { + return Err(format!( + "Failed to decode SQL Server sql_variant smalldatetime: {error:?}" + )) + } + }, + ColumnValues::DateTime2(_) => match row.try_get::(idx) { + Ok(Some(value)) => Value::String(temporal::format_datetime(&value)), + error => { + return Err(format!( + "Failed to decode SQL Server sql_variant datetime2: {error:?}" + )) + } + }, + ColumnValues::DateTimeOffset(_) => match row.try_get::, _>(idx) { + Ok(Some(value)) => Value::String(temporal::format_datetime_offset(&value)), + error => { + return Err(format!( + "Failed to decode SQL Server sql_variant datetimeoffset: {error:?}" + )) + } + }, + ColumnValues::Date(_) => match row.try_get::(idx) { + Ok(Some(value)) => Value::String(temporal::format_date(&value)), + error => { + return Err(format!( + "Failed to decode SQL Server sql_variant date: {error:?}" + )) + } + }, + ColumnValues::Time(_) => match row.try_get::(idx) { + Ok(Some(value)) => Value::String(temporal::format_time(&value)), + error => { + return Err(format!( + "Failed to decode SQL Server sql_variant time: {error:?}" + )) + } + }, + }; + Ok(json) } fn json_to_json(json: &SqlJson) -> Result { @@ -211,16 +295,22 @@ fn vector_to_json(vector: &SqlVector) -> Value { } fn read_binary_as_base64(row: &Row, idx: usize) -> Value { - use base64::Engine as _; match row.try_get::<&[u8], _>(idx) { - Ok(Some(bytes)) => Value::String(format!( - "base64:{}", - base64::engine::general_purpose::STANDARD.encode(bytes) - )), + Ok(Some(bytes)) => binary_to_json(bytes), _ => Value::Null, } } +fn binary_to_json(bytes: &[u8]) -> Value { + // Query grids and the dedicated preview RPC share the host's BLOB wire + // shape. The grid path has already received the bytes from SQL Server, so + // it has no useful pre-transfer size limit to apply here. + Value::String( + crate::driver::blob::encode_blob_full(bytes, u64::MAX) + .expect("u64::MAX cannot reject an in-memory BLOB"), + ) +} + // --- Pure helpers (testable) --------------------------------------------- /// Convert a `f64` to a JSON number, falling back to string for non-finite diff --git a/src/driver/extract/temporal.rs b/src/driver/extract/temporal.rs index f77ba74..920d36a 100644 --- a/src/driver/extract/temporal.rs +++ b/src/driver/extract/temporal.rs @@ -45,6 +45,19 @@ pub fn format_datetime(dt: &NaiveDateTime) -> String { } } +/// Decode SQL Server's legacy DATETIME ticks using its documented display +/// granularity (.000, .003, or .007 seconds). The bridge truncates 1/300 +/// second ticks to milliseconds, which incorrectly renders .006 and .996. +pub fn format_legacy_datetime(days: i32, ticks: u32) -> Option { + let base = NaiveDate::from_ymd_opt(1900, 1, 1)?; + let date = base.checked_add_signed(chrono::Duration::days(i64::from(days)))?; + let total_millis = (u64::from(ticks) * 10 + 1) / 3; + let seconds = u32::try_from(total_millis / 1_000).ok()?; + let nanos = u32::try_from(total_millis % 1_000).ok()? * 1_000_000; + let time = NaiveTime::from_num_seconds_from_midnight_opt(seconds, nanos)?; + Some(format_datetime(&NaiveDateTime::new(date, time))) +} + /// Format a `DateTime` as RFC3339 with fractional seconds when /// present. `datetimeoffset` is the only SQL Server temporal type that /// carries a zone; we keep the zone explicit so round-tripping is safe. diff --git a/src/driver/extract/temporal/tests.rs b/src/driver/extract/temporal/tests.rs index e87bd09..8df196d 100644 --- a/src/driver/extract/temporal/tests.rs +++ b/src/driver/extract/temporal/tests.rs @@ -63,6 +63,22 @@ fn datetime_with_fraction_trims_trailing_zeros() { ); } +#[test] +fn legacy_datetime_rounds_three_hundredth_second_ticks() { + assert_eq!( + format_legacy_datetime(0, 1).as_deref(), + Some("1900-01-01 00:00:00.003") + ); + assert_eq!( + format_legacy_datetime(0, 2).as_deref(), + Some("1900-01-01 00:00:00.007") + ); + assert_eq!( + format_legacy_datetime(0, 3).as_deref(), + Some("1900-01-01 00:00:00.01") + ); +} + #[test] fn datetime_epoch_value() { assert_eq!( diff --git a/src/driver/extract/tests.rs b/src/driver/extract/tests.rs index 66ac1a3..9445584 100644 --- a/src/driver/extract/tests.rs +++ b/src/driver/extract/tests.rs @@ -56,9 +56,9 @@ fn column_type_normalization_matches_the_replaced_tiberius_dispatch() { (TdsDataType::Image, ColumnType::Image), (TdsDataType::Xml, ColumnType::Xml), (TdsDataType::SsVariant, ColumnType::Ssvariant), - // The bridge has no UDT variant; the old best-effort string decode - // also produced null for the binary CLR payloads we receive. - (TdsDataType::Udt, ColumnType::Null), + // The bridge has no UDT ColumnType, but it decodes CLR payloads as + // bytes, so the plugin exposes them losslessly as BLOB values. + (TdsDataType::Udt, ColumnType::BigVarBin), (TdsDataType::None, ColumnType::Null), ]; @@ -86,7 +86,7 @@ fn column_type_normalization_covers_fixed_and_big_wire_names() { } #[test] -fn exact_decimal_within_rust_decimal_range_is_preserved() { +fn exact_decimal_is_preserved() { let parts = DecimalParts::from_string("123.4500", 10, 4).unwrap(); let value = numeric_value_to_json(&ColumnValues::Decimal(parts)).unwrap(); @@ -94,12 +94,14 @@ fn exact_decimal_within_rust_decimal_range_is_preserved() { } #[test] -fn decimal_beyond_rust_decimal_range_fails_loudly() { +fn decimal_38_preserves_every_digit() { let parts = DecimalParts::from_string("99999999999999999999999999999999999999", 38, 0).unwrap(); - let error = numeric_value_to_json(&ColumnValues::Numeric(parts)).unwrap_err(); + let value = numeric_value_to_json(&ColumnValues::Numeric(parts)).unwrap(); - assert!(error.contains("decimal(38, 0)")); - assert!(error.contains("exceeds rust_decimal's exact range")); + assert_eq!( + value, + Value::String("99999999999999999999999999999999999999".into()) + ); } #[test] @@ -118,6 +120,14 @@ fn money_and_smallmoney_are_exact_decimal_strings() { assert_eq!(money, Value::String("123.4567".into())); } +#[test] +fn binary_values_use_the_host_blob_wire_format() { + assert_eq!( + binary_to_json(&[0xde, 0xad, 0xbe, 0xef]), + Value::String("BLOB:4:application/octet-stream:3q2+7w==".into()) + ); +} + #[test] fn json_values_have_a_defined_text_representation() { let json = SqlJson::new(br#"{"ok":true}"#.to_vec()); diff --git a/src/driver/helpers.rs b/src/driver/helpers.rs index 9aecb5d..acb72dd 100644 --- a/src/driver/helpers.rs +++ b/src/driver/helpers.rs @@ -82,16 +82,14 @@ pub fn wrap_dml_with_rowcount(sql: &str) -> String { /// Build a parameterized SQL Server `INSERT` statement. /// -/// `qualified` is expected to already be a `[schema].[table]` produced by -/// [`qualify`]. `columns` is the in-order list of column names that will be -/// bound to `@P1, @P2, ...` (callers must bind values in the same order). +/// `schema`, `table`, and every entry in `columns` are identifiers and are +/// bracket-quoted here. Values are bound to `@P1, @P2, ...` by the caller in +/// the same order; this helper never accepts a pre-rendered table reference. /// -/// When `wrap_identity_insert` is `Some(target)`, the resulting batch toggles -/// `SET IDENTITY_INSERT ON` around the insert and is wrapped in -/// `BEGIN TRY / BEGIN CATCH` so the session-scoped setting is always cleared, -/// even if the insert fails. `target` should also be a `[schema].[table]` -/// reference (typically the same as `qualified`); accepting it as a parameter -/// keeps the helper pure and easy to unit-test. +/// When `wrap_identity_insert` is true, the resulting batch toggles +/// `SET IDENTITY_INSERT` around the insert and is wrapped in `BEGIN TRY / +/// BEGIN CATCH` so the session-scoped setting is always cleared, even if the +/// insert fails. /// /// The batch always ends by selecting the insert's `@@ROWCOUNT` as /// [`AFFECTED_ROWS_COLUMN`]. In the identity-wrapped variant the count is @@ -101,55 +99,67 @@ pub fn wrap_dml_with_rowcount(sql: &str) -> String { /// Returns the SQL batch. The number of placeholders always matches /// `columns.len()`. pub fn build_insert_sql( - qualified: &str, + schema: Option<&str>, + table: &str, columns: &[String], - wrap_identity_insert: Option<&str>, + wrap_identity_insert: bool, ) -> String { + let expressions: Vec = (1..=columns.len()).map(|i| format!("@P{i}")).collect(); + build_insert_sql_with_expressions(schema, table, columns, &expressions, wrap_identity_insert) +} + +/// Build an INSERT whose value expressions have already been classified. +/// Most entries are positional parameters; an explicitly marked `is_raw` +/// edit may supply a SQL expression instead. +pub fn build_insert_sql_with_expressions( + schema: Option<&str>, + table: &str, + columns: &[String], + expressions: &[String], + wrap_identity_insert: bool, +) -> String { + debug_assert_eq!(columns.len(), expressions.len()); + let target = qualify(schema, table); let col_list = columns .iter() .map(|c| bracket_quote(c)) .collect::>() .join(", "); - let placeholders = (1..=columns.len()) - .map(|i| format!("@P{}", i)) - .collect::>() - .join(", "); let insert = format!( "INSERT INTO {} ({}) VALUES ({})", - qualified, col_list, placeholders + target, + col_list, + expressions.join(", ") ); - match wrap_identity_insert { - None => format!("{};\n{}", insert, select_affected_rows("@@ROWCOUNT")), - Some(target) => { - // SET IDENTITY_INSERT is session-scoped and is *not* transactional, - // so the CATCH block must explicitly turn it OFF before re-raising. - // Setting OFF on a table that is already OFF is a no-op in SQL - // Server, so this is safe even if the failure occurs before the ON - // statement executes. The success and CATCH paths both turn it - // OFF; SS-003 verifies a failed insert does not poison the reused - // pooled session. No explicit transaction is needed — a single - // INSERT is atomic on its own, and the TDS client rejects - // BEGIN TRAN / COMMIT inside an `sp_executesql` RPC batch - // (error 3981). - format!( - "DECLARE @tabularis_affected BIGINT = 0;\n\ - BEGIN TRY\n\ - SET IDENTITY_INSERT {target} ON;\n\ - {insert};\n\ - SET @tabularis_affected = @@ROWCOUNT;\n\ - SET IDENTITY_INSERT {target} OFF;\n\ - END TRY\n\ - BEGIN CATCH\n\ - SET IDENTITY_INSERT {target} OFF;\n\ - THROW;\n\ - END CATCH;\n\ - {select}", - target = target, - insert = insert, - select = select_affected_rows("@tabularis_affected"), - ) - } + if wrap_identity_insert { + // SET IDENTITY_INSERT is session-scoped and is *not* transactional, + // so the CATCH block must explicitly turn it OFF before re-raising. + // Setting OFF on a table that is already OFF is a no-op in SQL + // Server, so this is safe even if the failure occurs before the ON + // statement executes. The success and CATCH paths both turn it + // OFF; SS-003 verifies a failed insert does not poison the reused + // pooled session. No explicit transaction is needed — a single + // INSERT is atomic on its own, and the TDS client rejects + // BEGIN TRAN / COMMIT inside an `sp_executesql` RPC batch + // (error 3981). + format!( + "DECLARE @tabularis_affected BIGINT = 0;\n\ + BEGIN TRY\n\ + SET IDENTITY_INSERT {target} ON;\n\ + {insert};\n\ + SET @tabularis_affected = @@ROWCOUNT;\n\ + SET IDENTITY_INSERT {target} OFF;\n\ + END TRY\n\ + BEGIN CATCH\n\ + SET IDENTITY_INSERT {target} OFF;\n\ + THROW;\n\ + END CATCH;\n\ + {select}", + select = select_affected_rows("@tabularis_affected"), + ) + } else { + format!("{};\n{}", insert, select_affected_rows("@@ROWCOUNT")) } } @@ -200,13 +210,43 @@ pub fn value_to_sql_param( .ok_or_else(|| format!("Invalid SQL Server numeric value: {number}")) } } - serde_json::Value::String(value) => Ok(Box::new(value.clone())), + serde_json::Value::String(value) => match crate::driver::blob::decode_blob_wire(value)? { + Some(bytes) => Ok(Box::new(bytes)), + None => Ok(Box::new(value.clone())), + }, serde_json::Value::Array(_) | serde_json::Value::Object(_) => { Ok(Box::new(value.to_string())) } } } +/// Return the SQL expression from the explicit row-edit raw-value shape. +/// Ordinary JSON objects remain bindable JSON values; only `is_raw: true` +/// opts into expression insertion. +pub fn raw_sql_expression(value: &serde_json::Value) -> Result, String> { + // An untyped SQL NULL is assignable to every nullable SQL Server type; + // the bridge's fallback NVARCHAR NULL parameter is not (notably binary + // and CLR UDT columns reject that implicit conversion). + if value.is_null() { + return Ok(Some("NULL")); + } + let serde_json::Value::Object(object) = value else { + return Ok(None); + }; + if object.get("is_raw") != Some(&serde_json::Value::Bool(true)) { + return Ok(None); + } + let expression = object + .get("value") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|expression| !expression.is_empty()) + .ok_or_else(|| { + "SQL Server raw row-edit values require a non-empty string 'value'".to_string() + })?; + Ok(Some(expression)) +} + /// Build a parameterised `WHERE` clause for a composite primary key. /// /// `pk_cols` are bracket-quoted; each column is bound to an ordinal marker @@ -262,17 +302,27 @@ pub fn build_update_composite_sql( col_name: &str, pk_cols: &[String], ) -> Option { - let where_clause = build_pk_where_clause(pk_cols, 2)?; + build_update_composite_sql_with_expression(schema, table, col_name, "@P1", pk_cols, 2) +} + +pub fn build_update_composite_sql_with_expression( + schema: Option<&str>, + table: &str, + col_name: &str, + value_expression: &str, + pk_cols: &[String], + first_pk_marker: usize, +) -> Option { + let where_clause = build_pk_where_clause(pk_cols, first_pk_marker)?; Some(format!( - "UPDATE {} SET {} = @P1 WHERE {}", + "UPDATE {} SET {} = {} WHERE {}", qualify(schema, table), bracket_quote(col_name), + value_expression, where_clause )) } -/// Apply SQL Server `OFFSET … FETCH` pagination, requesting one extra row so -/// callers can determine whether another page exists. pub fn render_column_definition(column: &ColumnDefinition, inline_primary_key: bool) -> String { let mut definition = format!("{} {}", bracket_quote(&column.name), column.data_type); if column.is_auto_increment { @@ -311,30 +361,18 @@ pub fn query_reports_affected_rows(query: &str) -> bool { .last() .map(|statement| { let words = top_level_words(statement); - let operation = if words.first().map(String::as_str) == Some("WITH") { - words.iter().skip(1).find(|word| { - matches!( - word.as_str(), - "SELECT" - | "VALUES" - | "EXEC" - | "EXECUTE" - | "INSERT" - | "UPDATE" - | "DELETE" - | "MERGE" - ) - }) - } else { - words.first() - }; - operation.is_some_and(|word| { - matches!(word.as_str(), "INSERT" | "UPDATE" | "DELETE" | "MERGE") + statement_operation(&words).is_some_and(|(operation_index, operation)| { + matches!(operation, "INSERT" | "UPDATE" | "DELETE" | "MERGE") + || (operation == "SELECT" && select_has_top_level_into(&words, operation_index)) }) }) .unwrap_or(false) } +/// Apply SQL Server `OFFSET … FETCH` pagination, requesting one extra row so +/// callers can determine whether another page exists. SQL Server requires an +/// `ORDER BY`; for an unordered query the synthetic order keeps the host's +/// pagination contract available but cannot make page boundaries stable. pub fn build_paginated_query(query: &str, page_size: u32, page: u32) -> String { let normalized = query.trim().trim_end_matches(';').trim_end(); let offset = page.saturating_sub(1).saturating_mul(page_size); @@ -351,47 +389,49 @@ pub fn build_paginated_query(query: &str, page_size: u32, page: u32) -> String { fn statement_can_be_paginated(statement: &str) -> bool { let words = top_level_words(statement); - match words.first().map(String::as_str) { - Some("SELECT" | "VALUES") => true, - Some("WITH") => words - .iter() - .skip(1) - .find_map(|word| match word.as_str() { - "SELECT" | "VALUES" => Some(true), - "INSERT" | "UPDATE" | "DELETE" | "MERGE" | "EXEC" | "EXECUTE" => Some(false), - _ => None, - }) - .unwrap_or(false), + statement_operation(&words).is_some_and(|(operation_index, operation)| match operation { + "SELECT" => !select_has_top_level_into(&words, operation_index), + "VALUES" => true, _ => false, - } + }) } fn statement_returns_result_set(statement: &str) -> bool { let words = top_level_words(statement); - let Some(first) = words.first().map(String::as_str) else { + let Some((operation_index, operation)) = statement_operation(&words) else { return false; }; - let dml_returns_rows = - |start: usize| words[start..].iter().any(|word| word.as_str() == "OUTPUT"); + let dml_returns_rows = words[operation_index + 1..] + .iter() + .any(|word| word == "OUTPUT"); + + match operation { + "SELECT" => !select_has_top_level_into(&words, operation_index), + "VALUES" | "EXEC" | "EXECUTE" => true, + "INSERT" | "UPDATE" | "DELETE" | "MERGE" => dml_returns_rows, + _ => crate::common::returns_result_set(statement), + } +} +fn statement_operation(words: &[String]) -> Option<(usize, &str)> { + let first = words.first()?; if first != "WITH" { - return match first { - "EXEC" | "EXECUTE" => true, - "INSERT" | "UPDATE" | "DELETE" | "MERGE" => dml_returns_rows(1), - _ => crate::common::returns_result_set(statement), - }; + return Some((0, first.as_str())); } - words + words.iter().enumerate().skip(1).find_map(|(index, word)| { + matches!( + word.as_str(), + "SELECT" | "VALUES" | "EXEC" | "EXECUTE" | "INSERT" | "UPDATE" | "DELETE" | "MERGE" + ) + .then_some((index, word.as_str())) + }) +} + +fn select_has_top_level_into(words: &[String], operation_index: usize) -> bool { + words[operation_index + 1..] .iter() - .enumerate() - .skip(1) - .find_map(|(index, word)| match word.as_str() { - "SELECT" | "VALUES" | "EXEC" | "EXECUTE" => Some(true), - "INSERT" | "UPDATE" | "DELETE" | "MERGE" => Some(dml_returns_rows(index + 1)), - _ => None, - }) - == Some(true) + .any(|word| word == "INTO") } fn top_level_words(statement: &str) -> Vec { @@ -535,93 +575,10 @@ fn code_mask(query: &str) -> String { } fn contains_top_level_order_by(query: &str) -> bool { - #[derive(Clone, Copy, PartialEq, Eq)] - enum State { - Normal, - SingleQuote, - DoubleQuote, - Bracket, - LineComment, - BlockComment, - } - - let upper = query.to_ascii_uppercase(); - let characters: Vec<(usize, char)> = upper.char_indices().collect(); - let mut state = State::Normal; - let mut depth = 0_u32; - let mut position = 0; - - while position < characters.len() { - let (byte_index, character) = characters[position]; - let next = characters.get(position + 1).map(|(_, value)| *value); - match state { - State::Normal => match (character, next) { - ('\'', _) => state = State::SingleQuote, - ('"', _) => state = State::DoubleQuote, - ('[', _) => state = State::Bracket, - ('-', Some('-')) => { - state = State::LineComment; - position += 1; - } - ('/', Some('*')) => { - state = State::BlockComment; - position += 1; - } - ('(', _) => depth = depth.saturating_add(1), - (')', _) => depth = depth.saturating_sub(1), - _ if depth == 0 && token_at(&upper, byte_index, "ORDER BY") => return true, - _ => {} - }, - State::SingleQuote if character == '\'' => { - if next == Some('\'') { - position += 1; - } else { - state = State::Normal; - } - } - State::DoubleQuote if character == '"' => { - if next == Some('"') { - position += 1; - } else { - state = State::Normal; - } - } - State::Bracket if character == ']' => { - if next == Some(']') { - position += 1; - } else { - state = State::Normal; - } - } - State::LineComment if matches!(character, '\n' | '\r') => state = State::Normal, - State::BlockComment if character == '*' && next == Some('/') => { - state = State::Normal; - position += 1; - } - _ => {} - } - position += 1; - } - false -} - -fn token_at(haystack: &str, index: usize, needle: &str) -> bool { - if !haystack[index..].starts_with(needle) { - return false; - } - let is_identifier = |character: char| character.is_alphanumeric() || character == '_'; - let left_is_clear = index == 0 - || !haystack[..index] - .chars() - .next_back() - .map(is_identifier) - .unwrap_or(false); - let right_is_clear = haystack[index + needle.len()..] - .chars() - .next() - .map(|character| !is_identifier(character)) - .unwrap_or(true); - left_is_clear && right_is_clear + let words = top_level_words(&code_mask(query)); + words + .windows(2) + .any(|pair| pair[0] == "ORDER" && pair[1] == "BY") } #[cfg(test)] diff --git a/src/driver/helpers/tests.rs b/src/driver/helpers/tests.rs index cf11895..c57f09e 100644 --- a/src/driver/helpers/tests.rs +++ b/src/driver/helpers/tests.rs @@ -78,9 +78,10 @@ fn bracket_quote_is_round_trip_safe_through_itself() { #[test] fn build_insert_sql_plain_emits_positional_placeholders() { let sql = build_insert_sql( - "[dbo].[Users]", - &["id".to_string(), "name".to_string(), "email".to_string()], None, + "Users", + &["id".to_string(), "name".to_string(), "email".to_string()], + false, ); assert_eq!( sql, @@ -102,9 +103,10 @@ fn wrap_dml_with_rowcount_keeps_multi_statement_batch_and_single_sentinel() { #[test] fn build_insert_sql_plain_quotes_column_identifiers() { let sql = build_insert_sql( - "[sales].[Orders]", + Some("sales"), + "Orders", &["order id".to_string(), "weird]col".to_string()], - None, + false, ); assert!(sql.contains("([order id], [weird]]col])")); assert!(sql.contains("VALUES (@P1, @P2)")); @@ -112,11 +114,7 @@ fn build_insert_sql_plain_quotes_column_identifiers() { #[test] fn build_insert_sql_with_identity_wraps_in_try_catch() { - let sql = build_insert_sql( - "[dbo].[Users]", - &["id".to_string(), "name".to_string()], - Some("[dbo].[Users]"), - ); + let sql = build_insert_sql(None, "Users", &["id".to_string(), "name".to_string()], true); assert!(sql.contains("BEGIN TRY")); assert!(sql.contains("SET IDENTITY_INSERT [dbo].[Users] ON;")); assert!(sql.contains("INSERT INTO [dbo].[Users] ([id], [name]) VALUES (@P1, @P2);")); @@ -146,12 +144,10 @@ fn build_insert_sql_with_identity_wraps_in_try_catch() { } #[test] -fn build_insert_sql_with_identity_uses_provided_target() { - // Caller may pass a different qualified name as the IDENTITY_INSERT - // target (e.g. for round-trip tests with escaped identifiers). - let sql = build_insert_sql("[dbo].[T]", &["k".to_string()], Some("[s].[we]]ird]")); - assert!(sql.contains("SET IDENTITY_INSERT [s].[we]]ird] ON;")); - assert!(sql.contains("SET IDENTITY_INSERT [s].[we]]ird] OFF;")); +fn build_insert_sql_with_identity_quotes_its_target() { + let sql = build_insert_sql(Some("9schéma]"), "weird\"name]", &["k".to_string()], true); + assert!(sql.contains("SET IDENTITY_INSERT [9schéma]]].[weird\"name]]] ON;")); + assert!(sql.contains("SET IDENTITY_INSERT [9schéma]]].[weird\"name]]] OFF;")); } #[test] @@ -170,6 +166,43 @@ fn value_to_sql_param_accepts_supported_json_variants() { } } +#[test] +fn raw_row_edit_shape_is_explicit_and_null_is_untyped() { + let raw = serde_json::json!({ + "value": "geometry::STGeomFromText('POINT (1 2)', 0)", + "is_raw": true + }); + assert_eq!( + raw_sql_expression(&raw).unwrap(), + Some("geometry::STGeomFromText('POINT (1 2)', 0)") + ); + assert_eq!( + raw_sql_expression(&serde_json::Value::Null).unwrap(), + Some("NULL") + ); + assert_eq!( + raw_sql_expression(&serde_json::json!({"value": "still JSON"})).unwrap(), + None + ); + assert!(raw_sql_expression(&serde_json::json!({"value": 1, "is_raw": true})).is_err()); +} + +#[test] +fn explicit_insert_expressions_do_not_consume_parameter_markers() { + let sql = build_insert_sql_with_expressions( + Some("dbo"), + "spatial", + &["id".into(), "shape".into(), "note".into()], + &[ + "@P1".into(), + "geometry::STGeomFromText('POINT (1 2)', 0)".into(), + "@P2".into(), + ], + false, + ); + assert!(sql.contains("VALUES (@P1, geometry::STGeomFromText('POINT (1 2)', 0), @P2)")); +} + // --- composite PK SQL builders (issue #145) ---------------------------- #[test] @@ -301,42 +334,74 @@ fn render_column_definition_handles_identity_default_and_pk() { } #[test] -fn result_set_classification_handles_cte_dml_and_mixed_batches() { - assert!(query_returns_result_set( - "WITH cte AS (SELECT 1 AS id) SELECT id FROM cte" - )); - assert!(!query_returns_result_set( - "WITH cte AS (SELECT 1 AS id) UPDATE users SET active = 1 FROM users JOIN cte ON users.id = cte.id" - )); - assert!(query_returns_result_set( - "INSERT INTO audit(message) VALUES ('x'); SELECT SCOPE_IDENTITY()" - )); - assert!(query_returns_result_set( - "SELECT 1; UPDATE users SET active = 1" - )); - assert!(query_returns_result_set( - "EXEC sp_executesql N'SELECT 1 AS value'" - )); - assert!(query_returns_result_set( - "UPDATE users SET active = 1 OUTPUT INSERTED.id WHERE id = 7" - )); - assert!(query_returns_result_set( - "WITH target AS (SELECT id FROM users) DELETE FROM target OUTPUT DELETED.id" - )); - assert!(!query_returns_result_set( - "UPDATE users SET active = 1 WHERE id = 7" - )); - assert!(query_can_be_paginated( - "WITH cte AS (SELECT 1 AS id) SELECT id FROM cte" - )); - assert!(!query_can_be_paginated("SELECT 1; SELECT 2")); - assert!(!query_can_be_paginated("EXEC sp_executesql N'SELECT 1'")); - assert!(!query_can_be_paginated( - "UPDATE users SET active = 1 OUTPUT INSERTED.id" - )); - assert!(!query_can_be_paginated( - "WITH cte AS (SELECT 1 AS id) DELETE FROM users WHERE id IN (SELECT id FROM cte)" - )); +fn classifier_handles_cte_followed_by_select() { + let query = "WITH cte AS (SELECT 1 AS id) SELECT id FROM cte"; + assert!(query_returns_result_set(query)); + assert!(query_can_be_paginated(query)); + assert!(!query_reports_affected_rows(query)); +} + +#[test] +fn classifier_handles_cte_followed_by_insert() { + let query = "WITH source AS (SELECT 1 AS id) INSERT INTO audit(id) SELECT id FROM source"; + assert!(!query_returns_result_set(query)); + assert!(!query_can_be_paginated(query)); + assert!(query_reports_affected_rows(query)); +} + +#[test] +fn classifier_treats_select_into_as_affected_rows_not_a_result_set() { + for query in [ + "SELECT id INTO #selected FROM users", + "WITH source AS (SELECT id FROM users) SELECT id INTO #selected FROM source", + ] { + assert!( + !query_returns_result_set(query), + "got result set for {query}" + ); + assert!(!query_can_be_paginated(query), "paginated {query}"); + assert!( + query_reports_affected_rows(query), + "lost row count for {query}" + ); + } +} + +#[test] +fn classifier_handles_merge_and_exec() { + let merge = "MERGE users AS target USING source ON target.id = source.id WHEN MATCHED THEN UPDATE SET target.active = 1"; + assert!(!query_returns_result_set(merge)); + assert!(!query_can_be_paginated(merge)); + assert!(query_reports_affected_rows(merge)); + assert!(query_returns_result_set(&format!( + "{merge} OUTPUT INSERTED.id" + ))); + + let exec = "EXEC sp_executesql N'SELECT 1 AS value'"; + assert!(query_returns_result_set(exec)); + assert!(!query_can_be_paginated(exec)); + assert!(!query_reports_affected_rows(exec)); +} + +#[test] +fn classifier_handles_select_from_temp_table() { + let query = "SELECT id FROM #selected ORDER BY id"; + assert!(query_returns_result_set(query)); + assert!(query_can_be_paginated(query)); + assert!(!query_reports_affected_rows(query)); +} + +#[test] +fn classifier_handles_mixed_batches() { + let ending_in_select = "UPDATE users SET active = 1; SELECT id FROM users"; + assert!(query_returns_result_set(ending_in_select)); + assert!(!query_can_be_paginated(ending_in_select)); + assert!(!query_reports_affected_rows(ending_in_select)); + + let ending_in_dml = "SELECT id FROM users; DELETE FROM users WHERE active = 0"; + assert!(query_returns_result_set(ending_in_dml)); + assert!(!query_can_be_paginated(ending_in_dml)); + assert!(query_reports_affected_rows(ending_in_dml)); } #[test] @@ -344,6 +409,9 @@ fn result_set_classification_ignores_literals_comments_and_identifiers() { assert!(!query_returns_result_set( "UPDATE [SELECT] SET [value] = '; SELECT 1' -- ; SELECT 2" )); + assert!(!query_returns_result_set( + "UPDATE users SET note = 'order by id' /* order by id; SELECT 1 */" + )); } #[test] @@ -376,31 +444,45 @@ fn paginated_query_adds_order_when_missing() { } #[test] -fn paginated_query_preserves_existing_order() { - assert_eq!( - build_paginated_query("SELECT * FROM [users] ORDER BY [id]", 10, 1), - "SELECT * FROM [users] ORDER BY [id] OFFSET 0 ROWS FETCH NEXT 11 ROWS ONLY" - ); +fn paginated_query_preserves_top_level_order_with_sql_whitespace() { + for query in [ + "SELECT * FROM [users] ORDER BY [id]", + "SELECT * FROM [users] ORDER\nBY [id]", + "SELECT * FROM [users] ORDER /* stable key */ BY [id]", + ] { + let paginated = build_paginated_query(query, 10, 1); + assert!( + !paginated.contains("ORDER BY (SELECT NULL)"), + "got {paginated}" + ); + assert!( + paginated.ends_with("OFFSET 0 ROWS FETCH NEXT 11 ROWS ONLY"), + "got {paginated}" + ); + } } #[test] -fn paginated_query_ignores_nested_order() { - assert_eq!( - build_paginated_query( - "SELECT * FROM (SELECT TOP 5 * FROM [users] ORDER BY [id]) AS [recent]", - 10, - 1, - ), - "SELECT * FROM (SELECT TOP 5 * FROM [users] ORDER BY [id]) AS [recent] ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 11 ROWS ONLY" - ); +fn paginated_query_ignores_nested_order_in_subquery_and_window() { + for query in [ + "SELECT * FROM (SELECT TOP 5 * FROM [users] ORDER BY [id]) AS [recent]", + "SELECT ROW_NUMBER() OVER (ORDER BY [id]) AS [position] FROM [users]", + ] { + let paginated = build_paginated_query(query, 10, 1); + assert!( + paginated.contains("ORDER BY (SELECT NULL) OFFSET"), + "got {paginated}" + ); + } } #[test] -fn paginated_query_ignores_order_by_in_literals_and_comments() { +fn paginated_query_ignores_order_words_in_literals_and_comments() { for query in [ + "SELECT 'order' AS [label]", "SELECT 'ORDER BY' AS [label]", - "SELECT 1 -- ORDER BY [id]", - "SELECT 1 /* ORDER BY [id] */", + "SELECT 1 -- order by [id]", + "SELECT 1 /* the word order and ORDER BY [id] */", "SELECT N'città ORDER BY nome'", ] { let paginated = build_paginated_query(query, 10, 1); diff --git a/src/driver/introspection.rs b/src/driver/introspection.rs index f97e4c6..d0b3149 100644 --- a/src/driver/introspection.rs +++ b/src/driver/introspection.rs @@ -39,6 +39,7 @@ SELECT \ END AS data_type, \ c.is_nullable AS is_nullable, \ c.is_identity AS is_identity, \ + c.is_computed AS is_generated, \ CAST(c.max_length AS INT) AS max_length, \ CAST(ISNULL(( \ SELECT TOP 1 1 \ @@ -193,6 +194,7 @@ SELECT \ END AS data_type, \ c.is_nullable AS is_nullable, \ c.is_identity AS is_identity, \ + c.is_computed AS is_generated, \ CAST(c.max_length AS INT) AS max_length, \ CAST(ISNULL(( \ SELECT TOP 1 1 \ @@ -357,11 +359,13 @@ pub fn normalize_routine_type(raw: Option<&str>) -> String { /// by the `sys.*` introspection queries. Extracted out of the async paths so /// the field-by-field mapping — including the non-obvious /// `character_maximum_length` policy — stays unit-testable. +#[allow(clippy::too_many_arguments)] pub fn build_table_column( name: String, data_type: String, is_nullable: bool, is_identity: bool, + is_generated: bool, max_length_bytes: i32, is_pk: bool, default_value: Option, @@ -377,6 +381,7 @@ pub fn build_table_column( is_pk, is_nullable, is_auto_increment: is_identity, + is_generated, default_value, character_maximum_length, } @@ -420,8 +425,10 @@ pub fn split_agg_columns(raw: &str) -> Vec { /// Whether a given SQL Server type name is a string-like type that should /// advertise `character_maximum_length` to the UI. pub fn is_string_type(data_type: &str) -> bool { + let normalized = data_type.to_ascii_lowercase(); + let base_type = normalized.split('(').next().unwrap_or(normalized.as_str()); matches!( - data_type.to_ascii_lowercase().as_str(), + base_type, "char" | "varchar" | "nchar" @@ -494,6 +501,7 @@ pub async fn get_columns( row_str(&r, "data_type"), row_bool(&r, "is_nullable"), row_bool(&r, "is_identity"), + row_bool(&r, "is_generated"), row_i32(&r, "max_length"), row_bool(&r, "is_pk"), row_str_opt(&r, "default_value"), @@ -585,6 +593,7 @@ pub async fn get_all_columns_batch( row_str(&r, "data_type"), row_bool(&r, "is_nullable"), row_bool(&r, "is_identity"), + row_bool(&r, "is_generated"), row_i32(&r, "max_length"), row_bool(&r, "is_pk"), row_str_opt(&r, "default_value"), @@ -822,6 +831,8 @@ pub async fn get_indexes( is_unique: row_bool(&r, "is_unique"), is_primary: row_bool(&r, "is_primary"), seq_in_index: row_i32(&r, "seq_in_index"), + // SQL Server indexes cannot contain arbitrary expressions. + is_expression: false, }) .collect()) } diff --git a/src/driver/introspection/tests.rs b/src/driver/introspection/tests.rs index 233da00..66baa63 100644 --- a/src/driver/introspection/tests.rs +++ b/src/driver/introspection/tests.rs @@ -18,6 +18,7 @@ fn q_get_columns_joins_sys_types_and_reports_pk() { assert!(Q_GET_COLUMNS.contains("sys.indexes")); assert!(Q_GET_COLUMNS.contains("is_primary_key")); assert!(Q_GET_COLUMNS.contains("sys.default_constraints")); + assert!(Q_GET_COLUMNS.contains("c.is_computed AS is_generated")); assert!(Q_GET_COLUMNS.contains("OBJECT_ID(@P1)")); assert!(Q_GET_COLUMNS.contains("ORDER BY c.column_id")); } @@ -189,6 +190,7 @@ fn build_table_column_populates_string_length() { "nvarchar".into(), true, false, + false, 40, false, None, @@ -204,7 +206,7 @@ fn build_table_column_populates_string_length() { #[test] fn build_table_column_leaves_length_none_for_numeric() { - let col = build_table_column("id".into(), "int".into(), false, true, 4, true, None); + let col = build_table_column("id".into(), "int".into(), false, true, false, 4, true, None); assert_eq!(col.character_maximum_length, None); assert!(col.is_pk); assert!(col.is_auto_increment); @@ -218,6 +220,7 @@ fn build_table_column_honours_max_as_none() { "varbinary".into(), true, false, + false, -1, false, None, @@ -232,6 +235,7 @@ fn build_table_column_carries_default_value() { "datetime2".into(), false, false, + false, 8, false, Some("(getdate())".into()), @@ -240,6 +244,22 @@ fn build_table_column_carries_default_value() { assert_eq!(col.character_maximum_length, None); } +#[test] +fn build_table_column_reports_generated_and_parameterized_lengths() { + let col = build_table_column( + "summary".into(), + "nvarchar(42)".into(), + true, + false, + true, + 84, + false, + None, + ); + assert!(col.is_generated); + assert_eq!(col.character_maximum_length, Some(42)); +} + // --- build_foreign_keys ---------------------------------------------- #[test] diff --git a/src/driver/mod.rs b/src/driver/mod.rs index f0f649c..5239ecd 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -4,7 +4,9 @@ //! The driver supports schema introspection, table/view DDL, foreign keys, //! triggers, and stored-routine management. +pub mod blob; pub mod ddl; +pub mod error; pub mod explain; pub mod extract; pub mod helpers; @@ -12,9 +14,9 @@ pub mod introspection; pub mod ops; pub mod pool; pub mod routines; -pub mod showplan; pub mod triggers; pub mod types; +pub mod users; pub mod version; use mssql_tds::connection::tds_client::{ResultSet, ResultSetClient}; @@ -22,14 +24,43 @@ use mssql_tiberius_bridge::row::RowSchema; use mssql_tiberius_bridge::Row; use crate::models::{ConnectionParams, Pagination, QueryResult}; -use crate::pool_manager::get_sqlserver_pool; +use crate::pool_manager::{self, get_sqlserver_pool}; + +/// Maximum rows retained across all result sets from one statement. +/// +/// JSON-RPC responses are single JSON lines, so they cannot be streamed to the +/// host incrementally. This ceiling bounds response memory even when callers +/// omit `limit` or request an impractically large page. SQL Server may produce +/// one additional row so the collector can set `truncated` accurately. +pub const MAX_RESULT_ROWS: usize = 10_000; + +#[derive(Clone, Copy)] +enum OverflowPolicy { + /// Cancel the remaining TDS result stream as soon as the ceiling is known + /// to have been exceeded. + Stop, + /// Drain without retaining ordinary rows. Result-bearing DML needs its + /// trailing affected-row sentinel, even when OUTPUT exceeds the ceiling. + DrainForAffectedRows, +} /// Acquire a pooled client from the pool manager. pub async fn acquire( params: &ConnectionParams, ) -> Result, String> { - let pool = get_sqlserver_pool(params).await?; - pool.get().await.map_err(|e| e.to_string()) + let pool = get_sqlserver_pool(params) + .await + .map_err(|message| error::redact_connection_secrets(message, params))?; + match pool.get().await { + Ok(connection) => Ok(connection), + Err(pool_error) => { + // A pool whose manager cannot connect must not pin stale host, + // credential, or TLS configuration under a stable connection id. + pool_manager::remove_sqlserver_pool(params).await; + let message = error::format_pool_error(&pool_error, params.ssl_mode.as_deref()); + Err(error::redact_connection_secrets(message, params)) + } + } } fn empty_query_result(columns: Vec) -> QueryResult { @@ -43,7 +74,7 @@ fn empty_query_result(columns: Vec) -> QueryResult { } } -/// Run `query` as a simple batch and collect every result set. +/// Run `query` as a simple batch and collect its bounded result sets. /// /// Goes through the bridge's `inner_mut()` escape hatch instead of /// `simple_query().into_results()`: the bridge derives columns from rows, so @@ -55,23 +86,30 @@ fn empty_query_result(columns: Vec) -> QueryResult { async fn run_query_collecting( conn: &mut pool::BridgeConnection, query: &str, + overflow_policy: OverflowPolicy, ) -> Result, String> { + let query_timeout_seconds = conn.query_timeout_seconds(); + let ssl_mode = conn.ssl_mode().to_owned(); let client = conn.inner_mut(); // Drain any leftover state from a prior query / dropped stream so we // don't hit "open batch" errors when re-using the client. client .close_query() .await - .map_err(|error| error.to_string())?; + .map_err(|error| error::format_tds_error(&error, Some(&ssl_mode)))?; client - .execute(query.to_string(), None, None) + .execute(query.to_string(), query_timeout_seconds, None) .await - .map_err(|error| error.to_string())?; + .map_err(|error| error::format_tds_error(&error, Some(&ssl_mode)))?; let mut results = Vec::new(); - while let Some(result_set) = client.get_current_resultset() { + let mut retained_rows = 0usize; + let mut stopped_early = false; + 'result_sets: while let Some(result_set) = client.get_current_resultset() { let metadata = result_set.get_metadata().clone(); let schema = RowSchema::from_metadata(&metadata); + let is_affected_rows_sentinel = + metadata.len() == 1 && metadata[0].column_name == helpers::AFFECTED_ROWS_COLUMN; let mut current = empty_query_result( metadata .iter() @@ -81,8 +119,18 @@ async fn run_query_collecting( while let Some(values) = result_set .next_row() .await - .map_err(|error| error.to_string())? + .map_err(|error| error::format_tds_error(&error, Some(&ssl_mode)))? { + if retained_rows >= MAX_RESULT_ROWS && !is_affected_rows_sentinel { + current.truncated = true; + if matches!(overflow_policy, OverflowPolicy::Stop) { + results.push(current); + stopped_early = true; + break 'result_sets; + } + continue; + } + let row = Row::from_schema(schema.clone(), values); current.rows.push( metadata @@ -97,16 +145,33 @@ async fn run_query_collecting( }) .collect::, _>>()?, ); + if !is_affected_rows_sentinel { + retained_rows += 1; + } } results.push(current); if !client .move_to_next() .await - .map_err(|error| error.to_string())? + .map_err(|error| error::format_tds_error(&error, Some(&ssl_mode)))? { break; } } + if stopped_early { + client + .close_query() + .await + .map_err(|error| error::format_tds_error(&error, Some(&ssl_mode)))?; + } + if results.iter().any(|result| result.truncated) { + // `truncated` on the primary result is the statement-level signal the + // host already consumes. Keep it true even if the budget was crossed + // in an additional result set. + if let Some(first) = results.first_mut() { + first.truncated = true; + } + } Ok(results) } @@ -160,7 +225,9 @@ async fn execute_result_bearing_dml( query: &str, ) -> Result { let wrapped = helpers::wrap_dml_with_rowcount(query); - finish_result_bearing_dml(run_query_collecting(conn, &wrapped).await?) + finish_result_bearing_dml( + run_query_collecting(conn, &wrapped, OverflowPolicy::DrainForAffectedRows).await?, + ) } pub async fn execute_on_connection( @@ -201,7 +268,7 @@ pub async fn execute_on_connection( Some(page_size) => helpers::build_paginated_query(query, page_size, page), None => query.to_string(), }; - let mut results = run_query_collecting(conn, &final_query).await?; + let mut results = run_query_collecting(conn, &final_query, OverflowPolicy::Stop).await?; let mut first = if results.is_empty() { empty_query_result(Vec::new()) } else { @@ -209,8 +276,8 @@ pub async fn execute_on_connection( }; if let Some(ref mut pagination) = pagination { - pagination.has_more = first.rows.len() > pagination.page_size as usize; - if pagination.has_more { + pagination.has_more = first.truncated || first.rows.len() > pagination.page_size as usize; + if first.rows.len() > pagination.page_size as usize { first.rows.truncate(pagination.page_size as usize); first.truncated = true; } @@ -222,6 +289,9 @@ pub async fn execute_on_connection( Ok(first) } +#[cfg(test)] +mod sql_audit_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/src/driver/ops.rs b/src/driver/ops.rs index b0f8d6a..abec3b5 100644 --- a/src/driver/ops.rs +++ b/src/driver/ops.rs @@ -6,15 +6,17 @@ use std::collections::HashMap; use mssql_tiberius_bridge::ToSql; use crate::driver::helpers::{ - bracket_quote, build_delete_composite_sql, build_update_composite_sql, qualify, + bracket_quote, build_delete_composite_sql, build_insert_sql_with_expressions, + build_update_composite_sql_with_expression, qualify, raw_sql_expression, }; use crate::driver::{ - acquire, ddl, execute_on_connection, explain, helpers, introspection, routines, triggers, + acquire, blob, ddl, execute_on_connection, explain, helpers, introspection, routines, triggers, + users, }; use crate::models::{ - AiSchemaContext, BatchStatementResult, ColumnDefinition, ConnectionParams, ForeignKey, Index, - PkMap, QueryResult, RoutineCallArg, RoutineInfo, RoutineParameter, TableColumn, TableInfo, - TableSchema, TriggerInfo, ViewInfo, + AiSchemaContext, BatchStatementResult, ColumnDefinition, ConnectionParams, DbPrivilegeCatalog, + DbUserGrantSet, DbUserInfo, ForeignKey, Index, PkMap, QueryResult, RoutineCallArg, RoutineInfo, + RoutineParameter, TableColumn, TableInfo, TableSchema, TriggerInfo, ViewInfo, }; pub async fn test_connection(params: &ConnectionParams) -> Result<(), String> { @@ -107,6 +109,26 @@ pub async fn get_indexes( // --- Views -------------------------------------------------------------- +pub(crate) fn build_create_view_sql( + view_name: &str, + definition: &str, + schema: Option<&str>, +) -> String { + format!("CREATE VIEW {} AS {definition}", qualify(schema, view_name)) +} + +pub(crate) fn build_alter_view_sql( + view_name: &str, + definition: &str, + schema: Option<&str>, +) -> String { + format!("ALTER VIEW {} AS {definition}", qualify(schema, view_name)) +} + +pub(crate) fn build_drop_view_sql(view_name: &str, schema: Option<&str>) -> String { + format!("DROP VIEW IF EXISTS {}", qualify(schema, view_name)) +} + pub async fn get_views( params: &ConnectionParams, schema: Option<&str>, @@ -142,11 +164,7 @@ pub async fn create_view( definition: &str, schema: Option<&str>, ) -> Result<(), String> { - let sql = format!( - "CREATE VIEW {} AS {}", - qualify(schema, view_name), - definition - ); + let sql = build_create_view_sql(view_name, definition, schema); let mut conn = acquire(params).await?; conn.simple_query(sql) .await @@ -161,11 +179,7 @@ pub async fn alter_view( definition: &str, schema: Option<&str>, ) -> Result<(), String> { - let sql = format!( - "ALTER VIEW {} AS {}", - qualify(schema, view_name), - definition - ); + let sql = build_alter_view_sql(view_name, definition, schema); let mut conn = acquire(params).await?; conn.simple_query(sql) .await @@ -179,7 +193,7 @@ pub async fn drop_view( view_name: &str, schema: Option<&str>, ) -> Result<(), String> { - let sql = format!("DROP VIEW IF EXISTS {}", qualify(schema, view_name)); + let sql = build_drop_view_sql(view_name, schema); let mut conn = acquire(params).await?; conn.simple_query(sql) .await @@ -266,6 +280,88 @@ pub async fn drop_routine( execute_query(params, &sql, None, 1).await.map(|_| ()) } +// --- Database users and privileges ------------------------------------ + +pub fn get_db_privilege_catalog() -> DbPrivilegeCatalog { + users::privilege_catalog() +} + +pub async fn get_db_users(params: &ConnectionParams) -> Result, String> { + let mut conn = acquire(params).await?; + users::get_users(&mut conn).await +} + +pub async fn create_db_user( + params: &ConnectionParams, + user: &str, + login: &str, + password: &str, +) -> Result<(), String> { + let mut conn = acquire(params).await?; + users::create_user(&mut conn, user, login, password).await +} + +pub async fn drop_db_user( + params: &ConnectionParams, + user: &str, + login: &str, +) -> Result<(), String> { + let mut conn = acquire(params).await?; + users::drop_user(&mut conn, user, login).await +} + +pub async fn set_db_user_password( + params: &ConnectionParams, + user: &str, + login: &str, + password: &str, +) -> Result<(), String> { + let mut conn = acquire(params).await?; + users::set_password(&mut conn, user, login, password).await +} + +pub async fn get_db_user_grants( + params: &ConnectionParams, + user: &str, + login: &str, +) -> Result, String> { + let mut conn = acquire(params).await?; + users::get_grants(&mut conn, user, login).await +} + +pub async fn get_db_user_privileges( + params: &ConnectionParams, + user: &str, + login: &str, +) -> Result, String> { + let mut conn = acquire(params).await?; + users::get_privileges(&mut conn, user, login).await +} + +#[allow(clippy::too_many_arguments)] +pub async fn apply_db_user_privileges( + params: &ConnectionParams, + user: &str, + login: &str, + database: Option<&str>, + table: Option<&str>, + privileges: &[String], + grant: bool, +) -> Result<(), String> { + let mut conn = acquire(params).await?; + users::apply_privileges( + &mut conn, + params.database.primary(), + user, + login, + database, + table, + privileges, + grant, + ) + .await +} + // --- Query execution --------------------------------------------------- pub async fn execute_query( @@ -294,11 +390,9 @@ pub async fn execute_batch( Ok(results) } -/// Run SHOWPLAN_XML / STATISTICS XML and parse the document into the visual -/// plan model the frontend renders. Unlike the built-in driver — whose raw -/// XML is parsed by `@tabularis/explain` in the frontend — a plugin's -/// `explain_query` result passes through to the frontend untouched, so the -/// parsing happens here. +/// Capture SHOWPLAN_XML / STATISTICS XML and return the raw plugin EXPLAIN +/// shape. Compatible hosts dispatch the tagged payload to the parser declared +/// in `.tabularium` instead of interpreting it in this Rust process. pub async fn explain_query( params: &ConnectionParams, query: &str, @@ -306,7 +400,12 @@ pub async fn explain_query( ) -> Result { let mut conn = acquire(params).await?; let payload = explain::explain_showplan_xml(&mut conn, query, analyze).await?; - crate::driver::showplan::parse_showplan_xml(&payload, query) + Ok(serde_json::json!({ + "engine": "sqlserver", + "format": "sqlserver-showplan-xml", + "payload": payload, + "original_query": query, + })) } // --- CRUD ---------------------------------------------------------------- @@ -338,24 +437,35 @@ pub async fn insert_record( .map(|id| columns.iter().any(|c| c.eq_ignore_ascii_case(id))) .unwrap_or(false); - let qualified = helpers::qualify(schema, table); - let sql = helpers::build_insert_sql( - &qualified, - &columns, - if needs_identity_insert { - Some(qualified.as_str()) + // Most values become parameters. The host may explicitly mark a value as + // raw SQL (`{ "value": "...", "is_raw": true }`) for server-side type + // constructors such as hierarchyid::Parse; raw values consume no marker. + let mut owned_params: Vec> = Vec::new(); + let mut expressions = Vec::with_capacity(columns.len()); + for column in &columns { + let value = &data[column]; + if let Some(expression) = raw_sql_expression(value)? { + expressions.push(expression.to_string()); } else { - None - }, - ); - - // Map each JSON value to a typed SQL parameter. Owned boxes live - // for the duration of the call so the borrowed `&dyn ToSql` slice is - // valid. - let owned_params: Vec> = columns + owned_params.push(helpers::value_to_sql_param(value)?); + expressions.push(format!("@P{}", owned_params.len())); + } + } + let sql = if expressions .iter() - .map(|column| helpers::value_to_sql_param(&data[column])) - .collect::>()?; + .enumerate() + .all(|(index, expression)| expression == &format!("@P{}", index + 1)) + { + helpers::build_insert_sql(schema, table, &columns, needs_identity_insert) + } else { + build_insert_sql_with_expressions( + schema, + table, + &columns, + &expressions, + needs_identity_insert, + ) + }; let params_slice: Vec<&dyn mssql_tiberius_bridge::ToSql> = owned_params.iter().map(|b| b.as_ref()).collect(); @@ -380,11 +490,28 @@ pub async fn update_record( .iter() .map(|(column, _)| (*column).clone()) .collect(); - let sql = build_update_composite_sql(schema, table, col_name, &pk_columns) - .ok_or_else(|| "SQL Server: UPDATE requires at least one primary-key column".to_string())?; - let mut owned_params = Vec::with_capacity(primary_keys.len() + 1); - owned_params.push(helpers::value_to_sql_param(&new_val)?); + let value_expression = if let Some(expression) = raw_sql_expression(&new_val)? { + expression.to_string() + } else { + owned_params.push(helpers::value_to_sql_param(&new_val)?); + "@P1".to_string() + }; + let first_pk_marker = owned_params.len() + 1; + let sql = if value_expression == "@P1" { + helpers::build_update_composite_sql(schema, table, col_name, &pk_columns) + } else { + build_update_composite_sql_with_expression( + schema, + table, + col_name, + &value_expression, + &pk_columns, + first_pk_marker, + ) + } + .ok_or_else(|| "SQL Server: UPDATE requires at least one primary-key column".to_string())?; + for (_, value) in primary_keys { owned_params.push(helpers::value_to_sql_param(value)?); } @@ -427,6 +554,37 @@ pub async fn delete_record( crate::driver::affected_rows_from_query(result) } +// --- BLOB export and preview -------------------------------------------- + +pub async fn save_blob_to_file( + params: &ConnectionParams, + table: &str, + col_name: &str, + pk_map: &PkMap, + schema: Option<&str>, + file_path: &str, +) -> Result<(), String> { + blob::validate_writable_file_path(file_path)?; + let bytes = blob::fetch_blob_bytes(params, table, col_name, pk_map, schema, None).await?; + tokio::fs::write(file_path, bytes) + .await + .map_err(|error| format!("Failed to write SQL Server BLOB to '{file_path}': {error}")) +} + +pub async fn fetch_blob_as_data_url( + params: &ConnectionParams, + table: &str, + col_name: &str, + pk_map: &PkMap, + schema: Option<&str>, + max_blob_size: u64, +) -> Result { + let bytes = + blob::fetch_blob_bytes(params, table, col_name, pk_map, schema, Some(max_blob_size)) + .await?; + blob::encode_blob_full(&bytes, max_blob_size) +} + // --- DDL generation ----------------------------------------------------- pub fn get_create_table_sql( @@ -516,17 +674,21 @@ pub fn get_create_foreign_key_sql( ) } +pub(crate) fn build_drop_index_sql(table: &str, index_name: &str, schema: Option<&str>) -> String { + format!( + "DROP INDEX {} ON {}", + bracket_quote(index_name), + qualify(schema, table), + ) +} + pub async fn drop_index( params: &ConnectionParams, table: &str, index_name: &str, schema: Option<&str>, ) -> Result<(), String> { - let sql = format!( - "DROP INDEX {} ON {}", - bracket_quote(index_name), - qualify(schema, table), - ); + let sql = build_drop_index_sql(table, index_name, schema); let mut conn = acquire(params).await?; conn.execute(sql, &[]) .await @@ -534,17 +696,25 @@ pub async fn drop_index( Ok(()) } -pub async fn drop_foreign_key( - params: &ConnectionParams, +pub(crate) fn build_drop_foreign_key_sql( table: &str, fk_name: &str, schema: Option<&str>, -) -> Result<(), String> { - let sql = format!( +) -> String { + format!( "ALTER TABLE {} DROP CONSTRAINT {}", qualify(schema, table), bracket_quote(fk_name), - ); + ) +} + +pub async fn drop_foreign_key( + params: &ConnectionParams, + table: &str, + fk_name: &str, + schema: Option<&str>, +) -> Result<(), String> { + let sql = build_drop_foreign_key_sql(table, fk_name, schema); let mut conn = acquire(params).await?; conn.execute(sql, &[]) .await diff --git a/src/driver/pool.rs b/src/driver/pool.rs index f191d22..350233d 100644 --- a/src/driver/pool.rs +++ b/src/driver/pool.rs @@ -10,59 +10,191 @@ //! `require` encrypts while accepting the server certificate, and `prefer` //! requests encrypted local-development-compatible connections. +use std::future::Future; +use std::time::Duration; + +use crate::connection::{custom_ca_error, resolve_connection_params}; +use crate::driver::error::{bridge_error_requires_discard, format_bridge_error}; use crate::models::ConnectionParams; +use crate::settings::PluginSettings; use deadpool::managed::{Manager, Metrics, RecycleError, RecycleResult}; -use mssql_tiberius_bridge::{AuthMethod, Client, Config, EncryptionLevel, Error}; +use mssql_tiberius_bridge::TdsClient; +use mssql_tiberius_bridge::{ + AuthMethod, Client, Config, EncryptionLevel, Error, ExecuteResult, QueryResult, ToSql, +}; +use tokio::time::timeout; + +/// A live bridge client with the query timeout snapshotted by its pool. +pub struct BridgeConnection { + client: Client, + query_timeout_seconds: Option, + ssl_mode: String, + recyclable: bool, +} + +impl BridgeConnection { + async fn with_query_timeout( + seconds: Option, + operation: impl Future>, + ) -> Result { + match seconds { + Some(seconds) => timeout(Duration::from_secs(u64::from(seconds)), operation) + .await + .map_err(|_| { + Error::Conversion(format!("Query timed out after {seconds} seconds")) + })?, + None => operation.await, + } + } + + async fn simple_query_raw(&mut self, sql: impl Into) -> Result { + let operation = self.client.simple_query(sql.into()); + Self::with_query_timeout(self.query_timeout_seconds, operation).await + } + + pub async fn simple_query(&mut self, sql: impl Into) -> Result { + let result = self.simple_query_raw(sql).await; + self.discard_if_transaction_open(); + self.format_result(result) + } + + pub async fn query( + &mut self, + sql: impl Into, + params: &[&dyn ToSql], + ) -> Result { + let operation = self.client.query(sql.into(), params); + let result = Self::with_query_timeout(self.query_timeout_seconds, operation).await; + self.discard_if_transaction_open(); + self.format_result(result) + } + + pub async fn execute( + &mut self, + sql: impl Into, + params: &[&dyn ToSql], + ) -> Result { + let operation = self.client.execute(sql.into(), params); + let result = Self::with_query_timeout(self.query_timeout_seconds, operation).await; + self.discard_if_transaction_open(); + self.format_result(result) + } + + fn discard_if_transaction_open(&mut self) { + if self.client.inner_mut().has_active_transaction() { + self.recyclable = false; + } + } + + fn format_result(&mut self, result: Result) -> Result { + result.map_err(|error| { + if bridge_error_requires_discard(&error) + || self.client.inner_mut().has_active_transaction() + { + // Dropping the socket lets SQL Server roll back the + // transaction and release temp/session state without trying + // to issue reset commands into an errored transaction stream. + self.recyclable = false; + } + format_bridge_error(&error, Some(&self.ssl_mode)) + }) + } -/// A live bridge client. `deadpool` hands one of these out per checkout. -pub type BridgeConnection = Client; + pub fn inner_mut(&mut self) -> &mut TdsClient { + self.client.inner_mut() + } + + pub fn query_timeout_seconds(&self) -> Option { + self.query_timeout_seconds + } + + pub fn ssl_mode(&self) -> &str { + &self.ssl_mode + } +} /// Deadpool `Manager` for bridge connections. #[derive(Debug, Clone)] pub struct BridgeManager { config: Config, startup_script: Option, + ssl_mode: String, + connect_timeout: Duration, + query_timeout_seconds: Option, } impl BridgeManager { - pub fn new(config: Config, startup_script: Option) -> Self { + pub fn new( + config: Config, + startup_script: Option, + ssl_mode: &str, + settings: &PluginSettings, + ) -> Self { Self { config, startup_script, + ssl_mode: ssl_mode.to_owned(), + connect_timeout: Duration::from_secs(u64::from(settings.connect_timeout_seconds)), + query_timeout_seconds: settings + .query_timeout() + .map(|_| settings.query_timeout_seconds), } } async fn apply_startup_script(&self, conn: &mut BridgeConnection) -> Result<(), Error> { if let Some(script) = self.startup_script.as_deref() { - conn.simple_query(script) - .await - .map_err(startup_script_error)? - .into_results(); + conn.simple_query_raw(script).await?.into_results(); } Ok(()) } } -fn startup_script_error(error: Error) -> Error { - Error::Conversion(format!("Startup script failed: {error}")) -} - impl Manager for BridgeManager { type Type = BridgeConnection; type Error = Error; async fn create(&self) -> Result { - let mut client = Client::connect(&self.config).await?; - self.apply_startup_script(&mut client).await?; - Ok(client) + let client = timeout(self.connect_timeout, Client::connect(&self.config)) + .await + .map_err(|_| { + Error::Conversion(format!( + "Connection timed out after {} seconds", + self.connect_timeout.as_secs() + )) + })??; + let mut connection = BridgeConnection { + client, + query_timeout_seconds: self.query_timeout_seconds, + ssl_mode: self.ssl_mode.clone(), + recyclable: true, + }; + self.apply_startup_script(&mut connection).await?; + Ok(connection) } async fn recycle(&self, conn: &mut Self::Type, _: &Metrics) -> RecycleResult { - // Reset transaction, temporary-object, and SET state before another - // caller receives this physical session, then restore its configured - // startup script. - conn.simple_query("EXEC sp_reset_connection") + if !conn.recyclable || conn.client.inner_mut().has_active_transaction() { + return Err(RecycleError::message( + "connection was discarded after an error, timeout, transport failure, or open transaction", + )); + } + + // SHOWPLAN must be disabled in its own batches: while SHOWPLAN_XML is + // active SQL Server plans later statements instead of executing them, + // including sp_reset_connection. Open transactions are discarded + // above; for reusable sessions the reset drops local temp tables, + // disables IDENTITY_INSERT and restores SET options before the startup + // script is reapplied. + conn.simple_query_raw("SET SHOWPLAN_XML OFF") + .await + .map_err(RecycleError::Backend)? + .into_results(); + conn.simple_query_raw("SET STATISTICS XML OFF") + .await + .map_err(RecycleError::Backend)? + .into_results(); + conn.simple_query_raw("EXEC sp_reset_connection") .await .map_err(RecycleError::Backend)? .into_results(); @@ -78,7 +210,11 @@ impl Manager for BridgeManager { /// Consumes the shared connection fields used by current Tabularis drivers. /// SQL Server authentication is currently username/password only. TLS maps /// the standard `ssl_mode` values onto the bridge's encryption policy. -pub fn build_config(params: &ConnectionParams) -> Result { +pub fn build_config( + params: &ConnectionParams, + settings: &PluginSettings, +) -> Result { + let params = resolve_connection_params(params)?; let mut cfg = Config::new(); cfg.host(params.host.as_deref().unwrap_or("localhost")); cfg.port(params.port.unwrap_or(1433)); @@ -87,16 +223,14 @@ pub fn build_config(params: &ConnectionParams) -> Result { params.username.as_deref().unwrap_or("sa"), params.password.as_deref().unwrap_or(""), )); + cfg.application_name(&settings.application_name); if params .ssl_ca .as_deref() .is_some_and(|path| !path.is_empty()) { - return Err( - "SQL Server custom CA files are not supported; use verify-full with the system trust store" - .into(), - ); + return Err(custom_ca_error().into()); } if params .ssl_cert @@ -133,6 +267,12 @@ pub fn build_config(params: &ConnectionParams) -> Result { } } + // Explicitly permit self-signed certificates even with a verifying TLS + // mode. `require` and `prefer` already trust certificates by definition. + if settings.trust_server_certificate { + cfg.trust_cert(); + } + Ok(cfg) } diff --git a/src/driver/pool/tests.rs b/src/driver/pool/tests.rs index 0f95144..4d2b6e6 100644 --- a/src/driver/pool/tests.rs +++ b/src/driver/pool/tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::{ConnectionParams, DatabaseSelection}; +use crate::settings::PluginSettings; fn base_params(host: Option<&str>, port: Option, db: &str) -> ConnectionParams { ConnectionParams { @@ -15,20 +16,31 @@ fn base_params(host: Option<&str>, port: Option, db: &str) -> ConnectionPar #[test] fn build_config_uses_explicit_host_port() { - let cfg = build_config(&base_params(Some("db.internal"), Some(1445), "master")) - .expect("config builds"); + let cfg = build_config( + &base_params(Some("db.internal"), Some(1445), "master"), + &PluginSettings::default(), + ) + .expect("config builds"); assert_eq!(cfg.get_addr(), "db.internal:1445"); } #[test] fn build_config_defaults_host_to_localhost() { - let cfg = build_config(&base_params(None, Some(1433), "master")).expect("config builds"); + let cfg = build_config( + &base_params(None, Some(1433), "master"), + &PluginSettings::default(), + ) + .expect("config builds"); assert_eq!(cfg.get_addr(), "localhost:1433"); } #[test] fn build_config_defaults_port_to_1433() { - let cfg = build_config(&base_params(Some("localhost"), None, "master")).expect("config builds"); + let cfg = build_config( + &base_params(Some("localhost"), None, "master"), + &PluginSettings::default(), + ) + .expect("config builds"); assert_eq!(cfg.get_addr(), "localhost:1433"); } @@ -37,7 +49,7 @@ fn build_config_empty_credentials_do_not_panic() { let mut params = base_params(Some("localhost"), Some(1433), "master"); params.username = None; params.password = None; - assert!(build_config(¶ms).is_ok()); + assert!(build_config(¶ms, &PluginSettings::default()).is_ok()); } #[test] @@ -52,15 +64,55 @@ fn manager_is_clone_send_sync() { #[test] fn manager_new_stores_config() { - let cfg = build_config(&base_params(Some("example.com"), Some(1433), "master")) - .expect("config builds"); - let mgr = BridgeManager::new(cfg, Some("SET NOCOUNT ON".into())); + let settings = PluginSettings::default(); + let cfg = build_config( + &base_params(Some("example.com"), Some(1433), "master"), + &settings, + ) + .expect("config builds"); + let mgr = BridgeManager::new(cfg, Some("SET NOCOUNT ON".into()), "prefer", &settings); let cloned = mgr.clone(); let original = format!("{:?}", mgr); let cloned_dbg = format!("{:?}", cloned); assert_eq!(original, cloned_dbg); } +#[test] +fn build_config_applies_application_name_and_certificate_override() { + let settings = PluginSettings { + application_name: "Tabularis Test".into(), + trust_server_certificate: true, + ..PluginSettings::default() + }; + let mut params = base_params(Some("localhost"), Some(1433), "master"); + params.ssl_mode = Some("verify-full".into()); + + let debug = format!( + "{:?}", + build_config(¶ms, &settings).expect("config builds") + ); + assert!(debug.contains("Tabularis Test")); + assert!(debug.contains("trust_cert: true")); +} + +#[test] +fn manager_snapshots_timeout_settings() { + let settings = PluginSettings { + connect_timeout_seconds: 8, + query_timeout_seconds: 42, + ..PluginSettings::default() + }; + let cfg = build_config( + &base_params(Some("localhost"), Some(1433), "master"), + &settings, + ) + .expect("config builds"); + let manager = BridgeManager::new(cfg, None, "prefer", &settings); + + assert_eq!(manager.connect_timeout, Duration::from_secs(8)); + assert_eq!(manager.query_timeout_seconds, Some(42)); +} + #[test] fn build_config_accepts_supported_tls_modes() { for mode in [ @@ -75,7 +127,7 @@ fn build_config_accepts_supported_tls_modes() { ] { let mut params = base_params(Some("localhost"), Some(1433), "master"); params.ssl_mode = Some(mode.into()); - let cfg = build_config(¶ms).expect("config builds"); + let cfg = build_config(¶ms, &PluginSettings::default()).expect("config builds"); assert_eq!(cfg.get_addr(), "localhost:1433"); } } @@ -84,15 +136,19 @@ fn build_config_accepts_supported_tls_modes() { fn build_config_rejects_unsupported_tls_inputs() { let mut verify_ca = base_params(Some("localhost"), Some(1433), "master"); verify_ca.ssl_mode = Some("verify_ca".into()); - assert!(build_config(&verify_ca).unwrap_err().contains("verify-ca")); + assert!(build_config(&verify_ca, &PluginSettings::default()) + .unwrap_err() + .contains("verify-ca")); let mut custom_ca = base_params(Some("localhost"), Some(1433), "master"); custom_ca.ssl_ca = Some("/tmp/ca.pem".into()); - assert!(build_config(&custom_ca).unwrap_err().contains("custom CA")); + assert!(build_config(&custom_ca, &PluginSettings::default()) + .unwrap_err() + .contains("custom CA")); let mut client_cert = base_params(Some("localhost"), Some(1433), "master"); client_cert.ssl_cert = Some("/tmp/client.pem".into()); - assert!(build_config(&client_cert) + assert!(build_config(&client_cert, &PluginSettings::default()) .unwrap_err() .contains("client certificates")); } diff --git a/src/driver/showplan.rs b/src/driver/showplan.rs deleted file mode 100644 index 10736b2..0000000 --- a/src/driver/showplan.rs +++ /dev/null @@ -1,240 +0,0 @@ -//! SHOWPLAN_XML → visual-plan JSON. -//! -//! Parses SQL Server's SHOWPLAN XML document into the `ExplainPlan` shape the -//! Tabularis frontend renders (`@tabularis/explain`'s plan model). The host -//! passes a plugin's `explain_query` result through to the frontend -//! untouched, so the plan must arrive already parsed. - -use roxmltree::{Document, Node}; -use serde_json::{json, Value}; - -/// Parse a SHOWPLAN XML document into the shared visual-plan model. -pub fn parse_showplan_xml(raw: &str, original_query: &str) -> Result { - let document = Document::parse(raw) - .map_err(|error| format!("Failed to parse SQL Server SHOWPLAN_XML: {error}"))?; - let operator = document - .descendants() - .find(|node| node.is_element() && node.tag_name().name() == "RelOp") - .ok_or_else(|| "SQL Server SHOWPLAN_XML does not contain a RelOp".to_string())?; - - let root = parse_operator(operator, 0); - let actual_time_ms = root.get("actual_time_ms").cloned().unwrap_or(Value::Null); - let has_analyze_data = !root.get("actual_rows").map(Value::is_null).unwrap_or(true); - Ok(json!({ - "root": root, - "planning_time_ms": Value::Null, - "execution_time_ms": actual_time_ms, - "original_query": original_query, - "driver": "sqlserver", - "has_analyze_data": has_analyze_data, - "raw_output": raw, - })) -} - -fn attr<'a>(node: Node<'a, '_>, name: &str) -> Option<&'a str> { - node.attributes() - .find(|a| a.name() == name) - .map(|a| a.value()) -} - -fn attr_number(node: Node, name: &str) -> Value { - attr(node, name) - .and_then(|text| text.parse::().ok()) - .filter(|value| value.is_finite()) - .and_then(serde_json::Number::from_f64) - .map(Value::Number) - .unwrap_or(Value::Null) -} - -/// Descendants of `operator` that do not cross into a nested `RelOp` -/// subtree, i.e. the parts of the plan node that belong to this operator -/// rather than to one of its children. -fn owned_descendant<'a>(operator: Node<'a, 'a>, name: &str) -> Option> { - fn visit<'a>(node: Node<'a, 'a>, name: &str) -> Option> { - for child in node.children().filter(Node::is_element) { - if child.tag_name().name() == "RelOp" { - continue; - } - if child.tag_name().name() == name { - return Some(child); - } - if let Some(found) = visit(child, name) { - return Some(found); - } - } - None - } - visit(operator, name) -} - -/// Direct child operators: descendant `RelOp` elements whose path from this -/// operator contains no other `RelOp`. -fn child_operators<'a>(operator: Node<'a, 'a>) -> Vec> { - fn visit<'a>(node: Node<'a, 'a>, out: &mut Vec>) { - for child in node.children().filter(Node::is_element) { - if child.tag_name().name() == "RelOp" { - out.push(child); - } else { - visit(child, out); - } - } - } - let mut out = Vec::new(); - visit(operator, &mut out); - out -} - -fn relation_name(operator: Node) -> Value { - match owned_descendant(operator, "Object").and_then(|target| attr(target, "Table")) { - Some(table) => Value::String(table.replace(['[', ']'], "")), - None => Value::Null, - } -} - -fn predicate(operator: Node) -> Value { - match owned_descendant(operator, "ScalarOperator") - .and_then(|scalar| attr(scalar, "ScalarString")) - { - Some(text) => Value::String(text.to_string()), - None => Value::Null, - } -} - -/// Actual rows / elapsed time / executions summed and maxed across the -/// per-thread runtime counters, when the plan carries analyze data. -fn runtime_metrics(operator: Node) -> (Value, Value, Value) { - let Some(runtime) = owned_descendant(operator, "RunTimeInformation") else { - return (Value::Null, Value::Null, Value::Null); - }; - let counters: Vec = runtime - .children() - .filter(|node| node.is_element() && node.tag_name().name() == "RunTimeCountersPerThread") - .collect(); - if counters.is_empty() { - return (Value::Null, Value::Null, Value::Null); - } - let number = |node: Node, name: &str| -> f64 { - attr(node, name) - .and_then(|text| text.parse::().ok()) - .filter(|value| value.is_finite()) - .unwrap_or(0.0) - }; - let rows: f64 = counters.iter().map(|c| number(*c, "ActualRows")).sum(); - let time = counters - .iter() - .map(|c| number(*c, "ActualElapsedms")) - .fold(f64::NEG_INFINITY, f64::max); - let loops: f64 = counters - .iter() - .map(|c| number(*c, "ActualExecutions")) - .sum(); - let to_value = |value: f64| { - serde_json::Number::from_f64(value) - .map(Value::Number) - .unwrap_or(Value::Null) - }; - (to_value(rows), to_value(time), to_value(loops)) -} - -fn parse_operator(operator: Node, fallback_id: u64) -> Value { - let physical = attr(operator, "PhysicalOp") - .or_else(|| attr(operator, "LogicalOp")) - .unwrap_or("Unknown") - .to_string(); - let logical = attr(operator, "LogicalOp") - .unwrap_or(physical.as_str()) - .to_string(); - let (actual_rows, actual_time_ms, actual_loops) = runtime_metrics(operator); - let id = attr(operator, "NodeId") - .map(str::to_string) - .unwrap_or_else(|| fallback_id.to_string()); - let children: Vec = child_operators(operator) - .into_iter() - .enumerate() - .map(|(index, child)| parse_operator(child, fallback_id * 10 + index as u64 + 1)) - .collect(); - let join_type = if logical.to_lowercase().contains("join") { - Value::String(logical.clone()) - } else { - Value::Null - }; - - json!({ - "id": format!("sqlserver-{id}"), - "node_type": physical, - "relation": relation_name(operator), - "startup_cost": Value::Null, - "total_cost": attr_number(operator, "EstimatedTotalSubtreeCost"), - "plan_rows": attr_number(operator, "EstimateRows"), - "actual_rows": actual_rows, - "actual_time_ms": actual_time_ms, - "actual_loops": actual_loops, - "buffers_hit": Value::Null, - "buffers_read": Value::Null, - "filter": predicate(operator), - "index_condition": Value::Null, - "join_type": join_type, - "hash_condition": Value::Null, - "extra": { "logical_operation": logical }, - "children": children, - }) -} - -#[cfg(test)] -mod tests { - use super::parse_showplan_xml; - - const SAMPLE: &str = r#" - - - - - - - - - - - - - - - - - - - - - -"#; - - #[test] - fn parses_root_operator_and_children() { - let plan = parse_showplan_xml(SAMPLE, "SELECT * FROM t").unwrap(); - assert_eq!(plan["driver"], "sqlserver"); - assert_eq!(plan["original_query"], "SELECT * FROM t"); - let root = &plan["root"]; - assert_eq!(root["id"], "sqlserver-0"); - assert_eq!(root["node_type"], "Nested Loops"); - assert_eq!(root["join_type"], "Inner Join"); - assert_eq!(root["children"].as_array().unwrap().len(), 1); - } - - #[test] - fn extracts_relation_predicate_and_runtime_metrics() { - let plan = parse_showplan_xml(SAMPLE, "").unwrap(); - let child = &plan["root"]["children"][0]; - assert_eq!(child["relation"], "t"); - assert_eq!(child["filter"], "[db].[dbo].[t].[id]>(5)"); - assert_eq!(child["actual_rows"], 9.0); - assert_eq!(child["actual_time_ms"], 5.0); - assert_eq!(child["actual_loops"], 2.0); - assert_eq!(plan["has_analyze_data"], false); // root has no runtime info - } - - #[test] - fn rejects_documents_without_relop() { - let err = parse_showplan_xml("", "").unwrap_err(); - assert!(err.contains("RelOp")); - } -} diff --git a/src/driver/sql_audit_tests.rs b/src/driver/sql_audit_tests.rs new file mode 100644 index 0000000..d1c9786 --- /dev/null +++ b/src/driver/sql_audit_tests.rs @@ -0,0 +1,276 @@ +//! Cross-module regression tests for the SQL construction audit. +//! +//! These tests deliberately use identifiers that are reserved words, start +//! with a digit, contain Unicode, quotes, and closing brackets. Each public +//! pure SQL builder is exercised here; live JSON-RPC coverage proves the same +//! quoting survives execution against SQL Server. + +use crate::driver::{ddl, helpers, ops, routines, triggers, users}; +use crate::models::{ColumnDefinition, RoutineCallArg, RoutineParameter}; + +const SCHEMA: &str = "9schéma]'"; +const TABLE: &str = "[weird\"name]]"; +const COLUMN: &str = "order"; +const OTHER_COLUMN: &str = "Δ\"value]"; + +fn column(name: &str, data_type: &str) -> ColumnDefinition { + ColumnDefinition { + name: name.to_string(), + data_type: data_type.to_string(), + is_nullable: false, + is_pk: false, + is_auto_increment: false, + default_value: None, + } +} + +#[test] +fn identifier_and_crud_builders_quote_every_hostile_identifier() { + let qualified = "[9schéma]]'].[[weird\"name]]]]]"; + assert_eq!(helpers::bracket_quote(TABLE), "[[weird\"name]]]]]"); + assert_eq!(helpers::quote_identifier("Δ\"value]"), "\"Δ\"\"value]\""); + assert_eq!(helpers::qualify(Some(SCHEMA), TABLE), qualified); + assert_eq!(helpers::escape_single_quoted("a'b"), "a''b"); + + let columns = vec![COLUMN.to_string(), OTHER_COLUMN.to_string()]; + let insert = helpers::build_insert_sql(Some(SCHEMA), TABLE, &columns, true); + assert!(insert.contains(&format!( + "INSERT INTO {qualified} ([order], [Δ\"value]]]) VALUES (@P1, @P2)" + ))); + assert_eq!( + insert + .matches(&format!("SET IDENTITY_INSERT {qualified} OFF")) + .count(), + 2 + ); + + assert_eq!( + helpers::build_pk_where_clause(&columns, 2).unwrap(), + "[order] = @P2 AND [Δ\"value]]] = @P3" + ); + assert_eq!( + helpers::build_update_composite_sql(Some(SCHEMA), TABLE, OTHER_COLUMN, &[COLUMN.into()]) + .unwrap(), + format!("UPDATE {qualified} SET [Δ\"value]]] = @P1 WHERE [order] = @P2") + ); + assert_eq!( + helpers::build_delete_composite_sql(Some(SCHEMA), TABLE, &[COLUMN.into()]).unwrap(), + format!("DELETE FROM {qualified} WHERE [order] = @P1") + ); + + let wrapped = helpers::wrap_dml_with_rowcount("UPDATE [safe] SET [value] = @P1"); + assert!(wrapped.ends_with("SELECT CAST(@@ROWCOUNT AS BIGINT) AS [__tabularis_affected_rows];")); + assert!(helpers::build_paginated_query("SELECT 1", 10, 1) + .ends_with("OFFSET 0 ROWS FETCH NEXT 11 ROWS ONLY")); +} + +#[test] +fn column_and_ddl_builders_quote_every_identifier() { + let qualified = helpers::qualify(Some(SCHEMA), TABLE); + let mut key = column(COLUMN, "INT"); + key.is_pk = true; + key.is_auto_increment = true; + key.default_value = Some("(7)".to_string()); + assert_eq!( + helpers::render_column_definition(&key, true), + "[order] INT IDENTITY(1,1) NOT NULL DEFAULT (7) PRIMARY KEY" + ); + + let create_table = ops::get_create_table_sql(TABLE, vec![key.clone()], Some(SCHEMA)).unwrap(); + assert!(create_table[0].starts_with(&format!("CREATE TABLE {qualified}"))); + assert!(create_table[0].contains("PRIMARY KEY ([order])")); + + let add = + ops::get_add_column_sql(TABLE, column(OTHER_COLUMN, "NVARCHAR(20)"), Some(SCHEMA)).unwrap(); + assert_eq!( + add[0], + format!("ALTER TABLE {qualified} ADD [Δ\"value]]] NVARCHAR(20) NOT NULL") + ); + + let index_name = "9índex\"]"; + let index = ops::get_create_index_sql( + TABLE, + index_name, + vec![COLUMN.into(), OTHER_COLUMN.into()], + true, + Some(SCHEMA), + ) + .unwrap(); + assert_eq!( + index[0], + format!("CREATE UNIQUE INDEX [9índex\"]]] ON {qualified} ([order], [Δ\"value]]])") + ); + + let fk_name = "9fk\"]"; + let referenced = "9référence\"]"; + let direct_fk = ddl::create_foreign_key_sql( + TABLE, + fk_name, + COLUMN, + referenced, + OTHER_COLUMN, + Some("cascade"), + Some("set null"), + Some(SCHEMA), + ) + .unwrap(); + let rpc_fk = ops::get_create_foreign_key_sql( + TABLE, + fk_name, + COLUMN, + referenced, + OTHER_COLUMN, + Some("cascade"), + Some("set null"), + Some(SCHEMA), + ) + .unwrap(); + assert_eq!(direct_fk, rpc_fk); + assert!(direct_fk[0].contains("CONSTRAINT [9fk\"]]] FOREIGN KEY ([order])")); + assert!(direct_fk[0].contains("REFERENCES [9schéma]]'].[9référence\"]]] ([Δ\"value]]])")); +} + +#[test] +fn alter_column_quotes_multipart_names_and_escapes_metadata_literals() { + let old = column("old'name]", "INT"); + let mut new = column(COLUMN, "BIGINT"); + new.is_nullable = true; + new.default_value = Some("(42)".to_string()); + + let direct = ddl::alter_column_sql(TABLE, &old, &new, Some(SCHEMA)).unwrap(); + let rpc = ops::get_alter_column_sql(TABLE, old, new, Some(SCHEMA)).unwrap(); + assert_eq!(direct, rpc); + assert!(direct[0].contains("[9schéma]]''].[[weird\"name]]]]].[old''name]]]")); + assert!(direct[0].contains(", N'order', N'COLUMN'")); + assert_eq!( + direct[1], + "ALTER TABLE [9schéma]]'].[[weird\"name]]]]] ALTER COLUMN [order] BIGINT NULL" + ); + assert!(direct[2].contains("OBJECT_ID(N'[9schéma]]''].[[weird\"name]]]]]')")); + assert!(direct[2].contains("c.[name] = N'order'")); + assert!(direct[3].contains("ADD CONSTRAINT [DF_[weird\"name]]]]_order]")); +} + +#[test] +fn directly_executed_drop_and_view_builders_quote_identifiers() { + let qualified = helpers::qualify(Some(SCHEMA), TABLE); + let definition = "SELECT CAST(1 AS INT) AS [order]"; + assert_eq!( + ops::build_create_view_sql(TABLE, definition, Some(SCHEMA)), + format!("CREATE VIEW {qualified} AS {definition}") + ); + assert_eq!( + ops::build_alter_view_sql(TABLE, definition, Some(SCHEMA)), + format!("ALTER VIEW {qualified} AS {definition}") + ); + assert_eq!( + ops::build_drop_view_sql(TABLE, Some(SCHEMA)), + format!("DROP VIEW IF EXISTS {qualified}") + ); + assert_eq!( + ops::build_drop_index_sql(TABLE, "9índex\"]", Some(SCHEMA)), + format!("DROP INDEX [9índex\"]]] ON {qualified}") + ); + assert_eq!( + ops::build_drop_foreign_key_sql(TABLE, "9fk\"]", Some(SCHEMA)), + format!("ALTER TABLE {qualified} DROP CONSTRAINT [9fk\"]]]") + ); + assert_eq!( + triggers::drop_trigger_sql(TABLE, Some(SCHEMA)), + format!("DROP TRIGGER {qualified}") + ); +} + +#[test] +fn routine_builders_escape_literals_and_preserve_only_explicit_raw_expressions() { + let args = [ + RoutineCallArg { + name: "order".to_string(), + mode: "IN".to_string(), + value: Some("x'); DROP TABLE victims;--".to_string()), + is_raw: false, + }, + RoutineCallArg { + name: "raw_value".to_string(), + mode: "IN".to_string(), + value: Some("DATEADD(day, 1, SYSDATETIME())".to_string()), + is_raw: true, + }, + RoutineCallArg { + name: "out_value".to_string(), + mode: "OUT".to_string(), + value: None, + is_raw: false, + }, + ]; + let parameters = [RoutineParameter { + name: "@out_value".to_string(), + data_type: "NVARCHAR(20)".to_string(), + mode: "OUT".to_string(), + ordinal_position: 3, + }]; + let sql = + routines::routine_call_sql(TABLE, "PROCEDURE", &args, ¶meters, false, Some(SCHEMA)) + .unwrap(); + assert!(sql.contains("@order = N'x''); DROP TABLE victims;--'")); + assert!(sql.contains("@raw_value = DATEADD(day, 1, SYSDATETIME())")); + assert!(sql.contains("EXEC [9schéma]]'].[[weird\"name]]]]]")); + assert!(sql.contains("@tabularis_output_2 AS [out_value]")); + + assert!(routines::routine_create_template("FUNCTION", Some(SCHEMA)) + .starts_with("CREATE FUNCTION [9schéma]]'].[my_function]")); + assert_eq!( + routines::routine_edit_script("CREATE PROCEDURE [safe].[p] AS SELECT 1").unwrap(), + "ALTER PROCEDURE [safe].[p] AS SELECT 1" + ); + assert_eq!( + routines::drop_routine_sql(TABLE, "FUNCTION", Some(SCHEMA)), + "DROP FUNCTION [9schéma]]'].[[weird\"name]]]]]" + ); +} + +#[test] +fn user_management_builders_quote_names_and_escape_unbindable_password_literals() { + let user = "9usér\"]"; + let login = "9lógin\"]"; + let password = "S3cret'); DROP LOGIN victim;--"; + + assert_eq!( + users::build_create_user_sql(user, login), + "CREATE USER [9usér\"]]] FOR LOGIN [9lógin\"]]]" + ); + assert_eq!(users::build_drop_user_sql(user), "DROP USER [9usér\"]]]"); + assert_eq!( + users::build_drop_login_sql(login), + "DROP LOGIN [9lógin\"]]]" + ); + + let create = users::build_create_login_sql(login, password); + let alter = users::build_set_password_sql(login, password); + for sql in [create, alter] { + assert!(sql.contains("[9lógin\"]]]")); + assert!(sql.contains("PASSWORD = N'S3cret''); DROP LOGIN victim;--'")); + } + + assert_eq!( + users::build_permission_change_sql( + "9database\"]", + user, + Some(SCHEMA), + Some(TABLE), + "select", + true, + ) + .unwrap(), + "GRANT SELECT ON OBJECT::[9schéma]]'].[[weird\"name]]]]] TO [9usér\"]]]" + ); + assert!(users::build_permission_change_sql( + "database", + user, + Some(SCHEMA), + Some(TABLE), + "SELECT; DROP TABLE victims", + true, + ) + .is_err()); +} diff --git a/src/driver/types.rs b/src/driver/types.rs index 839082e..6e69081 100644 --- a/src/driver/types.rs +++ b/src/driver/types.rs @@ -103,7 +103,7 @@ pub fn get_data_types() -> Vec { // Character strings DataTypeInfo { name: "CHAR".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: true, requires_precision: false, default_length: Some("1".to_string()), @@ -112,7 +112,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "VARCHAR".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: true, requires_precision: false, default_length: Some("255".to_string()), @@ -121,7 +121,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "VARCHAR(MAX)".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -130,7 +130,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "TEXT".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -140,7 +140,7 @@ pub fn get_data_types() -> Vec { // Unicode strings DataTypeInfo { name: "NCHAR".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: true, requires_precision: false, default_length: Some("1".to_string()), @@ -149,7 +149,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "NVARCHAR".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: true, requires_precision: false, default_length: Some("255".to_string()), @@ -158,7 +158,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "NVARCHAR(MAX)".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -167,7 +167,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "NTEXT".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -214,7 +214,7 @@ pub fn get_data_types() -> Vec { // Date / time DataTypeInfo { name: "DATE".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -223,7 +223,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "TIME".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -232,7 +232,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "DATETIME".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -241,7 +241,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "DATETIME2".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -250,7 +250,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "SMALLDATETIME".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -259,7 +259,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "DATETIMEOFFSET".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, diff --git a/src/driver/types/tests.rs b/src/driver/types/tests.rs index 0c93b7c..4347a53 100644 --- a/src/driver/types/tests.rs +++ b/src/driver/types/tests.rs @@ -1,5 +1,23 @@ use super::*; +#[test] +fn manifest_data_types_match_driver_types() { + let manifest: serde_json::Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/.tabularium" + ))) + .expect(".tabularium must be valid JSON"); + let manifest_types: Vec = serde_json::from_value( + manifest + .get("data_types") + .cloned() + .expect(".tabularium must declare data_types"), + ) + .expect(".tabularium data_types must use the host data-type shape"); + + assert_eq!(manifest_types, get_data_types()); +} + #[test] fn types_list_is_non_empty() { let types = get_data_types(); diff --git a/src/driver/users.rs b/src/driver/users.rs new file mode 100644 index 0000000..1b867a9 --- /dev/null +++ b/src/driver/users.rs @@ -0,0 +1,766 @@ +//! SQL Server database-user and database-permission management. +//! +//! Tabularis models MySQL-style global/database/table scopes. For SQL Server +//! we map those three wire shapes to database/schema/object respectively: +//! `(None, None)`, `(Some(schema), None)`, and +//! `(Some(schema), Some(object))`. + +use std::collections::BTreeMap; + +use mssql_tds::message::transaction_management::TransactionIsolationLevel; +use mssql_tiberius_bridge::Row; + +use crate::driver::helpers::bracket_quote; +use crate::driver::pool::BridgeConnection; +use crate::models::{DbPrivilegeCatalog, DbUserGrantSet, DbUserInfo}; + +const DATABASE_AND_SCHEMA_PRIVILEGES: &[&str] = &[ + "ALTER", + "CONTROL", + "DELETE", + "EXECUTE", + "INSERT", + "REFERENCES", + "SELECT", + "TAKE OWNERSHIP", + "UPDATE", + "VIEW CHANGE TRACKING", + "VIEW DEFINITION", +]; + +const DATABASE_ONLY_PRIVILEGES: &[&str] = &[ + "AUTHENTICATE", + "BACKUP DATABASE", + "BACKUP LOG", + "CHECKPOINT", + "CONNECT", + "CREATE FUNCTION", + "CREATE PROCEDURE", + "CREATE ROLE", + "CREATE SCHEMA", + "CREATE SYNONYM", + "CREATE TABLE", + "CREATE TYPE", + "CREATE VIEW", + "SHOWPLAN", + "SUBSCRIBE QUERY NOTIFICATIONS", + "UNMASK", + "VIEW DATABASE STATE", +]; + +const OBJECT_PRIVILEGES: &[&str] = &[ + "ALTER", + "CONTROL", + "DELETE", + "EXECUTE", + "INSERT", + "RECEIVE", + "REFERENCES", + "SELECT", + "TAKE OWNERSHIP", + "UPDATE", + "VIEW CHANGE TRACKING", + "VIEW DEFINITION", +]; + +const LIST_USERS: &str = r#" +SELECT dp.name AS user_name, + sp.name AS login_name, + CAST(ISNULL(LOGINPROPERTY(sp.name, 'IsLocked'), 0) AS bit) AS is_locked +FROM sys.database_principals AS dp +JOIN sys.server_principals AS sp + ON sp.sid = dp.sid AND sp.type = 'S' +WHERE dp.type = 'S' + AND dp.authentication_type = 1 + AND dp.principal_id > 4 + AND dp.name NOT IN ('dbo', 'guest', 'INFORMATION_SCHEMA', 'sys') +ORDER BY dp.name, sp.name +"#; + +const ACCOUNT_EXISTS: &str = r#" +SELECT CAST(CASE WHEN EXISTS ( + SELECT 1 + FROM sys.database_principals AS dp + JOIN sys.server_principals AS sp ON sp.sid = dp.sid AND sp.type = 'S' + WHERE dp.type = 'S' AND dp.authentication_type = 1 + AND dp.name = @P1 AND sp.name = @P2 +) THEN 1 ELSE 0 END AS bit) +"#; + +const LOGIN_EXISTS: &str = r#" +SELECT CAST(CASE WHEN EXISTS ( + SELECT 1 FROM sys.server_principals WHERE type = 'S' AND name = @P1 +) THEN 1 ELSE 0 END AS bit) +"#; + +const USER_EXISTS: &str = r#" +SELECT CAST(CASE WHEN EXISTS ( + SELECT 1 FROM sys.database_principals WHERE name = @P1 +) THEN 1 ELSE 0 END AS bit) +"#; + +const DIRECT_PERMISSIONS: &str = r#" +SELECT CAST('DIRECT' AS nvarchar(128)) AS source_name, + p.state_desc, + p.permission_name, + p.class_desc, + CASE WHEN p.class = 3 THEN SCHEMA_NAME(p.major_id) + WHEN p.class = 1 THEN OBJECT_SCHEMA_NAME(p.major_id) + ELSE DB_NAME() END AS scope_name, + CASE WHEN p.class = 1 THEN OBJECT_NAME(p.major_id) ELSE NULL END AS object_name +FROM sys.database_permissions AS p +JOIN sys.database_principals AS grantee ON grantee.principal_id = p.grantee_principal_id +WHERE grantee.name = @P1 + AND p.class IN (0, 1, 3) + AND (p.class <> 1 OR p.minor_id = 0) +ORDER BY p.class, scope_name, object_name, p.permission_name +"#; + +const INHERITED_PERMISSIONS: &str = r#" +WITH role_tree AS ( + SELECT drm.role_principal_id + FROM sys.database_role_members AS drm + JOIN sys.database_principals AS member + ON member.principal_id = drm.member_principal_id + WHERE member.name = @P1 + UNION ALL + SELECT drm.role_principal_id + FROM sys.database_role_members AS drm + JOIN role_tree AS child ON child.role_principal_id = drm.member_principal_id +) +SELECT role.name AS source_name, + p.state_desc, + p.permission_name, + p.class_desc, + CASE WHEN p.class = 3 THEN SCHEMA_NAME(p.major_id) + WHEN p.class = 1 THEN OBJECT_SCHEMA_NAME(p.major_id) + ELSE DB_NAME() END AS scope_name, + CASE WHEN p.class = 1 THEN OBJECT_NAME(p.major_id) ELSE NULL END AS object_name +FROM role_tree AS tree +JOIN sys.database_principals AS role ON role.principal_id = tree.role_principal_id +JOIN sys.database_permissions AS p ON p.grantee_principal_id = role.principal_id +WHERE p.class IN (0, 1, 3) + AND (p.class <> 1 OR p.minor_id = 0) +ORDER BY role.name, p.class, scope_name, object_name, p.permission_name +OPTION (MAXRECURSION 32) +"#; + +const ROLE_MEMBERSHIPS: &str = r#" +WITH role_tree AS ( + SELECT drm.role_principal_id + FROM sys.database_role_members AS drm + JOIN sys.database_principals AS member + ON member.principal_id = drm.member_principal_id + WHERE member.name = @P1 + UNION ALL + SELECT drm.role_principal_id + FROM sys.database_role_members AS drm + JOIN role_tree AS child ON child.role_principal_id = drm.member_principal_id +) +SELECT DISTINCT role.name +FROM role_tree AS tree +JOIN sys.database_principals AS role ON role.principal_id = tree.role_principal_id +ORDER BY role.name +OPTION (MAXRECURSION 32) +"#; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PermissionScope { + Database(String), + Schema(String), + Object { schema: String, object: String }, +} + +#[derive(Debug, Clone)] +struct Permission { + source: String, + state: String, + name: String, + scope: PermissionScope, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RequestedScope { + Database, + Schema(String), + Object { schema: String, object: String }, +} + +impl RequestedScope { + fn from_wire(database: Option<&str>, table: Option<&str>) -> Result { + match (database, table) { + (None, None) => Ok(Self::Database), + (Some(schema), None) if !schema.trim().is_empty() => { + Ok(Self::Schema(schema.to_string())) + } + (Some(schema), Some(object)) + if !schema.trim().is_empty() && !object.trim().is_empty() => + { + Ok(Self::Object { + schema: schema.to_string(), + object: object.to_string(), + }) + } + (None, Some(_)) => Err("An object scope requires a schema".to_string()), + _ => Err("Schema and object names cannot be empty".to_string()), + } + } + + fn target_sql(&self, database_name: &str) -> String { + match self { + Self::Database => format!("DATABASE::{}", bracket_quote(database_name)), + Self::Schema(schema) => format!("SCHEMA::{}", bracket_quote(schema)), + Self::Object { schema, object } => format!( + "OBJECT::{}.{}", + bracket_quote(schema), + bracket_quote(object) + ), + } + } + + fn allows(&self, privilege: &str) -> bool { + match self { + Self::Database => { + DATABASE_AND_SCHEMA_PRIVILEGES.contains(&privilege) + || DATABASE_ONLY_PRIVILEGES.contains(&privilege) + } + Self::Schema(_) => DATABASE_AND_SCHEMA_PRIVILEGES.contains(&privilege), + Self::Object { .. } => OBJECT_PRIVILEGES.contains(&privilege), + } + } + + fn matches(&self, permission: &PermissionScope) -> bool { + match (self, permission) { + (Self::Database, PermissionScope::Database(_)) => true, + (Self::Schema(requested), PermissionScope::Schema(actual)) => { + requested.eq_ignore_ascii_case(actual) + } + ( + Self::Object { + schema: requested_schema, + object: requested_object, + }, + PermissionScope::Object { + schema: actual_schema, + object: actual_object, + }, + ) => { + requested_schema.eq_ignore_ascii_case(actual_schema) + && requested_object.eq_ignore_ascii_case(actual_object) + } + _ => false, + } + } +} + +pub fn privilege_catalog() -> DbPrivilegeCatalog { + DbPrivilegeCatalog { + // The frontend shows `database + global` for its top-level card and + // `database` for its middle card. We use those as database and schema + // respectively, so `global` contains database-only permissions. + database: strings(DATABASE_AND_SCHEMA_PRIVILEGES), + global: strings(DATABASE_ONLY_PRIVILEGES), + table: strings(OBJECT_PRIVILEGES), + } +} + +fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() +} + +fn validate_account(user: &str, login: &str) -> Result<(), String> { + if user.trim().is_empty() { + return Err("Database user name cannot be empty".to_string()); + } + if login.trim().is_empty() { + return Err("SQL Server login name cannot be empty".to_string()); + } + Ok(()) +} + +fn password_literal(password: &str) -> String { + format!("N'{}'", password.replace('\'', "''")) +} + +pub(crate) fn build_create_login_sql(login: &str, password: &str) -> String { + format!( + "CREATE LOGIN {} WITH PASSWORD = {}, CHECK_POLICY = ON, CHECK_EXPIRATION = OFF", + bracket_quote(login), + password_literal(password) + ) +} + +pub(crate) fn build_create_user_sql(user: &str, login: &str) -> String { + format!( + "CREATE USER {} FOR LOGIN {}", + bracket_quote(user), + bracket_quote(login) + ) +} + +pub(crate) fn build_drop_user_sql(user: &str) -> String { + format!("DROP USER {}", bracket_quote(user)) +} + +pub(crate) fn build_drop_login_sql(login: &str) -> String { + format!("DROP LOGIN {}", bracket_quote(login)) +} + +pub(crate) fn build_set_password_sql(login: &str, password: &str) -> String { + format!( + "ALTER LOGIN {} WITH PASSWORD = {}", + bracket_quote(login), + password_literal(password) + ) +} + +fn redact_password(mut message: String, password: &str) -> String { + if !password.is_empty() { + message = message.replace(password, "[REDACTED]"); + let escaped = password.replace('\'', "''"); + if escaped != password { + message = message.replace(&escaped, "[REDACTED]"); + } + } + message +} + +async fn execute_transaction_batch( + conn: &mut BridgeConnection, + statements: &[String], +) -> Result<(), String> { + // SQL BEGIN/COMMIT sent as a regular batch triggers SQL Server error 3981 + // with this preview client. Its TDS transaction-management API carries the + // transaction descriptor correctly, so use that API around raw language + // batches and explicitly roll back the first failed statement. + let query_timeout_seconds = conn.query_timeout_seconds(); + let client = conn.inner_mut(); + client + .close_query() + .await + .map_err(|error| error.to_string())?; + client + .begin_transaction(TransactionIsolationLevel::ReadCommitted, None) + .await + .map_err(|error| error.to_string())?; + + for statement in statements { + let outcome = match client + .execute(statement.clone(), query_timeout_seconds, None) + .await + { + Ok(()) => client.close_query().await, + Err(error) => Err(error), + }; + if let Err(error) = outcome { + let _ = client.close_query().await; + let rollback = client.rollback_transaction(None, None).await; + return Err(match rollback { + Ok(()) => error.to_string(), + Err(rollback_error) => { + format!("{error}; transaction rollback also failed: {rollback_error}") + } + }); + } + } + + client + .commit_transaction(None, None) + .await + .map_err(|error| error.to_string()) +} + +async fn query_bool( + conn: &mut BridgeConnection, + query: &str, + values: &[&dyn mssql_tiberius_bridge::ToSql], +) -> Result { + Ok(conn + .query(query, values) + .await + .map_err(|error| error.to_string())? + .into_first_result() + .first() + .and_then(|row| row.get::(0)) + .unwrap_or(false)) +} + +async fn ensure_account( + conn: &mut BridgeConnection, + user: &str, + login: &str, +) -> Result<(), String> { + validate_account(user, login)?; + if query_bool(conn, ACCOUNT_EXISTS, &[&user, &login]).await? { + Ok(()) + } else { + Err(format!( + "Database user {} is not mapped to SQL Server login {} in the current database", + bracket_quote(user), + bracket_quote(login) + )) + } +} + +pub async fn get_users(conn: &mut BridgeConnection) -> Result, String> { + let rows = conn + .simple_query(LIST_USERS) + .await + .map_err(|error| format!("Failed to list SQL Server database users: {error}"))? + .into_first_result(); + Ok(rows + .into_iter() + .filter_map(|row| { + Some(DbUserInfo { + user: row.get::<&str, _>("user_name")?.to_string(), + host: row.get::<&str, _>("login_name")?.to_string(), + locked: row.get::("is_locked").unwrap_or(false), + }) + }) + .collect()) +} + +pub async fn create_user( + conn: &mut BridgeConnection, + user: &str, + login: &str, + password: &str, +) -> Result<(), String> { + validate_account(user, login)?; + if query_bool(conn, LOGIN_EXISTS, &[&login]).await? { + return Err(format!( + "SQL Server login {} already exists", + bracket_quote(login) + )); + } + if query_bool(conn, USER_EXISTS, &[&user]).await? { + return Err(format!( + "Database principal {} already exists in the current database", + bracket_quote(user) + )); + } + + let create_login = build_create_login_sql(login, password); + conn.simple_query(create_login) + .await + .map_err(|error| { + redact_password( + format!( + "Failed to create SQL Server login {}: {error}", + bracket_quote(login) + ), + password, + ) + })? + .into_results(); + + let create_database_user = build_create_user_sql(user, login); + if let Err(error) = conn.simple_query(create_database_user).await { + let cleanup = conn.simple_query(build_drop_login_sql(login)).await; + let cleanup_note = cleanup + .err() + .map(|cleanup_error| format!("; login cleanup also failed: {cleanup_error}")) + .unwrap_or_default(); + return Err(redact_password( + format!( + "Failed to create database user {} for login {}: {error}{cleanup_note}", + bracket_quote(user), + bracket_quote(login) + ), + password, + )); + } + Ok(()) +} + +pub async fn drop_user(conn: &mut BridgeConnection, user: &str, login: &str) -> Result<(), String> { + ensure_account(conn, user, login).await?; + conn.simple_query(build_drop_user_sql(user)).await + .map_err(|error| { + format!( + "Failed to drop database user {}. SQL Server may be protecting a schema or object owned by this user: {error}", + bracket_quote(user) + ) + })? + .into_results(); + conn.simple_query(build_drop_login_sql(login)).await + .map_err(|error| { + format!( + "Database user {} was dropped, but its SQL Server login {} could not be dropped: {error}", + bracket_quote(user), + bracket_quote(login) + ) + })? + .into_results(); + Ok(()) +} + +pub async fn set_password( + conn: &mut BridgeConnection, + user: &str, + login: &str, + password: &str, +) -> Result<(), String> { + ensure_account(conn, user, login).await?; + let sql = build_set_password_sql(login, password); + conn.simple_query(sql) + .await + .map_err(|error| { + redact_password( + format!( + "Failed to change password for SQL Server login {}: {error}", + bracket_quote(login) + ), + password, + ) + })? + .into_results(); + Ok(()) +} + +fn permission_from_row(row: &Row) -> Option { + let class = row.get::<&str, _>("class_desc")?; + let scope_name = row.get::<&str, _>("scope_name").unwrap_or(""); + let scope = match class { + "DATABASE" => PermissionScope::Database(scope_name.to_string()), + "SCHEMA" => PermissionScope::Schema(scope_name.to_string()), + "OBJECT_OR_COLUMN" => PermissionScope::Object { + schema: scope_name.to_string(), + object: row.get::<&str, _>("object_name")?.to_string(), + }, + _ => return None, + }; + Some(Permission { + source: row.get::<&str, _>("source_name")?.to_string(), + state: row.get::<&str, _>("state_desc")?.to_string(), + name: row.get::<&str, _>("permission_name")?.to_string(), + scope, + }) +} + +async fn permissions( + conn: &mut BridgeConnection, + user: &str, + inherited: bool, +) -> Result, String> { + let sql = if inherited { + INHERITED_PERMISSIONS + } else { + DIRECT_PERMISSIONS + }; + Ok(conn + .query(sql, &[&user]) + .await + .map_err(|error| format!("Failed to inspect SQL Server permissions: {error}"))? + .into_first_result() + .iter() + .filter_map(permission_from_row) + .collect()) +} + +fn scope_wire(scope: &PermissionScope) -> (Option, Option) { + match scope { + PermissionScope::Database(_) => (None, None), + PermissionScope::Schema(schema) => (Some(schema.clone()), None), + PermissionScope::Object { schema, object } => (Some(schema.clone()), Some(object.clone())), + } +} + +fn permission_sql(permission: &Permission, user: &str) -> String { + let target = match &permission.scope { + PermissionScope::Database(database) => { + format!("DATABASE::{}", bracket_quote(database)) + } + PermissionScope::Schema(schema) => { + format!("SCHEMA::{}", bracket_quote(schema)) + } + PermissionScope::Object { schema, object } => format!( + "OBJECT::{}.{}", + bracket_quote(schema), + bracket_quote(object) + ), + }; + let verb = if permission.state == "DENY" { + "DENY" + } else { + "GRANT" + }; + let suffix = if permission.state == "GRANT_WITH_GRANT_OPTION" { + " WITH GRANT OPTION" + } else { + "" + }; + format!( + "{verb} {} ON {target} TO {}{suffix}", + permission.name, + bracket_quote(user) + ) +} + +pub async fn get_grants( + conn: &mut BridgeConnection, + user: &str, + login: &str, +) -> Result, String> { + ensure_account(conn, user, login).await?; + let direct = permissions(conn, user, false).await?; + let inherited = permissions(conn, user, true).await?; + let role_rows = conn + .query(ROLE_MEMBERSHIPS, &[&user]) + .await + .map_err(|error| format!("Failed to inspect SQL Server role memberships: {error}"))? + .into_first_result(); + + let mut lines = direct + .iter() + .map(|permission| permission_sql(permission, user)) + .collect::>(); + lines.extend(role_rows.iter().filter_map(|row| { + row.get::<&str, _>(0).map(|role| { + format!( + "ROLE MEMBERSHIP: ALTER ROLE {} ADD MEMBER {}", + bracket_quote(role), + bracket_quote(user) + ) + }) + })); + lines.extend(inherited.iter().map(|permission| { + format!( + "INHERITED VIA ROLE {}: {}", + bracket_quote(&permission.source), + permission_sql(permission, &permission.source) + ) + })); + Ok(lines) +} + +pub async fn get_privileges( + conn: &mut BridgeConnection, + user: &str, + login: &str, +) -> Result, String> { + ensure_account(conn, user, login).await?; + let mut grouped: BTreeMap<(Option, Option), Vec> = BTreeMap::new(); + for permission in permissions(conn, user, false).await? { + // DENY and inherited role rights stay in the raw grants view. Showing + // either as a checked direct grant would make the editor lie about + // what a REVOKE can remove. + if permission.state != "GRANT" && permission.state != "GRANT_WITH_GRANT_OPTION" { + continue; + } + let names = grouped.entry(scope_wire(&permission.scope)).or_default(); + if !names.contains(&permission.name) { + names.push(permission.name); + names.sort(); + } + } + Ok(grouped + .into_iter() + .map(|((database, table), privileges)| DbUserGrantSet { + database, + table, + privileges, + }) + .collect()) +} + +fn canonical_privileges( + scope: &RequestedScope, + privileges: &[String], +) -> Result, String> { + if privileges.is_empty() { + return Err("No privileges selected".to_string()); + } + let mut canonical = Vec::with_capacity(privileges.len()); + for privilege in privileges { + let name = privilege.trim().to_uppercase(); + if !scope.allows(name.as_str()) { + return Err(format!( + "Unsupported SQL Server privilege '{privilege}' for this scope" + )); + } + if !canonical.contains(&name) { + canonical.push(name); + } + } + Ok(canonical) +} + +pub(crate) fn build_permission_change_sql( + database_name: &str, + user: &str, + database: Option<&str>, + table: Option<&str>, + privilege: &str, + grant: bool, +) -> Result { + let scope = RequestedScope::from_wire(database, table)?; + let privilege = canonical_privileges(&scope, &[privilege.to_string()])? + .into_iter() + .next() + .expect("one validated privilege"); + let verb = if grant { "GRANT" } else { "REVOKE" }; + let preposition = if grant { "TO" } else { "FROM" }; + Ok(format!( + "{verb} {privilege} ON {} {preposition} {}", + scope.target_sql(database_name), + bracket_quote(user) + )) +} + +#[allow(clippy::too_many_arguments)] +pub async fn apply_privileges( + conn: &mut BridgeConnection, + database_name: &str, + user: &str, + login: &str, + database: Option<&str>, + table: Option<&str>, + privileges: &[String], + grant: bool, +) -> Result<(), String> { + ensure_account(conn, user, login).await?; + let scope = RequestedScope::from_wire(database, table)?; + let requested = canonical_privileges(&scope, privileges)?; + let current = permissions(conn, user, false).await?; + + let mut statements = Vec::new(); + for privilege in requested { + let states = current + .iter() + .filter(|permission| { + scope.matches(&permission.scope) && permission.name.eq_ignore_ascii_case(&privilege) + }) + .map(|permission| permission.state.as_str()) + .collect::>(); + if states.contains(&"DENY") { + return Err(format!( + "Cannot manage denied permission '{privilege}': remove the SQL Server DENY explicitly before using Tabularis" + )); + } + let already_granted = states + .iter() + .any(|state| matches!(*state, "GRANT" | "GRANT_WITH_GRANT_OPTION")); + if already_granted == grant { + continue; + } + statements.push(build_permission_change_sql( + database_name, + user, + database, + table, + &privilege, + grant, + )?); + } + + if statements.is_empty() { + return Ok(()); + } + execute_transaction_batch(conn, &statements) + .await + .map_err(|error| format!("Failed to apply SQL Server privilege diff: {error}")) +} + +#[cfg(test)] +mod tests; diff --git a/src/driver/users/tests.rs b/src/driver/users/tests.rs new file mode 100644 index 0000000..7d3ffde --- /dev/null +++ b/src/driver/users/tests.rs @@ -0,0 +1,120 @@ +use super::*; + +#[test] +fn catalog_separates_database_schema_and_object_permissions() { + let catalog = privilege_catalog(); + assert!(catalog.database.contains(&"SELECT".to_string())); + assert!(catalog.global.contains(&"CREATE TABLE".to_string())); + assert!(!catalog.database.contains(&"CREATE TABLE".to_string())); + assert!(catalog.table.contains(&"RECEIVE".to_string())); +} + +#[test] +fn wire_scopes_map_to_database_schema_and_object() { + assert_eq!( + RequestedScope::from_wire(None, None).unwrap(), + RequestedScope::Database + ); + assert_eq!( + RequestedScope::from_wire(Some("sales"), None).unwrap(), + RequestedScope::Schema("sales".to_string()) + ); + assert_eq!( + RequestedScope::from_wire(Some("sales"), Some("orders")).unwrap(), + RequestedScope::Object { + schema: "sales".to_string(), + object: "orders".to_string() + } + ); + assert!(RequestedScope::from_wire(None, Some("orders")).is_err()); +} + +#[test] +fn targets_bracket_quote_every_identifier() { + assert_eq!( + RequestedScope::Database.target_sql("db]name"), + "DATABASE::[db]]name]" + ); + assert_eq!( + RequestedScope::Schema("schema]name".to_string()).target_sql("ignored"), + "SCHEMA::[schema]]name]" + ); + assert_eq!( + RequestedScope::Object { + schema: "odd]schema".to_string(), + object: "odd]object".to_string(), + } + .target_sql("ignored"), + "OBJECT::[odd]]schema].[odd]]object]" + ); +} + +#[test] +fn privileges_are_scope_validated_and_deduplicated() { + let database = canonical_privileges( + &RequestedScope::Database, + &[ + "select".to_string(), + " SELECT ".to_string(), + "SHOWPLAN".to_string(), + ], + ) + .unwrap(); + assert_eq!(database, ["SELECT", "SHOWPLAN"]); + + let schema = canonical_privileges( + &RequestedScope::Schema("dbo".to_string()), + &["CREATE TABLE".to_string()], + ); + assert!(schema.unwrap_err().contains("Unsupported")); +} + +#[test] +fn password_errors_are_redacted() { + let password = "Secret'Value"; + let error = redact_password( + "server rejected Secret'Value represented as Secret''Value".to_string(), + password, + ); + assert!(!error.contains("Secret")); + assert!(error.contains("[REDACTED]")); +} + +#[test] +fn permission_rendering_marks_scope_deny_and_grant_option() { + let database = Permission { + source: "DIRECT".to_string(), + state: "GRANT".to_string(), + name: "CONNECT".to_string(), + scope: PermissionScope::Database("db]name".to_string()), + }; + assert_eq!( + permission_sql(&database, "reader"), + "GRANT CONNECT ON DATABASE::[db]]name] TO [reader]" + ); + + let denied = Permission { + source: "DIRECT".to_string(), + state: "DENY".to_string(), + name: "DELETE".to_string(), + scope: PermissionScope::Schema("sales".to_string()), + }; + assert_eq!( + permission_sql(&denied, "reader]name"), + "DENY DELETE ON SCHEMA::[sales] TO [reader]]name]" + ); + + let grantable = Permission { + source: "DIRECT".to_string(), + state: "GRANT_WITH_GRANT_OPTION".to_string(), + name: "SELECT".to_string(), + scope: PermissionScope::Object { + schema: "sales".to_string(), + object: "orders".to_string(), + }, + }; + assert_eq!( + permission_sql(&grantable, "reader"), + "GRANT SELECT ON OBJECT::[sales].[orders] TO [reader] WITH GRANT OPTION" + ); +} diff --git a/src/handlers/blob.rs b/src/handlers/blob.rs new file mode 100644 index 0000000..27bb23e --- /dev/null +++ b/src/handlers/blob.rs @@ -0,0 +1,76 @@ +//! JSON-RPC adapters for SQL Server binary-column export and preview. + +use serde_json::Value; + +use crate::driver::{blob as driver_blob, ops}; +use crate::models::PkMap; +use crate::rpc::{conn_params, opt_str, req_field, req_str, respond}; + +pub async fn save_blob_to_file(id: Value, params: &Value) -> Value { + let (conn, table, col_name, file_path) = match ( + conn_params(params), + req_str(params, "table"), + req_str(params, "col_name"), + req_str(params, "file_path"), + ) { + (Ok(conn), Ok(table), Ok(col_name), Ok(file_path)) => (conn, table, col_name, file_path), + (Err(error), _, _, _) + | (_, Err(error), _, _) + | (_, _, Err(error), _) + | (_, _, _, Err(error)) => return respond::<()>(id, Err(error)), + }; + let pk_map: PkMap = match req_field(params, "pk_map") { + Ok(pk_map) => pk_map, + Err(error) => return respond::<()>(id, Err(error)), + }; + + respond( + id, + ops::save_blob_to_file( + &conn, + table, + col_name, + &pk_map, + opt_str(params, "schema"), + file_path, + ) + .await, + ) +} + +pub async fn fetch_blob_as_data_url(id: Value, params: &Value) -> Value { + let (conn, table, col_name) = match ( + conn_params(params), + req_str(params, "table"), + req_str(params, "col_name"), + ) { + (Ok(conn), Ok(table), Ok(col_name)) => (conn, table, col_name), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => { + return respond::<()>(id, Err(error)) + } + }; + let pk_map: PkMap = match req_field(params, "pk_map") { + Ok(pk_map) => pk_map, + Err(error) => return respond::<()>(id, Err(error)), + }; + let max_blob_size = match params.get("max_blob_size") { + Some(_) => match req_field(params, "max_blob_size") { + Ok(max_blob_size) => max_blob_size, + Err(error) => return respond::<()>(id, Err(error)), + }, + None => driver_blob::DEFAULT_MAX_BLOB_SIZE, + }; + + respond( + id, + ops::fetch_blob_as_data_url( + &conn, + table, + col_name, + &pk_map, + opt_str(params, "schema"), + max_blob_size, + ) + .await, + ) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 6300487..26f91e5 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,7 +1,9 @@ +pub mod blob; pub mod crud; pub mod ddl; pub mod metadata; pub mod query; pub mod routines; pub mod triggers; +pub mod users; pub mod views; diff --git a/src/handlers/users.rs b/src/handlers/users.rs new file mode 100644 index 0000000..c40ec7b --- /dev/null +++ b/src/handlers/users.rs @@ -0,0 +1,129 @@ +//! JSON-RPC adapters for SQL Server database users and privileges. + +use serde_json::Value; + +use crate::driver::ops; +use crate::rpc::{conn_params, opt_str, req_field, req_str, respond}; + +pub async fn get_db_privilege_catalog(id: Value) -> Value { + respond(id, Ok(ops::get_db_privilege_catalog())) +} + +pub async fn get_db_users(id: Value, params: &Value) -> Value { + let conn = match conn_params(params) { + Ok(conn) => conn, + Err(error) => return respond::<()>(id, Err(error)), + }; + respond(id, ops::get_db_users(&conn).await) +} + +pub async fn create_db_user(id: Value, params: &Value) -> Value { + let (conn, user, login, password) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + req_str(params, "password"), + ) { + (Ok(conn), Ok(user), Ok(login), Ok(password)) => (conn, user, login, password), + (Err(error), _, _, _) + | (_, Err(error), _, _) + | (_, _, Err(error), _) + | (_, _, _, Err(error)) => return respond::<()>(id, Err(error)), + }; + respond(id, ops::create_db_user(&conn, user, login, password).await) +} + +pub async fn drop_db_user(id: Value, params: &Value) -> Value { + let (conn, user, login) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + ) { + (Ok(conn), Ok(user), Ok(login)) => (conn, user, login), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => { + return respond::<()>(id, Err(error)) + } + }; + respond(id, ops::drop_db_user(&conn, user, login).await) +} + +pub async fn set_db_user_password(id: Value, params: &Value) -> Value { + let (conn, user, login, password) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + req_str(params, "password"), + ) { + (Ok(conn), Ok(user), Ok(login), Ok(password)) => (conn, user, login, password), + (Err(error), _, _, _) + | (_, Err(error), _, _) + | (_, _, Err(error), _) + | (_, _, _, Err(error)) => return respond::<()>(id, Err(error)), + }; + respond( + id, + ops::set_db_user_password(&conn, user, login, password).await, + ) +} + +pub async fn get_db_user_grants(id: Value, params: &Value) -> Value { + let (conn, user, login) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + ) { + (Ok(conn), Ok(user), Ok(login)) => (conn, user, login), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => { + return respond::<()>(id, Err(error)) + } + }; + respond(id, ops::get_db_user_grants(&conn, user, login).await) +} + +pub async fn get_db_user_privileges(id: Value, params: &Value) -> Value { + let (conn, user, login) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + ) { + (Ok(conn), Ok(user), Ok(login)) => (conn, user, login), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => { + return respond::<()>(id, Err(error)) + } + }; + respond(id, ops::get_db_user_privileges(&conn, user, login).await) +} + +pub async fn apply_db_user_privileges(id: Value, params: &Value) -> Value { + let (conn, user, login) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + ) { + (Ok(conn), Ok(user), Ok(login)) => (conn, user, login), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => { + return respond::<()>(id, Err(error)) + } + }; + let privileges: Vec = match req_field(params, "privileges") { + Ok(privileges) => privileges, + Err(error) => return respond::<()>(id, Err(error)), + }; + let grant: bool = match req_field(params, "grant") { + Ok(grant) => grant, + Err(error) => return respond::<()>(id, Err(error)), + }; + respond( + id, + ops::apply_db_user_privileges( + &conn, + user, + login, + opt_str(params, "database"), + opt_str(params, "table"), + &privileges, + grant, + ) + .await, + ) +} diff --git a/src/main.rs b/src/main.rs index 64b6139..616bf2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,35 +5,40 @@ //! are funneled through a single writer task so concurrent handlers never //! interleave bytes on stdout. -use std::{sync::Arc, time::Duration}; +use std::sync::Arc; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, sync::{mpsc, watch, Mutex}, - time::interval, + time::sleep, }; mod common; +mod connection; mod driver; mod handlers; mod models; mod pool_manager; mod rpc; +mod settings; +// This controls JSON-RPC dispatch concurrency rather than database +// connection concurrency, which is configured separately by max_pool_size. const WORKER_POOL_SIZE: usize = 4; -// Bounded so a burst of requests applies backpressure to the stdin reader -// instead of buffering unboundedly in memory. +// Both sides are bounded so backpressure reaches stdin even when the host is +// slow to consume responses. With four in-flight handlers this caps a burst at +// 134 queued or active payloads instead of moving it into an unbounded output +// queue (64 requests + 64 responses + 4 workers + reader + writer). const REQUEST_QUEUE_CAPACITY: usize = 64; - -const POOL_CLEANUP_INTERVAL: Duration = Duration::from_secs(600); // 10 minutes +const RESPONSE_QUEUE_CAPACITY: usize = 64; // The TDS client's async call chains produce large futures (especially in -// debug builds). A local SQL Server 2022 execute_query probe overflowed -// tokio's default 2 MiB stack while 4 MiB completed; 16 MiB is therefore a -// deliberate 4x safety margin, not a measured minimum. Keep the margin until -// the preview client flattens those polling chains or equivalent CI stress -// coverage proves a smaller stack across platforms. +// debug builds). A local SQL Server 2022 debug execute_query probe overflowed +// tokio's default 2 MiB stack while 4 MiB completed. The full release live +// suite also passed at 4 MiB in SS-045, but that does not remove the debug or +// cross-platform risk. Keep 16 MiB as a deliberate 4x margin until the preview +// client flattens those polling chains or CI stress proves less everywhere. const WORKER_STACK_SIZE: usize = 16 * 1024 * 1024; fn main() { @@ -53,7 +58,7 @@ async fn run() { let (req_tx, req_rx) = mpsc::channel::(REQUEST_QUEUE_CAPACITY); let req_rx = Arc::new(Mutex::new(req_rx)); - let (resp_tx, resp_rx) = mpsc::unbounded_channel::(); + let (resp_tx, resp_rx) = mpsc::channel::(RESPONSE_QUEUE_CAPACITY); let writer_handle = tokio::spawn(run_writer(resp_rx)); let worker_handles: Vec<_> = (0..WORKER_POOL_SIZE) @@ -73,10 +78,17 @@ async fn run() { } async fn run_pool_cleanup(mut shutdown_rx: watch::Receiver) { - let mut timer = interval(POOL_CLEANUP_INTERVAL); + let mut settings_rx = settings::subscribe(); loop { + let cleanup_interval = settings::current().pool_idle_eviction_interval(); tokio::select! { - _ = timer.tick() => pool_manager::cleanup_idle_pools().await, + _ = sleep(cleanup_interval) => pool_manager::cleanup_idle_pools().await, + // Reset the timer immediately when initialize supplies an override. + result = settings_rx.changed() => { + if result.is_err() { + break; + } + }, _ = shutdown_rx.changed() => break, } } @@ -107,10 +119,7 @@ async fn run_reader(req_tx: mpsc::Sender) { } } -async fn run_worker( - req_rx: Arc>>, - resp_tx: mpsc::UnboundedSender, -) { +async fn run_worker(req_rx: Arc>>, resp_tx: mpsc::Sender) { loop { let line = { let mut rx = req_rx.lock().await; @@ -129,13 +138,13 @@ async fn run_worker( ), }; - if resp_tx.send(body).is_err() { + if resp_tx.send(body).await.is_err() { break; } } } -async fn run_writer(mut resp_rx: mpsc::UnboundedReceiver) { +async fn run_writer(mut resp_rx: mpsc::Receiver) { let mut stdout = tokio::io::stdout(); while let Some(mut body) = resp_rx.recv().await { body.push('\n'); diff --git a/src/models.rs b/src/models.rs index 6386b2a..a2c6b30 100644 --- a/src/models.rs +++ b/src/models.rs @@ -53,6 +53,9 @@ pub struct ConnectionParams { pub ssl_ca: Option, pub ssl_cert: Option, pub ssl_key: Option, + /// URL or ADO.NET/ODBC keyword connection string. It is parsed and + /// reconciled with the discrete fields before a pool is selected. + pub connection_string: Option, /// SQL run on every new physical connection in the pool. Statements are /// separated by `;`. Runs per pooled connection so the setting applies to /// every query regardless of which connection the pool hands out. @@ -73,6 +76,8 @@ pub struct TableColumn { pub is_pk: bool, pub is_nullable: bool, pub is_auto_increment: bool, + #[serde(default)] + pub is_generated: bool, #[serde(skip_serializing_if = "Option::is_none")] pub default_value: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -96,6 +101,8 @@ pub struct Index { pub is_unique: bool, pub is_primary: bool, pub seq_in_index: i32, + #[serde(default)] + pub is_expression: bool, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -205,6 +212,33 @@ pub struct TriggerInfo { pub definition: Option, } +/// One database principal backed by a SQL Server login. The host's `host` +/// field carries the mapped login name for SQL Server. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DbUserInfo { + pub user: String, + pub host: String, + pub locked: bool, +} + +/// SQL Server privilege names accepted by the three host scope lists. +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct DbPrivilegeCatalog { + pub database: Vec, + pub global: Vec, + pub table: Vec, +} + +/// Direct grants at one database, schema, or object scope. SQL Server maps +/// those levels to `(None, None)`, `(Some(schema), None)`, and +/// `(Some(schema), Some(object))` on the host wire shape. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct DbUserGrantSet { + pub database: Option, + pub table: Option, + pub privileges: Vec, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ColumnDefinition { pub name: String, @@ -215,7 +249,7 @@ pub struct ColumnDefinition { pub default_value: Option, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct DataTypeInfo { pub name: String, pub category: String, diff --git a/src/pool_manager.rs b/src/pool_manager.rs index bc0a253..40c2a32 100644 --- a/src/pool_manager.rs +++ b/src/pool_manager.rs @@ -10,8 +10,10 @@ use deadpool::managed::Pool as DeadPool; use once_cell::sync::Lazy; use tokio::sync::RwLock; +use crate::connection::resolve_connection_params; use crate::driver::pool::{build_config, BridgeManager}; use crate::models::ConnectionParams; +use crate::settings; pub type SqlServerPool = DeadPool; type SqlServerPoolMap = Arc>>; @@ -36,7 +38,7 @@ fn build_connection_key(params: &ConnectionParams) -> String { "{}:{}:{}:{}:{}", params.driver, params.host.as_deref().unwrap_or("localhost"), - params.port.unwrap_or(0), + params.port.unwrap_or(1433), params.username.as_deref().unwrap_or(""), params.database ) @@ -54,26 +56,75 @@ fn startup_script(params: &ConnectionParams) -> Option { } pub async fn get_sqlserver_pool(params: &ConnectionParams) -> Result { - let key = build_connection_key(params); + let params = resolve_connection_params(params)?; + let key = build_connection_key(¶ms); let mut pools = SQLSERVER_POOLS.write().await; if let Some(pool) = pools.get(&key).cloned() { return Ok(pool); } - let manager = BridgeManager::new(build_config(params)?, startup_script(params)); + // A pool snapshots process settings. The host initializes the process + // before opening connections, and live pools are never mutated in place. + let settings = settings::current(); + let manager = BridgeManager::new( + build_config(¶ms, &settings)?, + startup_script(¶ms), + params.ssl_mode.as_deref().unwrap_or("prefer"), + &settings, + ); let pool = DeadPool::builder(manager) - .max_size(10) + .max_size(settings.max_pool_size) .build() .map_err(|error| error.to_string())?; pools.insert(key, pool.clone()); Ok(pool) } +/// Remove a cached pool after checkout failed. This lets corrected connection +/// parameters replace a failed lazy manager under the same connection id. +pub async fn remove_sqlserver_pool(params: &ConnectionParams) { + let Ok(params) = resolve_connection_params(params) else { + return; + }; + SQLSERVER_POOLS + .write() + .await + .remove(&build_connection_key(¶ms)); +} + /// Drop pools that currently have no checked-out connections. Called /// periodically so long-idle sessions don't linger for the plugin's lifetime. pub async fn cleanup_idle_pools() { let mut pools = SQLSERVER_POOLS.write().await; - pools.retain(|_, pool| pool.status().size > pool.status().available); + pools.retain(|_, pool| { + let status = pool.status(); + let has_checked_out_connection = status.size > status.available; + if !has_checked_out_connection { + // Explicit close makes the server-side sessions disappear now; + // removing the last registry handle alone would rely on drop + // timing inside deadpool. + pool.close(); + } + has_checked_out_connection + }); +} + +/// Close every cached pool and remove it from the process-wide registry. +pub async fn shutdown() { + let pools: Vec<_> = SQLSERVER_POOLS + .write() + .await + .drain() + .map(|(_, pool)| pool) + .collect(); + for pool in pools { + pool.close(); + } +} + +#[cfg(test)] +pub async fn pool_count() -> usize { + SQLSERVER_POOLS.read().await.len() } #[cfg(test)] @@ -105,4 +156,41 @@ mod tests { let key = build_connection_key(&p); assert_eq!(key, "sqlserver:localhost:1433:sa:master:ssl:require"); } + + #[test] + fn database_is_part_of_the_key_and_identical_params_are_stable() { + let master = params(Some("ss045-key")); + let identical = master.clone(); + let mut other_database = master.clone(); + other_database.database = crate::models::DatabaseSelection::Single("tempdb".into()); + + assert_eq!( + build_connection_key(&master), + build_connection_key(&identical) + ); + assert_ne!( + build_connection_key(&master), + build_connection_key(&other_database) + ); + } + + #[test] + fn equivalent_discrete_and_connection_string_params_share_a_key() { + let mut discrete = params(None); + discrete.ssl_mode = Some("require".into()); + let discrete = resolve_connection_params(&discrete).unwrap(); + let from_string = resolve_connection_params(&ConnectionParams { + connection_string: Some( + "sqlserver://sa@localhost/master?Encrypt=true&TrustServerCertificate=true".into(), + ), + password: Some(String::new()), + ..Default::default() + }) + .unwrap(); + + assert_eq!( + build_connection_key(&discrete), + build_connection_key(&from_string) + ); + } } diff --git a/src/rpc.rs b/src/rpc.rs index 0dcc7ed..2bfb00c 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -3,8 +3,37 @@ use serde::Serialize; use serde_json::{json, Value}; -use crate::handlers::{crud, ddl, metadata, query, routines, triggers, views}; +use crate::connection::resolve_connection_params; +use crate::driver::error::redact_connection_secrets; +use crate::handlers::{blob, crud, ddl, metadata, query, routines, triggers, users, views}; use crate::models::ConnectionParams; +use crate::{pool_manager, settings}; + +const PLUGIN_NAME: &str = "SQL Server plugin"; + +/// Host RPCs that SQL Server deliberately does not implement. +/// +/// Keep this list limited to methods present in the host protocol. The +/// coverage tests below require every host method to be dispatched or listed +/// here with a non-empty reason. +const NOT_IMPLEMENTED: &[(&str, &str)] = &[ + ( + "get_materialized_views", + "SQL Server has indexed views, not materialized views; indexed views are maintained synchronously", + ), + ( + "get_materialized_view_columns", + "SQL Server has indexed views, not materialized views; indexed views are maintained synchronously", + ), + ( + "get_materialized_view_definition", + "SQL Server has indexed views, not materialized views; indexed views are maintained synchronously", + ), + ( + "refresh_materialized_view", + "SQL Server indexed views are maintained synchronously and cannot be refreshed as materialized views", + ), +]; /// Parse one JSON-RPC line and return the response value (serialised /// downstream by `main.rs`). Never panics — parse errors and method @@ -24,9 +53,18 @@ pub async fn handle_line(line: &str) -> Value { let params = request.get("params").cloned().unwrap_or(Value::Null); match method.as_str() { - "initialize" => ok_response(id, Value::Null), + "initialize" => { + // Initialization is intentionally infallible: malformed known + // values warn and fall back, while unknown keys are ignored. + settings::initialize(¶ms); + ok_response(id, Value::Null) + } "ping" => query::ping(id, ¶ms).await, "test_connection" => query::test_connection(id, ¶ms).await, + "shutdown" => { + pool_manager::shutdown().await; + ok_response(id, Value::Null) + } // Metadata. "get_databases" => metadata::get_databases(id, ¶ms).await, @@ -63,6 +101,16 @@ pub async fn handle_line(line: &str) -> Value { "create_trigger" => triggers::create_trigger(id, ¶ms).await, "drop_trigger" => triggers::drop_trigger(id, ¶ms).await, + // Database users and privileges. + "get_db_privilege_catalog" => users::get_db_privilege_catalog(id).await, + "get_db_users" => users::get_db_users(id, ¶ms).await, + "create_db_user" => users::create_db_user(id, ¶ms).await, + "drop_db_user" => users::drop_db_user(id, ¶ms).await, + "set_db_user_password" => users::set_db_user_password(id, ¶ms).await, + "get_db_user_grants" => users::get_db_user_grants(id, ¶ms).await, + "get_db_user_privileges" => users::get_db_user_privileges(id, ¶ms).await, + "apply_db_user_privileges" => users::apply_db_user_privileges(id, ¶ms).await, + // Query execution. "execute_query" => query::execute_query(id, ¶ms).await, "execute_query_batch" => query::execute_query_batch(id, ¶ms).await, @@ -73,6 +121,10 @@ pub async fn handle_line(line: &str) -> Value { "update_record" => crud::update_record(id, ¶ms).await, "delete_record" => crud::delete_record(id, ¶ms).await, + // BLOB export and preview. + "save_blob_to_file" => blob::save_blob_to_file(id, ¶ms).await, + "fetch_blob_as_data_url" => blob::fetch_blob_as_data_url(id, ¶ms).await, + // DDL. "get_create_table_sql" => ddl::get_create_table_sql(id, ¶ms).await, "get_add_column_sql" => ddl::get_add_column_sql(id, ¶ms).await, @@ -82,7 +134,10 @@ pub async fn handle_line(line: &str) -> Value { "drop_index" => ddl::drop_index(id, ¶ms).await, "drop_foreign_key" => ddl::drop_foreign_key(id, ¶ms).await, - other => not_implemented(id, other), + other => match not_implemented_reason(other) { + Some(reason) => not_implemented(id, other, reason), + None => method_not_found(id, other), + }, } } @@ -102,11 +157,29 @@ pub fn error_response(id: Value, code: i64, message: &str) -> Value { }) } -pub fn not_implemented(id: Value, method: &str) -> Value { +fn not_implemented_reason(method: &str) -> Option<&'static str> { + NOT_IMPLEMENTED + .iter() + .find_map(|(candidate, reason)| (*candidate == method).then_some(*reason)) +} + +fn not_implemented(id: Value, method: &str, reason: &str) -> Value { error_response( id, -32601, - &format!("method '{method}' is not implemented by this plugin"), + &format!( + "Method not found (-32601): '{method}' is not implemented by {PLUGIN_NAME}: {reason}" + ), + ) +} + +fn method_not_found(id: Value, method: &str) -> Value { + error_response( + id, + -32601, + &format!( + "Method not found (-32601): '{method}' is not implemented by {PLUGIN_NAME}: unknown JSON-RPC method" + ), ) } @@ -124,8 +197,12 @@ pub fn respond(id: Value, outcome: Result) -> Value { /// Deserialize the nested `params.params` connection object every RPC method /// receives. pub fn conn_params(params: &Value) -> Result { - serde_json::from_value(params.get("params").cloned().unwrap_or(Value::Null)) - .map_err(|err| format!("invalid connection params: {err}")) + let params: ConnectionParams = + serde_json::from_value(params.get("params").cloned().unwrap_or(Value::Null)) + .map_err(|err| format!("invalid connection params: {err}"))?; + resolve_connection_params(¶ms).map_err(|error| { + redact_connection_secrets(format!("invalid connection params: {error}"), ¶ms) + }) } pub fn opt_str<'a>(params: &'a Value, key: &str) -> Option<&'a str> { @@ -141,3 +218,217 @@ pub fn req_field(params: &Value, key: &str) -> R serde_json::from_value(params.get(key).cloned().unwrap_or(Value::Null)) .map_err(|err| format!("invalid parameter '{key}': {err}")) } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + + /// Snapshot extracted from every literal `PluginProcess::call` and + /// `call_with_timeout` in Tabularis + /// `src-tauri/src/plugins/driver.rs` at core commit 9e6975aa. + const HOST_METHODS: &[&str] = &[ + "initialize", + "ping", + "test_connection", + "get_databases", + "get_schemas", + "get_tables", + "get_columns", + "get_foreign_keys", + "get_indexes", + "get_views", + "get_view_definition", + "get_view_columns", + "create_view", + "alter_view", + "drop_view", + "get_materialized_views", + "get_materialized_view_columns", + "get_materialized_view_definition", + "refresh_materialized_view", + "get_routines", + "get_routine_parameters", + "get_routine_definition", + "build_routine_call_sql", + "routine_create_template", + "get_routine_edit_script", + "drop_routine", + "execute_query", + "execute_query_batch", + "explain_query", + "insert_record", + "update_record", + "delete_record", + "save_blob_to_file", + "fetch_blob_as_data_url", + "get_create_table_sql", + "get_add_column_sql", + "get_alter_column_sql", + "get_create_index_sql", + "get_create_foreign_key_sql", + "drop_index", + "drop_foreign_key", + "get_triggers", + "get_db_privilege_catalog", + "get_db_users", + "get_db_user_grants", + "create_db_user", + "drop_db_user", + "set_db_user_password", + "get_db_user_privileges", + "apply_db_user_privileges", + "get_trigger_definition", + "create_trigger", + "drop_trigger", + "get_schema_snapshot", + "get_ai_schema_context", + "get_all_columns_batch", + "get_all_foreign_keys_batch", + ]; + + fn dispatched_match_arms() -> BTreeSet<&'static str> { + include_str!("rpc.rs") + .lines() + .filter_map(|line| { + line.trim() + .strip_prefix('"')? + .split_once("\" =>") + .map(|(method, _)| method) + }) + .collect() + } + + fn handle_line_on_worker_stack(line: String) -> Value { + std::thread::Builder::new() + .stack_size(crate::WORKER_STACK_SIZE) + .spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(Box::pin(handle_line(&line))) + }) + .unwrap() + .join() + .unwrap() + } + + #[test] + fn every_host_method_is_dispatched_or_deliberately_not_implemented() { + let dispatched = dispatched_match_arms(); + let host_methods: BTreeSet<_> = HOST_METHODS.iter().copied().collect(); + let not_implemented: BTreeSet<_> = NOT_IMPLEMENTED + .iter() + .map(|(method, reason)| { + assert!(!reason.trim().is_empty(), "{method} needs a reason"); + *method + }) + .collect(); + + assert_eq!( + host_methods.len(), + HOST_METHODS.len(), + "duplicate host method" + ); + assert_eq!(not_implemented.len(), NOT_IMPLEMENTED.len()); + + let uncovered: Vec<_> = host_methods + .difference(&dispatched) + .filter(|method| !not_implemented.contains(*method)) + .copied() + .collect(); + assert!( + uncovered.is_empty(), + "host methods are neither dispatched nor deliberately unsupported: {uncovered:?}" + ); + + let stale_exclusions: Vec<_> = not_implemented.difference(&host_methods).copied().collect(); + assert!( + stale_exclusions.is_empty(), + "NOT_IMPLEMENTED contains methods outside the host contract: {stale_exclusions:?}" + ); + + let plugin_only: Vec<_> = dispatched + .difference(&host_methods) + .filter(|method| **method != "shutdown") + .copied() + .collect(); + assert!( + plugin_only.is_empty(), + "dispatch contains methods outside the host contract: {plugin_only:?}" + ); + assert!(dispatched.contains("shutdown")); + } + + #[test] + fn deliberate_exclusions_return_named_reasoned_errors() { + for (method, reason) in NOT_IMPLEMENTED { + let request = json!({ "jsonrpc": "2.0", "method": method, "id": 7 }); + let response = handle_line_on_worker_stack(request.to_string()); + + assert_eq!(response["error"]["code"], -32601, "{method}"); + let message = response["error"]["message"].as_str().unwrap(); + assert!(message.contains(method), "{message}"); + assert!(message.contains("-32601"), "{message}"); + assert!(message.contains(PLUGIN_NAME), "{message}"); + assert!(message.contains(reason), "{message}"); + } + } + + #[test] + fn unknown_method_error_names_the_method_and_plugin() { + let request = json!({ "jsonrpc": "2.0", "method": "future_host_rpc", "id": 9 }); + let response = handle_line_on_worker_stack(request.to_string()); + + assert_eq!(response["error"]["code"], -32601); + let message = response["error"]["message"].as_str().unwrap(); + assert!(message.contains("future_host_rpc")); + assert!(message.contains("-32601")); + assert!(message.contains(PLUGIN_NAME)); + assert!(message.contains("unknown JSON-RPC method")); + } + + #[test] + fn shutdown_closes_cached_pools_and_returns_null() { + std::thread::Builder::new() + .stack_size(crate::WORKER_STACK_SIZE) + .spawn(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + let pool = pool_manager::get_sqlserver_pool(&ConnectionParams { + driver: "sqlserver".into(), + host: Some("localhost".into()), + port: Some(1433), + username: Some("sa".into()), + password: Some("test-password".into()), + database: crate::models::DatabaseSelection::Single("master".into()), + connection_id: Some("shutdown-rpc-test".into()), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(pool_manager::pool_count().await, 1); + + let response = Box::pin(handle_line( + r#"{"jsonrpc":"2.0","method":"shutdown","id":11}"#, + )) + .await; + + assert_eq!( + response, + json!({ "jsonrpc": "2.0", "result": null, "id": 11 }) + ); + assert_eq!(pool_manager::pool_count().await, 0); + assert!(pool.is_closed()); + }); + }) + .unwrap() + .join() + .unwrap(); + } +} diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..4aa8469 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,270 @@ +//! Process-wide plugin settings received through the `initialize` RPC. +//! +//! The host initializes a plugin once, before sending connection requests. +//! Pools snapshot these values when they are created; updating this state does +//! not mutate live pooled sessions. + +use std::sync::RwLock; +use std::time::Duration; + +use once_cell::sync::Lazy; +use serde_json::{Map, Value}; +use tokio::sync::watch; + +pub const DEFAULT_MAX_POOL_SIZE: usize = 10; +pub const DEFAULT_CONNECT_TIMEOUT_SECONDS: u32 = 15; +pub const DEFAULT_QUERY_TIMEOUT_SECONDS: u32 = 0; +pub const DEFAULT_APPLICATION_NAME: &str = "Tabularis"; +pub const DEFAULT_TRUST_SERVER_CERTIFICATE: bool = false; +pub const DEFAULT_POOL_IDLE_EVICTION_MINUTES: u32 = 10; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PluginSettings { + pub max_pool_size: usize, + pub connect_timeout_seconds: u32, + pub query_timeout_seconds: u32, + pub application_name: String, + pub trust_server_certificate: bool, + pub pool_idle_eviction_minutes: u32, +} + +impl Default for PluginSettings { + fn default() -> Self { + Self { + max_pool_size: DEFAULT_MAX_POOL_SIZE, + connect_timeout_seconds: DEFAULT_CONNECT_TIMEOUT_SECONDS, + query_timeout_seconds: DEFAULT_QUERY_TIMEOUT_SECONDS, + application_name: DEFAULT_APPLICATION_NAME.to_owned(), + trust_server_certificate: DEFAULT_TRUST_SERVER_CERTIFICATE, + pool_idle_eviction_minutes: DEFAULT_POOL_IDLE_EVICTION_MINUTES, + } + } +} + +impl PluginSettings { + fn from_initialize_params(params: &Value) -> Self { + let defaults = Self::default(); + let Some(settings) = params.get("settings") else { + return defaults; + }; + let Some(settings) = settings.as_object() else { + eprintln!("invalid plugin setting 'settings': expected an object, using all defaults"); + return defaults; + }; + + Self { + max_pool_size: positive_usize(settings, "max_pool_size", defaults.max_pool_size), + connect_timeout_seconds: positive_u32( + settings, + "connect_timeout_seconds", + defaults.connect_timeout_seconds, + ), + query_timeout_seconds: nonnegative_u32( + settings, + "query_timeout_seconds", + defaults.query_timeout_seconds, + ), + application_name: string_setting( + settings, + "application_name", + &defaults.application_name, + ), + trust_server_certificate: boolean_setting( + settings, + "trust_server_certificate", + defaults.trust_server_certificate, + ), + pool_idle_eviction_minutes: positive_u32( + settings, + "pool_idle_eviction_minutes", + defaults.pool_idle_eviction_minutes, + ), + } + } + + pub fn query_timeout(&self) -> Option { + (self.query_timeout_seconds > 0) + .then(|| Duration::from_secs(u64::from(self.query_timeout_seconds))) + } + + pub fn pool_idle_eviction_interval(&self) -> Duration { + Duration::from_secs(u64::from(self.pool_idle_eviction_minutes) * 60) + } +} + +static SETTINGS: Lazy> = + Lazy::new(|| RwLock::new(PluginSettings::default())); +static SETTINGS_VERSION: Lazy> = Lazy::new(|| { + let (sender, _) = watch::channel(0); + sender +}); + +/// Apply one forgiving `initialize` payload. Unknown keys are deliberately +/// ignored and each malformed known value falls back independently. +pub fn initialize(params: &Value) { + let new_settings = PluginSettings::from_initialize_params(params); + match SETTINGS.write() { + Ok(mut settings) => *settings = new_settings, + Err(poisoned) => *poisoned.into_inner() = new_settings, + } + SETTINGS_VERSION.send_modify(|version| *version = version.wrapping_add(1)); +} + +/// Return a snapshot suitable for a newly created pool. +pub fn current() -> PluginSettings { + match SETTINGS.read() { + Ok(settings) => settings.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + } +} + +/// Notify long-lived maintenance tasks when `initialize` changes settings. +pub fn subscribe() -> watch::Receiver { + SETTINGS_VERSION.subscribe() +} + +fn positive_usize(settings: &Map, key: &str, default: usize) -> usize { + match settings.get(key) { + None => default, + Some(value) => match value.as_u64().and_then(|value| usize::try_from(value).ok()) { + Some(value) if value > 0 => value, + _ => { + warn_fallback(key, value, default); + default + } + }, + } +} + +fn positive_u32(settings: &Map, key: &str, default: u32) -> u32 { + match settings.get(key) { + None => default, + Some(value) => match value.as_u64().and_then(|value| u32::try_from(value).ok()) { + Some(value) if value > 0 => value, + _ => { + warn_fallback(key, value, default); + default + } + }, + } +} + +fn nonnegative_u32(settings: &Map, key: &str, default: u32) -> u32 { + match settings.get(key) { + None => default, + Some(value) => match value.as_u64().and_then(|value| u32::try_from(value).ok()) { + Some(value) => value, + None => { + warn_fallback(key, value, default); + default + } + }, + } +} + +fn string_setting(settings: &Map, key: &str, default: &str) -> String { + match settings.get(key) { + None => default.to_owned(), + Some(value) => match value.as_str() { + Some(value) => value.to_owned(), + None => { + warn_fallback(key, value, default); + default.to_owned() + } + }, + } +} + +fn boolean_setting(settings: &Map, key: &str, default: bool) -> bool { + match settings.get(key) { + None => default, + Some(value) => match value.as_bool() { + Some(value) => value, + None => { + warn_fallback(key, value, default); + default + } + }, + } +} + +fn warn_fallback(key: &str, value: &Value, default: impl std::fmt::Display) { + eprintln!("invalid plugin setting '{key}': got {value}, using default {default}"); +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn defaults_apply_when_initialize_is_never_called() { + assert_eq!( + PluginSettings::default(), + PluginSettings { + max_pool_size: 10, + connect_timeout_seconds: 15, + query_timeout_seconds: 0, + application_name: "Tabularis".into(), + trust_server_certificate: false, + pool_idle_eviction_minutes: 10, + } + ); + } + + #[test] + fn empty_initialize_settings_use_defaults() { + assert_eq!( + PluginSettings::from_initialize_params(&json!({})), + PluginSettings::default() + ); + assert_eq!( + PluginSettings::from_initialize_params(&json!({ "settings": {} })), + PluginSettings::default() + ); + } + + #[test] + fn initialize_settings_override_every_default() { + let parsed = PluginSettings::from_initialize_params(&json!({ + "settings": { + "max_pool_size": 24, + "connect_timeout_seconds": 7, + "query_timeout_seconds": 90, + "application_name": "Tabularis CI", + "trust_server_certificate": true, + "pool_idle_eviction_minutes": 3, + "future_setting": "ignored" + } + })); + + assert_eq!( + parsed, + PluginSettings { + max_pool_size: 24, + connect_timeout_seconds: 7, + query_timeout_seconds: 90, + application_name: "Tabularis CI".into(), + trust_server_certificate: true, + pool_idle_eviction_minutes: 3, + } + ); + } + + #[test] + fn malformed_initialize_values_fall_back_independently() { + let parsed = PluginSettings::from_initialize_params(&json!({ + "settings": { + "max_pool_size": 0, + "connect_timeout_seconds": "soon", + "query_timeout_seconds": -1, + "application_name": false, + "trust_server_certificate": "yes", + "pool_idle_eviction_minutes": 1.5 + } + })); + + assert_eq!(parsed, PluginSettings::default()); + } +} diff --git a/tests/capture_conformance.py b/tests/capture_conformance.py new file mode 100644 index 0000000..a2983af --- /dev/null +++ b/tests/capture_conformance.py @@ -0,0 +1,508 @@ +#!/usr/bin/env python3 +"""Capture one successful JSON-RPC response for every implemented plugin RPC. + +Run against the SQL Server container started by `just run-sqlserver`: + + cargo build + python3 tests/capture_conformance.py + +Connection and binary paths use the same SQLSERVER_TEST_* and +SQLSERVER_PLUGIN_BIN overrides as tests/live_db.rs. Fixtures never contain +requests, so credentials are not written to the repository. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE_DIR = ROOT / "tests" / "fixtures" / "conformance" +SCHEMA = "ss044" +USER = "ss044_user" +LOGIN = "ss044_login" +PASSWORD = "Ss044!Conformance9" +NEW_PASSWORD = "Ss044!Conformance10" + + +class Plugin: + def __init__(self) -> None: + binary = os.environ.get( + "SQLSERVER_PLUGIN_BIN", str(ROOT / "target" / "debug" / "sqlserver-plugin") + ) + self.process = subprocess.Popen( + [binary], + cwd=ROOT, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + ) + + def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + assert self.process.stdin is not None + assert self.process.stdout is not None + request = { + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1, + } + self.process.stdin.write(json.dumps(request, separators=(",", ":")) + "\n") + self.process.stdin.flush() + line = self.process.stdout.readline() + if not line: + raise RuntimeError(f"plugin exited while handling {method}") + response = json.loads(line) + if "error" in response: + raise RuntimeError(f"{method} failed: {response['error']}") + return response + + def close(self) -> None: + if self.process.poll() is None: + self.process.terminate() + self.process.wait(timeout=5) + + +def connection_params() -> dict[str, Any]: + return { + "driver": "sqlserver", + "host": os.environ.get("SQLSERVER_TEST_HOST", "127.0.0.1"), + "port": int(os.environ.get("SQLSERVER_TEST_PORT", "1433")), + "username": os.environ.get("SQLSERVER_TEST_USER", "sa"), + "password": os.environ.get("SQLSERVER_TEST_PASSWORD", "Str0ng!Passw0rd"), + "database": os.environ.get("SQLSERVER_TEST_DATABASE", "tabularis_test"), + "ssl_mode": "require", + "connection_id": "ss044-conformance-capture", + } + + +def main() -> None: + plugin = Plugin() + params = connection_params() + FIXTURE_DIR.parent.mkdir(parents=True, exist_ok=True) + staging = Path( + tempfile.mkdtemp(prefix=".ss044-conformance-", dir=FIXTURE_DIR.parent) + ) + captured: set[str] = set() + + def rpc_params(**values: Any) -> dict[str, Any]: + return {"params": params, **values} + + def execute(sql: str) -> Any: + return plugin.call("execute_query", rpc_params(query=sql))["result"] + + def capture(method: str, values: dict[str, Any]) -> Any: + if method in captured: + raise RuntimeError(f"duplicate fixture for {method}") + response = plugin.call(method, values) + (staging / f"{method}.json").write_text( + json.dumps(response, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + captured.add(method) + return response["result"] + + try: + capture( + "initialize", + { + "settings": { + "application_name": "Tabularis SS-044 conformance capture", + "query_timeout_seconds": 30, + } + }, + ) + + execute( + f"IF DATABASE_PRINCIPAL_ID(N'{USER}') IS NOT NULL DROP USER [{USER}]; " + f"IF SUSER_ID(N'{LOGIN}') IS NOT NULL DROP LOGIN [{LOGIN}]; " + f"DROP VIEW IF EXISTS [{SCHEMA}].[base_view]; " + f"DROP VIEW IF EXISTS [{SCHEMA}].[created_view]; " + f"DROP PROCEDURE IF EXISTS [{SCHEMA}].[sample_proc]; " + f"DROP PROCEDURE IF EXISTS [{SCHEMA}].[drop_proc]; " + f"DROP TABLE IF EXISTS [{SCHEMA}].[drop_fk_child]; " + f"DROP TABLE IF EXISTS [{SCHEMA}].[crud_rows]; " + f"DROP TABLE IF EXISTS [{SCHEMA}].[blob_rows]; " + f"DROP TABLE IF EXISTS [{SCHEMA}].[child]; " + f"DROP TABLE IF EXISTS [{SCHEMA}].[parent]; " + f"IF SCHEMA_ID(N'{SCHEMA}') IS NULL EXEC(N'CREATE SCHEMA [{SCHEMA}]')" + ) + execute( + f"CREATE TABLE [{SCHEMA}].[parent] (id INT PRIMARY KEY); " + f"CREATE TABLE [{SCHEMA}].[child] (" + "id INT IDENTITY(1,1) PRIMARY KEY, " + "parent_id INT NULL, " + "label NVARCHAR(42) NOT NULL CONSTRAINT [df_ss044_label] DEFAULT N'pending', " + "note NVARCHAR(MAX) NULL, " + "generated_value AS (id + 1), " + f"CONSTRAINT [fk_child_parent] FOREIGN KEY (parent_id) REFERENCES [{SCHEMA}].[parent](id) ON DELETE SET NULL); " + f"CREATE UNIQUE INDEX [ix_child_label] ON [{SCHEMA}].[child] (label); " + f"CREATE INDEX [ix_drop] ON [{SCHEMA}].[child] (parent_id); " + f"CREATE TABLE [{SCHEMA}].[drop_fk_child] (id INT PRIMARY KEY, parent_id INT NULL, " + f"CONSTRAINT [fk_drop] FOREIGN KEY (parent_id) REFERENCES [{SCHEMA}].[parent](id)); " + f"CREATE TABLE [{SCHEMA}].[crud_rows] (id INT PRIMARY KEY, value NVARCHAR(20) NOT NULL); " + f"CREATE TABLE [{SCHEMA}].[blob_rows] (id INT PRIMARY KEY, payload VARBINARY(MAX) NOT NULL); " + f"INSERT INTO [{SCHEMA}].[parent] VALUES (1), (2); " + f"INSERT INTO [{SCHEMA}].[child] (parent_id, label, note) VALUES (1, N'alpha', NULL), (2, N'beta', N'note'); " + f"INSERT INTO [{SCHEMA}].[blob_rows] VALUES (1, 0x89504E470D0A1A0A0000000D49484452)" + ) + execute(f"CREATE VIEW [{SCHEMA}].[base_view] AS SELECT id, label FROM [{SCHEMA}].[child]") + execute( + f"CREATE PROCEDURE [{SCHEMA}].[sample_proc] " + "@input INT, @output NVARCHAR(20) OUTPUT AS BEGIN SET NOCOUNT ON; " + "SET @output = CONCAT(N'value-', @input); SELECT @input AS input_value; END" + ) + execute(f"CREATE PROCEDURE [{SCHEMA}].[drop_proc] AS SELECT 1 AS value") + execute( + f"CREATE TRIGGER [{SCHEMA}].[base_after] ON [{SCHEMA}].[child] " + "AFTER INSERT, UPDATE AS BEGIN SET NOCOUNT ON; END" + ) + execute( + f"CREATE TRIGGER [{SCHEMA}].[base_instead] ON [{SCHEMA}].[child] " + "INSTEAD OF DELETE AS BEGIN SET NOCOUNT ON; END" + ) + + capture("ping", rpc_params()) + capture("test_connection", rpc_params()) + capture("get_databases", rpc_params()) + capture("get_schemas", rpc_params()) + capture("get_tables", rpc_params(schema=SCHEMA)) + capture("get_columns", rpc_params(schema=SCHEMA, table="child")) + capture("get_foreign_keys", rpc_params(schema=SCHEMA, table="child")) + capture("get_indexes", rpc_params(schema=SCHEMA, table="child")) + capture("get_schema_snapshot", rpc_params(schema=SCHEMA)) + capture("get_all_columns_batch", rpc_params(schema=SCHEMA)) + capture("get_all_foreign_keys_batch", rpc_params(schema=SCHEMA)) + capture("get_ai_schema_context", rpc_params(schema=SCHEMA, max_tables=3)) + + capture("get_views", rpc_params(schema=SCHEMA)) + capture( + "get_view_definition", + rpc_params(schema=SCHEMA, view_name="base_view"), + ) + capture("get_view_columns", rpc_params(schema=SCHEMA, view_name="base_view")) + capture( + "create_view", + rpc_params( + schema=SCHEMA, + view_name="created_view", + definition=f"SELECT id FROM [{SCHEMA}].[parent]", + ), + ) + capture( + "alter_view", + rpc_params( + schema=SCHEMA, + view_name="created_view", + definition=f"SELECT id FROM [{SCHEMA}].[parent] WHERE id > 0", + ), + ) + capture("drop_view", rpc_params(schema=SCHEMA, view_name="created_view")) + + capture("get_routines", rpc_params(schema=SCHEMA)) + capture( + "get_routine_parameters", + rpc_params(schema=SCHEMA, routine_name="sample_proc"), + ) + capture( + "get_routine_definition", + rpc_params( + schema=SCHEMA, + routine_name="sample_proc", + routine_type="PROCEDURE", + ), + ) + capture( + "build_routine_call_sql", + rpc_params( + schema=SCHEMA, + routine_name="sample_proc", + routine_type="PROCEDURE", + args=[ + {"name": "@input", "mode": "IN", "value": "7", "is_raw": True}, + { + "name": "@output", + "mode": "INOUT", + "value": None, + "is_raw": False, + }, + ], + ), + ) + capture("routine_create_template", {"schema": SCHEMA, "routine_type": "FUNCTION"}) + capture( + "get_routine_edit_script", + rpc_params( + schema=SCHEMA, + routine_name="sample_proc", + routine_type="PROCEDURE", + ), + ) + capture( + "drop_routine", + rpc_params(schema=SCHEMA, routine_name="drop_proc", routine_type="PROCEDURE"), + ) + + capture("get_triggers", rpc_params(schema=SCHEMA)) + capture( + "get_trigger_definition", + rpc_params(schema=SCHEMA, trigger_name="base_after", table_name="child"), + ) + capture( + "create_trigger", + rpc_params( + schema=SCHEMA, + trigger_sql=f"CREATE TRIGGER [{SCHEMA}].[created_trigger] ON [{SCHEMA}].[parent] AFTER UPDATE AS BEGIN SET NOCOUNT ON; END", + ), + ) + capture( + "drop_trigger", + rpc_params(schema=SCHEMA, trigger_name="created_trigger", table_name="parent"), + ) + + capture("get_db_privilege_catalog", {}) + capture( + "create_db_user", + rpc_params(user=USER, host=LOGIN, password=PASSWORD), + ) + capture( + "set_db_user_password", + rpc_params(user=USER, host=LOGIN, password=NEW_PASSWORD), + ) + capture( + "apply_db_user_privileges", + rpc_params( + user=USER, + host=LOGIN, + database=SCHEMA, + table="parent", + privileges=["SELECT"], + grant=True, + ), + ) + capture("get_db_users", rpc_params()) + capture("get_db_user_grants", rpc_params(user=USER, host=LOGIN)) + capture("get_db_user_privileges", rpc_params(user=USER, host=LOGIN)) + capture("drop_db_user", rpc_params(user=USER, host=LOGIN)) + + capture( + "execute_query", + rpc_params( + query=( + f"SELECT id, label FROM [{SCHEMA}].[child] ORDER BY id; " + f"SELECT parent_id FROM [{SCHEMA}].[child] ORDER BY id" + ) + ), + ) + capture( + "execute_query_batch", + rpc_params( + queries=[ + f"SELECT id FROM [{SCHEMA}].[child] ORDER BY id", + f"SELECT * FROM [{SCHEMA}].[missing_table]", + ], + limit=1, + page=1, + ), + ) + capture( + "explain_query", + rpc_params( + query=f"SELECT label FROM [{SCHEMA}].[child] WHERE id = 1", + analyze=False, + ), + ) + + capture( + "insert_record", + rpc_params(schema=SCHEMA, table="crud_rows", data={"id": 1, "value": "before"}), + ) + capture( + "update_record", + rpc_params( + schema=SCHEMA, + table="crud_rows", + pk_map={"id": 1}, + col_name="value", + new_val="after", + ), + ) + capture( + "delete_record", + rpc_params(schema=SCHEMA, table="crud_rows", pk_map={"id": 1}), + ) + + blob_path = Path(tempfile.gettempdir()) / "ss044-conformance-blob.bin" + blob_path.unlink(missing_ok=True) + capture( + "save_blob_to_file", + rpc_params( + schema=SCHEMA, + table="blob_rows", + col_name="payload", + pk_map={"id": 1}, + file_path=str(blob_path), + ), + ) + blob_path.unlink(missing_ok=True) + capture( + "fetch_blob_as_data_url", + rpc_params( + schema=SCHEMA, + table="blob_rows", + col_name="payload", + pk_map={"id": 1}, + max_blob_size=1024, + ), + ) + + column = { + "name": "value", + "data_type": "NVARCHAR(40)", + "is_nullable": True, + "is_pk": False, + "is_auto_increment": False, + "default_value": None, + } + capture( + "get_create_table_sql", + { + "schema": SCHEMA, + "table_name": "generated_table", + "columns": [ + { + **column, + "name": "id", + "data_type": "INT", + "is_nullable": False, + "is_pk": True, + }, + column, + ], + }, + ) + capture( + "get_add_column_sql", + {"schema": SCHEMA, "table": "generated_table", "column": column}, + ) + capture( + "get_alter_column_sql", + { + "schema": SCHEMA, + "table": "generated_table", + "old_column": column, + "new_column": {**column, "data_type": "NVARCHAR(80)", "is_nullable": False}, + }, + ) + capture( + "get_create_index_sql", + { + "schema": SCHEMA, + "table": "generated_table", + "index_name": "ix_generated_value", + "columns": ["value"], + "is_unique": True, + }, + ) + capture( + "get_create_foreign_key_sql", + { + "params": params, + "schema": SCHEMA, + "table": "generated_table", + "fk_name": "fk_generated_parent", + "column": "id", + "ref_table": "parent", + "ref_column": "id", + "on_delete": "CASCADE", + "on_update": "NO ACTION", + }, + ) + capture( + "drop_index", + rpc_params(schema=SCHEMA, table="child", index_name="ix_drop"), + ) + capture( + "drop_foreign_key", + rpc_params(schema=SCHEMA, table="drop_fk_child", fk_name="fk_drop"), + ) + + capture("shutdown", {}) + + expected = { + "initialize", + "ping", + "test_connection", + "shutdown", + "get_databases", + "get_schemas", + "get_tables", + "get_columns", + "get_foreign_keys", + "get_indexes", + "get_schema_snapshot", + "get_all_columns_batch", + "get_all_foreign_keys_batch", + "get_ai_schema_context", + "get_views", + "get_view_definition", + "get_view_columns", + "create_view", + "alter_view", + "drop_view", + "get_routines", + "get_routine_parameters", + "get_routine_definition", + "build_routine_call_sql", + "routine_create_template", + "get_routine_edit_script", + "drop_routine", + "get_triggers", + "get_trigger_definition", + "create_trigger", + "drop_trigger", + "get_db_privilege_catalog", + "get_db_users", + "create_db_user", + "drop_db_user", + "set_db_user_password", + "get_db_user_grants", + "get_db_user_privileges", + "apply_db_user_privileges", + "execute_query", + "execute_query_batch", + "explain_query", + "insert_record", + "update_record", + "delete_record", + "save_blob_to_file", + "fetch_blob_as_data_url", + "get_create_table_sql", + "get_add_column_sql", + "get_alter_column_sql", + "get_create_index_sql", + "get_create_foreign_key_sql", + "drop_index", + "drop_foreign_key", + } + if captured != expected: + raise RuntimeError( + f"fixture inventory mismatch; missing={expected - captured}, extra={captured - expected}" + ) + + if FIXTURE_DIR.exists(): + shutil.rmtree(FIXTURE_DIR) + staging.rename(FIXTURE_DIR) + print(f"captured {len(captured)} responses in {FIXTURE_DIR}") + finally: + if staging.exists(): + shutil.rmtree(staging) + plugin.close() + + +if __name__ == "__main__": + main() diff --git a/tests/conformance.rs b/tests/conformance.rs new file mode 100644 index 0000000..d0db5b4 --- /dev/null +++ b/tests/conformance.rs @@ -0,0 +1,431 @@ +//! Recorded JSON-RPC response conformance against Tabularis host models. +//! +//! The model definitions below are copied verbatim from +//! `tabularis/src-tauri/src/models.rs` at host commit +//! `ba0463d3b861ec8fad110126c67e3fc12bac9839`. Re-sync them and regenerate +//! `tests/fixtures/conformance/` with `python3 tests/capture_conformance.py` +//! whenever the host models or plugin RPC surface changes. + +#![allow(dead_code)] + +use std::collections::{BTreeSet, HashMap}; +use std::fs; +use std::path::PathBuf; + +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::Value; + +// BEGIN verbatim host model definitions. + +#[derive(Debug, Serialize, Deserialize)] +pub struct TableInfo { + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TableColumn { + pub name: String, + pub data_type: String, + pub is_pk: bool, + pub is_nullable: bool, + pub is_auto_increment: bool, + #[serde(default)] + pub is_generated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub character_maximum_length: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ForeignKey { + pub name: String, + pub column_name: String, + pub ref_table: String, + pub ref_column: String, + pub on_delete: Option, + pub on_update: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Index { + pub name: String, + pub column_name: String, + pub is_unique: bool, + pub is_primary: bool, + pub seq_in_index: i32, + #[serde(default)] + pub is_expression: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Pagination { + pub page: u32, + pub page_size: u32, + pub total_rows: Option, + pub has_more: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct QueryResult { + pub columns: Vec, + pub rows: Vec>, + pub affected_rows: u64, + #[serde(default)] + pub truncated: bool, + pub pagination: Option, + /// Extra result sets produced by a single statement beyond the first one, + /// e.g. a MySQL `CALL` to a stored procedure containing multiple `SELECT`s. + /// The first result set stays in `columns` / `rows` so consumers unaware + /// of multi-result statements keep working unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub additional_results: Option>, +} + +/// One statement's outcome within an `execute_batch` call. Exactly one of +/// `result` / `error` is `Some` — kept as separate optionals (not a tagged +/// enum) so the TypeScript side can do `if (item.error) ... else ... item.result` +/// without a discriminated-union helper. Use [`BatchStatementResult::from_outcome`] +/// to construct so the invariant is enforced. +/// +/// `execution_time_ms` is measured server-side because a batch is one +/// Tauri round-trip but the history UI wants per-statement timings. +#[derive(Debug, Serialize, Deserialize)] +pub struct BatchStatementResult { + pub result: Option, + pub error: Option, + pub execution_time_ms: Option, +} + +/// Raw EXPLAIN output produced by a built-in driver. +/// +/// Parsing lives in the `@tabularis/explain` TypeScript package +/// (`parseRawExplain`): a driver's job ends at handing over the payload it +/// obtained — text, a JSON document, or decoded rows re-serialised as a JSON +/// array — plus the format tag naming what it is. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RawExplainOutput { + /// Driver id of the engine that produced the payload ("postgres", …). + pub engine: String, + /// Wire format tag understood by `@tabularis/explain`: + /// `postgres-json`, `mysql-json`, `mysql-analyze-text`, + /// `mysql-tabular-rows` or `sqlite-eqp-rows`. + pub format: String, + /// The untouched payload: text, a JSON document, or rows as a JSON array. + pub payload: String, + pub original_query: String, +} + +/// What `explain_query` hands to the frontend: a raw payload for a registered +/// parser, or a plan a plugin driver already parsed. Both plugin result shapes +/// remain supported for backwards compatibility. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum ExplainQueryOutput { + Raw { raw: RawExplainOutput }, + Plan { plan: serde_json::Value }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TableSchema { + pub name: String, + pub columns: Vec, + pub foreign_keys: Vec, +} + +/// Bounded schema metadata prepared by a database driver for AI features. +/// The host remains responsible for rendering this structured data into a +/// provider-agnostic prompt. +#[derive(Debug, Serialize, Deserialize)] +pub struct AiSchemaContext { + pub tables: Vec, + pub total_table_count: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RoutineInfo { + pub name: String, + pub routine_type: String, // "PROCEDURE" | "FUNCTION" + pub definition: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct RoutineParameter { + pub name: String, + pub data_type: String, + pub mode: String, // "IN", "OUT", "INOUT" + pub ordinal_position: i32, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ViewInfo { + pub name: String, + pub definition: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TriggerInfo { + pub name: String, + pub table_name: String, + pub event: String, // e.g. "INSERT", "UPDATE", "DELETE", "INSERT OR UPDATE" + pub timing: String, // "BEFORE", "AFTER", "INSTEAD OF" + pub definition: Option, +} + +/// One database account as listed by the server (MySQL/MariaDB: +/// `mysql.user` rows, identified by the `user`@`host` pair). +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DbUserInfo { + pub user: String, + pub host: String, + /// Account is locked (`ALTER USER ... ACCOUNT LOCK`); `false` when the + /// server does not expose the flag. + pub locked: bool, +} + +/// The privilege keywords a driver accepts in `apply_db_user_privileges`, +/// split by scope. Sent to the frontend so the privilege editor renders the +/// dialect's own catalog instead of hardcoding one. +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct DbPrivilegeCatalog { + /// Privileges valid at the database scope (and also globally). + pub database: Vec, + /// Privileges valid only at the global scope. + pub global: Vec, + /// Privileges valid at the table scope. + pub table: Vec, +} + +/// One account's privileges on one scope, parsed from the server's grant +/// metadata (MySQL: one `SHOW GRANTS` line). `database == None` is the +/// global scope; `table` is only ever `Some` when `database` is. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct DbUserGrantSet { + pub database: Option, + pub table: Option, + /// Canonical privilege keywords, `GRANT OPTION` included as an entry. + pub privileges: Vec, +} + +// END verbatim host model definitions. + +#[derive(Debug, Deserialize)] +struct RpcResponse { + jsonrpc: String, + result: Value, + id: u64, +} + +const DELIBERATELY_UNSUPPORTED: &[&str] = &[ + "get_materialized_views", + "get_materialized_view_columns", + "get_materialized_view_definition", + "refresh_materialized_view", +]; + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/conformance") +} + +fn fixture_methods() -> BTreeSet { + fs::read_dir(fixture_dir()) + .expect("read conformance fixture directory") + .filter_map(|entry| { + let path = entry.expect("read fixture entry").path(); + (path.extension().and_then(|extension| extension.to_str()) == Some("json")).then(|| { + path.file_stem() + .and_then(|stem| stem.to_str()) + .expect("fixture names must be UTF-8") + .to_string() + }) + }) + .collect() +} + +fn implemented_methods() -> BTreeSet { + let unsupported: BTreeSet<_> = DELIBERATELY_UNSUPPORTED.iter().copied().collect(); + include_str!("../src/rpc.rs") + .lines() + .filter_map(|line| { + line.trim() + .strip_prefix('"')? + .split_once("\" =>") + .map(|(method, _)| method) + }) + .filter(|method| !unsupported.contains(method)) + .map(str::to_string) + .collect() +} + +fn fixture_result(method: &str) -> Value { + let path = fixture_dir().join(format!("{method}.json")); + let fixture: RpcResponse = serde_json::from_str( + &fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())), + ) + .unwrap_or_else(|error| panic!("deserialize {}: {error}", path.display())); + assert_eq!(fixture.jsonrpc, "2.0", "{method} JSON-RPC version"); + assert_eq!(fixture.id, 1, "{method} JSON-RPC id"); + fixture.result +} + +fn assert_deserializes(methods: &[&str], checked: &mut BTreeSet) { + for method in methods { + serde_json::from_value::(fixture_result(method)) + .unwrap_or_else(|error| panic!("{method} does not match the host target: {error}")); + assert!(checked.insert((*method).to_string()), "duplicate {method}"); + } +} + +#[test] +fn fixtures_cover_every_implemented_rpc() { + assert_eq!(fixture_methods(), implemented_methods()); +} + +#[test] +fn every_recorded_result_deserializes_into_the_host_target() { + let mut checked = BTreeSet::new(); + + assert_deserializes::(&["test_connection"], &mut checked); + assert_deserializes::<()>( + &[ + "initialize", + "ping", + "shutdown", + "create_view", + "alter_view", + "drop_view", + "drop_routine", + "create_trigger", + "drop_trigger", + "create_db_user", + "drop_db_user", + "set_db_user_password", + "apply_db_user_privileges", + "save_blob_to_file", + "drop_index", + "drop_foreign_key", + ], + &mut checked, + ); + assert_deserializes::( + &[ + "get_view_definition", + "get_routine_definition", + "build_routine_call_sql", + "routine_create_template", + "get_routine_edit_script", + "get_trigger_definition", + "fetch_blob_as_data_url", + ], + &mut checked, + ); + assert_deserializes::( + &["insert_record", "update_record", "delete_record"], + &mut checked, + ); + assert_deserializes::>( + &[ + "get_databases", + "get_schemas", + "get_db_user_grants", + "get_create_table_sql", + "get_add_column_sql", + "get_alter_column_sql", + "get_create_index_sql", + "get_create_foreign_key_sql", + ], + &mut checked, + ); + assert_deserializes::>(&["get_tables"], &mut checked); + assert_deserializes::>(&["get_columns", "get_view_columns"], &mut checked); + assert_deserializes::>(&["get_foreign_keys"], &mut checked); + assert_deserializes::>(&["get_indexes"], &mut checked); + assert_deserializes::>(&["get_schema_snapshot"], &mut checked); + assert_deserializes::>>( + &["get_all_columns_batch"], + &mut checked, + ); + assert_deserializes::>>( + &["get_all_foreign_keys_batch"], + &mut checked, + ); + assert_deserializes::(&["get_ai_schema_context"], &mut checked); + assert_deserializes::>(&["get_views"], &mut checked); + assert_deserializes::>(&["get_routines"], &mut checked); + assert_deserializes::>(&["get_routine_parameters"], &mut checked); + assert_deserializes::>(&["get_triggers"], &mut checked); + assert_deserializes::(&["get_db_privilege_catalog"], &mut checked); + assert_deserializes::>(&["get_db_users"], &mut checked); + assert_deserializes::>(&["get_db_user_privileges"], &mut checked); + assert_deserializes::(&["execute_query"], &mut checked); + assert_deserializes::>(&["execute_query_batch"], &mut checked); + assert_deserializes::(&["explain_query"], &mut checked); + + assert_eq!(checked, implemented_methods()); +} + +#[test] +fn drift_prone_wire_fields_are_exercised() { + let query: QueryResult = serde_json::from_value(fixture_result("execute_query")).unwrap(); + assert!(query.additional_results.is_some()); + let batch: Vec = + serde_json::from_value(fixture_result("execute_query_batch")).unwrap(); + assert!(batch[0] + .result + .as_ref() + .and_then(|result| result.pagination.as_ref()) + .is_some()); + assert!(batch[1].error.is_some()); + + let columns: Vec = serde_json::from_value(fixture_result("get_columns")).unwrap(); + let label = columns + .iter() + .find(|column| column.name == "label") + .unwrap(); + assert_eq!(label.character_maximum_length, Some(42)); + assert_eq!(label.default_value.as_deref(), Some("(N'pending')")); + let parent = columns + .iter() + .find(|column| column.name == "parent_id") + .unwrap(); + assert_eq!(parent.character_maximum_length, None); + assert_eq!(parent.default_value, None); + assert!(columns.iter().any(|column| column.is_generated)); + + let foreign_keys: Vec = + serde_json::from_value(fixture_result("get_foreign_keys")).unwrap(); + assert_eq!(foreign_keys[0].on_delete.as_deref(), Some("SET NULL")); + assert_eq!(foreign_keys[0].on_update.as_deref(), Some("NO ACTION")); + let mut nullable_foreign_keys = fixture_result("get_foreign_keys"); + nullable_foreign_keys[0]["on_delete"] = Value::Null; + nullable_foreign_keys[0]["on_update"] = Value::Null; + let nullable: Vec = serde_json::from_value(nullable_foreign_keys).unwrap(); + assert!(nullable[0].on_delete.is_none() && nullable[0].on_update.is_none()); + + let triggers: Vec = + serde_json::from_value(fixture_result("get_triggers")).unwrap(); + let vocabulary: BTreeSet<_> = triggers + .iter() + .map(|trigger| (trigger.timing.as_str(), trigger.event.as_str())) + .collect(); + assert!(vocabulary.contains(&("AFTER", "INSERT OR UPDATE"))); + assert!(vocabulary.contains(&("INSTEAD OF", "DELETE"))); + + let parameters: Vec = + serde_json::from_value(fixture_result("get_routine_parameters")).unwrap(); + assert_eq!(parameters[0].mode, "IN"); + assert_eq!(parameters[1].mode, "INOUT"); + + let context: AiSchemaContext = + serde_json::from_value(fixture_result("get_ai_schema_context")).unwrap(); + assert_eq!(context.tables.len(), 3); + assert_eq!(context.total_table_count, 5); + + let raw: RawExplainOutput = serde_json::from_value(fixture_result("explain_query")).unwrap(); + assert_eq!(raw.engine, "sqlserver"); + assert_eq!(raw.format, "sqlserver-showplan-xml"); + assert!(raw.payload.contains("" + } +} diff --git a/tests/fixtures/conformance/fetch_blob_as_data_url.json b/tests/fixtures/conformance/fetch_blob_as_data_url.json new file mode 100644 index 0000000..4896b35 --- /dev/null +++ b/tests/fixtures/conformance/fetch_blob_as_data_url.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": "BLOB:16:image/png:iVBORw0KGgoAAAANSUhEUg==" +} diff --git a/tests/fixtures/conformance/get_add_column_sql.json b/tests/fixtures/conformance/get_add_column_sql.json new file mode 100644 index 0000000..b868b39 --- /dev/null +++ b/tests/fixtures/conformance/get_add_column_sql.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + "ALTER TABLE [ss044].[generated_table] ADD [value] NVARCHAR(40) NULL" + ] +} diff --git a/tests/fixtures/conformance/get_ai_schema_context.json b/tests/fixtures/conformance/get_ai_schema_context.json new file mode 100644 index 0000000..1777bf0 --- /dev/null +++ b/tests/fixtures/conformance/get_ai_schema_context.json @@ -0,0 +1,111 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "tables": [ + { + "columns": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "varbinary(max)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "payload" + } + ], + "foreign_keys": [], + "name": "blob_rows" + }, + { + "columns": [ + { + "data_type": "int", + "is_auto_increment": true, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "parent_id" + }, + { + "character_maximum_length": 42, + "data_type": "nvarchar(42)", + "default_value": "(N'pending')", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "label" + }, + { + "data_type": "nvarchar(max)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "note" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": true, + "is_nullable": true, + "is_pk": false, + "name": "generated_value" + } + ], + "foreign_keys": [ + { + "column_name": "parent_id", + "name": "fk_child_parent", + "on_delete": "SET NULL", + "on_update": "NO ACTION", + "ref_column": "id", + "ref_table": "parent" + } + ], + "name": "child" + }, + { + "columns": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "character_maximum_length": 20, + "data_type": "nvarchar(20)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "value" + } + ], + "foreign_keys": [], + "name": "crud_rows" + } + ], + "total_table_count": 5 + } +} diff --git a/tests/fixtures/conformance/get_all_columns_batch.json b/tests/fixtures/conformance/get_all_columns_batch.json new file mode 100644 index 0000000..24970fc --- /dev/null +++ b/tests/fixtures/conformance/get_all_columns_batch.json @@ -0,0 +1,115 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "blob_rows": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "varbinary(max)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "payload" + } + ], + "child": [ + { + "data_type": "int", + "is_auto_increment": true, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "parent_id" + }, + { + "character_maximum_length": 42, + "data_type": "nvarchar(42)", + "default_value": "(N'pending')", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "label" + }, + { + "data_type": "nvarchar(max)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "note" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": true, + "is_nullable": true, + "is_pk": false, + "name": "generated_value" + } + ], + "crud_rows": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "character_maximum_length": 20, + "data_type": "nvarchar(20)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "value" + } + ], + "drop_fk_child": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "parent_id" + } + ], + "parent": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + } + ] + } +} diff --git a/tests/fixtures/conformance/get_all_foreign_keys_batch.json b/tests/fixtures/conformance/get_all_foreign_keys_batch.json new file mode 100644 index 0000000..84769e2 --- /dev/null +++ b/tests/fixtures/conformance/get_all_foreign_keys_batch.json @@ -0,0 +1,26 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "child": [ + { + "column_name": "parent_id", + "name": "fk_child_parent", + "on_delete": "SET NULL", + "on_update": "NO ACTION", + "ref_column": "id", + "ref_table": "parent" + } + ], + "drop_fk_child": [ + { + "column_name": "parent_id", + "name": "fk_drop", + "on_delete": "NO ACTION", + "on_update": "NO ACTION", + "ref_column": "id", + "ref_table": "parent" + } + ] + } +} diff --git a/tests/fixtures/conformance/get_alter_column_sql.json b/tests/fixtures/conformance/get_alter_column_sql.json new file mode 100644 index 0000000..6b21697 --- /dev/null +++ b/tests/fixtures/conformance/get_alter_column_sql.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + "ALTER TABLE [ss044].[generated_table] ALTER COLUMN [value] NVARCHAR(80) NOT NULL" + ] +} diff --git a/tests/fixtures/conformance/get_columns.json b/tests/fixtures/conformance/get_columns.json new file mode 100644 index 0000000..46ca8bd --- /dev/null +++ b/tests/fixtures/conformance/get_columns.json @@ -0,0 +1,48 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "data_type": "int", + "is_auto_increment": true, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "parent_id" + }, + { + "character_maximum_length": 42, + "data_type": "nvarchar(42)", + "default_value": "(N'pending')", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "label" + }, + { + "data_type": "nvarchar(max)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "note" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": true, + "is_nullable": true, + "is_pk": false, + "name": "generated_value" + } + ] +} diff --git a/tests/fixtures/conformance/get_create_foreign_key_sql.json b/tests/fixtures/conformance/get_create_foreign_key_sql.json new file mode 100644 index 0000000..b4cbba0 --- /dev/null +++ b/tests/fixtures/conformance/get_create_foreign_key_sql.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + "ALTER TABLE [ss044].[generated_table] ADD CONSTRAINT [fk_generated_parent] FOREIGN KEY ([id]) REFERENCES [ss044].[parent] ([id]) ON DELETE CASCADE ON UPDATE NO ACTION" + ] +} diff --git a/tests/fixtures/conformance/get_create_index_sql.json b/tests/fixtures/conformance/get_create_index_sql.json new file mode 100644 index 0000000..268ac07 --- /dev/null +++ b/tests/fixtures/conformance/get_create_index_sql.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + "CREATE UNIQUE INDEX [ix_generated_value] ON [ss044].[generated_table] ([value])" + ] +} diff --git a/tests/fixtures/conformance/get_create_table_sql.json b/tests/fixtures/conformance/get_create_table_sql.json new file mode 100644 index 0000000..d65915b --- /dev/null +++ b/tests/fixtures/conformance/get_create_table_sql.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + "CREATE TABLE [ss044].[generated_table] (\n [id] INT NOT NULL,\n [value] NVARCHAR(40) NULL,\n PRIMARY KEY ([id])\n)" + ] +} diff --git a/tests/fixtures/conformance/get_databases.json b/tests/fixtures/conformance/get_databases.json new file mode 100644 index 0000000..e9017ec --- /dev/null +++ b/tests/fixtures/conformance/get_databases.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + "tabularis_test" + ] +} diff --git a/tests/fixtures/conformance/get_db_privilege_catalog.json b/tests/fixtures/conformance/get_db_privilege_catalog.json new file mode 100644 index 0000000..be7a21b --- /dev/null +++ b/tests/fixtures/conformance/get_db_privilege_catalog.json @@ -0,0 +1,52 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "database": [ + "ALTER", + "CONTROL", + "DELETE", + "EXECUTE", + "INSERT", + "REFERENCES", + "SELECT", + "TAKE OWNERSHIP", + "UPDATE", + "VIEW CHANGE TRACKING", + "VIEW DEFINITION" + ], + "global": [ + "AUTHENTICATE", + "BACKUP DATABASE", + "BACKUP LOG", + "CHECKPOINT", + "CONNECT", + "CREATE FUNCTION", + "CREATE PROCEDURE", + "CREATE ROLE", + "CREATE SCHEMA", + "CREATE SYNONYM", + "CREATE TABLE", + "CREATE TYPE", + "CREATE VIEW", + "SHOWPLAN", + "SUBSCRIBE QUERY NOTIFICATIONS", + "UNMASK", + "VIEW DATABASE STATE" + ], + "table": [ + "ALTER", + "CONTROL", + "DELETE", + "EXECUTE", + "INSERT", + "RECEIVE", + "REFERENCES", + "SELECT", + "TAKE OWNERSHIP", + "UPDATE", + "VIEW CHANGE TRACKING", + "VIEW DEFINITION" + ] + } +} diff --git a/tests/fixtures/conformance/get_db_user_grants.json b/tests/fixtures/conformance/get_db_user_grants.json new file mode 100644 index 0000000..7a87345 --- /dev/null +++ b/tests/fixtures/conformance/get_db_user_grants.json @@ -0,0 +1,8 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + "GRANT CONNECT ON DATABASE::[tabularis_test] TO [ss044_user]", + "GRANT SELECT ON OBJECT::[ss044].[parent] TO [ss044_user]" + ] +} diff --git a/tests/fixtures/conformance/get_db_user_privileges.json b/tests/fixtures/conformance/get_db_user_privileges.json new file mode 100644 index 0000000..ec94f1e --- /dev/null +++ b/tests/fixtures/conformance/get_db_user_privileges.json @@ -0,0 +1,20 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "database": null, + "privileges": [ + "CONNECT" + ], + "table": null + }, + { + "database": "ss044", + "privileges": [ + "SELECT" + ], + "table": "parent" + } + ] +} diff --git a/tests/fixtures/conformance/get_db_users.json b/tests/fixtures/conformance/get_db_users.json new file mode 100644 index 0000000..87c61b2 --- /dev/null +++ b/tests/fixtures/conformance/get_db_users.json @@ -0,0 +1,11 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "host": "ss044_login", + "locked": false, + "user": "ss044_user" + } + ] +} diff --git a/tests/fixtures/conformance/get_foreign_keys.json b/tests/fixtures/conformance/get_foreign_keys.json new file mode 100644 index 0000000..5dab610 --- /dev/null +++ b/tests/fixtures/conformance/get_foreign_keys.json @@ -0,0 +1,14 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "column_name": "parent_id", + "name": "fk_child_parent", + "on_delete": "SET NULL", + "on_update": "NO ACTION", + "ref_column": "id", + "ref_table": "parent" + } + ] +} diff --git a/tests/fixtures/conformance/get_indexes.json b/tests/fixtures/conformance/get_indexes.json new file mode 100644 index 0000000..307bf80 --- /dev/null +++ b/tests/fixtures/conformance/get_indexes.json @@ -0,0 +1,30 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "column_name": "label", + "is_expression": false, + "is_primary": false, + "is_unique": true, + "name": "ix_child_label", + "seq_in_index": 1 + }, + { + "column_name": "parent_id", + "is_expression": false, + "is_primary": false, + "is_unique": false, + "name": "ix_drop", + "seq_in_index": 1 + }, + { + "column_name": "id", + "is_expression": false, + "is_primary": true, + "is_unique": true, + "name": "PK__child__3213E83FF87841BF", + "seq_in_index": 1 + } + ] +} diff --git a/tests/fixtures/conformance/get_routine_definition.json b/tests/fixtures/conformance/get_routine_definition.json new file mode 100644 index 0000000..5e57e3f --- /dev/null +++ b/tests/fixtures/conformance/get_routine_definition.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": "CREATE PROCEDURE [ss044].[sample_proc] @input INT, @output NVARCHAR(20) OUTPUT AS BEGIN SET NOCOUNT ON; SET @output = CONCAT(N'value-', @input); SELECT @input AS input_value; END" +} diff --git a/tests/fixtures/conformance/get_routine_edit_script.json b/tests/fixtures/conformance/get_routine_edit_script.json new file mode 100644 index 0000000..389d9b4 --- /dev/null +++ b/tests/fixtures/conformance/get_routine_edit_script.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": "ALTER PROCEDURE [ss044].[sample_proc] @input INT, @output NVARCHAR(20) OUTPUT AS BEGIN SET NOCOUNT ON; SET @output = CONCAT(N'value-', @input); SELECT @input AS input_value; END" +} diff --git a/tests/fixtures/conformance/get_routine_parameters.json b/tests/fixtures/conformance/get_routine_parameters.json new file mode 100644 index 0000000..8eb48bd --- /dev/null +++ b/tests/fixtures/conformance/get_routine_parameters.json @@ -0,0 +1,18 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "data_type": "int", + "mode": "IN", + "name": "@input", + "ordinal_position": 1 + }, + { + "data_type": "nvarchar(20)", + "mode": "INOUT", + "name": "@output", + "ordinal_position": 2 + } + ] +} diff --git a/tests/fixtures/conformance/get_routines.json b/tests/fixtures/conformance/get_routines.json new file mode 100644 index 0000000..09d3b8c --- /dev/null +++ b/tests/fixtures/conformance/get_routines.json @@ -0,0 +1,16 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "definition": null, + "name": "drop_proc", + "routine_type": "PROCEDURE" + }, + { + "definition": null, + "name": "sample_proc", + "routine_type": "PROCEDURE" + } + ] +} diff --git a/tests/fixtures/conformance/get_schema_snapshot.json b/tests/fixtures/conformance/get_schema_snapshot.json new file mode 100644 index 0000000..14203ff --- /dev/null +++ b/tests/fixtures/conformance/get_schema_snapshot.json @@ -0,0 +1,153 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "columns": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "varbinary(max)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "payload" + } + ], + "foreign_keys": [], + "name": "blob_rows" + }, + { + "columns": [ + { + "data_type": "int", + "is_auto_increment": true, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "parent_id" + }, + { + "character_maximum_length": 42, + "data_type": "nvarchar(42)", + "default_value": "(N'pending')", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "label" + }, + { + "data_type": "nvarchar(max)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "note" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": true, + "is_nullable": true, + "is_pk": false, + "name": "generated_value" + } + ], + "foreign_keys": [ + { + "column_name": "parent_id", + "name": "fk_child_parent", + "on_delete": "SET NULL", + "on_update": "NO ACTION", + "ref_column": "id", + "ref_table": "parent" + } + ], + "name": "child" + }, + { + "columns": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "character_maximum_length": 20, + "data_type": "nvarchar(20)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "value" + } + ], + "foreign_keys": [], + "name": "crud_rows" + }, + { + "columns": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + }, + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": true, + "is_pk": false, + "name": "parent_id" + } + ], + "foreign_keys": [ + { + "column_name": "parent_id", + "name": "fk_drop", + "on_delete": "NO ACTION", + "on_update": "NO ACTION", + "ref_column": "id", + "ref_table": "parent" + } + ], + "name": "drop_fk_child" + }, + { + "columns": [ + { + "data_type": "int", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": true, + "name": "id" + } + ], + "foreign_keys": [], + "name": "parent" + } + ] +} diff --git a/tests/fixtures/conformance/get_schemas.json b/tests/fixtures/conformance/get_schemas.json new file mode 100644 index 0000000..f2ea0a6 --- /dev/null +++ b/tests/fixtures/conformance/get_schemas.json @@ -0,0 +1,9 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + "dbo", + "ss003", + "ss044" + ] +} diff --git a/tests/fixtures/conformance/get_tables.json b/tests/fixtures/conformance/get_tables.json new file mode 100644 index 0000000..29764d5 --- /dev/null +++ b/tests/fixtures/conformance/get_tables.json @@ -0,0 +1,21 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "name": "blob_rows" + }, + { + "name": "child" + }, + { + "name": "crud_rows" + }, + { + "name": "drop_fk_child" + }, + { + "name": "parent" + } + ] +} diff --git a/tests/fixtures/conformance/get_trigger_definition.json b/tests/fixtures/conformance/get_trigger_definition.json new file mode 100644 index 0000000..eabb03c --- /dev/null +++ b/tests/fixtures/conformance/get_trigger_definition.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": "CREATE TRIGGER [ss044].[base_after] ON [ss044].[child] AFTER INSERT, UPDATE AS BEGIN SET NOCOUNT ON; END" +} diff --git a/tests/fixtures/conformance/get_triggers.json b/tests/fixtures/conformance/get_triggers.json new file mode 100644 index 0000000..67e53c4 --- /dev/null +++ b/tests/fixtures/conformance/get_triggers.json @@ -0,0 +1,20 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "definition": "CREATE TRIGGER [ss044].[base_after] ON [ss044].[child] AFTER INSERT, UPDATE AS BEGIN SET NOCOUNT ON; END", + "event": "INSERT OR UPDATE", + "name": "base_after", + "table_name": "child", + "timing": "AFTER" + }, + { + "definition": "CREATE TRIGGER [ss044].[base_instead] ON [ss044].[child] INSTEAD OF DELETE AS BEGIN SET NOCOUNT ON; END", + "event": "DELETE", + "name": "base_instead", + "table_name": "child", + "timing": "INSTEAD OF" + } + ] +} diff --git a/tests/fixtures/conformance/get_view_columns.json b/tests/fixtures/conformance/get_view_columns.json new file mode 100644 index 0000000..f3a9e8f --- /dev/null +++ b/tests/fixtures/conformance/get_view_columns.json @@ -0,0 +1,23 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "data_type": "int", + "is_auto_increment": true, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "id" + }, + { + "character_maximum_length": 42, + "data_type": "nvarchar(42)", + "is_auto_increment": false, + "is_generated": false, + "is_nullable": false, + "is_pk": false, + "name": "label" + } + ] +} diff --git a/tests/fixtures/conformance/get_view_definition.json b/tests/fixtures/conformance/get_view_definition.json new file mode 100644 index 0000000..6f07573 --- /dev/null +++ b/tests/fixtures/conformance/get_view_definition.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": "CREATE VIEW [ss044].[base_view] AS SELECT id, label FROM [ss044].[child]" +} diff --git a/tests/fixtures/conformance/get_views.json b/tests/fixtures/conformance/get_views.json new file mode 100644 index 0000000..649e0e2 --- /dev/null +++ b/tests/fixtures/conformance/get_views.json @@ -0,0 +1,10 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": [ + { + "definition": null, + "name": "base_view" + } + ] +} diff --git a/tests/fixtures/conformance/initialize.json b/tests/fixtures/conformance/initialize.json new file mode 100644 index 0000000..e1e4b81 --- /dev/null +++ b/tests/fixtures/conformance/initialize.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": null +} diff --git a/tests/fixtures/conformance/insert_record.json b/tests/fixtures/conformance/insert_record.json new file mode 100644 index 0000000..fcbcb5d --- /dev/null +++ b/tests/fixtures/conformance/insert_record.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": 1 +} diff --git a/tests/fixtures/conformance/ping.json b/tests/fixtures/conformance/ping.json new file mode 100644 index 0000000..e1e4b81 --- /dev/null +++ b/tests/fixtures/conformance/ping.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": null +} diff --git a/tests/fixtures/conformance/routine_create_template.json b/tests/fixtures/conformance/routine_create_template.json new file mode 100644 index 0000000..acc943f --- /dev/null +++ b/tests/fixtures/conformance/routine_create_template.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": "CREATE FUNCTION [ss044].[my_function] (@value INT)\nRETURNS INT\nAS\nBEGIN\n RETURN @value;\nEND" +} diff --git a/tests/fixtures/conformance/save_blob_to_file.json b/tests/fixtures/conformance/save_blob_to_file.json new file mode 100644 index 0000000..e1e4b81 --- /dev/null +++ b/tests/fixtures/conformance/save_blob_to_file.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": null +} diff --git a/tests/fixtures/conformance/set_db_user_password.json b/tests/fixtures/conformance/set_db_user_password.json new file mode 100644 index 0000000..e1e4b81 --- /dev/null +++ b/tests/fixtures/conformance/set_db_user_password.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": null +} diff --git a/tests/fixtures/conformance/shutdown.json b/tests/fixtures/conformance/shutdown.json new file mode 100644 index 0000000..e1e4b81 --- /dev/null +++ b/tests/fixtures/conformance/shutdown.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": null +} diff --git a/tests/fixtures/conformance/test_connection.json b/tests/fixtures/conformance/test_connection.json new file mode 100644 index 0000000..2a28900 --- /dev/null +++ b/tests/fixtures/conformance/test_connection.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "success": true + } +} diff --git a/tests/fixtures/conformance/update_record.json b/tests/fixtures/conformance/update_record.json new file mode 100644 index 0000000..fcbcb5d --- /dev/null +++ b/tests/fixtures/conformance/update_record.json @@ -0,0 +1,5 @@ +{ + "id": 1, + "jsonrpc": "2.0", + "result": 1 +} diff --git a/tests/live_db.rs b/tests/live_db.rs index 4680c78..5fa4c70 100644 --- a/tests/live_db.rs +++ b/tests/live_db.rs @@ -18,6 +18,7 @@ use std::collections::BTreeSet; use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, Command, Stdio}; +use base64::Engine as _; use serde_json::{json, Value}; const TEST_SCHEMA: &str = "ss003"; @@ -57,6 +58,22 @@ fn string_literal(value: &str) -> String { value.replace('\'', "''") } +fn url_encode_component(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +fn brace_connection_value(value: &str) -> String { + format!("{{{}}}", value.replace('}', "}}")) +} + /// A running plugin process driven through real newline-delimited JSON-RPC. struct Plugin { child: Child, @@ -116,7 +133,7 @@ impl Plugin { plugin } - fn call(&mut self, method: &str, params: Value) -> Value { + fn send(&mut self, method: &str, params: Value) -> u64 { let id = self.next_id; self.next_id += 1; let request = json!({ @@ -131,7 +148,10 @@ impl Plugin { .write_all(line.as_bytes()) .expect("write request to plugin stdin"); self.stdin.flush().expect("flush plugin stdin"); + id + } + fn read_response(&mut self) -> Value { let mut response_line = String::new(); self.stdout .read_line(&mut response_line) @@ -140,8 +160,12 @@ impl Plugin { !response_line.is_empty(), "plugin exited without a response" ); - let response: Value = - serde_json::from_str(response_line.trim()).expect("parse JSON-RPC response"); + serde_json::from_str(response_line.trim()).expect("parse JSON-RPC response") + } + + fn call(&mut self, method: &str, params: Value) -> Value { + let id = self.send(method, params); + let response = self.read_response(); assert_eq!( response.get("id").and_then(Value::as_u64), Some(id), @@ -173,13 +197,17 @@ impl Plugin { .to_string() } - fn execute(&mut self, query: impl Into) -> Value { + fn execute_with(&mut self, params: &Value, query: impl Into) -> Value { self.call_ok( "execute_query", - json!({ "params": connection_params(), "query": query.into() }), + json!({ "params": params, "query": query.into() }), ) } + fn execute(&mut self, query: impl Into) -> Value { + self.execute_with(&connection_params(), query) + } + fn reset_table(&mut self, table: &str, definition: &str) { self.execute(format!( "DROP TABLE IF EXISTS [{TEST_SCHEMA}].[{table}]; \ @@ -202,6 +230,128 @@ fn result_rows(result: &Value) -> &Vec { .expect("query result must contain a rows array") } +fn blob_wire(bytes: &[u8]) -> Value { + json!(format!( + "BLOB:{}:application/octet-stream:{}", + bytes.len(), + base64::engine::general_purpose::STANDARD.encode(bytes) + )) +} + +fn raw_sql(expression: &str) -> Value { + json!({ "value": expression, "is_raw": true }) +} + +#[derive(Clone)] +enum ExpectedCell { + Exact(Value), + Approx(f64), + Blob { exact_size: Option }, +} + +struct TypeCase { + advertised_name: &'static str, + ddl: &'static str, + insert: Value, + inserted: ExpectedCell, + boundary: Value, + bounded: ExpectedCell, + semantic_check: Option<(&'static str, Value)>, +} + +impl TypeCase { + fn exact( + advertised_name: &'static str, + ddl: &'static str, + insert: Value, + inserted: Value, + boundary: Value, + bounded: Value, + ) -> Self { + Self { + advertised_name, + ddl, + insert, + inserted: ExpectedCell::Exact(inserted), + boundary, + bounded: ExpectedCell::Exact(bounded), + semantic_check: None, + } + } +} + +fn assert_cell(case: &TypeCase, label: &str, actual: &Value, expected: &ExpectedCell) { + assert_ne!( + actual, + &Value::Null, + "{} {label} silently decoded as null", + case.advertised_name + ); + match expected { + ExpectedCell::Exact(expected) => assert_eq!( + actual, expected, + "{} {label} representation", + case.advertised_name + ), + ExpectedCell::Approx(expected) => { + let actual = actual + .as_f64() + .unwrap_or_else(|| panic!("{} {label} must be numeric", case.advertised_name)); + let relative_error = ((actual - expected) / expected).abs(); + assert!( + relative_error <= f64::from(f32::EPSILON), + "{} {label}: expected approximately {expected}, got {actual}", + case.advertised_name + ); + } + ExpectedCell::Blob { exact_size } => { + let wire = actual.as_str().unwrap_or_else(|| { + panic!("{} {label} must be a BLOB string", case.advertised_name) + }); + let mut fields = wire.splitn(4, ':'); + assert_eq!( + fields.next(), + Some("BLOB"), + "{} {label}", + case.advertised_name + ); + let size = fields + .next() + .and_then(|size| size.parse::().ok()) + .unwrap_or_else(|| { + panic!("{} {label} has invalid BLOB size", case.advertised_name) + }); + assert!( + size > 0, + "{} {label} BLOB must not be empty", + case.advertised_name + ); + assert_eq!( + fields.next(), + Some("application/octet-stream"), + "{} {label} MIME type", + case.advertised_name + ); + let decoded = base64::engine::general_purpose::STANDARD + .decode(fields.next().expect("BLOB payload")) + .expect("BLOB base64"); + assert_eq!( + decoded.len(), + size, + "{} {label} byte count", + case.advertised_name + ); + if let Some(expected_size) = exact_size { + assert_eq!( + size, *expected_size, + "{} {label} size", + case.advertised_name + ); + } + } + } +} + fn generated_create_table_sql( plugin: &mut Plugin, table_name: &str, @@ -361,6 +511,445 @@ fn ddl_creates_identity_composite_and_all_data_type_categories() { assert_eq!(names.len(), 7); } +fn advertised_type_cases() -> Vec { + vec![ + TypeCase::exact( + "TINYINT", + "TINYINT", + json!(42), + json!(42), + json!(255), + json!(255), + ), + TypeCase::exact( + "SMALLINT", + "SMALLINT", + json!(-123), + json!(-123), + json!(-32768), + json!(-32768), + ), + TypeCase::exact( + "INT", + "INT", + json!(123456), + json!(123456), + json!(2147483647), + json!(2147483647), + ), + TypeCase::exact( + "BIGINT", + "BIGINT", + json!("9007199254740992"), + json!("9007199254740992"), + json!("-9223372036854775808"), + json!("-9223372036854775808"), + ), + TypeCase::exact( + "DECIMAL", + "DECIMAL(38,10)", + json!("1234567890123456789012345678.1234567890"), + json!("1234567890123456789012345678.123456789"), + json!("-9999999999999999999999999999.9999999999"), + json!("-9999999999999999999999999999.9999999999"), + ), + TypeCase::exact( + "NUMERIC", + "NUMERIC(38,0)", + json!("90071992547409931234567890123456789012"), + json!("90071992547409931234567890123456789012"), + json!("99999999999999999999999999999999999999"), + json!("99999999999999999999999999999999999999"), + ), + TypeCase::exact( + "SMALLMONEY", + "SMALLMONEY", + json!("12.3456"), + json!("12.3456"), + json!("-214748.3648"), + json!("-214748.3648"), + ), + TypeCase::exact( + "MONEY", + "MONEY", + json!("-12.3400"), + json!("-12.34"), + json!("922337203685477.5807"), + json!("922337203685477.5807"), + ), + TypeCase::exact( + "FLOAT", + "FLOAT", + json!(1.25), + json!(1.25), + json!(1.7976931348623157e308), + json!(1.7976931348623157e308), + ), + TypeCase { + advertised_name: "REAL", + ddl: "REAL", + insert: json!(1.25), + inserted: ExpectedCell::Exact(json!(1.25)), + boundary: json!(3.4028235e38), + bounded: ExpectedCell::Approx(3.4028235e38), + semantic_check: None, + }, + TypeCase::exact( + "CHAR", + "CHAR(5)", + json!("abc"), + json!("abc "), + json!("12345"), + json!("12345"), + ), + TypeCase::exact( + "VARCHAR", + "VARCHAR(8)", + json!("plain"), + json!("plain"), + json!("edge'123"), + json!("edge'123"), + ), + TypeCase::exact( + "VARCHAR(MAX)", + "VARCHAR(MAX)", + json!("max text"), + json!("max text"), + json!("boundary text"), + json!("boundary text"), + ), + TypeCase::exact( + "TEXT", + "TEXT", + json!("legacy text"), + json!("legacy text"), + json!("legacy boundary"), + json!("legacy boundary"), + ), + TypeCase::exact( + "NCHAR", + "NCHAR(4)", + json!("猫"), + json!("猫 "), + json!("猫犬鳥魚"), + json!("猫犬鳥魚"), + ), + TypeCase::exact( + "NVARCHAR", + "NVARCHAR(16)", + json!("Grüße 🦀"), + json!("Grüße 🦀"), + json!("東京"), + json!("東京"), + ), + TypeCase::exact( + "NVARCHAR(MAX)", + "NVARCHAR(MAX)", + json!("Unicode Ω"), + json!("Unicode Ω"), + json!("boundary 🦀"), + json!("boundary 🦀"), + ), + TypeCase::exact( + "NTEXT", + "NTEXT", + json!("legacy Ω"), + json!("legacy Ω"), + json!("旧式"), + json!("旧式"), + ), + TypeCase::exact( + "BINARY", + "BINARY(4)", + blob_wire(&[1, 2]), + blob_wire(&[1, 2, 0, 0]), + blob_wire(&[0xde, 0xad, 0xbe, 0xef]), + blob_wire(&[0xde, 0xad, 0xbe, 0xef]), + ), + TypeCase::exact( + "VARBINARY", + "VARBINARY(8)", + blob_wire(&[1, 2, 3]), + blob_wire(&[1, 2, 3]), + blob_wire(&[0, 1, 2, 3, 4, 5, 6, 7]), + blob_wire(&[0, 1, 2, 3, 4, 5, 6, 7]), + ), + TypeCase::exact( + "VARBINARY(MAX)", + "VARBINARY(MAX)", + blob_wire(&[0xca, 0xfe]), + blob_wire(&[0xca, 0xfe]), + blob_wire(&[0xde, 0xad, 0xbe, 0xef]), + blob_wire(&[0xde, 0xad, 0xbe, 0xef]), + ), + TypeCase::exact( + "IMAGE", + "IMAGE", + blob_wire(&[9, 8, 7]), + blob_wire(&[9, 8, 7]), + blob_wire(&[6, 5, 4, 3]), + blob_wire(&[6, 5, 4, 3]), + ), + TypeCase::exact( + "DATE", + "DATE", + json!("2024-02-29"), + json!("2024-02-29"), + json!("0001-01-01"), + json!("0001-01-01"), + ), + TypeCase::exact( + "TIME", + "TIME(7)", + json!("12:34:56.1234567"), + json!("12:34:56.1234567"), + json!("23:59:59.9999999"), + json!("23:59:59.9999999"), + ), + TypeCase::exact( + "DATETIME", + "DATETIME", + json!("2024-01-02 03:04:05.006"), + json!("2024-01-02 03:04:05.007"), + json!("9999-12-31 23:59:59.997"), + json!("9999-12-31 23:59:59.997"), + ), + TypeCase::exact( + "DATETIME2", + "DATETIME2(7)", + json!("2024-01-02 03:04:05.1234567"), + json!("2024-01-02 03:04:05.1234567"), + json!("9999-12-31 23:59:59.9999999"), + json!("9999-12-31 23:59:59.9999999"), + ), + TypeCase::exact( + "SMALLDATETIME", + "SMALLDATETIME", + json!("2024-01-02 12:34:31"), + json!("2024-01-02 12:35:00"), + json!("1900-01-01 00:00:00"), + json!("1900-01-01 00:00:00"), + ), + TypeCase::exact( + "DATETIMEOFFSET", + "DATETIMEOFFSET(7)", + json!("2024-01-02 03:04:05.1234567 +05:30"), + json!("2024-01-02T03:04:05.123456700+05:30"), + json!("9999-12-31 23:59:59.9999999 +14:00"), + json!("9999-12-31T23:59:59.999999900+14:00"), + ), + TypeCase::exact( + "BIT", + "BIT", + json!(true), + json!(true), + json!(false), + json!(false), + ), + TypeCase::exact( + "UNIQUEIDENTIFIER", + "UNIQUEIDENTIFIER", + json!("00112233-4455-6677-8899-aabbccddeeff"), + json!("00112233-4455-6677-8899-aabbccddeeff"), + json!("ffffffff-ffff-ffff-ffff-ffffffffffff"), + json!("ffffffff-ffff-ffff-ffff-ffffffffffff"), + ), + TypeCase::exact( + "XML", + "XML", + json!("text"), + json!("text"), + json!(""), + json!(""), + ), + TypeCase::exact( + "SQL_VARIANT", + "SQL_VARIANT", + json!("variant text"), + json!("variant text"), + raw_sql("CAST(2147483647 AS INT)"), + json!(2147483647), + ), + TypeCase { + advertised_name: "HIERARCHYID", + ddl: "HIERARCHYID", + insert: raw_sql("hierarchyid::Parse('/1/3/')"), + inserted: ExpectedCell::Blob { exact_size: None }, + boundary: raw_sql("hierarchyid::Parse('/9/')"), + bounded: ExpectedCell::Blob { exact_size: None }, + semantic_check: Some(("value.ToString()", json!("/9/"))), + }, + TypeCase { + advertised_name: "GEOGRAPHY", + ddl: "GEOGRAPHY", + insert: raw_sql("geography::STGeomFromText('POINT (-122.35 47.65)', 4326)"), + inserted: ExpectedCell::Blob { exact_size: None }, + boundary: raw_sql("geography::STGeomFromText('POINT (180 90)', 4326)"), + bounded: ExpectedCell::Blob { exact_size: None }, + semantic_check: Some(("value.STSrid", json!(4326))), + }, + TypeCase { + advertised_name: "GEOMETRY", + ddl: "GEOMETRY", + insert: raw_sql("geometry::STGeomFromText('LINESTRING (0 0, 3 4)', 0)"), + inserted: ExpectedCell::Blob { exact_size: None }, + boundary: raw_sql("geometry::STGeomFromText('POINT (1 2)', 0)"), + bounded: ExpectedCell::Blob { exact_size: None }, + semantic_check: Some(("value.ToString()", json!("POINT (1 2)"))), + }, + ] +} + +#[test] +fn advertised_types_round_trip_through_query_insert_update_and_null() { + let manifest: Value = serde_json::from_str(include_str!("../.tabularium")) + .expect(".tabularium must be valid JSON"); + let advertised: BTreeSet = manifest["data_types"] + .as_array() + .expect("manifest data_types") + .iter() + .map(|data_type| data_type["name"].as_str().expect("type name").to_string()) + .collect(); + let cases = advertised_type_cases(); + let covered: BTreeSet = cases + .iter() + .map(|case| case.advertised_name.to_string()) + .chain(["ROWVERSION".to_string(), "TIMESTAMP".to_string()]) + .collect(); + assert_eq!( + covered, advertised, + "live matrix must cover every advertised type" + ); + assert!( + !advertised.contains("JSON") && !advertised.contains("VECTOR"), + "native JSON and VECTOR require a SQL Server version newer than the 2022 release baseline" + ); + + let mut plugin = Plugin::with_scratch_database(); + for (index, case) in cases.iter().enumerate() { + let table = format!("type_{index:02}"); + plugin.reset_table( + &table, + &format!("id INT PRIMARY KEY, value {} NULL", case.ddl), + ); + for (id, value) in [ + (1, case.insert.clone()), + (2, Value::Null), + (3, case.insert.clone()), + ] { + assert_eq!( + plugin.call_ok( + "insert_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": table, + "data": { "id": id, "value": value } + }), + ), + json!(1), + "{} insert id {id}", + case.advertised_name + ); + } + assert_eq!( + plugin.call_ok( + "update_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": table, + "pk_map": { "id": 3 }, "col_name": "value", + "new_val": case.boundary.clone() + }), + ), + json!(1), + "{} update", + case.advertised_name + ); + + let result = plugin.execute(format!( + "SELECT value FROM [{TEST_SCHEMA}].[{table}] ORDER BY id" + )); + let rows = result_rows(&result); + assert_eq!(rows.len(), 3, "{} row count", case.advertised_name); + assert_cell(case, "representative", &rows[0][0], &case.inserted); + assert_eq!( + rows[1][0], + Value::Null, + "{} SQL NULL representation", + case.advertised_name + ); + assert_cell(case, "boundary", &rows[2][0], &case.bounded); + + if let Some((expression, expected)) = &case.semantic_check { + let semantic = plugin.execute(format!( + "SELECT {expression} FROM [{TEST_SCHEMA}].[{table}] WHERE id = 3" + )); + assert_eq!( + semantic["rows"][0][0], *expected, + "{} raw-expression write semantics", + case.advertised_name + ); + } + } + + // ROWVERSION and its TIMESTAMP synonym are generated concurrency tokens: + // they have a defined eight-byte read representation but deliberately no + // NULL, insert-value, or update-value direction. + for (index, type_name) in ["ROWVERSION", "TIMESTAMP"].iter().enumerate() { + let table = format!("type_readonly_{index}"); + plugin.reset_table(&table, &format!("id INT PRIMARY KEY, value {type_name}")); + for id in [1, 2] { + plugin.call_ok( + "insert_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": table, + "data": { "id": id } + }), + ); + } + let result = plugin.execute(format!( + "SELECT value FROM [{TEST_SCHEMA}].[{table}] ORDER BY id" + )); + for row in result_rows(&result) { + let case = TypeCase { + advertised_name: type_name, + ddl: type_name, + insert: Value::Null, + inserted: ExpectedCell::Blob { + exact_size: Some(8), + }, + boundary: Value::Null, + bounded: ExpectedCell::Blob { + exact_size: Some(8), + }, + semantic_check: None, + }; + assert_cell(&case, "generated value", &row[0], &case.inserted); + } + let insert_error = plugin.call_error( + "insert_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": table, + "data": { "id": 3, "value": blob_wire(&[0; 8]) } + }), + ); + assert!( + insert_error.to_ascii_lowercase().contains("timestamp"), + "{type_name} must reject explicit host inserts: {insert_error}" + ); + let update_error = plugin.call_error( + "update_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": table, + "pk_map": { "id": 1 }, "col_name": "value", + "new_val": blob_wire(&[0; 8]) + }), + ); + assert!( + update_error.to_ascii_lowercase().contains("timestamp"), + "{type_name} must reject host updates: {update_error}" + ); + } +} + #[test] fn crud_insert_update_and_delete_support_single_and_composite_primary_keys() { let mut plugin = Plugin::with_scratch_database(); @@ -448,6 +1037,76 @@ fn crud_insert_update_and_delete_support_single_and_composite_primary_keys() { ); } +#[test] +fn hostile_identifiers_survive_ddl_and_crud_round_trip() { + const TABLE: &str = "[weird\"name]]"; + const KEY_COLUMN: &str = "order"; + const VALUE_COLUMN: &str = "9Δ\"value]"; + + let mut plugin = Plugin::with_scratch_database(); + let table_ref = format!("{}.{}", bracket_quote(TEST_SCHEMA), bracket_quote(TABLE)); + plugin.execute(format!("DROP TABLE IF EXISTS {table_ref}")); + + let create = generated_create_table_sql( + &mut plugin, + TABLE, + json!([ + { + "name": KEY_COLUMN, "data_type": "INT", "is_nullable": false, + "is_pk": true, "is_auto_increment": false, "default_value": null + }, + { + "name": VALUE_COLUMN, "data_type": "NVARCHAR(100)", "is_nullable": false, + "is_pk": false, "is_auto_increment": false, "default_value": null + } + ]), + ); + for statement in create { + plugin.execute(statement); + } + + assert_eq!( + plugin.call_ok( + "insert_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": TABLE, + "data": { "order": 7, "9Δ\"value]": "before" } + }), + ), + json!(1) + ); + assert_eq!( + plugin.call_ok( + "update_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": TABLE, + "pk_map": { "order": 7 }, "col_name": VALUE_COLUMN, "new_val": "after" + }), + ), + json!(1) + ); + + let selected = plugin.execute(format!( + "SELECT {} FROM {table_ref} WHERE {} = 7", + bracket_quote(VALUE_COLUMN), + bracket_quote(KEY_COLUMN), + )); + assert_eq!(selected["columns"], json!([VALUE_COLUMN])); + assert_eq!(selected["rows"], json!([["after"]])); + + assert_eq!( + plugin.call_ok( + "delete_record", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, "table": TABLE, + "pk_map": { "order": 7 } + }), + ), + json!(1) + ); + plugin.execute(format!("DROP TABLE {table_ref}")); +} + #[test] fn zero_row_select_preserves_column_headers() { let mut plugin = Plugin::with_scratch_database(); @@ -569,36 +1228,151 @@ fn identity_insert_succeeds_and_failure_restores_session_state() { } #[test] -fn pagination_returns_ordered_pages_has_more_and_explicit_unknown_total() { +fn pagination_and_batch_semantics_cover_ordered_unordered_cte_and_dml() { let mut plugin = Plugin::with_scratch_database(); - plugin.reset_table("pagination", "id INT PRIMARY KEY"); + plugin.reset_table( + "pagination", + "id INT PRIMARY KEY, touched BIT NOT NULL DEFAULT 0", + ); plugin.execute(format!( - "INSERT INTO [{TEST_SCHEMA}].[pagination] VALUES (1), (2), (3), (4), (5)" + "INSERT INTO [{TEST_SCHEMA}].[pagination] (id) VALUES (1), (2), (3), (4), (5)" )); - let query = format!("SELECT id FROM [{TEST_SCHEMA}].[pagination] ORDER BY id"); + let ordered_query = format!("SELECT id FROM [{TEST_SCHEMA}].[pagination] ORDER BY id"); let page_one = plugin.call_ok( "execute_query", - json!({ "params": connection_params(), "query": query, "limit": 2, "page": 1 }), + json!({ "params": connection_params(), "query": ordered_query, "limit": 2, "page": 1 }), ); assert_eq!(page_one["rows"], json!([[1], [2]])); assert_eq!(page_one["pagination"]["page"], 1); assert_eq!(page_one["pagination"]["page_size"], 2); assert_eq!(page_one["pagination"]["has_more"], true); assert_eq!(page_one["pagination"]["total_rows"], Value::Null); + assert_eq!(page_one["truncated"], true); + assert!(page_one.get("additional_results").is_none()); + + let final_page = plugin.call_ok( + "execute_query", + json!({ "params": connection_params(), "query": ordered_query, "limit": 2, "page": 3 }), + ); + assert_eq!(final_page["rows"], json!([[5]])); + assert_eq!(final_page["pagination"]["has_more"], false); + assert_eq!(final_page["truncated"], false); + + let unordered = plugin.call_ok( + "execute_query", + json!({ + "params": connection_params(), + "query": format!("SELECT id FROM [{TEST_SCHEMA}].[pagination]"), + "limit": 2, + "page": 1 + }), + ); + assert_eq!(result_rows(&unordered).len(), 2); + assert_eq!(unordered["pagination"]["has_more"], true); + assert_eq!(unordered["pagination"]["total_rows"], Value::Null); + + let cte = plugin.call_ok( + "execute_query", + json!({ + "params": connection_params(), + "query": format!( + "WITH source AS (SELECT id FROM [{TEST_SCHEMA}].[pagination] WHERE id >= 2) \ + SELECT id FROM source ORDER BY id DESC" + ), + "limit": 2, + "page": 2 + }), + ); + assert_eq!(cte["rows"], json!([[3], [2]])); + assert_eq!(cte["pagination"]["has_more"], false); + + let batch = plugin.call_ok( + "execute_query_batch", + json!({ + "params": connection_params(), + "queries": [ + format!("UPDATE [{TEST_SCHEMA}].[pagination] SET touched = 1 WHERE id = 1"), + ordered_query, + format!( + "SELECT id INTO #ss041_selected FROM [{TEST_SCHEMA}].[pagination] WHERE id <= 3" + ), + "SELECT id FROM #ss041_selected ORDER BY id" + ], + "limit": 2, + "page": 1 + }), + ); + assert_eq!(batch[0]["result"]["affected_rows"], 1); + assert!(batch[0]["result"].get("additional_results").is_none()); + assert_eq!(batch[1]["result"]["rows"], json!([[1], [2]])); + assert_eq!(batch[1]["result"]["pagination"]["has_more"], true); + assert_eq!(batch[2]["result"]["affected_rows"], 3); + assert_eq!(batch[2]["result"]["rows"], json!([])); + assert!(batch[2]["result"].get("additional_results").is_none()); + assert_eq!(batch[3]["result"]["rows"], json!([[1], [2]])); + assert_eq!(batch[3]["result"]["pagination"]["has_more"], true); +} - let page_two = plugin.call_ok( +#[test] +fn million_row_query_is_bounded_and_marks_truncation() { + let mut plugin = Plugin::with_scratch_database(); + let result = plugin.execute( + "SELECT TOP (1000000) \ + CAST(ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS INT) AS row_number \ + FROM sys.all_objects AS left_source \ + CROSS JOIN sys.all_objects AS right_source", + ); + + assert_eq!(result_rows(&result).len(), 10_000); + assert_eq!(result["truncated"], true); + assert_eq!(result["pagination"], Value::Null); + + // Cancelling the remainder of the TDS stream must leave the pooled + // connection immediately reusable. + let recovered = plugin.execute("SELECT CAST(1 AS INT) AS connection_ok"); + assert_eq!(recovered["rows"], json!([[1]])); +} + +#[test] +fn request_burst_is_bounded_and_slow_query_does_not_block_ping() { + let mut plugin = Plugin::with_scratch_database(); + let params = connection_params(); + let started = std::time::Instant::now(); + let slow_id = plugin.send( "execute_query", - json!({ "params": connection_params(), "query": query, "limit": 2, "page": 2 }), + json!({ + "params": params, + "query": "WAITFOR DELAY '00:00:02'; SELECT CAST(1 AS INT) AS finished" + }), ); - assert_eq!(page_two["rows"], json!([[3], [4]])); - assert_eq!(page_two["pagination"]["page"], 2); - assert_eq!(page_two["pagination"]["has_more"], true); - assert_eq!(page_two["pagination"]["total_rows"], Value::Null); + let ping_ids: BTreeSet = (0..200) + .map(|_| plugin.send("ping", json!({ "params": params }))) + .collect(); + + let first = plugin.read_response(); + let first_id = first["id"].as_u64().expect("response id"); + assert!(ping_ids.contains(&first_id), "a ping must finish first"); + assert!( + started.elapsed() < std::time::Duration::from_secs(2), + "the slow query blocked all JSON-RPC workers" + ); + + let mut seen = BTreeSet::from([first_id]); + for _ in 0..200 { + let response = plugin.read_response(); + assert!( + response.get("error").is_none(), + "burst response: {response}" + ); + seen.insert(response["id"].as_u64().expect("response id")); + } + assert!(seen.contains(&slow_id)); + assert!(ping_ids.iter().all(|id| seen.contains(id))); } #[test] -fn syntax_and_constraint_errors_surface_and_pooled_connection_recovers() { +fn syntax_and_constraint_errors_keep_server_details_and_pool_recovery() { let mut plugin = Plugin::with_scratch_database(); plugin.reset_table( "errors", @@ -610,9 +1384,17 @@ fn syntax_and_constraint_errors_surface_and_pooled_connection_recovers() { let syntax_error = plugin.call_error( "execute_query", - json!({ "params": connection_params(), "query": "SELEC definitely_invalid" }), + json!({ + "params": connection_params(), + "query": "\nSELECT 1 +" + }), + ); + assert!( + syntax_error.starts_with("SQL Server error 102:"), + "{syntax_error}" ); - assert!(!syntax_error.is_empty()); + assert!(syntax_error.contains("syntax error"), "{syntax_error}"); + assert!(syntax_error.contains("line 2"), "{syntax_error}"); let after_syntax = plugin.execute("SELECT CAST(1 AS INT) AS connection_ok"); assert_eq!(after_syntax["rows"], json!([[1]])); @@ -620,10 +1402,19 @@ fn syntax_and_constraint_errors_surface_and_pooled_connection_recovers() { "execute_query", json!({ "params": connection_params(), - "query": format!("INSERT INTO [{TEST_SCHEMA}].[errors] VALUES (2, 10)") + "query": format!( + "EXEC(N'INSERT INTO [{TEST_SCHEMA}].[errors] VALUES (2, 10)')" + ) }), ); - assert!(!constraint_error.is_empty()); + assert!( + constraint_error.starts_with("SQL Server error 2627:"), + "{constraint_error}" + ); + assert!( + constraint_error.contains("constraint violation"), + "{constraint_error}" + ); let after_constraint = plugin.execute(format!( "SELECT COUNT(*) AS row_count FROM [{TEST_SCHEMA}].[errors]" )); @@ -631,7 +1422,382 @@ fn syntax_and_constraint_errors_surface_and_pooled_connection_recovers() { } #[test] -fn explain_query_returns_showplan_xml_for_estimate_and_analyze() { +fn connection_authentication_and_tls_errors_are_actionable_and_redacted() { + let mut plugin = Plugin::with_scratch_database(); + let valid = connection_params(); + let username = valid["username"].as_str().expect("username"); + let password = valid["password"].as_str().expect("password"); + let host = valid["host"].as_str().expect("host"); + let port = valid["port"].as_u64().expect("port"); + let database = valid["database"].as_str().expect("database"); + + let connection_secret = "Ss043!ConnectionSecret9"; + let failed_connection_string = format!( + "sqlserver://{}:{}@{}:1/{}?Encrypt=true&TrustServerCertificate=true", + url_encode_component(username), + url_encode_component(connection_secret), + host, + url_encode_component(database), + ); + let connection_error = plugin.call_error( + "test_connection", + json!({ + "params": { + "connection_string": failed_connection_string, + "connection_id": "ss043-connection-recovery" + } + }), + ); + assert!( + connection_error.contains("SQL Server connection failure"), + "{connection_error}" + ); + assert!(!connection_error.contains(connection_secret)); + assert!(!connection_error.contains(password)); + assert!(!connection_error.contains(&failed_connection_string)); + + let mut recovered_connection = valid.clone(); + recovered_connection["connection_id"] = json!("ss043-connection-recovery"); + assert_eq!( + plugin.call_ok("test_connection", json!({ "params": recovered_connection })), + json!({ "success": true }) + ); + + let authentication_secret = "Ss043!WrongPassword9"; + let failed_auth_string = format!( + "sqlserver://{}:{}@{}:{}/{}?Encrypt=true&TrustServerCertificate=true", + url_encode_component(username), + url_encode_component(authentication_secret), + host, + port, + url_encode_component(database), + ); + let authentication_error = plugin.call_error( + "test_connection", + json!({ + "params": { + "connection_string": failed_auth_string, + "connection_id": "ss043-auth-recovery" + } + }), + ); + assert!( + authentication_error.starts_with("SQL Server error 18456:"), + "{authentication_error}" + ); + assert!( + authentication_error.contains("authentication failure"), + "{authentication_error}" + ); + assert!(!authentication_error.contains(authentication_secret)); + assert!(!authentication_error.contains(password)); + assert!(!authentication_error.contains(&failed_auth_string)); + + let mut recovered_auth = valid.clone(); + recovered_auth["connection_id"] = json!("ss043-auth-recovery"); + assert_eq!( + plugin.call_ok("test_connection", json!({ "params": recovered_auth })), + json!({ "success": true }) + ); + + let mut verify_full = valid; + verify_full["connection_id"] = json!("ss043-tls"); + verify_full["ssl_mode"] = json!("verify-full"); + let tls_error = plugin.call_error("test_connection", json!({ "params": verify_full })); + assert!( + tls_error.contains("SQL Server TLS negotiation failure"), + "{tls_error}" + ); + assert!(tls_error.contains("ssl_mode 'verify-full'"), "{tls_error}"); + assert!(tls_error.contains("ssl_mode 'require'"), "{tls_error}"); + + // A setup failure has no physical session to recycle, but it must not + // poison healthy pools in the same plugin process. + let after_tls = plugin.execute("SELECT CAST(1 AS INT) AS connection_ok"); + assert_eq!(after_tls["rows"], json!([[1]])); +} + +#[test] +fn permission_denial_keeps_number_and_pool_recovers() { + const LOGIN: &str = "ss043_denied_login"; + const USER: &str = "ss043_denied_user"; + const PASSWORD: &str = "Ss043!DeniedPassword9"; + + let mut plugin = Plugin::with_scratch_database(); + plugin.execute(format!( + "IF DATABASE_PRINCIPAL_ID(N'{USER}') IS NOT NULL DROP USER [{USER}]; \ + IF SUSER_ID(N'{LOGIN}') IS NOT NULL DROP LOGIN [{LOGIN}]; \ + DROP TABLE IF EXISTS [{TEST_SCHEMA}].[permission_error]; \ + CREATE TABLE [{TEST_SCHEMA}].[permission_error] (id INT PRIMARY KEY); \ + CREATE LOGIN [{LOGIN}] WITH PASSWORD = N'{PASSWORD}', CHECK_POLICY = OFF; \ + CREATE USER [{USER}] FOR LOGIN [{LOGIN}]; \ + DENY SELECT ON OBJECT::[{TEST_SCHEMA}].[permission_error] TO [{USER}]" + )); + + let mut denied_params = connection_params(); + denied_params["username"] = json!(LOGIN); + denied_params["password"] = json!(PASSWORD); + denied_params["connection_id"] = json!("ss043-permission"); + let permission_error = plugin.call_error( + "execute_query", + json!({ + "params": denied_params, + "query": format!("SELECT id FROM [{TEST_SCHEMA}].[permission_error]") + }), + ); + assert!( + permission_error.starts_with("SQL Server error 229:"), + "{permission_error}" + ); + assert!( + permission_error.contains("permission denial"), + "{permission_error}" + ); + assert!(!permission_error.contains(PASSWORD)); + let recovered = plugin.execute_with(&denied_params, "SELECT CAST(1 AS INT) AS connection_ok"); + assert_eq!(recovered["rows"], json!([[1]])); + + plugin.call_ok("shutdown", json!({})); + plugin.execute(format!( + "DROP TABLE IF EXISTS [{TEST_SCHEMA}].[permission_error]; \ + IF DATABASE_PRINCIPAL_ID(N'{USER}') IS NOT NULL DROP USER [{USER}]; \ + IF SUSER_ID(N'{LOGIN}') IS NOT NULL DROP LOGIN [{LOGIN}]" + )); +} + +#[test] +fn timeout_is_named_and_pool_replaces_the_cancelled_session() { + let mut plugin = Plugin::with_scratch_database(); + plugin.call_ok( + "initialize", + json!({ "settings": { "query_timeout_seconds": 1 } }), + ); + let mut params = connection_params(); + params["connection_id"] = json!("ss043-timeout"); + + let timeout_error = plugin.call_error( + "execute_query", + json!({ + "params": params, + "query": "WAITFOR DELAY '00:00:03'; SELECT CAST(1 AS INT) AS too_late" + }), + ); + assert!( + timeout_error.contains("SQL Server timeout"), + "{timeout_error}" + ); + let recovered = plugin.execute_with(¶ms, "SELECT CAST(1 AS INT) AS connection_ok"); + assert_eq!(recovered["rows"], json!([[1]])); +} + +#[test] +fn recycle_clears_identity_showplan_transaction_and_temp_table_state() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table( + "recycle_identity_first", + "id INT IDENTITY(1,1) PRIMARY KEY, value INT NOT NULL", + ); + plugin.reset_table( + "recycle_identity_second", + "id INT IDENTITY(1,1) PRIMARY KEY, value INT NOT NULL", + ); + let mut params = connection_params(); + params["connection_id"] = json!("ss043-session-state"); + let before = plugin.execute_with(¶ms, "SELECT @@SPID AS session_id"); + let session_id = before["rows"][0][0].as_i64().expect("session id"); + + // Leave identity and temp-table state deliberately active after a + // successful RPC. The next checkout must run the manager's reset on the + // same physical session. + plugin.execute_with( + ¶ms, + format!( + "SET IDENTITY_INSERT [{TEST_SCHEMA}].[recycle_identity_first] ON; \ + CREATE TABLE #ss043_temp (id INT)" + ), + ); + let reset_state = plugin.execute_with( + ¶ms, + "SELECT @@SPID AS session_id, @@TRANCOUNT AS transaction_count, \ + CASE WHEN OBJECT_ID('tempdb..#ss043_temp') IS NULL THEN 0 ELSE 1 END AS temp_exists", + ); + assert_eq!(reset_state["rows"], json!([[session_id, 0, 0]])); + let identity_recovered = plugin.call_ok( + "insert_record", + json!({ + "params": params, "schema": TEST_SCHEMA, "table": "recycle_identity_second", + "data": { "id": 43, "value": 1 } + }), + ); + assert_eq!(identity_recovered, json!(1)); + + plugin.execute_with(¶ms, "SET SHOWPLAN_XML ON"); + let reset_showplan = plugin.execute_with( + ¶ms, + "SELECT @@SPID AS session_id, CAST(1 AS INT) AS connection_ok", + ); + assert_eq!(reset_showplan["rows"], json!([[session_id, 1]])); + + // Open transactions are discarded without issuing commands into their + // session. Closing the socket rolls back and drops local temp objects. + plugin.execute_with( + ¶ms, + "BEGIN TRANSACTION; CREATE TABLE #ss043_open_transaction_temp (id INT)", + ); + let reset_transaction = plugin.execute_with( + ¶ms, + "SELECT @@TRANCOUNT AS transaction_count, \ + CASE WHEN OBJECT_ID('tempdb..#ss043_open_transaction_temp') IS NULL THEN 0 ELSE 1 END AS temp_exists", + ); + assert_eq!(reset_transaction["rows"], json!([[0, 0]])); + + // Server errors are conservatively discarded because an error token can + // leave unread protocol state. Replacement must still be immediate and + // must roll back the transaction and remove its temp table. + let identity_error = plugin.call_error( + "execute_query", + json!({ + "params": params, + "query": format!( + "SET IDENTITY_INSERT [{TEST_SCHEMA}].[recycle_identity_first] ON; \ + THROW 50043, 'identity cleanup fixture', 1" + ) + }), + ); + assert!(identity_error.starts_with("SQL Server error 50043:")); + let after_identity_error = plugin.call_ok( + "insert_record", + json!({ + "params": params, "schema": TEST_SCHEMA, "table": "recycle_identity_second", + "data": { "id": 44, "value": 1 } + }), + ); + assert_eq!(after_identity_error, json!(1)); + + let state_error = plugin.call_error( + "execute_query", + json!({ + "params": params, + "query": "BEGIN TRANSACTION; CREATE TABLE #ss043_error_temp (id INT); SELECT 1 / 0" + }), + ); + assert!( + state_error.starts_with("SQL Server error 8134:"), + "{state_error}" + ); + let state = plugin.execute_with( + ¶ms, + "SELECT @@TRANCOUNT AS transaction_count, \ + CASE WHEN OBJECT_ID('tempdb..#ss043_error_temp') IS NULL THEN 0 ELSE 1 END AS temp_exists", + ); + assert_eq!(state["rows"], json!([[0, 0]])); + + let showplan_error = plugin.call_error( + "explain_query", + json!({ + "params": params, + "query": "SELECT missing_column FROM definitely_missing_table", + "analyze": false + }), + ); + assert!( + showplan_error.starts_with("SQL Server error 208:"), + "{showplan_error}" + ); + let after_showplan = plugin.execute_with(¶ms, "SELECT CAST(1 AS INT) AS connection_ok"); + assert_eq!(after_showplan["rows"], json!([[1]])); +} + +#[test] +fn deadlock_victim_is_named_and_its_pool_recovers() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table("deadlock_error", "id INT PRIMARY KEY, value INT NOT NULL"); + plugin.execute(format!( + "INSERT INTO [{TEST_SCHEMA}].[deadlock_error] VALUES (1, 0), (2, 0)" + )); + + let mut params_a = connection_params(); + params_a["connection_id"] = json!("ss043-deadlock-a"); + let mut params_b = connection_params(); + params_b["connection_id"] = json!("ss043-deadlock-b"); + let query_a = format!( + "SET DEADLOCK_PRIORITY LOW; BEGIN TRANSACTION; \ + UPDATE [{TEST_SCHEMA}].[deadlock_error] SET value = value + 1 WHERE id = 1; \ + WAITFOR DELAY '00:00:01'; \ + UPDATE [{TEST_SCHEMA}].[deadlock_error] SET value = value + 1 WHERE id = 2; COMMIT" + ); + let query_b = format!( + "BEGIN TRANSACTION; \ + UPDATE [{TEST_SCHEMA}].[deadlock_error] SET value = value + 1 WHERE id = 2; \ + WAITFOR DELAY '00:00:01'; \ + UPDATE [{TEST_SCHEMA}].[deadlock_error] SET value = value + 1 WHERE id = 1; COMMIT" + ); + let id_a = plugin.send( + "execute_query", + json!({ "params": params_a, "query": query_a }), + ); + let id_b = plugin.send( + "execute_query", + json!({ "params": params_b, "query": query_b }), + ); + let first = plugin.read_response(); + let second = plugin.read_response(); + let responses = [first, second]; + let (victim_id, deadlock_error) = responses + .iter() + .find_map(|response| { + let message = response["error"]["message"].as_str()?; + message + .starts_with("SQL Server error 1205:") + .then(|| (response["id"].as_u64().expect("response id"), message)) + }) + .expect("one concurrent transaction must be the deadlock victim"); + assert!( + deadlock_error.starts_with("SQL Server error 1205:"), + "{deadlock_error}" + ); + assert!( + deadlock_error.contains("deadlock victim"), + "{deadlock_error}" + ); + assert_eq!(responses.len(), 2); + + let victim_params = if victim_id == id_a { + ¶ms_a + } else { + ¶ms_b + }; + assert!(victim_id == id_a || victim_id == id_b); + let recovered = plugin.execute_with( + victim_params, + "SELECT @@TRANCOUNT AS transaction_count, CAST(1 AS INT) AS connection_ok", + ); + assert_eq!(recovered["rows"], json!([[0, 1]])); +} + +#[test] +fn killed_pooled_connection_is_detected_and_replaced() { + let mut plugin = Plugin::with_scratch_database(); + let mut victim_params = connection_params(); + victim_params["connection_id"] = json!("ss043-killed-victim"); + let mut killer_params = connection_params(); + killer_params["connection_id"] = json!("ss043-killer"); + + let before = plugin.execute_with(&victim_params, "SELECT @@SPID AS session_id"); + let killed_session = before["rows"][0][0].as_i64().expect("session id"); + plugin.execute_with(&killer_params, format!("KILL {killed_session}")); + + let after = plugin.execute_with( + &victim_params, + "SELECT @@SPID AS session_id, @@TRANCOUNT AS transaction_count, \ + CAST(1 AS INT) AS connection_ok", + ); + assert_eq!(after["rows"][0][1], json!(0)); + assert_eq!(after["rows"][0][2], json!(1)); +} + +#[test] +fn explain_query_returns_raw_showplan_xml_for_estimate_and_analyze() { let mut plugin = Plugin::with_scratch_database(); plugin.reset_table("explain", "id INT PRIMARY KEY, value INT NOT NULL"); plugin.execute(format!( @@ -640,7 +1806,7 @@ fn explain_query_returns_showplan_xml_for_estimate_and_analyze() { let query = format!("SELECT value FROM [{TEST_SCHEMA}].[explain] WHERE id = 1"); for analyze in [false, true] { - let plan = plugin.call_ok( + let raw = plugin.call_ok( "explain_query", json!({ "params": connection_params(), @@ -648,14 +1814,166 @@ fn explain_query_returns_showplan_xml_for_estimate_and_analyze() { "analyze": analyze }), ); - let raw = plan["raw_output"] + let object = raw + .as_object() + .expect("raw EXPLAIN result must be an object"); + assert_eq!(object.len(), 4, "raw EXPLAIN shape changed: {raw}"); + assert_eq!(raw["engine"], "sqlserver"); + assert_eq!(raw["format"], "sqlserver-showplan-xml"); + assert_eq!(raw["original_query"], query); + + let payload = raw["payload"] .as_str() - .expect("parsed plan must retain its raw SHOWPLAN XML"); - assert!(raw.contains("ShowPlanXML"), "analyze={analyze}: {raw}"); - assert_eq!(plan["driver"], "sqlserver"); + .expect("raw EXPLAIN payload must be a SHOWPLAN XML string"); + assert!( + payload.trim_start().starts_with(""), + "analyze={analyze}: {payload}" + ); } } +#[test] +fn blob_png_round_trip_supports_composite_keys_image_and_clean_null_errors() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table( + "blob_round_trip", + "tenant_id INT NOT NULL, record_id INT NOT NULL, \ + png VARBINARY(MAX) NOT NULL, legacy IMAGE NULL, nullable VARBINARY(MAX) NULL, \ + version ROWVERSION, PRIMARY KEY (tenant_id, record_id)", + ); + let png = base64::engine::general_purpose::STANDARD + .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=") + .expect("valid PNG fixture"); + let png_hex: String = png.iter().map(|byte| format!("{byte:02X}")).collect(); + plugin.execute(format!( + "INSERT INTO [{TEST_SCHEMA}].[blob_round_trip] \ + (tenant_id, record_id, png, legacy, nullable) \ + VALUES (7, 9, 0x{png_hex}, 0x{png_hex}, NULL)" + )); + let row = json!({ "tenant_id": 7, "record_id": 9 }); + + let wire = plugin.call_ok( + "fetch_blob_as_data_url", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_round_trip", "col_name": "png", "pk_map": row, + "max_blob_size": png.len() + }), + ); + assert_eq!( + wire, + json!(format!( + "BLOB:{}:image/png:{}", + png.len(), + base64::engine::general_purpose::STANDARD.encode(&png) + )) + ); + + let legacy_wire = plugin.call_ok( + "fetch_blob_as_data_url", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_round_trip", "col_name": "legacy", "pk_map": row, + "max_blob_size": png.len() + }), + ); + assert!(legacy_wire + .as_str() + .expect("IMAGE preview wire string") + .starts_with(&format!("BLOB:{}:image/png:", png.len()))); + + let export_path = std::env::temp_dir().join(format!( + "tabularis-sqlserver-ss012-png-{}.png", + std::process::id() + )); + let _ = std::fs::remove_file(&export_path); + assert_eq!( + plugin.call_ok( + "save_blob_to_file", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_round_trip", "col_name": "png", "pk_map": row, + "file_path": export_path.to_string_lossy() + }), + ), + Value::Null + ); + assert_eq!(std::fs::read(&export_path).expect("exported PNG"), png); + std::fs::remove_file(&export_path).expect("remove exported PNG"); + + let null_path = std::env::temp_dir().join(format!( + "tabularis-sqlserver-ss012-null-{}.bin", + std::process::id() + )); + let _ = std::fs::remove_file(&null_path); + let null_error = plugin.call_error( + "save_blob_to_file", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_round_trip", "col_name": "nullable", "pk_map": row, + "file_path": null_path.to_string_lossy() + }), + ); + assert!(null_error.contains("NULL"), "{null_error}"); + assert!(!null_path.exists(), "NULL must not create a zero-byte file"); + + let rowversion_error = plugin.call_error( + "fetch_blob_as_data_url", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_round_trip", "col_name": "version", "pk_map": row, + "max_blob_size": 8 + }), + ); + assert!(rowversion_error.contains("concurrency token")); +} + +#[test] +fn varbinary_max_preview_ceiling_rejects_before_encoding_but_export_still_works() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table( + "blob_ceiling", + "id INT PRIMARY KEY, payload VARBINARY(MAX) NOT NULL", + ); + plugin.execute(format!( + "INSERT INTO [{TEST_SCHEMA}].[blob_ceiling] (id, payload) \ + VALUES (1, CONVERT(VARBINARY(MAX), REPLICATE(CAST('x' AS VARCHAR(MAX)), 4096)))" + )); + + let error = plugin.call_error( + "fetch_blob_as_data_url", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_ceiling", "col_name": "payload", "pk_map": { "id": 1 }, + "max_blob_size": 1024 + }), + ); + assert!(error.contains("4096 bytes"), "{error}"); + assert!(error.contains("max_blob_size of 1024 bytes"), "{error}"); + + let export_path = std::env::temp_dir().join(format!( + "tabularis-sqlserver-ss012-large-{}.bin", + std::process::id() + )); + let _ = std::fs::remove_file(&export_path); + plugin.call_ok( + "save_blob_to_file", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_ceiling", "col_name": "payload", "pk_map": { "id": 1 }, + "file_path": export_path.to_string_lossy() + }), + ); + assert_eq!( + std::fs::metadata(&export_path) + .expect("exported large VARBINARY(MAX)") + .len(), + 4096 + ); + std::fs::remove_file(export_path).expect("remove large BLOB export"); +} + #[test] fn startup_script_runs_on_pooled_connections() { let mut plugin = Plugin::with_scratch_database(); @@ -672,24 +1990,258 @@ fn startup_script_runs_on_pooled_connections() { } #[test] -fn connection_string_only_is_rejected_until_ss_011() { +fn connection_string_only_connects_for_url_and_keyword_syntaxes() { let mut plugin = Plugin::with_scratch_database(); let params = connection_params(); + let username = params["username"].as_str().expect("username"); + let password = params["password"].as_str().expect("password"); + let host = params["host"].as_str().expect("host"); + let port = params["port"].as_u64().expect("port"); + let database = params["database"].as_str().expect("database"); + + let url = format!( + "sqlserver://{}:{}@{}:{}/{}?Encrypt=true&TrustServerCertificate=true", + url_encode_component(username), + url_encode_component(password), + host, + port, + url_encode_component(database), + ); + let keyword = format!( + "Server=tcp:{host},{port};Database={};User Id={};Password={};Encrypt=true;TrustServerCertificate=true;", + brace_connection_value(database), + brace_connection_value(username), + brace_connection_value(password), + ); + + for (syntax, connection_string) in [("URL", url), ("keyword", keyword)] { + let result = plugin.call_ok( + "test_connection", + json!({ "params": { "connection_string": connection_string } }), + ); + assert_eq!(result, json!({ "success": true }), "{syntax} syntax"); + } +} + +#[test] +fn pool_keys_reuse_identical_and_equivalent_forms_but_separate_databases() { + let mut plugin = Plugin::with_scratch_database(); + + let identical = connection_params_for(&test_database(), "ss045-identical"); + let first = plugin.execute_with(&identical, "SELECT @@SPID AS session_id"); + let second = plugin.execute_with(&identical, "SELECT @@SPID AS session_id"); + assert_eq!(first["rows"][0][0], second["rows"][0][0]); + + let master = connection_params_for("master", "ss045-database-key"); + let selected = connection_params_for(&test_database(), "ss045-database-key"); + let master_session = plugin.execute_with(&master, "SELECT @@SPID AS session_id"); + let selected_session = plugin.execute_with(&selected, "SELECT @@SPID AS session_id"); + assert_ne!(master_session["rows"][0][0], selected_session["rows"][0][0]); + + let mut discrete = connection_params(); + discrete + .as_object_mut() + .expect("connection params object") + .remove("connection_id"); + let username = discrete["username"].as_str().expect("username"); + let password = discrete["password"].as_str().expect("password"); + let host = discrete["host"].as_str().expect("host"); + let port = discrete["port"].as_u64().expect("port"); + let database = discrete["database"].as_str().expect("database"); let connection_string = format!( - "sqlserver://{}:{}@{}:{}/{}", - params["username"].as_str().expect("username"), - params["password"].as_str().expect("password"), - params["host"].as_str().expect("host"), - params["port"].as_u64().expect("port"), - params["database"].as_str().expect("database"), + "sqlserver://{}:{}@{}:{}/{}?Encrypt=true&TrustServerCertificate=true", + url_encode_component(username), + url_encode_component(password), + host, + port, + url_encode_component(database), ); + let from_discrete = plugin.execute_with(&discrete, "SELECT @@SPID AS session_id"); + let from_string = plugin.execute_with( + &json!({ "connection_string": connection_string }), + "SELECT @@SPID AS session_id", + ); + assert_eq!(from_discrete["rows"][0][0], from_string["rows"][0][0]); +} - // TODO(SS-011): change this to call_ok once ConnectionParams accepts and - // parses connection_string. Today serde ignores the field and the plugin - // attempts its empty/default discrete connection, which must fail. - let error = plugin.call_error( - "test_connection", - json!({ "params": { "connection_string": connection_string } }), +#[test] +fn database_user_lifecycle_privilege_diff_roles_and_ownership_guard() { + const LOGIN: &str = "ss014_login"; + const USER: &str = "ss014_user"; + const ROLE: &str = "ss014_role"; + const OWNED_SCHEMA: &str = "ss014_owned"; + const PASSWORD_1: &str = "Ss014!InitialPass9"; + const PASSWORD_2: &str = "Ss014!ChangedPass9"; + + let mut plugin = Plugin::with_scratch_database(); + plugin.execute(format!( + "IF SCHEMA_ID(N'{OWNED_SCHEMA}') IS NOT NULL BEGIN \ + ALTER AUTHORIZATION ON SCHEMA::[{OWNED_SCHEMA}] TO [dbo]; \ + DROP SCHEMA [{OWNED_SCHEMA}]; \ + END; \ + IF DATABASE_PRINCIPAL_ID(N'{ROLE}') IS NOT NULL \ + AND DATABASE_PRINCIPAL_ID(N'{USER}') IS NOT NULL \ + ALTER ROLE [{ROLE}] DROP MEMBER [{USER}]; \ + IF DATABASE_PRINCIPAL_ID(N'{USER}') IS NOT NULL DROP USER [{USER}]; \ + IF DATABASE_PRINCIPAL_ID(N'{ROLE}') IS NOT NULL DROP ROLE [{ROLE}]; \ + IF SUSER_ID(N'{LOGIN}') IS NOT NULL DROP LOGIN [{LOGIN}]; \ + DROP TABLE IF EXISTS [{TEST_SCHEMA}].[ss014_permissions]; \ + CREATE TABLE [{TEST_SCHEMA}].[ss014_permissions] \ + (id INT PRIMARY KEY, value NVARCHAR(20) NOT NULL)" + )); + + let catalog = plugin.call_ok("get_db_privilege_catalog", json!({})); + assert!(catalog["database"] + .as_array() + .expect("database catalog") + .contains(&json!("SELECT"))); + assert!(catalog["global"] + .as_array() + .expect("database-only catalog") + .contains(&json!("SHOWPLAN"))); + assert!(catalog["table"] + .as_array() + .expect("object catalog") + .contains(&json!("UPDATE"))); + + plugin.call_ok( + "create_db_user", + json!({ + "params": connection_params(), "user": USER, "host": LOGIN, + "password": PASSWORD_1 + }), ); - assert!(!error.is_empty()); + let users = plugin.call_ok("get_db_users", json!({ "params": connection_params() })); + assert!(users + .as_array() + .expect("users array") + .iter() + .any(|account| { account == &json!({ "user": USER, "host": LOGIN, "locked": false }) })); + + plugin.call_ok( + "set_db_user_password", + json!({ + "params": connection_params(), "user": USER, "host": LOGIN, + "password": PASSWORD_2 + }), + ); + plugin.execute(format!( + "CREATE ROLE [{ROLE}]; \ + GRANT UPDATE ON OBJECT::[{TEST_SCHEMA}].[ss014_permissions] TO [{ROLE}]; \ + ALTER ROLE [{ROLE}] ADD MEMBER [{USER}]" + )); + for (database, table, privileges) in [ + (Value::Null, Value::Null, vec!["SELECT"]), + (json!(TEST_SCHEMA), Value::Null, vec!["EXECUTE"]), + ( + json!(TEST_SCHEMA), + json!("ss014_permissions"), + vec!["SELECT", "INSERT"], + ), + ] { + let request = json!({ + "params": connection_params(), "user": USER, "host": LOGIN, + "database": database, "table": table, + "privileges": privileges, "grant": true + }); + plugin.call_ok("apply_db_user_privileges", request.clone()); + // Applying an already-satisfied request exercises the server-side diff. + plugin.call_ok("apply_db_user_privileges", request); + } + + let parsed = plugin.call_ok( + "get_db_user_privileges", + json!({ "params": connection_params(), "user": USER, "host": LOGIN }), + ); + let object_scope = parsed + .as_array() + .expect("grant sets") + .iter() + .find(|scope| scope["database"] == TEST_SCHEMA && scope["table"] == "ss014_permissions") + .expect("direct object grant"); + assert!(object_scope["privileges"] + .as_array() + .expect("object privileges") + .contains(&json!("SELECT"))); + assert!(object_scope["privileges"] + .as_array() + .expect("object privileges") + .contains(&json!("INSERT"))); + assert!( + !object_scope["privileges"] + .as_array() + .expect("object privileges") + .contains(&json!("UPDATE")), + "inherited rights must not look direct" + ); + + let raw = plugin.call_ok( + "get_db_user_grants", + json!({ "params": connection_params(), "user": USER, "host": LOGIN }), + ); + let raw = raw.as_array().expect("raw grants"); + assert!(raw.iter().any(|line| line + .as_str() + .is_some_and(|line| { line.contains("ROLE MEMBERSHIP") && line.contains(ROLE) }))); + assert!(raw.iter().any(|line| line + .as_str() + .is_some_and(|line| { line.contains("INHERITED VIA ROLE") && line.contains("UPDATE") }))); + + plugin.call_ok( + "apply_db_user_privileges", + json!({ + "params": connection_params(), "user": USER, "host": LOGIN, + "database": TEST_SCHEMA, "table": "ss014_permissions", + "privileges": ["SELECT", "INSERT"], "grant": false + }), + ); + plugin.execute(format!( + "DENY DELETE ON OBJECT::[{TEST_SCHEMA}].[ss014_permissions] TO [{USER}]" + )); + let deny_error = plugin.call_error( + "apply_db_user_privileges", + json!({ + "params": connection_params(), "user": USER, "host": LOGIN, + "database": TEST_SCHEMA, "table": "ss014_permissions", + "privileges": ["DELETE"], "grant": true + }), + ); + assert!(deny_error.contains("DENY"), "{deny_error}"); + plugin.execute(format!( + "REVOKE DELETE ON OBJECT::[{TEST_SCHEMA}].[ss014_permissions] FROM [{USER}]" + )); + plugin.execute(format!( + "CREATE SCHEMA [{OWNED_SCHEMA}] AUTHORIZATION [{USER}]" + )); + + let ownership_error = plugin.call_error( + "drop_db_user", + json!({ "params": connection_params(), "user": USER, "host": LOGIN }), + ); + assert!( + ownership_error.contains("schema or object"), + "{ownership_error}" + ); + assert!(ownership_error.contains("owns"), "{ownership_error}"); + + plugin.execute(format!( + "ALTER AUTHORIZATION ON SCHEMA::[{OWNED_SCHEMA}] TO [dbo]; \ + DROP SCHEMA [{OWNED_SCHEMA}]; \ + ALTER ROLE [{ROLE}] DROP MEMBER [{USER}]; \ + DROP ROLE [{ROLE}]" + )); + plugin.call_ok( + "drop_db_user", + json!({ "params": connection_params(), "user": USER, "host": LOGIN }), + ); + let users = plugin.call_ok("get_db_users", json!({ "params": connection_params() })); + assert!(!users + .as_array() + .expect("users array") + .iter() + .any(|account| { account["user"] == USER || account["host"] == LOGIN })); + let login = plugin.execute(format!( + "SELECT COUNT(*) AS login_count FROM sys.server_principals WHERE name = N'{LOGIN}'" + )); + assert_eq!(login["rows"], json!([[0]])); }