diff --git a/.github/RELEASING.md b/.github/RELEASING.md new file mode 100644 index 0000000..57ab811 --- /dev/null +++ b/.github/RELEASING.md @@ -0,0 +1,40 @@ +# Releasing + +The website and the packages ship separately. + +## Website + +Cloudflare Pages builds `website/` (`npm ci && npm run build`, output `dist`) from its own Git +integration. A push to `main` deploys https://sqlclean.io; every other branch deploys a preview +at `.sqlclean.pages.dev`. No workflow here deploys the site, and publishing packages never +touches it. + +## Packages + +Release Please keeps a release pull request open on `main`. Merging it tags `v0.MINOR.PATCH` and +dispatches `publish.yml`, which builds the `sqlclean-cli` wheels and both sdists and publishes +`sqlclean` and `sqlclean-cli` to PyPI. + +Release Please counts every Conventional Commit on `main` since the last release, whatever its +type: `docs:` and `chore:` commits bump the patch version even though their changelog sections +are hidden or not. What keeps a change out of a release is `exclude-paths` in +`release-please-config.json`. A commit whose files all sit under `website/`, `benchmarks/` or +`.github/` is ignored, so site, benchmark and workflow edits never cut a release. + +Documentation edits usually do release, on purpose: `make skills` copies the docs into +`crates/sqlclean-cli/skill/`, which the `sqlclean` executable embeds for `sqlclean skills`, so the +installed skill stays in step with the docs. A homepage or styling change touches only `website/` +and does not. + +## Verify + +`verify.yml` classifies a pull request by the paths it changes and runs only the suites it needs. +The `Verify` job is the one check to require: it passes when every selected suite passed. + +| Change | Suites | +|--------|--------| +| Only `website/`, `benchmarks/`, root `*.md`, `.github/demos/`, images, `website.yml` | docs skill check and ruff | +| Also the bundled skill under `crates/sqlclean-cli/skill/` | plus a Linux build and the `sqlclean skills` tests | +| Anything else, including workflows, Rust, Python, tests and lockfiles | everything: `make verify`, minimum Rust, Windows, three-platform package smoke tests and the source build | + +A manual or release dispatch has no pull request diff, so it runs everything. diff --git a/.github/demos/check-fix.gif b/.github/demos/check-fix.gif new file mode 100644 index 0000000..7cdbd1e Binary files /dev/null and b/.github/demos/check-fix.gif differ diff --git a/.github/scripts/assert_cli_wheel_tags.sh b/.github/scripts/assert_cli_wheel_tags.sh new file mode 100644 index 0000000..bb3c99a --- /dev/null +++ b/.github/scripts/assert_cli_wheel_tags.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Guard the sqlclean-cli wheel tag. The package ships one standalone Rust +# executable that links no libpython, so an interpreter-specific tag makes the +# wheel uninstallable on every other Python and silently forces a source build. +set -euo pipefail +shopt -s nullglob + +directory="${1:?usage: assert_cli_wheel_tags.sh }" +wheels=("${directory}"/sqlclean_cli-*.whl) + +if [ ${#wheels[@]} -eq 0 ]; then + echo "No sqlclean-cli wheel found in ${directory}." + exit 1 +fi + +status=0 +for wheel in "${wheels[@]}"; do + name="$(basename "${wheel}")" + case "${name}" in + sqlclean_cli-*-py3-none-*.whl) + echo "ok: ${name}" + ;; + *) + echo "error: sqlclean-cli wheel must be tagged py3-none-: ${name}" + status=1 + ;; + esac +done + +exit "${status}" diff --git a/.github/scripts/classify_changes.py b/.github/scripts/classify_changes.py new file mode 100644 index 0000000..82929ef --- /dev/null +++ b/.github/scripts/classify_changes.py @@ -0,0 +1,55 @@ +"""Decide which Verify suites a change needs from the paths it touches, one per line on stdin. + +Prints `full=` and `skills=` lines for GITHUB_OUTPUT: + +- Documentation, website, benchmark and image changes need only the light checks. +- Changes confined to the bundled agent skill also rebuild the executable on Linux and run the + skills command tests, because the executable embeds the skill. +- Any other change, including workflows, Rust, Python, tests and lockfiles, runs every suite. + +An empty path list means the diff could not be computed, so every suite runs. +""" + +from __future__ import annotations + +import fnmatch +import sys + +LIGHT: tuple[str, ...] = ( + "website/*", + "benchmarks/*", + ".github/demos/*", + ".github/*.png", + ".github/*.md", + ".github/workflows/website.yml", + "*.md", +) +SKILL: tuple[str, ...] = ("crates/sqlclean-cli/skill/*",) + + +def classify(paths: list[str]) -> dict[str, bool]: + """Return which suites the changed paths need.""" + + def matches(path: str, patterns: tuple[str, ...]) -> bool: + # `*.md` must only match a root file, while `website/*` matches every depth. + return any( + fnmatch.fnmatch(path, pattern) and ("/" in pattern or "/" not in path) + for pattern in patterns + ) + + skills: bool = any(matches(path, SKILL) for path in paths) + full: bool = not paths or any( + not matches(path, LIGHT) and not matches(path, SKILL) for path in paths + ) + return {"full": full, "skills": skills and not full} + + +def main() -> int: + paths: list[str] = [line.strip() for line in sys.stdin if line.strip()] + for name, value in classify(paths).items(): + print(f"{name}={str(value).lower()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/release_metadata_check.py b/.github/scripts/release_metadata_check.py new file mode 100644 index 0000000..2d6f632 --- /dev/null +++ b/.github/scripts/release_metadata_check.py @@ -0,0 +1,469 @@ +"""Reject release branches containing non-generated metadata changes.""" + +from __future__ import annotations + +import argparse +import copy +import json +import re +import subprocess +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +SEMVER_PART_COUNT: int = 3 +NORMALIZED_VERSION: str = "0.0.0" +CHANGELOG_PATH: str = "CHANGELOG.md" +RELEASE_MANIFEST_PATH: str = ".release-please-manifest.json" +ROOT_PROJECT_PATH: str = "pyproject.toml" +CLI_PROJECT_PATH: str = "packages/sqlclean-cli/pyproject.toml" +CARGO_WORKSPACE_PATH: str = "Cargo.toml" +CARGO_LOCK_PATH: str = "Cargo.lock" +UV_LOCK_PATH: str = "uv.lock" +CLI_DISTRIBUTION: str = "sqlclean-cli" +VERSION_PATHS: tuple[str, ...] = ( + RELEASE_MANIFEST_PATH, + ROOT_PROJECT_PATH, + CLI_PROJECT_PATH, + CARGO_WORKSPACE_PATH, + UV_LOCK_PATH, + CARGO_LOCK_PATH, +) +# Files that may not exist before the first release. +OPTIONAL_BASE_PATHS: frozenset[str] = frozenset({CHANGELOG_PATH}) +OWNED_CARGO_PACKAGES: frozenset[str] = frozenset({"sqlclean", "sqlclean-cli"}) +OWNED_UV_PACKAGES: frozenset[str] = frozenset({"sqlclean", "sqlclean-cli"}) +ALLOWED_PATHS: frozenset[str] = frozenset((*VERSION_PATHS, CHANGELOG_PATH)) +# The TOML table holding the owned version field in each manifest. +MANIFEST_SECTIONS: dict[str, tuple[str, ...]] = { + ROOT_PROJECT_PATH: ("project",), + CLI_PROJECT_PATH: ("project",), + CARGO_WORKSPACE_PATH: ("workspace", "package"), +} + + +@dataclass(frozen=True) +class ReleaseDelta: + """Base and release-head content for generated metadata files.""" + + old_version: str + new_version: str + changed_paths: frozenset[str] + base_files: dict[str, str] + head_files: dict[str, str] + + +def load_release_delta(*, repo_root: Path, base_ref: str, head_ref: str) -> ReleaseDelta: + """Read the changed metadata and package versions from two Git revisions.""" + + changed: list[str] = _git( + "diff", "--name-only", base_ref, head_ref, repo_root=repo_root + ).splitlines() + base_files: dict[str, str] = { + path: _show(ref=base_ref, path=path, repo_root=repo_root) for path in ALLOWED_PATHS + } + head_files: dict[str, str] = { + path: _git("show", f"{head_ref}:{path}", repo_root=repo_root) for path in ALLOWED_PATHS + } + old_version: str = tomllib.loads(base_files[ROOT_PROJECT_PATH])["project"]["version"] + new_version: str = tomllib.loads(head_files[ROOT_PROJECT_PATH])["project"]["version"] + return ReleaseDelta( + old_version=old_version, + new_version=new_version, + changed_paths=frozenset(changed), + base_files=base_files, + head_files=head_files, + ) + + +def get_release_delta_errors( + *, + delta: ReleaseDelta, + allow_stale_lockfiles: bool = False, +) -> list[str]: + """Return every unsafe or inconsistent release delta.""" + + errors: list[str] = [] + unexpected: list[str] = sorted(delta.changed_paths - ALLOWED_PATHS) + if unexpected: + errors.append(f"unexpected release files: {', '.join(unexpected)}") + if not is_forward_semver(old_version=delta.old_version, new_version=delta.new_version): + errors.append("release version must be a forward MAJOR.MINOR.PATCH transition") + for path in VERSION_PATHS: + if not version_file_is_valid( + path=path, + delta=delta, + allow_stale_lockfiles=allow_stale_lockfiles, + ): + errors.append(f"{path} contains changes beyond the release version substitution") + changelog: str = delta.head_files[CHANGELOG_PATH] + base_history: str = delta.base_files[CHANGELOG_PATH].partition("\n\n")[2] + if not changelog.startswith("# Changelog\n\n") or base_history not in changelog: + errors.append("CHANGELOG.md rewrites existing release history") + if f"## [{delta.new_version}]" not in changelog: + errors.append(f"CHANGELOG.md has no {delta.new_version} release heading") + return errors + + +def is_forward_semver(*, old_version: str, new_version: str) -> bool: + """Return whether two plain semantic versions form a forward transition.""" + + old_semver: tuple[int, int, int] | None = _parse_semver(value=old_version) + new_semver: tuple[int, int, int] | None = _parse_semver(value=new_version) + return old_semver is not None and new_semver is not None and new_semver > old_semver + + +def version_file_is_valid( + *, + path: str, + delta: ReleaseDelta, + allow_stale_lockfiles: bool, +) -> bool: + """Compare one parsed metadata file after normalizing owned version fields.""" + + try: + semantic_valid: bool + if path == RELEASE_MANIFEST_PATH: + semantic_valid = _release_manifest_is_valid(delta=delta, path=path) + else: + base: dict[str, Any] = tomllib.loads(delta.base_files[path]) + head: dict[str, Any] = tomllib.loads(delta.head_files[path]) + if path == ROOT_PROJECT_PATH: + semantic_valid = _root_project_is_valid(base=base, head=head, delta=delta) + elif path in MANIFEST_SECTIONS: + semantic_valid = _manifest_is_valid( + base=base, + head=head, + section=MANIFEST_SECTIONS[path], + delta=delta, + ) + elif path == CARGO_LOCK_PATH: + semantic_valid = _lock_is_valid( + base=base, + head=head, + owned_packages=OWNED_CARGO_PACKAGES, + delta=delta, + allow_old=allow_stale_lockfiles, + ) + elif path == UV_LOCK_PATH: + semantic_valid = _lock_is_valid( + base=base, + head=head, + owned_packages=OWNED_UV_PACKAGES, + delta=delta, + allow_old=allow_stale_lockfiles, + ) + else: + return False + return semantic_valid and _version_text_is_valid( + path=path, + delta=delta, + allow_stale_lockfiles=allow_stale_lockfiles, + ) + except (KeyError, TypeError, ValueError, tomllib.TOMLDecodeError): + return False + + +def _parse_semver(*, value: str) -> tuple[int, int, int] | None: + parts: list[str] = value.split(".") + if len(parts) != SEMVER_PART_COUNT or any(not part.isdigit() for part in parts): + return None + return (int(parts[0]), int(parts[1]), int(parts[2])) + + +def _release_manifest_is_valid(*, delta: ReleaseDelta, path: str) -> bool: + base: dict[str, Any] = json.loads(delta.base_files[path]) + head: dict[str, Any] = json.loads(delta.head_files[path]) + base_normalized: dict[str, Any] | None = _normalized_field( + document=base, + section=(), + field=".", + expected=delta.old_version, + ) + head_normalized: dict[str, Any] | None = _normalized_field( + document=head, + section=(), + field=".", + expected=delta.new_version, + ) + return base_normalized is not None and base_normalized == head_normalized + + +def _manifest_is_valid( + *, + base: dict[str, Any], + head: dict[str, Any], + section: tuple[str, ...], + delta: ReleaseDelta, +) -> bool: + base_normalized: dict[str, Any] | None = _normalized_field( + document=base, + section=section, + field="version", + expected=delta.old_version, + ) + head_normalized: dict[str, Any] | None = _normalized_field( + document=head, + section=section, + field="version", + expected=delta.new_version, + ) + return base_normalized is not None and base_normalized == head_normalized + + +def _root_project_is_valid( + *, + base: dict[str, Any], + head: dict[str, Any], + delta: ReleaseDelta, +) -> bool: + base_normalized: dict[str, Any] | None = _normalized_field( + document=base, + section=("project",), + field="version", + expected=delta.old_version, + ) + head_normalized: dict[str, Any] | None = _normalized_field( + document=head, + section=("project",), + field="version", + expected=delta.new_version, + ) + if base_normalized is None or head_normalized is None: + return False + base_normalized = _normalized_cli_dependency( + document=base_normalized, + version=delta.old_version, + ) + head_normalized = _normalized_cli_dependency( + document=head_normalized, + version=delta.new_version, + ) + return base_normalized is not None and base_normalized == head_normalized + + +def _normalized_field( + *, + document: dict[str, Any], + section: tuple[str, ...], + field: str, + expected: str, +) -> dict[str, Any] | None: + normalized: dict[str, Any] = copy.deepcopy(document) + target: dict[str, Any] = normalized + for key in section: + target = target[key] + if target.get(field) != expected: + return None + target[field] = NORMALIZED_VERSION + return normalized + + +def _normalized_cli_dependency( + *, + document: dict[str, Any], + version: str, +) -> dict[str, Any] | None: + requirement: str = f"{CLI_DISTRIBUTION}=={version}" + dependencies: list[Any] = document["project"].get("dependencies", []) + if dependencies.count(requirement) != 1: + return None + normalized: dict[str, Any] = copy.deepcopy(document) + normalized["project"]["dependencies"] = [ + f"{CLI_DISTRIBUTION}=={NORMALIZED_VERSION}" if item == requirement else item + for item in dependencies + ] + return normalized + + +def _lock_is_valid( + *, + base: dict[str, Any], + head: dict[str, Any], + owned_packages: frozenset[str], + delta: ReleaseDelta, + allow_old: bool, +) -> bool: + base_normalized: dict[str, Any] | None = _normalized_lock( + document=base, + owned_packages=owned_packages, + allowed_versions=frozenset({delta.old_version}), + ) + allowed_head: frozenset[str] = frozenset( + {delta.old_version, delta.new_version} if allow_old else {delta.new_version} + ) + head_normalized: dict[str, Any] | None = _normalized_lock( + document=head, + owned_packages=owned_packages, + allowed_versions=allowed_head, + ) + return base_normalized is not None and base_normalized == head_normalized + + +def _normalized_lock( + *, + document: dict[str, Any], + owned_packages: frozenset[str], + allowed_versions: frozenset[str], +) -> dict[str, Any] | None: + normalized: dict[str, Any] = copy.deepcopy(document) + found: set[str] = set() + for package in normalized.get("package", []): + if package.get("name") not in owned_packages: + continue + found.add(package["name"]) + if package.get("version") not in allowed_versions: + return None + package["version"] = NORMALIZED_VERSION + return normalized if found == owned_packages else None + + +def _version_text_is_valid( + *, + path: str, + delta: ReleaseDelta, + allow_stale_lockfiles: bool, +) -> bool: + base: str = delta.base_files[path] + expected: str | None + if path == RELEASE_MANIFEST_PATH: + expected = _replace_once( + value=base, + pattern=rf'("\."\s*:\s*"){re.escape(delta.old_version)}(")', + replacement=rf"\g<1>{delta.new_version}\g<2>", + ) + elif path in MANIFEST_SECTIONS: + expected = _replace_manifest_version( + value=base, + section=".".join(MANIFEST_SECTIONS[path]), + old_version=delta.old_version, + new_version=delta.new_version, + ) + if expected is not None and path == ROOT_PROJECT_PATH: + expected = _replace_once( + value=expected, + pattern=re.escape(f"{CLI_DISTRIBUTION}=={delta.old_version}"), + replacement=f"{CLI_DISTRIBUTION}=={delta.new_version}", + ) + elif path == CARGO_LOCK_PATH: + expected = _replace_lock_versions( + value=base, + owned_packages=OWNED_CARGO_PACKAGES, + old_version=delta.old_version, + new_version=delta.new_version, + ) + elif path == UV_LOCK_PATH: + expected = _replace_lock_versions( + value=base, + owned_packages=OWNED_UV_PACKAGES, + old_version=delta.old_version, + new_version=delta.new_version, + ) + else: + return False + if expected is None: + return False + head: str = delta.head_files[path] + return head == expected or ( + allow_stale_lockfiles and path in {CARGO_LOCK_PATH, UV_LOCK_PATH} and head == base + ) + + +def _replace_manifest_version( + *, + value: str, + section: str, + old_version: str, + new_version: str, +) -> str | None: + return _replace_once( + value=value, + pattern=( + rf'(?ms)(^\[{re.escape(section)}\]\s*$.*?^version\s*=\s*")' + rf"{re.escape(old_version)}" + r'("[^\n]*$)' + ), + replacement=rf"\g<1>{new_version}\g<2>", + ) + + +def _replace_lock_versions( + *, + value: str, + owned_packages: frozenset[str], + old_version: str, + new_version: str, +) -> str | None: + blocks: list[str] = re.split(r"(?=^\[\[package\]\]\s*$)", value, flags=re.MULTILINE) + found: set[str] = set() + for index, block in enumerate(blocks): + name_match: re.Match[str] | None = re.search(r'^name\s*=\s*"([^"]+)"', block, re.MULTILINE) + if name_match is None or name_match.group(1) not in owned_packages: + continue + found.add(name_match.group(1)) + replaced: str | None = _replace_once( + value=block, + pattern=rf'(?m)(^version\s*=\s*"){re.escape(old_version)}("[^\n]*$)', + replacement=rf"\g<1>{new_version}\g<2>", + ) + if replaced is None: + return None + blocks[index] = replaced + return "".join(blocks) if found == owned_packages else None + + +def _replace_once(*, value: str, pattern: str, replacement: str) -> str | None: + replaced, count = re.subn(pattern, replacement, value, count=1) + return replaced if count == 1 else None + + +def _show(*, ref: str, path: str, repo_root: Path) -> str: + try: + return _git("show", f"{ref}:{path}", repo_root=repo_root) + except subprocess.CalledProcessError: + if path in OPTIONAL_BASE_PATHS: + return "" + raise + + +def _git(*arguments: str, repo_root: Path) -> str: + result: subprocess.CompletedProcess[str] = subprocess.run( + ["git", *arguments], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ) + return result.stdout + + +def _parse_args() -> argparse.Namespace: + parser: argparse.ArgumentParser = argparse.ArgumentParser(prog="release_metadata_check") + parser.add_argument("--base", required=True) + parser.add_argument("--head", default="HEAD") + parser.add_argument("--repo", type=Path, default=Path.cwd()) + parser.add_argument("--allow-stale-lockfiles", action="store_true") + return parser.parse_args() + + +def main() -> int: + """Validate one generated release delta and render actionable failures.""" + + arguments: argparse.Namespace = _parse_args() + delta: ReleaseDelta = load_release_delta( + repo_root=arguments.repo, + base_ref=arguments.base, + head_ref=arguments.head, + ) + errors: list[str] = get_release_delta_errors( + delta=delta, + allow_stale_lockfiles=arguments.allow_stale_lockfiles, + ) + for error in errors: + print(f"release metadata error: {error}") + return int(bool(errors)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/sqlclean-logo.png b/.github/sqlclean-logo.png new file mode 100644 index 0000000..24592d0 Binary files /dev/null and b/.github/sqlclean-logo.png differ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..7de9a50 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,180 @@ +name: Publish + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag to publish, for example v0.1.0" + required: true + type: string + +permissions: + contents: read + +jobs: + release-tag: + name: Resolve release tag + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.release-tag.outputs.tag }} + version: ${{ steps.release-tag.outputs.version }} + steps: + - name: Resolve release tag + id: release-tag + run: | + tag="${{ inputs.tag }}" + + if [[ ! "$tag" =~ ^v0\.[0-9]+\.[0-9]+$ ]]; then + echo "Refusing to publish tag '$tag'. Expected exact v0.MINOR.PATCH semver." + exit 1 + fi + + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=${tag#v}" >> "$GITHUB_OUTPUT" + + release-metadata: + name: Validate release metadata + needs: release-tag + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.release-tag.outputs.tag }} + - name: Match package versions to release tag + env: + RELEASE_VERSION: ${{ needs.release-tag.outputs.version }} + run: | + python - <<'PY' + import os + import tomllib + from pathlib import Path + + expected = os.environ["RELEASE_VERSION"] + with Path("pyproject.toml").open("rb") as file: + root = tomllib.load(file)["project"] + cli_dependency = next( + item for item in root["dependencies"] if item.startswith("sqlclean-cli==") + ).partition("==")[2] + with Path("packages/sqlclean-cli/pyproject.toml").open("rb") as file: + cli_package_version = tomllib.load(file)["project"]["version"] + with Path("Cargo.toml").open("rb") as file: + workspace_version = tomllib.load(file)["workspace"]["package"]["version"] + manifests = [ + ("root project", root["version"]), + ("root CLI dependency", cli_dependency), + ("CLI package", cli_package_version), + ("Cargo workspace", workspace_version), + ] + mismatches = [f"{label}={version}" for label, version in manifests if version != expected] + if mismatches: + raise SystemExit( + f"Release tag version {expected} does not match: {', '.join(mismatches)}" + ) + PY + + wheels: + name: Build wheel (${{ matrix.platform.name }}) + needs: [release-tag, release-metadata] + runs-on: ${{ matrix.platform.runner }} + strategy: + fail-fast: false + matrix: + platform: + - name: linux-x86_64 + runner: ubuntu-latest + target: x86_64 + - name: linux-aarch64 + runner: ubuntu-24.04-arm + target: aarch64 + - name: macos-x86_64 + runner: macos-latest + target: x86_64 + - name: macos-arm64 + runner: macos-latest + target: aarch64 + - name: windows-x86_64 + runner: windows-latest + target: x86_64 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.release-tag.outputs.tag }} + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.95.0 + # sqlclean-cli ships one standalone Rust executable and links no libpython, + # so maturin tags it py3-none-. The tag is asserted before release. + - name: Build native CLI wheel + uses: PyO3/maturin-action@v1 + with: + command: build + args: --release --out ../../dist + target: ${{ matrix.platform.target }} + manylinux: auto + working-directory: packages/sqlclean-cli + - name: Assert native CLI wheel is interpreter-agnostic + shell: bash + run: bash .github/scripts/assert_cli_wheel_tags.sh dist + - uses: actions/upload-artifact@v5 + with: + name: wheel-${{ matrix.platform.name }} + path: dist + + sdist: + name: Build sdists and Python wheel + needs: [release-tag, release-metadata] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.release-tag.outputs.tag }} + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.95.0 + - uses: astral-sh/setup-uv@v6 + # The sqlclean package is pure Python, so one wheel serves every platform. + - name: Build Python package sdist and wheel + run: uv build --sdist --wheel --out-dir dist + - name: Build native CLI sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out ../../dist + working-directory: packages/sqlclean-cli + - uses: actions/upload-artifact@v5 + with: + name: sdist + path: dist + + pypi: + name: Publish to PyPI + needs: + - release-tag + - wheels + - sdist + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.release-tag.outputs.tag }} + - uses: actions/download-artifact@v5 + with: + path: dist + merge-multiple: true + + - name: Assert native CLI wheels are interpreter-agnostic + shell: bash + run: bash .github/scripts/assert_cli_wheel_tags.sh dist + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + skip-existing: true diff --git a/.github/workflows/pull-request-metadata.yml b/.github/workflows/pull-request-metadata.yml new file mode 100644 index 0000000..64889d1 --- /dev/null +++ b/.github/workflows/pull-request-metadata.yml @@ -0,0 +1,98 @@ +name: Pull Request Metadata + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to validate + required: true + type: number + +permissions: + contents: read + pull-requests: read + +jobs: + validate: + name: Pull Request Metadata + runs-on: ubuntu-latest + steps: + - name: Resolve pull request metadata + env: + EVENT_NAME: ${{ github.event_name }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + if [[ "${EVENT_NAME}" == "pull_request" ]]; then + cp "${GITHUB_EVENT_PATH}" "${RUNNER_TEMP}/pull-request.json" + else + gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ + > "${RUNNER_TEMP}/pull-request.json" + fi + + - name: Validate branch, title, and description + env: + PR_METADATA_PATH: ${{ runner.temp }}/pull-request.json + run: | + python - <<'PY' + import json + import os + import re + import sys + + with open(os.environ["PR_METADATA_PATH"], encoding="utf-8") as metadata_file: + payload = json.load(metadata_file) + pull_request = payload.get("pull_request", payload) + branch = pull_request["head"]["ref"] + title = pull_request["title"] + body = pull_request.get("body") or "" + + branch_pattern = re.compile( + r"^(feat|fix|perf|refactor|test|docs|build|ci|chore|revert)/" + r"[a-z0-9]+(?:-[a-z0-9]+)*$" + ) + automation_pattern = re.compile( + r"^(release-please--|dependabot/|renovate/|github-actions/)" + ) + title_pattern = re.compile( + r"^(feat|fix|perf|refactor|test|docs|build|ci|chore|revert)" + r"(?:\([a-z0-9][a-z0-9-]*\))?!?: [^ ].+$" + ) + + errors: list[str] = [] + automated = automation_pattern.match(branch) is not None + if not automated and branch_pattern.fullmatch(branch) is None: + errors.append("Branch must use /.") + if title_pattern.fullmatch(title) is None: + errors.append("PR title must follow Conventional Commits.") + + if not automated: + if len(body) > 2_000: + errors.append("PR description must not exceed 2,000 characters.") + sections = list( + re.finditer(r"^## (Why|Changes|Verification)\s*$", body, re.MULTILINE) + ) + found = [match.group(1) for match in sections] + expected = ["Why", "Changes", "Verification"] + if found != expected: + errors.append( + "PR description must contain Why, Changes, and Verification " + "sections in that order." + ) + else: + for index, match in enumerate(sections): + end = sections[index + 1].start() if index + 1 < len(sections) else len(body) + content = re.sub(r"", "", body[match.end():end], flags=re.DOTALL) + if not content.strip(): + errors.append(f"PR description section {match.group(1)} is empty.") + + if errors: + print(f"Invalid pull request metadata for {branch}:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + sys.exit(1) + + print(f"Pull request metadata is valid for {branch}.") + PY diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..6d6d6dc --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,263 @@ +name: Release Please + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + actions: write + contents: write + pull-requests: write + statuses: write + +concurrency: + group: release-please + cancel-in-progress: false + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - name: Create delivery token + id: delivery-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-actions: write + permission-contents: write + permission-issues: write + permission-pull-requests: write + + - uses: googleapis/release-please-action@v4 + id: release + with: + token: ${{ steps.delivery-token.outputs.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + - name: Publish created release + if: steps.release.outputs.release_created == 'true' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag_name }} + run: | + gh workflow run publish.yml --repo "${GITHUB_REPOSITORY}" --ref main \ + -f "tag=${RELEASE_TAG}" + + - name: Resolve release pull request + id: release_pr + if: steps.release.outputs.prs_created == 'true' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_PRS: ${{ steps.release.outputs.prs }} + run: | + number="$(jq -r '.[0].number // empty' <<< "${RELEASE_PRS}")" + branch="$(jq -r '.[0].headBranchName // empty' <<< "${RELEASE_PRS}")" + if [[ -z "${number}" || -z "${branch}" ]]; then + echo "Release Please did not return a pull request number and branch" >&2 + exit 1 + fi + pr_json="$(gh pr view "${number}" --repo "${GITHUB_REPOSITORY}" \ + --json baseRefName,baseRefOid,headRefName)" + if [[ "$(jq -r .baseRefName <<< "${pr_json}")" != "main" \ + || "$(jq -r .headRefName <<< "${pr_json}")" != "${branch}" ]]; then + echo "Release pull request branches do not match Release Please output" >&2 + exit 1 + fi + head_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${branch}" \ + --jq .object.sha)" + echo "number=${number}" >> "${GITHUB_OUTPUT}" + echo "branch=${branch}" >> "${GITHUB_OUTPUT}" + echo "base_sha=$(jq -r .baseRefOid <<< "${pr_json}")" >> "${GITHUB_OUTPUT}" + echo "head_sha=${head_sha}" >> "${GITHUB_OUTPUT}" + + - uses: actions/checkout@v5 + if: steps.release.outputs.prs_created == 'true' + with: + ref: ${{ steps.release_pr.outputs.base_sha }} + path: trusted-base + persist-credentials: false + + - uses: actions/checkout@v5 + if: steps.release.outputs.prs_created == 'true' + with: + ref: ${{ steps.release_pr.outputs.head_sha }} + path: release-head + fetch-depth: 0 + token: ${{ steps.delivery-token.outputs.token }} + + - name: Validate release metadata and source provenance + if: steps.release.outputs.prs_created == 'true' + working-directory: trusted-base + env: + BASE_SHA: ${{ steps.release_pr.outputs.base_sha }} + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ steps.release_pr.outputs.head_sha }} + run: | + python .github/scripts/release_metadata_check.py \ + --repo ../release-head --base "${BASE_SHA}" --head "${HEAD_SHA}" \ + --allow-stale-lockfiles + + source_pr_json="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${BASE_SHA}/pulls" \ + --jq '[.[] | select(.merged_at != null and .base.ref == "main")] | sort_by(.merged_at) | last')" + source_pr="$(jq -r '.number // empty' <<< "${source_pr_json}")" + source_head="$(jq -r '.head.sha // empty' <<< "${source_pr_json}")" + if [[ -z "${source_pr}" || -z "${source_head}" ]]; then + echo "Release base ${BASE_SHA} is not associated with a pull request merged to main" >&2 + exit 1 + fi + base_tree="$(gh api "repos/${GITHUB_REPOSITORY}/git/commits/${BASE_SHA}" --jq .tree.sha)" + verified_tree="$(gh api "repos/${GITHUB_REPOSITORY}/git/commits/${source_head}" --jq .tree.sha)" + if [[ "${base_tree}" != "${verified_tree}" ]]; then + echo "Release base tree was not verified on source pull request ${source_pr}" >&2 + exit 1 + fi + successful_runs="$(gh api \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/verify.yml/runs?head_sha=${source_head}&event=pull_request&status=completed&per_page=100" \ + | jq --arg source_head "${source_head}" \ + '[.workflow_runs[] | select(.conclusion == "success" and .head_sha == $source_head)] | length')" + if [[ "${successful_runs}" -lt 1 ]]; then + echo "No successful Verify run belongs to source pull request ${source_pr}" >&2 + exit 1 + fi + + - uses: astral-sh/setup-uv@v6 + if: steps.release.outputs.prs_created == 'true' + + - uses: dtolnay/rust-toolchain@master + if: steps.release.outputs.prs_created == 'true' + with: + toolchain: 1.95.0 + + - name: Refresh release lockfiles + if: steps.release.outputs.prs_created == 'true' + working-directory: release-head + env: + RELEASE_BRANCH: ${{ steps.release_pr.outputs.branch }} + run: | + uv lock + cargo check --workspace + if git diff --quiet -- uv.lock Cargo.lock; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add uv.lock Cargo.lock + git commit -m "chore: refresh release lockfiles" + git push origin "HEAD:${RELEASE_BRANCH}" + + - name: Validate refreshed release metadata + if: steps.release.outputs.prs_created == 'true' + working-directory: trusted-base + env: + BASE_SHA: ${{ steps.release_pr.outputs.base_sha }} + run: | + python .github/scripts/release_metadata_check.py \ + --repo ../release-head --base "${BASE_SHA}" --head HEAD + + - name: Run required release checks + if: steps.release.outputs.prs_created == 'true' + working-directory: release-head + env: + GH_TOKEN: ${{ github.token }} + RELEASE_BRANCH: ${{ steps.release_pr.outputs.branch }} + RELEASE_PR: ${{ steps.release_pr.outputs.number }} + run: | + head_sha="$(git rev-parse HEAD)" + remote_head="$(gh api \ + "repos/${GITHUB_REPOSITORY}/git/ref/heads/${RELEASE_BRANCH}" \ + --jq .object.sha)" + if [[ "${remote_head}" != "${head_sha}" ]]; then + echo "Release pull request moved from ${head_sha} to ${remote_head}" >&2 + exit 1 + fi + echo "head_sha=${head_sha}" >> "${GITHUB_OUTPUT}" + workflows=(pull-request-metadata.yml version-guard.yml verify.yml) + metadata_contexts=("Pull Request Metadata") + version_contexts=("Block automatic 1.0 releases") + verify_contexts=("Verify") + required_contexts=( + "${metadata_contexts[@]}" + "${version_contexts[@]}" + "${verify_contexts[@]}" + ) + fail_required_contexts() { + status=$? + set +e + run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + for context in "${required_contexts[@]}"; do + gh api --method POST "repos/${GITHUB_REPOSITORY}/statuses/${head_sha}" \ + -f state=failure -f "context=${context}" -f "target_url=${run_url}" \ + -f description="Trusted release orchestration failed" >/dev/null + done + exit "${status}" + } + trap fail_required_contexts EXIT + + declare -A previous_run_ids + for workflow in "${workflows[@]}"; do + previous_run_ids["${workflow}"]="$(gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \ + -f "branch=${RELEASE_BRANCH}" -f event=workflow_dispatch -f per_page=100 \ + --jq '[.workflow_runs[].id] | max // 0')" + done + for context in "${required_contexts[@]}"; do + gh api --method POST "repos/${GITHUB_REPOSITORY}/statuses/${head_sha}" \ + -f state=pending -f "context=${context}" \ + -f description="Trusted release check is running" >/dev/null + done + + gh workflow run pull-request-metadata.yml --repo "${GITHUB_REPOSITORY}" \ + --ref "${RELEASE_BRANCH}" -f "pr_number=${RELEASE_PR}" + gh workflow run version-guard.yml --repo "${GITHUB_REPOSITORY}" \ + --ref "${RELEASE_BRANCH}" + gh workflow run verify.yml --repo "${GITHUB_REPOSITORY}" \ + --ref "${RELEASE_BRANCH}" + + for workflow in "${workflows[@]}"; do + run_id="" + for _ in {1..30}; do + runs_json="$(gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \ + -f "branch=${RELEASE_BRANCH}" -f event=workflow_dispatch -f per_page=100)" + run_id="$(jq -r --arg sha "${head_sha}" \ + --argjson previous "${previous_run_ids[${workflow}]}" \ + '[.workflow_runs[] | select( + .id > $previous + and .head_sha == $sha + and .actor.login == "github-actions[bot]" + )] | sort_by(.id) | first | .id // empty' <<< "${runs_json}")" + [[ -n "${run_id}" ]] && break + sleep 2 + done + if [[ -z "${run_id}" ]]; then + echo "Required workflow ${workflow} did not start for ${head_sha}" >&2 + exit 1 + fi + run_url="$(gh run view "${run_id}" --repo "${GITHUB_REPOSITORY}" \ + --json url --jq .url)" + if gh run watch "${run_id}" --repo "${GITHUB_REPOSITORY}" --exit-status; then + state=success + description="Trusted release check passed" + else + state=failure + description="Trusted release check failed" + fi + case "${workflow}" in + pull-request-metadata.yml) contexts=("${metadata_contexts[@]}") ;; + version-guard.yml) contexts=("${version_contexts[@]}") ;; + verify.yml) contexts=("${verify_contexts[@]}") ;; + esac + for context in "${contexts[@]}"; do + gh api --method POST "repos/${GITHUB_REPOSITORY}/statuses/${head_sha}" \ + -f "state=${state}" -f "context=${context}" -f "target_url=${run_url}" \ + -f "description=${description}" >/dev/null + done + [[ "${state}" == "success" ]] || exit 1 + done + trap - EXIT diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..6b79a57 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,190 @@ +name: Verify + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: verify-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + changes: + name: Classify changes + runs-on: ubuntu-latest + outputs: + full: ${{ steps.classify.outputs.full }} + skills: ${{ steps.classify.outputs.skills }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: classify + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # Without an exact pull request diff, such as a manual or release dispatch, run everything. + if [[ -n "${BASE_SHA}" && -n "${HEAD_SHA}" ]] \ + && changed="$(git diff --name-only "${BASE_SHA}...${HEAD_SHA}")"; then + printf '%s\n' "${changed}" + else + changed="" + fi + printf '%s\n' "${changed}" | python3 -I .github/scripts/classify_changes.py | tee -a "${GITHUB_OUTPUT}" + + light: + name: Docs skill and lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + - run: python3 -m scripts.check_docs_skill + - run: uvx ruff@0.17.3 format --check . + - run: uvx ruff@0.17.3 check . + + skills: + name: Skills command + needs: [changes] + if: needs.changes.outputs.skills == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.95.0 + - uses: Swatinem/rust-cache@v2 + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - run: uv python install 3.12 + - run: uv sync --locked --all-groups + - run: uv run pytest tests/e2e -q -n auto -k skills + + checks: + name: Checks and tests + needs: [changes] + if: needs.changes.outputs.full == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.95.0 + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - run: uv python install 3.12 + - run: uv sync --locked --all-groups + - run: make verify + + minimum-rust: + name: Minimum supported Rust (1.88) + needs: [changes] + if: needs.changes.outputs.full == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.88.0 + - uses: Swatinem/rust-cache@v2 + - run: cargo +1.88.0 check --workspace --all-targets --locked + + windows: + needs: [changes] + if: needs.changes.outputs.full == 'true' + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.95.0 + - uses: Swatinem/rust-cache@v2 + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - run: uv python install 3.12 + - run: uv sync --locked --all-groups + - run: cargo test --workspace --locked + - run: uv run pytest tests -q -n auto + + native-package-smoke: + needs: [changes] + if: needs.changes.outputs.full == 'true' + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.95.0 + - uses: Swatinem/rust-cache@v2 + - uses: astral-sh/setup-uv@v6 + - run: uv python install 3.12 + - run: uv sync --locked --all-groups + - run: uv run sqlclean --version + - run: uv run pytest tests/e2e -q + + source-build: + needs: [changes] + if: needs.changes.outputs.full == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.95.0 + - uses: Swatinem/rust-cache@v2 + - uses: astral-sh/setup-uv@v6 + - run: uv python install 3.12 + - run: uv build --sdist --out-dir dist + - run: uvx maturin sdist --out ../../dist + working-directory: packages/sqlclean-cli + - run: uv venv .source-venv --python 3.12 + - run: uv pip install --python .source-venv/bin/python --no-deps dist/*.tar.gz + - run: .source-venv/bin/sqlclean --version + - name: The source build embeds the skill + run: | + cd "$(mktemp -d)" + "${GITHUB_WORKSPACE}/.source-venv/bin/sqlclean" skills --target agents + test -s .agents/skills/sqlclean/references/docs/CONTENTS.md + + verify: + # The single check to require: it passes when every suite the change selected passed. + name: Verify + if: always() + needs: [changes, light, skills, checks, minimum-rust, windows, native-package-smoke, source-build] + runs-on: ubuntu-latest + steps: + - env: + CHANGES: ${{ needs.changes.result }} + LIGHT: ${{ needs.light.result }} + FULL: ${{ needs.changes.outputs.full }} + CHECKS: ${{ needs.checks.result }} + SUITES: >- + ${{ needs.skills.result }} ${{ needs.minimum-rust.result }} ${{ needs.windows.result }} + ${{ needs.native-package-smoke.result }} ${{ needs.source-build.result }} + run: | + if [[ "${CHANGES}" != success || "${LIGHT}" != success ]]; then + echo "Change classification or the light checks did not pass" >&2 + exit 1 + fi + if [[ "${FULL}" == true && "${CHECKS}" != success ]]; then + echo "The full suite was selected but did not pass: ${CHECKS}" >&2 + exit 1 + fi + for result in ${CHECKS} ${SUITES}; do + if [[ "${result}" != success && "${result}" != skipped ]]; then + echo "A selected suite did not pass: ${result}" >&2 + exit 1 + fi + done + echo "Every selected suite passed (full=${FULL})" diff --git a/.github/workflows/version-guard.yml b/.github/workflows/version-guard.yml new file mode 100644 index 0000000..fb521f6 --- /dev/null +++ b/.github/workflows/version-guard.yml @@ -0,0 +1,67 @@ +name: Version Guard + +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + block-1-0: + name: Block automatic 1.0 releases + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Reject versions at or above 1.0.0 + run: | + python - <<'PY' + import sys + import tomllib + from pathlib import Path + + with open("pyproject.toml", "rb") as pyproject: + project = tomllib.load(pyproject)["project"] + version = project["version"] + + cli_dependency = next( + item for item in project["dependencies"] if item.startswith("sqlclean-cli==") + ).partition("==")[2] + with Path("packages/sqlclean-cli/pyproject.toml").open("rb") as pyproject: + cli_package_version = tomllib.load(pyproject)["project"]["version"] + with Path("Cargo.toml").open("rb") as cargo: + workspace_version = tomllib.load(cargo)["workspace"]["package"]["version"] + + versions = { + version, + cli_dependency, + cli_package_version, + workspace_version, + } + if len(versions) != 1: + print(f"SQLClean package versions are not locked: {sorted(versions)}", file=sys.stderr) + sys.exit(1) + + parts = version.split(".") + try: + major = int(parts[0]) + except (IndexError, ValueError): + print(f"Invalid project version: {version}", file=sys.stderr) + sys.exit(1) + + if major >= 1: + print( + f"Refusing automatic release version {version}. " + "Crossing to 1.0.0 requires an explicit manual decision.", + file=sys.stderr, + ) + sys.exit(1) + + print(f"Project version {version} is below 1.0.0.") + PY diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml new file mode 100644 index 0000000..a22350f --- /dev/null +++ b/.github/workflows/website.yml @@ -0,0 +1,45 @@ +name: Website + +on: + push: + branches: [main] + paths: + - "website/**" + - ".github/workflows/website.yml" + pull_request: + paths: + - "website/**" + - ".github/workflows/website.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: website-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build the website + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: website + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: website/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Check links + run: python3 scripts/check_links.py dist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1fd7af --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.venv/ +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Rust +target/ +**/*.rs.bk + +# Tool caches +.fensu/cache/ +.sqlclean/ + +# Editors and environments +.env +.envrc +.idea/ +.vscode/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..062da2d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,55 @@ +default_stages: [pre-commit] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: local + hooks: + - id: ruff-format + name: ruff format + entry: uv run ruff format + language: system + types_or: [python, pyi] + + - id: ruff-check + name: ruff check + entry: uv run ruff check --fix + language: system + types_or: [python, pyi] + + - id: ty-check + name: ty check + entry: bash -c 'uv run ty check src tests' + language: system + pass_filenames: false + + - id: fensu-check + name: fensu check + entry: bash -c 'uv run fensu check' + language: system + pass_filenames: false + + - id: cargo-fmt + name: cargo fmt + entry: cargo fmt --check + language: system + types: [rust] + pass_filenames: false + + - id: pytest + name: pytest + entry: bash -c 'uv run pytest tests/unit tests/integration -q -n auto' + language: system + pass_filenames: false + + - id: conventional-commit + name: conventional commit message + entry: uv run cz check --commit-msg-file + language: system + stages: [commit-msg] diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..b284a28 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,23 @@ +- id: sqlclean-check + name: sqlclean check + description: Report SQL lint findings without changing files. + entry: sqlclean check + language: python + types: [sql] + require_serial: true + +- id: sqlclean-fix + name: sqlclean fix + description: Apply safe SQL lint fixes, then format. + entry: sqlclean fix + language: python + types: [sql] + require_serial: true + +- id: sqlclean-format + name: sqlclean format + description: Rewrite SQL files in the canonical layout. + entry: sqlclean format + language: python + types: [sql] + require_serial: true diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..b985ff6 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.1" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..825c32f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1c7ca93 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,844 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "globset" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98b0d85522dc0a824cfebbc9594ff4a9b289c568bea0370319dfbdf20ccdd2d" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ignore" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484be5657551d7868d67587df967fb4fcbe9d266a761cc855aaa148fda0b3cc7" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "polyglot-sql" +version = "0.13.9" +source = "git+https://github.com/sqlstack-io/polyglot.git?rev=8767d688c5b504ad609645e27ab77a260d91c5a1#8767d688c5b504ad609645e27ab77a260d91c5a1" +dependencies = [ + "polyglot-sql-ast-derive", + "polyglot-sql-function-catalogs", + "regex", + "serde", + "serde_json", + "stacker", + "thiserror", + "unicode-segmentation", +] + +[[package]] +name = "polyglot-sql-ast-derive" +version = "0.13.9" +source = "git+https://github.com/sqlstack-io/polyglot.git?rev=8767d688c5b504ad609645e27ab77a260d91c5a1#8767d688c5b504ad609645e27ab77a260d91c5a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "polyglot-sql-function-catalogs" +version = "0.13.9" +source = "git+https://github.com/sqlstack-io/polyglot.git?rev=8767d688c5b504ad609645e27ab77a260d91c5a1#8767d688c5b504ad609645e27ab77a260d91c5a1" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "sqlclean" +version = "0.0.1" +dependencies = [ + "polyglot-sql", + "polyglot-sql-function-catalogs", + "rayon", + "rustc-hash", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "sqlclean-cli" +version = "0.0.1" +dependencies = [ + "clap", + "globset", + "ignore", + "include_dir", + "rayon", + "serde", + "serde_json", + "sha2", + "similar", + "sqlclean", + "tempfile", + "toml", +] + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5da44b2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,60 @@ +[workspace] +members = [ + "crates/sqlclean", + "crates/sqlclean-cli", +] +resolver = "2" + +[workspace.package] +version = "0.0.1" # x-release-please-version +edition = "2024" +license = "Apache-2.0" +publish = false +repository = "https://github.com/sqlstack-io/sqlclean" +# SQLBuild builds these crates as dependencies, so they must not require a newer toolchain than it. +rust-version = "1.88" + +[workspace.dependencies] +clap = { version = "4", features = ["derive"] } +globset = "0.4" +include_dir = "0.7" +ignore = "0.4" +tempfile = "3" +toml = "0.9" +# The same Polyglot source and revision as SQLBuild, so a build that contains both compiles it once. +polyglot-sql = { git = "https://github.com/sqlstack-io/polyglot.git", rev = "8767d688c5b504ad609645e27ab77a260d91c5a1", default-features = false, features = [ + "ast-tools", + "builder", + "all-dialects", + "semantic", + "stacker", +] } +polyglot-sql-function-catalogs = { git = "https://github.com/sqlstack-io/polyglot.git", rev = "8767d688c5b504ad609645e27ab77a260d91c5a1", default-features = false, features = [ + "dialect-duckdb", +] } +rayon = "1" +regex = "1" +rustc-hash = "2" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +sha2 = "0.10" +similar = "2" +sqlclean = { path = "crates/sqlclean" } + +[workspace.lints.rust] +unsafe_code = "forbid" +unreachable_pub = "deny" +unused_must_use = "deny" + +[workspace.lints.clippy] +await_holding_lock = "deny" + +[profile.dev] +debug = "line-tables-only" + +[profile.dev.package."*"] +opt-level = 1 +debug = false + +[profile.release] +strip = "symbols" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + 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/Makefile b/Makefile new file mode 100644 index 0000000..a848e61 --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +SHELL := /bin/bash + +.PHONY: check check-ci check-docs-skill check-rust develop-native skills test test-e2e test-integration test-rust test-unit verify + +check: + uv run ruff format . + uv run ruff check --fix . + uv run ty check src tests + @if command -v cargo >/dev/null; then uv run fensu check; else uv run fensu check --target python; fi + @command -v cargo >/dev/null && $(MAKE) --no-print-directory check-rust || true + +check-ci: + uv run ruff format --check . + uv run ruff check . + uv run ty check src tests + uv run fensu check + $(MAKE) --no-print-directory check-docs-skill + $(MAKE) --no-print-directory check-rust + +check-docs-skill: + uv run python -m scripts.check_docs_skill + +skills: + uv run python -m scripts.generate_docs_skill + +check-rust: + cargo fmt --check + cargo clippy --all-targets --quiet -- -D warnings + +develop-native: + uv sync --all-groups --reinstall-package sqlclean-cli + +test: test-rust + uv run pytest tests -q -n auto + +test-rust: + cargo test --all --quiet + +test-unit: + uv run pytest tests/unit -q -n auto + +test-integration: + uv run pytest tests/integration -q -n auto + +test-e2e: + uv run pytest tests/e2e -q -n auto + +verify: check-ci test diff --git a/README.md b/README.md index d0f6478..80d14da 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,237 @@ -# SQLClean +

+ SQLClean +

-Fast, opinionated SQL linting and deterministic formatting. +

+ Clean SQL, every time. Fast linting, safe fixes and one canonical format. +

-SQLClean checks and formats `.sql` files with one consistent style, and supports custom lint Rules written in Python. It is under development; this repository will hold its source. +SQLClean checks `.sql` files against built-in Rules, applies their fixes and formats every file in +one canonical layout. The engine and the `sqlclean` command are written in Rust. Custom Rules +are plain Python functions that read structural facts about each statement. + +

+ sqlclean check reports three findings, then sqlclean fix applies two fixes and formats the file +

+ +`sqlclean check` reports a bare `JOIN`, a `COUNT(1)` and a temporary table from a custom Rule. +`sqlclean fix` applies the two fixes and formats the file; the custom Rule's finding has no fix, +so it stays. + +## Install + +```bash +uv tool install sqlclean # or: pip install sqlclean +sqlclean --version +``` + +The `sqlclean` package installs the native `sqlclean` executable. Custom Rules run in the same +Python environment as that executable. + +## Commands + +```bash +sqlclean check [PATHS]... # report findings; exit 1 when there are any +sqlclean fix [PATHS]... # apply safe fixes until stable, then format +sqlclean format [PATHS]... # rewrite files in the canonical layout +sqlclean rules [CODE] # list the Rules, or explain one +sqlclean skills # install the skill for AI coding agents +``` + +- With no paths, a command reads every matching file under the configuration root. +- `check` and `fix` accept `--select` and `--ignore`, which take codes or prefixes such as + `SQC001` or `SQC`, and `--no-cache`. +- `check --output-format` takes `text`, `json` or `sarif`. Use SARIF for code-scanning uploads. +- `fix --unsafe-fixes` also applies fixes that can change query results. `fix --diff` and + `format --diff` print a unified diff instead of writing. +- `format --check` fails when a file needs formatting, without writing it. +- Exit status is 0 when clean, 1 when there are findings or files to change, and 2 on errors. + +Each statement is formatted on its own. Statements are separated by one blank line, and every +file ends with a newline. Formatting is idempotent: a formatted file formats to itself. + +## Configuration + +SQLClean reads the nearest `sqlclean.toml` at or above the working directory, or the file passed +with `--config`. Paths in it are relative to the file's directory. + +```toml +dialect = "duckdb" # default "generic" +include = ["**/*.sql"] +exclude = ["vendor/**"] # files ignored by .gitignore are skipped too + +[[dialects]] # a different dialect for some paths +paths = ["warehouse/**"] +dialect = "snowflake" + +[format] +line_width = 100 +indent = 4 + +[rules] +select = ["SQC"] # omit to run the Rules enabled by default +ignore = ["SQC027"] +custom = ["sql_rules"] # Python files or directories that define custom Rules + +[rules.thresholds] +max_literal_length = 100 # SQC044 +max_ranking_order_by = 5 # SQC043 + +[rules.rule_options.XSQCNAME001] +prefixes = ["tmp_", "scratch_"] + +[[rules.rule_exceptions]] # one reviewed finding that is allowed +rule = "SQC038" +path = "models/calendar.sql" +relation = "dates" # optional: the joined relation the CROSS JOIN reads +reason = "Every pair of dates is needed." + +[[rules.rule_ignores]] # Rules that do not apply to some paths +rules = ["SQC034"] +paths = ["scratch/**"] +reason = "Exploratory queries." +``` + +An exception that no longer matches a finding is reported as stale, so exceptions are removed +when the SQL they cover changes. A parse error is reported as `SQC999` and cannot be ignored. + +Results are cached in `.sqlclean/cache/`, keyed by file content, dialect, selection, options and +the custom Rule sources. The directory has its own `.gitignore`. + +## Rules + +Built-in Rules have `SQC###` codes. Run `sqlclean rules` for the full list, or +`sqlclean rules SQC001` for one Rule. A finding's fix is applied by `sqlclean fix`. `SQC001` turns +`= NULL` into `IS NULL`, which changes which rows match, so it needs `--unsafe-fixes`. + +## Custom Rules + +A custom Rule is a function decorated with `@rule`. Its first keyword parameter names its +subject: + +- `statement: Statement` runs once per statement. +- `file: SqlFile` runs once per file. +- `repository: Repository` runs once over every checked file. + +Its second parameter receives a `RuleContext`. Codes are `XSQC`, optional capital letters, and +three digits, such as `XSQCNAME001`. + +```python +from sqlclean import FixSafety, RuleContext, RuleOption, Statement, rule + +PREFIXES = RuleOption.string_list( + name="prefixes", default=("tmp_",), description="Table prefixes that must not be read." +) + + +@rule( + code="XSQCNAME001", + message="Query reads a temporary table", + remediation="Read from the published table instead.", + options=(PREFIXES,), + enabled_by_default=True, +) +def temporary_table(*, statement: Statement, ctx: RuleContext) -> list: + prefixes = ctx.option(PREFIXES) + return [ + ctx.finding(subject=statement, node=table) + for table in statement.tables + if table.name.startswith(prefixes) + ] + + +@rule( + code="XSQCSTYLE001", + message="COUNT(1) should be COUNT(*)", + remediation="Write COUNT(*).", + fixable=FixSafety.SAFE, + enabled_by_default=True, +) +def count_one(*, statement: Statement, ctx: RuleContext) -> list: + counts = {f.end + 1 for f in statement.functions if f.name.upper() == "COUNT"} + return [ + ctx.finding(subject=statement, node=literal, fix=[ctx.replace(node=literal, text="*")]) + for literal in statement.literals + if literal.text == "1" and literal.start in counts + ] +``` + +- A `Statement` exposes `kind` and `text`, plus located `ctes`, `tables`, `joins`, `stars`, + `functions`, `columns` and `literals`. Each node has `start` and `end` character offsets in + the file, `line`, `column` and its `text`. +- `statement.polyglot_ast()` returns the full Polyglot syntax tree. It needs + `pip install "sqlclean[ast]"`. +- Fixes are lists of edits made with `ctx.replace`, `ctx.insert_before`, `ctx.insert_after` and + `ctx.delete`. A fixing Rule declares `fixable` as `FixSafety.SAFE`, or as `FixSafety.UNSAFE` + when the fix can change results. +- Options are declared with `RuleOption.boolean`, `integer`, `string` or `string_list`, set under + `[rules.rule_options.]`, and validated before any Rule runs. +- Files named `test_*.py` and `conftest.py` under custom Rule paths are not loaded as Rules. + +### Testing custom Rules + +```python +from sqlclean.testing import RuleCase, evaluate_rule + +from sql_rules.naming import temporary_table + + +def test_temporary_tables_are_reported() -> None: + result = evaluate_rule( + rule=temporary_table, + test_case=RuleCase( + description="reads tmp_orders", + source="SELECT * FROM tmp_orders", + dialect="duckdb", + expected_finding_count=1, + ), + ) + assert result.fixed_source == "SELECT * FROM tmp_orders" +``` + +`evaluate_rule` parses the SQL with the same native engine as `sqlclean check`. It fails when the +finding count differs, and when the Rule's findings change with the order of `files`. + +## Editors, CI and pre-commit + +Run `sqlclean check` in CI. `--output-format sarif` produces a report for code-scanning tools, +and `--output-format json` produces one for other integrations. Editors can run +`sqlclean format` on save. + +```yaml +# .pre-commit-config.yaml +repos: + - repo: https://github.com/sqlstack-io/sqlclean + rev: v0.0.1 + hooks: + - id: sqlclean-fix + - id: sqlclean-format +``` + +`sqlclean-check` is also available for hooks that must not change files. + +## Using the engine from Rust + +`crates/sqlclean` is the engine without the command. Tools that template or compile SQL can call +it directly and keep their own configuration and Rule codes: + +- `sql_lint::main::lint::lint` lints one SQL text. `sql_lint::main::engine::lint_json` and + `batch_engine::lint_batch_json` do the same through JSON requests. +- `sql_lint::main::formatter::format_json` formats SQL. +- `sql_lint::main::catalogue::catalogue` and `default_rules::default_rules` describe the Rules. +- `sql_facts::main::file_facts::file_facts` returns the statement facts custom Rules read. + +Requests can carry external identifiers and declared relation keys, so a host tool can tell the +Rules about names it resolves itself. + +## Development + +```bash +uv sync --all-groups # builds the native executable into .venv +make check # format, lint, type-check, fensu and clippy +make test # Rust and Python tests +``` + +## License + +Apache-2.0 diff --git a/benchmarks/linters/README.md b/benchmarks/linters/README.md new file mode 100644 index 0000000..e91e37c --- /dev/null +++ b/benchmarks/linters/README.md @@ -0,0 +1,49 @@ +# Linter benchmark + +SQLClean, sqruff and SQLFluff linting the same 10,282 Snowflake SQL files. The corpus is SQLBuild's +DuckShop 10K project with the SQLBuild-specific parts removed, so every tool reads plain SQL. + +## Build the corpus + +The corpus comes from SQLBuild's `benchmarks/duckshop-10k` generator (generator version 2, SQLBuild +commit `def64244c`). Compiling expands its macros and drops its model headers; `convert_duckshop.py` +then replaces SQLBuild's reference functions, such as `__ref("orders")`, with plain names. + +```bash +python sqlbuild/benchmarks/duckshop-10k/generate.py --out /tmp/duckshop-10k +(cd /tmp/duckshop-10k && sqb compile) +python benchmarks/linters/convert_duckshop.py /tmp/duckshop-10k/target/compiled/models /tmp/corpus +``` + +`/tmp/corpus/sql` then holds 10,282 `.sql` files (44 MB) and one configuration per tool, each +selecting the Snowflake dialect and nothing else, so every tool runs its default rules. + +## Run + +```bash +pip install sqruff sqlfluff +benchmarks/linters/bench.sh /tmp/corpus/sql /tmp/linter-bench 3 +python benchmarks/linters/summarize.py /tmp/linter-bench +``` + +Each round copies the corpus into a fresh directory, so no tool starts with a cache, and runs every +variant in alternating order. SQLClean variants run a second time with nothing changed to measure +its result cache; sqruff and SQLFluff have no result cache. `bench.sh` records wall time, CPU time +and peak memory with `/usr/bin/time`, and every lint command writes JSON findings to stdout. + +| Variant | Command | +|---|---| +| `sqlclean` | `sqlclean check --output-format json` (13 default Rules) | +| `sqlclean_all` | `sqlclean check --select SQC --output-format json` (all 45 Rules) | +| `sqlclean_custom` | `sqlclean check --config custom.toml --output-format json` (default Rules and the three Python Rules in `rules/`) | +| `sqlclean_format` | `sqlclean format --check` | +| `sqruff` | `sqruff lint -f json .` | +| `sqlfluff` | `sqlfluff lint -f json --processes $(nproc) --disable-progress-bar .` | + +## Reading the results + +The tools do not check the same things. Almost every sqruff and SQLFluff finding on this corpus +comes from a layout rule (`LT01`–`LT14`), which SQLClean leaves to its formatter. The closest +like-for-like comparison is therefore `sqlclean check` plus `sqlclean format --check` against the +other tools' default `lint`. Counts of findings are reported to show that every tool read the +corpus, not to compare how many problems each finds. diff --git a/benchmarks/linters/bench.sh b/benchmarks/linters/bench.sh new file mode 100755 index 0000000..643b744 --- /dev/null +++ b/benchmarks/linters/bench.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Linter benchmark: SQLClean vs sqruff vs SQLFluff on the plain-SQL DuckShop 10K corpus. +# +# Usage: bench.sh CORPUS OUTDIR ROUNDS [VARIANT...] +# +# CORPUS is OUTDIR/sql from convert_duckshop.py: the SQL files plus a configuration for each linter +# that selects the Snowflake dialect. Each round runs every variant once, in alternating order (odd +# rounds forward, even rounds reversed). For each variant and round: +# 1. copy the pristine corpus into a work directory (no caches) +# 2. cold: lint every file +# 3. warm: lint again with nothing changed (only for variants listed in WARM_VARIANTS) +# +# Every tool writes JSON findings to stdout, so each run also pays for its own reporting. +# +# Environment: +# SQLCLEAN sqlclean executable (default: sqlclean) +# SQRUFF sqruff executable (default: sqruff) +# SQLFLUFF sqlfluff executable (default: sqlfluff) +# PROCESSES SQLFluff worker processes (default: nproc). SQLClean and sqruff pick their own. +# +# Default variants: sqlclean sqlclean_all sqlclean_custom sqlclean_format sqruff sqlfluff. +set -u +CORPUS=$(realpath "$1") +OUT=$(realpath -m "$2") +ROUNDS=$3 +shift 3 +HERE=$(cd "$(dirname "$0")" && pwd) +SQLCLEAN=${SQLCLEAN:-sqlclean} +SQRUFF=${SQRUFF:-sqruff} +SQLFLUFF=${SQLFLUFF:-sqlfluff} +PROCESSES=${PROCESSES:-$(nproc)} +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +declare -A CMD +CMD[sqlclean]="$SQLCLEAN check --output-format json" +CMD[sqlclean_all]="$SQLCLEAN check --select SQC --output-format json" +# The default Rules plus the three Python Rules in rules/, copied into the work directory. +CMD[sqlclean_custom]="$SQLCLEAN check --config custom.toml --output-format json" +# sqruff's and SQLFluff's default rules include layout; SQLClean checks layout with its formatter. +CMD[sqlclean_format]="$SQLCLEAN format --check" +CMD[sqruff]="$SQRUFF lint -f json ." +CMD[sqlfluff]="$SQLFLUFF lint -f json --processes $PROCESSES --disable-progress-bar ." +# SQLClean reuses results for unchanged files; the others have no result cache. +WARM_VARIANTS=" sqlclean sqlclean_all sqlclean_custom " + +VARIANTS=("$@") +if [ ${#VARIANTS[@]} -eq 0 ]; then + VARIANTS=(sqlclean sqlclean_all sqlclean_custom sqlclean_format sqruff sqlfluff) +fi +for v in "${VARIANTS[@]}"; do + if [ -z "${CMD[$v]+set}" ]; then + echo "unknown variant: $v" >&2 + exit 2 + fi +done +mkdir -p "$OUT" +{ + echo "files $(find "$CORPUS" -name '*.sql' | wc -l)" + echo "nproc $(nproc)" + echo "processes $PROCESSES" + "$SQLCLEAN" --version + "$SQRUFF" --version + "$SQLFLUFF" --version +} >"$OUT/environment.txt" 2>&1 + +load1() { cut -d' ' -f1 /proc/loadavg; } + +measure() { + local v=$1 scenario=$2 round=$3 base before after + base="$OUT/$v.$scenario.r$round" + echo "${CMD[$v]}" >"$base.cmd" + before=$(load1) + /usr/bin/time -f '%e %U %S %M' -o "$base.time" \ + bash -c "cd '$WORK/$v' && exec ${CMD[$v]}" >"$base.out" 2>"$base.err" + echo $? >"$base.rc" + after=$(load1) + echo "$v $scenario r$round load $before $after" >>"$OUT/uptime.log" + echo "$v $scenario r$round rc=$(cat "$base.rc") time=$(tail -1 "$base.time") load=$before/$after" | + tee -a "$OUT/summary.log" +} + +for round in $(seq 1 "$ROUNDS"); do + n=${#VARIANTS[@]} + for i in $(seq 0 $((n - 1))); do + if [ $((round % 2)) -eq 0 ]; then v=${VARIANTS[$((n - 1 - i))]}; else v=${VARIANTS[$i]}; fi + rm -rf "${WORK:?}/$v" + cp -a "$CORPUS" "$WORK/$v" + if [ "$v" = sqlclean_custom ]; then + cp -r "$HERE/rules" "$WORK/$v/bench_rules" + printf 'dialect = "snowflake"\n[rules]\ncustom = ["bench_rules"]\n' >"$WORK/$v/custom.toml" + fi + sync + measure "$v" cold "$round" + [[ $WARM_VARIANTS == *" $v "* ]] && measure "$v" warm "$round" + rm -rf "${WORK:?}/$v" + done +done diff --git a/benchmarks/linters/convert_duckshop.py b/benchmarks/linters/convert_duckshop.py new file mode 100644 index 0000000..7fc7839 --- /dev/null +++ b/benchmarks/linters/convert_duckshop.py @@ -0,0 +1,73 @@ +"""Turn SQLBuild's compiled DuckShop 10K models into plain Snowflake SQL files. + +Usage: python convert_duckshop.py COMPILED_MODELS OUTDIR + +COMPILED_MODELS is `target/compiled/models` after `sqb compile` in a project written by SQLBuild's +`benchmarks/duckshop-10k/generate.py`. Compiling has already removed the MODEL headers and expanded +the macros; what is left of SQLBuild is its reference functions, which become ordinary names: + +| Compiled SQL | Plain SQL | +|---|---| +| `__ref("orders")` | `orders` | +| `__source("ledger__journals")` | `raw.ledger__journals` | +| `__seed("source_instances")` | `seeds.source_instances` | +| `__udf("luhn_valid")` | `luhn_valid` | +| `__table_fn("customer_orders")(...)` | `TABLE(customer_orders(...))` | +| `__cursor_start()`, `__cursor_end()` | fixed timestamps | + +OUTDIR/sql/ receives one file per model in the same folders, plus a configuration for each linter +that selects the Snowflake dialect and nothing else, so each tool runs its default Rules. +""" + +import argparse +import re +from pathlib import Path + +REPLACEMENTS: tuple[tuple[re.Pattern[str], str], ...] = ( + (re.compile(r'__table_fn\("([^"]+)"\)\(([^()]*)\)'), r"TABLE(\1(\2))"), + (re.compile(r'__ref\("([^"]+)"\)'), r"\1"), + (re.compile(r'__source\("([^"]+)"\)'), r"raw.\1"), + (re.compile(r'__seed\("([^"]+)"\)'), r"seeds.\1"), + (re.compile(r'__udf\("([^"]+)"\)'), r"\1"), + (re.compile(r"__cursor_start\(\)"), "'2026-01-01 00:00:00'::TIMESTAMP_NTZ"), + (re.compile(r"__cursor_end\(\)"), "'2026-01-02 00:00:00'::TIMESTAMP_NTZ"), +) +LEFTOVER: re.Pattern[str] = re.compile(r"\b__[a-z_]+\(") +CONFIGS: dict[str, str] = { + "sqlclean.toml": 'dialect = "snowflake"\n', + ".sqruff": "[sqruff]\ndialect = snowflake\n", + ".sqlfluff": "[sqlfluff]\ndialect = snowflake\n", +} + + +def plain_sql(text: str) -> str: + """Return text with every SQLBuild reference function replaced by plain SQL.""" + + for pattern, replacement in REPLACEMENTS: + text = pattern.sub(replacement, text) + leftover: re.Match[str] | None = LEFTOVER.search(text) + if leftover is not None: + raise SystemExit(f"unconverted SQLBuild function: {leftover.group(0)}") + return text + + +def main() -> None: + """Convert every compiled model and write the linter configurations.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("compiled", type=Path) + parser.add_argument("out", type=Path) + arguments = parser.parse_args() + sql: Path = arguments.out / "sql" + files: list[Path] = sorted(arguments.compiled.rglob("*.sql")) + for source in files: + target: Path = sql / source.relative_to(arguments.compiled) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(plain_sql(source.read_text(encoding="utf-8")), encoding="utf-8") + for name, text in CONFIGS.items(): + (sql / name).write_text(text, encoding="utf-8") + print(f"wrote {len(files)} SQL files to {sql}") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/linters/rules/layers.py b/benchmarks/linters/rules/layers.py new file mode 100644 index 0000000..f3b25af --- /dev/null +++ b/benchmarks/linters/rules/layers.py @@ -0,0 +1,61 @@ +"""Benchmark custom Rules: layering and determinism checks a data team might write.""" + +from sqlclean import Finding, RuleContext, Statement, rule + +NONDETERMINISTIC_FUNCTIONS: frozenset[str] = frozenset( + {"CURRENT_DATE", "CURRENT_TIMESTAMP", "GETDATE", "NOW", "RANDOM", "SYSDATE", "UUID_STRING"} +) + + +@rule( + code="XSQCLAYER001", + message="Mart reads a raw source", + remediation="Read the staging model instead.", + enabled_by_default=True, +) +def marts_read_staging(*, statement: Statement, ctx: RuleContext) -> list[Finding]: + """Flag every raw table a mart reads.""" + + if not statement.path.startswith("marts/"): + return [] + return [ + ctx.finding(subject=statement, node=table) + for table in statement.tables + if table.qualified_name.startswith("raw.") + ] + + +@rule( + code="XSQCLAYER002", + message="Staging model reads another model", + remediation="Staging models read only raw sources and seeds.", + enabled_by_default=True, +) +def staging_reads_sources(*, statement: Statement, ctx: RuleContext) -> list[Finding]: + """Flag every table a staging model reads that is neither raw, a seed nor one of its CTEs.""" + + if not statement.path.startswith("staging/"): + return [] + ctes: set[str] = {cte.name.lower() for cte in statement.ctes} + return [ + ctx.finding(subject=statement, node=table) + for table in statement.tables + if not table.qualified_name.startswith(("raw.", "seeds.")) + and table.name.lower() not in ctes + ] + + +@rule( + code="XSQCDET001", + message="Model calls a nondeterministic function", + remediation="Pass the time or seed in as a column so reruns give the same rows.", + enabled_by_default=True, +) +def deterministic_functions(*, statement: Statement, ctx: RuleContext) -> list[Finding]: + """Flag calls such as CURRENT_TIMESTAMP that change between runs.""" + + return [ + ctx.finding(subject=statement, node=function) + for function in statement.functions + if function.name.upper() in NONDETERMINISTIC_FUNCTIONS + ] diff --git a/benchmarks/linters/summarize.py b/benchmarks/linters/summarize.py new file mode 100644 index 0000000..f58f32c --- /dev/null +++ b/benchmarks/linters/summarize.py @@ -0,0 +1,95 @@ +"""Summarize bench.sh output: median wall, CPU, peak memory and findings per variant and scenario. + +Usage: python summarize.py OUTDIR +""" + +import json +import re +import statistics +import sys +from collections import defaultdict +from pathlib import Path + +SCENARIOS: tuple[str, ...] = ("cold", "warm") +VARIANT_ORDER: tuple[str, ...] = ( + "sqlclean", + "sqlclean_all", + "sqlclean_custom", + "sqlclean_format", + "sqruff", + "sqlfluff", +) +LABELS: dict[str, str] = { + "sqlclean": "SQLClean, default Rules", + "sqlclean_all": "SQLClean, every built-in Rule", + "sqlclean_custom": "SQLClean, default Rules and three Python Rules", + "sqlclean_format": "SQLClean formatter, `format --check`", + "sqruff": "sqruff, default rules", + "sqlfluff": "SQLFluff, default rules", +} +TIME_RE: re.Pattern[str] = re.compile( + r"^(?P\w+)\.(?P\w+)\.r(?P\d+)\.time$" +) + + +def count_findings(variant: str, out: str) -> tuple[int, int]: + """Return (findings, parse errors) from a tool's JSON output.""" + + if variant == "sqlclean_format": + return out.count("Would reformat: "), 0 + data = json.loads(out) + if variant.startswith("sqlclean"): + codes = [finding["code"] for finding in data["findings"]] + return len(codes), codes.count("SQC999") + if variant == "sqruff": + codes = [finding.get("code") for findings in data.values() for finding in findings] + return len(codes), sum(1 for code in codes if code in (None, "PRS")) + codes = [violation["code"] for file in data for violation in file["violations"]] + return len(codes), sum(1 for code in codes if code in ("PRS", "TMP")) + + +def main() -> None: + """Print one Markdown table row per variant and scenario.""" + + out = Path(sys.argv[1]) + runs: dict[tuple[str, str], list[tuple[float, float, int, int]]] = defaultdict(list) + findings: dict[tuple[str, str], set[tuple[int, int]]] = defaultdict(set) + for path in sorted(out.glob("*.time")): + match = TIME_RE.match(path.name) + if match is None: + continue + stem = str(path)[: -len(".time")] + wall, user, system, rss = path.read_text().strip().splitlines()[-1].split() + rc = int(Path(stem + ".rc").read_text()) + key = (match["variant"], match["scenario"]) + runs[key].append((float(wall), float(user) + float(system), int(rss), rc)) + findings[key].add(count_findings(match["variant"], Path(stem + ".out").read_text())) + + print( + "| tool | scenario | runs | wall s (median) | wall s (all) | user+sys s (median) " + "| peak RSS MiB (median) | exit codes | findings | parse errors |" + ) + print("|---|---|---|---|---|---|---|---|---|---|") + found = {variant for variant, _ in runs} + variants = [v for v in VARIANT_ORDER if v in found] + sorted(found - set(VARIANT_ORDER)) + for variant in variants: + for scenario in SCENARIOS: + cell = runs.get((variant, scenario)) + if not cell: + continue + walls = [run[0] for run in cell] + counts = sorted(findings[(variant, scenario)]) + print( + f"| {LABELS.get(variant, variant)} | {scenario} | {len(cell)} " + f"| {statistics.median(walls):.2f} " + f"| {', '.join(f'{wall:.2f}' for wall in walls)} " + f"| {statistics.median(run[1] for run in cell):.2f} " + f"| {statistics.median(run[2] for run in cell) / 1024:.0f} " + f"| {','.join(sorted({str(run[3]) for run in cell}))} " + f"| {', '.join(str(count[0]) for count in counts)} " + f"| {', '.join(str(count[1]) for count in counts)} |" + ) + + +if __name__ == "__main__": + main() diff --git a/crates/sqlclean-cli/Cargo.toml b/crates/sqlclean-cli/Cargo.toml new file mode 100644 index 0000000..0709861 --- /dev/null +++ b/crates/sqlclean-cli/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "sqlclean-cli" +version.workspace = true +description = "The sqlclean command: check, fix and format SQL files." +edition.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true + +[[bin]] +name = "sqlclean" +path = "src/main.rs" + +[dependencies] +clap.workspace = true +globset.workspace = true +ignore.workspace = true +include_dir.workspace = true +rayon.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +similar.workspace = true +sqlclean.workspace = true +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/sqlclean-cli/build.rs b/crates/sqlclean-cli/build.rs new file mode 100644 index 0000000..032c5bd --- /dev/null +++ b/crates/sqlclean-cli/build.rs @@ -0,0 +1,5 @@ +//! Rebuild the executable when the bundled skill changes. + +fn main() { + println!("cargo:rerun-if-changed=skill"); +} diff --git a/crates/sqlclean-cli/skill/SKILL.md b/crates/sqlclean-cli/skill/SKILL.md new file mode 100644 index 0000000..69ee72e --- /dev/null +++ b/crates/sqlclean-cli/skill/SKILL.md @@ -0,0 +1,61 @@ +--- +name: sqlclean +description: ALWAYS load this skill when linting, fixing or formatting SQL with SQLClean, editing sqlclean.toml, writing or testing custom Rules (Python @rule functions with XSQC codes), recording Rule exceptions, or reading SQLClean findings (SQC codes, SQC999 parse errors) in CI, JSON or SARIF output. +--- + + +# SQLClean + +SQLClean is a fast SQL linter and formatter. `sqlclean check` reports Rule findings, +`sqlclean fix` applies their fixes and formats, and `sqlclean format` rewrites files in one +canonical layout. Custom Rules are plain Python functions over structural facts that the Rust +engine extracts from each statement. + +## The working loop + +1. **Orient.** Read the nearest `sqlclean.toml` (dialect, include and exclude, `[format]`, + `[rules]`). Paths in it are relative to its directory. +2. **Check.** `sqlclean check [PATHS]` exits 0 when clean, 1 with findings, 2 on errors. Use + `--output-format json` when you need to read findings programmatically. +3. **Explain.** `sqlclean rules SQC001` shows a Rule's message, fix safety and remediation. +4. **Fix.** `sqlclean fix --diff` previews safe fixes plus formatting; `sqlclean fix` writes them. + Fixes marked unsafe can change query results, such as `= NULL` becoming `IS NULL`. Never pass + `--unsafe-fixes` without the user's say-so. +5. **Format.** `sqlclean format --check` fails when a file needs formatting. Formatting is + idempotent, so rerunning it changes nothing. +6. **Recheck** until `sqlclean check` exits 0. + +## Rules of thumb + +- Fix the SQL rather than silencing a finding. When a finding is genuinely intended, record a + `[[rules.rule_exceptions]]` entry with the Rule, path and a `reason`. Use + `[[rules.rule_ignores]]` only for whole paths a Rule does not apply to. +- An exception that no longer matches a finding is reported as stale; remove it. +- `SQC999` is a parse error. It cannot be ignored: fix the SQL or set the right `dialect`, + globally or per path under `[[dialects]]`. +- `--select` and `--ignore` take codes or prefixes such as `SQC001` or `SQC`. +- Results are cached in `.sqlclean/cache/`. Use `--no-cache` only when debugging a custom Rule + that imports helpers from outside its `[rules] custom` paths. + +## Custom Rules + +- A custom Rule is a function decorated with `@rule` from `sqlclean`, in a file under a path + listed in `[rules] custom`. Its code is `XSQC`, optional capital letters, then three digits. +- The first keyword parameter names the subject: `statement: Statement`, `file: SqlFile` or + `repository: Repository`. The second is `ctx: RuleContext`. +- Return `ctx.finding(...)` values. Fixes are lists of `ctx.replace`, `ctx.insert_before`, + `ctx.insert_after` and `ctx.delete` edits, and the Rule declares `fixable`. +- Never print to standard output inside a Rule: the host speaks JSON over it. +- Test Rules with `sqlclean.testing.evaluate_rule` and `RuleCase`. +- `sqlclean facts --dialect duckdb < query.sql` prints the facts a Rule sees for a statement. + +## Exact syntax + +The bundled documentation in `references/docs/` matches the installed SQLClean version. Start +from `references/docs/CONTENTS.md` and open only the page you need: + +- Configuration keys: `references/docs/configuration.md` +- Every built-in Rule: `references/docs/rules/reference.md` +- Writing custom Rules: `references/docs/custom-rules/overview.md` and + `references/docs/custom-rules/statement-facts.md` +- Command flags: `references/docs/cli/` diff --git a/crates/sqlclean-cli/skill/references/docs/CONTENTS.md b/crates/sqlclean-cli/skill/references/docs/CONTENTS.md new file mode 100644 index 0000000..ea0132e --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/CONTENTS.md @@ -0,0 +1,39 @@ + + +# SQLClean documentation index + +Bundled copies of every page on the SQLClean documentation site, matching the installed SQLClean version. Open only the page you need. + +## Getting Started + +- [Introduction](index.md) (`index`) - Clean SQL, every time. Fast linting, safe fixes and one canonical format. +- [Quickstart](quickstart.md) (`quickstart`) - Install SQLClean, then check, fix and format your first SQL file. + +## Concepts + +- [Configuration](configuration.md) (`configuration`) - Every sqlclean.toml key, its default, per-path dialects and how the file is found. +- [Overview](rules/overview.md) (`rules/overview`) - Select Rules, record reviewed exceptions, and apply safe and unsafe fixes. +- [Rule reference](rules/reference.md) (`rules/reference`) - Every built-in Rule, whether it runs by default, and whether it has a fix. +- [Formatting](formatting.md) (`formatting`) - One canonical layout for every SQL file, the line width and indent, and files that can't be formatted. + +## Custom Rules + +- [Overview](custom-rules/overview.md) (`custom-rules/overview`) - Write your own Rules as Python functions over the facts of each SQL statement. +- [Statement facts](custom-rules/statement-facts.md) (`custom-rules/statement-facts`) - Every node type and field a custom Rule can read from a statement, a file and the repository. +- [Fixes and options](custom-rules/fixes-and-options.md) (`custom-rules/fixes-and-options`) - Give custom Rules safe or unsafe fixes, and typed options configured in sqlclean.toml. +- [Testing](custom-rules/testing.md) (`custom-rules/testing`) - Test custom Rules with the sqlclean.testing harness, and keep them deterministic. + +## Integrations + +- [Integrations](integrations.md) (`integrations`) - Run SQLClean in CI with JSON or SARIF output, in pre-commit, and from editors. +- [Embedding the engine](embedding.md) (`embedding`) - Lint, format and read statement facts from Rust, in a tool that owns its own configuration. +- [Benchmarks](benchmarks.md) (`benchmarks`) - SQLClean, sqruff and SQLFluff linting the same 10,282 Snowflake SQL files. + +## CLI Reference + +- [sqlclean check](cli/check.md) (`cli/check`) - Report Rule findings without changing any file. +- [sqlclean fix](cli/fix.md) (`cli/fix`) - Apply Rule fixes until the files stop changing, then format them. +- [sqlclean format](cli/format.md) (`cli/format`) - Rewrite SQL files in the canonical layout, or check that they already are. +- [sqlclean rules](cli/rules.md) (`cli/rules`) - List the built-in and custom Rules, or show one Rule's details. +- [sqlclean facts](cli/facts.md) (`cli/facts`) - Print the statement facts custom Rules read for SQL on standard input. +- [sqlclean skills](cli/skills.md) (`cli/skills`) - Install the SQLClean skill so AI coding agents know its commands, Rules and configuration. diff --git a/crates/sqlclean-cli/skill/references/docs/benchmarks.md b/crates/sqlclean-cli/skill/references/docs/benchmarks.md new file mode 100644 index 0000000..3b48db7 --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/benchmarks.md @@ -0,0 +1,80 @@ + + +# Benchmarks + +> SQLClean, sqruff and SQLFluff linting the same 10,282 Snowflake SQL files. + +Online: https://sqlclean.io/docs/benchmarks/ + +SQLClean 0.0.1, sqruff 0.44.0 and SQLFluff 4.4.0 linting the same [10,282 SQL files](#the-corpus) on +a Linux machine with 4 CPUs. Each tool runs its default rules on the Snowflake dialect, writes JSON +findings, and starts with no cache. SQLClean and sqruff cells are the median of three rounds; +SQLFluff ran once. + +| Command | Wall time | CPU time | Peak memory | vs SQLClean defaults | +|---|---:|---:|---:|---:| +| `sqlclean check` (13 default Rules) | **0.81 s** | 2.5 s | 48 MiB | 1× | +| `sqlclean check --select SQC` (all 45 Rules) | **1.33 s** | 3.8 s | 140 MiB | 1.6× | +| `sqlclean check` with three [custom Rules](#custom-rules) | **2.27 s** | 6.9 s | 182 MiB | 2.8× | +| `sqlclean format --check` | **3.03 s** | 11.5 s | 49 MiB | 3.7× | +| `sqruff lint` | 41.0 s | 114 s | 235 MiB | 51× | +| `sqlfluff lint --processes 4` | 919 s | 2,796 s | 992 MiB | 1,135× | + +Run again with nothing changed, `sqlclean check` reads its result cache and finishes in 0.23 s. +sqruff and SQLFluff have no result cache, so a second run costs the same as the first. + +## Custom Rules + +The custom variant adds three Python Rules from `benchmarks/linters/rules/` to the default Rules: +marts must not read raw sources, staging models read only sources and seeds, and no model calls a +nondeterministic function such as `CURRENT_TIMESTAMP`. They report 3,355 findings on top of the +default Rules' 3,872. + +SQLClean builds each statement's facts in Rust and evaluates statement and file Rules in one Python +process per CPU, so custom Rules use every core. A Rule over the whole repository runs in one +process, because it reads every file. Run again with nothing changed, the custom variant reads its +result cache and finishes in 0.46 s. + +## Comparing like with like + +The tools don't check the same things. Almost every sqruff and SQLFluff finding on this corpus +comes from a layout rule, such as indentation (`LT02`) or line length (`LT05`). SQLClean leaves +layout to its [formatter](formatting.md), so the fair comparison is `sqlclean check` plus +`sqlclean format --check`: 3.8 s together, against 41 s for sqruff and 919 s for SQLFluff. + +The finding counts show that each tool read every file, not how many problems each one finds: + +| Tool | Findings | Parse errors | +|---|---:|---:| +| SQLClean, default Rules | 3,872 | 0 | +| SQLClean, all Rules | 34,322 | 0 | +| sqruff | 258,378 | 0 | +| SQLFluff | 266,367 | 0 | + +## The corpus + +The files come from DuckShop 10K, the generated project SQLBuild benchmarks its compiler with. It +describes a fictional home goods business: storefronts, marketplaces, carriers, ledgers, ad networks +and support tools, with every name invented. Compiling the project expands its macros, and +`benchmarks/linters/convert_duckshop.py` then replaces SQLBuild's reference functions with plain +table names, so every linter reads ordinary Snowflake SQL. + +| What | Count | +|---|---:| +| SQL files | 10,282 | +| Staging, intermediate and mart models | 6,149, 1,961 and 2,162 | +| Size | 44 MB | + +## Run it yourself + +The harness lives in `benchmarks/linters/`. Its README has the steps to build the corpus; +then: + +```bash +pip install sqruff sqlfluff +benchmarks/linters/bench.sh /tmp/corpus/sql /tmp/linter-bench 3 +python benchmarks/linters/summarize.py /tmp/linter-bench +``` + +Each round copies the corpus into a fresh directory and runs every tool in alternating order. +`/usr/bin/time` records wall time, CPU time and peak memory. diff --git a/crates/sqlclean-cli/skill/references/docs/cli/check.md b/crates/sqlclean-cli/skill/references/docs/cli/check.md new file mode 100644 index 0000000..8e0ff69 --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/cli/check.md @@ -0,0 +1,170 @@ + + +# sqlclean check + +> Report Rule findings without changing any file. + +Online: https://sqlclean.io/docs/cli/check/ + +## Contents + +- Usage +- Flags +- Text output +- JSON output +- SARIF output +- Exit codes + +`sqlclean check` runs the selected Rules over SQL files and reports every finding. It never changes +a file. + +`sqlclean lint` is the same command, for anyone used to SQLFluff or sqruff. + +## Usage + +```bash +sqlclean check [OPTIONS] [PATHS]... +``` + +```bash +sqlclean check # every matching file under the configuration root +sqlclean check models/orders.sql reports/ # only these files and directories +sqlclean check --select SQC001,SQC004 # only these Rules +sqlclean check --output-format sarif > sqlclean.sarif +``` + +With no paths, `check` reads every file under the configuration root that matches `include` and +not `exclude`. A directory is walked the same way. A file named on the command line is read even +when `include` does not match it, unless `exclude` does. See +[Choosing files](../configuration.md#choosing-files). + +## Flags + +| Flag | Description | +|------|-------------| +| `PATHS...` | Files or directories to read; the configuration root when omitted | +| `--select ` | Rule codes or prefixes to run instead of `[rules] select` | +| `--ignore ` | Rule codes or prefixes to skip, in addition to `[rules] ignore` | +| `--no-cache` | Evaluate every file instead of reusing results for unchanged inputs | +| `--output-format ` | `text` (default), `json` or `sarif` | +| `--config ` | Use this configuration file instead of the nearest `sqlclean.toml` | + +`--select` and `--ignore` take a comma-separated list, or the flag repeated. A value is an exact +code such as `SQC001` or a prefix such as `SQC` or `XSQCNAME`. A value that matches no Rule is an +error. See [Selecting Rules](../rules/overview.md#selecting-rules). + +## Text output + +```text +error[SQC004]: Row selection is nondeterministic + --> models/customers.sql:6:1 + | + 6 | LIMIT 10; + | ^^^^^ + = help: Add ORDER BY with a deterministic tie-breaker before LIMIT or OFFSET. + +error[SQC001]: Comparison with NULL is never true + --> models/orders.sql:8:22 + | + 8 | WHERE o.cancelled_at = NULL + | ^ + = help: Use IS NULL or IS NOT NULL when testing for NULL. + = fix: available with `sqlclean fix --unsafe-fixes` + +error: stale rule exception: SQC004 in models/orders.sql matches no finding; remove it from sqlclean.toml + +Found 3 problems (1 fixable with `sqlclean fix --unsafe-fixes`). +``` + +Findings are sorted by path, then position. Each one shows the Rule code and message, the +location as `path:line:column`, the source line, the remediation, and the command that applies its +fix when it has one. [Stale exceptions](../rules/overview.md#stale-exceptions) follow the +findings. A clean run prints `All checks passed.` + +Paths are relative to the configuration root, with `/` separators. Lines and columns start at 1 +and count characters, not bytes. + +## JSON output + +`--output-format json` prints one document: + +```json +{ + "findings": [ + { + "code": "SQC004", + "path": "models/customers.sql", + "line": 6, + "column": 1, + "end_line": 6, + "end_column": 6, + "message": "Row selection is nondeterministic", + "remediation": "Add ORDER BY with a deterministic tie-breaker before LIMIT or OFFSET.", + "fix": null + }, + { + "code": "SQC001", + "path": "models/orders.sql", + "line": 8, + "column": 22, + "end_line": 8, + "end_column": 23, + "message": "Comparison with NULL is never true", + "remediation": "Use IS NULL or IS NOT NULL when testing for NULL.", + "fix": "unsafe" + } + ], + "stale_exceptions": [ + { + "rule": "SQC004", + "path": "models/orders.sql", + "relation": null + } + ], + "summary": { + "findings": 2, + "fixable": 1, + "stale_exceptions": 1 + } +} +``` + +`fix` is `"safe"`, `"unsafe"` or `null` when the finding has no fix. `summary.fixable` counts +findings with a fix of either kind. + +## SARIF output + +`--output-format sarif` prints a SARIF 2.1.0 log for code-scanning tools. Every finding is a result +with `level` `error`, its Rule code as `ruleId`, and its message and remediation as the result +text: + +```json +{ + "ruleId": "SQC001", + "level": "error", + "message": { + "text": "Comparison with NULL is never true. Use IS NULL or IS NOT NULL when testing for NULL." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { "uri": "models/orders.sql" }, + "region": { "startLine": 8, "startColumn": 22, "endLine": 8, "endColumn": 23 } + } + } + ] +} +``` + +The SARIF log lists findings only. Stale exceptions still make the command exit `1`; read them +from the text or JSON output. See [CI](../integrations.md#ci) for an upload example. + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | No findings and no stale exceptions | +| `1` | At least one finding or stale exception | +| `2` | The command could not run: invalid arguments, configuration or input, or a custom Rule that failed | + +Errors are printed on stderr as `sqlclean: error: `. diff --git a/crates/sqlclean-cli/skill/references/docs/cli/facts.md b/crates/sqlclean-cli/skill/references/docs/cli/facts.md new file mode 100644 index 0000000..1016d5f --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/cli/facts.md @@ -0,0 +1,78 @@ + + +# sqlclean facts + +> Print the statement facts custom Rules read for SQL on standard input. + +Online: https://sqlclean.io/docs/cli/facts/ + +`sqlclean facts` parses SQL from standard input and prints the structural facts of each statement +as one line of JSON. These are the facts a [custom Rule](../custom-rules/overview.md) receives, +so use it to see what a Rule will read before writing one. + +## Usage + +```bash +sqlclean facts [--dialect ] < query.sql +``` + +```bash +echo "SELECT o.order_id, COUNT(*) AS items FROM orders AS o JOIN order_items AS i ON i.order_id = o.order_id WHERE o.status = 'paid' GROUP BY o.order_id;" \ + | sqlclean facts --dialect duckdb +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--dialect ` | The dialect to parse with (default: `generic`); see [Dialects](../configuration.md#dialects) | + +`facts` reads no configuration file and no paths. + +## Output + +The output, pretty-printed here and trimmed to some of the columns: + +```json +{ + "statements": [ + { + "index": 0, + "kind": "select", + "start": 0, + "end": 146, + "terminated_end": 147, + "ctes": [], + "tables": [ + { "start": 42, "end": 48, "name": "orders", "qualified_name": "orders", "alias": "o" }, + { "start": 59, "end": 70, "name": "order_items", "qualified_name": "order_items", "alias": "i" } + ], + "joins": [ + { "start": 54, "end": 102, "kind": "INNER", "relation": "order_items", "has_condition": true } + ], + "stars": [], + "functions": [ + { "start": 19, "end": 24, "name": "COUNT" } + ], + "columns": [ + { "start": 7, "end": 17, "name": "order_id", "qualifier": "o" }, + { "start": 109, "end": 117, "name": "status", "qualifier": "o" } + ], + "literals": [ + { "start": 120, "end": 126, "kind": "string", "text": "'paid'" } + ] + } + ] +} +``` + +Offsets count characters from the start of the input, not bytes. Custom Rules receive the same +facts with `line`, `column` and `text` added to every node. See +[Statement facts](../custom-rules/statement-facts.md) for every field. + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | The facts were printed | +| `2` | The SQL does not parse under the dialect, or the dialect is unknown | diff --git a/crates/sqlclean-cli/skill/references/docs/cli/fix.md b/crates/sqlclean-cli/skill/references/docs/cli/fix.md new file mode 100644 index 0000000..9e1e033 --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/cli/fix.md @@ -0,0 +1,99 @@ + + +# sqlclean fix + +> Apply Rule fixes until the files stop changing, then format them. + +Online: https://sqlclean.io/docs/cli/fix/ + +`sqlclean fix` applies the fixes of the selected Rules, checks again, and repeats until no fix +applies. It then formats every file it read and reports the findings that remain. + +## Usage + +```bash +sqlclean fix [OPTIONS] [PATHS]... +``` + +```bash +sqlclean fix # apply safe fixes, then format +sqlclean fix --diff # print the changes without writing them +sqlclean fix --unsafe-fixes # also apply fixes that can change query results +sqlclean fix models/orders.sql # only this file +``` + +Files are chosen as for [`sqlclean check`](check.md). + +## Flags + +| Flag | Description | +|------|-------------| +| `PATHS...` | Files or directories to read; the configuration root when omitted | +| `--unsafe-fixes` | Also apply fixes their Rules mark unsafe because they can change results | +| `--diff` | Print the changes as a unified diff instead of writing them | +| `--select ` | Rule codes or prefixes to run instead of `[rules] select` | +| `--ignore ` | Rule codes or prefixes to skip, in addition to `[rules] ignore` | +| `--no-cache` | Evaluate every file instead of reusing results for unchanged inputs | +| `--config ` | Use this configuration file instead of the nearest `sqlclean.toml` | + +Only fixes of selected Rules apply. `sqlclean fix --select SQC005` removes unused CTEs and +formats the files, and leaves every other finding alone. + +## Output + +```text +Fixed 1 problem(s) and changed 1 file(s). +error[SQC001]: Comparison with NULL is never true + --> models/open_orders.sql:7:24 + | + 7 | WHERE cancelled_at = NULL + | ^ + = help: Use IS NULL or IS NOT NULL when testing for NULL. + = fix: available with `sqlclean fix --unsafe-fixes` + +Found 1 problem (1 fixable with `sqlclean fix --unsafe-fixes`). +``` + +The summary line goes to stderr. The remaining findings go to stdout in the same format as +[`sqlclean check`](check.md#text-output), with positions in the fixed file. + +With `--diff`, nothing is written. `fix` prints a unified diff of every file it would change, +followed by `Would fix N problem(s) and change N file(s).`, and does not list the remaining +findings: + +```diff +--- a/models/orders.sql ++++ b/models/orders.sql +@@ -5,7 +5,7 @@ + FROM orders o + JOIN customers c + ON o.customer_id = c.customer_id +-WHERE o.cancelled_at = NULL ++WHERE o.cancelled_at IS NULL + GROUP BY + o.order_id, + c.customer_name +``` + +## How fixes are applied + +- Each pass checks the files, then applies every eligible fix whose edits do not overlap an earlier + fix in the same file. A skipped fix is reconsidered on the next pass. +- A pass that would turn a file that parses into one that does not is not applied to that file. +- `fix` stops when a pass applies nothing, and after at most 10 passes. +- Safe fixes always apply. Unsafe fixes apply only with `--unsafe-fixes`. See + [Safe and unsafe fixes](../rules/overview.md#safe-and-unsafe-fixes). +- Fixes from [custom Rules](../custom-rules/fixes-and-options.md) apply the same way. +- Every file is formatted after the last pass, whether or not a fix changed it. A file the + formatter cannot format is reported as `: could not format: ` and keeps its fixes. + +A file that does not parse gets no fixes and is reported as +[`SQC999`](../rules/overview.md#parse-errors-sqc999). + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Every file is fixed and formatted, and no finding or stale exception remains | +| `1` | Findings or stale exceptions remain, a file could not be formatted, or `--diff` found changes to make | +| `2` | The command could not run: invalid arguments, configuration or input, or a custom Rule that failed | diff --git a/crates/sqlclean-cli/skill/references/docs/cli/format.md b/crates/sqlclean-cli/skill/references/docs/cli/format.md new file mode 100644 index 0000000..6060937 --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/cli/format.md @@ -0,0 +1,77 @@ + + +# sqlclean format + +> Rewrite SQL files in the canonical layout, or check that they already are. + +Online: https://sqlclean.io/docs/cli/format/ + +`sqlclean format` rewrites SQL files in SQLClean's canonical layout. It runs no Rules and applies +no fixes; use [`sqlclean fix`](fix.md) for both. + +## Usage + +```bash +sqlclean format [OPTIONS] [PATHS]... +``` + +```bash +sqlclean format # rewrite every matching file +sqlclean format --check # fail when a file needs formatting +sqlclean format --diff reports/ # print the changes for one directory +``` + +Files are chosen as for [`sqlclean check`](check.md). Each file is formatted with its own +[dialect](../configuration.md#dialects) and the configured +[`line_width`](../formatting.md#line-width) and [`indent`](../formatting.md#indent). + +## Flags + +| Flag | Description | +|------|-------------| +| `PATHS...` | Files or directories to format; the configuration root when omitted | +| `--check` | Fail when a file needs formatting, without writing it | +| `--diff` | Print the changes as a unified diff instead of writing them | +| `--config ` | Use this configuration file instead of the nearest `sqlclean.toml` | + +## Output + +```bash +sqlclean format --check +``` + +```text +j.sql: could not format: Parse error at line 1, column 16: Expected Colon, got RBrace ('}') near [{{ col }} from orders] +Would reformat: models_a.sql +1 file(s) would be reformatted, 2 file(s) already formatted. +``` + +`--check` names each file that would change. `--diff` prints a unified diff instead: + +```diff +--- a/models_a.sql ++++ b/models_a.sql +@@ -1 +1,5 @@ +-select order_id, amount from orders where amount > 0 ++SELECT ++ order_id, ++ amount ++FROM orders ++WHERE amount > 0 +``` + +Without either flag, files are rewritten in place and the summary reads +`N file(s) reformatted, N file(s) already formatted.` The summary and the `could not format` +lines go to stderr; the `Would reformat` lines and diffs go to stdout. + +A file the formatter cannot format, usually because it does not parse under its dialect, is left +unchanged and reported with the reason. See +[When a file can't be formatted](../formatting.md#when-a-file-cant-be-formatted). + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Every file is formatted, or was rewritten | +| `1` | A file could not be formatted, or `--check` or `--diff` found a file to change | +| `2` | The command could not run: invalid arguments, configuration or input | diff --git a/crates/sqlclean-cli/skill/references/docs/cli/rules.md b/crates/sqlclean-cli/skill/references/docs/cli/rules.md new file mode 100644 index 0000000..2b80af8 --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/cli/rules.md @@ -0,0 +1,122 @@ + + +# sqlclean rules + +> List the built-in and custom Rules, or show one Rule's details. + +Online: https://sqlclean.io/docs/cli/rules/ + +## Contents + +- Usage +- Flags +- Listing Rules +- Showing one Rule +- JSON output +- Exit codes + +`sqlclean rules` lists every Rule the configuration can select: the built-in Rules, then the custom +Rules under `[rules] custom`. With a code, it shows that Rule. + +## Usage + +```bash +sqlclean rules [OPTIONS] [CODE] +``` + +```bash +sqlclean rules # list every Rule +sqlclean rules SQC001 # show one Rule +sqlclean rules --json # every Rule as JSON +sqlclean rules --json SQC001 # one Rule as JSON +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `CODE` | Show this Rule's details; an exact code, not a prefix | +| `--json` | Print Rules as a JSON array | +| `--config ` | Use this configuration file instead of the nearest `sqlclean.toml` | + +## Listing Rules + +```text +SQC001 default Comparison with NULL is never true +SQC002 default Comma-separated sources create an implicit cartesian join +SQC003 default Non-cross join has no meaningful condition +SQC004 default Row selection is nondeterministic +... +SQC045 JSON payload is parsed more than once in one SELECT +XSQCNAME001 default Query reads a temporary table +XSQCSTYLE001 default COUNT(1) should be COUNT(*) +``` + +`default` marks the Rules that run when `[rules] select` is not set. See +[Default Rules](../rules/overview.md#default-rules) and the +[Rule reference](../rules/reference.md). + +## Showing one Rule + +```bash +sqlclean rules SQC001 +``` + +```text +SQC001: Comparison with NULL is never true +Subject: statement +Enabled by default: yes +Fixable: unsafe +Remediation: Use IS NULL or IS NOT NULL when testing for NULL. +``` + +A custom Rule also shows the Python module that defines it: + +```text +XSQCSTYLE001: COUNT(1) should be COUNT(*) +Subject: statement +Enabled by default: yes +Defined in: sql_rules.naming +Fixable: safe +Remediation: Write COUNT(*). +``` + +`Fixable` is left out when the Rule has no fix. + +## JSON output + +```bash +sqlclean rules --json XSQCNAME001 +``` + +```json +[ + { + "code": "XSQCNAME001", + "message": "Query reads a temporary table", + "remediation": "Read from the published table instead.", + "enabled_by_default": true, + "custom": true, + "subject": "statement", + "module": "sql_rules.naming" + } +] +``` + +| Field | Description | +|-------|-------------| +| `code` | The Rule code | +| `message` | The finding message | +| `remediation` | What to do about a finding | +| `enabled_by_default` | Whether the Rule runs when `[rules] select` is not set | +| `custom` | `true` for custom Rules | +| `subject` | `statement`, `file` or `repository` | +| `fixable` | `safe` or `unsafe`; absent when the Rule has no fix | +| `module` | The Python module of a custom Rule; absent for built-in Rules | + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | The Rules were printed | +| `2` | Unknown Rule code, or the configuration or a custom Rule module could not be loaded | diff --git a/crates/sqlclean-cli/skill/references/docs/cli/skills.md b/crates/sqlclean-cli/skill/references/docs/cli/skills.md new file mode 100644 index 0000000..336137f --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/cli/skills.md @@ -0,0 +1,61 @@ + + +# sqlclean skills + +> Install the SQLClean skill so AI coding agents know its commands, Rules and configuration. + +Online: https://sqlclean.io/docs/cli/skills/ + +`sqlclean skills` writes the SQLClean skill into your project, so AI coding agents such as Claude +Code, OpenCode and Cursor know how to check, fix and format SQL, configure `sqlclean.toml` and +write custom Rules. The skill ships inside the `sqlclean` package with a copy of every page of +these docs, so it always matches the installed version. + +## Usage + +```bash +sqlclean skills [--target ]... [--global] [--force] +``` + +```bash +# Install the agents and claude targets (default) +sqlclean skills + +# Install for Claude Code only +sqlclean skills --target claude + +# Install under your home directory for every project +sqlclean skills --global +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--target ` | The agent to install for: `agents`, `claude` or `opencode`. Repeat it for several. | +| `--global` | Install under the home directory instead of the configuration root | +| `--force` | Overwrite skill files that SQLClean did not generate | + +## Targets + +| Target | Project path | Global path | +|--------|--------------|-------------| +| `agents` | `.agents/skills/sqlclean/` | `~/.agents/skills/sqlclean/` | +| `claude` | `.claude/skills/sqlclean/` | `~/.claude/skills/sqlclean/` | +| `opencode` | `.opencode/skills/sqlclean/` | `~/.config/opencode/skills/sqlclean/` | + +Without `--target`, SQLClean installs `agents` and `claude`. OpenCode and several other agents +read the shared `.agents` directory; Claude Code needs its own `.claude` copy. + +The project path is relative to the directory of the nearest `sqlclean.toml`, or the working +directory when there is none. + +## Updating + +Every file SQLClean writes carries a `generated-by: sqlclean skills` comment. Rerun +`sqlclean skills` after upgrading SQLClean: + +- Generated files are rewritten with the new version's content. +- Generated pages the new version no longer ships are removed. +- A file without the comment, such as one you wrote yourself, is never overwritten. The command + stops with exit status 2 before writing anything, unless you pass `--force`. diff --git a/crates/sqlclean-cli/skill/references/docs/configuration.md b/crates/sqlclean-cli/skill/references/docs/configuration.md new file mode 100644 index 0000000..5a26208 --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/configuration.md @@ -0,0 +1,188 @@ + + +# Configuration + +> Every sqlclean.toml key, its default, per-path dialects and how the file is found. + +Online: https://sqlclean.io/docs/configuration/ + +## Contents + +- Finding the file +- Keys +- Choosing files +- Dialects +- Validation + +SQLClean reads one `sqlclean.toml`. Every key is optional, and without a file every default +applies. + +```toml +dialect = "duckdb" # default "generic" +include = ["**/*.sql"] +exclude = ["vendor/**"] + +[[dialects]] # a different dialect for some paths +paths = ["warehouse/**"] +dialect = "snowflake" + +[format] +line_width = 100 +indent = 4 + +[rules] +select = ["SQC"] # omit to run the Rules enabled by default +ignore = ["SQC027"] +custom = ["sql_rules"] # Python files or directories that define custom Rules + +[rules.thresholds] +max_literal_length = 100 # SQC044 +max_ranking_order_by = 5 # SQC043 + +[rules.rule_options.XSQCNAME001] +prefixes = ["tmp_", "scratch_"] + +[[rules.rule_exceptions]] # one reviewed finding that is allowed +rule = "SQC038" +path = "models/calendar.sql" +relation = "dates" +reason = "Every pair of dates is needed." + +[[rules.rule_ignores]] # Rules that do not apply to some paths +rules = ["SQC034"] +paths = ["scratch/**"] +reason = "Exploratory queries." +``` + +## Finding the file + +Every command uses the nearest `sqlclean.toml` in the working directory or one of its parents. +`--config ` uses that file instead. + +The directory that holds the file is the **configuration root**. Every path in the file, every path +in findings, and the default set of files to read are relative to it. Without a file, the working +directory is the root. + +## Keys + +| Key | Default | Description | +|-----|---------|-------------| +| `dialect` | `"generic"` | The SQL dialect of every file without a `[[dialects]]` match | +| `include` | `["**/*.sql"]` | Glob patterns of the files to read | +| `exclude` | `[]` | Glob patterns of files never to read | +| `[[dialects]]` | none | `paths` and `dialect`: a different dialect for matching files | +| `[format] line_width` | `100` | The line width the formatter fills; see [Line width](formatting.md#line-width) | +| `[format] indent` | `4` | Spaces per indent step, from `1` to `8`; see [Indent](formatting.md#indent) | +| `[rules] select` | Rules enabled by default | Rule codes or prefixes to run | +| `[rules] ignore` | `[]` | Rule codes or prefixes to skip | +| `[rules] custom` | `[]` | Python files or directories that define [custom Rules](custom-rules/overview.md) | +| `[rules.thresholds] max_literal_length` | `100` | The longest string literal `SQC044` allows | +| `[rules.thresholds] max_ranking_order_by` | `5` | The most `ORDER BY` expressions `SQC043` allows in a ranking window | +| `[rules.rule_options.]` | none | Option values of one custom Rule; see [Options](custom-rules/fixes-and-options.md#options) | +| `[[rules.rule_exceptions]]` | none | One allowed finding; see [Exceptions](rules/overview.md#exceptions) | +| `[[rules.rule_ignores]]` | none | Rules that do not apply to some paths; see [Path ignores](rules/overview.md#path-ignores) | + +Selection, exceptions and ignores are described in [Rules](rules/overview.md). + +## Choosing files + +With no paths on the command line, a command reads every file under the configuration root whose +root-relative path matches `include` and no `exclude` pattern. + +- In patterns, `*` matches within one directory and `**` matches across directories: + `models/*.sql` reads `models/orders.sql` but not `models/marts/orders.sql`. +- In a Git repository, files that `.gitignore` ignores are skipped. +- Hidden files and directories, whose names start with `.`, are skipped. +- A directory on the command line is walked the same way. +- A file named on the command line is read even when `include` does not match it. `exclude` still + applies. + +## Dialects + +`dialect` sets how every file is parsed and formatted. `[[dialects]]` entries set a different +dialect for the files their `paths` match. The first matching entry wins; files that match none use +`dialect`. + +```toml +dialect = "duckdb" + +[[dialects]] +paths = ["reports/**"] +dialect = "tsql" +``` + +A file that does not parse under its dialect is reported as +[`SQC999`](rules/overview.md#parse-errors-sqc999), with the dialect in the message. Without +the `[[dialects]]` entry above, this T-SQL file under `reports/` is parsed as `duckdb`: + +```sql +SELECT [order id] FROM orders +``` + +```text +error[SQC999]: SQL could not be parsed as duckdb: Parse error at line 1, column 17: Expected RBracket, got Var ('id') near [SELECT [order id] FROM orders] +``` + +Supported dialects, case-insensitive: + +| Dialect | Also accepted | +|---------|---------------| +| `generic` | | +| `athena` | | +| `bigquery` | | +| `clickhouse` | | +| `cockroachdb` | `cockroach` | +| `databricks` | | +| `datafusion` | `arrow-datafusion`, `arrow_datafusion` | +| `doris` | | +| `dremio` | | +| `drill` | | +| `druid` | | +| `duckdb` | | +| `dune` | | +| `exasol` | | +| `fabric` | | +| `hana` | `saphana`, `sap_hana` | +| `hive` | | +| `materialize` | | +| `mysql` | | +| `oracle` | | +| `postgres` | `postgresql` | +| `presto` | | +| `redshift` | | +| `risingwave` | | +| `singlestore` | `memsql` | +| `snowflake` | | +| `solr` | | +| `spark` | `spark2` | +| `sqlite` | | +| `starrocks` | | +| `tableau` | | +| `teradata` | | +| `tidb` | | +| `trino` | | +| `tsql` | `mssql`, `sqlserver` | +| `vertica` | | + +## Validation + +SQLClean reads and validates the file when a command starts, and exits with `2` on the first +problem it finds. `check` and `fix` also validate Rule selectors and `rule_options` before they +report any finding. + +| Problem | Error | +|---------|-------| +| A key SQLClean does not know | ``unknown field `colour`, expected one of `dialect`, `include`, ...`` | +| An unknown dialect | `sqlclean.toml: unknown SQL dialect 'nosuch': ...` | +| An invalid glob pattern | `sqlclean.toml: invalid path pattern '[bad': ...` | +| `line_width` of `0` | `sqlclean.toml: [format] line_width must be positive` | +| `indent` of `0` or more than `8` | `sqlclean.toml: [format] indent must be between 1 and 8` | +| A threshold of `0` | `sqlclean.toml: [rules.thresholds] max_literal_length and max_ranking_order_by must be positive` | +| An exception or ignore without a reason | `sqlclean.toml: rule exception or ignore for SQC001 needs a reason` | +| An exception with `cte` but no `relation` | `sqlclean.toml: rule exception for SQC038 at models/calendar.sql sets cte without relation` | +| A `custom` path that does not exist | `[rules] custom path does not exist: sql_rules` | +| `rule_options` for a built-in Rule | `[rules.rule_options.SQC001]: built-in Rules take no options; set limits in [rules.thresholds]` | +| `rule_options` for a code no custom Rule has | `[rules.rule_options.XSQCNOPE001]: no custom Rule has this code` | +| A selector that matches no Rule | `Rule selector 'SQC9' matches no Rule` | + +Errors are printed as `sqlclean: error: `. diff --git a/crates/sqlclean-cli/skill/references/docs/custom-rules/fixes-and-options.md b/crates/sqlclean-cli/skill/references/docs/custom-rules/fixes-and-options.md new file mode 100644 index 0000000..e3f6c1f --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/custom-rules/fixes-and-options.md @@ -0,0 +1,160 @@ + + +# Fixes and options + +> Give custom Rules safe or unsafe fixes, and typed options configured in sqlclean.toml. + +Online: https://sqlclean.io/docs/custom-rules/fixes-and-options/ + +## Contents + +- Fixes +- Options + +A custom Rule can attach a fix to each finding, which [`sqlclean fix`](../cli/fix.md) applies, +and can declare typed options that a project sets in `sqlclean.toml`. + +## Fixes + +A fix is a list of edits to the file. Declare `fixable` on the Rule, then pass the edits to +`ctx.finding`: + +```python +from sqlclean import FixSafety, Finding, RuleContext, Statement, rule + +@rule( + code="XSQCSTYLE001", + message="COUNT(1) should be COUNT(*)", + remediation="Write COUNT(*).", + fixable=FixSafety.SAFE, + enabled_by_default=True, +) +def count_one(*, statement: Statement, ctx: RuleContext) -> list[Finding]: + counts = {f.end + 1 for f in statement.functions if f.name.upper() == "COUNT"} + return [ + ctx.finding(subject=statement, node=literal, fix=[ctx.replace(node=literal, text="*")]) + for literal in statement.literals + if literal.text == "1" and literal.start in counts + ] +``` + +A function call is located at its name, so `f.end + 1` is the first character inside its +parentheses. + +```bash +sqlclean fix --diff --select XSQCSTYLE +``` + +```diff +--- a/models/orders.sql ++++ b/models/orders.sql +@@ -1,3 +1,6 @@ +-SELECT customer_id, COUNT(1) AS orders ++SELECT ++ customer_id, ++ COUNT(*) AS orders + FROM customer_orders +-GROUP BY customer_id ++GROUP BY ++ customer_id +Would fix 1 problem(s) and change 1 file(s). +``` + +### Making edits + +| Method | Edit | +|--------|------| +| `ctx.replace(node=..., text=...)` | Replace the node's text | +| `ctx.insert_before(node=..., text=...)` | Insert text just before the node | +| `ctx.insert_after(node=..., text=...)` | Insert text just after the node | +| `ctx.delete(node=...)` | Remove the node's text | + +`node` is any node or statement, or anything with `start` and `end` character offsets in the file. +An `Edit(start=..., end=..., text=...)` from `sqlclean` builds an edit from offsets directly. Every +edit must lie inside the finding's file. + +### Safe and unsafe + +`fixable=FixSafety.SAFE` declares fixes that keep the query's results; `sqlclean fix` applies them. +`fixable=FixSafety.UNSAFE` declares fixes that can change results; they apply only with +`--unsafe-fixes`. The safety belongs to the Rule, so every fix of one Rule has the same safety. +See [Safe and unsafe fixes](../rules/overview.md#safe-and-unsafe-fixes). + +A finding without `fix` has no fix, even in a fixable Rule. A Rule that returns a fix without +declaring `fixable` fails with `Rule XSQCSTYLE001 returned a fix but does not declare fixable`. + +### How fixes combine + +`sqlclean fix` applies custom fixes exactly as it applies built-in ones: + +- A fix whose edits overlap another fix's edits waits for the next pass, where the Rule runs again + on the changed file. +- A pass that would leave a file that no longer parses is not applied to that file. +- After at most 10 passes, the files are formatted, so a fix need not produce formatted SQL. + +Keep each edit as small as the change: replace the literal, not the statement. + +## Options + +An option is a typed value a project sets per Rule. Declare it with `RuleOption`, list it in +`options`, and read it with `ctx.option`: + +```python +from sqlclean import Finding, RuleContext, RuleOption, Statement, rule + +PREFIXES = RuleOption.string_list( + name="prefixes", default=("tmp_",), description="Table prefixes that must not be read." +) + +@rule( + code="XSQCNAME001", + message="Query reads a temporary table", + remediation="Read from the published table instead.", + options=(PREFIXES,), + enabled_by_default=True, +) +def temporary_table(*, statement: Statement, ctx: RuleContext) -> list[Finding]: + prefixes = ctx.option(PREFIXES) + return [ + ctx.finding(subject=statement, node=table) + for table in statement.tables + if table.name.startswith(prefixes) + ] +``` + +```toml +[rules.rule_options.XSQCNAME001] +prefixes = ["tmp_", "scratch_"] +``` + +`ctx.option` returns the configured value, or the default when `sqlclean.toml` does not set it. +List options arrive as tuples. + +| Constructor | TOML value | Extra arguments | +|-------------|------------|-----------------| +| `RuleOption.boolean(name=, default=, description=)` | `true` or `false` | | +| `RuleOption.integer(name=, default=, description=)` | An integer | `minimum`, `maximum` | +| `RuleOption.string(name=, default=, description=)` | A string | `choices` | +| `RuleOption.string_list(name=, default=, description=)` | A list of strings | | +| `RuleOption.integer_list(name=, default=, description=)` | A list of integers | | + +`name` must be a Python identifier, and the default must be valid for the option. + +### Validation + +Configured values are checked against the declarations, and a bad value stops the command with +exit code `2`: + +| Problem | Error | +|---------|-------| +| A value of the wrong type | `[rules.rule_options.XSQCNAME001] option prefixes expects a list, got 'tmp_'` | +| An integer out of bounds | `[rules.rule_options.XSQCFILE002] option max_statements must be at least 1` | +| A string outside `choices` | `[rules.rule_options.XSQCFILE002] option style must be one of ['strict', 'loose']` | +| A name the Rule does not declare | `Rule XSQCNAME001 has no option prefix` | +| A code no custom Rule has | `[rules.rule_options.XSQCNOPE001]: no custom Rule has this code` | +| A Rule that reads an option it did not declare | `Rule XSQCFILE002 reads undeclared option verbose` | + +Built-in Rules take no options. Their two limits are set in +[`[rules.thresholds]`](../rules/overview.md#thresholds). + +Changing an option evaluates that Rule again on every file; cached results for other Rules stay. diff --git a/crates/sqlclean-cli/skill/references/docs/custom-rules/overview.md b/crates/sqlclean-cli/skill/references/docs/custom-rules/overview.md new file mode 100644 index 0000000..7793d4e --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/custom-rules/overview.md @@ -0,0 +1,194 @@ + + +# Overview + +> Write your own Rules as Python functions over the facts of each SQL statement. + +Online: https://sqlclean.io/docs/custom-rules/overview/ + +## Contents + +- Where Rules live +- Declaring a Rule +- Choosing a subject +- Reporting findings +- How Rules run +- Python environment +- Errors +- Continue + +A custom Rule is a Python function decorated with `@rule`. It receives a statement, a file or the +whole repository, and returns findings. Custom Rules use the same selection, exceptions, fixes, +output and cache as the built-in Rules. + +```python +# sql_rules/naming.py +from sqlclean import Finding, RuleContext, Statement, rule + +@rule( + code="XSQCNAME001", + message="Query reads a temporary table", + remediation="Read from the published table instead.", + enabled_by_default=True, +) +def temporary_table(*, statement: Statement, ctx: RuleContext) -> list[Finding]: + return [ + ctx.finding(subject=statement, node=table) + for table in statement.tables + if table.name.startswith("tmp_") + ] +``` + +Point `[rules] custom` at the file or its directory: + +```toml +[rules] +custom = ["sql_rules"] +``` + +```text +error[XSQCNAME001]: Query reads a temporary table + --> models/orders.sql:2:6 + | + 2 | FROM tmp_orders + | ^^^^^^^^^^ + = help: Read from the published table instead. +``` + +## Where Rules live + +`[rules] custom` lists Python files or directories, relative to the configuration root. SQLClean +imports every `.py` file under them and registers each function decorated with `@rule`. Other +functions, constants and classes stay ordinary Python. + +- Files named `test_*.py`, `conftest.py` and `__init__.py` are not loaded as Rules, so tests can + live next to the Rules they test. +- Modules are imported by their path from the configuration root: `sql_rules/naming.py` is + `sql_rules.naming`. A Rule module can import a helper the same way, for example + `from sql_rules.helpers import table_prefixes`. +- Two Rules with the same code are an error: `custom Rule code XSQCNAME001 is defined twice`. + +## Declaring a Rule + +| `@rule` argument | Default | Description | +|------------------|---------|-------------| +| `code` | required | `XSQC`, optional capital letters, and three digits, such as `XSQCNAME001` | +| `message` | required | The finding message | +| `remediation` | required | What to do about a finding | +| `enabled_by_default` | `False` | Run the Rule when `[rules] select` is not set | +| `fixable` | `None` | `FixSafety.SAFE` or `FixSafety.UNSAFE` when findings carry fixes | +| `options` | `()` | Typed `RuleOption`s configured in `sqlclean.toml` | +| `slug` | the code in lower case | A short name for the Rule | + +A Rule that is not enabled by default runs when `select` names it, by code or by a prefix such as +`XSQC` or `XSQCNAME`. See [Selecting Rules](../rules/overview.md#selecting-rules). Fixes and +options are covered in [Fixes and options](fixes-and-options.md). + +## Choosing a subject + +The function takes exactly two keyword-only parameters: one subject and one `RuleContext`. Their +type annotations decide how the Rule is called; their names and order do not matter. + +| Subject | Called | Use it for | +|---------|--------|------------| +| `statement: Statement` | Once per statement | Checks of one query, such as the tables it reads | +| `file: SqlFile` | Once per file | Checks of a file as a whole, such as how many statements it holds | +| `repository: Repository` | Once over every checked file | Conventions across files | + +```python +from collections import Counter + +from sqlclean import Finding, Repository, RuleContext, SqlFile, rule + +@rule( + code="XSQCFILE001", + message="File holds more than one statement", + remediation="Split the file so each holds one statement.", +) +def one_statement(*, file: SqlFile, ctx: RuleContext) -> list[Finding]: + return [ctx.finding(subject=file)] if len(file.statements) > 1 else [] + +@rule( + code="XSQCREPO001", + message="Two files define a CTE with the same name", + remediation="Give each CTE a name that is unique across files.", +) +def duplicate_cte_names(*, repository: Repository, ctx: RuleContext) -> list[Finding]: + statements = [s for file in repository.files for s in file.statements] + counts = Counter(cte.name for s in statements for cte in s.ctes) + return [ + ctx.finding(subject=s, node=cte) + for s in statements + for cte in s.ctes + if counts[cte.name] > 1 + ] +``` + +A Repository Rule is evaluated again whenever any checked file changes, so prefer a statement or +file subject when the check needs only one file. + +[Statement facts](statement-facts.md) lists everything a subject exposes. + +## Reporting findings + +A Rule returns a list of findings made with `ctx.finding`: + +| Argument | Description | +|----------|-------------| +| `subject` | The `Statement` or `SqlFile` the finding is in | +| `node` | Where to report it: any node or statement; the start of `subject` when omitted | +| `message` | Replaces the Rule's message for this finding | +| `remediation` | Replaces the Rule's remediation for this finding | +| `fix` | A list of edits; see [Fixes](fixes-and-options.md#fixes) | + +A Repository Rule passes the statement or file each finding belongs to as `subject`. + +## How Rules run + +- Custom Rules run after the built-in Rules, on files that parse. A file with a + [parse error](../rules/overview.md#parse-errors-sqc999) gets no custom Rule findings. +- From 500 files, Statement and File Rules split the files across one Python process per CPU. A + Rule can't share state between files, so anything that spans files belongs in a Repository Rule, + which always runs in one process with every file. +- The working directory is the configuration root, and `PYTHONHASHSEED` is `0`, so iterating a set + of strings gives the same order on every run. +- Results are cached with the built-in results. Editing any Python file under `[rules] custom` + evaluates the custom Rules again. See [Caching](../rules/overview.md#caching). +- Don't print to standard output from a Rule. The command reads Rule results from it and fails + with `custom Rules host returned no result`. + +## Python environment + +Custom Rules run in a Python interpreter that has the `sqlclean` package installed. SQLClean uses, +in order: + +1. the interpreter named by the `SQLCLEAN_PYTHON` environment variable; +2. the Python installed next to the `sqlclean` executable, such as the one in the same virtual + environment; +3. `python3` on the `PATH`. + +When none has the package, the command fails with: + +```text +Custom Rules need the sqlclean Python package in the environment that runs sqlclean: install it with `pip install sqlclean`, or set SQLCLEAN_PYTHON to an interpreter that has it. +``` + +## Errors + +A Rule that cannot load or run stops the command with exit code `2`: + +| Problem | Error | +|---------|-------| +| An invalid code | `custom Rule code must be XSQC, optional capital letters and 3 digits, such as XSQCNAME001: XSQC01` | +| A wrong signature | `custom Rule XSQCBAD001 must accept exactly one typed Statement, SqlFile or Repository subject and one typed RuleContext, both keyword-only` | +| A module that fails to import | `could not import custom Rule module sql_rules.bad: ` | +| An exception in the Rule | `custom Rule XSQCBAD001 failed on models/a.sql: IndexError: tuple index out of range`, then the traceback | +| A return value that is not a list of findings | `custom Rule XSQCRET001 must return a list of findings from ctx.finding(...)` | +| A fix from a Rule without `fixable` | `Rule XSQCBAD001 returned a fix but does not declare fixable` | + +## Continue + + Every node type and field a Rule can read. + Add safe or unsafe fixes, and typed options set in `sqlclean.toml`. + Test Rules with the `sqlclean.testing` harness. + See the facts of any SQL from the command line. diff --git a/crates/sqlclean-cli/skill/references/docs/custom-rules/statement-facts.md b/crates/sqlclean-cli/skill/references/docs/custom-rules/statement-facts.md new file mode 100644 index 0000000..49828ae --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/custom-rules/statement-facts.md @@ -0,0 +1,186 @@ + + +# Statement facts + +> Every node type and field a custom Rule can read from a statement, a file and the repository. + +Online: https://sqlclean.io/docs/custom-rules/statement-facts/ + +## Contents + +- Positions +- Statement +- Cte +- Table +- Join +- Star +- FunctionCall +- ColumnReference +- Literal +- SqlFile +- Repository +- RuleContext + +A custom Rule reads structural facts that the native engine extracts when it parses each file: the +CTEs, tables, joins, stars, function calls, columns and literals of every statement. Every node +carries its position, so a finding can point at it and a fix can replace it. + +```python +from sqlclean import Finding, RuleContext, Statement, rule + +@rule( + code="XSQCJOIN001", + message="Join to customers has no condition", + remediation="Join customers on customer_id.", +) +def unkeyed_customer_join(*, statement: Statement, ctx: RuleContext) -> list[Finding]: + return [ + ctx.finding(subject=statement, node=join) + for join in statement.joins + if join.relation == "customers" and not join.has_condition + ] +``` + +To see the facts of any SQL, pipe it to [`sqlclean facts`](../cli/facts.md). + +## Positions + +Every node, and every statement, has these fields: + +| Field | Type | Description | +|-------|------|-------------| +| `start` | `int` | Offset of the first character in the file | +| `end` | `int` | Offset just past the last character | +| `line` | `int` | Line of `start`, from 1 | +| `column` | `int` | Column of `start`, from 1 | +| `text` | `str` | The source text from `start` to `end` | + +Offsets count Unicode characters from the start of the file, not bytes, so +`file.source[node.start:node.end] == node.text` in Python. Offsets are in the file, not the +statement: the second statement of a file starts after the first. + +## Statement + +One statement of a file, without its terminating semicolon. + +| Field | Type | Description | +|-------|------|-------------| +| `path` | `str` | The file's path relative to the configuration root | +| `index` | `int` | The statement's position in the file, from 0 | +| `kind` | `str` | The statement kind in lower case, such as `select`, `union`, `insert`, `update`, `delete` or `create_table` | +| `dialect` | `str` | The dialect the file was parsed with | +| `ctes` | `tuple[Cte, ...]` | Common table expressions | +| `tables` | `tuple[Table, ...]` | Relations read in `FROM` and `JOIN` | +| `joins` | `tuple[Join, ...]` | Joins | +| `stars` | `tuple[Star, ...]` | `*` and `qualifier.*` projections | +| `functions` | `tuple[FunctionCall, ...]` | Calls of named functions | +| `columns` | `tuple[ColumnReference, ...]` | Column references | +| `literals` | `tuple[Literal, ...]` | String and numeric literals | + +Each tuple is in source order and includes nodes inside CTEs and subqueries. + +`statement.polyglot_ast()` returns the full Polyglot syntax tree of the statement, parsed on first +use. It needs the `ast` extra: `pip install "sqlclean[ast]"`. Without it, the call fails with +`polyglot_ast() needs the Polyglot SQL package: install the ast extra: pip install 'sqlclean[ast]'`. + +## Cte + +A common table expression, located at its name. + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `str` | The CTE name | + +## Table + +A relation read in `FROM` or `JOIN`, located at its name. A CTE that the query reads appears here +too. The target of `INSERT INTO`, `UPDATE` and `DELETE FROM` does not. + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `str` | The relation name without its schema, such as `payments` | +| `qualified_name` | `str` | The name as qualified in the SQL, such as `shop.payments` | +| `alias` | `str \| None` | The alias, or `None` | + +## Join + +One join, from its join keyword to the end of its condition, such as +`LEFT JOIN paid ON o.order_id = paid.order_id`. + +| Field | Type | Description | +|-------|------|-------------| +| `kind` | `str` | The join kind in upper case: `INNER` for a bare `JOIN`, `LEFT`, `FULL`, `CROSS`, `NATURAL` and so on | +| `relation` | `str \| None` | The joined relation's name, or `None` when the join reads a subquery | +| `has_condition` | `bool` | Whether the join has `ON` or `USING` | + +A join that holds no table or column reference, such as `CROSS JOIN UNNEST([1, 2])`, is not +listed. + +## Star + +A `*` or `qualifier.*` projection. + +| Field | Type | Description | +|-------|------|-------------| +| `qualifier` | `str \| None` | The qualifier of `o.*`, or `None` for a bare `*` | + +## FunctionCall + +A call of a named function, located at its name. The arguments follow at `end + 1`, after the +opening parenthesis. + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `str` | The name as written: `count` and `COUNT` differ | + +## ColumnReference + +A column reference, including its qualifier: the text of `o.order_id` is `o.order_id`. + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `str` | The column name, without quotes | +| `qualifier` | `str \| None` | The relation or alias that qualifies it, or `None` | + +## Literal + +A string or numeric literal as written. `TRUE`, `FALSE` and `NULL` are not literals here. + +| Field | Type | Description | +|-------|------|-------------| +| `kind` | `str` | `string` or `number` | + +`text` holds the literal with its quotes: `'paid'`, `1.5`. + +## SqlFile + +The subject of a file Rule, and the parent of every statement. + +| Field | Type | Description | +|-------|------|-------------| +| `path` | `str` | The path relative to the configuration root | +| `source` | `str` | The file's full text | +| `dialect` | `str` | The dialect the file was parsed with | +| `statements` | `tuple[Statement, ...]` | The statements, in order | + +## Repository + +The subject of a repository Rule. + +| Field | Type | Description | +|-------|------|-------------| +| `files` | `tuple[SqlFile, ...]` | Every checked file that parses, sorted by path | + +## RuleContext + +The second parameter of every Rule. + +| Member | Description | +|--------|-------------| +| `ctx.file` | The `SqlFile` being checked, or `None` in a repository Rule | +| `ctx.dialect` | The dialect of that file, or `None` in a repository Rule | +| `ctx.option(OPTION)` | The configured value of a declared option; see [Options](fixes-and-options.md#options) | +| `ctx.finding(...)` | A finding; see [Reporting findings](overview.md#reporting-findings) | +| `ctx.replace`, `ctx.insert_before`, `ctx.insert_after`, `ctx.delete` | Fix edits; see [Fixes](fixes-and-options.md#fixes) | + +All facts are read-only. A Rule cannot change the SQL except through the edits of a fix. diff --git a/crates/sqlclean-cli/skill/references/docs/custom-rules/testing.md b/crates/sqlclean-cli/skill/references/docs/custom-rules/testing.md new file mode 100644 index 0000000..2c10112 --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/custom-rules/testing.md @@ -0,0 +1,183 @@ + + +# Testing + +> Test custom Rules with the sqlclean.testing harness, and keep them deterministic. + +Online: https://sqlclean.io/docs/custom-rules/testing/ + +## Contents + +- Running the tests +- RuleCase +- RuleResult +- Failures +- Determinism + +`sqlclean.testing` runs one Rule over SQL you give it, with the same native engine as +`sqlclean check`. It fails when the Rule reports a different number of findings than you expect, +or when its findings depend on the order of the files. + +```python +# sql_rules/test_naming.py +from sqlclean.testing import RuleCase, evaluate_rule + +from sql_rules.naming import count_one, temporary_table + +def test_scratch_tables_are_reported() -> None: + result = evaluate_rule( + rule=temporary_table, + test_case=RuleCase( + description="reads scratch_orders", + source="SELECT order_id FROM scratch_orders", + dialect="duckdb", + options={"prefixes": ["tmp_", "scratch_"]}, + expected_finding_count=1, + ), + ) + finding = result.findings[0] + assert (finding.start, finding.end) == (21, 35) + +def test_published_tables_pass() -> None: + evaluate_rule( + rule=temporary_table, + test_case=RuleCase( + description="reads orders", + source="SELECT order_id FROM orders", + expected_finding_count=0, + ), + ) + +def test_count_one_is_fixed() -> None: + result = evaluate_rule( + rule=count_one, + test_case=RuleCase( + description="COUNT(1)", + source="SELECT COUNT(1) FROM orders", + expected_finding_count=1, + ), + ) + assert result.fixed_source == "SELECT COUNT(*) FROM orders" +``` + +Test failing cases, passing cases and near misses, and assert exact positions where they matter. + +## Running the tests + +Files named `test_*.py` and `conftest.py` under `[rules] custom` are not loaded as Rules, so tests +can live next to the Rules they test. They import Rules by module path from the configuration +root, so put the root on the import path: run `python -m pytest` from the root, add a +`conftest.py` there, or set `pythonpath`: + +```toml +# pyproject.toml +[tool.pytest.ini_options] +pythonpath = ["."] +``` + +The harness runs the `sqlclean` executable installed in the same environment, or the one on the +`PATH`, to parse the case SQL. + +## RuleCase + +| Field | Default | Description | +|-------|---------|-------------| +| `description` | required | Names the case in failure messages | +| `source` | required | The SQL of the file under test | +| `expected_finding_count` | required | How many findings the Rule must report over every file of the case | +| `path` | `"query.sql"` | The path of `source` | +| `dialect` | `"generic"` | The dialect every file of the case is parsed with | +| `files` | `()` | More files, as `RuleFile(path=..., source=...)` | +| `options` | `{}` | Option values, as in `[rules.rule_options.]` | + +`options` are validated as in `sqlclean.toml`, so a test with a bad value fails with the same +error as `sqlclean check`. See [Validation](fixes-and-options.md#validation). + +`files` matter for file and repository Rules. A repository Rule sees every file of the case: + +```python +from sqlclean.testing import RuleCase, RuleFile, evaluate_rule + +from sql_rules.layout import duplicate_cte_names + +def test_cte_names_repeated_across_files() -> None: + result = evaluate_rule( + rule=duplicate_cte_names, + test_case=RuleCase( + description="paid is defined in two files", + source="WITH paid AS (SELECT order_id FROM payments) SELECT order_id FROM paid", + path="models/paid_orders.sql", + files=( + RuleFile( + path="models/refunds.sql", + source="WITH paid AS (SELECT order_id FROM refunds) SELECT order_id FROM paid", + ), + ), + expected_finding_count=2, + ), + ) + assert [finding.path for finding in result.findings] == [ + "models/paid_orders.sql", + "models/refunds.sql", + ] +``` + +## RuleResult + +| Member | Description | +|--------|-------------| +| `findings` | The findings, sorted by path and position | +| `finding_count` | `len(findings)` | +| `fixed_source` | `source` with the fixes of its findings applied; `source` itself when there are none | + +Each `Finding` has `code`, `path`, `start`, `end`, `message`, `remediation` and `edits`. Offsets +count characters in the file, as in [Statement facts](statement-facts.md#positions). + +`fixed_source` applies every finding's edits once, skipping an edit that overlaps an earlier one, +and does not format the result. It shows exactly what the Rule's fix changes. + +## Failures + +`evaluate_rule` raises `RuleCaseError`, which fails the test: + +| Problem | Message | +|---------|---------| +| A different number of findings | `reads orders: expected 1 findings, got 0` | +| Findings that depend on file order | `custom Rule XSQCND001 is not deterministic` | +| Case SQL that does not parse | `sqlclean could not parse the case SQL: sqlclean: error: Parse error at line 1, column 18: ...` | +| A function without `@rule` | `evaluate_rule needs a function decorated with @rule` | +| No `sqlclean` executable | `the sqlclean executable is not installed; install sqlclean` | + +Errors in the Rule itself, such as an exception or a fix without `fixable`, raise the same errors +as in [`sqlclean check`](overview.md#errors). + +## Determinism + +`sqlclean check` caches each Rule's findings per file and reuses them while the inputs are +unchanged, so a Rule must report the same findings for the same input every time. The harness +checks this: it evaluates the Rule over the case's files, then again in reverse file order, and +fails when the two sets of findings differ. + +The usual cause is state kept between calls, such as a module-level set of names already seen: + +```python +SEEN: set[str] = set() + +@rule(code="XSQCND001", message="Table read in an earlier file", remediation="...") +def seen_before(*, statement: Statement, ctx: RuleContext) -> list[Finding]: + found = [ctx.finding(subject=statement, node=t) for t in statement.tables if t.name in SEEN] + SEEN.update(t.name for t in statement.tables) + return found +``` + +```text +RuleCaseError: custom Rule XSQCND001 is not deterministic +``` + +Compute findings from the subject and `ctx` alone. When a Rule needs to compare files, give it a +[repository subject](overview.md#choosing-a-subject), which sees every file at +once. + +Avoid other inputs SQLClean does not track, such as the clock, environment variables, network +calls and files read directly from disk. A changed input of that kind does not invalidate cached +results; run with `--no-cache` when it changes. diff --git a/crates/sqlclean-cli/skill/references/docs/embedding.md b/crates/sqlclean-cli/skill/references/docs/embedding.md new file mode 100644 index 0000000..e8e4a90 --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/embedding.md @@ -0,0 +1,160 @@ + + +# Embedding the engine + +> Lint, format and read statement facts from Rust, in a tool that owns its own configuration. + +Online: https://sqlclean.io/docs/embedding/ + +## Contents + +- Adding the dependency +- Entry points +- Lint requests +- Fixes +- Formatting +- Statement facts +- What stays in the host + +The `sqlclean` crate is the engine without the command: the built-in Rules, the formatter and the +statement facts. Tools that template, compile or generate SQL can call it in process and keep their +own configuration, file discovery and Rule codes. + +```rust +use sqlclean::sql_lint::constants::LINT_API_VERSION; +use sqlclean::sql_lint::main::lint::lint; +use sqlclean::sql_lint::models::{LintRequest, LintResponse}; + +fn main() -> Result<(), String> { + let request = LintRequest { + version: LINT_API_VERSION, + sql: "SELECT order_id FROM orders WHERE cancelled_at = NULL LIMIT 10".to_owned(), + dialect: "duckdb".to_owned(), + enabled_rules: Some(vec!["SQC001".to_owned(), "SQC004".to_owned()]), + ..LintRequest::default() + }; + let response: LintResponse = lint(request)?; + for diagnostic in &response.diagnostics { + println!("{} {}..{} {}", diagnostic.code, diagnostic.start, diagnostic.end, diagnostic.message); + if let Some(fix) = &diagnostic.fix { + println!(" fix: replace {}..{} with {:?}", fix.start, fix.end, fix.replacement); + } + } + Ok(()) +} +``` + +```text +SQC001 47..48 Comparison with NULL is never true + fix: replace 47..48 with "IS" +SQC004 54..59 Row selection is nondeterministic +``` + +## Adding the dependency + +The crate is not published to crates.io. Depend on it from the +[GitHub repository](https://github.com/sqlstack-io/sqlclean), pinned to a release tag or commit: + +```toml +[dependencies] +sqlclean = { git = "https://github.com/sqlstack-io/sqlclean", tag = "" } +``` + +## Entry points + +| Function | What it does | +|----------|--------------| +| `sql_lint::main::lint::lint` | Lints one SQL text with a typed `LintRequest` | +| `sql_lint::main::engine::lint_json` | The same, with the request and response as JSON strings | +| `sql_lint::main::batch_engine::lint_batch_json` | Lints a JSON array of requests in parallel; returns an array of `{"Ok": response}` or `{"Err": message}` | +| `sql_lint::main::formatter::format_json` | Formats SQL from a JSON request | +| `sql_lint::main::catalogue::catalogue` | Every built-in Rule's code, message and remediation | +| `sql_lint::main::default_rules::default_rules` | The codes of the Rules enabled by default | +| `sql_facts::main::file_facts::file_facts` | The statement facts of a SQL text, as custom Rules read them | + +`lint`, `lint_json` and `file_facts` return `Err` with a message when the SQL does not parse under +the dialect. Offsets in requests, findings, edits and facts count Unicode characters, not bytes. + +## Lint requests + +`version` must be `LINT_API_VERSION`; another value is rejected with +`unsupported native lint request version 2; expected 1`. + +| Field | Default | Description | +|-------|---------|-------------| +| `version` | required | `LINT_API_VERSION` | +| `sql` | required | The SQL text | +| `dialect` | required | A [dialect name](configuration.md#dialects) | +| `enabled_rules` | the default Rules | Rule codes or prefixes to run | +| `ignored_rules` | `[]` | Rule codes or prefixes to skip | +| `max_literal_length` | `100` | The `SQC044` limit | +| `max_ranking_order_by` | `5` | The `SQC043` limit | +| `config_file` | `sqlclean.toml` | The configuration file name that remediation text refers to | +| `externally_referenced_ctes` | `[]` | CTE names that something outside the SQL reads, so `SQC005` does not report them | +| `relation_keys` | `{}` | Declared unique keys by relation name; an empty key means the relation holds at most one row, so `SQC038` does not report a cross join with it | + +Other fields describe template code the host has replaced before linting. See `LintRequest` in +`sql_lint::models`. + +A selector that matches no Rule is an error: `unknown native lint rule 'SQC9'`. + +## Fixes + +`LintDiagnostic.fix` holds at most one edit: `start`, `end` and `replacement`. The engine does not +say whether a fix is safe. The `sqlclean` command treats the `SQC001` fix as unsafe, because +turning `= NULL` into `IS NULL` changes which rows match, and every other built-in fix as safe. A +host that applies fixes makes the same decision. See +[Safe and unsafe fixes](rules/overview.md#safe-and-unsafe-fixes). + +## Formatting + +```rust +let request = r#"{"version": 1, "sql": "select order_id, amount from orders where amount > 0", "dialect": "duckdb", "line_width": 100}"#; +println!("{}", format_json(request)?); +``` + +```json +{"version":1,"sql":"SELECT\n order_id,\n amount\nFROM orders\nWHERE amount > 0","changed":true,"formatted":true} +``` + +`indent` sets the spaces per indent step. Without it the engine indents two spaces, its layout +before the `sqlclean` command chose four; pass `"indent": 4` to match `sqlclean format`. + +When the formatter cannot format the SQL, `formatted` is `false`, `sql` is the input unchanged, +and `reason` says why. + +`format_json` formats the text it is given as one unit. `sqlclean format` formats each statement on +its own, joins them with one blank line and ends the file with a newline. To produce the same +layout, split the file with `file_facts`, whose statements carry `start` and `terminated_end`, and +format each statement. See [Formatting](formatting.md#statements). + +## Statement facts + +```rust +let facts = file_facts("SELECT o.order_id FROM orders AS o", "duckdb")?; +for statement in &facts.statements { + for table in &statement.tables { + println!("{} {} {}..{}", statement.kind, table.qualified_name, table.start, table.end); + } +} +``` + +```text +select orders 23..29 +``` + +The fields are those of [Statement facts](custom-rules/statement-facts.md), without `line`, +`column` and `text`. Functions are `NamedNode` values with a `name`. + +## What stays in the host + +The crate reads no files, no configuration and no cache, and does not run Python. A host owns: + +- which files to lint and in which dialect; +- Rule selection, which it passes as `enabled_rules` and `ignored_rules`; +- exceptions, ignores and their reasons; +- the codes and configuration it shows its users; +- caching, and applying fixes. + +The [`sqlclean` command](cli/check.md) is one such host. Its behaviour, described in the rest of +these docs, is a reference for what each of these pieces does. diff --git a/crates/sqlclean-cli/skill/references/docs/formatting.md b/crates/sqlclean-cli/skill/references/docs/formatting.md new file mode 100644 index 0000000..c6d51b1 --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/formatting.md @@ -0,0 +1,200 @@ + + +# Formatting + +> One canonical layout for every SQL file, the line width and indent, and files that can't be formatted. + +Online: https://sqlclean.io/docs/formatting/ + +## Contents + +- Before and after +- The layout +- Statements +- Line width +- Indent +- Idempotency +- Formatting in CI +- When a file can't be formatted + +`sqlclean format` prints every statement in one canonical layout. The only style options are the +line width and the indent, so every file in a project looks the same whoever wrote it. +[`sqlclean fix`](cli/fix.md) formats the files it reads too. + +## Before and after + +```sql +-- Revenue per customer +with paid as (select order_id, customer_id, amount from payments where status = 'paid' and amount > 0), +totals as (select customer_id, sum(amount) as revenue, count(*) as orders from paid group by customer_id) +select c.customer_id, c.customer_name, t.revenue, t.orders, case when t.revenue > 1000 then 'gold' when t.revenue > 100 then 'silver' else 'bronze' end as tier +from customers c left join totals t on c.customer_id = t.customer_id +order by t.revenue desc; +delete from orders where status='cancelled';select 1 +``` + +```bash +sqlclean format +``` + +```sql +-- Revenue per customer +WITH paid AS ( + SELECT + order_id, + customer_id, + amount + FROM payments + WHERE + status = 'paid' AND amount > 0 +), + +totals AS ( + SELECT + customer_id, + SUM(amount) AS revenue, + COUNT(*) AS orders + FROM paid + GROUP BY + customer_id +) + +SELECT + c.customer_id, + c.customer_name, + t.revenue, + t.orders, + CASE + WHEN t.revenue > 1000 + THEN 'gold' + WHEN t.revenue > 100 + THEN 'silver' + ELSE 'bronze' + END AS tier +FROM customers c +LEFT JOIN totals t + ON c.customer_id = t.customer_id +ORDER BY + t.revenue DESC; + +DELETE FROM orders WHERE status = 'cancelled'; + +SELECT 1 +``` + +## The layout + +- Clause keywords and operators such as `SELECT`, `LEFT JOIN`, `AND` and `DESC`, and function + names such as `SUM` and `COUNT`, are upper case. Identifiers, aliases and literals keep the case + they were written in. +- Each clause starts a line. Each indent step is four spaces; see [Indent](#indent). +- A `SELECT` list with two or more items puts one item per line. A single item stays on the + `SELECT` line: `SELECT order_id`, `SELECT *`. +- `GROUP BY` and `ORDER BY` items go on the lines below the clause. +- A `WHERE` with one condition stays on the clause line: `WHERE amount > 0`. Several conditions + go on an indented line below it. +- A join's `ON` condition goes on the next line, indented. +- Each CTE closes with `),` and one blank line. One blank line separates the last CTE from the + query that reads it. +- `CASE` puts each `WHEN`, `THEN` and `ELSE` on its own line. +- Comments are kept. + +## Statements + +Each statement in a file is formatted on its own. Statements are separated by one blank line, and +every file ends with a newline. Semicolons are kept where they are written; the formatter does not +add one after the last statement. + +## Line width + +`line_width` sets the width the formatter fills before it breaks a line. The default is `100`: + +```toml +[format] +line_width = 100 +``` + +A line that does not fit is broken at its outermost structure. Inner parentheses that fit on +their line stay on one line. At the default width this `IN` list fits; at `line_width = 60` it is +broken: + +```sql +SELECT order_id +FROM orders +WHERE status IN ('placed', 'paid', 'packed', 'shipped', 'delivered', 'returned') +``` + +```sql +SELECT order_id +FROM orders +WHERE + status IN ( + 'placed', + 'paid', + 'packed', + 'shipped', + 'delivered', + 'returned' + ) +``` + +`line_width` must be positive. + +## Indent + +`indent` sets the number of spaces per indent step. The default is `4`: + +```toml +[format] +indent = 4 +``` + +With `indent = 2`, `select order_id, amount from orders where amount > 0` formats as: + +```sql +SELECT + order_id, + amount +FROM orders +WHERE amount > 0 +``` + +`indent` must be between `1` and `8`. + +## Idempotency + +Formatting is idempotent: a formatted file formats to itself. Running `sqlclean format` twice +changes nothing the second time, so `sqlclean format --check` passes on any file +`sqlclean format` wrote: + +```text +0 file(s) reformatted, 1 file(s) already formatted. +``` + +## Formatting in CI + +`sqlclean format --check` exits with `1` and names every file that would change, without writing: + +```text +Would reformat: models/open_orders.sql +1 file(s) would be reformatted, 0 file(s) already formatted. +``` + +`--diff` prints the changes instead. See [`sqlclean format`](cli/format.md) and +[Integrations](integrations.md). + +## When a file can't be formatted + +The formatter leaves a file unchanged when it cannot format it, usually because the file does not +parse under its [dialect](configuration.md#dialects). Template code such as `{{ col }}` does +not parse: + +```text +j.sql: could not format: Parse error at line 1, column 16: Expected Colon, got RBrace ('}') near [{{ col }} from orders] +``` + +`sqlclean format` and `sqlclean fix` report the file on stderr and exit with `1`, and format every +other file as usual. Fix the SQL, set the file's dialect with +[`[[dialects]]`](configuration.md#dialects), or +[`exclude`](configuration.md#choosing-files) the file. `sqlclean check` reports the same file as +[`SQC999`](rules/overview.md#parse-errors-sqc999). diff --git a/crates/sqlclean-cli/skill/references/docs/index.md b/crates/sqlclean-cli/skill/references/docs/index.md new file mode 100644 index 0000000..42d7053 --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/index.md @@ -0,0 +1,83 @@ + + +# Introduction + +> Clean SQL, every time. Fast linting, safe fixes and one canonical format. + +Online: https://sqlclean.io/docs/ + +SQLClean is a free, open-source SQL linter and formatter. It checks `.sql` files against built-in +Rules, applies their fixes, and formats every file in one canonical layout. The engine and the +`sqlclean` command are written in Rust. Custom Rules are plain Python functions that read +structural facts about each statement. + +```bash +sqlclean check +``` + +```text +error[SQC001]: Comparison with NULL is never true + --> models/open_orders.sql:1:77 + | + 1 | with open_orders as (select order_id, amount from orders where cancelled_at = null), + | ^ + = help: Use IS NULL or IS NOT NULL when testing for NULL. + = fix: available with `sqlclean fix --unsafe-fixes` + +error[SQC005]: CTE is unreachable from the final query + --> models/open_orders.sql:2:1 + | + 2 | refunds as (select order_id from payments where amount < 0) + | ^^^^^^^ + = help: Reference the CTE from the final query or another reachable CTE, or remove it. + = fix: available with `sqlclean fix` + +Found 2 problems (1 fixable with `sqlclean fix`, 1 more with `--unsafe-fixes`). +``` + +`sqlclean fix` removes the unused CTE, then formats the file. The `= NULL` comparison has a fix +too, but it changes which rows match, so it waits for `--unsafe-fixes`. + +## Check + +[`sqlclean check`](cli/check.md) reports findings with the source line, the problem and what to +do about it. Output is text for people, or [JSON and SARIF](integrations.md) for CI and code +scanning. The [built-in Rules](rules/reference.md) cover SQL that runs but returns the wrong +rows: `NULL` comparisons, implicit cartesian joins, `LIMIT` without `ORDER BY`, unused CTEs and +mismatched set operations. Opt-in Rules enforce a consistent query structure. + +## Fix + +[`sqlclean fix`](cli/fix.md) applies every safe fix until the files stop changing, then formats +them. A fix that can change query results is marked unsafe and runs only with `--unsafe-fixes`. +See [Safe and unsafe fixes](rules/overview.md#safe-and-unsafe-fixes). + +## Format + +[`sqlclean format`](cli/format.md) rewrites every file in one canonical layout. Keywords are +upper case, each clause starts a line, and statements are separated by one blank line. Formatting +is idempotent: a formatted file formats to itself. See [Formatting](formatting.md). + +## Extend + +[Custom Rules](custom-rules/overview.md) are Python functions decorated with `@rule`. They read +the tables, joins, CTEs, columns and literals of each statement, can carry fixes and typed options, +and run with the same selection, exceptions and cache as the built-in Rules. Tools that compile or +template SQL can [embed the engine](embedding.md) from Rust. + +## Commands + +| Command | What it does | +|---------|--------------| +| [`sqlclean check`](cli/check.md) | Reports Rule findings without changing any file | +| [`sqlclean fix`](cli/fix.md) | Applies Rule fixes until stable, then formats | +| [`sqlclean format`](cli/format.md) | Rewrites SQL files in the canonical layout | +| [`sqlclean rules`](cli/rules.md) | Lists the available Rules, or shows one | +| [`sqlclean facts`](cli/facts.md) | Prints the statement facts custom Rules read, as JSON | + +## Where to start + + Install SQLClean, then check, fix and format your first file. + Selection, defaults, exceptions and fixes. + Encode your team's SQL conventions in Python. + Every command and flag. diff --git a/crates/sqlclean-cli/skill/references/docs/integrations.md b/crates/sqlclean-cli/skill/references/docs/integrations.md new file mode 100644 index 0000000..9b7ae55 --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/integrations.md @@ -0,0 +1,116 @@ + + +# Integrations + +> Run SQLClean in CI with JSON or SARIF output, in pre-commit, and from editors. + +Online: https://sqlclean.io/docs/integrations/ + +## Contents + +- CI +- pre-commit +- Editors + +`sqlclean check`, `fix` and `format` exit with `0` when there is nothing to report, `1` when there +are findings or files to change, and `2` when they cannot run. Any CI system, Git hook or editor +can gate on that. + +## CI + +Run `sqlclean check` and `sqlclean format --check` on every change: + +```bash +sqlclean check +sqlclean format --check +``` + +Both read every matching file under the configuration root. `check` fails on findings and +[stale exceptions](rules/overview.md#stale-exceptions); `format --check` fails on files that +need formatting or can't be formatted. + +### Code scanning with SARIF + +`--output-format sarif` writes a SARIF 2.1.0 log that code-scanning tools show as annotations on +the changed lines. On GitHub Actions: + +```yaml +name: sqlclean + +on: [push, pull_request] + +jobs: + sqlclean: + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install sqlclean + - run: sqlclean format --check + - run: sqlclean check --output-format sarif > sqlclean.sarif + - uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: sqlclean.sarif +``` + +`sqlclean check` exits with `1` when it reports findings, so the upload step runs with +`if: always()`. The SARIF log lists findings only; stale exceptions still fail the step. See +[SARIF output](cli/check.md#sarif-output). + +### JSON for other tools + +`--output-format json` prints one document with every finding, its position, message, remediation +and fix safety, the stale exceptions, and a summary. See +[JSON output](cli/check.md#json-output). `sqlclean rules --json` describes every Rule a finding +can come from. + +### Caching in CI + +`check` stores results in `.sqlclean/` under the configuration root. Keep that directory between +runs to skip files that have not changed, or pass `--no-cache` to evaluate everything. See +[Caching](rules/overview.md#caching). + +## pre-commit + +SQLClean ships three [pre-commit](https://pre-commit.com/) hooks: + +| Hook | Runs | Changes files | +|------|------|---------------| +| `sqlclean-check` | `sqlclean check` | No | +| `sqlclean-fix` | `sqlclean fix` | Yes: safe fixes, then formatting | +| `sqlclean-format` | `sqlclean format` | Yes: formatting | + +```yaml +# .pre-commit-config.yaml +repos: + - repo: https://github.com/sqlstack-io/sqlclean + rev: v0.0.1 + hooks: + - id: sqlclean-fix + - id: sqlclean-format +``` + +Use `sqlclean-check` instead of `sqlclean-fix` where a hook must not change files. + +The hooks run on staged `.sql` files and pass them to the command by name, so `exclude` in +`sqlclean.toml` still applies but `include` does not. They use the nearest `sqlclean.toml` at +or above the repository root. A hook that changes a file, or leaves a finding, fails the commit; +stage the changes and commit again. + +## Editors + +Editors can run `sqlclean format` on save, passing the saved file: + +```bash +sqlclean format path/to/file.sql +``` + +The file is formatted with the dialect, `line_width` and `indent` of the nearest `sqlclean.toml` at or above +the editor's working directory. Use `sqlclean check --output-format json path/to/file.sql` to show +findings with their lines and columns. Lines and columns start at 1 and count characters. diff --git a/crates/sqlclean-cli/skill/references/docs/quickstart.md b/crates/sqlclean-cli/skill/references/docs/quickstart.md new file mode 100644 index 0000000..a0714e2 --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/quickstart.md @@ -0,0 +1,223 @@ + + +# Quickstart + +> Install SQLClean, then check, fix and format your first SQL file. + +Online: https://sqlclean.io/docs/quickstart/ + +## Contents + +- Prerequisites +- 1. Check a file +- 2. Fix +- 3. Format +- 4. Add a sqlclean.toml +- Next steps + +This guide installs SQLClean, checks one SQL file, fixes what can be fixed, and adds a +`sqlclean.toml` that selects more Rules. + +## Prerequisites + +- Python 3.12 or newer +- SQLClean installed from [PyPI](https://pypi.org/project/sqlclean/): + +```bash +uv tool install sqlclean # or: pip install sqlclean +sqlclean --version +``` + +The `sqlclean` package installs the native `sqlclean` executable. Custom Rules run in the same +Python environment as that executable. + +## 1. Check a file + +Save this query as `models/open_orders.sql`: + +```sql +with open_orders as (select order_id, customer_id, amount from orders where cancelled_at = null), +refunds as (select order_id from payments where amount < 0) +select order_id, customer_id, amount from open_orders limit 100 +``` + +Run `sqlclean check` in the project directory. With no paths, it reads every `.sql` file under the +directory: + +```bash +sqlclean check +``` + +```text +error[SQC001]: Comparison with NULL is never true + --> models/open_orders.sql:1:90 + | + 1 | with open_orders as (select order_id, customer_id, amount from orders where cancelled_at = null), + | ^ + = help: Use IS NULL or IS NOT NULL when testing for NULL. + = fix: available with `sqlclean fix --unsafe-fixes` + +error[SQC005]: CTE is unreachable from the final query + --> models/open_orders.sql:2:1 + | + 2 | refunds as (select order_id from payments where amount < 0) + | ^^^^^^^ + = help: Reference the CTE from the final query or another reachable CTE, or remove it. + = fix: available with `sqlclean fix` + +error[SQC004]: Row selection is nondeterministic + --> models/open_orders.sql:3:55 + | + 3 | select order_id, customer_id, amount from open_orders limit 100 + | ^^^^^ + = help: Add ORDER BY with a deterministic tie-breaker before LIMIT or OFFSET. + +Found 3 problems (1 fixable with `sqlclean fix`, 1 more with `--unsafe-fixes`). +``` + +Each finding names its Rule code, the file, line and column, and what to do. The command exits +with `1` because there are findings. + +## 2. Fix + +Preview the safe fixes as a diff, then apply them: + +```bash +sqlclean fix --diff +sqlclean fix +``` + +```text +Fixed 1 problem(s) and changed 1 file(s). +error[SQC001]: Comparison with NULL is never true + --> models/open_orders.sql:7:24 + | + 7 | WHERE cancelled_at = NULL + | ^ + = help: Use IS NULL or IS NOT NULL when testing for NULL. + = fix: available with `sqlclean fix --unsafe-fixes` + +error[SQC004]: Row selection is nondeterministic + --> models/open_orders.sql:15:1 + | + 15 | LIMIT 100 + | ^^^^^ + = help: Add ORDER BY with a deterministic tie-breaker before LIMIT or OFFSET. + +Found 2 problems (1 fixable with `sqlclean fix --unsafe-fixes`). +``` + +`fix` removed the unused `refunds` CTE and formatted the file. The `= NULL` fix turns the +comparison into `IS NULL`, which changes which rows match, so it is unsafe. Review it, then apply +it: + +```bash +sqlclean fix --unsafe-fixes +``` + +`SQC004` has no fix: only you know which column makes the order deterministic. Add +`ORDER BY order_id` before `LIMIT 100`, then run `sqlclean fix` once more to format the new +clause. The file is now: + +```sql +WITH open_orders AS ( + SELECT + order_id, + customer_id, + amount + FROM orders + WHERE cancelled_at IS NULL +) + +SELECT + order_id, + customer_id, + amount +FROM open_orders +ORDER BY + order_id +LIMIT 100 +``` + +```bash +sqlclean check +``` + +```text +All checks passed. +``` + +## 3. Format + +`sqlclean fix` formats the files it reads. To format without applying fixes, or to check +formatting in CI, use `sqlclean format`: + +```bash +sqlclean format # rewrite files +sqlclean format --check # exit 1 when a file needs formatting +``` + +See [Formatting](formatting.md) for the layout. + +## 4. Add a sqlclean.toml + +Without a configuration file, SQLClean parses files as `generic` SQL and runs the 13 Rules that +are enabled by default. Create `sqlclean.toml` in the project root to set the dialect and select +every built-in Rule: + +```toml +dialect = "duckdb" + +[rules] +select = ["SQC"] +``` + +`SQC` is a prefix, so it selects all 45 built-in Rules. Three of the opt-in structure Rules now +report the file: + +```text +error[SQC041]: Only the last top-level CTE may be named final, and it must use that name + --> models/open_orders.sql:1:6 + | + 1 | WITH open_orders AS ( + | ^^^^^^^^^^^ + = help: Name the last top-level CTE final and rename any other final CTE; SQC035 checks the terminal SELECT. + +error[SQC034]: Query keeps transformation logic outside top-level CTEs + --> models/open_orders.sql:10:1 + | + 10 | SELECT + | ^^^^^^ + = help: Move transformation logic into named top-level CTEs before the terminal SELECT. + +error[SQC035]: Terminal SELECT does not read plainly from the final top-level CTE + --> models/open_orders.sql:10:1 + | + 10 | SELECT + | ^^^^^^ + = help: Name the final logical CTE and leave one terminal SELECT from that CTE. + +Found 3 problems. +``` + +Follow them, or skip the ones your team does not want: + +```toml +dialect = "duckdb" + +[rules] +select = ["SQC"] +ignore = ["SQC034", "SQC035", "SQC041"] +``` + +```text +All checks passed. +``` + +## Next steps + +- [Configuration](configuration.md) lists every key, including per-path dialects. +- [Rules](rules/overview.md) covers selection, exceptions and fixes, and the + [Rule reference](rules/reference.md) lists every built-in Rule. +- [Integrations](integrations.md) runs SQLClean in CI and pre-commit. +- [Custom Rules](custom-rules/overview.md) checks your own conventions. diff --git a/crates/sqlclean-cli/skill/references/docs/rules/overview.md b/crates/sqlclean-cli/skill/references/docs/rules/overview.md new file mode 100644 index 0000000..5c13b1b --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/rules/overview.md @@ -0,0 +1,273 @@ + + +# Overview + +> Select Rules, record reviewed exceptions, and apply safe and unsafe fixes. + +Online: https://sqlclean.io/docs/rules/overview/ + +## Contents + +- Selecting Rules +- Default Rules +- Thresholds +- Exceptions +- Path ignores +- Parse errors (SQC999) +- Safe and unsafe fixes +- Caching +- Continue + +A Rule is one check over SQL. It reports a finding at an exact position, with a message, a +remediation, and sometimes a fix. SQLClean has 45 built-in Rules with `SQC###` codes, and runs +[custom Rules](../custom-rules/overview.md) written in Python with `XSQC` codes. + +Consider a query that returns an arbitrary hundred rows: + +```sql +SELECT order_id +FROM orders +LIMIT 100 +``` + +```bash +sqlclean check +``` + +```text +error[SQC004]: Row selection is nondeterministic + --> models/recent_orders.sql:3:1 + | + 3 | LIMIT 100 + | ^^^^^ + = help: Add ORDER BY with a deterministic tie-breaker before LIMIT or OFFSET. + +Found 1 problem. +``` + +Add an ordering and check again: + +```sql +SELECT order_id +FROM orders +ORDER BY order_id +LIMIT 100 +``` + +Run `sqlclean rules` for every Rule, or see the [Rule reference](reference.md). + +## Selecting Rules + +Select Rules in `sqlclean.toml` by exact code or by prefix: + +```toml +[rules] +select = ["SQC"] # every built-in Rule +ignore = ["SQC027", "SQC03"] # except SQC027 and SQC030 to SQC039 +``` + +- An exact code such as `SQC004` selects one Rule. +- A prefix selects every Rule whose code starts with it: `SQC` selects all built-in Rules, + `SQC00` selects `SQC001` to `SQC009`, and `XSQCNAME` selects every custom Rule in the `NAME` + family. +- `ignore` removes Rules from the selection, by code or prefix. +- A selector that matches no Rule is an error: `Rule selector 'SQC9' matches no Rule`. + +On the command line, `--select` replaces `[rules] select` for one run, and `--ignore` adds to +`[rules] ignore`: + +```bash +sqlclean check --select SQC001,SQC004 +sqlclean check --ignore SQC039 +``` + +`select = []` runs no Rules. Files are still parsed, so [parse errors](#parse-errors-sqc999) are +still reported. + +## Default Rules + +Without `[rules] select`, SQLClean runs the 13 Rules that are enabled by default. They cover +mistakes that run without an error and are easy to miss in review: NULL handling, joins, row order, +duplicate names and unused CTEs. + +| Code | Finding | +|------|---------| +| `SQC001` | Comparison with NULL is never true | +| `SQC002` | Comma-separated sources create an implicit cartesian join | +| `SQC003` | Non-cross join has no meaningful condition | +| `SQC004` | Row selection is nondeterministic | +| `SQC005` | CTE is unreachable from the final query | +| `SQC006` | DISTINCT is redundant with the grouped output | +| `SQC007` | Positional set operation is vulnerable to column-order drift | +| `SQC009` | Table alias is duplicated in the same query scope | +| `SQC010` | Output alias is duplicated in the same select list | +| `SQC018` | ROW_NUMBER has no deterministic ordering | +| `SQC019` | NOT IN list contains NULL and can reject every row | +| `SQC020` | Set-operation branches project different column counts | +| `SQC039` | Inline query relation obscures data flow | + +The other 32 Rules run only when `select` names them, by code or prefix. A prefix selects every +matching Rule, whether it is enabled by default or not. A custom Rule is enabled by default when its +`@rule` sets `enabled_by_default=True`. + +## Thresholds + +Two built-in Rules compare against a limit, set under `[rules.thresholds]`: + +```toml +[rules.thresholds] +max_literal_length = 100 # SQC044: the longest string literal allowed +max_ranking_order_by = 5 # SQC043: the most ORDER BY expressions in a ranking window +``` + +Both default to the values shown and must be positive. Built-in Rules take no other options. + +## Exceptions + +An exception allows one reviewed finding: one Rule code in one file. It lives in `sqlclean.toml`, +so a reviewer sees it in the diff, and it needs a reason: + +```toml +[[rules.rule_exceptions]] +rule = "SQC004" +path = "models/order_preview.sql" +reason = "A preview of any hundred orders is enough." +``` + +`rule` is an exact code, not a prefix. `path` is the file's path relative to the configuration +root, as findings print it. The exception covers every finding of that Rule in that file. + +SQLClean has no inline suppression comments. A comment such as `-- noqa: SQC001` changes nothing; +record the exception in `sqlclean.toml` instead. + +### Cross join exceptions + +A file-wide exception for a deliberate `CROSS JOIN` would also hide any accidental cross join added +to the file later. `SQC038` findings name the joined relation and, inside a CTE, that CTE, so an +exception can cover exactly one join: + +```text +error[SQC038]: CROSS JOIN with `regions` in CTE `pairs` creates an explicit cartesian product + --> models/calendar.sql:11:3 +``` + +```toml +[[rules.rule_exceptions]] +rule = "SQC038" +path = "models/calendar.sql" +relation = "regions" +cte = "pairs" # optional: only the join inside this CTE +reason = "Every order date is reported for every region." +``` + +`relation` and `cte` compare without case. Without `cte`, the exception covers joins with that +relation anywhere in the file. `cte` needs `relation`, and `relation` matches only `SQC038` +findings. + +`SQC038` does not report a cross join when one side is provably at most one row, such as a CTE +that aggregates without `GROUP BY`, `LIMIT 1` or a one-row `VALUES`, so many intentional cross +joins need no exception: + +```sql +WITH latest AS ( + SELECT MAX(order_date) AS latest_date + FROM orders +) + +SELECT + o.order_id, + l.latest_date +FROM orders AS o +CROSS JOIN latest AS l +``` + +### Stale exceptions + +An exception that matches no finding is stale. `check` and `fix` report it and exit with `1`: + +```text +error: stale rule exception: SQC004 in models/orders.sql matches no finding; remove it from sqlclean.toml +``` + +Delete the entry. Exceptions are only judged stale when their file was checked and their Rule was +selected, so `sqlclean check models/customers.sql` or `--select SQC001` never reports an exception +for another file or Rule. + +## Path ignores + +A path ignore turns Rules off for files that follow a different convention. It takes codes or +prefixes, glob patterns, and a reason: + +```toml +[[rules.rule_ignores]] +rules = ["SQC034", "SQC035"] +paths = ["scratch/**"] +reason = "Exploratory queries." +``` + +Patterns are relative to the configuration root, as in [`include`](../configuration.md#choosing-files). +To turn a Rule off everywhere, add it to `[rules] ignore` instead. + +## Parse errors (SQC999) + +A file that does not parse under its [dialect](../configuration.md#dialects) gets one `SQC999` +finding and no other: + +```text +error[SQC999]: SQL could not be parsed as duckdb: Parse error at line 1, column 18: Expected table name or subquery, got Where + --> scratch/broken.sql:1:1 + | + 1 | SELECT FROM WHERE orders + | ^ + = help: Fix the syntax error, or set this file's dialect in sqlclean.toml. +``` + +The finding points at the start of the file; the message gives the line and column of the error. +`SQC999` is not a selectable Rule: selection, `ignore`, path ignores and exceptions never hide it. +Fix the SQL, set the right dialect for the file, or [`exclude`](../configuration.md#choosing-files) +the file. + +## Safe and unsafe fixes + +Some Rules carry a fix. [`sqlclean fix`](../cli/fix.md) applies it. + +- A **safe** fix keeps the query's results. `SQC005` removes an unused CTE; `SQC002` turns + `FROM orders, customers` into `FROM orders CROSS JOIN customers`. +- An **unsafe** fix can change results. `SQC001` turns `cancelled_at = NULL`, which matches no row, + into `cancelled_at IS NULL`, which matches rows. That is almost always the intent, but it is a + different query. Unsafe fixes apply only with `--unsafe-fixes`. + +`sqlclean check` says which command applies each fix: + +```text + = fix: available with `sqlclean fix --unsafe-fixes` +``` + +The [Rule reference](reference.md) lists which Rules can fix and how safely. + +## Caching + +`check` and `fix` reuse the results of earlier runs for inputs that have not changed, so a second +run over an unchanged project costs little more than reading the files. Results are stored in +`.sqlclean/` under the configuration root, which has its own `.gitignore`. + +A file's results are evaluated again when any of these change: + +- the file's text, path or dialect; +- the selected Rules or `[rules.thresholds]`; +- a custom Rule's options, or any Python file under `[rules] custom`; +- for a custom Rule over the whole repository, any checked file; +- the SQLClean version. + +Exceptions and ignores apply after the cache, so editing them takes effect at once. + +Pass `--no-cache` to evaluate every file without reading or writing the cache. Use it when a custom +Rule imports Python from outside its `[rules] custom` paths, since changes there are not tracked. +`sqlclean format` does not use the cache. + +## Continue + + Every built-in Rule, its default and its fix. + Define your own Rules in Python. + Every `sqlclean.toml` key. + Flags, output formats and exit codes. diff --git a/crates/sqlclean-cli/skill/references/docs/rules/reference.md b/crates/sqlclean-cli/skill/references/docs/rules/reference.md new file mode 100644 index 0000000..eff1381 --- /dev/null +++ b/crates/sqlclean-cli/skill/references/docs/rules/reference.md @@ -0,0 +1,275 @@ + + +# Rule reference + +> Every built-in Rule, whether it runs by default, and whether it has a fix. + +Online: https://sqlclean.io/docs/rules/reference/ + +## Contents + +- SQC001: NULL comparisons +- SQC002 and SQC003: joins without keys +- SQC005: unused CTEs +- SQC018: unstable row numbers +- SQC019: NULL in NOT IN +- SQC039: inline subqueries +- SQC034, SQC035 and SQC041: query structure + +SQLClean has 45 built-in Rules. `Default` Rules run when `[rules] select` is not set; the others +run when `select` names them by code or prefix. A Rule with a `Safe` fix is fixed by +`sqlclean fix`; an `Unsafe` fix also needs `--unsafe-fixes`. See +[Selecting Rules](overview.md#selecting-rules) and +[Safe and unsafe fixes](overview.md#safe-and-unsafe-fixes). + +Run `sqlclean rules ` for a Rule's remediation. + +| Code | Finding | Default | Fix | +|------|---------|---------|-----| +| `SQC001` | Comparison with NULL is never true | Yes | Unsafe | +| `SQC002` | Comma-separated sources create an implicit cartesian join | Yes | Safe | +| `SQC003` | Non-cross join has no meaningful condition | Yes | Safe | +| `SQC004` | Row selection is nondeterministic | Yes | — | +| `SQC005` | CTE is unreachable from the final query | Yes | Safe | +| `SQC006` | DISTINCT is redundant with the grouped output | Yes | Safe | +| `SQC007` | Positional set operation is vulnerable to column-order drift | Yes | — | +| `SQC008` | Bare UNION leaves duplicate handling implicit | No | Safe | +| `SQC009` | Table alias is duplicated in the same query scope | Yes | — | +| `SQC010` | Output alias is duplicated in the same select list | Yes | — | +| `SQC011` | Clause mixes positional and named references | No | — | +| `SQC012` | ELSE NULL is redundant | No | — | +| `SQC013` | DISTINCT is written as though it were a function | No | Safe | +| `SQC014` | Predicate compares identical constant values | No | — | +| `SQC015` | Empty statement follows another terminator | No | — | +| `SQC016` | Expression is aliased to its existing name | No | — | +| `SQC017` | Row count uses a constant expression | No | Safe | +| `SQC018` | ROW_NUMBER has no deterministic ordering | Yes | — | +| `SQC019` | NOT IN list contains NULL and can reject every row | Yes | — | +| `SQC020` | Set-operation branches project different column counts | Yes | — | +| `SQC021` | Query output has an uncontrolled column shape | No | — | +| `SQC022` | Calculated projection has no stable output name | No | — | +| `SQC023` | Table alias is never referenced | No | — | +| `SQC024` | WHERE predicate rejects NULL rows from a LEFT JOIN | No | — | +| `SQC025` | JOIN type is implicit | No | Safe | +| `SQC026` | ORDER BY mixes explicit and implicit directions | No | — | +| `SQC027` | Column reference is unqualified in a multi-relation query | No | — | +| `SQC028` | Single-relation query mixes qualified and unqualified columns | No | — | +| `SQC029` | Qualified reference uses a relation absent from this query scope | No | — | +| `SQC030` | CASE expression only converts a condition to TRUE or FALSE | No | Safe | +| `SQC031` | CASE expression is nested directly inside ELSE | No | — | +| `SQC032` | Joined relation contributes no referenced values | No | — | +| `SQC033` | Comment is not attached to the syntax node it explains | No | — | +| `SQC034` | Query keeps transformation logic outside top-level CTEs | No | — | +| `SQC035` | Terminal SELECT does not read plainly from the final top-level CTE | No | — | +| `SQC036` | CTE is nested inside another query | No | — | +| `SQC037` | Recursive CTE is not permitted | No | — | +| `SQC038` | CROSS JOIN creates an explicit cartesian product | No | — | +| `SQC039` | Inline query relation obscures data flow | Yes | — | +| `SQC040` | JOIN ON contains a computed expression | No | — | +| `SQC041` | Only the last top-level CTE may be named final, and it must use that name | No | — | +| `SQC042` | CTE output column is never read later in the query | No | — | +| `SQC043` | Ranking window sorts by more expressions than max_ranking_order_by | No | — | +| `SQC044` | String literal is longer than max_literal_length | No | — | +| `SQC045` | JSON payload is parsed more than once in one SELECT | No | — | + +`SQC999` is the [parse error](overview.md#parse-errors-sqc999) finding. It is not a +selectable Rule. + +`SQC043` and `SQC044` read their limits from +[`[rules.thresholds]`](overview.md#thresholds). + +## SQC001: NULL comparisons + +`= NULL` and `<> NULL` are never true, so the filter matches no row: + +```sql +SELECT order_id +FROM orders +WHERE cancelled_at = NULL +``` + +```text +error[SQC001]: Comparison with NULL is never true + --> models/open_orders.sql:3:20 + | + 3 | WHERE cancelled_at = NULL + | ^ + = help: Use IS NULL or IS NOT NULL when testing for NULL. + = fix: available with `sqlclean fix --unsafe-fixes` +``` + +The fix writes `WHERE cancelled_at IS NULL`. It is unsafe because the query then returns rows it +did not return before. + +## SQC002 and SQC003: joins without keys + +A comma between sources joins every row with every row unless a `WHERE` predicate filters them: + +```sql +SELECT + o.order_id, + c.customer_name +FROM orders AS o, customers AS c +WHERE o.customer_id = c.customer_id +``` + +The `SQC002` fix keeps the meaning and makes the cartesian product visible: + +```diff +-FROM orders AS o, customers AS c ++FROM orders AS o ++CROSS JOIN customers AS c + WHERE o.customer_id = c.customer_id +``` + +Move the predicate into `JOIN customers AS c ON o.customer_id = c.customer_id` by hand to make it a +keyed join. `SQC003` reports a `JOIN` whose condition is missing or constant, such as +`ON 1 = 1`; its fix turns a `JOIN` without a condition into `CROSS JOIN`. + +## SQC005: unused CTEs + +A CTE that no reachable query reads is dead code: + +```sql +WITH paid AS ( + SELECT order_id FROM payments +), + +refunded AS ( + SELECT order_id FROM refunds +) + +SELECT order_id FROM paid +``` + +```text +error[SQC005]: CTE is unreachable from the final query + --> models/paid_orders.sql:5:1 + | + 5 | refunded AS ( + | ^^^^^^^^ + = help: Reference the CTE from the final query or another reachable CTE, or remove it. + = fix: available with `sqlclean fix` +``` + +The fix removes `refunded`. + +## SQC018: unstable row numbers + +Without `ORDER BY` in the window, the row numbered `1` can change between runs: + +```sql +SELECT + order_id, + ROW_NUMBER() OVER (PARTITION BY customer_id) AS order_rank +FROM orders +``` + +```text +error[SQC018]: ROW_NUMBER has no deterministic ordering +``` + +An `ORDER BY` is not always enough. Ties still rank in any order unless `PARTITION BY` and +`ORDER BY` together cover a unique key of the ranked rows. SQLClean only knows a key it can prove +from the query, such as the `GROUP BY` columns of a CTE. Ranking straight over a table is reported +with a second message: + +```text +error[SQC018]: Ranking window order is not proven unique within its partition + --> models/ranked.sql:3:3 + | + 3 | ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ordered_at, order_id) AS order_rank + | ^^^^^^^^^^ + = help: PARTITION BY and ORDER BY must together cover a unique key of the ranked rows. End the ORDER BY with a unique, non-null tie-breaker column. If the rows have no unique column, build one upstream from the columns that identify a row (for example `HASH(source_partition, source_offset, line_index) AS row_key`) and order by it last. +``` + +Here the CTE's `GROUP BY` makes `customer_id, order_id` a key, so the window passes: + +```sql +WITH customer_orders AS ( + SELECT + customer_id, + order_id, + MAX(ordered_at) AS ordered_at + FROM orders + GROUP BY + customer_id, + order_id +) + +SELECT + order_id, + ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ordered_at, order_id) AS order_rank +FROM customer_orders +``` + +`SQC004` reports the same problem for `LIMIT` and `OFFSET`; see the example in +[Rules](overview.md). + +## SQC019: NULL in NOT IN + +`x NOT IN (..., NULL)` is never true, so the filter rejects every row: + +```sql +SELECT order_id +FROM orders +WHERE status NOT IN ('cancelled', NULL) +``` + +```text +error[SQC019]: NOT IN list contains NULL and can reject every row + --> models/active_orders.sql:3:14 + | + 3 | WHERE status NOT IN ('cancelled', NULL) + | ^^^ + = help: Remove NULL from the list or express the anti-join with a NULL-safe NOT EXISTS. +``` + +## SQC039: inline subqueries + +A subquery in `FROM` hides a step of the query. Name it as a CTE instead: + +```sql +-- Reported +SELECT order_id +FROM (SELECT order_id FROM orders WHERE amount > 0) AS paid + +-- Passes +WITH paid AS ( + SELECT order_id + FROM orders + WHERE amount > 0 +) + +SELECT order_id +FROM paid +``` + +## SQC034, SQC035 and SQC041: query structure + +These opt-in Rules ask every query to do its work in top-level CTEs, name the last one `final`, +and end with a plain `SELECT` of named columns from it. This query passes every built-in Rule: + +```sql +WITH open_orders AS ( + SELECT + order_id, + amount + FROM orders + WHERE cancelled_at IS NULL +), + +final AS ( + SELECT + order_id, + amount + FROM open_orders +) + +SELECT + order_id, + amount +FROM final +``` + +Ending with `SELECT * FROM final` is reported by `SQC021`, which asks for named output columns. diff --git a/crates/sqlclean-cli/src/caching/_helpers/entries.rs b/crates/sqlclean-cli/src/caching/_helpers/entries.rs new file mode 100644 index 0000000..58d502a --- /dev/null +++ b/crates/sqlclean-cli/src/caching/_helpers/entries.rs @@ -0,0 +1,13 @@ +//! Read the stored cache file. + +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use crate::caching::models::CacheEntry; + +/// The entries stored in `file`; a missing or damaged file is an error the caller may drop. +pub(crate) fn read_entries(file: &Path) -> Result, String> { + let bytes: Vec = fs::read(file).map_err(|error| error.to_string())?; + serde_json::from_slice(&bytes).map_err(|error| error.to_string()) +} diff --git a/crates/sqlclean-cli/src/caching/_helpers/mod.rs b/crates/sqlclean-cli/src/caching/_helpers/mod.rs new file mode 100644 index 0000000..f700be7 --- /dev/null +++ b/crates/sqlclean-cli/src/caching/_helpers/mod.rs @@ -0,0 +1 @@ +pub mod entries; diff --git a/crates/sqlclean-cli/src/caching/constants.rs b/crates/sqlclean-cli/src/caching/constants.rs new file mode 100644 index 0000000..e750637 --- /dev/null +++ b/crates/sqlclean-cli/src/caching/constants.rs @@ -0,0 +1,4 @@ +pub(crate) const CACHE_DIRECTORY: &str = ".sqlclean/cache"; +pub(crate) const CACHE_FILE: &str = "results-v1.json"; +pub(crate) const CACHE_PARENT_IGNORE: &str = ".sqlclean/.gitignore"; +pub(crate) const CACHE_PARENT_IGNORE_TEXT: &str = "*\n"; diff --git a/crates/sqlclean-cli/src/caching/main/cache_key.rs b/crates/sqlclean-cli/src/caching/main/cache_key.rs new file mode 100644 index 0000000..ed2af4a --- /dev/null +++ b/crates/sqlclean-cli/src/caching/main/cache_key.rs @@ -0,0 +1,14 @@ +//! The digest that identifies one cached result. + +use sha2::{Digest, Sha256}; + +/// A SHA-256 over this build's version and every part, each prefixed by its length. +pub fn cache_key(parts: &[&str]) -> String { + let mut digest = Sha256::new(); + for part in [env!("CARGO_PKG_VERSION")].iter().chain(parts) { + digest.update(part.len().to_string().as_bytes()); + digest.update(b":"); + digest.update(part.as_bytes()); + } + format!("{:x}", digest.finalize()) +} diff --git a/crates/sqlclean-cli/src/caching/main/mod.rs b/crates/sqlclean-cli/src/caching/main/mod.rs new file mode 100644 index 0000000..b92fc68 --- /dev/null +++ b/crates/sqlclean-cli/src/caching/main/mod.rs @@ -0,0 +1 @@ +pub mod cache_key; diff --git a/crates/sqlclean-cli/src/caching/mod.rs b/crates/sqlclean-cli/src/caching/mod.rs new file mode 100644 index 0000000..ae7f4ca --- /dev/null +++ b/crates/sqlclean-cli/src/caching/mod.rs @@ -0,0 +1,9 @@ +//! Findings reused while their inputs are unchanged, in `.sqlclean/cache` under the root. + +pub mod _helpers; +pub mod constants; +pub mod main; +pub mod models; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlclean-cli/src/caching/models.rs b/crates/sqlclean-cli/src/caching/models.rs new file mode 100644 index 0000000..018b2de --- /dev/null +++ b/crates/sqlclean-cli/src/caching/models.rs @@ -0,0 +1,105 @@ +//! The result cache of one command run. + +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use crate::caching::_helpers::entries::read_entries; +use crate::caching::constants::{ + CACHE_DIRECTORY, CACHE_FILE, CACHE_PARENT_IGNORE, CACHE_PARENT_IGNORE_TEXT, +}; + +/// One cached value and the file it describes, `None` for repository-wide results. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheEntry { + pub path: Option, + pub value: serde_json::Value, +} + +/// Cached results by key. A damaged or unreadable cache file is treated as empty. +#[derive(Debug, Default)] +pub struct ResultCache { + file: Option, + root: PathBuf, + entries: BTreeMap, + fresh: BTreeMap, +} + +impl ResultCache { + /// The cache under `root`, or a cache that stores nothing when `enabled` is false. + pub fn open(root: &Path, enabled: bool) -> Self { + if !enabled { + return Self::default(); + } + let file: PathBuf = root.join(CACHE_DIRECTORY).join(CACHE_FILE); + let entries: BTreeMap = read_entries(&file).unwrap_or_default(); + Self { + file: Some(file), + root: root.to_path_buf(), + entries, + fresh: BTreeMap::new(), + } + } + + pub fn get(&mut self, key: &str) -> Option { + self.file.as_ref()?; + let entry: CacheEntry = self.entries.get(key)?.clone(); + match serde_json::from_value(entry.value.clone()) { + Ok(value) => { + self.fresh.insert(key.to_owned(), entry); + Some(value) + } + Err(_) => None, + } + } + + pub fn put(&mut self, key: &str, path: Option<&str>, value: &T) { + if self.file.is_none() { + return; + } + let value: serde_json::Value = match serde_json::to_value(value) { + Ok(value) => value, + Err(_) => return, + }; + self.fresh.insert( + key.to_owned(), + CacheEntry { + path: path.map(ToOwned::to_owned), + value, + }, + ); + } + + /// Keep this run's entries and earlier entries for files this run did not read. + pub fn save(self, read_paths: &HashSet) -> Result<(), String> { + let Some(file) = self.file else { + return Ok(()); + }; + let mut kept: BTreeMap = self + .entries + .into_iter() + .filter(|(_, entry)| { + entry + .path + .as_ref() + .is_some_and(|path| !read_paths.contains(path)) + }) + .collect(); + kept.extend(self.fresh); + let bytes: Vec = serde_json::to_vec(&kept).map_err(|error| error.to_string())?; + let directory: &Path = file.parent().unwrap_or(&self.root); + let failed = |error: std::io::Error| format!("{}: {error}", directory.display()); + fs::create_dir_all(directory).map_err(failed)?; + fs::write( + self.root.join(CACHE_PARENT_IGNORE), + CACHE_PARENT_IGNORE_TEXT, + ) + .map_err(failed)?; + let staging: PathBuf = file.with_extension("tmp"); + fs::write(&staging, bytes).map_err(failed)?; + fs::rename(&staging, &file).map_err(failed) + } +} diff --git a/crates/sqlclean-cli/src/caching/tests.rs b/crates/sqlclean-cli/src/caching/tests.rs new file mode 100644 index 0000000..95523ad --- /dev/null +++ b/crates/sqlclean-cli/src/caching/tests.rs @@ -0,0 +1,4 @@ +#[path = "tests/test_result_cache.rs"] +mod result_cache; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlclean-cli/src/caching/tests/test_result_cache.rs b/crates/sqlclean-cli/src/caching/tests/test_result_cache.rs new file mode 100644 index 0000000..1041313 --- /dev/null +++ b/crates/sqlclean-cli/src/caching/tests/test_result_cache.rs @@ -0,0 +1,128 @@ +use std::collections::HashSet; +use std::fs; + +use crate::caching::main::cache_key::cache_key; +use crate::caching::models::ResultCache; +use crate::caching::tests::test_types::{CacheKeyTestCase, CacheRunTestCase}; + +const SEEDED: &str = r#"{"a":{"path":"a.sql","value":1},"b":{"path":"b.sql","value":2},"repo":{"path":null,"value":3}}"#; + +#[test] +fn given_a_cache_run_when_reopening_then_only_entries_for_unread_or_rewritten_files_remain() +-> Result<(), String> { + let test_cases = [ + CacheRunTestCase { + description: "a fresh cache keeps what it stored", + existing_cache: "", + enabled: true, + puts: &[("a", "a.sql", 1), ("b", "b.sql", 2)], + read_paths: &["a.sql", "b.sql"], + gets: &[], + expected_file_rewritten: true, + expected_reopened: &[("a", Some(1)), ("b", Some(2))], + }, + CacheRunTestCase { + description: "an entry read again survives and an unread file keeps its entry", + existing_cache: SEEDED, + enabled: true, + puts: &[], + read_paths: &["a.sql"], + gets: &["a"], + expected_file_rewritten: true, + expected_reopened: &[("a", Some(1)), ("b", Some(2)), ("repo", None)], + }, + CacheRunTestCase { + description: "a read file whose entry was not used is dropped", + existing_cache: SEEDED, + enabled: true, + puts: &[], + read_paths: &["a.sql", "b.sql"], + gets: &["b"], + expected_file_rewritten: true, + expected_reopened: &[("a", None), ("b", Some(2))], + }, + CacheRunTestCase { + description: "a damaged cache file reads as empty", + existing_cache: "{not json", + enabled: true, + puts: &[], + read_paths: &[], + gets: &["a"], + expected_file_rewritten: true, + expected_reopened: &[("a", None)], + }, + CacheRunTestCase { + description: "a disabled cache neither stores nor writes", + existing_cache: "", + enabled: false, + puts: &[("a", "a.sql", 1)], + read_paths: &["a.sql"], + gets: &["a"], + expected_file_rewritten: false, + expected_reopened: &[("a", None)], + }, + ]; + for test_case in &test_cases { + let directory = tempfile::tempdir().map_err(|error| error.to_string())?; + let cache_file = directory.path().join(".sqlclean/cache/results-v1.json"); + fs::create_dir_all(directory.path().join(".sqlclean/cache")) + .map_err(|error| error.to_string())?; + fs::write(&cache_file, test_case.existing_cache).map_err(|error| error.to_string())?; + let mut cache = ResultCache::open(directory.path(), test_case.enabled); + for key in test_case.gets { + let _value: Option = cache.get(key); + } + for (key, path, value) in test_case.puts { + cache.put(key, Some(path), value); + } + let read: HashSet = test_case + .read_paths + .iter() + .map(|path| (*path).to_owned()) + .collect(); + cache.save(&read)?; + let stored: String = fs::read_to_string(&cache_file).map_err(|error| error.to_string())?; + assert_eq!( + stored != test_case.existing_cache, + test_case.expected_file_rewritten, + "{}", + test_case.description + ); + let mut reopened = ResultCache::open(directory.path(), true); + for (key, expected) in test_case.expected_reopened { + assert_eq!( + reopened.get::(key), + *expected, + "{}: {key}", + test_case.description + ); + } + } + Ok(()) +} + +#[test] +fn given_key_parts_when_hashing_then_part_boundaries_change_the_key() { + let test_cases = [ + CacheKeyTestCase { + description: "moving a character between parts changes the key", + left: &["a", "bc"], + right: &["ab", "c"], + expected_equal: false, + }, + CacheKeyTestCase { + description: "the same parts give the same key", + left: &["a", "bc"], + right: &["a", "bc"], + expected_equal: true, + }, + ]; + for test_case in &test_cases { + assert_eq!( + cache_key(test_case.left) == cache_key(test_case.right), + test_case.expected_equal, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlclean-cli/src/caching/tests/test_types.rs b/crates/sqlclean-cli/src/caching/tests/test_types.rs new file mode 100644 index 0000000..25e6725 --- /dev/null +++ b/crates/sqlclean-cli/src/caching/tests/test_types.rs @@ -0,0 +1,17 @@ +pub(crate) struct CacheRunTestCase { + pub(crate) description: &'static str, + pub(crate) existing_cache: &'static str, + pub(crate) enabled: bool, + pub(crate) puts: &'static [(&'static str, &'static str, i32)], + pub(crate) read_paths: &'static [&'static str], + pub(crate) gets: &'static [&'static str], + pub(crate) expected_file_rewritten: bool, + pub(crate) expected_reopened: &'static [(&'static str, Option)], +} + +pub(crate) struct CacheKeyTestCase { + pub(crate) description: &'static str, + pub(crate) left: &'static [&'static str], + pub(crate) right: &'static [&'static str], + pub(crate) expected_equal: bool, +} diff --git a/crates/sqlclean-cli/src/checking/_helpers/builtin.rs b/crates/sqlclean-cli/src/checking/_helpers/builtin.rs new file mode 100644 index 0000000..fa51aae --- /dev/null +++ b/crates/sqlclean-cli/src/checking/_helpers/builtin.rs @@ -0,0 +1,88 @@ +//! The built-in Rules on one file, through the engine. + +use std::collections::BTreeSet; + +use sqlclean::sql_lint::constants::LINT_API_VERSION; +use sqlclean::sql_lint::main::fill_finding_path::fill_finding_path; +use sqlclean::sql_lint::main::lint::lint; +use sqlclean::sql_lint::models::{LintDiagnostic, LintRequest}; + +use crate::checking::constants::{ + PARSE_ERROR_CODE, PARSE_ERROR_MESSAGE, PARSE_ERROR_REMEDIATION, UNSAFE_FIX_BUILTIN_RULES, +}; +use crate::checking::models::{Finding, FixSafety}; +use crate::config::constants::CONFIG_FILE_NAME; +use crate::config::models::Thresholds; +use crate::discovery::models::SourceFile; +use crate::positions::models::Edit; + +/// The built-in findings of `file`, or one parse-error finding when the engine rejects it. +pub(crate) fn builtin_findings( + file: &SourceFile, + codes: &BTreeSet, + thresholds: Thresholds, +) -> Vec { + let request = LintRequest { + version: LINT_API_VERSION, + sql: file.text.clone(), + dialect: file.dialect.clone(), + enabled_rules: Some(codes.iter().cloned().collect()), + max_literal_length: Some(thresholds.max_literal_length), + max_ranking_order_by: Some(thresholds.max_ranking_order_by), + config_file: Some(CONFIG_FILE_NAME.to_owned()), + ..LintRequest::default() + }; + match lint(request) { + Ok(response) => response + .diagnostics + .into_iter() + .map(|diagnostic| finding(file, diagnostic)) + .collect(), + Err(error) => vec![parse_error(file, &error)], + } +} + +/// The finding for a file the parser rejects. +pub(crate) fn parse_error(file: &SourceFile, error: &str) -> Finding { + Finding { + code: PARSE_ERROR_CODE.to_owned(), + path: file.path.clone(), + start: 0, + end: 0, + message: format!("{PARSE_ERROR_MESSAGE} as {}: {error}", file.dialect), + remediation: PARSE_ERROR_REMEDIATION.to_owned(), + edits: Vec::new(), + fix: None, + } +} + +fn finding(file: &SourceFile, diagnostic: LintDiagnostic) -> Finding { + let edits: Vec = diagnostic + .fix + .into_iter() + .map(|edit| Edit { + start: edit.start, + end: edit.end, + text: edit.replacement, + }) + .collect(); + Finding { + code: diagnostic.code.to_owned(), + path: file.path.clone(), + start: diagnostic.start, + end: diagnostic.end, + message: diagnostic.message.into_owned(), + remediation: fill_finding_path(&diagnostic.remediation, &file.path), + fix: (!edits.is_empty()).then(|| builtin_fix_safety(diagnostic.code)), + edits, + } +} + +/// How safe the fixes of the built-in Rule `code` are. +pub(crate) fn builtin_fix_safety(code: &str) -> FixSafety { + if UNSAFE_FIX_BUILTIN_RULES.contains(&code) { + FixSafety::Unsafe + } else { + FixSafety::Safe + } +} diff --git a/crates/sqlclean-cli/src/checking/_helpers/catalogue.rs b/crates/sqlclean-cli/src/checking/_helpers/catalogue.rs new file mode 100644 index 0000000..a2b60ee --- /dev/null +++ b/crates/sqlclean-cli/src/checking/_helpers/catalogue.rs @@ -0,0 +1,109 @@ +//! The Rules a configuration can select: the built-ins and its custom Rules. + +use std::fs; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; +use sqlclean::sql_lint::main::catalogue::catalogue; +use sqlclean::sql_lint::main::default_rules::default_rules; + +use crate::caching::main::cache_key::cache_key; +use crate::caching::models::ResultCache; +use crate::checking::_helpers::builtin::builtin_fix_safety; +use crate::checking::constants::FIXABLE_BUILTIN_RULES; +use crate::checking::models::{RuleInfo, RuleSubject}; +use crate::config::models::Config; +use crate::hosting::constants::CATALOGUE_MODE; +use crate::hosting::main::run_host::run_host; +use crate::hosting::models::HostRequest; + +const CATALOGUE_KEY: &str = "custom-catalogue"; +const PYTHON_SUFFIX: &str = "py"; +const PYTHON_CACHE_DIRECTORY: &str = "__pycache__"; + +pub(crate) fn builtin_rules() -> Vec { + let defaults: Vec<&str> = default_rules(); + catalogue() + .into_iter() + .map(|rule| RuleInfo { + code: rule.code.to_owned(), + message: rule.message.to_owned(), + remediation: rule.remediation.to_owned(), + enabled_by_default: defaults.contains(&rule.code), + custom: false, + subject: RuleSubject::Statement, + fixable: FIXABLE_BUILTIN_RULES + .contains(&rule.code) + .then(|| builtin_fix_safety(rule.code)), + module: None, + }) + .collect() +} + +/// A digest of every Python file under the configured custom Rule paths. +pub(crate) fn custom_sources_digest(config: &Config) -> Result { + let mut files: Vec = Vec::new(); + for entry in &config.rules.custom { + let path: PathBuf = config.root.join(entry); + if !path.exists() { + return Err(format!("[rules] custom path does not exist: {entry}")); + } + files.extend(python_files(&path)?); + } + files.sort(); + let mut digest = Sha256::new(); + for file in files { + let text: Vec = + fs::read(&file).map_err(|error| format!("{}: {error}", file.display()))?; + digest.update(file.to_string_lossy().as_bytes()); + digest.update(text.len().to_string().as_bytes()); + digest.update(&text); + } + Ok(format!("{:x}", digest.finalize())) +} + +/// The custom Rules, from the cache while their sources are unchanged, else from the host. +pub(crate) fn custom_rules( + config: &Config, + digest: &str, + cache: &mut ResultCache, +) -> Result, String> { + let key: String = cache_key(&[CATALOGUE_KEY, digest, &config.rules.custom.join("\n")]); + if let Some(rules) = cache.get::>(&key) { + return Ok(rules); + } + let response = run_host(&HostRequest { + mode: CATALOGUE_MODE.to_owned(), + root: config.root.to_string_lossy().into_owned(), + custom: config.rules.custom.clone(), + options: Default::default(), + files: Vec::new(), + repository_rules: Vec::new(), + })?; + cache.put(&key, None, &response.rules); + Ok(response.rules) +} + +/// The Python files at `path`, or under it when it is a directory. +fn python_files(path: &Path) -> Result, String> { + if path.is_file() { + let is_python: bool = path + .extension() + .is_some_and(|suffix| suffix == PYTHON_SUFFIX); + return Ok(if is_python { + vec![path.to_path_buf()] + } else { + Vec::new() + }); + } + let mut files: Vec = Vec::new(); + let entries = fs::read_dir(path).map_err(|error| format!("{}: {error}", path.display()))?; + for entry in entries { + let entry = entry.map_err(|error| error.to_string())?; + let name: String = entry.file_name().to_string_lossy().into_owned(); + if !name.starts_with('.') && name != PYTHON_CACHE_DIRECTORY { + files.extend(python_files(&entry.path())?); + } + } + Ok(files) +} diff --git a/crates/sqlclean-cli/src/checking/_helpers/custom.rs b/crates/sqlclean-cli/src/checking/_helpers/custom.rs new file mode 100644 index 0000000..e3b881e --- /dev/null +++ b/crates/sqlclean-cli/src/checking/_helpers/custom.rs @@ -0,0 +1,239 @@ +//! Custom Rules: cached per file, Rule and options, with the host started only for misses. + +use std::collections::{BTreeMap, BTreeSet}; + +use rayon::iter::{IntoParallelIterator, ParallelIterator}; +use sha2::{Digest, Sha256}; +use sqlclean::sql_facts::main::file_facts::file_facts; + +use crate::caching::main::cache_key::cache_key; +use crate::caching::models::ResultCache; +use crate::checking::models::{Finding, FixSafety, RuleInfo, RuleSubject}; +use crate::config::models::Config; +use crate::discovery::models::SourceFile; +use crate::hosting::constants::EVALUATE_MODE; +use crate::hosting::main::run_host::run_host; +use crate::hosting::models::{HostFile, HostFinding, HostRequest, HostResponse, HostResult}; + +const FILE_RESULT_KEY: &str = "custom-file"; +const REPOSITORY_RESULT_KEY: &str = "custom-repository"; +/// The fewest files worth starting another host process for. +const MIN_FILES_PER_HOST: usize = 250; + +/// What custom evaluation needs besides the files. +pub(crate) struct CustomEvaluation<'a> { + pub(crate) config: &'a Config, + pub(crate) rules: Vec<&'a RuleInfo>, + pub(crate) sources_digest: &'a str, +} + +/// The findings of the selected custom Rules on `files`, which must all parse. +pub(crate) fn custom_findings( + evaluation: &CustomEvaluation<'_>, + files: &[&SourceFile], + cache: &mut ResultCache, +) -> Result, String> { + let options: BTreeMap = rule_options(evaluation)?; + let (file_rules, repository_rules): (Vec<&RuleInfo>, Vec<&RuleInfo>) = evaluation + .rules + .iter() + .partition(|rule| rule.subject != RuleSubject::Repository); + let mut findings: Vec = Vec::new(); + let mut misses: Vec<(String, Option, String)> = Vec::new(); + let mut pending: Vec<(&SourceFile, Vec)> = Vec::new(); + for file in files { + let mut missing: Vec = Vec::new(); + for rule in &file_rules { + let key: String = file_key(evaluation, rule, &options, file); + match cache.get::>(&key) { + Some(cached) => { + findings.extend(cached.into_iter().map(|found| finding(rule, found))) + } + None => { + missing.push(rule.code.clone()); + misses.push((key, Some(file.path.clone()), rule.code.clone())); + } + } + } + if !missing.is_empty() { + pending.push((file, missing)); + } + } + let repository_digest: String = repository_digest(files); + let mut missing_repository: Vec = Vec::new(); + for rule in &repository_rules { + let key: String = cache_key(&[ + REPOSITORY_RESULT_KEY, + evaluation.sources_digest, + &rule.code, + &options_text(&options, &rule.code), + &repository_digest, + ]); + match cache.get::>(&key) { + Some(cached) => findings.extend(cached.into_iter().map(|found| finding(rule, found))), + None => { + missing_repository.push(rule.code.clone()); + misses.push((key, None, rule.code.clone())); + } + } + } + if misses.is_empty() { + return Ok(findings); + } + let mut host_files: Vec = pending + .into_par_iter() + .map(|(file, missing)| host_file(file, missing)) + .collect::>()?; + if !missing_repository.is_empty() { + host_files = with_every_file(files, host_files)?; + } + let results: Vec = + evaluate_on_hosts(evaluation, options, host_files, missing_repository)?; + let mut by_subject: BTreeMap<(Option, String), Vec> = BTreeMap::new(); + for result in results { + by_subject.insert((result.path, result.code), result.findings); + } + for (key, path, code) in misses { + let found: Vec = by_subject + .remove(&(path.clone(), code.clone())) + .unwrap_or_default(); + cache.put(&key, path.as_deref(), &found); + if let Some(rule) = evaluation.rules.iter().find(|rule| rule.code == code) { + findings.extend(found.into_iter().map(|item| finding(rule, item))); + } + } + Ok(findings) +} + +/// Evaluate on one host per share of the files; repository Rules need every file in one host. +fn evaluate_on_hosts( + evaluation: &CustomEvaluation<'_>, + options: BTreeMap, + host_files: Vec, + repository_rules: Vec, +) -> Result, String> { + let request = |files: Vec, repository_rules: Vec| HostRequest { + mode: EVALUATE_MODE.to_owned(), + root: evaluation.config.root.to_string_lossy().into_owned(), + custom: evaluation.config.rules.custom.clone(), + options: options.clone(), + files, + repository_rules, + }; + let hosts: usize = + (host_files.len() / MIN_FILES_PER_HOST).clamp(1, rayon::current_num_threads()); + if hosts == 1 || !repository_rules.is_empty() { + return Ok(run_host(&request(host_files, repository_rules))?.results); + } + let share: usize = host_files.len().div_ceil(hosts); + let mut shares: Vec> = Vec::with_capacity(hosts); + let mut rest: Vec = host_files; + while rest.len() > share { + let tail: Vec = rest.split_off(share); + shares.push(rest); + rest = tail; + } + shares.push(rest); + let responses: Vec> = shares + .into_par_iter() + .map(|files| run_host(&request(files, Vec::new()))) + .collect(); + let mut results: Vec = Vec::new(); + for response in responses { + results.extend(response?.results); + } + Ok(results) +} + +fn rule_options( + evaluation: &CustomEvaluation<'_>, +) -> Result, String> { + let codes: BTreeSet<&str> = evaluation + .rules + .iter() + .map(|rule| rule.code.as_str()) + .collect(); + let mut options: BTreeMap = BTreeMap::new(); + for (code, table) in &evaluation.config.rules.rule_options { + if codes.contains(code.as_str()) { + let value = serde_json::to_value(table) + .map_err(|error| format!("[rules.rule_options.{code}]: {error}"))?; + options.insert(code.clone(), value); + } + } + Ok(options) +} + +fn options_text(options: &BTreeMap, code: &str) -> String { + options + .get(code) + .map(ToString::to_string) + .unwrap_or_default() +} + +fn file_key( + evaluation: &CustomEvaluation<'_>, + rule: &RuleInfo, + options: &BTreeMap, + file: &SourceFile, +) -> String { + cache_key(&[ + FILE_RESULT_KEY, + evaluation.sources_digest, + &rule.code, + &options_text(options, &rule.code), + &file.path, + &file.dialect, + &file.text, + ]) +} + +fn host_file(file: &SourceFile, rules: Vec) -> Result { + Ok(HostFile { + path: file.path.clone(), + source: file.text.clone(), + dialect: file.dialect.clone(), + facts: file_facts(&file.text, &file.dialect)?, + rules, + }) +} + +/// A repository Rule reads every file, including those whose own Rules were all cached. +fn with_every_file( + files: &[&SourceFile], + mut host_files: Vec, +) -> Result, String> { + for file in files { + if !host_files.iter().any(|host| host.path == file.path) { + host_files.push(host_file(file, Vec::new())?); + } + } + host_files.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(host_files) +} + +fn repository_digest(files: &[&SourceFile]) -> String { + let mut digest = Sha256::new(); + for file in files { + for part in [&file.path, &file.dialect, &file.text] { + digest.update(part.len().to_string().as_bytes()); + digest.update(part.as_bytes()); + } + } + format!("{:x}", digest.finalize()) +} + +fn finding(rule: &RuleInfo, found: HostFinding) -> Finding { + let fix: Option = + (!found.edits.is_empty()).then(|| rule.fixable.unwrap_or(FixSafety::Safe)); + Finding { + code: rule.code.clone(), + path: found.path, + start: found.start, + end: found.end, + message: found.message, + remediation: found.remediation, + edits: found.edits, + fix, + } +} diff --git a/crates/sqlclean-cli/src/checking/_helpers/exemptions.rs b/crates/sqlclean-cli/src/checking/_helpers/exemptions.rs new file mode 100644 index 0000000..218b1ac --- /dev/null +++ b/crates/sqlclean-cli/src/checking/_helpers/exemptions.rs @@ -0,0 +1,97 @@ +//! Configured path ignores and reviewed exceptions, and exceptions that no longer match. + +use std::collections::{BTreeSet, HashSet}; + +use globset::GlobSet; +use sqlclean::sql_lint::main::cross_join_subject::cross_join_subject; + +use crate::checking::constants::{CROSS_JOIN_CODE, PARSE_ERROR_CODE}; +use crate::checking::models::{Finding, StaleException}; +use crate::config::models::{RuleException, RulesConfig}; +use crate::discovery::main::path_patterns::path_patterns; + +/// Findings left after ignores and exceptions, and the exceptions this check proves stale. +pub(crate) fn apply_exemptions( + findings: Vec, + rules: &RulesConfig, + checked_paths: &HashSet, + selected: &BTreeSet, +) -> Result<(Vec, Vec), String> { + let mut ignores: Vec<(GlobSet, &[String])> = Vec::new(); + for ignore in &rules.rule_ignores { + ignores.push((path_patterns(&ignore.paths)?, ignore.rules.as_slice())); + } + let mut used: HashSet = HashSet::new(); + let kept: Vec = findings + .into_iter() + .filter(|finding| finding.code == PARSE_ERROR_CODE || !is_ignored(finding, &ignores)) + .filter( + |finding| match exception_for(finding, &rules.rule_exceptions) { + Some(index) => { + used.insert(index); + false + } + None => true, + }, + ) + .collect(); + let stale: Vec = rules + .rule_exceptions + .iter() + .enumerate() + .filter(|(index, exception)| { + !used.contains(index) + && checked_paths.contains(&exception.path) + && selected.contains(&exception.rule) + }) + .map(|(_, exception)| StaleException { + rule: exception.rule.clone(), + path: exception.path.clone(), + relation: exception.relation.clone(), + }) + .collect(); + Ok((kept, stale)) +} + +fn is_ignored(finding: &Finding, ignores: &[(GlobSet, &[String])]) -> bool { + ignores + .iter() + .any(|(paths, codes)| paths.is_match(&finding.path) && selects(codes, &finding.code)) +} + +fn selects(selectors: &[String], code: &str) -> bool { + selectors + .iter() + .any(|selector| code.starts_with(selector.as_str())) +} + +/// The index of the exception that covers `finding`; parse errors are never excepted. +fn exception_for(finding: &Finding, exceptions: &[RuleException]) -> Option { + if finding.code == PARSE_ERROR_CODE { + return None; + } + exceptions + .iter() + .position(|exception| covers(exception, finding)) +} + +fn covers(exception: &RuleException, finding: &Finding) -> bool { + if exception.rule != finding.code || exception.path != finding.path { + return false; + } + let Some(relation) = &exception.relation else { + return true; + }; + if finding.code != CROSS_JOIN_CODE { + return false; + } + let Some((joined, cte)) = cross_join_subject(&finding.message) else { + return false; + }; + let cte_matches: bool = match (&exception.cte, cte) { + (None, _) => true, + (Some(expected), Some(cte)) => cte.eq_ignore_ascii_case(expected), + (Some(_), None) => false, + }; + joined.eq_ignore_ascii_case(relation) && cte_matches +} diff --git a/crates/sqlclean-cli/src/checking/_helpers/mod.rs b/crates/sqlclean-cli/src/checking/_helpers/mod.rs new file mode 100644 index 0000000..d9cb031 --- /dev/null +++ b/crates/sqlclean-cli/src/checking/_helpers/mod.rs @@ -0,0 +1,6 @@ +pub mod builtin; +pub mod catalogue; +pub mod custom; +pub mod exemptions; +pub mod orchestration; +pub mod selection; diff --git a/crates/sqlclean-cli/src/checking/_helpers/orchestration.rs b/crates/sqlclean-cli/src/checking/_helpers/orchestration.rs new file mode 100644 index 0000000..6fc3a19 --- /dev/null +++ b/crates/sqlclean-cli/src/checking/_helpers/orchestration.rs @@ -0,0 +1,179 @@ +//! One check: select Rules, run the built-ins, then custom Rules on files that parse. + +use std::collections::{BTreeSet, HashSet}; + +use rayon::iter::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator}; + +use crate::caching::main::cache_key::cache_key; +use crate::caching::models::ResultCache; +use crate::checking::_helpers::builtin::builtin_findings; +use crate::checking::_helpers::catalogue::{builtin_rules, custom_rules, custom_sources_digest}; +use crate::checking::_helpers::custom::{CustomEvaluation, custom_findings}; +use crate::checking::_helpers::exemptions::apply_exemptions; +use crate::checking::_helpers::selection::selected_codes; +use crate::checking::constants::{CUSTOM_RULE_PREFIX, PARSE_ERROR_CODE}; +use crate::checking::models::{CheckOptions, CheckOutcome, Finding, RuleInfo}; +use crate::config::models::Config; +use crate::discovery::models::SourceFile; + +const BUILTIN_RESULT_KEY: &str = "builtin"; + +pub(crate) fn check_sources( + config: &Config, + files: &[SourceFile], + options: &CheckOptions, +) -> Result { + let mut cache = ResultCache::open(&config.root, options.use_cache); + let select: Option<&[String]> = options.select.as_deref().or(config.rules.select.as_deref()); + let builtins: Vec = builtin_rules(); + let needs_custom: bool = + !config.rules.custom.is_empty() && select.is_none_or(selects_custom_rules); + let digest: String = if needs_custom { + custom_sources_digest(config)? + } else { + String::new() + }; + let custom: Vec = if needs_custom { + custom_rules(config, &digest, &mut cache)? + } else { + Vec::new() + }; + validate_rule_options(config, &builtins, needs_custom.then_some(custom.as_slice()))?; + let all: Vec = builtins.iter().chain(&custom).cloned().collect(); + let ignore: Vec = config + .rules + .ignore + .iter() + .chain(&options.ignore) + .filter(|selector| needs_custom || !selector.starts_with('X')) + .cloned() + .collect(); + let selected: BTreeSet = selected_codes(&all, select, &ignore)?; + let (mut findings, updated) = run_builtins( + &BuiltinRun { + config, + files, + builtins: &builtins, + selected: &selected, + }, + cache, + ); + let mut cache: ResultCache = updated; + let unparsed: HashSet<&str> = findings + .iter() + .filter(|finding| finding.code == PARSE_ERROR_CODE) + .map(|finding| finding.path.as_str()) + .collect(); + let parsed: Vec<&SourceFile> = files + .iter() + .filter(|file| !unparsed.contains(file.path.as_str())) + .collect(); + let custom_selected: Vec<&RuleInfo> = custom + .iter() + .filter(|rule| selected.contains(&rule.code)) + .collect(); + if !custom_selected.is_empty() { + let evaluation = CustomEvaluation { + config, + rules: custom_selected, + sources_digest: &digest, + }; + findings.extend(custom_findings(&evaluation, &parsed, &mut cache)?); + } + let checked: HashSet = files.iter().map(|file| file.path.clone()).collect(); + let (mut kept, stale_exceptions) = + apply_exemptions(findings, &config.rules, &checked, &selected)?; + kept.sort_by(|left, right| { + (&left.path, left.start, left.end, &left.code).cmp(&( + &right.path, + right.start, + right.end, + &right.code, + )) + }); + cache + .save(&checked) + .map_err(|error| format!("could not write the result cache (use --no-cache): {error}"))?; + Ok(CheckOutcome { + findings: kept, + stale_exceptions, + }) +} + +/// Whether a selection can match custom Rules: an `XSQC` code or a prefix of `XSQC`. +fn selects_custom_rules(selectors: &[String]) -> bool { + selectors.iter().any(|selector| { + selector.starts_with(CUSTOM_RULE_PREFIX) + || CUSTOM_RULE_PREFIX.starts_with(selector.as_str()) + }) +} + +/// The inputs of one built-in Rule run. +struct BuiltinRun<'a> { + config: &'a Config, + files: &'a [SourceFile], + builtins: &'a [RuleInfo], + selected: &'a BTreeSet, +} + +fn run_builtins(run: &BuiltinRun<'_>, mut cache: ResultCache) -> (Vec, ResultCache) { + let codes: BTreeSet = run + .builtins + .iter() + .filter(|rule| run.selected.contains(&rule.code)) + .map(|rule| rule.code.clone()) + .collect(); + let selection: String = codes.iter().cloned().collect::>().join(","); + let thresholds = run.config.rules.thresholds; + let limits: String = format!( + "{}:{}", + thresholds.max_literal_length, thresholds.max_ranking_order_by + ); + let keys: Vec = run + .files + .iter() + .map(|file| { + cache_key(&[ + BUILTIN_RESULT_KEY, + &selection, + &limits, + &file.path, + &file.dialect, + &file.text, + ]) + }) + .collect(); + let cached: Vec>> = keys.iter().map(|key| cache.get(key)).collect(); + let computed: Vec> = run + .files + .par_iter() + .zip(cached) + .map(|(file, cached)| cached.unwrap_or_else(|| builtin_findings(file, &codes, thresholds))) + .collect(); + for ((file, key), found) in run.files.iter().zip(&keys).zip(&computed) { + cache.put(key, Some(&file.path), found); + } + (computed.into_iter().flatten().collect(), cache) +} + +fn validate_rule_options( + config: &Config, + builtins: &[RuleInfo], + custom: Option<&[RuleInfo]>, +) -> Result<(), String> { + for code in config.rules.rule_options.keys() { + if builtins.iter().any(|rule| &rule.code == code) { + return Err(format!( + "[rules.rule_options.{code}]: built-in Rules take no options; set limits in [rules.thresholds]" + )); + } + if let Some(custom) = custom + && !custom.iter().any(|rule| &rule.code == code) + { + return Err(format!( + "[rules.rule_options.{code}]: no custom Rule has this code" + )); + } + } + Ok(()) +} diff --git a/crates/sqlclean-cli/src/checking/_helpers/selection.rs b/crates/sqlclean-cli/src/checking/_helpers/selection.rs new file mode 100644 index 0000000..04d4523 --- /dev/null +++ b/crates/sqlclean-cli/src/checking/_helpers/selection.rs @@ -0,0 +1,39 @@ +//! Resolve `select` and `ignore` selectors, exact codes or family prefixes, to Rule codes. + +use std::collections::BTreeSet; + +use crate::checking::models::RuleInfo; + +pub(crate) fn selected_codes( + rules: &[RuleInfo], + select: Option<&[String]>, + ignore: &[String], +) -> Result, String> { + let mut selected: BTreeSet = match select { + None => rules + .iter() + .filter(|rule| rule.enabled_by_default) + .map(|rule| rule.code.clone()) + .collect(), + Some(selectors) => expand(rules, selectors)?, + }; + for code in expand(rules, ignore)? { + selected.remove(&code); + } + Ok(selected) +} + +fn expand(rules: &[RuleInfo], selectors: &[String]) -> Result, String> { + let mut codes: BTreeSet = BTreeSet::new(); + for selector in selectors { + let matches: Vec<&RuleInfo> = rules + .iter() + .filter(|rule| rule.code.starts_with(selector.as_str())) + .collect(); + if matches.is_empty() { + return Err(format!("Rule selector '{selector}' matches no Rule")); + } + codes.extend(matches.into_iter().map(|rule| rule.code.clone())); + } + Ok(codes) +} diff --git a/crates/sqlclean-cli/src/checking/constants.rs b/crates/sqlclean-cli/src/checking/constants.rs new file mode 100644 index 0000000..d0cb12f --- /dev/null +++ b/crates/sqlclean-cli/src/checking/constants.rs @@ -0,0 +1,15 @@ +/// The finding for a file the parser rejects; it cannot be deselected or excepted. +pub const PARSE_ERROR_CODE: &str = "SQC999"; +pub(crate) const PARSE_ERROR_MESSAGE: &str = "SQL could not be parsed"; +pub(crate) const PARSE_ERROR_REMEDIATION: &str = + "Fix the syntax error, or set this file's dialect in sqlclean.toml."; +pub(crate) const CROSS_JOIN_CODE: &str = "SQC038"; +/// Custom Rule codes start with this prefix. +pub const CUSTOM_RULE_PREFIX: &str = "XSQC"; +/// Built-in Rules whose findings can carry a fix. +pub(crate) const FIXABLE_BUILTIN_RULES: [&str; 10] = [ + "SQC001", "SQC002", "SQC003", "SQC005", "SQC006", "SQC008", "SQC013", "SQC017", "SQC025", + "SQC030", +]; +/// Built-in Rules whose fix can change query results, so `--unsafe-fixes` must opt in. +pub(crate) const UNSAFE_FIX_BUILTIN_RULES: [&str; 1] = ["SQC001"]; diff --git a/crates/sqlclean-cli/src/checking/main/check_sources.rs b/crates/sqlclean-cli/src/checking/main/check_sources.rs new file mode 100644 index 0000000..a02c603 --- /dev/null +++ b/crates/sqlclean-cli/src/checking/main/check_sources.rs @@ -0,0 +1,14 @@ +//! Check SQL files against the configured Rules. + +use crate::checking::models::{CheckOptions, CheckOutcome}; +use crate::config::models::Config; +use crate::discovery::models::SourceFile; + +/// The findings of the selected Rules on `files`, after ignores and exceptions. +pub fn check_sources( + config: &Config, + files: &[SourceFile], + options: &CheckOptions, +) -> Result { + crate::checking::_helpers::orchestration::check_sources(config, files, options) +} diff --git a/crates/sqlclean-cli/src/checking/main/mod.rs b/crates/sqlclean-cli/src/checking/main/mod.rs new file mode 100644 index 0000000..5c960b7 --- /dev/null +++ b/crates/sqlclean-cli/src/checking/main/mod.rs @@ -0,0 +1,2 @@ +pub mod check_sources; +pub mod rule_catalogue; diff --git a/crates/sqlclean-cli/src/checking/main/rule_catalogue.rs b/crates/sqlclean-cli/src/checking/main/rule_catalogue.rs new file mode 100644 index 0000000..29fcb6b --- /dev/null +++ b/crates/sqlclean-cli/src/checking/main/rule_catalogue.rs @@ -0,0 +1,18 @@ +//! Every Rule a configuration can select, for listing and inspection. + +use crate::caching::models::ResultCache; +use crate::checking::_helpers::catalogue::{builtin_rules, custom_rules, custom_sources_digest}; +use crate::checking::models::RuleInfo; +use crate::config::models::Config; + +/// The built-in Rules followed by the configuration's custom Rules. +pub fn rule_catalogue(config: &Config, use_cache: bool) -> Result, String> { + let mut rules: Vec = builtin_rules(); + if !config.rules.custom.is_empty() { + let mut cache = ResultCache::open(&config.root, use_cache); + let digest: String = custom_sources_digest(config)?; + rules.extend(custom_rules(config, &digest, &mut cache)?); + cache.save(&Default::default())?; + } + Ok(rules) +} diff --git a/crates/sqlclean-cli/src/checking/mod.rs b/crates/sqlclean-cli/src/checking/mod.rs new file mode 100644 index 0000000..2f922c4 --- /dev/null +++ b/crates/sqlclean-cli/src/checking/mod.rs @@ -0,0 +1,6 @@ +//! Evaluate the selected built-in and custom Rules over SQL files. + +pub mod _helpers; +pub mod constants; +pub mod main; +pub mod models; diff --git a/crates/sqlclean-cli/src/checking/models.rs b/crates/sqlclean-cli/src/checking/models.rs new file mode 100644 index 0000000..85b95b5 --- /dev/null +++ b/crates/sqlclean-cli/src/checking/models.rs @@ -0,0 +1,77 @@ +//! Findings, Rule metadata and the result of one check. + +use serde::{Deserialize, Serialize}; + +use crate::positions::models::Edit; + +/// Whether a fix keeps the query's meaning (`safe`) or may change it (`unsafe`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FixSafety { + Safe, + Unsafe, +} + +/// One Rule violation, located by character offsets in its file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Finding { + pub code: String, + pub path: String, + pub start: usize, + pub end: usize, + pub message: String, + pub remediation: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub edits: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fix: Option, +} + +/// The subject a Rule evaluates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RuleSubject { + Statement, + File, + Repository, +} + +/// One built-in or custom Rule. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuleInfo { + pub code: String, + pub message: String, + pub remediation: String, + pub enabled_by_default: bool, + pub custom: bool, + pub subject: RuleSubject, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fixable: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub module: Option, +} + +/// A configured exception that matched no finding. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct StaleException { + pub rule: String, + pub path: String, + pub relation: Option, +} + +/// Every finding of one check, sorted by path, position and code. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct CheckOutcome { + pub findings: Vec, + pub stale_exceptions: Vec, +} + +/// What a check evaluates beyond the configuration. +#[derive(Debug, Clone, Default)] +pub struct CheckOptions { + /// Replaces the configured `[rules] select` when given. + pub select: Option>, + /// Added to the configured `[rules] ignore`. + pub ignore: Vec, + pub use_cache: bool, +} diff --git a/crates/sqlclean-cli/src/command/_helpers/check_command.rs b/crates/sqlclean-cli/src/command/_helpers/check_command.rs new file mode 100644 index 0000000..25b6b36 --- /dev/null +++ b/crates/sqlclean-cli/src/command/_helpers/check_command.rs @@ -0,0 +1,33 @@ +//! `sqlclean check`. + +use std::collections::HashMap; +use std::io::Write; + +use crate::checking::main::check_sources::check_sources; +use crate::checking::models::CheckOptions; +use crate::command::constants::{EXIT_FINDINGS, EXIT_SUCCESS}; +use crate::command::models::{CheckArgs, SelectionArgs}; +use crate::config::models::Config; +use crate::discovery::main::discover_files::discover_files; +use crate::reporting::main::render_findings::render_findings; + +pub(crate) fn run(config: &Config, arguments: &CheckArgs) -> Result { + let files = discover_files(config, &arguments.selection.paths)?; + let outcome = check_sources(config, &files, &check_options(&arguments.selection))?; + let texts: HashMap = files + .into_iter() + .map(|file| (file.path, file.text)) + .collect(); + let report: String = render_findings(&outcome, &texts, arguments.output_format); + let _ = std::io::stdout().write_all(report.as_bytes()); + let clean: bool = outcome.findings.is_empty() && outcome.stale_exceptions.is_empty(); + Ok(if clean { EXIT_SUCCESS } else { EXIT_FINDINGS }) +} + +pub(crate) fn check_options(selection: &SelectionArgs) -> CheckOptions { + CheckOptions { + select: selection.select.clone(), + ignore: selection.ignore.clone(), + use_cache: !selection.no_cache, + } +} diff --git a/crates/sqlclean-cli/src/command/_helpers/diffs.rs b/crates/sqlclean-cli/src/command/_helpers/diffs.rs new file mode 100644 index 0000000..e4447fe --- /dev/null +++ b/crates/sqlclean-cli/src/command/_helpers/diffs.rs @@ -0,0 +1,25 @@ +//! Unified diffs and file writes for commands that change files. + +use std::fs; +use std::io::Write; + +use similar::TextDiff; + +use crate::discovery::models::SourceFile; + +const CONTEXT_LINES: usize = 3; + +pub(crate) fn print_diff(file: &SourceFile, changed: &str) { + let diff = TextDiff::from_lines(file.text.as_str(), changed); + let rendered: String = diff + .unified_diff() + .context_radius(CONTEXT_LINES) + .header(&format!("a/{}", file.path), &format!("b/{}", file.path)) + .to_string(); + let _ = std::io::stdout().write_all(rendered.as_bytes()); +} + +pub(crate) fn write_file(file: &SourceFile, changed: &str) -> Result<(), String> { + fs::write(&file.absolute, changed) + .map_err(|error| format!("could not write {}: {error}", file.path)) +} diff --git a/crates/sqlclean-cli/src/command/_helpers/dispatch.rs b/crates/sqlclean-cli/src/command/_helpers/dispatch.rs new file mode 100644 index 0000000..bdc1a0f --- /dev/null +++ b/crates/sqlclean-cli/src/command/_helpers/dispatch.rs @@ -0,0 +1,58 @@ +//! Argument parsing and the error boundary every subcommand shares. + +use std::env; +use std::ffi::OsString; +use std::io::Write; +use std::path::PathBuf; + +use clap::Parser; + +use crate::command::constants::{EXIT_ERROR, EXIT_SUCCESS}; +use crate::command::models::{Cli, Command}; +use crate::config::main::load_config::load_config; +use crate::config::models::Config; + +pub(crate) fn run(arguments: Vec) -> u8 { + let cli: Cli = match Cli::try_parse_from(arguments) { + Ok(cli) => cli, + Err(error) => { + let _ = error.print(); + return if error.use_stderr() { + EXIT_ERROR + } else { + EXIT_SUCCESS + }; + } + }; + match execute(cli) { + Ok(code) => code, + Err(error) => { + let _ = writeln!(std::io::stderr(), "sqlclean: error: {error}"); + EXIT_ERROR + } + } +} + +fn execute(cli: Cli) -> Result { + if let Command::Facts(arguments) = &cli.command { + return crate::command::_helpers::facts_command::run(arguments); + } + let start: PathBuf = env::current_dir().map_err(|error| error.to_string())?; + let config: Config = load_config(cli.config.as_deref(), &start)?; + match cli.command { + Command::Check(arguments) => { + crate::command::_helpers::check_command::run(&config, &arguments) + } + Command::Fix(arguments) => crate::command::_helpers::fix_command::run(&config, &arguments), + Command::Format(arguments) => { + crate::command::_helpers::format_command::run(&config, &arguments) + } + Command::Rules(arguments) => { + crate::command::_helpers::rules_command::run(&config, &arguments) + } + Command::Skills(arguments) => { + crate::command::_helpers::skills_command::run(&config.root, &arguments) + } + Command::Facts(_) => Ok(EXIT_SUCCESS), + } +} diff --git a/crates/sqlclean-cli/src/command/_helpers/facts_command.rs b/crates/sqlclean-cli/src/command/_helpers/facts_command.rs new file mode 100644 index 0000000..325eb80 --- /dev/null +++ b/crates/sqlclean-cli/src/command/_helpers/facts_command.rs @@ -0,0 +1,19 @@ +//! `sqlclean facts`: the statement facts of SQL on standard input, for Rule test harnesses. + +use std::io::{Read, Write}; + +use sqlclean::sql_facts::main::file_facts::file_facts; + +use crate::command::constants::EXIT_SUCCESS; +use crate::command::models::FactsArgs; + +pub(crate) fn run(arguments: &FactsArgs) -> Result { + let mut sql = String::new(); + std::io::stdin() + .read_to_string(&mut sql) + .map_err(|error| error.to_string())?; + let facts = file_facts(&sql, &arguments.dialect)?; + let json: String = serde_json::to_string(&facts).map_err(|error| error.to_string())?; + let _ = writeln!(std::io::stdout(), "{json}"); + Ok(EXIT_SUCCESS) +} diff --git a/crates/sqlclean-cli/src/command/_helpers/fix_command.rs b/crates/sqlclean-cli/src/command/_helpers/fix_command.rs new file mode 100644 index 0000000..53ed26c --- /dev/null +++ b/crates/sqlclean-cli/src/command/_helpers/fix_command.rs @@ -0,0 +1,70 @@ +//! `sqlclean fix`. + +use std::collections::HashMap; +use std::io::Write; + +use crate::command::_helpers::check_command::check_options; +use crate::command::_helpers::diffs::{print_diff, write_file}; +use crate::command::constants::{EXIT_FINDINGS, EXIT_SUCCESS}; +use crate::command::models::FixArgs; +use crate::config::models::Config; +use crate::discovery::main::discover_files::discover_files; +use crate::fixing::main::fix_sources::fix_sources; +use crate::reporting::main::render_findings::render_findings; +use crate::reporting::models::OutputFormat; + +pub(crate) fn run(config: &Config, arguments: &FixArgs) -> Result { + let originals = discover_files(config, &arguments.selection.paths)?; + let outcome = fix_sources( + config, + originals.clone(), + &check_options(&arguments.selection), + arguments.unsafe_fixes, + )?; + let mut changed_files: usize = 0; + for (original, fixed) in originals.iter().zip(&outcome.files) { + if original.text == fixed.text { + continue; + } + changed_files += 1; + if arguments.diff { + print_diff(original, &fixed.text); + } else { + write_file(original, &fixed.text)?; + } + } + let mut stderr = std::io::stderr(); + for (path, reason) in &outcome.format_refusals { + let _ = writeln!(stderr, "{path}: could not format: {reason}"); + } + let summary: &str = if arguments.diff { + "Would fix {fixes} problem(s) and change {files} file(s)." + } else { + "Fixed {fixes} problem(s) and changed {files} file(s)." + }; + let _ = writeln!( + stderr, + "{}", + summary + .replace("{fixes}", &outcome.applied_fixes.to_string()) + .replace("{files}", &changed_files.to_string()) + ); + if !arguments.diff { + let texts: HashMap = outcome + .files + .iter() + .map(|file| (file.path.clone(), file.text.clone())) + .collect(); + let report: String = render_findings(&outcome.remaining, &texts, OutputFormat::Text); + let _ = std::io::stdout().write_all(report.as_bytes()); + } + let remaining: bool = !outcome.remaining.findings.is_empty() + || !outcome.remaining.stale_exceptions.is_empty() + || !outcome.format_refusals.is_empty() + || (arguments.diff && changed_files > 0); + Ok(if remaining { + EXIT_FINDINGS + } else { + EXIT_SUCCESS + }) +} diff --git a/crates/sqlclean-cli/src/command/_helpers/format_command.rs b/crates/sqlclean-cli/src/command/_helpers/format_command.rs new file mode 100644 index 0000000..37cfe72 --- /dev/null +++ b/crates/sqlclean-cli/src/command/_helpers/format_command.rs @@ -0,0 +1,55 @@ +//! `sqlclean format`. + +use std::io::Write; + +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; + +use crate::command::_helpers::diffs::{print_diff, write_file}; +use crate::command::constants::{EXIT_FINDINGS, EXIT_SUCCESS}; +use crate::command::models::FormatArgs; +use crate::config::models::Config; +use crate::discovery::main::discover_files::discover_files; +use crate::formatting::main::format_source::format_source; + +pub(crate) fn run(config: &Config, arguments: &FormatArgs) -> Result { + let files = discover_files(config, &arguments.paths)?; + let mut stderr = std::io::stderr(); + let (mut changed, mut refused): (usize, usize) = (0, 0); + let results: Vec> = files + .par_iter() + .map(|file| format_source(&file.text, &file.dialect, config.line_width, config.indent)) + .collect(); + for (file, result) in files.iter().zip(results) { + let formatted: String = match result { + Ok(formatted) => formatted, + Err(reason) => { + refused += 1; + let _ = writeln!(stderr, "{}: could not format: {reason}", file.path); + continue; + } + }; + if formatted == file.text { + continue; + } + changed += 1; + if arguments.diff { + print_diff(file, &formatted); + } else if arguments.check { + let _ = writeln!(std::io::stdout(), "Would reformat: {}", file.path); + } else { + write_file(file, &formatted)?; + } + } + let unchanged: usize = files.len() - changed - refused; + let verb: &str = if arguments.check || arguments.diff { + "would be reformatted" + } else { + "reformatted" + }; + let _ = writeln!( + stderr, + "{changed} file(s) {verb}, {unchanged} file(s) already formatted." + ); + let failed: bool = refused > 0 || ((arguments.check || arguments.diff) && changed > 0); + Ok(if failed { EXIT_FINDINGS } else { EXIT_SUCCESS }) +} diff --git a/crates/sqlclean-cli/src/command/_helpers/mod.rs b/crates/sqlclean-cli/src/command/_helpers/mod.rs new file mode 100644 index 0000000..8a524c2 --- /dev/null +++ b/crates/sqlclean-cli/src/command/_helpers/mod.rs @@ -0,0 +1,8 @@ +pub mod check_command; +pub mod diffs; +pub mod dispatch; +pub mod facts_command; +pub mod fix_command; +pub mod format_command; +pub mod rules_command; +pub mod skills_command; diff --git a/crates/sqlclean-cli/src/command/_helpers/rules_command.rs b/crates/sqlclean-cli/src/command/_helpers/rules_command.rs new file mode 100644 index 0000000..b23799a --- /dev/null +++ b/crates/sqlclean-cli/src/command/_helpers/rules_command.rs @@ -0,0 +1,77 @@ +//! `sqlclean rules`. + +use std::fmt::Write as _; +use std::io::Write; + +use crate::checking::main::rule_catalogue::rule_catalogue; +use crate::checking::models::RuleInfo; +use crate::command::constants::{EXIT_ERROR, EXIT_SUCCESS}; +use crate::command::models::RulesArgs; +use crate::config::models::Config; + +pub(crate) fn run(config: &Config, arguments: &RulesArgs) -> Result { + let rules: Vec = rule_catalogue(config, true)?; + let shown: Vec<&RuleInfo> = match &arguments.code { + Some(code) => rules.iter().filter(|rule| &rule.code == code).collect(), + None => rules.iter().collect(), + }; + if shown.is_empty() { + let _ = writeln!(std::io::stderr(), "sqlclean: error: unknown Rule code"); + return Ok(EXIT_ERROR); + } + let output: String = if arguments.json { + serde_json::to_string_pretty(&shown).map_err(|error| error.to_string())? + "\n" + } else if arguments.code.is_some() { + detail(shown[0]) + } else { + table(&shown) + }; + let _ = std::io::stdout().write_all(output.as_bytes()); + Ok(EXIT_SUCCESS) +} + +fn table(rules: &[&RuleInfo]) -> String { + let mut output = String::new(); + for rule in rules { + let default: &str = if rule.enabled_by_default { + "default" + } else { + "" + }; + let _ = writeln!(output, "{:<12} {:<8} {}", rule.code, default, rule.message); + } + output +} + +fn detail(rule: &RuleInfo) -> String { + let mut output = String::new(); + let _ = writeln!(output, "{}: {}", rule.code, rule.message); + let _ = writeln!( + output, + "Subject: {}", + serde_json::to_value(rule.subject) + .unwrap_or_default() + .as_str() + .unwrap_or_default() + ); + let _ = writeln!( + output, + "Enabled by default: {}", + if rule.enabled_by_default { "yes" } else { "no" } + ); + if let Some(module) = &rule.module { + let _ = writeln!(output, "Defined in: {module}"); + } + if let Some(fixable) = rule.fixable { + let _ = writeln!( + output, + "Fixable: {}", + serde_json::to_value(fixable) + .unwrap_or_default() + .as_str() + .unwrap_or_default() + ); + } + let _ = writeln!(output, "Remediation: {}", rule.remediation); + output +} diff --git a/crates/sqlclean-cli/src/command/_helpers/skills_command.rs b/crates/sqlclean-cli/src/command/_helpers/skills_command.rs new file mode 100644 index 0000000..72dc206 --- /dev/null +++ b/crates/sqlclean-cli/src/command/_helpers/skills_command.rs @@ -0,0 +1,140 @@ +//! `sqlclean skills`: install the bundled SQLClean skill for AI coding agents. + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use include_dir::{Dir, DirEntry, include_dir}; + +use crate::command::constants::{ + DEFAULT_SKILL_TARGETS, EXIT_SUCCESS, SKILL_FILE_EXTENSION, SKILL_FILE_SUFFIX, + SKILL_GENERATED_MARKER, SKILL_NAME, SKILL_TARGETS, +}; +use crate::command::models::SkillsArgs; + +static SKILL: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/skill"); + +pub(crate) fn run(root: &Path, arguments: &SkillsArgs) -> Result { + let base: PathBuf = if arguments.global { + home()? + } else { + root.to_path_buf() + }; + let targets: Vec<&str> = if arguments.target.is_empty() { + DEFAULT_SKILL_TARGETS.to_vec() + } else { + arguments.target.iter().map(String::as_str).collect() + }; + let mut skill_dirs: Vec = Vec::new(); + for target in targets { + let (_, local, global) = SKILL_TARGETS + .iter() + .find(|(name, _, _)| *name == target) + .ok_or_else(|| { + format!("unknown skill target '{target}'; expected agents, claude or opencode") + })?; + let relative: &str = if arguments.global { global } else { local }; + let skill_dir: PathBuf = base.join(relative).join(SKILL_NAME); + if !skill_dirs.contains(&skill_dir) { + skill_dirs.push(skill_dir); + } + } + let files: Vec<(String, &str)> = packaged_files(&SKILL); + if !arguments.force { + for skill_dir in &skill_dirs { + refuse_unowned(skill_dir, &files)?; + } + } + let mut stdout = std::io::stdout(); + for skill_dir in &skill_dirs { + install(skill_dir, &files)?; + let _ = writeln!(stdout, "Installed {}", skill_dir.join("SKILL.md").display()); + } + Ok(EXIT_SUCCESS) +} + +fn home() -> Result { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .ok_or_else(|| "cannot find the home directory for --global".to_owned()) +} + +fn packaged_files(dir: &'static Dir<'static>) -> Vec<(String, &'static str)> { + let mut files: Vec<(String, &str)> = Vec::new(); + for entry in dir.entries() { + match entry { + DirEntry::Dir(child) => files.extend(packaged_files(child)), + DirEntry::File(file) => { + let relative: String = file.path().to_string_lossy().replace('\\', "/"); + if let (true, Some(text)) = + (relative.ends_with(SKILL_FILE_SUFFIX), file.contents_utf8()) + { + files.push((relative, text)); + } + } + } + } + files.sort(); + files +} + +fn generated(path: &Path) -> bool { + fs::read_to_string(path).is_ok_and(|text| text.contains(SKILL_GENERATED_MARKER)) +} + +fn refuse_unowned(skill_dir: &Path, files: &[(String, &str)]) -> Result<(), String> { + for (relative, _) in files { + let path: PathBuf = skill_dir.join(relative); + if path.is_file() && !generated(&path) { + return Err(format!( + "refusing to overwrite a skill file SQLClean did not generate: {}; rerun with --force to replace it", + path.display() + )); + } + } + Ok(()) +} + +/// Write every packaged file, then remove generated files the package no longer ships. +fn install(skill_dir: &Path, files: &[(String, &str)]) -> Result<(), String> { + for (relative, text) in files { + let path: PathBuf = skill_dir.join(relative); + if fs::read_to_string(&path).is_ok_and(|current| current == *text) { + continue; + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + fs::write(&path, text).map_err(|error| format!("{}: {error}", path.display()))?; + } + for path in markdown_files(skill_dir) { + let relative: String = path + .strip_prefix(skill_dir) + .map(|relative| relative.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + if !files.iter().any(|(shipped, _)| *shipped == relative) && generated(&path) { + fs::remove_file(&path).map_err(|error| error.to_string())?; + if let Some(parent) = path.parent() { + let _ = fs::remove_dir(parent); + } + } + } + Ok(()) +} + +fn markdown_files(dir: &Path) -> Vec { + let mut found: Vec = Vec::new(); + for entry in fs::read_dir(dir).into_iter().flatten().flatten() { + let path: PathBuf = entry.path(); + if path.is_dir() { + found.extend(markdown_files(&path)); + } else if path + .extension() + .is_some_and(|extension| extension == SKILL_FILE_EXTENSION) + { + found.push(path); + } + } + found +} diff --git a/crates/sqlclean-cli/src/command/constants.rs b/crates/sqlclean-cli/src/command/constants.rs new file mode 100644 index 0000000..233e2f8 --- /dev/null +++ b/crates/sqlclean-cli/src/command/constants.rs @@ -0,0 +1,20 @@ +pub const EXIT_SUCCESS: u8 = 0; +/// Findings remain, or files need changes that `--check` or `--diff` did not write. +pub const EXIT_FINDINGS: u8 = 1; +/// The command could not run: invalid arguments, configuration or input. +pub const EXIT_ERROR: u8 = 2; +/// The directory name of the installed skill under each agent's skills directory. +pub(crate) const SKILL_NAME: &str = "sqlclean"; +/// The comment that marks a skill file as written by `sqlclean skills`. +pub(crate) const SKILL_GENERATED_MARKER: &str = ""; +/// Each agent target with its project-relative and home-relative skills directory. +pub(crate) const SKILL_TARGETS: [(&str, &str, &str); 3] = [ + ("agents", ".agents/skills", ".agents/skills"), + ("claude", ".claude/skills", ".claude/skills"), + ("opencode", ".opencode/skills", ".config/opencode/skills"), +]; +/// The targets installed when no `--target` is given; OpenCode also reads `.agents`. +pub(crate) const DEFAULT_SKILL_TARGETS: [&str; 2] = ["agents", "claude"]; +/// Only Markdown files are installed and cleaned up. +pub(crate) const SKILL_FILE_EXTENSION: &str = "md"; +pub(crate) const SKILL_FILE_SUFFIX: &str = ".md"; diff --git a/crates/sqlclean-cli/src/command/main/mod.rs b/crates/sqlclean-cli/src/command/main/mod.rs new file mode 100644 index 0000000..9137f27 --- /dev/null +++ b/crates/sqlclean-cli/src/command/main/mod.rs @@ -0,0 +1 @@ +pub mod run; diff --git a/crates/sqlclean-cli/src/command/main/run.rs b/crates/sqlclean-cli/src/command/main/run.rs new file mode 100644 index 0000000..117bbb6 --- /dev/null +++ b/crates/sqlclean-cli/src/command/main/run.rs @@ -0,0 +1,10 @@ +//! Run `sqlclean` with the process arguments. + +use std::process::ExitCode; + +/// Parse the process arguments, run one subcommand and return its exit code. +pub fn run() -> ExitCode { + ExitCode::from(crate::command::_helpers::dispatch::run( + std::env::args_os().collect(), + )) +} diff --git a/crates/sqlclean-cli/src/command/mod.rs b/crates/sqlclean-cli/src/command/mod.rs new file mode 100644 index 0000000..0225000 --- /dev/null +++ b/crates/sqlclean-cli/src/command/mod.rs @@ -0,0 +1,6 @@ +//! The `sqlclean` command line: arguments, subcommands and exit codes. + +pub mod _helpers; +pub mod constants; +pub mod main; +pub mod models; diff --git a/crates/sqlclean-cli/src/command/models.rs b/crates/sqlclean-cli/src/command/models.rs new file mode 100644 index 0000000..72ff5f0 --- /dev/null +++ b/crates/sqlclean-cli/src/command/models.rs @@ -0,0 +1,110 @@ +//! The command-line interface. + +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand}; + +use crate::reporting::models::OutputFormat; + +/// Fast, opinionated SQL linting and deterministic formatting. +#[derive(Debug, Parser)] +#[command(name = "sqlclean", version, about)] +pub struct Cli { + /// Use this configuration file instead of the nearest sqlclean.toml. + #[arg(long, global = true)] + pub config: Option, + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Report Rule findings without changing any file. + #[command(alias = "lint")] + Check(CheckArgs), + /// Apply Rule fixes until stable, then format. + Fix(FixArgs), + /// Rewrite SQL files in the canonical layout. + Format(FormatArgs), + /// List the available Rules, or show one. + Rules(RulesArgs), + /// Print the structural facts custom Rules read for SQL on standard input, as JSON. + Facts(FactsArgs), + /// Install the SQLClean skill so AI coding agents know its commands and Rules. + Skills(SkillsArgs), +} + +/// Rule selection shared by check and fix. +#[derive(Debug, Args)] +pub struct SelectionArgs { + /// Files or directories to read; the configuration root when omitted. + pub paths: Vec, + /// Rule codes or families to run instead of the configured selection. + #[arg(long, value_delimiter = ',')] + pub select: Option>, + /// Rule codes or families to skip, in addition to the configured ones. + #[arg(long, value_delimiter = ',')] + pub ignore: Vec, + /// Evaluate every file instead of reusing results for unchanged inputs. + #[arg(long)] + pub no_cache: bool, +} + +#[derive(Debug, Args)] +pub struct CheckArgs { + #[command(flatten)] + pub selection: SelectionArgs, + #[arg(long, value_enum, default_value = "text")] + pub output_format: OutputFormat, +} + +#[derive(Debug, Args)] +pub struct FixArgs { + #[command(flatten)] + pub selection: SelectionArgs, + /// Also apply fixes their Rules mark unsafe because they can change results. + #[arg(long)] + pub unsafe_fixes: bool, + /// Print the changes as a unified diff instead of writing them. + #[arg(long)] + pub diff: bool, +} + +#[derive(Debug, Args)] +pub struct FormatArgs { + /// Files or directories to format; the configuration root when omitted. + pub paths: Vec, + /// Fail when a file needs formatting, without writing it. + #[arg(long)] + pub check: bool, + /// Print the changes as a unified diff instead of writing them. + #[arg(long)] + pub diff: bool, +} + +#[derive(Debug, Args)] +pub struct RulesArgs { + /// Show one Rule's details. + pub code: Option, + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct FactsArgs { + #[arg(long, default_value = "generic")] + pub dialect: String, +} + +#[derive(Debug, Args)] +pub struct SkillsArgs { + /// Agent to install for: agents, claude or opencode. Repeat for several; default agents and claude. + #[arg(long)] + pub target: Vec, + /// Install under the home directory instead of the configuration root. + #[arg(long)] + pub global: bool, + /// Overwrite skill files that SQLClean did not generate. + #[arg(long)] + pub force: bool, +} diff --git a/crates/sqlclean-cli/src/config/_helpers/file_schema.rs b/crates/sqlclean-cli/src/config/_helpers/file_schema.rs new file mode 100644 index 0000000..e4607d2 --- /dev/null +++ b/crates/sqlclean-cli/src/config/_helpers/file_schema.rs @@ -0,0 +1,47 @@ +//! The configuration file as written, before defaults and validation. + +use std::collections::BTreeMap; + +use serde::Deserialize; + +use crate::config::models::{DialectOverride, RuleException, RuleIgnore, Thresholds}; + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ConfigFile { + pub(crate) dialect: Option, + pub(crate) include: Option>, + #[serde(default)] + pub(crate) exclude: Vec, + #[serde(default)] + pub(crate) dialects: Vec, + #[serde(default)] + pub(crate) format: FormatTable, + #[serde(default)] + pub(crate) rules: RulesTable, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct FormatTable { + pub(crate) line_width: Option, + pub(crate) indent: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RulesTable { + pub(crate) select: Option>, + #[serde(default)] + pub(crate) ignore: Vec, + #[serde(default)] + pub(crate) custom: Vec, + #[serde(default)] + pub(crate) thresholds: Thresholds, + #[serde(default)] + pub(crate) rule_options: BTreeMap, + #[serde(default)] + pub(crate) rule_exceptions: Vec, + #[serde(default)] + pub(crate) rule_ignores: Vec, +} diff --git a/crates/sqlclean-cli/src/config/_helpers/loading.rs b/crates/sqlclean-cli/src/config/_helpers/loading.rs new file mode 100644 index 0000000..53d4491 --- /dev/null +++ b/crates/sqlclean-cli/src/config/_helpers/loading.rs @@ -0,0 +1,65 @@ +//! Locating and reading the configuration file. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::config::_helpers::file_schema::ConfigFile; +use crate::config::_helpers::validation::validate; +use crate::config::constants::{ + CONFIG_FILE_NAME, DEFAULT_DIALECT, DEFAULT_INCLUDE, DEFAULT_INDENT, DEFAULT_LINE_WIDTH, +}; +use crate::config::models::{Config, RulesConfig}; + +pub(crate) fn load_config(explicit: Option<&Path>, start: &Path) -> Result { + let located: Option = match explicit { + Some(path) => Some(start.join(path)), + None => find_config(start), + }; + let (root, file): (PathBuf, ConfigFile) = match located { + Some(path) => ( + path.parent() + .map_or_else(|| start.to_path_buf(), Path::to_path_buf), + read_config(&path)?, + ), + None => (start.to_path_buf(), ConfigFile::default()), + }; + let config: Config = effective_config(root, file); + validate(&config).map_err(|error| format!("{CONFIG_FILE_NAME}: {error}"))?; + Ok(config) +} + +fn find_config(start: &Path) -> Option { + start + .ancestors() + .map(|directory| directory.join(CONFIG_FILE_NAME)) + .find(|candidate| candidate.is_file()) +} + +fn read_config(path: &Path) -> Result { + let text: String = fs::read_to_string(path) + .map_err(|error| format!("could not read {}: {error}", path.display()))?; + toml::from_str(&text).map_err(|error| format!("{}: {error}", path.display())) +} + +fn effective_config(root: PathBuf, file: ConfigFile) -> Config { + Config { + root, + dialect: file.dialect.unwrap_or_else(|| DEFAULT_DIALECT.to_owned()), + include: file + .include + .unwrap_or_else(|| vec![DEFAULT_INCLUDE.to_owned()]), + exclude: file.exclude, + dialects: file.dialects, + line_width: file.format.line_width.unwrap_or(DEFAULT_LINE_WIDTH), + indent: file.format.indent.unwrap_or(DEFAULT_INDENT), + rules: RulesConfig { + select: file.rules.select, + ignore: file.rules.ignore, + custom: file.rules.custom, + thresholds: file.rules.thresholds, + rule_options: file.rules.rule_options, + rule_exceptions: file.rules.rule_exceptions, + rule_ignores: file.rules.rule_ignores, + }, + } +} diff --git a/crates/sqlclean-cli/src/config/_helpers/mod.rs b/crates/sqlclean-cli/src/config/_helpers/mod.rs new file mode 100644 index 0000000..bc14e27 --- /dev/null +++ b/crates/sqlclean-cli/src/config/_helpers/mod.rs @@ -0,0 +1,3 @@ +pub mod file_schema; +pub mod loading; +pub mod validation; diff --git a/crates/sqlclean-cli/src/config/_helpers/validation.rs b/crates/sqlclean-cli/src/config/_helpers/validation.rs new file mode 100644 index 0000000..190a703 --- /dev/null +++ b/crates/sqlclean-cli/src/config/_helpers/validation.rs @@ -0,0 +1,62 @@ +//! Checks that need more than the schema: positive limits, valid globs, dialects and reasons. + +use globset::Glob; +use sqlclean::sql_lint::main::validate_dialect::validate_dialect; + +use crate::config::constants::MAX_INDENT; +use crate::config::models::Config; + +pub(crate) fn validate(config: &Config) -> Result<(), String> { + validate_dialect(&config.dialect)?; + for dialect in &config.dialects { + validate_dialect(&dialect.dialect)?; + validate_globs(&dialect.paths)?; + } + validate_globs(&config.include)?; + validate_globs(&config.exclude)?; + if config.line_width == 0 { + return Err("[format] line_width must be positive".to_owned()); + } + if !(1..=MAX_INDENT).contains(&config.indent) { + return Err(format!( + "[format] indent must be between 1 and {MAX_INDENT}" + )); + } + let thresholds = config.rules.thresholds; + if thresholds.max_literal_length == 0 || thresholds.max_ranking_order_by == 0 { + return Err( + "[rules.thresholds] max_literal_length and max_ranking_order_by must be positive" + .to_owned(), + ); + } + for exception in &config.rules.rule_exceptions { + require_reason(&exception.reason, &exception.rule)?; + if exception.relation.is_none() && exception.cte.is_some() { + return Err(format!( + "rule exception for {} at {} sets cte without relation", + exception.rule, exception.path + )); + } + } + for ignore in &config.rules.rule_ignores { + require_reason(&ignore.reason, &ignore.rules.join(", "))?; + validate_globs(&ignore.paths)?; + } + Ok(()) +} + +fn validate_globs(patterns: &[String]) -> Result<(), String> { + for pattern in patterns { + Glob::new(pattern).map_err(|error| format!("invalid path pattern '{pattern}': {error}"))?; + } + Ok(()) +} + +fn require_reason(reason: &str, subject: &str) -> Result<(), String> { + if reason.trim().is_empty() { + return Err(format!( + "rule exception or ignore for {subject} needs a reason" + )); + } + Ok(()) +} diff --git a/crates/sqlclean-cli/src/config/constants.rs b/crates/sqlclean-cli/src/config/constants.rs new file mode 100644 index 0000000..2d6a743 --- /dev/null +++ b/crates/sqlclean-cli/src/config/constants.rs @@ -0,0 +1,8 @@ +pub const CONFIG_FILE_NAME: &str = "sqlclean.toml"; +pub(crate) const DEFAULT_DIALECT: &str = "generic"; +pub(crate) const DEFAULT_INCLUDE: &str = "**/*.sql"; +pub(crate) const DEFAULT_LINE_WIDTH: usize = 100; +pub(crate) const DEFAULT_INDENT: usize = 4; +pub(crate) const MAX_INDENT: usize = 8; +pub(crate) const DEFAULT_MAX_LITERAL_LENGTH: usize = 100; +pub(crate) const DEFAULT_MAX_RANKING_ORDER_BY: usize = 5; diff --git a/crates/sqlclean-cli/src/config/main/load_config.rs b/crates/sqlclean-cli/src/config/main/load_config.rs new file mode 100644 index 0000000..305c78a --- /dev/null +++ b/crates/sqlclean-cli/src/config/main/load_config.rs @@ -0,0 +1,10 @@ +//! Find, read and validate the configuration for a command. + +use std::path::Path; + +use crate::config::models::Config; + +/// The configuration at `explicit`, else the nearest `sqlclean.toml` at or above `start`. +pub fn load_config(explicit: Option<&Path>, start: &Path) -> Result { + crate::config::_helpers::loading::load_config(explicit, start) +} diff --git a/crates/sqlclean-cli/src/config/main/mod.rs b/crates/sqlclean-cli/src/config/main/mod.rs new file mode 100644 index 0000000..1384d72 --- /dev/null +++ b/crates/sqlclean-cli/src/config/main/mod.rs @@ -0,0 +1 @@ +pub mod load_config; diff --git a/crates/sqlclean-cli/src/config/mod.rs b/crates/sqlclean-cli/src/config/mod.rs new file mode 100644 index 0000000..a68d4fb --- /dev/null +++ b/crates/sqlclean-cli/src/config/mod.rs @@ -0,0 +1,9 @@ +//! `sqlclean.toml`: where it is found, what it may contain and its defaults. + +pub mod _helpers; +pub mod constants; +pub mod main; +pub mod models; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlclean-cli/src/config/models.rs b/crates/sqlclean-cli/src/config/models.rs new file mode 100644 index 0000000..0db827f --- /dev/null +++ b/crates/sqlclean-cli/src/config/models.rs @@ -0,0 +1,82 @@ +//! The validated configuration every command reads. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde::Deserialize; + +/// The effective configuration: file values over defaults, with paths relative to `root`. +#[derive(Debug, Clone)] +pub struct Config { + /// The directory that holds the configuration file, or the working directory without one. + pub root: PathBuf, + pub dialect: String, + pub include: Vec, + pub exclude: Vec, + pub dialects: Vec, + pub line_width: usize, + /// Spaces per indent step in formatted SQL. + pub indent: usize, + pub rules: RulesConfig, +} + +/// A dialect for every file whose root-relative path matches one of `paths`. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DialectOverride { + pub paths: Vec, + pub dialect: String, +} + +/// The `[rules]` table. +#[derive(Debug, Clone, Default)] +pub struct RulesConfig { + /// Exact codes or family prefixes; `None` selects the default Rules. + pub select: Option>, + pub ignore: Vec, + /// Python files or directories, relative to the root, that define custom Rules. + pub custom: Vec, + pub thresholds: Thresholds, + pub rule_options: BTreeMap, + pub rule_exceptions: Vec, + pub rule_ignores: Vec, +} + +/// Project-wide limits of the SQL quality Rules. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Thresholds { + pub max_literal_length: usize, + pub max_ranking_order_by: usize, +} + +impl Default for Thresholds { + fn default() -> Self { + Self { + max_literal_length: crate::config::constants::DEFAULT_MAX_LITERAL_LENGTH, + max_ranking_order_by: crate::config::constants::DEFAULT_MAX_RANKING_ORDER_BY, + } + } +} + +/// One reviewed finding that is allowed: a Rule code in one file, optionally one joined relation. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RuleException { + pub rule: String, + pub path: String, + #[serde(default)] + pub relation: Option, + #[serde(default)] + pub cte: Option, + pub reason: String, +} + +/// Rule codes or families that do not apply to files matching `paths`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RuleIgnore { + pub rules: Vec, + pub paths: Vec, + pub reason: String, +} diff --git a/crates/sqlclean-cli/src/config/tests.rs b/crates/sqlclean-cli/src/config/tests.rs new file mode 100644 index 0000000..63f578b --- /dev/null +++ b/crates/sqlclean-cli/src/config/tests.rs @@ -0,0 +1,4 @@ +#[path = "tests/test_load_config.rs"] +mod load_config; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlclean-cli/src/config/tests/test_load_config.rs b/crates/sqlclean-cli/src/config/tests/test_load_config.rs new file mode 100644 index 0000000..021acd3 --- /dev/null +++ b/crates/sqlclean-cli/src/config/tests/test_load_config.rs @@ -0,0 +1,123 @@ +use std::fs; + +use crate::config::main::load_config::load_config; +use crate::config::tests::test_types::{LoadedConfigTestCase, RejectedConfigTestCase}; + +#[test] +fn given_a_config_file_when_loading_from_below_then_the_nearest_one_applies() -> Result<(), String> +{ + let test_cases = [ + LoadedConfigTestCase { + description: "values set in a parent directory apply and unset thresholds keep defaults", + config_text: "dialect = \"duckdb\"\n[format]\nline_width = 80\n[rules]\nselect = [\"SQC004\"]\n[rules.thresholds]\nmax_literal_length = 40\n", + start: "models/orders", + expected_dialect: "duckdb", + expected_line_width: 80, + expected_select: Some(&["SQC004"]), + expected_max_literal_length: 40, + expected_max_ranking_order_by: 5, + }, + LoadedConfigTestCase { + description: "an empty file gives the defaults", + config_text: "", + start: "", + expected_dialect: "generic", + expected_line_width: 100, + expected_select: None, + expected_max_literal_length: 100, + expected_max_ranking_order_by: 5, + }, + ]; + for test_case in &test_cases { + let directory = tempfile::tempdir().map_err(|error| error.to_string())?; + let start = directory.path().join(test_case.start); + fs::create_dir_all(&start).map_err(|error| error.to_string())?; + fs::write( + directory.path().join("sqlclean.toml"), + test_case.config_text, + ) + .map_err(|error| error.to_string())?; + let config = load_config(None, &start)?; + let select: Option> = config + .rules + .select + .as_ref() + .map(|codes| codes.iter().map(String::as_str).collect()); + assert_eq!(config.root, directory.path(), "{}", test_case.description); + assert_eq!( + config.dialect, test_case.expected_dialect, + "{}", + test_case.description + ); + assert_eq!( + config.line_width, test_case.expected_line_width, + "{}", + test_case.description + ); + assert_eq!( + select, + test_case.expected_select.map(<[&str]>::to_vec), + "{}", + test_case.description + ); + assert_eq!( + config.rules.thresholds.max_literal_length, test_case.expected_max_literal_length, + "{}", + test_case.description + ); + assert_eq!( + config.rules.thresholds.max_ranking_order_by, test_case.expected_max_ranking_order_by, + "{}", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_invalid_values_when_loading_then_each_is_rejected_with_its_reason() -> Result<(), String> { + let test_cases = [ + RejectedConfigTestCase { + description: "an unknown dialect", + config_text: "dialect = \"nosuch\"\n", + expected_error: "unknown SQL dialect 'nosuch'", + }, + RejectedConfigTestCase { + description: "an unknown rules key", + config_text: "[rules]\nunknown = 1\n", + expected_error: "unknown field `unknown`", + }, + RejectedConfigTestCase { + description: "an exception with a blank reason", + config_text: "[[rules.rule_exceptions]]\nrule = \"SQC004\"\npath = \"a.sql\"\nreason = \" \"\n", + expected_error: "needs a reason", + }, + RejectedConfigTestCase { + description: "a zero line width", + config_text: "[format]\nline_width = 0\n", + expected_error: "line_width must be positive", + }, + RejectedConfigTestCase { + description: "an indent of zero is rejected", + config_text: "[format]\nindent = 0\n", + expected_error: "indent must be between 1 and 8", + }, + ]; + for test_case in &test_cases { + let directory = tempfile::tempdir().map_err(|error| error.to_string())?; + fs::write( + directory.path().join("sqlclean.toml"), + test_case.config_text, + ) + .map_err(|error| error.to_string())?; + let error: String = load_config(None, directory.path()) + .err() + .unwrap_or_default(); + assert!( + error.contains(test_case.expected_error), + "{}: {error}", + test_case.description + ); + } + Ok(()) +} diff --git a/crates/sqlclean-cli/src/config/tests/test_types.rs b/crates/sqlclean-cli/src/config/tests/test_types.rs new file mode 100644 index 0000000..1115689 --- /dev/null +++ b/crates/sqlclean-cli/src/config/tests/test_types.rs @@ -0,0 +1,16 @@ +pub(crate) struct LoadedConfigTestCase { + pub(crate) description: &'static str, + pub(crate) config_text: &'static str, + pub(crate) start: &'static str, + pub(crate) expected_dialect: &'static str, + pub(crate) expected_line_width: usize, + pub(crate) expected_select: Option<&'static [&'static str]>, + pub(crate) expected_max_literal_length: usize, + pub(crate) expected_max_ranking_order_by: usize, +} + +pub(crate) struct RejectedConfigTestCase { + pub(crate) description: &'static str, + pub(crate) config_text: &'static str, + pub(crate) expected_error: &'static str, +} diff --git a/crates/sqlclean-cli/src/discovery/_helpers/matching.rs b/crates/sqlclean-cli/src/discovery/_helpers/matching.rs new file mode 100644 index 0000000..d05d07b --- /dev/null +++ b/crates/sqlclean-cli/src/discovery/_helpers/matching.rs @@ -0,0 +1,60 @@ +//! Root-relative path matching for include, exclude and per-path dialect patterns. + +use globset::{Glob, GlobSet, GlobSetBuilder}; + +use crate::config::models::Config; + +/// Compiled path patterns of one configuration. +pub(crate) struct PathMatchers { + include: GlobSet, + exclude: GlobSet, + dialects: Vec<(GlobSet, String)>, + default_dialect: String, +} + +impl PathMatchers { + pub(crate) fn new(config: &Config) -> Result { + let mut dialects: Vec<(GlobSet, String)> = Vec::new(); + for entry in &config.dialects { + dialects.push((glob_set(&entry.paths)?, entry.dialect.clone())); + } + Ok(Self { + include: glob_set(&config.include)?, + exclude: glob_set(&config.exclude)?, + dialects, + default_dialect: config.dialect.clone(), + }) + } + + pub(crate) fn is_included(&self, path: &str) -> bool { + self.include.is_match(path) && !self.is_excluded(path) + } + + pub(crate) fn is_excluded(&self, path: &str) -> bool { + self.exclude.is_match(path) + } + + /// The dialect of the first matching per-path entry, or the configured default. + pub(crate) fn dialect(&self, path: &str) -> String { + self.dialects + .iter() + .find(|(patterns, _)| patterns.is_match(path)) + .map_or_else( + || self.default_dialect.clone(), + |(_, dialect)| dialect.clone(), + ) + } +} + +/// Compile root-relative glob patterns; `*` does not cross `/`, and `**` does. +pub(crate) fn glob_set(patterns: &[String]) -> Result { + let mut builder = GlobSetBuilder::new(); + for pattern in patterns { + let glob: Glob = globset::GlobBuilder::new(pattern) + .literal_separator(true) + .build() + .map_err(|error| format!("invalid path pattern '{pattern}': {error}"))?; + builder.add(glob); + } + builder.build().map_err(|error| error.to_string()) +} diff --git a/crates/sqlclean-cli/src/discovery/_helpers/mod.rs b/crates/sqlclean-cli/src/discovery/_helpers/mod.rs new file mode 100644 index 0000000..130be8f --- /dev/null +++ b/crates/sqlclean-cli/src/discovery/_helpers/mod.rs @@ -0,0 +1,2 @@ +pub mod matching; +pub mod walking; diff --git a/crates/sqlclean-cli/src/discovery/_helpers/walking.rs b/crates/sqlclean-cli/src/discovery/_helpers/walking.rs new file mode 100644 index 0000000..212893a --- /dev/null +++ b/crates/sqlclean-cli/src/discovery/_helpers/walking.rs @@ -0,0 +1,77 @@ +//! Walk the requested paths, honouring ignore files, and read the selected SQL files. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use ignore::WalkBuilder; + +use crate::config::models::Config; +use crate::discovery::_helpers::matching::PathMatchers; +use crate::discovery::models::SourceFile; + +pub(crate) fn discover_files( + config: &Config, + paths: &[PathBuf], +) -> Result, String> { + let matchers = PathMatchers::new(config)?; + let roots: Vec = if paths.is_empty() { + vec![config.root.clone()] + } else { + paths.to_vec() + }; + let mut selected: BTreeMap = BTreeMap::new(); + for root in &roots { + if !root.exists() { + return Err(format!("{}: no such file or directory", root.display())); + } + if root.is_file() { + let relative: String = relative_path(&config.root, root); + if !matchers.is_excluded(&relative) { + selected.insert(relative, root.clone()); + } + continue; + } + for entry in WalkBuilder::new(root).build() { + let entry = entry.map_err(|error| error.to_string())?; + if !entry.file_type().is_some_and(|kind| kind.is_file()) { + continue; + } + let relative: String = relative_path(&config.root, entry.path()); + if matchers.is_included(&relative) { + selected.insert(relative, entry.path().to_path_buf()); + } + } + } + selected + .into_iter() + .map(|(path, absolute)| read_source(&matchers, path, absolute)) + .collect() +} + +fn read_source( + matchers: &PathMatchers, + path: String, + absolute: PathBuf, +) -> Result { + let text: String = + fs::read_to_string(&absolute).map_err(|error| format!("could not read {path}: {error}"))?; + Ok(SourceFile { + dialect: matchers.dialect(&path), + path, + absolute, + text, + }) +} + +/// `path` relative to `root` with `/` separators, or as given when it lies outside the root. +pub(crate) fn relative_path(root: &Path, path: &Path) -> String { + let absolute_root: PathBuf = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + let absolute_path: PathBuf = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let relative: &Path = absolute_path.strip_prefix(&absolute_root).unwrap_or(path); + relative + .components() + .map(|component| component.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/") +} diff --git a/crates/sqlclean-cli/src/discovery/main/discover_files.rs b/crates/sqlclean-cli/src/discovery/main/discover_files.rs new file mode 100644 index 0000000..248ac4c --- /dev/null +++ b/crates/sqlclean-cli/src/discovery/main/discover_files.rs @@ -0,0 +1,11 @@ +//! Read every SQL file the given paths select. + +use std::path::PathBuf; + +use crate::config::models::Config; +use crate::discovery::models::SourceFile; + +/// The configured SQL files under `paths` (the configuration root when empty), sorted by path. +pub fn discover_files(config: &Config, paths: &[PathBuf]) -> Result, String> { + crate::discovery::_helpers::walking::discover_files(config, paths) +} diff --git a/crates/sqlclean-cli/src/discovery/main/mod.rs b/crates/sqlclean-cli/src/discovery/main/mod.rs new file mode 100644 index 0000000..1841aef --- /dev/null +++ b/crates/sqlclean-cli/src/discovery/main/mod.rs @@ -0,0 +1,2 @@ +pub mod discover_files; +pub mod path_patterns; diff --git a/crates/sqlclean-cli/src/discovery/main/path_patterns.rs b/crates/sqlclean-cli/src/discovery/main/path_patterns.rs new file mode 100644 index 0000000..0493b4a --- /dev/null +++ b/crates/sqlclean-cli/src/discovery/main/path_patterns.rs @@ -0,0 +1,8 @@ +//! Root-relative glob patterns compiled the way every configured path list is matched. + +use globset::GlobSet; + +/// Compile `patterns`; `*` does not cross `/`, and `**` does. +pub fn path_patterns(patterns: &[String]) -> Result { + crate::discovery::_helpers::matching::glob_set(patterns) +} diff --git a/crates/sqlclean-cli/src/discovery/mod.rs b/crates/sqlclean-cli/src/discovery/mod.rs new file mode 100644 index 0000000..ecb90e4 --- /dev/null +++ b/crates/sqlclean-cli/src/discovery/mod.rs @@ -0,0 +1,8 @@ +//! Which SQL files a command reads, and the dialect of each. + +pub mod _helpers; +pub mod main; +pub mod models; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlclean-cli/src/discovery/models.rs b/crates/sqlclean-cli/src/discovery/models.rs new file mode 100644 index 0000000..e4de420 --- /dev/null +++ b/crates/sqlclean-cli/src/discovery/models.rs @@ -0,0 +1,11 @@ +use std::path::PathBuf; + +/// One SQL file a command reads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceFile { + /// The path relative to the configuration root, with `/` separators. + pub path: String, + pub absolute: PathBuf, + pub text: String, + pub dialect: String, +} diff --git a/crates/sqlclean-cli/src/discovery/tests.rs b/crates/sqlclean-cli/src/discovery/tests.rs new file mode 100644 index 0000000..de9dcc6 --- /dev/null +++ b/crates/sqlclean-cli/src/discovery/tests.rs @@ -0,0 +1,4 @@ +#[path = "tests/test_discover_files.rs"] +mod discover_files; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlclean-cli/src/discovery/tests/test_discover_files.rs b/crates/sqlclean-cli/src/discovery/tests/test_discover_files.rs new file mode 100644 index 0000000..bc14258 --- /dev/null +++ b/crates/sqlclean-cli/src/discovery/tests/test_discover_files.rs @@ -0,0 +1,67 @@ +use std::fs; +use std::path::PathBuf; + +use crate::config::main::load_config::load_config; +use crate::discovery::main::discover_files::discover_files; +use crate::discovery::tests::test_types::DiscoveryTestCase; + +const PROJECT: [(&str, &str); 7] = [ + ( + "sqlclean.toml", + "dialect = \"duckdb\"\nexclude = [\"vendor/**\"]\n[[dialects]]\npaths = [\"warehouse/**\"]\ndialect = \"snowflake\"\n", + ), + (".gitignore", "build/\n"), + ("models/orders.sql", "SELECT 1"), + ("warehouse/customers.sql", "SELECT 2"), + ("vendor/third_party.sql", "SELECT 3"), + ("build/generated.sql", "SELECT 4"), + ("notes.txt", "not sql"), +]; + +#[test] +fn given_patterns_and_ignore_files_when_discovering_then_selects_files_and_dialects() +-> Result<(), String> { + let test_cases = [ + DiscoveryTestCase { + description: "a full walk skips excluded and git-ignored files and maps dialects by path", + explicit_paths: &[], + expected_files: &[ + ("models/orders.sql", "duckdb"), + ("warehouse/customers.sql", "snowflake"), + ], + }, + DiscoveryTestCase { + description: "an explicit file is read on its own", + explicit_paths: &["models/orders.sql"], + expected_files: &[("models/orders.sql", "duckdb")], + }, + DiscoveryTestCase { + description: "an explicit directory is walked with the same filters", + explicit_paths: &["warehouse"], + expected_files: &[("warehouse/customers.sql", "snowflake")], + }, + ]; + let directory = tempfile::tempdir().map_err(|error| error.to_string())?; + let root = directory.path(); + for (path, text) in PROJECT { + let file = root.join(path); + fs::create_dir_all(file.parent().unwrap_or(root)).map_err(|error| error.to_string())?; + fs::write(file, text).map_err(|error| error.to_string())?; + } + fs::create_dir_all(root.join(".git")).map_err(|error| error.to_string())?; + let config = load_config(None, root)?; + for test_case in &test_cases { + let paths: Vec = test_case + .explicit_paths + .iter() + .map(|path| root.join(path)) + .collect(); + let files = discover_files(&config, &paths)?; + let found: Vec<(&str, &str)> = files + .iter() + .map(|file| (file.path.as_str(), file.dialect.as_str())) + .collect(); + assert_eq!(found, test_case.expected_files, "{}", test_case.description); + } + Ok(()) +} diff --git a/crates/sqlclean-cli/src/discovery/tests/test_types.rs b/crates/sqlclean-cli/src/discovery/tests/test_types.rs new file mode 100644 index 0000000..532cfae --- /dev/null +++ b/crates/sqlclean-cli/src/discovery/tests/test_types.rs @@ -0,0 +1,5 @@ +pub(crate) struct DiscoveryTestCase { + pub(crate) description: &'static str, + pub(crate) explicit_paths: &'static [&'static str], + pub(crate) expected_files: &'static [(&'static str, &'static str)], +} diff --git a/crates/sqlclean-cli/src/fixing/_helpers/edit_selection.rs b/crates/sqlclean-cli/src/fixing/_helpers/edit_selection.rs new file mode 100644 index 0000000..5aa8dad --- /dev/null +++ b/crates/sqlclean-cli/src/fixing/_helpers/edit_selection.rs @@ -0,0 +1,56 @@ +//! Choose the fixes one pass can apply together. + +use std::collections::BTreeMap; + +use crate::checking::models::{Finding, FixSafety}; +use crate::positions::models::Edit; + +/// Edits by path from every eligible finding whose edits overlap no earlier accepted edit. +pub(crate) fn select_edits( + findings: &[Finding], + unsafe_fixes: bool, +) -> BTreeMap> { + let mut eligible: Vec<&Finding> = findings + .iter() + .filter(|finding| is_eligible(finding, unsafe_fixes)) + .collect(); + eligible.sort_by_key(|finding| (finding.path.clone(), finding.start, finding.code.clone())); + let mut accepted: BTreeMap> = BTreeMap::new(); + for finding in eligible { + let edits: &mut Vec = accepted.entry(finding.path.clone()).or_default(); + if is_compatible(&finding.edits, edits) { + edits.extend(finding.edits.iter().cloned()); + } + } + for edits in accepted.values_mut() { + edits.sort_by_key(|edit| (edit.start, edit.end)); + } + accepted.retain(|_, edits| !edits.is_empty()); + accepted +} + +fn is_eligible(finding: &Finding, unsafe_fixes: bool) -> bool { + match finding.fix { + Some(FixSafety::Safe) => true, + Some(FixSafety::Unsafe) => unsafe_fixes, + None => false, + } +} + +/// The finding's edits overlap neither each other nor any accepted edit. +fn is_compatible(edits: &[Edit], accepted: &[Edit]) -> bool { + edits.iter().enumerate().all(|(index, edit)| { + !overlaps_any(edit, accepted) && !overlaps_any(edit, &edits[index + 1..]) + }) +} + +fn overlaps_any(edit: &Edit, others: &[Edit]) -> bool { + others.iter().any(|other| overlaps(edit, other)) +} + +/// Two edits touch the same characters, or both insert at the same point. +fn overlaps(left: &Edit, right: &Edit) -> bool { + let both_insert_here: bool = + left.start == left.end && right.start == right.end && left.start == right.start; + both_insert_here || (left.start < right.end && right.start < left.end) +} diff --git a/crates/sqlclean-cli/src/fixing/_helpers/mod.rs b/crates/sqlclean-cli/src/fixing/_helpers/mod.rs new file mode 100644 index 0000000..148233a --- /dev/null +++ b/crates/sqlclean-cli/src/fixing/_helpers/mod.rs @@ -0,0 +1,2 @@ +pub mod edit_selection; +pub mod passes; diff --git a/crates/sqlclean-cli/src/fixing/_helpers/passes.rs b/crates/sqlclean-cli/src/fixing/_helpers/passes.rs new file mode 100644 index 0000000..4768da3 --- /dev/null +++ b/crates/sqlclean-cli/src/fixing/_helpers/passes.rs @@ -0,0 +1,74 @@ +//! The fix loop: check, apply non-overlapping fixes, keep only passes that still parse. + +use std::collections::BTreeMap; + +use sqlclean::sql_facts::main::file_facts::file_facts; + +use crate::checking::main::check_sources::check_sources; +use crate::checking::models::{CheckOptions, CheckOutcome}; +use crate::config::models::Config; +use crate::discovery::models::SourceFile; +use crate::fixing::_helpers::edit_selection::select_edits; +use crate::fixing::constants::MAX_FIX_PASSES; +use crate::fixing::models::FixOutcome; +use crate::formatting::main::format_source::format_source; +use crate::positions::main::apply_edits::apply_edits; +use crate::positions::models::Edit; + +pub(crate) fn fix_sources( + config: &Config, + mut files: Vec, + options: &CheckOptions, + unsafe_fixes: bool, +) -> Result { + let mut applied_fixes: usize = 0; + let mut passes: usize = 0; + while passes < MAX_FIX_PASSES { + let outcome: CheckOutcome = check_sources(config, &files, options)?; + let edits: BTreeMap> = select_edits(&outcome.findings, unsafe_fixes); + let applied: usize = apply_pass(&mut files, &edits); + if applied == 0 { + break; + } + applied_fixes += applied; + passes += 1; + } + let mut format_refusals: Vec<(String, String)> = Vec::new(); + for file in &mut files { + match format_source(&file.text, &file.dialect, config.line_width, config.indent) { + Ok(formatted) => file.text = formatted, + Err(reason) => format_refusals.push((file.path.clone(), reason)), + } + } + let remaining: CheckOutcome = check_sources(config, &files, options)?; + Ok(FixOutcome { + files, + applied_fixes, + passes, + format_refusals, + remaining, + }) +} + +/// Apply each file's edits unless they turn parsing SQL into SQL that no longer parses. +fn apply_pass(files: &mut [SourceFile], edits: &BTreeMap>) -> usize { + let mut applied: usize = 0; + for file in files.iter_mut() { + let Some(file_edits) = edits.get(&file.path) else { + continue; + }; + let fixed: String = apply_edits(&file.text, file_edits); + if fixed == file.text { + continue; + } + if let (Ok(_), Err(_)) = ( + file_facts(&file.text, &file.dialect), + file_facts(&fixed, &file.dialect), + ) { + continue; + } + file.text = fixed; + applied += file_edits.len(); + } + applied +} diff --git a/crates/sqlclean-cli/src/fixing/constants.rs b/crates/sqlclean-cli/src/fixing/constants.rs new file mode 100644 index 0000000..51eddc9 --- /dev/null +++ b/crates/sqlclean-cli/src/fixing/constants.rs @@ -0,0 +1,2 @@ +/// The most fix passes one run applies; a Rule whose fix keeps re-triggering stops here. +pub const MAX_FIX_PASSES: usize = 10; diff --git a/crates/sqlclean-cli/src/fixing/main/fix_sources.rs b/crates/sqlclean-cli/src/fixing/main/fix_sources.rs new file mode 100644 index 0000000..9747681 --- /dev/null +++ b/crates/sqlclean-cli/src/fixing/main/fix_sources.rs @@ -0,0 +1,16 @@ +//! Fix and format SQL files in memory; the caller decides whether to write them. + +use crate::checking::models::CheckOptions; +use crate::config::models::Config; +use crate::discovery::models::SourceFile; +use crate::fixing::models::FixOutcome; + +/// Apply safe fixes (and unsafe ones when `unsafe_fixes`) until stable, then format. +pub fn fix_sources( + config: &Config, + files: Vec, + options: &CheckOptions, + unsafe_fixes: bool, +) -> Result { + crate::fixing::_helpers::passes::fix_sources(config, files, options, unsafe_fixes) +} diff --git a/crates/sqlclean-cli/src/fixing/main/mod.rs b/crates/sqlclean-cli/src/fixing/main/mod.rs new file mode 100644 index 0000000..bb4e9bf --- /dev/null +++ b/crates/sqlclean-cli/src/fixing/main/mod.rs @@ -0,0 +1 @@ +pub mod fix_sources; diff --git a/crates/sqlclean-cli/src/fixing/mod.rs b/crates/sqlclean-cli/src/fixing/mod.rs new file mode 100644 index 0000000..bae8480 --- /dev/null +++ b/crates/sqlclean-cli/src/fixing/mod.rs @@ -0,0 +1,9 @@ +//! Apply Rule fixes until the files are stable, then format them. + +pub mod _helpers; +pub mod constants; +pub mod main; +pub mod models; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlclean-cli/src/fixing/models.rs b/crates/sqlclean-cli/src/fixing/models.rs new file mode 100644 index 0000000..3858145 --- /dev/null +++ b/crates/sqlclean-cli/src/fixing/models.rs @@ -0,0 +1,14 @@ +use crate::checking::models::CheckOutcome; +use crate::discovery::models::SourceFile; + +/// The fixed and formatted files, and what remains to fix by hand. +#[derive(Debug, Clone)] +pub struct FixOutcome { + /// Every input file with its new text; unchanged files keep their text. + pub files: Vec, + pub applied_fixes: usize, + pub passes: usize, + /// Files the formatter refused, with its reason. + pub format_refusals: Vec<(String, String)>, + pub remaining: CheckOutcome, +} diff --git a/crates/sqlclean-cli/src/fixing/tests.rs b/crates/sqlclean-cli/src/fixing/tests.rs new file mode 100644 index 0000000..16208ca --- /dev/null +++ b/crates/sqlclean-cli/src/fixing/tests.rs @@ -0,0 +1,6 @@ +#[path = "tests/test_edit_selection.rs"] +mod edit_selection; +#[path = "tests/helpers.rs"] +mod helpers; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlclean-cli/src/fixing/tests/helpers.rs b/crates/sqlclean-cli/src/fixing/tests/helpers.rs new file mode 100644 index 0000000..75df54d --- /dev/null +++ b/crates/sqlclean-cli/src/fixing/tests/helpers.rs @@ -0,0 +1,27 @@ +use crate::checking::models::{Finding, FixSafety}; +use crate::positions::models::Edit; + +/// A finding in `q.sql` whose fix applies `edits`. +pub(crate) fn finding( + code: &str, + fix: Option, + edits: &[(usize, usize, &str)], +) -> Finding { + Finding { + code: code.to_owned(), + path: "q.sql".to_owned(), + start: edits.first().map_or(0, |edit| edit.0), + end: 0, + message: String::new(), + remediation: String::new(), + edits: edits + .iter() + .map(|(start, end, text)| Edit { + start: *start, + end: *end, + text: (*text).to_owned(), + }) + .collect(), + fix, + } +} diff --git a/crates/sqlclean-cli/src/fixing/tests/test_edit_selection.rs b/crates/sqlclean-cli/src/fixing/tests/test_edit_selection.rs new file mode 100644 index 0000000..5b9faa3 --- /dev/null +++ b/crates/sqlclean-cli/src/fixing/tests/test_edit_selection.rs @@ -0,0 +1,37 @@ +use crate::checking::models::{Finding, FixSafety}; +use crate::fixing::_helpers::edit_selection::select_edits; +use crate::fixing::tests::helpers::finding; +use crate::fixing::tests::test_types::EditSelectionTestCase; + +#[test] +fn given_overlapping_and_unsafe_fixes_when_selecting_then_keeps_first_eligible_non_overlapping() { + let findings: Vec = vec![ + finding("A", Some(FixSafety::Safe), &[(0, 5, "x")]), + finding("B", Some(FixSafety::Safe), &[(3, 8, "y")]), + finding("C", Some(FixSafety::Safe), &[(5, 5, "z"), (9, 10, "")]), + finding("D", Some(FixSafety::Unsafe), &[(20, 21, "u")]), + finding("E", None, &[(30, 31, "n")]), + finding("F", Some(FixSafety::Safe), &[(9, 9, "w")]), + finding("G", Some(FixSafety::Safe), &[(9, 9, "v")]), + ]; + let test_cases = [ + EditSelectionTestCase { + description: "safe only: overlaps, a second insert at one point and unfixable findings are skipped", + unsafe_fixes: false, + expected_spans: &[(0, 5), (5, 5), (9, 9), (9, 10)], + }, + EditSelectionTestCase { + description: "unsafe fixes are added when allowed", + unsafe_fixes: true, + expected_spans: &[(0, 5), (5, 5), (9, 9), (9, 10), (20, 21)], + }, + ]; + for test_case in &test_cases { + let selected = select_edits(&findings, test_case.unsafe_fixes); + let spans: Vec<(usize, usize)> = selected + .get("q.sql") + .map(|edits| edits.iter().map(|edit| (edit.start, edit.end)).collect()) + .unwrap_or_default(); + assert_eq!(spans, test_case.expected_spans, "{}", test_case.description); + } +} diff --git a/crates/sqlclean-cli/src/fixing/tests/test_types.rs b/crates/sqlclean-cli/src/fixing/tests/test_types.rs new file mode 100644 index 0000000..f19e298 --- /dev/null +++ b/crates/sqlclean-cli/src/fixing/tests/test_types.rs @@ -0,0 +1,5 @@ +pub(crate) struct EditSelectionTestCase { + pub(crate) description: &'static str, + pub(crate) unsafe_fixes: bool, + pub(crate) expected_spans: &'static [(usize, usize)], +} diff --git a/crates/sqlclean-cli/src/formatting/constants.rs b/crates/sqlclean-cli/src/formatting/constants.rs new file mode 100644 index 0000000..69e54fd --- /dev/null +++ b/crates/sqlclean-cli/src/formatting/constants.rs @@ -0,0 +1,2 @@ +pub(crate) const FORMAT_REFUSAL: &str = "the formatter could not format this SQL"; +pub(crate) const STATEMENT_SEPARATOR: &str = "\n\n"; diff --git a/crates/sqlclean-cli/src/formatting/main/format_source.rs b/crates/sqlclean-cli/src/formatting/main/format_source.rs new file mode 100644 index 0000000..0f5ebb8 --- /dev/null +++ b/crates/sqlclean-cli/src/formatting/main/format_source.rs @@ -0,0 +1,70 @@ +//! Format one SQL file: each statement in canonical layout, separated by one blank line. + +use sqlclean::sql_facts::main::file_facts::file_facts; +use sqlclean::sql_lint::constants::LINT_API_VERSION; +use sqlclean::sql_lint::main::format_requests::format_requests; +use sqlclean::sql_lint::models::{FormatRequest, FormatResponse}; + +use crate::formatting::constants::{FORMAT_REFUSAL as REFUSAL, STATEMENT_SEPARATOR}; + +/// `text` in canonical layout ending in one newline, or why the formatter left it unchanged. +pub fn format_source( + text: &str, + dialect: &str, + line_width: usize, + indent: usize, +) -> Result { + let facts = file_facts(text, dialect)?; + let characters: Vec = text.chars().collect(); + let mut boundaries: Vec = facts + .statements + .iter() + .map(|statement| statement.terminated_end) + .collect(); + if let Some(last) = boundaries.last_mut() { + *last = characters.len(); + } + let mut chunks: Vec = Vec::new(); + let mut start: usize = 0; + for end in boundaries { + let chunk: String = characters[start..end].iter().collect(); + start = end; + let formatted: String = format_statement(chunk.trim(), dialect, line_width, indent)?; + if !formatted.trim().is_empty() { + chunks.push(formatted.trim_end().to_owned()); + } + } + if chunks.is_empty() { + let comments: &str = text.trim(); + return Ok(if comments.is_empty() { + String::new() + } else { + format!("{comments}\n") + }); + } + Ok(chunks.join(STATEMENT_SEPARATOR) + "\n") +} + +fn format_statement( + sql: &str, + dialect: &str, + line_width: usize, + indent: usize, +) -> Result { + let request = FormatRequest { + version: LINT_API_VERSION, + sql: sql.to_owned(), + dialect: dialect.to_owned(), + line_width: Some(line_width), + indent: Some(indent), + ..FormatRequest::default() + }; + let response: FormatResponse = format_requests(vec![request]) + .pop() + .ok_or_else(|| REFUSAL.to_owned())?; + if response.formatted { + Ok(response.sql) + } else { + Err(response.reason.unwrap_or_else(|| REFUSAL.to_owned())) + } +} diff --git a/crates/sqlclean-cli/src/formatting/main/mod.rs b/crates/sqlclean-cli/src/formatting/main/mod.rs new file mode 100644 index 0000000..caa197a --- /dev/null +++ b/crates/sqlclean-cli/src/formatting/main/mod.rs @@ -0,0 +1 @@ +pub mod format_source; diff --git a/crates/sqlclean-cli/src/formatting/mod.rs b/crates/sqlclean-cli/src/formatting/mod.rs new file mode 100644 index 0000000..641a36d --- /dev/null +++ b/crates/sqlclean-cli/src/formatting/mod.rs @@ -0,0 +1,4 @@ +//! Canonical layout of SQL text through the engine's formatter. + +pub mod constants; +pub mod main; diff --git a/crates/sqlclean-cli/src/hosting/_helpers/interpreter.rs b/crates/sqlclean-cli/src/hosting/_helpers/interpreter.rs new file mode 100644 index 0000000..5ace31e --- /dev/null +++ b/crates/sqlclean-cli/src/hosting/_helpers/interpreter.rs @@ -0,0 +1,26 @@ +//! The Python interpreter that runs custom Rules. + +use std::env; +use std::path::PathBuf; + +use crate::hosting::constants::PYTHON_VARIABLE; + +const SIBLING_NAMES: [&str; 3] = ["python", "python3", "python.exe"]; +const PATH_FALLBACK: &str = "python3"; + +/// `SQLCLEAN_PYTHON`, else the interpreter installed beside this executable, else `python3`. +pub(crate) fn python_executable() -> PathBuf { + if let Some(value) = env::var_os(PYTHON_VARIABLE) { + return PathBuf::from(value); + } + let sibling: Option = match env::current_exe() { + Ok(current) => current.parent().and_then(|directory| { + SIBLING_NAMES + .iter() + .map(|name| directory.join(name)) + .find(|candidate| candidate.is_file()) + }), + Err(_) => None, + }; + sibling.unwrap_or_else(|| PathBuf::from(PATH_FALLBACK)) +} diff --git a/crates/sqlclean-cli/src/hosting/_helpers/mod.rs b/crates/sqlclean-cli/src/hosting/_helpers/mod.rs new file mode 100644 index 0000000..44f59d9 --- /dev/null +++ b/crates/sqlclean-cli/src/hosting/_helpers/mod.rs @@ -0,0 +1,2 @@ +pub mod interpreter; +pub mod process; diff --git a/crates/sqlclean-cli/src/hosting/_helpers/process.rs b/crates/sqlclean-cli/src/hosting/_helpers/process.rs new file mode 100644 index 0000000..0c60575 --- /dev/null +++ b/crates/sqlclean-cli/src/hosting/_helpers/process.rs @@ -0,0 +1,57 @@ +//! Start the host, write the request to its standard input and parse its standard output. + +use std::io::Write; +use std::process::{Command, Stdio}; + +use crate::hosting::_helpers::interpreter::python_executable; +use crate::hosting::constants::{HOST_PROGRAM, MISSING_PACKAGE_HELP}; +use crate::hosting::models::{HostRequest, HostResponse}; + +const MISSING_MODULE_MARKER: &str = "No module named 'sqlclean"; + +pub(crate) fn run_host(request: &HostRequest) -> Result { + let payload: Vec = serde_json::to_vec(request).map_err(|error| error.to_string())?; + let python = python_executable(); + let mut child = Command::new(&python) + .args(["-c", HOST_PROGRAM]) + .current_dir(&request.root) + .env("PYTHONHASHSEED", "0") + .env("PYTHONDONTWRITEBYTECODE", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| { + format!( + "could not start {}: {error}. {MISSING_PACKAGE_HELP}", + python.display() + ) + })?; + if let Some(mut input) = child.stdin.take() { + input + .write_all(&payload) + .map_err(|error| format!("could not send custom Rules request: {error}"))?; + } + let output = child + .wait_with_output() + .map_err(|error| format!("custom Rules host failed: {error}"))?; + let stderr: String = String::from_utf8_lossy(&output.stderr).into_owned(); + if stderr.contains(MISSING_MODULE_MARKER) { + return Err(MISSING_PACKAGE_HELP.to_owned()); + } + let response: HostResponse = serde_json::from_slice(&output.stdout).map_err(|error| { + format!( + "custom Rules host returned no result ({error}):\n{}", + stderr.trim_end() + ) + })?; + match response.error { + Some(error) => Err(error), + None if !output.status.success() => Err(format!( + "custom Rules host exited with {}:\n{}", + output.status, + stderr.trim_end() + )), + None => Ok(response), + } +} diff --git a/crates/sqlclean-cli/src/hosting/constants.rs b/crates/sqlclean-cli/src/hosting/constants.rs new file mode 100644 index 0000000..b1efdf2 --- /dev/null +++ b/crates/sqlclean-cli/src/hosting/constants.rs @@ -0,0 +1,7 @@ +/// Names a Python interpreter to use instead of the one beside the `sqlclean` executable. +pub const PYTHON_VARIABLE: &str = "SQLCLEAN_PYTHON"; +pub(crate) const HOST_PROGRAM: &str = + "from sqlclean.hosting.main.serve import serve; raise SystemExit(serve())"; +pub(crate) const CATALOGUE_MODE: &str = "catalogue"; +pub(crate) const EVALUATE_MODE: &str = "evaluate"; +pub(crate) const MISSING_PACKAGE_HELP: &str = "Custom Rules need the sqlclean Python package in the environment that runs sqlclean: install it with `pip install sqlclean`, or set SQLCLEAN_PYTHON to an interpreter that has it."; diff --git a/crates/sqlclean-cli/src/hosting/main/mod.rs b/crates/sqlclean-cli/src/hosting/main/mod.rs new file mode 100644 index 0000000..fd108b6 --- /dev/null +++ b/crates/sqlclean-cli/src/hosting/main/mod.rs @@ -0,0 +1 @@ +pub mod run_host; diff --git a/crates/sqlclean-cli/src/hosting/main/run_host.rs b/crates/sqlclean-cli/src/hosting/main/run_host.rs new file mode 100644 index 0000000..7767003 --- /dev/null +++ b/crates/sqlclean-cli/src/hosting/main/run_host.rs @@ -0,0 +1,8 @@ +//! Send one request to a fresh Python host and read its response. + +use crate::hosting::models::{HostRequest, HostResponse}; + +/// Run the Python host for `request`; a host failure or reported error is an `Err`. +pub fn run_host(request: &HostRequest) -> Result { + crate::hosting::_helpers::process::run_host(request) +} diff --git a/crates/sqlclean-cli/src/hosting/mod.rs b/crates/sqlclean-cli/src/hosting/mod.rs new file mode 100644 index 0000000..d12a934 --- /dev/null +++ b/crates/sqlclean-cli/src/hosting/mod.rs @@ -0,0 +1,6 @@ +//! The Python process that imports and runs custom Rules, started only when one is needed. + +pub mod _helpers; +pub mod constants; +pub mod main; +pub mod models; diff --git a/crates/sqlclean-cli/src/hosting/models.rs b/crates/sqlclean-cli/src/hosting/models.rs new file mode 100644 index 0000000..9f00563 --- /dev/null +++ b/crates/sqlclean-cli/src/hosting/models.rs @@ -0,0 +1,59 @@ +//! The JSON exchanged with the Python host. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use sqlclean::sql_facts::models::FileFacts; + +use crate::checking::models::RuleInfo; +use crate::positions::models::Edit; + +/// One request: list the custom Rules, or evaluate the named ones. +#[derive(Debug, Serialize)] +pub struct HostRequest { + pub mode: String, + pub root: String, + pub custom: Vec, + pub options: BTreeMap, + pub files: Vec, + pub repository_rules: Vec, +} + +/// One file and the statement and file Rules to evaluate on it. +#[derive(Debug, Serialize)] +pub struct HostFile { + pub path: String, + pub source: String, + pub dialect: String, + pub facts: FileFacts, + pub rules: Vec, +} + +#[derive(Debug, Default, Deserialize)] +pub struct HostResponse { + #[serde(default)] + pub rules: Vec, + #[serde(default)] + pub results: Vec, + #[serde(default)] + pub error: Option, +} + +/// The findings of one Rule on one file, or on the repository when `path` is `None`. +#[derive(Debug, Deserialize)] +pub struct HostResult { + pub code: String, + pub path: Option, + pub findings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostFinding { + pub path: String, + pub start: usize, + pub end: usize, + pub message: String, + pub remediation: String, + #[serde(default)] + pub edits: Vec, +} diff --git a/crates/sqlclean-cli/src/lib.rs b/crates/sqlclean-cli/src/lib.rs new file mode 100644 index 0000000..13589f9 --- /dev/null +++ b/crates/sqlclean-cli/src/lib.rs @@ -0,0 +1,14 @@ +//! The `sqlclean` command: configuration, discovery, checking, fixing, formatting and reports. + +#![forbid(unsafe_code)] + +pub mod caching; +pub mod checking; +pub mod command; +pub mod config; +pub mod discovery; +pub mod fixing; +pub mod formatting; +pub mod hosting; +pub mod positions; +pub mod reporting; diff --git a/crates/sqlclean-cli/src/main.rs b/crates/sqlclean-cli/src/main.rs new file mode 100644 index 0000000..6f20307 --- /dev/null +++ b/crates/sqlclean-cli/src/main.rs @@ -0,0 +1,9 @@ +//! The `sqlclean` executable. + +use std::process::ExitCode; + +use sqlclean_cli::command::main::run::run; + +fn main() -> ExitCode { + run() +} diff --git a/crates/sqlclean-cli/src/positions/main/apply_edits.rs b/crates/sqlclean-cli/src/positions/main/apply_edits.rs new file mode 100644 index 0000000..5cebdce --- /dev/null +++ b/crates/sqlclean-cli/src/positions/main/apply_edits.rs @@ -0,0 +1,18 @@ +//! Apply non-overlapping character-offset edits to a text. + +use crate::positions::models::Edit; + +/// `text` with every edit applied; `edits` must be sorted by start and must not overlap. +pub fn apply_edits(text: &str, edits: &[Edit]) -> String { + let characters: Vec = text.chars().collect(); + let mut result = String::with_capacity(text.len()); + let mut cursor: usize = 0; + for edit in edits { + let start: usize = edit.start.min(characters.len()).max(cursor); + result.extend(&characters[cursor..start]); + result.push_str(&edit.text); + cursor = edit.end.min(characters.len()).max(start); + } + result.extend(&characters[cursor..]); + result +} diff --git a/crates/sqlclean-cli/src/positions/main/mod.rs b/crates/sqlclean-cli/src/positions/main/mod.rs new file mode 100644 index 0000000..2f22719 --- /dev/null +++ b/crates/sqlclean-cli/src/positions/main/mod.rs @@ -0,0 +1 @@ +pub mod apply_edits; diff --git a/crates/sqlclean-cli/src/positions/mod.rs b/crates/sqlclean-cli/src/positions/mod.rs new file mode 100644 index 0000000..6f6d393 --- /dev/null +++ b/crates/sqlclean-cli/src/positions/mod.rs @@ -0,0 +1,7 @@ +//! Character offsets: their line and column, and edits applied at them. + +pub mod main; +pub mod models; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlclean-cli/src/positions/models.rs b/crates/sqlclean-cli/src/positions/models.rs new file mode 100644 index 0000000..0381d36 --- /dev/null +++ b/crates/sqlclean-cli/src/positions/models.rs @@ -0,0 +1,37 @@ +//! Offsets count Unicode characters, the unit of the engine's spans and of Python strings. + +/// The character offset at which each line of one text starts. +#[derive(Debug, Clone)] +pub struct LineIndex { + starts: Vec, + length: usize, +} + +impl LineIndex { + pub fn new(text: &str) -> Self { + let mut starts: Vec = vec![0]; + let mut length: usize = 0; + for (index, character) in text.chars().enumerate() { + if character == '\n' { + starts.push(index + 1); + } + length = index + 1; + } + Self { starts, length } + } + + /// The 1-based line and column of character `offset`, clamped to the end of the text. + pub fn line_column(&self, offset: usize) -> (usize, usize) { + let offset: usize = offset.min(self.length); + let line: usize = self.starts.partition_point(|start| *start <= offset); + (line, offset - self.starts[line - 1] + 1) + } +} + +/// A replacement of the characters `start..end` with `text`. +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct Edit { + pub start: usize, + pub end: usize, + pub text: String, +} diff --git a/crates/sqlclean-cli/src/positions/tests.rs b/crates/sqlclean-cli/src/positions/tests.rs new file mode 100644 index 0000000..393a10c --- /dev/null +++ b/crates/sqlclean-cli/src/positions/tests.rs @@ -0,0 +1,4 @@ +#[path = "tests/test_positions.rs"] +mod positions; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlclean-cli/src/positions/tests/test_positions.rs b/crates/sqlclean-cli/src/positions/tests/test_positions.rs new file mode 100644 index 0000000..888fc5b --- /dev/null +++ b/crates/sqlclean-cli/src/positions/tests/test_positions.rs @@ -0,0 +1,78 @@ +use crate::positions::main::apply_edits::apply_edits; +use crate::positions::models::{Edit, LineIndex}; +use crate::positions::tests::test_types::{ApplyEditsTestCase, LineColumnTestCase}; + +#[test] +fn given_multibyte_text_when_locating_offsets_then_lines_and_columns_count_characters() { + let index = LineIndex::new("SELECT 'é'\nFROM t\n"); + let test_cases = [ + LineColumnTestCase { + description: "the first character", + offset: 0, + expected_line_column: (1, 1), + }, + LineColumnTestCase { + description: "the closing quote after a two-byte character", + offset: 9, + expected_line_column: (1, 10), + }, + LineColumnTestCase { + description: "the start of the second line", + offset: 11, + expected_line_column: (2, 1), + }, + LineColumnTestCase { + description: "the end of the second line", + offset: 16, + expected_line_column: (2, 6), + }, + LineColumnTestCase { + description: "an offset past the end clamps to the last line", + offset: 99, + expected_line_column: (3, 1), + }, + ]; + for test_case in &test_cases { + assert_eq!( + index.line_column(test_case.offset), + test_case.expected_line_column, + "{}", + test_case.description + ); + } +} + +#[test] +fn given_edits_when_applying_then_offsets_count_characters() { + let test_cases = [ + ApplyEditsTestCase { + description: "insert, replace and delete after a multibyte character", + text: "SELECT 'é' FROM", + edits: &[(0, 0, "-- x\n"), (8, 11, "'e'"), (12, 16, "")], + expected_text: "-- x\nSELECT 'e' ", + }, + ApplyEditsTestCase { + description: "no edits leaves the text unchanged", + text: "SELECT 'é'", + edits: &[], + expected_text: "SELECT 'é'", + }, + ]; + for test_case in &test_cases { + let edits: Vec = test_case + .edits + .iter() + .map(|(start, end, text)| Edit { + start: *start, + end: *end, + text: (*text).to_owned(), + }) + .collect(); + assert_eq!( + apply_edits(test_case.text, &edits), + test_case.expected_text, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlclean-cli/src/positions/tests/test_types.rs b/crates/sqlclean-cli/src/positions/tests/test_types.rs new file mode 100644 index 0000000..5750021 --- /dev/null +++ b/crates/sqlclean-cli/src/positions/tests/test_types.rs @@ -0,0 +1,12 @@ +pub(crate) struct LineColumnTestCase { + pub(crate) description: &'static str, + pub(crate) offset: usize, + pub(crate) expected_line_column: (usize, usize), +} + +pub(crate) struct ApplyEditsTestCase { + pub(crate) description: &'static str, + pub(crate) text: &'static str, + pub(crate) edits: &'static [(usize, usize, &'static str)], + pub(crate) expected_text: &'static str, +} diff --git a/crates/sqlclean-cli/src/reporting/_helpers/location.rs b/crates/sqlclean-cli/src/reporting/_helpers/location.rs new file mode 100644 index 0000000..312c359 --- /dev/null +++ b/crates/sqlclean-cli/src/reporting/_helpers/location.rs @@ -0,0 +1,32 @@ +//! Character offsets to line and column positions. + +use std::collections::HashMap; + +use crate::checking::models::Finding; +use crate::positions::models::LineIndex; +use crate::reporting::models::LocatedFinding; + +pub(crate) fn locate(findings: &[Finding], texts: &HashMap) -> Vec { + let mut indexes: HashMap<&str, LineIndex> = HashMap::new(); + findings + .iter() + .map(|finding| { + let index: &LineIndex = indexes.entry(finding.path.as_str()).or_insert_with(|| { + LineIndex::new(texts.get(&finding.path).map_or("", String::as_str)) + }); + let (line, column) = index.line_column(finding.start); + let (end_line, end_column) = index.line_column(finding.end.max(finding.start)); + LocatedFinding { + code: finding.code.clone(), + path: finding.path.clone(), + line, + column, + end_line, + end_column, + message: finding.message.clone(), + remediation: finding.remediation.clone(), + fix: finding.fix, + } + }) + .collect() +} diff --git a/crates/sqlclean-cli/src/reporting/_helpers/machine.rs b/crates/sqlclean-cli/src/reporting/_helpers/machine.rs new file mode 100644 index 0000000..7753ca0 --- /dev/null +++ b/crates/sqlclean-cli/src/reporting/_helpers/machine.rs @@ -0,0 +1,68 @@ +//! JSON and SARIF reports. + +use serde_json::{Value, json}; + +use crate::checking::models::CheckOutcome; +use crate::reporting::models::LocatedFinding; + +const SARIF_SCHEMA: &str = "https://json.schemastore.org/sarif-2.1.0.json"; +const SARIF_VERSION: &str = "2.1.0"; +const TOOL_NAME: &str = "sqlclean"; +const TOOL_URI: &str = "https://github.com/sqlstack-io/sqlclean"; + +pub(crate) fn render_json(outcome: &CheckOutcome, located: &[LocatedFinding]) -> String { + let report: Value = json!({ + "findings": located, + "stale_exceptions": outcome.stale_exceptions, + "summary": { + "findings": located.len(), + "fixable": located.iter().filter(|finding| finding.fix.is_some()).count(), + "stale_exceptions": outcome.stale_exceptions.len(), + }, + }); + format!("{report:#}\n") +} + +pub(crate) fn render_sarif(located: &[LocatedFinding]) -> String { + let mut codes: Vec<&str> = located + .iter() + .map(|finding| finding.code.as_str()) + .collect(); + codes.sort_unstable(); + codes.dedup(); + let results: Vec = located + .iter() + .map(|finding| { + json!({ + "ruleId": finding.code, + "level": "error", + "message": { "text": format!("{}. {}", finding.message.trim_end_matches('.'), finding.remediation) }, + "locations": [{ + "physicalLocation": { + "artifactLocation": { "uri": finding.path }, + "region": { + "startLine": finding.line, + "startColumn": finding.column, + "endLine": finding.end_line, + "endColumn": finding.end_column.max(finding.column + 1), + }, + }, + }], + }) + }) + .collect(); + let report: Value = json!({ + "$schema": SARIF_SCHEMA, + "version": SARIF_VERSION, + "runs": [{ + "tool": { "driver": { + "name": TOOL_NAME, + "informationUri": TOOL_URI, + "version": env!("CARGO_PKG_VERSION"), + "rules": codes.iter().map(|code| json!({ "id": code })).collect::>(), + }}, + "results": results, + }], + }); + format!("{report:#}\n") +} diff --git a/crates/sqlclean-cli/src/reporting/_helpers/mod.rs b/crates/sqlclean-cli/src/reporting/_helpers/mod.rs new file mode 100644 index 0000000..60264ba --- /dev/null +++ b/crates/sqlclean-cli/src/reporting/_helpers/mod.rs @@ -0,0 +1,3 @@ +pub mod location; +pub mod machine; +pub mod text; diff --git a/crates/sqlclean-cli/src/reporting/_helpers/text.rs b/crates/sqlclean-cli/src/reporting/_helpers/text.rs new file mode 100644 index 0000000..3e697f9 --- /dev/null +++ b/crates/sqlclean-cli/src/reporting/_helpers/text.rs @@ -0,0 +1,110 @@ +//! The terminal report: each finding with its source line, then a summary. + +use std::collections::HashMap; +use std::fmt::Write; + +use crate::checking::models::{CheckOutcome, FixSafety}; +use crate::config::constants::CONFIG_FILE_NAME; +use crate::reporting::models::LocatedFinding; + +pub(crate) fn render( + outcome: &CheckOutcome, + located: &[LocatedFinding], + texts: &HashMap, +) -> String { + let mut report = String::new(); + for finding in located { + report.push_str(&finding_text(finding, texts.get(&finding.path))); + } + for stale in &outcome.stale_exceptions { + let relation: String = stale + .relation + .as_ref() + .map(|relation| format!(" for relation {relation}")) + .unwrap_or_default(); + let _ = writeln!( + report, + "error: stale rule exception: {} in {}{relation} matches no finding; remove it from {CONFIG_FILE_NAME}\n", + stale.rule, stale.path + ); + } + let safe: usize = fix_count(located, FixSafety::Safe); + let unsafe_only: usize = fix_count(located, FixSafety::Unsafe); + let problems: usize = located.len() + outcome.stale_exceptions.len(); + if problems == 0 { + report.push_str("All checks passed.\n"); + } else { + let noun: &str = if problems == 1 { "problem" } else { "problems" }; + let _ = write!(report, "Found {problems} {noun}"); + match (safe, unsafe_only) { + (0, 0) => {} + (safe, 0) => { + let _ = write!(report, " ({safe} fixable with `sqlclean fix`)"); + } + (0, unsafe_only) => { + let _ = write!( + report, + " ({unsafe_only} fixable with `sqlclean fix --unsafe-fixes`)" + ); + } + (safe, unsafe_only) => { + let _ = write!( + report, + " ({safe} fixable with `sqlclean fix`, {unsafe_only} more with `--unsafe-fixes`)" + ); + } + } + report.push_str(".\n"); + } + report +} + +fn fix_count(located: &[LocatedFinding], safety: FixSafety) -> usize { + located + .iter() + .filter(|finding| finding.fix == Some(safety)) + .count() +} + +fn finding_text(finding: &LocatedFinding, text: Option<&String>) -> String { + let mut report = String::new(); + let _ = writeln!(report, "error[{}]: {}", finding.code, finding.message); + let _ = writeln!( + report, + " --> {}:{}:{}", + finding.path, finding.line, finding.column + ); + if let Some(line) = text.and_then(|text| text.lines().nth(finding.line - 1)) { + let number: String = finding.line.to_string(); + let gutter: String = " ".repeat(number.len()); + let width: usize = if finding.end_line == finding.line { + finding.end_column.saturating_sub(finding.column).max(1) + } else { + line.chars() + .count() + .saturating_sub(finding.column - 1) + .max(1) + }; + let _ = writeln!(report, " {gutter} |"); + let _ = writeln!(report, " {number} | {line}"); + let _ = writeln!( + report, + " {gutter} | {}{}", + " ".repeat(finding.column - 1), + "^".repeat(width) + ); + } + for (index, help) in finding.remediation.lines().enumerate() { + let label: &str = if index == 0 { " = help: " } else { "" }; + let _ = writeln!(report, "{label}{help}"); + } + if let Some(fix) = finding.fix { + let flag: &str = match fix { + crate::checking::models::FixSafety::Safe => "", + crate::checking::models::FixSafety::Unsafe => " --unsafe-fixes", + }; + let _ = writeln!(report, " = fix: available with `sqlclean fix{flag}`"); + } + report.push('\n'); + report +} diff --git a/crates/sqlclean-cli/src/reporting/main/mod.rs b/crates/sqlclean-cli/src/reporting/main/mod.rs new file mode 100644 index 0000000..369023b --- /dev/null +++ b/crates/sqlclean-cli/src/reporting/main/mod.rs @@ -0,0 +1 @@ +pub mod render_findings; diff --git a/crates/sqlclean-cli/src/reporting/main/render_findings.rs b/crates/sqlclean-cli/src/reporting/main/render_findings.rs new file mode 100644 index 0000000..00d9a85 --- /dev/null +++ b/crates/sqlclean-cli/src/reporting/main/render_findings.rs @@ -0,0 +1,20 @@ +//! Render one check's outcome in the requested format. + +use std::collections::HashMap; + +use crate::checking::models::CheckOutcome; +use crate::reporting::models::OutputFormat; + +/// The report for `outcome`; `texts` holds each checked file's text by path. +pub fn render_findings( + outcome: &CheckOutcome, + texts: &HashMap, + format: OutputFormat, +) -> String { + let located = crate::reporting::_helpers::location::locate(&outcome.findings, texts); + match format { + OutputFormat::Text => crate::reporting::_helpers::text::render(outcome, &located, texts), + OutputFormat::Json => crate::reporting::_helpers::machine::render_json(outcome, &located), + OutputFormat::Sarif => crate::reporting::_helpers::machine::render_sarif(&located), + } +} diff --git a/crates/sqlclean-cli/src/reporting/mod.rs b/crates/sqlclean-cli/src/reporting/mod.rs new file mode 100644 index 0000000..9c7f2e7 --- /dev/null +++ b/crates/sqlclean-cli/src/reporting/mod.rs @@ -0,0 +1,5 @@ +//! Findings rendered as text for people, JSON for tools and SARIF for code scanning. + +pub mod _helpers; +pub mod main; +pub mod models; diff --git a/crates/sqlclean-cli/src/reporting/models.rs b/crates/sqlclean-cli/src/reporting/models.rs new file mode 100644 index 0000000..315feee --- /dev/null +++ b/crates/sqlclean-cli/src/reporting/models.rs @@ -0,0 +1,24 @@ +use serde::Serialize; + +use crate::checking::models::FixSafety; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum OutputFormat { + Text, + Json, + Sarif, +} + +/// One finding with 1-based line and column positions, as JSON output writes it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LocatedFinding { + pub code: String, + pub path: String, + pub line: usize, + pub column: usize, + pub end_line: usize, + pub end_column: usize, + pub message: String, + pub remediation: String, + pub fix: Option, +} diff --git a/crates/sqlclean/Cargo.toml b/crates/sqlclean/Cargo.toml new file mode 100644 index 0000000..f9dc9b9 --- /dev/null +++ b/crates/sqlclean/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "sqlclean" +version.workspace = true +description = "SQL lint Rules, SQL quality Rules and deterministic formatting over Polyglot." +edition.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true + +[features] +default = [] +# Expose test-only helpers to the tests of dependent crates. +test-support = [] + +[dependencies] +polyglot-sql.workspace = true +polyglot-sql-function-catalogs.workspace = true +rayon.workspace = true +rustc-hash.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true + +[lints] +workspace = true diff --git a/crates/sqlclean/src/constants.rs b/crates/sqlclean/src/constants.rs new file mode 100644 index 0000000..2954b0f --- /dev/null +++ b/crates/sqlclean/src/constants.rs @@ -0,0 +1,15 @@ +//! Constants shared by the engine modules, and the placeholders hosts write for template code. + +pub(crate) const SQL_WILDCARD: &str = "*"; +pub(crate) const OPEN_PAREN: &str = "("; +pub(crate) const CLOSE_PAREN: &str = ")"; +pub(crate) const EMPTY_FIXTURE_QUERY_TOKEN_COUNT: usize = 6; +pub(crate) const ENCLOSING_CTE_TOKEN_COUNT: usize = 3; +/// The indent of each line of a configuration snippet printed in remediation text. +pub(crate) const SETTING_SNIPPET_INDENT: &str = " "; +/// The configuration file remediation text names when the request does not name one. +pub const DEFAULT_CONFIG_FILE: &str = "sqlclean.toml"; +/// The identifier prefix a host writes in place of each template interpolation. +pub const LINT_SENTINEL_PREFIX: &str = "__sqb_lint_"; +/// The identifier prefix a host writes in place of a SQL macro parameter. +pub const SQL_MACRO_PARAMETER_SENTINEL_PREFIX: &str = "__sqlbuild_macro_parameter_"; diff --git a/crates/sqlclean/src/lib.rs b/crates/sqlclean/src/lib.rs new file mode 100644 index 0000000..78fd494 --- /dev/null +++ b/crates/sqlclean/src/lib.rs @@ -0,0 +1,9 @@ +//! SQLClean's engine: lint and quality Rules, statement facts and the formatter, over Polyglot. + +#![forbid(unsafe_code)] + +pub mod constants; +pub mod sql_facts; +pub mod sql_lint; +pub mod sql_quality; +pub mod sql_tokens; diff --git a/crates/sqlclean/src/sql_facts/_helpers/collection.rs b/crates/sqlclean/src/sql_facts/_helpers/collection.rs new file mode 100644 index 0000000..5fd0bbd --- /dev/null +++ b/crates/sqlclean/src/sql_facts/_helpers/collection.rs @@ -0,0 +1,219 @@ +//! Walk each parsed statement once and collect its structural facts. + +use polyglot_sql::expressions::{Column, Cte, Join, TableRef, With}; +use polyglot_sql::tokens::{Span, Token, TokenType}; +use polyglot_sql::{Expression, ExpressionWalk}; + +use crate::sql_facts::_helpers::statements::{StatementTokens, statement_token_ranges}; +use crate::sql_facts::_helpers::tokens::{ + function_nodes, join_clause_span, literal_nodes, star_nodes, +}; +use crate::sql_facts::models::{ + ColumnNode, FileFacts, JoinNode, NamedNode, StatementFacts, TableNode, +}; +use crate::sql_lint::main::parse_sql::parse_sql; +use crate::sql_lint::models::ParsedSql; + +pub(crate) fn file_facts(sql: &str, dialect: &str) -> Result { + let ParsedSql { + tokens, statements, .. + } = parse_sql(sql, dialect)?; + let characters: Vec = sql.chars().collect(); + let ranges: Vec = statement_token_ranges(&tokens); + if ranges.len() != statements.len() { + return Err(format!( + "found {} statements but {} statement ranges", + statements.len(), + ranges.len() + )); + } + let facts: Vec = statements + .iter() + .zip(ranges) + .enumerate() + .map(|(index, (statement, range))| { + statement_facts( + &StatementSource { + index, + statement, + tokens: &tokens[range.first..=range.last], + terminated_end: range.terminated_end, + }, + &characters, + ) + }) + .collect(); + Ok(FileFacts { statements: facts }) +} + +/// One parsed statement, its tokens and the end offset of its terminator. +struct StatementSource<'a> { + index: usize, + statement: &'a Expression, + tokens: &'a [Token], + terminated_end: usize, +} + +fn statement_facts(source: &StatementSource<'_>, characters: &[char]) -> StatementFacts { + let StatementSource { + index, + statement, + tokens, + terminated_end, + } = *source; + let nodes: Vec<&Expression> = statement_nodes(statement); + StatementFacts { + index, + kind: statement.variant_name().to_owned(), + start: tokens.first().map_or(0, |token| token.span.start), + end: tokens.last().map_or(0, |token| token.span.end), + terminated_end, + ctes: sorted( + nodes + .iter() + .flat_map(|node| with_ctes(node)) + .filter_map(|cte| cte_node(cte, tokens)) + .collect(), + ), + tables: sorted(nodes.iter().filter_map(|node| table_node(node)).collect()), + joins: sorted( + nodes + .iter() + .flat_map(|node| select_joins(node)) + .filter_map(|join| join_node(join, tokens)) + .collect(), + ), + stars: star_nodes(tokens), + functions: function_nodes(tokens), + columns: sorted(nodes.iter().filter_map(|node| column_node(node)).collect()), + literals: literal_nodes(tokens, characters), + } +} + +/// Every node of `expression`, including the bodies of the CTEs its queries define. +fn statement_nodes(expression: &Expression) -> Vec<&Expression> { + let mut nodes: Vec<&Expression> = Vec::new(); + for node in expression.dfs() { + nodes.push(node); + for cte in with_ctes(node) { + nodes.extend(statement_nodes(&cte.this)); + } + } + nodes +} + +fn sorted(mut items: Vec) -> Vec { + items.sort(); + items.dedup(); + items +} + +fn with_ctes(node: &Expression) -> &[Cte] { + let with: Option<&With> = match node { + Expression::Select(select) => select.with.as_ref(), + Expression::Union(union) => union.with.as_ref(), + Expression::Intersect(intersect) => intersect.with.as_ref(), + Expression::Except(except) => except.with.as_ref(), + Expression::Insert(insert) => insert.with.as_ref(), + Expression::Update(update) => update.with.as_ref(), + Expression::Delete(delete) => delete.with.as_ref(), + _ => None, + }; + with.map_or(&[], |with| with.ctes.as_slice()) +} + +fn select_joins(node: &Expression) -> &[Join] { + match node { + Expression::Select(select) => select.joins.as_slice(), + _ => &[], + } +} + +/// The CTE located at its name; the parser leaves CTE names unlocated, so read `name AS (`. +fn cte_node(cte: &Cte, tokens: &[Token]) -> Option { + let span: Span = cte.alias.span.or_else(|| { + tokens + .windows(2) + .find(|pair| { + pair[1].token_type == TokenType::As + && pair[0] + .text + .trim_matches(['"', '`', '[', ']']) + .eq_ignore_ascii_case(&cte.alias.name) + }) + .map(|pair| pair[0].span) + })?; + Some(NamedNode { + name: cte.alias.name.clone(), + start: span.start, + end: span.end, + }) +} + +fn table_node(node: &Expression) -> Option { + let Expression::Table(table) = node else { + return None; + }; + let span: Span = table.span.or(table.name.span)?; + let start: usize = [&table.catalog, &table.schema] + .into_iter() + .flatten() + .filter_map(|part| part.span) + .map(|part| part.start) + .fold(span.start, usize::min); + let qualified_name: String = [&table.catalog, &table.schema] + .into_iter() + .flatten() + .map(|part| part.name.as_str()) + .chain([table.name.name.as_str()]) + .collect::>() + .join("."); + Some(TableNode { + start, + end: span.end, + name: table.name.name.clone(), + qualified_name, + alias: table.alias.as_ref().map(|alias| alias.name.clone()), + }) +} + +fn join_node(join: &Join, tokens: &[Token]) -> Option { + let relation: Option<&TableRef> = match &join.this { + Expression::Table(table) => Some(table), + _ => None, + }; + let (start, end) = join_clause_span(tokens, first_span(&join.this)?.start)?; + Some(JoinNode { + start, + end, + kind: format!("{:?}", join.kind).to_ascii_uppercase(), + relation: relation.map(|table| table.name.name.clone()), + has_condition: join.on.is_some() || !join.using.is_empty(), + }) +} + +fn column_node(node: &Expression) -> Option { + let Expression::Column(column) = node else { + return None; + }; + let column: &Column = column; + let span: Span = column.span.or(column.name.span)?; + Some(ColumnNode { + start: span.start, + end: span.end, + name: column.name.name.clone(), + qualifier: column.table.as_ref().map(|table| table.name.clone()), + }) +} + +/// The first located table or column inside `expression`, in source order. +fn first_span(expression: &Expression) -> Option { + expression + .dfs() + .filter_map(|node| match node { + Expression::Table(table) => table.span.or(table.name.span), + Expression::Column(column) => column.span, + _ => None, + }) + .min_by_key(|span| span.start) +} diff --git a/crates/sqlclean/src/sql_facts/_helpers/mod.rs b/crates/sqlclean/src/sql_facts/_helpers/mod.rs new file mode 100644 index 0000000..b7d4c25 --- /dev/null +++ b/crates/sqlclean/src/sql_facts/_helpers/mod.rs @@ -0,0 +1,3 @@ +pub mod collection; +pub mod statements; +pub mod tokens; diff --git a/crates/sqlclean/src/sql_facts/_helpers/statements.rs b/crates/sqlclean/src/sql_facts/_helpers/statements.rs new file mode 100644 index 0000000..fbc66d8 --- /dev/null +++ b/crates/sqlclean/src/sql_facts/_helpers/statements.rs @@ -0,0 +1,38 @@ +//! Statement source ranges from the token stream. + +use polyglot_sql::tokens::{Token, TokenType}; + +/// The first and last token index of one statement, and the end of its terminator. +pub(crate) struct StatementTokens { + pub(crate) first: usize, + pub(crate) last: usize, + pub(crate) terminated_end: usize, +} + +/// The tokens of each non-empty statement, split at semicolons. +pub(crate) fn statement_token_ranges(tokens: &[Token]) -> Vec { + let mut ranges: Vec = Vec::new(); + let mut first: Option = None; + for (index, token) in tokens.iter().enumerate() { + if token.token_type == TokenType::Semicolon { + if let Some(start) = first.take() { + ranges.push(StatementTokens { + first: start, + last: index - 1, + terminated_end: token.span.end, + }); + } + } else if first.is_none() { + first = Some(index); + } + } + if let Some(start) = first { + let last: usize = tokens.len() - 1; + ranges.push(StatementTokens { + first: start, + last, + terminated_end: tokens[last].span.end, + }); + } + ranges +} diff --git a/crates/sqlclean/src/sql_facts/_helpers/tokens.rs b/crates/sqlclean/src/sql_facts/_helpers/tokens.rs new file mode 100644 index 0000000..933a1c1 --- /dev/null +++ b/crates/sqlclean/src/sql_facts/_helpers/tokens.rs @@ -0,0 +1,234 @@ +//! Function calls and literals, which the syntax tree does not locate, read from tokens. + +use polyglot_sql::tokens::{Token, TokenType}; + +use crate::sql_facts::models::{LiteralNode, NamedNode, StarNode}; + +const STRING_KIND: &str = "string"; +const NUMBER_KIND: &str = "number"; +/// Words before `name (` that make the parentheses a column list rather than a call. +const COLUMN_LIST_OWNERS: [&str; 4] = ["INTO", "TABLE", "VIEW", "INDEX"]; + +/// Every call of a named function in `tokens`, located at its name. +pub(crate) fn function_nodes(tokens: &[Token]) -> Vec { + tokens + .windows(2) + .enumerate() + .filter(|(index, pair)| { + pair[1].token_type == TokenType::LParen + && is_callable_name(&pair[0]) + && !owns_column_list(tokens, *index) + }) + .map(|(_, pair)| NamedNode { + name: pair[0].text.clone(), + start: pair[0].span.start, + end: pair[0].span.end, + }) + .collect() +} + +/// Every string and numeric literal in `tokens`. +pub(crate) fn literal_nodes(tokens: &[Token], sql: &[char]) -> Vec { + tokens + .iter() + .filter_map(|token| { + let kind: &str = match token.token_type { + TokenType::String | TokenType::NationalString => STRING_KIND, + TokenType::Number => NUMBER_KIND, + _ => return None, + }; + Some(LiteralNode { + kind: kind.to_owned(), + text: sql + .get(token.span.start..token.span.end) + .map_or_else(|| token.text.clone(), |chars| chars.iter().collect()), + start: token.span.start, + end: token.span.end, + }) + }) + .collect() +} + +/// Every `*` or `qualifier.*` projection in `tokens`; the parser leaves stars unlocated. +pub(crate) fn star_nodes(tokens: &[Token]) -> Vec { + tokens + .iter() + .enumerate() + .filter(|(_, token)| token.token_type == TokenType::Star) + .filter_map(|(index, star)| { + let previous: &Token = tokens.get(index.checked_sub(1)?)?; + if previous.token_type == TokenType::Dot { + let qualifier: &Token = tokens.get(index.checked_sub(2)?)?; + return Some(StarNode { + qualifier: Some(qualifier.text.clone()), + start: qualifier.span.start, + end: star.span.end, + }); + } + matches!( + previous.token_type, + TokenType::Select | TokenType::Comma | TokenType::Distinct | TokenType::All + ) + .then(|| StarNode { + qualifier: None, + start: star.span.start, + end: star.span.end, + }) + }) + .collect() +} + +fn is_callable_name(token: &Token) -> bool { + let adjacent_word: bool = token + .text + .chars() + .next() + .is_some_and(|first| first.is_alphabetic() || first == '_'); + adjacent_word + && !matches!( + token.token_type, + TokenType::String | TokenType::Number | TokenType::NationalString + ) + && !is_clause_keyword(token) +} + +fn is_clause_keyword(token: &Token) -> bool { + matches!( + token.token_type, + TokenType::In + | TokenType::Exists + | TokenType::As + | TokenType::Values + | TokenType::Over + | TokenType::Using + | TokenType::On + | TokenType::From + | TokenType::Join + | TokenType::With + | TokenType::Where + | TokenType::And + | TokenType::Or + | TokenType::Not + | TokenType::Select + | TokenType::Union + | TokenType::Window + | TokenType::Lateral + ) +} + +fn owns_column_list(tokens: &[Token], name_index: usize) -> bool { + name_index + .checked_sub(1) + .and_then(|previous| tokens.get(previous)) + .is_some_and(|previous| { + COLUMN_LIST_OWNERS + .iter() + .any(|owner| previous.text.eq_ignore_ascii_case(owner)) + }) +} + +/// The character span of the join clause whose relation starts at `relation_start`. +pub(crate) fn join_clause_span(tokens: &[Token], relation_start: usize) -> Option<(usize, usize)> { + let relation: usize = tokens + .iter() + .position(|token| token.span.start >= relation_start)?; + let start: usize = join_clause_start(tokens, relation); + let end: usize = join_clause_end(tokens, start); + Some((tokens[start].span.start, tokens[end].span.end)) +} + +/// The first token of the clause: its join keyword and modifiers, or the relation of a comma join. +fn join_clause_start(tokens: &[Token], relation: usize) -> usize { + let keyword: Option = tokens[..relation] + .iter() + .rposition(|token| { + matches!( + token.token_type, + TokenType::Join | TokenType::StraightJoin | TokenType::Comma | TokenType::From + ) + }) + .filter(|index| { + matches!( + tokens[*index].token_type, + TokenType::Join | TokenType::StraightJoin + ) + }); + let Some(mut start) = keyword else { + return relation; + }; + while start > 0 && is_join_modifier(&tokens[start - 1]) { + start -= 1; + } + start +} + +/// The last token of the clause that begins at `start`, before the next clause at its depth. +fn join_clause_end(tokens: &[Token], start: usize) -> usize { + let keywords: usize = tokens[start..] + .iter() + .take_while(|token| { + is_join_modifier(token) + || matches!(token.token_type, TokenType::Join | TokenType::StraightJoin) + }) + .count(); + let mut depth: usize = 0; + for index in start + keywords.max(1)..tokens.len() { + let token: &Token = &tokens[index]; + match token.token_type { + TokenType::LParen => depth += 1, + TokenType::RParen if depth == 0 => return index - 1, + TokenType::RParen => depth -= 1, + _ if depth == 0 && ends_join_clause(tokens, index) => return index - 1, + _ => {} + } + } + tokens.len() - 1 +} + +fn is_join_modifier(token: &Token) -> bool { + matches!( + token.token_type, + TokenType::Left + | TokenType::Right + | TokenType::Inner + | TokenType::Outer + | TokenType::Full + | TokenType::Cross + | TokenType::Natural + | TokenType::Semi + | TokenType::Anti + | TokenType::Positional + ) +} + +/// The token at `index` begins another join or clause, rather than continuing this one. +fn ends_join_clause(tokens: &[Token], index: usize) -> bool { + let token: &Token = &tokens[index]; + let calls_function: bool = tokens + .get(index + 1) + .is_some_and(|next| next.token_type == TokenType::LParen); + let starts_join: bool = (is_join_modifier(token) && !calls_function) + || matches!(token.token_type, TokenType::Join | TokenType::StraightJoin); + starts_join + || matches!( + token.token_type, + TokenType::Comma + | TokenType::Semicolon + | TokenType::Where + | TokenType::Group + | TokenType::GroupBy + | TokenType::Order + | TokenType::OrderBy + | TokenType::Having + | TokenType::Limit + | TokenType::Offset + | TokenType::Fetch + | TokenType::Qualify + | TokenType::Window + | TokenType::Union + | TokenType::Except + | TokenType::Intersect + | TokenType::Pivot + | TokenType::Unpivot + ) +} diff --git a/crates/sqlclean/src/sql_facts/main/file_facts.rs b/crates/sqlclean/src/sql_facts/main/file_facts.rs new file mode 100644 index 0000000..31bae43 --- /dev/null +++ b/crates/sqlclean/src/sql_facts/main/file_facts.rs @@ -0,0 +1,8 @@ +//! The structural facts of every statement in one SQL file. + +use crate::sql_facts::models::FileFacts; + +/// Parse `sql` under `dialect` and return each statement's structural facts. +pub fn file_facts(sql: &str, dialect: &str) -> Result { + crate::sql_facts::_helpers::collection::file_facts(sql, dialect) +} diff --git a/crates/sqlclean/src/sql_facts/main/mod.rs b/crates/sqlclean/src/sql_facts/main/mod.rs new file mode 100644 index 0000000..2177dca --- /dev/null +++ b/crates/sqlclean/src/sql_facts/main/mod.rs @@ -0,0 +1 @@ +pub mod file_facts; diff --git a/crates/sqlclean/src/sql_facts/mod.rs b/crates/sqlclean/src/sql_facts/mod.rs new file mode 100644 index 0000000..1d1f678 --- /dev/null +++ b/crates/sqlclean/src/sql_facts/mod.rs @@ -0,0 +1,8 @@ +//! Structural facts about each SQL statement, which custom Python Rules read. + +pub mod _helpers; +pub mod main; +pub mod models; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlclean/src/sql_facts/models.rs b/crates/sqlclean/src/sql_facts/models.rs new file mode 100644 index 0000000..bd8e28e --- /dev/null +++ b/crates/sqlclean/src/sql_facts/models.rs @@ -0,0 +1,81 @@ +//! Statement facts, located by Unicode character offsets as Python string indexes are. + +use serde::Serialize; + +/// Every statement of one file, in source order. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct FileFacts { + pub statements: Vec, +} + +/// One statement's source range, kind and the common structures it contains. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct StatementFacts { + pub index: usize, + pub kind: String, + pub start: usize, + pub end: usize, + /// The end of the statement including its terminating semicolon, when it has one. + pub terminated_end: usize, + pub ctes: Vec, + pub tables: Vec, + pub joins: Vec, + pub stars: Vec, + pub functions: Vec, + pub columns: Vec, + pub literals: Vec, +} + +/// A named structure such as a CTE or a function call, located at its name. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct NamedNode { + pub start: usize, + pub end: usize, + pub name: String, +} + +/// A relation read in FROM or JOIN, located at its name. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct TableNode { + pub start: usize, + pub end: usize, + pub name: String, + pub qualified_name: String, + pub alias: Option, +} + +/// One join, located at the relation it joins. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct JoinNode { + pub start: usize, + pub end: usize, + pub kind: String, + pub relation: Option, + pub has_condition: bool, +} + +/// One `*` or `qualifier.*` projection. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct StarNode { + pub start: usize, + pub end: usize, + pub qualifier: Option, +} + +/// One column reference, with its qualifier when written. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct ColumnNode { + pub start: usize, + pub end: usize, + pub name: String, + pub qualifier: Option, +} + +/// One string or numeric literal as written. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct LiteralNode { + pub start: usize, + pub end: usize, + pub kind: String, + pub text: String, +} diff --git a/crates/sqlclean/src/sql_facts/tests.rs b/crates/sqlclean/src/sql_facts/tests.rs new file mode 100644 index 0000000..5032545 --- /dev/null +++ b/crates/sqlclean/src/sql_facts/tests.rs @@ -0,0 +1,6 @@ +#[path = "tests/test_file_facts.rs"] +mod file_facts; +#[path = "tests/helpers.rs"] +mod helpers; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlclean/src/sql_facts/tests/helpers.rs b/crates/sqlclean/src/sql_facts/tests/helpers.rs new file mode 100644 index 0000000..19d7237 --- /dev/null +++ b/crates/sqlclean/src/sql_facts/tests/helpers.rs @@ -0,0 +1,4 @@ +/// The characters of `sql` from `start` to `end`. +pub(crate) fn text(sql: &str, start: usize, end: usize) -> String { + sql.chars().skip(start).take(end - start).collect() +} diff --git a/crates/sqlclean/src/sql_facts/tests/test_file_facts.rs b/crates/sqlclean/src/sql_facts/tests/test_file_facts.rs new file mode 100644 index 0000000..7e60040 --- /dev/null +++ b/crates/sqlclean/src/sql_facts/tests/test_file_facts.rs @@ -0,0 +1,231 @@ +use crate::sql_facts::main::file_facts::file_facts; +use crate::sql_facts::models::{FileFacts, StatementFacts}; +use crate::sql_facts::tests::helpers::text; +use crate::sql_facts::tests::test_types::{ParseOutcomeTestCase, StatementFactsTestCase}; + +const TWO_STATEMENTS: &str = "WITH recent AS (SELECT o.order_id, o.* FROM shop.orders AS o WHERE o.total > 10)\n\ +SELECT r.order_id, UPPER(c.name) AS name, 'é' AS mark\n\ +FROM recent AS r LEFT JOIN customers AS c ON r.customer_id = c.customer_id;\n\ +SELECT COUNT(*) FROM customers"; + +#[test] +fn given_statements_when_collecting_facts_then_every_structure_is_located_in_characters() +-> Result<(), String> { + let test_cases = [ + StatementFactsTestCase { + description: "a CTE query after a multibyte literal locates every node by character", + sql: TWO_STATEMENTS, + statement: 0, + expected_kind: "select", + expected_text: "WITH recent AS (SELECT o.order_id, o.* FROM shop.orders AS o WHERE o.total > 10)\n\ +SELECT r.order_id, UPPER(c.name) AS name, 'é' AS mark\n\ +FROM recent AS r LEFT JOIN customers AS c ON r.customer_id = c.customer_id", + expected_terminator: ";", + expected_ctes: &["recent"], + expected_tables: &[ + ("shop.orders", "o", "shop.orders"), + ("recent", "r", "recent"), + ("customers", "c", "customers"), + ], + expected_joins: &[( + "LEFT", + "customers", + true, + "LEFT JOIN customers AS c ON r.customer_id = c.customer_id", + )], + expected_stars: &["o.*"], + expected_functions: &["UPPER"], + expected_literals: &["10", "'é'"], + expected_columns: &[("c", "customer_id"), ("o", "total")], + }, + StatementFactsTestCase { + description: "the last statement has no terminator and spans its own text", + sql: TWO_STATEMENTS, + statement: 1, + expected_kind: "select", + expected_text: "SELECT COUNT(*) FROM customers", + expected_terminator: "", + expected_ctes: &[], + expected_tables: &[("customers", "", "customers")], + expected_joins: &[], + expected_stars: &[], + expected_functions: &["COUNT"], + expected_literals: &[], + expected_columns: &[], + }, + StatementFactsTestCase { + description: "a join without a condition reports it", + sql: "SELECT a.id FROM a CROSS JOIN b", + statement: 0, + expected_kind: "select", + expected_text: "SELECT a.id FROM a CROSS JOIN b", + expected_terminator: "", + expected_ctes: &[], + expected_tables: &[("a", "", "a"), ("b", "", "b")], + expected_joins: &[("CROSS", "b", false, "CROSS JOIN b")], + expected_stars: &[], + expected_functions: &[], + expected_literals: &[], + expected_columns: &[("a", "id")], + }, + StatementFactsTestCase { + description: "join clauses end at the next join or clause, around subqueries and calls", + sql: "SELECT a.id FROM a INNER JOIN (SELECT id FROM b) AS s ON a.id = s.id LEFT JOIN c USING (id) WHERE LEFT(a.name, 1) = 'x'", + statement: 0, + expected_kind: "select", + expected_text: "SELECT a.id FROM a INNER JOIN (SELECT id FROM b) AS s ON a.id = s.id LEFT JOIN c USING (id) WHERE LEFT(a.name, 1) = 'x'", + expected_terminator: "", + expected_ctes: &[], + expected_tables: &[("a", "", "a"), ("b", "", "b"), ("c", "", "c")], + expected_joins: &[ + ( + "INNER", + "", + true, + "INNER JOIN (SELECT id FROM b) AS s ON a.id = s.id", + ), + ("LEFT", "c", true, "LEFT JOIN c USING (id)"), + ], + expected_stars: &[], + expected_functions: &["LEFT"], + expected_literals: &["1", "'x'"], + expected_columns: &[("a", "name"), ("s", "id")], + }, + ]; + for test_case in &test_cases { + let facts: FileFacts = file_facts(test_case.sql, "duckdb")?; + let statement: &StatementFacts = &facts.statements[test_case.statement]; + let located = |start: usize, end: usize| text(test_case.sql, start, end); + assert_eq!( + statement.kind, test_case.expected_kind, + "{}", + test_case.description + ); + assert_eq!( + located(statement.start, statement.end), + test_case.expected_text, + "{}", + test_case.description + ); + assert_eq!( + located(statement.end, statement.terminated_end), + test_case.expected_terminator, + "{}", + test_case.description + ); + let ctes: Vec = statement + .ctes + .iter() + .map(|cte| located(cte.start, cte.end)) + .collect(); + assert_eq!(ctes, test_case.expected_ctes, "{}", test_case.description); + let tables: Vec<(&str, &str, String)> = statement + .tables + .iter() + .map(|table| { + ( + table.qualified_name.as_str(), + table.alias.as_deref().unwrap_or(""), + located(table.start, table.end), + ) + }) + .collect(); + let expected_tables: Vec<(&str, &str, String)> = test_case + .expected_tables + .iter() + .map(|(name, alias, text)| (*name, *alias, (*text).to_owned())) + .collect(); + assert_eq!(tables, expected_tables, "{}", test_case.description); + let joins: Vec<(&str, &str, bool, String)> = statement + .joins + .iter() + .map(|join| { + ( + join.kind.as_str(), + join.relation.as_deref().unwrap_or(""), + join.has_condition, + located(join.start, join.end), + ) + }) + .collect(); + let expected_joins: Vec<(&str, &str, bool, String)> = test_case + .expected_joins + .iter() + .map(|(kind, relation, condition, text)| { + (*kind, *relation, *condition, (*text).to_owned()) + }) + .collect(); + assert_eq!(joins, expected_joins, "{}", test_case.description); + let stars: Vec = statement + .stars + .iter() + .map(|star| located(star.start, star.end)) + .collect(); + assert_eq!(stars, test_case.expected_stars, "{}", test_case.description); + let functions: Vec = statement + .functions + .iter() + .map(|function| located(function.start, function.end)) + .collect(); + assert_eq!( + functions, test_case.expected_functions, + "{}", + test_case.description + ); + let literals: Vec = statement + .literals + .iter() + .map(|literal| located(literal.start, literal.end)) + .collect(); + assert_eq!( + literals, test_case.expected_literals, + "{}", + test_case.description + ); + let columns: Vec<(&str, &str)> = statement + .columns + .iter() + .map(|column| { + ( + column.qualifier.as_deref().unwrap_or(""), + column.name.as_str(), + ) + }) + .collect(); + assert!( + test_case + .expected_columns + .iter() + .all(|expected| columns.contains(expected)), + "{}: {columns:?}", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_sql_text_when_collecting_facts_then_only_unparseable_sql_is_an_error() { + let test_cases = [ + ParseOutcomeTestCase { + description: "a broken statement returns the parse error", + sql: "SELECT FROM WHERE (", + expected_statements: None, + }, + ParseOutcomeTestCase { + description: "two terminated statements both parse", + sql: "SELECT 1; SELECT 2;", + expected_statements: Some(2), + }, + ]; + for test_case in &test_cases { + let statements: Option = file_facts(test_case.sql, "duckdb") + .map(|facts| facts.statements.len()) + .ok(); + assert_eq!( + statements, test_case.expected_statements, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlclean/src/sql_facts/tests/test_types.rs b/crates/sqlclean/src/sql_facts/tests/test_types.rs new file mode 100644 index 0000000..e6329e4 --- /dev/null +++ b/crates/sqlclean/src/sql_facts/tests/test_types.rs @@ -0,0 +1,21 @@ +pub(crate) struct StatementFactsTestCase { + pub(crate) description: &'static str, + pub(crate) sql: &'static str, + pub(crate) statement: usize, + pub(crate) expected_kind: &'static str, + pub(crate) expected_text: &'static str, + pub(crate) expected_terminator: &'static str, + pub(crate) expected_ctes: &'static [&'static str], + pub(crate) expected_tables: &'static [(&'static str, &'static str, &'static str)], + pub(crate) expected_joins: &'static [(&'static str, &'static str, bool, &'static str)], + pub(crate) expected_stars: &'static [&'static str], + pub(crate) expected_functions: &'static [&'static str], + pub(crate) expected_literals: &'static [&'static str], + pub(crate) expected_columns: &'static [(&'static str, &'static str)], +} + +pub(crate) struct ParseOutcomeTestCase { + pub(crate) description: &'static str, + pub(crate) sql: &'static str, + pub(crate) expected_statements: Option, +} diff --git a/crates/sqlclean/src/sql_lint/_helpers/additional.rs b/crates/sqlclean/src/sql_lint/_helpers/additional.rs new file mode 100644 index 0000000..301f421 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/_helpers/additional.rs @@ -0,0 +1,1873 @@ +use std::collections::HashSet; + +use polyglot_sql::tokens::{Span, Token, TokenType}; + +use crate::constants::SQL_MACRO_PARAMETER_SENTINEL_PREFIX; +use crate::constants::{CLOSE_PAREN, OPEN_PAREN, SQL_WILDCARD}; +use crate::constants::{EMPTY_FIXTURE_QUERY_TOKEN_COUNT, ENCLOSING_CTE_TOKEN_COUNT}; +use crate::sql_lint::_helpers::engine::{ + direct_indices, is_comment, is_layout, is_query_from, query_end, significant_after, + significant_before, token_depths, +}; +use crate::sql_lint::constants::RELATION_MODIFIER_KEYWORDS; +use crate::sql_lint::models::{AdditionalFactOptions, AdditionalQueryFacts, SqlMacroOutput}; +use rustc_hash::{FxHashMap, FxHashSet}; + +const COUNT_ONE_LITERAL: &str = "1"; +const QUALIFIED_REFERENCE_LENGTH: usize = 3; +const QUALIFIER_PAIR_LENGTH: usize = 2; +const IMPLICIT_ALIAS_MINIMUM_LENGTH: usize = 2; +const GENERATED_IDENTIFIER_PREFIXES: [&str; 3] = [ + "__sqb_lint_", + "__sqlbuild_audit_parameter_", + "__sqlbuild_context_parameter_", +]; + +struct QuerySlice<'a> { + tokens: &'a [Token], + depths: &'a [usize], + direct: &'a [usize], + start: usize, + end: usize, + depth: usize, +} + +struct ReferenceFactContext<'a> { + query: &'a QuerySlice<'a>, + outer_relations: &'a FxHashSet, + external_identifiers: &'a HashSet, + sql_macro_outputs: &'a [SqlMacroOutput], + query_start: usize, + query_end: usize, +} + +struct ReferenceCandidateContext<'a> { + tokens: &'a [Token], + references: &'a [usize], + external_identifiers: &'a HashSet, + projection_aliases: &'a FxHashMap, +} + +struct SelectFactContext<'a> { + tokens: &'a [Token], + depths: &'a [usize], + external_identifiers: &'a HashSet, + sql_macro_outputs: &'a [SqlMacroOutput], + dependency_identifiers: &'a HashSet, + allows_ceremonial_select: bool, + allows_dynamic_output_star: bool, + allows_empty_fixture_star: bool, +} + +struct ProjectedStarContext<'a> { + tokens: &'a [Token], + depths: &'a [usize], + direct: &'a [usize], + query_end: usize, +} + +pub(super) fn collect_additional_facts( + tokens: &[Token], + options: &AdditionalFactOptions<'_>, +) -> AdditionalQueryFacts { + let mut facts = AdditionalQueryFacts::default(); + let depths = token_depths(tokens); + let significant: Vec = (0..tokens.len()) + .filter(|&index| !is_layout(&tokens[index]) && !is_comment(&tokens[index])) + .collect(); + for (position, &index) in significant.iter().enumerate() { + let token = &tokens[index]; + if token.token_type == TokenType::Union + && significant.get(position + 1).is_none_or(|&next| { + !matches!( + tokens[next].token_type, + TokenType::All | TokenType::Distinct | TokenType::By + ) + }) + { + facts.bare_unions.push(token.span); + } + if token.token_type == TokenType::Else + && significant + .get(position + 1) + .is_some_and(|&next| tokens[next].token_type == TokenType::Null) + { + let next = significant[position + 1]; + facts.redundant_else_nulls.push(Span { + start: token.span.start, + end: tokens[next].span.end, + line: token.span.line, + column: token.span.column, + }); + } + if token.token_type == TokenType::Distinct + && significant + .get(position + 1) + .is_some_and(|&next| tokens[next].token_type == TokenType::LParen) + && let Some(span) = + parenthesized_distinct_span(tokens, &depths, significant[position + 1]) + { + facts.parenthesized_distincts.push(span); + } + if token.token_type == TokenType::Semicolon + && significant + .get(position.wrapping_sub(1)) + .is_some_and(|&previous| tokens[previous].token_type == TokenType::Semicolon) + { + facts.consecutive_semicolons.push(token.span); + } + if is_identifier(token) + && let Some((&as_index, &alias_index)) = significant + .get(position + 1) + .zip(significant.get(position + 2)) + && tokens[as_index].token_type == TokenType::As + && is_identifier(&tokens[alias_index]) + && token.token_type == tokens[alias_index].token_type + && token.text == tokens[alias_index].text + && is_simple_projection_reference(tokens, &depths, &significant, position) + { + facts.redundant_self_aliases.push(Span { + start: token.span.end, + end: tokens[alias_index].span.end, + line: tokens[as_index].span.line, + column: tokens[as_index].span.column, + }); + } + if token.text.eq_ignore_ascii_case("COUNT") + && let Some(span) = count_one_span(tokens, &significant, position) + { + facts.count_one_literals.push(span); + } + if token.text.eq_ignore_ascii_case("ROW_NUMBER") + && let Some(span) = unstable_row_number_span(tokens, &depths, index) + { + facts.unstable_row_numbers.push(span); + } + if token.token_type == TokenType::Not + && let Some(span) = null_not_in_span(tokens, &depths, &significant, position) + { + facts.null_not_in_predicates.push(span); + } + if token.token_type == TokenType::Case + && let Some(span) = simple_boolean_case_span(tokens, &significant, position) + { + facts.simple_boolean_cases.push(span); + } + if token.token_type == TokenType::Else + && significant + .get(position + 1) + .is_some_and(|&next| tokens[next].token_type == TokenType::Case) + { + facts + .nested_else_cases + .push(tokens[significant[position + 1]].span); + } + if position + 2 < significant.len() + && constant_predicate(tokens, &significant[position..position + 3]) + { + facts + .constant_predicates + .push(tokens[significant[position + 1]].span); + } + } + let select_facts = collect_select_additional_facts(&SelectFactContext { + tokens, + depths: &depths, + external_identifiers: options.external_identifiers, + sql_macro_outputs: options.sql_macro_outputs, + dependency_identifiers: options.dependency_identifiers, + allows_ceremonial_select: options.allows_ceremonial_select, + allows_dynamic_output_star: options.allows_dynamic_output_star, + allows_empty_fixture_star: options.allows_empty_fixture_star, + }); + facts.duplicate_output_aliases = select_facts.duplicate_output_aliases; + facts.unaliased_calculations = select_facts.unaliased_calculations; + facts.projected_stars = select_facts.projected_stars; + facts.duplicate_table_aliases = select_facts.duplicate_table_aliases; + facts.unused_table_aliases = select_facts.unused_table_aliases; + facts.null_rejected_left_joins = select_facts.null_rejected_left_joins; + facts.implicit_inner_joins = select_facts.implicit_inner_joins; + facts.unqualified_multi_source_columns = select_facts.unqualified_multi_source_columns; + facts.inconsistent_single_source_qualification = + select_facts.inconsistent_single_source_qualification; + facts.unknown_relation_qualifiers = select_facts.unknown_relation_qualifiers; + facts.unused_joined_relations = select_facts.unused_joined_relations; + facts.mixed_group_order_references = select_facts.mixed_group_order_references; + facts.ambiguous_order_directions = select_facts.ambiguous_order_directions; + facts.set_arity_mismatches = set_arity_mismatch_spans(tokens, &depths); + facts +} + +fn is_simple_projection_reference( + tokens: &[Token], + depths: &[usize], + significant: &[usize], + position: usize, +) -> bool { + let index = significant[position]; + let Some(boundary) = (0..index).rev().find(|&candidate| { + depths[candidate] == depths[index] + && (tokens[candidate].token_type == TokenType::Select + || is_query_from(tokens, candidate) + || tokens[candidate].token_type == TokenType::Join) + }) else { + return false; + }; + if tokens[boundary].token_type != TokenType::Select { + return false; + } + let item_boundary = significant[..position] + .iter() + .rposition(|&candidate| { + candidate > boundary + && depths[candidate] == depths[index] + && tokens[candidate].token_type == TokenType::Comma + }) + .map_or(boundary, |boundary_position| significant[boundary_position]); + let prefix: Vec = significant[..position] + .iter() + .copied() + .filter(|&candidate| candidate > item_boundary && depths[candidate] == depths[index]) + .collect(); + if is_bare_star_prefix(tokens, &prefix) { + return true; + } + let mut qualifier_start = prefix.len(); + while qualifier_start >= QUALIFIER_PAIR_LENGTH + && tokens[prefix[qualifier_start - 1]].token_type == TokenType::Dot + && is_identifier(&tokens[prefix[qualifier_start - 2]]) + { + qualifier_start -= 2; + } + qualifier_start < prefix.len() && is_bare_star_prefix(tokens, &prefix[..qualifier_start]) +} + +fn collect_select_additional_facts(context: &SelectFactContext<'_>) -> AdditionalQueryFacts { + let tokens = context.tokens; + let depths = context.depths; + let mut facts = AdditionalQueryFacts::default(); + for (select_index, token) in tokens.iter().enumerate() { + if token.token_type != TokenType::Select { + continue; + } + let depth = depths[select_index]; + let end = query_end(tokens, depths, select_index, depth); + let direct: Vec = direct_indices(depths, select_index + 1, end, depth) + .into_iter() + .filter(|&index| !is_layout(&tokens[index]) && !is_comment(&tokens[index])) + .collect(); + let from_position = direct + .iter() + .position(|&index| is_query_from(tokens, index)) + .unwrap_or(direct.len()); + facts + .duplicate_output_aliases + .extend(duplicate_alias_spans(tokens, &direct[..from_position])); + if !is_exists_subquery(tokens, select_index) + && !is_set_continuation_select(tokens, select_index) + && !is_scalar_subquery(tokens, depths, select_index) + && !has_enclosing_cte_column_list(tokens, depths, select_index) + && !is_ceremonial_select( + tokens, + &direct[..from_position], + context.allows_ceremonial_select, + ) + { + facts + .unaliased_calculations + .extend(unaliased_calculation_spans( + tokens, + &direct[..from_position], + )); + } + if !is_dependency_import_select(context, select_index, &direct, from_position) + && !is_compiler_proven_dynamic_output_select( + context, + select_index, + &direct, + from_position, + ) + && !is_canonical_empty_input_fixture(context, select_index, end) + && !has_irreducible_projected_star( + &ProjectedStarContext { + tokens, + depths, + direct: &direct, + query_end: end, + }, + from_position, + ) + { + facts + .projected_stars + .extend(projected_star_spans(tokens, &direct[..from_position])); + } + if from_position < direct.len() { + let clause_end = direct[from_position + 1..] + .iter() + .position(|&index| is_after_relation_clause(tokens[index].token_type)) + .map_or(direct.len(), |offset| from_position + 1 + offset); + facts.duplicate_table_aliases.extend(duplicate_alias_spans( + tokens, + &direct[from_position + 1..clause_end], + )); + let query_slice = QuerySlice { + tokens, + depths, + direct: &direct[from_position + 1..clause_end], + start: select_index, + end, + depth, + }; + facts + .unused_table_aliases + .extend(unused_alias_spans(&query_slice)); + facts + .null_rejected_left_joins + .extend(null_rejected_left_join_spans(tokens, &direct, clause_end)); + facts.implicit_inner_joins.extend(implicit_inner_join_spans( + tokens, + &direct[from_position + 1..clause_end], + )); + let reference_slice = QuerySlice { + tokens, + depths, + direct: &direct, + start: from_position, + end: clause_end, + depth, + }; + let outer_relations = outer_relation_names(tokens, depths, select_index, depth); + let reference_context = ReferenceFactContext { + query: &reference_slice, + outer_relations: &outer_relations, + external_identifiers: context.external_identifiers, + sql_macro_outputs: context.sql_macro_outputs, + query_start: select_index, + query_end: end, + }; + let reference_facts = collect_reference_facts(&reference_context); + facts + .unqualified_multi_source_columns + .extend(reference_facts.unqualified_multi_source_columns); + facts + .inconsistent_single_source_qualification + .extend(reference_facts.inconsistent_single_source_qualification); + facts + .unknown_relation_qualifiers + .extend(reference_facts.unknown_relation_qualifiers); + facts + .unused_joined_relations + .extend(reference_facts.unused_joined_relations); + } + facts + .mixed_group_order_references + .extend(mixed_reference_clause_spans(tokens, &direct)); + facts + .ambiguous_order_directions + .extend(ambiguous_order_direction_spans(tokens, &direct)); + } + facts +} + +fn is_canonical_empty_input_fixture( + context: &SelectFactContext<'_>, + select_index: usize, + query_end: usize, +) -> bool { + if !context.allows_empty_fixture_star { + return false; + } + let significant_query: Vec<&str> = context.tokens[select_index..query_end] + .iter() + .filter(|token| !is_layout(token) && !is_comment(token)) + .map(|token| token.text.as_str()) + .collect(); + if significant_query.len() != EMPTY_FIXTURE_QUERY_TOKEN_COUNT + || !significant_query[0].eq_ignore_ascii_case("select") + || significant_query[1] != SQL_WILDCARD + || !significant_query[2].eq_ignore_ascii_case("from") + || !significant_query[3].eq_ignore_ascii_case("__empty_fixture") + || significant_query[4] != OPEN_PAREN + || significant_query[5] != CLOSE_PAREN + { + return false; + } + let previous: Vec<&Token> = context.tokens[..select_index] + .iter() + .filter(|token| !is_layout(token) && !is_comment(token)) + .rev() + .take(ENCLOSING_CTE_TOKEN_COUNT) + .collect(); + if previous.len() != ENCLOSING_CTE_TOKEN_COUNT + || previous[0].token_type != TokenType::LParen + || !previous[1].text.eq_ignore_ascii_case("as") + { + return false; + } + let cte_name = previous[2].text.trim_matches(['"', '`']); + ["__ref__", "__source__", "__seed__"] + .iter() + .any(|prefix| cte_name.starts_with(prefix)) +} + +fn is_compiler_proven_dynamic_output_select( + context: &SelectFactContext<'_>, + select_index: usize, + direct: &[usize], + from_position: usize, +) -> bool { + if !context.allows_dynamic_output_star + || context.depths[select_index] != 0 + || direct[..from_position].len() != 1 + || context.tokens[direct[0]].token_type != TokenType::Star + { + return false; + } + !context.tokens[select_index + 1..] + .iter() + .zip(&context.depths[select_index + 1..]) + .any(|(token, depth)| token.token_type == TokenType::Select && *depth == 0) +} + +fn has_irreducible_projected_star( + context: &ProjectedStarContext<'_>, + from_position: usize, +) -> bool { + let ProjectedStarContext { + tokens, + depths, + direct, + query_end, + } = context; + let projection = &direct[..from_position]; + if projection.len() != 1 || tokens[projection[0]].token_type != TokenType::Star { + return false; + } + + let Some(relation_start) = from_position + .checked_add(1) + .filter(|&start| start < direct.len()) + else { + return false; + }; + let relation_end = direct[relation_start..] + .iter() + .position(|&index| is_after_relation_clause(tokens[index].token_type)) + .map_or(direct.len(), |offset| relation_start + offset); + let relation = &direct[relation_start..relation_end]; + if relation + .iter() + .any(|&index| matches!(tokens[index].token_type, TokenType::Join | TokenType::Comma)) + { + return false; + } + if is_lone_audit_parameter_relation(tokens, relation) { + return true; + } + + let mut pivots = relation + .iter() + .copied() + .filter(|&index| tokens[index].text.eq_ignore_ascii_case("PIVOT")); + let Some(pivot_index) = pivots.next() else { + return false; + }; + if pivots.next().is_some() { + return false; + } + dynamic_pivot_uses_any(tokens, depths, pivot_index, *query_end) +} + +fn is_lone_audit_parameter_relation(tokens: &[Token], relation: &[usize]) -> bool { + let Some(&relation_index) = relation.first() else { + return false; + }; + let normalized = tokens[relation_index].text.to_ascii_lowercase(); + let Some(index) = normalized + .strip_prefix("__sqlbuild_audit_parameter_") + .and_then(|value| value.strip_suffix("__")) + else { + return false; + }; + if index.is_empty() || !index.chars().all(|character| character.is_ascii_digit()) { + return false; + } + match relation.get(1..) { + Some([]) => true, + Some([alias]) => is_identifier(&tokens[*alias]), + Some([as_index, alias]) => { + tokens[*as_index].token_type == TokenType::As && is_identifier(&tokens[*alias]) + } + _ => false, + } +} + +fn dynamic_pivot_uses_any( + tokens: &[Token], + depths: &[usize], + pivot_index: usize, + query_end: usize, +) -> bool { + let Some(pivot_open) = significant_after(tokens, pivot_index) else { + return false; + }; + if tokens[pivot_open].token_type != TokenType::LParen { + return false; + } + let pivot_depth = depths[pivot_open]; + let Some(pivot_close) = (pivot_open + 1..query_end).find(|&index| { + depths[index] == pivot_depth + 1 && tokens[index].token_type == TokenType::RParen + }) else { + return false; + }; + (pivot_open + 1..pivot_close).any(|in_index| { + depths[in_index] == pivot_depth + 1 + && tokens[in_index].token_type == TokenType::In + && significant_after(tokens, in_index).is_some_and(|any_open| { + any_open < pivot_close + && tokens[any_open].token_type == TokenType::LParen + && significant_after(tokens, any_open).is_some_and(|any_index| { + any_index < pivot_close && tokens[any_index].token_type == TokenType::Any + }) + }) + }) +} + +fn has_enclosing_cte_column_list(tokens: &[Token], depths: &[usize], select_index: usize) -> bool { + let select_depth = depths[select_index]; + let Some(body_open) = (0..select_index).rev().find(|&index| { + tokens[index].token_type == TokenType::LParen + && depths[index].saturating_add(1) == select_depth + }) else { + return false; + }; + let Some(as_index) = significant_before(tokens, body_open) else { + return false; + }; + if tokens[as_index].token_type != TokenType::As { + return false; + } + significant_before(tokens, as_index) + .is_some_and(|index| tokens[index].token_type == TokenType::RParen) +} + +fn is_dependency_import_select( + context: &SelectFactContext<'_>, + select_index: usize, + direct: &[usize], + from_position: usize, +) -> bool { + let tokens = context.tokens; + let depths = context.depths; + if from_position != 1 + || tokens[direct[0]].token_type != TokenType::Star + || !is_top_level_cte_body(tokens, depths, select_index) + { + return false; + } + if direct.len() <= from_position + 1 || has_cte_body_set_operation(tokens, depths, select_index) + { + return false; + } + let relation = &direct[from_position + 1..]; + let Some(&relation_index) = relation.first() else { + return false; + }; + if !context + .dependency_identifiers + .contains(&tokens[relation_index].text.to_ascii_lowercase()) + { + return false; + } + match relation { + [_] => true, + [_, alias] => is_identifier(&tokens[*alias]), + [_, explicit_as, alias] => { + tokens[*explicit_as].token_type == TokenType::As && is_identifier(&tokens[*alias]) + } + _ => false, + } +} + +fn has_cte_body_set_operation(tokens: &[Token], depths: &[usize], select_index: usize) -> bool { + let select_depth = depths[select_index]; + let body_end = (select_index + 1..tokens.len()) + .find(|&index| { + depths[index] == select_depth && tokens[index].token_type == TokenType::RParen + }) + .unwrap_or(tokens.len()); + (select_index + 1..body_end).any(|index| { + depths[index] == select_depth + && matches!( + tokens[index].token_type, + TokenType::Union | TokenType::Intersect | TokenType::Except + ) + }) +} + +fn is_top_level_cte_body(tokens: &[Token], depths: &[usize], select_index: usize) -> bool { + let Some(open_index) = significant_before(tokens, select_index) else { + return false; + }; + if tokens[open_index].token_type != TokenType::LParen || depths[open_index] != 0 { + return false; + } + significant_before(tokens, open_index) + .is_some_and(|index| tokens[index].token_type == TokenType::As) +} + +fn collect_reference_facts(context: &ReferenceFactContext<'_>) -> AdditionalQueryFacts { + let query = context.query; + let QuerySlice { + tokens, + direct, + start: from_position, + end: relation_end, + .. + } = query; + let from_position = *from_position; + let relation_end = *relation_end; + let mut facts = AdditionalQueryFacts::default(); + let relations = &direct[from_position + 1..relation_end]; + let relation_count = if is_values_relation(tokens, relations) { + 1 + } else { + 1 + relations + .iter() + .filter(|&&index| { + matches!(tokens[index].token_type, TokenType::Join | TokenType::Comma) + }) + .count() + }; + let mut known = relation_names(tokens, relations); + known.extend(context.outer_relations.iter().cloned()); + let references: Vec = direct[..from_position] + .iter() + .chain(direct[relation_end..].iter()) + .copied() + .collect(); + let projection_aliases = projection_alias_positions(tokens, &direct[..from_position]); + let candidate_context = ReferenceCandidateContext { + tokens, + references: &references, + external_identifiers: context.external_identifiers, + projection_aliases: &projection_aliases, + }; + let qualified: Vec = references + .iter() + .enumerate() + .filter_map(|(position, &index)| simple_qualifier(tokens, &references, position, index)) + .collect(); + let unqualified: Vec = references + .iter() + .enumerate() + .filter_map(|(position, &index)| unqualified_reference(&candidate_context, position, index)) + .collect(); + if relation_count > 1 { + facts + .unqualified_multi_source_columns + .extend(unqualified.iter().map(|&index| tokens[index].span)); + } else { + facts + .inconsistent_single_source_qualification + .extend(inconsistent_qualification( + tokens, + (&qualified, &unqualified), + context.sql_macro_outputs, + )); + } + facts.unknown_relation_qualifiers.extend( + qualified + .iter() + .filter(|&&index| !known.contains(&tokens[index].text.to_ascii_lowercase())) + .map(|&index| tokens[index].span), + ); + let projects_bare_star = has_bare_projected_star(tokens, &direct[..from_position]); + if !projects_bare_star { + facts.unused_joined_relations.extend(unused_join_spans( + query, + context.query_start, + context.query_end, + )); + } + facts +} + +/// The unqualified references of a single-relation scope that also qualifies some, each judged as it was written. In a macro file a plain slot read such as `@orders.amount` lints as a parameter sentinel pair and counts as unqualified. In a call's output, a read qualified by a relation the call passes takes neither style: the slot decides how it expands, not the caller or the macro author. A scope whose every reference a macro call generated is judged once, in the macro file. +fn inconsistent_qualification( + tokens: &[Token], + (qualified, unqualified): (&[usize], &[usize]), + outputs: &[SqlMacroOutput], +) -> Vec { + let output_of = |index: usize| -> Option { + let start: usize = tokens[index].span.start; + outputs + .iter() + .position(|output| output.start <= start && start < output.end) + }; + let mut references = qualified.iter().chain(unqualified).copied(); + if let Some(first) = references.next() + && let Some(output) = output_of(first) + && references.all(|index| output_of(index) == Some(output)) + { + return Vec::new(); + } + let passed_relation_read = |index: &usize| { + output_of(*index).is_some_and(|output| { + let qualifier: String = unquoted_name(&tokens[*index].text); + outputs[output] + .relations + .iter() + .any(|relation| relation.eq_ignore_ascii_case(&qualifier)) + }) + }; + let (plain_reads, qualified): (Vec, Vec) = qualified + .iter() + .copied() + .filter(|index| !passed_relation_read(index)) + .partition(|&index| is_plain_slot_read(tokens, index)); + if qualified.is_empty() || (unqualified.is_empty() && plain_reads.is_empty()) { + return Vec::new(); + } + let mut unqualified: Vec = unqualified.to_vec(); + unqualified.extend(plain_reads); + unqualified.sort_unstable(); + unqualified + .iter() + .map(|&index| tokens[index].span) + .collect() +} + +/// An identifier's name without its quotes. +fn unquoted_name(text: &str) -> String { + text.trim_matches(|character| matches!(character, '"' | '`' | '[' | ']')) + .to_owned() +} + +/// Whether the qualified reference at `index` is a plain SQL macro slot read, both its relation and its slot a macro parameter. +fn is_plain_slot_read(tokens: &[Token], index: usize) -> bool { + let is_parameter = |token: &Token| { + token + .text + .to_ascii_lowercase() + .starts_with(SQL_MACRO_PARAMETER_SENTINEL_PREFIX) + }; + is_parameter(&tokens[index]) && tokens.get(index + 2).is_some_and(is_parameter) +} + +fn simple_qualifier( + tokens: &[Token], + references: &[usize], + position: usize, + index: usize, +) -> Option { + let previous = position.checked_sub(1).map(|value| references[value]); + let dot = references.get(position + 1).copied(); + let column = references.get(position + 2).copied(); + let following = references.get(position + 3).copied(); + (is_identifier(&tokens[index]) + && !is_generated_identifier(&tokens[index]) + && previous.is_none_or(|candidate| { + !matches!( + tokens[candidate].token_type, + TokenType::Dot | TokenType::Colon | TokenType::DColon | TokenType::DotColon + ) + }) + && dot.is_some_and(|candidate| tokens[candidate].token_type == TokenType::Dot) + && column.is_some_and(|candidate| is_identifier(&tokens[candidate])) + && following.is_none_or(|candidate| tokens[candidate].token_type != TokenType::Dot)) + .then_some(index) +} + +fn unqualified_reference( + context: &ReferenceCandidateContext<'_>, + position: usize, + index: usize, +) -> Option { + let ReferenceCandidateContext { + tokens, + references, + external_identifiers, + projection_aliases, + } = context; + let previous = position.checked_sub(1).map(|value| references[value]); + let next = references.get(position + 1).copied(); + (is_identifier(&tokens[index]) + && !is_generated_identifier(&tokens[index]) + && !is_context_value(&tokens[index]) + && !external_identifiers.contains(&tokens[index].text.to_ascii_lowercase()) + && projection_aliases + .get(&tokens[index].text.to_ascii_lowercase()) + .is_none_or(|alias_index| *alias_index >= index) + && previous.is_none_or(|candidate| { + !matches!( + tokens[candidate].token_type, + TokenType::Dot + | TokenType::As + | TokenType::Colon + | TokenType::DColon + | TokenType::DotColon + ) + }) + && next.is_none_or(|candidate| { + !matches!( + tokens[candidate].token_type, + TokenType::Dot + | TokenType::LParen + | TokenType::Colon + | TokenType::DColon + | TokenType::DotColon + ) + })) + .then_some(index) +} + +fn relation_names(tokens: &[Token], relation_clause: &[usize]) -> FxHashSet { + let mut names: FxHashSet = FxHashSet::default(); + for (position, &index) in relation_clause.iter().enumerate() { + let starts_relation = position == 0 + || matches!( + tokens[relation_clause[position - 1]].token_type, + TokenType::Join | TokenType::Comma + ); + if starts_relation && is_identifier(&tokens[index]) { + names.insert(tokens[index].text.to_ascii_lowercase()); + } + if tokens[index].token_type == TokenType::As + && let Some(&alias) = relation_clause.get(position + 1) + && is_identifier(&tokens[alias]) + { + names.insert(tokens[alias].text.to_ascii_lowercase()); + } + } + let mut segment_start = 0_usize; + for segment_end in relation_clause + .iter() + .enumerate() + .filter_map(|(position, &index)| { + matches!(tokens[index].token_type, TokenType::Join | TokenType::Comma) + .then_some(position) + }) + .chain(std::iter::once(relation_clause.len())) + { + let segment = &relation_clause[segment_start..segment_end]; + segment_start = segment_end + 1; + let relation_end = segment + .iter() + .position(|&index| is_relation_condition_start(&tokens[index])) + .unwrap_or(segment.len()); + if let Some(&implicit_name) = segment[..relation_end] + .iter() + .rev() + .find(|&&index| is_identifier(&tokens[index])) + { + names.insert(tokens[implicit_name].text.to_ascii_lowercase()); + } + } + names +} + +fn outer_relation_names( + tokens: &[Token], + depths: &[usize], + select_index: usize, + depth: usize, +) -> FxHashSet { + let mut names: FxHashSet = FxHashSet::default(); + for outer_select in (0..select_index).filter(|&index| { + tokens[index].token_type == TokenType::Select + && depths[index] < depth + && query_end(tokens, depths, index, depths[index]) > select_index + }) { + let outer_depth = depths[outer_select]; + let outer_end = query_end(tokens, depths, outer_select, outer_depth); + let direct: Vec = direct_indices(depths, outer_select + 1, outer_end, outer_depth) + .into_iter() + .filter(|&index| !is_layout(&tokens[index]) && !is_comment(&tokens[index])) + .collect(); + let Some(from_position) = direct + .iter() + .position(|&index| is_query_from(tokens, index)) + else { + continue; + }; + let clause_end = direct[from_position + 1..] + .iter() + .position(|&index| is_after_relation_clause(tokens[index].token_type)) + .map_or(direct.len(), |offset| from_position + 1 + offset); + names.extend(relation_names( + tokens, + &direct[from_position + 1..clause_end], + )); + } + names +} + +fn projected_star_spans(tokens: &[Token], projection: &[usize]) -> Vec { + let mut spans: Vec = Vec::new(); + for (position, &index) in projection.iter().enumerate() { + if tokens[index].token_type != TokenType::Star { + continue; + } + let previous = position.checked_sub(1).map(|value| projection[value]); + let qualified = + previous.is_some_and(|candidate| tokens[candidate].token_type == TokenType::Dot); + let mut item_start = 0_usize; + for prior_position in (0..position).rev() { + if tokens[projection[prior_position]].token_type == TokenType::Comma { + item_start = prior_position + 1; + break; + } + } + let bare = is_bare_star_prefix(tokens, &projection[item_start..position]); + if qualified || bare { + spans.push(tokens[index].span); + } + } + spans +} + +fn has_bare_projected_star(tokens: &[Token], projection: &[usize]) -> bool { + projection.iter().enumerate().any(|(position, &index)| { + if tokens[index].token_type != TokenType::Star { + return false; + } + let mut item_start = 0_usize; + for prior_position in (0..position).rev() { + if tokens[projection[prior_position]].token_type == TokenType::Comma { + item_start = prior_position + 1; + break; + } + } + is_bare_star_prefix(tokens, &projection[item_start..position]) + }) +} + +fn is_exists_subquery(tokens: &[Token], select_index: usize) -> bool { + let Some(open_index) = significant_before(tokens, select_index) else { + return false; + }; + tokens[open_index].token_type == TokenType::LParen + && significant_before(tokens, open_index) + .is_some_and(|index| tokens[index].token_type == TokenType::Exists) +} + +fn is_set_continuation_select(tokens: &[Token], select_index: usize) -> bool { + let mut previous = significant_before(tokens, select_index); + if previous.is_some_and(|index| { + matches!( + tokens[index].token_type, + TokenType::All | TokenType::Distinct + ) + }) { + previous = previous.and_then(|index| significant_before(tokens, index)); + } + previous.is_some_and(|index| { + matches!( + tokens[index].token_type, + TokenType::Union | TokenType::Intersect | TokenType::Except + ) + }) +} + +fn is_bare_star_prefix(tokens: &[Token], prefix: &[usize]) -> bool { + let mut position = 0_usize; + while prefix.get(position).is_some_and(|&index| { + matches!( + tokens[index].token_type, + TokenType::Distinct | TokenType::All + ) + }) { + position += 1; + } + if position == prefix.len() { + return true; + } + if tokens[prefix[position]].token_type != TokenType::Top { + return false; + } + position += 1; + if !prefix + .get(position) + .is_some_and(|&index| tokens[index].token_type == TokenType::Number) + { + return false; + } + position += 1; + if prefix + .get(position) + .is_some_and(|&index| tokens[index].token_type == TokenType::Percent) + { + position += 1; + } + if prefix + .get(position) + .is_some_and(|&index| tokens[index].token_type == TokenType::With) + && prefix + .get(position + 1) + .is_some_and(|&index| tokens[index].token_type == TokenType::Ties) + { + position += 2; + } + position == prefix.len() +} + +fn is_scalar_subquery(tokens: &[Token], depths: &[usize], select_index: usize) -> bool { + let Some(open_index) = significant_before(tokens, select_index) else { + return false; + }; + if tokens[open_index].token_type != TokenType::LParen { + return false; + } + let Some(previous) = significant_before(tokens, open_index) else { + return false; + }; + if matches!( + tokens[previous].token_type, + TokenType::From | TokenType::Join | TokenType::As | TokenType::Exists | TokenType::Lateral + ) { + return false; + } + if tokens[previous].token_type != TokenType::Comma { + return true; + } + let parent_depth = depths[previous]; + let query_start = (0..previous) + .rev() + .find(|&index| { + tokens[index].token_type == TokenType::Select && depths[index] < parent_depth + }) + .map_or(0, |index| index + 1); + let relation_boundary = (query_start..previous).rev().find(|&index| { + depths[index] == parent_depth + && (tokens[index].token_type == TokenType::Select + || is_query_from(tokens, index) + || matches!( + tokens[index].token_type, + TokenType::Join | TokenType::Semicolon + )) + }); + !relation_boundary.is_some_and(|index| { + is_query_from(tokens, index) || tokens[index].token_type == TokenType::Join + }) +} + +fn is_ceremonial_select(tokens: &[Token], projection: &[usize], allowed: bool) -> bool { + allowed + && projection.len() == 1 + && tokens[projection[0]].token_type == TokenType::Number + && tokens[projection[0]].text == COUNT_ONE_LITERAL +} + +fn is_values_relation(tokens: &[Token], relation_clause: &[usize]) -> bool { + relation_clause + .first() + .is_some_and(|&index| tokens[index].token_type == TokenType::Values) +} + +fn is_generated_identifier(token: &Token) -> bool { + let normalized = token.text.to_ascii_lowercase(); + GENERATED_IDENTIFIER_PREFIXES + .iter() + .any(|prefix| normalized.starts_with(prefix)) +} + +fn is_relation_condition_start(token: &Token) -> bool { + matches!( + token.token_type, + TokenType::On | TokenType::Using | TokenType::MatchCondition + ) || token.text.eq_ignore_ascii_case("MATCH_CONDITION") +} + +fn is_context_value(token: &Token) -> bool { + matches!( + token.text.to_ascii_uppercase().as_str(), + "CURRENT_CATALOG" + | "CURRENT_DATE" + | "CURRENT_DATETIME" + | "CURRENT_ROLE" + | "CURRENT_SCHEMA" + | "CURRENT_TIME" + | "CURRENT_TIMESTAMP" + | "CURRENT_USER" + ) +} + +fn projection_alias_positions(tokens: &[Token], projection: &[usize]) -> FxHashMap { + let mut aliases: FxHashMap = FxHashMap::default(); + for window in projection.windows(2) { + if tokens[window[0]].token_type == TokenType::As && is_identifier(&tokens[window[1]]) { + aliases.insert(tokens[window[1]].text.to_ascii_lowercase(), window[1]); + } + } + aliases +} + +fn unused_join_spans(query: &QuerySlice<'_>, query_start: usize, query_end: usize) -> Vec { + let QuerySlice { + tokens, + direct, + start: from_position, + end: relation_end, + .. + } = query; + let from_position = *from_position; + let relation_end = *relation_end; + let relation_start_index = direct.get(from_position + 1).copied().unwrap_or(query_end); + let relation_end_index = direct.get(relation_end).copied().unwrap_or(query_end); + let references: Vec = (query_start..relation_start_index) + .chain(relation_end_index..query_end) + .filter(|&index| !is_layout(&tokens[index]) && !is_comment(&tokens[index])) + .collect(); + if contains_possible_unqualified_reference(tokens, &references) { + return Vec::new(); + } + let mut spans: Vec = Vec::new(); + for position in from_position + 1..relation_end { + let index = direct[position]; + if tokens[index].token_type != TokenType::Join { + continue; + } + let Some(alias) = relation_alias_after_join(tokens, direct, position) else { + continue; + }; + let used_outside_relation_clause = + contains_qualified_reference(tokens, &references, &alias); + let downstream_relation_start = direct[position + 1..relation_end] + .iter() + .position(|&candidate| { + matches!( + tokens[candidate].token_type, + TokenType::Join | TokenType::Comma + ) + }) + .map(|offset| position + 1 + offset); + let used_by_downstream_relation = downstream_relation_start.is_some_and(|start| { + let downstream_start_index = direct[start]; + let downstream_end_index = direct.get(relation_end).copied().unwrap_or(query_end); + let downstream_references: Vec = (downstream_start_index..downstream_end_index) + .filter(|&candidate| { + !is_layout(&tokens[candidate]) && !is_comment(&tokens[candidate]) + }) + .collect(); + contains_qualified_reference(tokens, &downstream_references, &alias) + }); + let used = used_outside_relation_clause || used_by_downstream_relation; + if !used { + spans.push(tokens[index].span); + } + } + spans +} + +fn contains_possible_unqualified_reference(tokens: &[Token], references: &[usize]) -> bool { + references.iter().enumerate().any(|(position, &index)| { + if !is_identifier(&tokens[index]) + || is_generated_identifier(&tokens[index]) + || is_context_value(&tokens[index]) + { + return false; + } + let previous = if position == 0 { + None + } else { + Some(references[position - 1]) + }; + let next = references.get(position + 1).copied(); + previous.is_none_or(|candidate| { + !matches!(tokens[candidate].token_type, TokenType::Dot | TokenType::As) + }) && next.is_none_or(|candidate| { + !matches!( + tokens[candidate].token_type, + TokenType::Dot | TokenType::LParen + ) + }) + }) +} + +fn contains_qualified_reference(tokens: &[Token], references: &[usize], alias: &str) -> bool { + references.windows(2).any(|window| { + tokens[window[0]].text.eq_ignore_ascii_case(alias) + && tokens[window[1]].token_type == TokenType::Dot + }) +} + +fn is_after_relation_clause(token_type: TokenType) -> bool { + matches!( + token_type, + TokenType::Where + | TokenType::Group + | TokenType::Having + | TokenType::Qualify + | TokenType::Order + | TokenType::Limit + | TokenType::Offset + | TokenType::Fetch + | TokenType::Union + | TokenType::Intersect + | TokenType::Except + | TokenType::Semicolon + ) +} + +fn unaliased_calculation_spans(tokens: &[Token], projection: &[usize]) -> Vec { + let mut spans: Vec = Vec::new(); + let mut start = 0_usize; + let ends: Vec = projection + .iter() + .enumerate() + .filter_map(|(position, &index)| { + (tokens[index].token_type == TokenType::Comma).then_some(position) + }) + .chain(std::iter::once(projection.len())) + .collect(); + for end in ends { + let item = &projection[start..end]; + start = end + 1; + if item.is_empty() + || item + .iter() + .any(|&index| tokens[index].token_type == TokenType::As) + || item + .iter() + .any(|&index| is_generated_identifier(&tokens[index])) + || simple_projection(tokens, item) + || implicit_alias_projection(tokens, item) + { + continue; + } + spans.push(tokens[item[0]].span); + } + spans +} + +fn simple_projection(tokens: &[Token], item: &[usize]) -> bool { + let trimmed: Vec = item + .iter() + .copied() + .skip_while(|&index| { + matches!( + tokens[index].token_type, + TokenType::Distinct | TokenType::All + ) + }) + .collect(); + let wildcard = trimmed.first().is_some_and(|&index| { + tokens[index].token_type == TokenType::Star + || (trimmed.len() >= QUALIFIED_REFERENCE_LENGTH + && is_identifier(&tokens[index]) + && tokens[trimmed[1]].token_type == TokenType::Dot + && tokens[trimmed[2]].token_type == TokenType::Star) + }); + wildcard + || trimmed.len() == 1 + && (is_bare_column_token(&tokens[trimmed[0]]) + || tokens[trimmed[0]].token_type == TokenType::Star) + || trimmed.len() == QUALIFIED_REFERENCE_LENGTH + && is_identifier(&tokens[trimmed[0]]) + && tokens[trimmed[1]].token_type == TokenType::Dot + && (is_bare_column_token(&tokens[trimmed[2]]) + || tokens[trimmed[2]].token_type == TokenType::Star) +} + +fn is_bare_column_token(token: &Token) -> bool { + is_identifier(token) + || (!matches!( + token.token_type, + TokenType::Null + | TokenType::True + | TokenType::False + | TokenType::Number + | TokenType::String + ) && token + .text + .chars() + .next() + .is_some_and(|character| character.is_alphabetic() || character == '_') + && token + .text + .chars() + .all(|character| character.is_alphanumeric() || character == '_')) +} + +fn implicit_alias_projection(tokens: &[Token], item: &[usize]) -> bool { + item.len() >= IMPLICIT_ALIAS_MINIMUM_LENGTH + && is_identifier(&tokens[*item.last().unwrap_or(&item[0])]) + && !matches!( + tokens[item[item.len() - 2]].token_type, + TokenType::Dot | TokenType::Plus | TokenType::Dash | TokenType::Star | TokenType::Slash + ) +} + +fn unused_alias_spans(query: &QuerySlice<'_>) -> Vec { + let QuerySlice { + tokens, + depths, + direct: relation_clause, + start: query_start, + end: query_end, + depth, + } = query; + let query_start = *query_start; + let query_end = *query_end; + let depth = *depth; + let mut spans: Vec = Vec::new(); + for (span_start, alias_index) in relation_alias_positions(tokens, relation_clause) { + if significant_after(tokens, alias_index) + .is_some_and(|index| tokens[index].token_type == TokenType::LParen) + { + continue; + } + let alias = &tokens[alias_index].text; + let relation_name = significant_before(tokens, span_start); + let used = (query_start..query_end).any(|index| { + index != alias_index + && Some(index) != relation_name + && depths[index] >= depth + && is_identifier(&tokens[index]) + && tokens[index].text.eq_ignore_ascii_case(alias) + && significant_before(tokens, index).is_none_or(|previous| { + !matches!(tokens[previous].token_type, TokenType::Dot | TokenType::As) + }) + && significant_after(tokens, index) + .is_none_or(|next| tokens[next].token_type != TokenType::LParen) + }); + if !used { + spans.push(Span { + start: tokens[span_start].span.start, + end: tokens[alias_index].span.end, + line: tokens[span_start].span.line, + column: tokens[span_start].span.column, + }); + } + } + spans +} + +fn relation_alias_positions(tokens: &[Token], relation_clause: &[usize]) -> Vec<(usize, usize)> { + let mut aliases: Vec<(usize, usize)> = Vec::new(); + let mut segment_start = 0_usize; + let mut in_condition = false; + for (position, &index) in relation_clause.iter().enumerate() { + if matches!(tokens[index].token_type, TokenType::Join | TokenType::Comma) { + segment_start = position + 1; + in_condition = false; + continue; + } + if in_condition || is_relation_condition_start(&tokens[index]) { + in_condition = true; + continue; + } + if tokens[index].token_type == TokenType::As { + if let Some(&alias) = relation_clause.get(position + 1) + && is_identifier(&tokens[alias]) + { + aliases.push((index, alias)); + } + continue; + } + if is_implicit_relation_alias( + tokens, + &relation_clause[segment_start..], + position - segment_start, + ) { + aliases.push((index, index)); + } + } + aliases +} + +fn is_implicit_relation_alias(tokens: &[Token], segment: &[usize], position: usize) -> bool { + let candidate = &tokens[segment[position]]; + if position == 0 + || !is_identifier(candidate) + || candidate.token_type != TokenType::QuotedIdentifier + && RELATION_MODIFIER_KEYWORDS + .iter() + .any(|keyword| candidate.text.eq_ignore_ascii_case(keyword)) + { + return false; + } + let previous = &tokens[segment[position - 1]]; + if previous.token_type == TokenType::LParen { + return true; + } + is_identifier(previous) + && position + .checked_sub(QUALIFIER_PAIR_LENGTH) + .is_none_or(|before| tokens[segment[before]].token_type == TokenType::Dot) +} + +fn null_rejected_left_join_spans( + tokens: &[Token], + direct: &[usize], + relation_end: usize, +) -> Vec { + let Some(where_position) = direct + .iter() + .position(|&index| tokens[index].token_type == TokenType::Where) + else { + return Vec::new(); + }; + if direct[where_position + 1..] + .iter() + .any(|&index| tokens[index].token_type == TokenType::Or) + { + return Vec::new(); + } + let mut spans: Vec = Vec::new(); + for position in 0..relation_end { + let index = direct[position]; + if tokens[index].token_type != TokenType::Left { + continue; + } + let Some(join_position) = direct[position + 1..relation_end] + .iter() + .position(|&candidate| tokens[candidate].token_type == TokenType::Join) + .map(|offset| position + 1 + offset) + else { + continue; + }; + let Some(alias) = relation_alias_after_join(tokens, direct, join_position) else { + continue; + }; + if right_side_null_rejected(tokens, &direct[where_position + 1..], &alias) { + spans.push(tokens[index].span); + } + } + spans +} + +fn right_side_null_rejected(tokens: &[Token], predicate: &[usize], alias: &str) -> bool { + for window in predicate.windows(4) { + if !tokens[window[0]].text.eq_ignore_ascii_case(alias) + || tokens[window[1]].token_type != TokenType::Dot + || !is_identifier(&tokens[window[2]]) + { + continue; + } + if matches!( + tokens[window[3]].token_type, + TokenType::Eq + | TokenType::Neq + | TokenType::Lt + | TokenType::Lte + | TokenType::Gt + | TokenType::Gte + | TokenType::Like + | TokenType::In + | TokenType::Between + ) { + return true; + } + } + for window in predicate.windows(6) { + if tokens[window[0]].text.eq_ignore_ascii_case(alias) + && tokens[window[1]].token_type == TokenType::Dot + && is_identifier(&tokens[window[2]]) + && tokens[window[3]].token_type == TokenType::Is + && tokens[window[4]].token_type == TokenType::Not + && tokens[window[5]].token_type == TokenType::Null + { + return true; + } + } + false +} + +fn relation_alias_after_join( + tokens: &[Token], + direct: &[usize], + join_position: usize, +) -> Option { + let relation = &direct[join_position + 1..]; + let relation_end = relation + .iter() + .position(|&index| { + is_relation_condition_start(&tokens[index]) + || is_after_relation_clause(tokens[index].token_type) + || matches!(tokens[index].token_type, TokenType::Join | TokenType::Comma) + }) + .unwrap_or(relation.len()); + relation[..relation_end] + .iter() + .rev() + .find(|&&index| is_identifier(&tokens[index])) + .map(|&index| tokens[index].text.to_ascii_lowercase()) +} + +fn implicit_inner_join_spans(tokens: &[Token], relation_clause: &[usize]) -> Vec { + let mut spans: Vec = Vec::new(); + for (position, &index) in relation_clause.iter().enumerate() { + if is_plain_conditioned_join(tokens, relation_clause, position, index) { + spans.push(tokens[index].span); + } + } + spans +} + +fn is_plain_conditioned_join( + tokens: &[Token], + relation_clause: &[usize], + position: usize, + index: usize, +) -> bool { + if tokens[index].token_type != TokenType::Join + || position > 0 + && matches!( + tokens[relation_clause[position - 1]].token_type, + TokenType::Inner + | TokenType::Left + | TokenType::Right + | TokenType::Full + | TokenType::Outer + | TokenType::Cross + | TokenType::Natural + | TokenType::AsOf + | TokenType::Semi + | TokenType::Anti + ) + { + return false; + } + for &candidate in &relation_clause[position + 1..] { + if tokens[candidate].token_type == TokenType::Join { + break; + } + if matches!( + tokens[candidate].token_type, + TokenType::On | TokenType::Using + ) { + return true; + } + } + false +} + +fn ambiguous_order_direction_spans(tokens: &[Token], direct: &[usize]) -> Vec { + let mut spans: Vec = Vec::new(); + for (position, &index) in direct.iter().enumerate() { + if tokens[index].token_type != TokenType::Order + || direct + .get(position + 1) + .is_none_or(|&by| tokens[by].token_type != TokenType::By) + { + continue; + } + let end = direct[position + 2..] + .iter() + .position(|&candidate| is_clause_boundary(tokens[candidate].token_type)) + .map_or(direct.len(), |offset| position + 2 + offset); + let clause = &direct[position + 2..end]; + let item_ends: Vec = clause + .iter() + .enumerate() + .filter_map(|(item_position, &candidate)| { + (tokens[candidate].token_type == TokenType::Comma).then_some(item_position) + }) + .chain(std::iter::once(clause.len())) + .collect(); + let mut item_start = 0_usize; + let mut explicit = false; + let mut implicit = false; + for item_end in item_ends { + let item = &clause[item_start..item_end]; + item_start = item_end + 1; + let has_direction = item.last().is_some_and(|&candidate| { + matches!( + tokens[candidate].token_type, + TokenType::Asc | TokenType::Desc + ) + }); + explicit |= has_direction; + implicit |= !has_direction && !item.is_empty(); + } + if explicit && implicit { + spans.push(tokens[index].span); + } + } + spans +} + +fn duplicate_alias_spans(tokens: &[Token], indices: &[usize]) -> Vec { + let mut seen: FxHashSet = FxHashSet::default(); + let mut duplicates: Vec = Vec::new(); + for window in indices.windows(2) { + if tokens[window[0]].token_type != TokenType::As || !is_identifier(&tokens[window[1]]) { + continue; + } + let normalized = tokens[window[1]].text.to_ascii_lowercase(); + if !seen.insert(normalized) { + duplicates.push(tokens[window[1]].span); + } + } + duplicates +} + +fn mixed_reference_clause_spans(tokens: &[Token], direct: &[usize]) -> Vec { + let mut spans: Vec = Vec::new(); + for (position, &index) in direct.iter().enumerate() { + if !matches!( + tokens[index].token_type, + TokenType::Group | TokenType::Order + ) || direct + .get(position + 1) + .is_none_or(|&by| tokens[by].token_type != TokenType::By) + { + continue; + } + let end = direct[position + 2..] + .iter() + .position(|&candidate| is_clause_boundary(tokens[candidate].token_type)) + .map_or(direct.len(), |offset| position + 2 + offset); + let clause = &direct[position + 2..end]; + let mut has_number = false; + let mut has_name = false; + for (item_position, &candidate) in clause.iter().enumerate() { + if item_position == 0 + || tokens[clause[item_position - 1]].token_type == TokenType::Comma + { + has_number |= tokens[candidate].token_type == TokenType::Number; + has_name |= tokens[candidate].token_type != TokenType::Number; + } + } + if has_number && has_name { + spans.push(tokens[index].span); + } + } + spans +} + +fn is_clause_boundary(token_type: TokenType) -> bool { + matches!( + token_type, + TokenType::Having + | TokenType::Qualify + | TokenType::Order + | TokenType::Limit + | TokenType::Offset + | TokenType::Fetch + | TokenType::Union + | TokenType::Intersect + | TokenType::Except + | TokenType::Semicolon + ) +} + +fn parenthesized_distinct_span( + tokens: &[Token], + depths: &[usize], + open_index: usize, +) -> Option { + let close_depth = depths[open_index] + 1; + let close_index = (open_index + 1..tokens.len()).find(|&index| { + tokens[index].token_type == TokenType::RParen && depths[index] == close_depth + })?; + Some(Span { + start: tokens[significant_before(tokens, open_index)?].span.start, + end: tokens[close_index].span.end, + line: tokens[significant_before(tokens, open_index)?].span.line, + column: tokens[significant_before(tokens, open_index)?].span.column, + }) +} + +fn count_one_span(tokens: &[Token], significant: &[usize], position: usize) -> Option { + let open = *significant.get(position + 1)?; + let one = *significant.get(position + 2)?; + let close = *significant.get(position + 3)?; + (tokens[open].token_type == TokenType::LParen + && tokens[one].token_type == TokenType::Number + && tokens[one].text == COUNT_ONE_LITERAL + && tokens[close].token_type == TokenType::RParen) + .then_some(tokens[one].span) +} + +fn simple_boolean_case_span( + tokens: &[Token], + significant: &[usize], + position: usize, +) -> Option { + let tail = &significant[position + 1..]; + let end_offset = tail + .iter() + .position(|&index| tokens[index].token_type == TokenType::End)?; + let body = &tail[..=end_offset]; + if body + .iter() + .any(|&index| tokens[index].token_type == TokenType::Case) + { + return None; + } + let when_position = body + .iter() + .position(|&index| tokens[index].token_type == TokenType::When)?; + let then_position = body + .iter() + .position(|&index| tokens[index].token_type == TokenType::Then)?; + let else_position = body + .iter() + .position(|&index| tokens[index].token_type == TokenType::Else)?; + let then_value = *body.get(then_position + 1)?; + let else_value = *body.get(else_position + 1)?; + if when_position != 0 + || tokens[then_value].token_type != TokenType::True + || tokens[else_value].token_type != TokenType::False + || else_position + 2 != body.len() - 1 + { + return None; + } + let start = tokens[significant[position]].span; + let end = tokens[*body.last()?].span; + Some(Span { + start: start.start, + end: end.end, + line: start.line, + column: start.column, + }) +} + +fn unstable_row_number_span( + tokens: &[Token], + depths: &[usize], + function_index: usize, +) -> Option { + let over_index = (function_index + 1..tokens.len()).find(|&index| { + depths[index] == depths[function_index] && tokens[index].token_type == TokenType::Over + })?; + let open_index = significant_after(tokens, over_index)?; + if tokens[open_index].token_type != TokenType::LParen { + return None; + } + let close_depth = depths[open_index] + 1; + let close_index = (open_index + 1..tokens.len()).find(|&index| { + tokens[index].token_type == TokenType::RParen && depths[index] == close_depth + })?; + (!tokens[open_index + 1..close_index] + .iter() + .any(|token| matches!(token.token_type, TokenType::Order | TokenType::OrderBy))) + .then_some(tokens[function_index].span) +} + +fn null_not_in_span( + tokens: &[Token], + depths: &[usize], + significant: &[usize], + position: usize, +) -> Option { + let in_index = *significant.get(position + 1)?; + let open_index = *significant.get(position + 2)?; + if tokens[in_index].token_type != TokenType::In + || tokens[open_index].token_type != TokenType::LParen + { + return None; + } + let close_depth = depths[open_index] + 1; + let close_index = (open_index + 1..tokens.len()).find(|&index| { + tokens[index].token_type == TokenType::RParen && depths[index] == close_depth + })?; + let list_depth = depths[open_index] + 1; + let list = &tokens[open_index + 1..close_index]; + (!list + .iter() + .any(|token| token.token_type == TokenType::Select) + && list.iter().enumerate().any(|(offset, token)| { + token.token_type == TokenType::Null && depths[open_index + 1 + offset] == list_depth + })) + .then_some(tokens[significant[position]].span) +} + +fn constant_predicate(tokens: &[Token], indices: &[usize]) -> bool { + let left = &tokens[indices[0]]; + let operator = &tokens[indices[1]]; + let right = &tokens[indices[2]]; + matches!( + left.token_type, + TokenType::Number | TokenType::String | TokenType::True | TokenType::False + ) && left.token_type == right.token_type + && left.text == right.text + && matches!(operator.token_type, TokenType::Eq | TokenType::Neq) +} + +fn set_arity_mismatch_spans(tokens: &[Token], depths: &[usize]) -> Vec { + let mut spans: Vec = Vec::new(); + for (index, token) in tokens.iter().enumerate() { + if !matches!( + token.token_type, + TokenType::Union | TokenType::Intersect | TokenType::Except + ) { + continue; + } + let depth = depths[index]; + let Some(left_select) = (0..index).rev().find(|&candidate| { + depths[candidate] == depth && tokens[candidate].token_type == TokenType::Select + }) else { + continue; + }; + let Some(right_select) = (index + 1..tokens.len()).find(|&candidate| { + depths[candidate] == depth && tokens[candidate].token_type == TokenType::Select + }) else { + continue; + }; + let left_query = QuerySlice { + tokens, + depths, + direct: &[], + start: left_select, + end: index, + depth, + }; + let left_count = projection_arity(&left_query); + let right_end = query_end(tokens, depths, right_select, depth); + let right_query = QuerySlice { + tokens, + depths, + direct: &[], + start: right_select, + end: right_end, + depth, + }; + let right_count = projection_arity(&right_query); + if left_count + .zip(right_count) + .is_some_and(|(left, right)| left != right) + { + spans.push(token.span); + } + } + spans +} + +fn projection_arity(query: &QuerySlice<'_>) -> Option { + let QuerySlice { + tokens, + depths, + start: select, + end, + depth, + .. + } = query; + let select = *select; + let end = *end; + let depth = *depth; + let projection_end = (select + 1..end) + .find(|&index| depths[index] == depth && is_query_from(tokens, index)) + .unwrap_or(end); + if (select + 1..projection_end) + .any(|index| depths[index] == depth && tokens[index].token_type == TokenType::Star) + { + return None; + } + Some( + 1 + (select + 1..projection_end) + .filter(|&index| depths[index] == depth && tokens[index].token_type == TokenType::Comma) + .count(), + ) +} + +fn is_identifier(token: &Token) -> bool { + matches!( + token.token_type, + TokenType::Identifier | TokenType::QuotedIdentifier | TokenType::Var + ) +} diff --git a/crates/sqlclean/src/sql_lint/_helpers/engine.rs b/crates/sqlclean/src/sql_lint/_helpers/engine.rs new file mode 100644 index 0000000..eb5212f --- /dev/null +++ b/crates/sqlclean/src/sql_lint/_helpers/engine.rs @@ -0,0 +1,1978 @@ +use std::borrow::Cow; +use std::collections::{HashMap, HashSet, VecDeque}; + +use polyglot_sql::expressions::With; +use polyglot_sql::tokens::{Span, Token, TokenType}; +use polyglot_sql::{DialectType, Expression, ExpressionWalk}; + +use crate::sql_lint::constants::LINT_API_VERSION; +use crate::sql_lint::models::{ + AdditionalFactOptions, CrossJoinRequest, LintDiagnostic, LintEdit, LintRequest, LintResponse, + LintRuleMetadata, ParsedSql, QueryFacts, SqlMacroOutput, +}; + +use crate::sql_lint::_helpers::additional::collect_additional_facts; +use crate::sql_lint::_helpers::inline_relations::collect_inline_query_relation_spans; +use crate::sql_lint::_helpers::join_predicates::cross_join_diagnostics; +use crate::sql_lint::_helpers::parsing::parse; +use crate::sql_lint::_helpers::terminal_shape::collect_terminal_shape_facts; + +const NULL_COMPARISON: LintRuleMetadata = LintRuleMetadata { + code: "SQC001", + message: "Comparison with NULL is never true", + remediation: "Use IS NULL or IS NOT NULL when testing for NULL.", +}; +const CEREMONIAL_CTE_PREFIXES: [&str; 11] = [ + "__expected__", + "__assert__", + "__ref__", + "__source__", + "__seed__", + "__dbt_ref__", + "__macro_actual__", + "__macro_expected__", + "__udf_actual__", + "__udf_expected__", + "__table_fn_", +]; + +const IMPLICIT_CARTESIAN_JOIN: LintRuleMetadata = LintRuleMetadata { + code: "SQC002", + message: "Comma-separated sources create an implicit cartesian join", + remediation: "Replace comma-separated sources with an explicit keyed join, or use CROSS JOIN when the cartesian product is intentional.", +}; +const JOIN_WITHOUT_CONDITION: LintRuleMetadata = LintRuleMetadata { + code: "SQC003", + message: "Non-cross join has no meaningful condition", + remediation: "Add a meaningful ON or USING condition, or declare an intentional cartesian product with CROSS JOIN.", +}; +const UNORDERED_LIMIT: LintRuleMetadata = LintRuleMetadata { + code: "SQC004", + message: "Row selection is nondeterministic", + remediation: "Add ORDER BY with a deterministic tie-breaker before LIMIT or OFFSET.", +}; +const UNUSED_CTE: LintRuleMetadata = LintRuleMetadata { + code: "SQC005", + message: "CTE is unreachable from the final query", + remediation: "Reference the CTE from the final query or another reachable CTE, or remove it.", +}; +const REDUNDANT_DISTINCT: LintRuleMetadata = LintRuleMetadata { + code: "SQC006", + message: "DISTINCT is redundant with the grouped output", + remediation: "Remove DISTINCT; the equivalent GROUP BY already determines the output groups.", +}; +const POSITIONAL_SET_STAR: LintRuleMetadata = LintRuleMetadata { + code: "SQC007", + message: "Positional set operation is vulnerable to column-order drift", + remediation: "Enumerate columns in the same order in every set-operation branch.", +}; +const EXPLICIT_UNION: LintRuleMetadata = LintRuleMetadata { + code: "SQC008", + message: "Bare UNION leaves duplicate handling implicit", + remediation: "Use UNION DISTINCT or UNION ALL to state duplicate handling explicitly.", +}; +const DUPLICATE_TABLE_ALIAS: LintRuleMetadata = LintRuleMetadata { + code: "SQC009", + message: "Table alias is duplicated in the same query scope", + remediation: "Give every relation in this query scope a unique alias.", +}; +const DUPLICATE_OUTPUT_ALIAS: LintRuleMetadata = LintRuleMetadata { + code: "SQC010", + message: "Output alias is duplicated in the same select list", + remediation: "Give every projected expression a unique output alias.", +}; +const MIXED_GROUP_ORDER_REFERENCES: LintRuleMetadata = LintRuleMetadata { + code: "SQC011", + message: "Clause mixes positional and named references", + remediation: "Use column names or positions consistently throughout this clause.", +}; +const REDUNDANT_ELSE_NULL: LintRuleMetadata = LintRuleMetadata { + code: "SQC012", + message: "ELSE NULL is redundant", + remediation: "Remove ELSE NULL; CASE expressions return NULL when ELSE is omitted.", +}; +const PARENTHESIZED_DISTINCT: LintRuleMetadata = LintRuleMetadata { + code: "SQC013", + message: "DISTINCT is written as though it were a function", + remediation: "Write DISTINCT as a SELECT modifier without function-like parentheses.", +}; +const CONSTANT_PREDICATE: LintRuleMetadata = LintRuleMetadata { + code: "SQC014", + message: "Predicate compares identical constant values", + remediation: "Remove the scaffold predicate or replace it with the intended condition.", +}; +const CONSECUTIVE_SEMICOLON: LintRuleMetadata = LintRuleMetadata { + code: "SQC015", + message: "Empty statement follows another terminator", + remediation: "Remove the redundant statement terminator.", +}; +const REDUNDANT_SELF_ALIAS: LintRuleMetadata = LintRuleMetadata { + code: "SQC016", + message: "Expression is aliased to its existing name", + remediation: "Remove the self-alias when the inferred output identity remains stable.", +}; +const COUNT_ONE: LintRuleMetadata = LintRuleMetadata { + code: "SQC017", + message: "Row count uses a constant expression", + remediation: "Use COUNT(*) to state that rows, rather than a nullable expression, are counted.", +}; +const UNSTABLE_ROW_NUMBER: LintRuleMetadata = LintRuleMetadata { + code: "SQC018", + message: "ROW_NUMBER has no deterministic ordering", + remediation: "Add ORDER BY with a stable tie-breaker inside the window specification.", +}; +const NULL_NOT_IN: LintRuleMetadata = LintRuleMetadata { + code: "SQC019", + message: "NOT IN list contains NULL and can reject every row", + remediation: "Remove NULL from the list or express the anti-join with a NULL-safe NOT EXISTS.", +}; +const SET_ARITY_MISMATCH: LintRuleMetadata = LintRuleMetadata { + code: "SQC020", + message: "Set-operation branches project different column counts", + remediation: "Project the same number of columns in every set-operation branch.", +}; +const PROJECTED_STAR: LintRuleMetadata = LintRuleMetadata { + code: "SQC021", + message: "Query output has an uncontrolled column shape", + remediation: "Enumerate the intended output columns explicitly.", +}; +const UNALIASED_CALCULATION: LintRuleMetadata = LintRuleMetadata { + code: "SQC022", + message: "Calculated projection has no stable output name", + remediation: "Add an explicit AS alias that describes the projected value.", +}; +const UNUSED_TABLE_ALIAS: LintRuleMetadata = LintRuleMetadata { + code: "SQC023", + message: "Table alias is never referenced", + remediation: "Use the alias to qualify references or remove it.", +}; +const NULL_REJECTED_LEFT_JOIN: LintRuleMetadata = LintRuleMetadata { + code: "SQC024", + message: "WHERE predicate rejects NULL rows from a LEFT JOIN", + remediation: "Move the right-side predicate into ON or use INNER JOIN when row rejection is intentional.", +}; +const IMPLICIT_INNER_JOIN: LintRuleMetadata = LintRuleMetadata { + code: "SQC025", + message: "JOIN type is implicit", + remediation: "Write INNER JOIN explicitly.", +}; +const AMBIGUOUS_ORDER_DIRECTIONS: LintRuleMetadata = LintRuleMetadata { + code: "SQC026", + message: "ORDER BY mixes explicit and implicit directions", + remediation: "State ASC or DESC for every ordering expression in this clause.", +}; +const UNQUALIFIED_MULTI_SOURCE_COLUMN: LintRuleMetadata = LintRuleMetadata { + code: "SQC027", + message: "Column reference is unqualified in a multi-relation query", + remediation: "Qualify the column with its relation alias.", +}; +const INCONSISTENT_SINGLE_SOURCE_QUALIFICATION: LintRuleMetadata = LintRuleMetadata { + code: "SQC028", + message: "Single-relation query mixes qualified and unqualified columns", + remediation: "Use one consistent qualification style in this query scope.", +}; +const UNKNOWN_RELATION_QUALIFIER: LintRuleMetadata = LintRuleMetadata { + code: "SQC029", + message: "Qualified reference uses a relation absent from this query scope", + remediation: "Use an available relation alias or add the intended relation to FROM.", +}; +const SIMPLE_BOOLEAN_CASE: LintRuleMetadata = LintRuleMetadata { + code: "SQC030", + message: "CASE expression only converts a condition to TRUE or FALSE", + remediation: "Use a NULL-safe boolean expression directly.", +}; +const NESTED_ELSE_CASE: LintRuleMetadata = LintRuleMetadata { + code: "SQC031", + message: "CASE expression is nested directly inside ELSE", + remediation: "Flatten the nested branch into the surrounding CASE expression.", +}; +const UNUSED_JOINED_RELATION: LintRuleMetadata = LintRuleMetadata { + code: "SQC032", + message: "Joined relation contributes no referenced values", + remediation: "Use the joined relation or remove the join. When the join intentionally filters rows, record it as a reviewed rule exception with a reason in the configuration file.", +}; +const COMMENT_ATTACHMENT: LintRuleMetadata = LintRuleMetadata { + code: "SQC033", + message: "Comment is not attached to the syntax node it explains", + remediation: "Use a standalone -- comment block immediately above the relevant CTE, JOIN, filter, predicate, selected expression, CASE branch, grouping, or ordering node.", +}; +const CTE_ONLY_BODY: LintRuleMetadata = LintRuleMetadata { + code: "SQC034", + message: "Query keeps transformation logic outside top-level CTEs", + remediation: "Move transformation logic into named top-level CTEs before the terminal SELECT.", +}; +const TERMINAL_SELECT: LintRuleMetadata = LintRuleMetadata { + code: "SQC035", + message: "Terminal SELECT does not read plainly from the final top-level CTE", + remediation: "Name the final logical CTE and leave one terminal SELECT from that CTE.", +}; +const NESTED_CTE: LintRuleMetadata = LintRuleMetadata { + code: "SQC036", + message: "CTE is nested inside another query", + remediation: "Hoist the nested CTE into the statement's top-level WITH list.", +}; +const RECURSIVE_CTE: LintRuleMetadata = LintRuleMetadata { + code: "SQC037", + message: "Recursive CTE is not permitted", + remediation: "Replace recursion with an explicit upstream relation or a bounded non-recursive query shape.", +}; +const CROSS_JOIN: LintRuleMetadata = LintRuleMetadata { + code: "SQC038", + message: "CROSS JOIN creates an explicit cartesian product", + remediation: "Use a keyed JOIN, or make one side provably at most one row (LIMIT 1, an aggregate without GROUP BY, or a one-row VALUES). When the cartesian product is intentional, record it as a reviewed [[rules.rule_exceptions]] entry with rule, path, relation and reason in the configuration file.", +}; +const INLINE_QUERY_RELATION: LintRuleMetadata = LintRuleMetadata { + code: "SQC039", + message: "Inline query relation obscures data flow", + remediation: "Extract the derived relation into a named top-level CTE; use explicit LATERAL only when the relation must remain correlated.", +}; +const TRIVIAL_EQUALITY_TOKEN_COUNT: usize = 3; +const JOIN_EXPRESSION: LintRuleMetadata = LintRuleMetadata { + code: "SQC040", + message: "JOIN ON contains a computed expression", + remediation: "Compute keys, including variant path access, in input CTEs and join on plain columns. Literal-only predicates such as ON TRUE are allowed.", +}; +const FINAL_CTE_NAME: LintRuleMetadata = LintRuleMetadata { + code: "SQC041", + message: "Only the last top-level CTE may be named final, and it must use that name", + remediation: "Name the last top-level CTE final and rename any other final CTE; SQC035 checks the terminal SELECT.", +}; + +pub(crate) const DEFAULT_RULES: [&str; 13] = [ + NULL_COMPARISON.code, + IMPLICIT_CARTESIAN_JOIN.code, + JOIN_WITHOUT_CONDITION.code, + UNORDERED_LIMIT.code, + UNUSED_CTE.code, + REDUNDANT_DISTINCT.code, + POSITIONAL_SET_STAR.code, + DUPLICATE_TABLE_ALIAS.code, + DUPLICATE_OUTPUT_ALIAS.code, + UNSTABLE_ROW_NUMBER.code, + NULL_NOT_IN.code, + SET_ARITY_MISMATCH.code, + INLINE_QUERY_RELATION.code, +]; + +const ALL_RULE_METADATA: [&LintRuleMetadata; 45] = [ + &NULL_COMPARISON, + &IMPLICIT_CARTESIAN_JOIN, + &JOIN_WITHOUT_CONDITION, + &UNORDERED_LIMIT, + &UNUSED_CTE, + &REDUNDANT_DISTINCT, + &POSITIONAL_SET_STAR, + &EXPLICIT_UNION, + &DUPLICATE_TABLE_ALIAS, + &DUPLICATE_OUTPUT_ALIAS, + &MIXED_GROUP_ORDER_REFERENCES, + &REDUNDANT_ELSE_NULL, + &PARENTHESIZED_DISTINCT, + &CONSTANT_PREDICATE, + &CONSECUTIVE_SEMICOLON, + &REDUNDANT_SELF_ALIAS, + &COUNT_ONE, + &UNSTABLE_ROW_NUMBER, + &NULL_NOT_IN, + &SET_ARITY_MISMATCH, + &PROJECTED_STAR, + &UNALIASED_CALCULATION, + &UNUSED_TABLE_ALIAS, + &NULL_REJECTED_LEFT_JOIN, + &IMPLICIT_INNER_JOIN, + &AMBIGUOUS_ORDER_DIRECTIONS, + &UNQUALIFIED_MULTI_SOURCE_COLUMN, + &INCONSISTENT_SINGLE_SOURCE_QUALIFICATION, + &UNKNOWN_RELATION_QUALIFIER, + &SIMPLE_BOOLEAN_CASE, + &NESTED_ELSE_CASE, + &UNUSED_JOINED_RELATION, + &COMMENT_ATTACHMENT, + &CTE_ONLY_BODY, + &TERMINAL_SELECT, + &NESTED_CTE, + &RECURSIVE_CTE, + &CROSS_JOIN, + &INLINE_QUERY_RELATION, + &JOIN_EXPRESSION, + &FINAL_CTE_NAME, + &crate::sql_quality::constants::UNUSED_CTE_OUTPUT, + &crate::sql_quality::constants::RANKING_SORT_CAP, + &crate::sql_quality::constants::LONG_LITERAL, + &crate::sql_quality::constants::REPEATED_JSON_PARSE, +]; + +fn is_ceremonial_cte_name(name: &str) -> bool { + CEREMONIAL_CTE_PREFIXES + .iter() + .any(|prefix| name.starts_with(prefix)) +} + +struct QueryTokenContext<'a> { + tokens: &'a [Token], + depths: &'a [usize], + direct: &'a [usize], + query_end: usize, + depth: usize, +} + +struct ExpressionRange { + start: usize, + end: usize, + depth: usize, + strip_alias: bool, +} + +struct DiagnosticContext<'a> { + sql: &'a str, + dialect: DialectType, + facts: &'a QueryFacts, + tokens: &'a [Token], + enabled: &'a HashSet, +} + +struct FactBuildOptions<'a> { + external_identifiers: &'a HashSet, + sql_macro_outputs: &'a [SqlMacroOutput], + dependency_identifiers: &'a HashSet, + externally_referenced_ctes: &'a HashSet, + allows_ceremonial_select: bool, + allows_dynamic_output_star: bool, + allows_empty_fixture_star: bool, +} + +pub(crate) fn lint_json_impl(request_json: &str) -> Result { + let request: LintRequest = + serde_json::from_str(request_json).map_err(|error| error.to_string())?; + let response = lint(request)?; + serde_json::to_string(&response).map_err(|error| error.to_string()) +} + +pub(crate) fn rule_metadata() -> Vec { + ALL_RULE_METADATA.iter().map(|rule| **rule).collect() +} + +pub(crate) fn lint(request: LintRequest) -> Result { + if request.version != LINT_API_VERSION { + return Err(format!( + "unsupported native lint request version {}; expected {LINT_API_VERSION}", + request.version + )); + } + let enabled = enabled_rules(request.enabled_rules, &request.ignored_rules)?; + let ParsedSql { + dialect: dialect_type, + tokens, + statements, + } = parse(&request.sql, &request.dialect)?; + let external_identifiers: HashSet = request + .external_identifiers + .iter() + .map(|identifier| identifier.to_ascii_lowercase()) + .collect(); + let dependency_identifiers: HashSet = request + .dependency_identifiers + .iter() + .map(|identifier| identifier.to_ascii_lowercase()) + .collect(); + let externally_referenced_ctes: HashSet = request + .externally_referenced_ctes + .iter() + .map(|name| name.to_ascii_lowercase()) + .collect(); + let options = FactBuildOptions { + external_identifiers: &external_identifiers, + sql_macro_outputs: &request.sql_macro_outputs, + dependency_identifiers: &dependency_identifiers, + externally_referenced_ctes: &externally_referenced_ctes, + allows_ceremonial_select: request.allows_ceremonial_select, + allows_dynamic_output_star: request.allows_dynamic_output_star, + allows_empty_fixture_star: request.allows_empty_fixture_star, + }; + let facts = build_facts(&statements, &tokens, &request.sql, &options); + let context = DiagnosticContext { + sql: &request.sql, + dialect: dialect_type, + facts: &facts, + tokens: &tokens, + enabled: &enabled, + }; + let mut diagnostics = diagnostics(&context); + if enabled.contains(CROSS_JOIN.code) { + let cross_joins = CrossJoinRequest { + statements: &statements, + tokens: &tokens, + cross_spans: &facts.cross_joins, + relation_keys: &request.relation_keys, + relation_names: &request.relation_names, + }; + diagnostics.extend(cross_join_diagnostics(&cross_joins, &CROSS_JOIN)); + } + let depths = token_depths(&tokens); + if enabled.contains(JOIN_EXPRESSION.code) { + diagnostics.extend(diagnostics_for_spans( + &JOIN_EXPRESSION, + &crate::sql_lint::_helpers::join_predicates::join_expression_spans( + &tokens, + &depths, + dialect_type, + ), + Some("computing a join key requires an authored CTE rewrite"), + )); + } + if enabled.contains(FINAL_CTE_NAME.code) && !request.allows_ceremonial_select { + diagnostics.extend(diagnostics_for_spans( + &FINAL_CTE_NAME, + &crate::sql_lint::_helpers::terminal_shape::final_cte_name_spans(&tokens, &depths), + Some("renaming a CTE requires updating its references"), + )); + } + diagnostics.extend(crate::sql_quality::main::diagnostics::diagnostics( + &crate::sql_quality::models::QualityRequest { + statements: &statements, + tokens: &tokens, + dialect: dialect_type, + enabled: &enabled, + max_literal_length: request + .max_literal_length + .unwrap_or(crate::sql_quality::constants::DEFAULT_MAX_LITERAL_LENGTH), + max_ranking_order_by: request + .max_ranking_order_by + .unwrap_or(crate::sql_quality::constants::DEFAULT_MAX_RANKING_ORDER_BY), + relation_keys: &request.relation_keys, + externally_referenced_ctes: &externally_referenced_ctes, + fixture_body: request.allows_ceremonial_select, + config_file: request + .config_file + .as_deref() + .unwrap_or(crate::constants::DEFAULT_CONFIG_FILE), + }, + )?); + diagnostics.sort_by_key(|diagnostic| (diagnostic.start, diagnostic.end, diagnostic.code)); + Ok(LintResponse { + version: LINT_API_VERSION, + diagnostics, + }) +} + +fn enabled_rules( + requested: Option>, + ignored: &[String], +) -> Result, String> { + let selectors = + requested.unwrap_or_else(|| DEFAULT_RULES.iter().map(ToString::to_string).collect()); + let mut rules = expand_rule_selectors(&selectors)?; + for code in expand_rule_selectors(ignored)? { + rules.remove(&code); + } + Ok(rules) +} + +fn expand_rule_selectors(selectors: &[String]) -> Result, String> { + let mut selected: HashSet = HashSet::new(); + for selector in selectors { + let matches: Vec<&str> = ALL_RULE_METADATA + .iter() + .map(|rule| rule.code) + .filter(|code| code.starts_with(selector)) + .collect(); + if matches.is_empty() { + return Err(format!("unknown native lint rule '{selector}'")); + } + selected.extend(matches.into_iter().map(ToString::to_string)); + } + Ok(selected) +} + +fn build_facts( + expressions: &[Expression], + tokens: &[Token], + sql: &str, + options: &FactBuildOptions<'_>, +) -> QueryFacts { + let mut facts: QueryFacts = + collect_token_query_facts(tokens, sql, options.allows_ceremonial_select); + facts.null_comparisons = null_comparison_spans(tokens); + facts.additional = collect_additional_facts( + tokens, + &AdditionalFactOptions { + external_identifiers: options.external_identifiers, + sql_macro_outputs: options.sql_macro_outputs, + dependency_identifiers: options.dependency_identifiers, + allows_ceremonial_select: options.allows_ceremonial_select, + allows_dynamic_output_star: options.allows_dynamic_output_star, + allows_empty_fixture_star: options.allows_empty_fixture_star, + }, + ); + for expression in expressions { + facts + .unused_cte_names + .extend(collect_unused_ctes(expression, options)); + } + facts +} + +fn collect_token_query_facts( + tokens: &[Token], + sql: &str, + allows_ceremonial_select: bool, +) -> QueryFacts { + let depths = token_depths(tokens); + let mut facts = collect_rule_migration_facts(tokens, &depths, sql, allows_ceremonial_select); + for (select_index, token) in tokens.iter().enumerate() { + if token.token_type != TokenType::Select { + continue; + } + let depth = depths[select_index]; + let end = query_end(tokens, &depths, select_index, depth); + let direct = direct_indices(&depths, select_index + 1, end, depth); + let order = first_type(tokens, &direct, TokenType::Order); + let limit = first_row_selection_clause(tokens, &direct); + if order.is_none() + && let Some(index) = limit + && !limits_to_no_rows(tokens, index) + { + facts.unordered_limits.push(tokens[index].span); + } + let context = QueryTokenContext { + tokens, + depths: &depths, + direct: &direct, + query_end: end, + depth, + }; + if let Some(span) = redundant_distinct_span(&context) { + facts.redundant_distincts.push(span); + } + let (implicit, missing_conditions) = collect_from_and_join_facts(tokens, &direct); + facts.implicit_cartesian_joins.extend(implicit); + facts.joins_without_condition.extend(missing_conditions); + } + facts.positional_set_stars = collect_set_operation_facts(tokens, &depths); + facts +} + +fn collect_rule_migration_facts( + tokens: &[Token], + depths: &[usize], + sql: &str, + allows_ceremonial_select: bool, +) -> QueryFacts { + let mut facts = QueryFacts::default(); + let significant: Vec = (0..tokens.len()) + .filter(|&index| !is_layout(&tokens[index]) && !is_comment(&tokens[index])) + .collect(); + + let root_with = crate::sql_lint::_helpers::terminal_shape::root_with_indices(tokens, depths); + + for (position, &index) in significant.iter().enumerate() { + let text = tokens[index].text.as_str(); + if text.eq_ignore_ascii_case("cross") + && significant + .get(position + 1) + .is_some_and(|&next| tokens[next].token_type == TokenType::Join) + { + facts.cross_joins.push(tokens[index].span); + } + if tokens[index].token_type == TokenType::With { + let starts_hierarchy = position.checked_sub(1).is_some_and(|previous| { + tokens[significant[previous]].token_type == TokenType::Start + }); + if !root_with.contains(&index) && !starts_hierarchy { + facts.nested_ctes.push(tokens[index].span); + } + if !starts_hierarchy + && significant + .get(position + 1) + .is_some_and(|&next| tokens[next].text.eq_ignore_ascii_case("recursive")) + { + facts.recursive_ctes.push(tokens[index].span); + } + } + } + facts + .inline_query_relations + .extend(collect_inline_query_relation_spans( + tokens, + depths, + &significant, + )); + + let (cte_only_bodies, terminal_selects) = + collect_terminal_shape_facts(tokens, depths, &significant, allows_ceremonial_select); + facts.cte_only_bodies.extend(cte_only_bodies); + facts.terminal_selects.extend(terminal_selects); + facts + .invalid_comment_attachments + .extend(collect_comment_attachment_facts(tokens, sql)); + facts +} + +fn collect_comment_attachment_facts(tokens: &[Token], sql: &str) -> Vec { + let mut invalid: Vec = Vec::new(); + for (index, token) in tokens.iter().enumerate() { + if !token.trailing_comments.is_empty() { + invalid.push(token.span); + } + if token.comments.is_empty() { + continue; + } + let gap_start = index + .checked_sub(1) + .map_or(0, |previous| tokens[previous].span.end); + let gap = char_slice(sql, gap_start, token.span.start).unwrap_or_default(); + let block_comment = gap.contains("/*"); + let detached = gap.replace("\r\n", "\n").ends_with("\n\n"); + let unsupported_target = matches!( + token.token_type, + TokenType::Comma + | TokenType::RParen + | TokenType::Semicolon + | TokenType::Then + | TokenType::Else + | TokenType::From + ); + if block_comment || detached || unsupported_target { + invalid.push(token.span); + } + } + invalid +} + +pub(super) fn token_depths(tokens: &[Token]) -> Vec { + let mut depth = 0_usize; + tokens + .iter() + .map(|token| { + let current = depth; + if matches!( + token.token_type, + TokenType::LParen | TokenType::LBracket | TokenType::LBrace + ) { + depth += 1; + } else if matches!( + token.token_type, + TokenType::RParen | TokenType::RBracket | TokenType::RBrace + ) { + depth = depth.saturating_sub(1); + } + current + }) + .collect() +} + +pub(super) fn query_end(tokens: &[Token], depths: &[usize], start: usize, depth: usize) -> usize { + (start + 1..tokens.len()) + .find(|&index| { + depths[index] < depth + || depths[index] == depth + && matches!( + tokens[index].token_type, + TokenType::RParen + | TokenType::Semicolon + | TokenType::Union + | TokenType::Intersect + | TokenType::Except + ) + }) + .unwrap_or(tokens.len()) +} + +pub(super) fn direct_indices( + depths: &[usize], + start: usize, + end: usize, + depth: usize, +) -> Vec { + (start..end) + .filter(|&index| depths[index] == depth) + .collect() +} + +fn first_type(tokens: &[Token], indices: &[usize], token_type: TokenType) -> Option { + indices + .iter() + .copied() + .find(|&index| tokens[index].token_type == token_type) +} + +fn first_row_selection_clause(tokens: &[Token], direct: &[usize]) -> Option { + let from = direct + .iter() + .copied() + .find(|&index| is_query_from(tokens, index)); + direct.iter().copied().find(|&index| { + matches!( + tokens[index].token_type, + TokenType::Limit | TokenType::Offset + ) && from.is_none_or(|from_index| index > from_index) + && significant_before(tokens, index).is_none_or(|previous| { + !matches!( + tokens[previous].token_type, + TokenType::Colon | TokenType::DColon | TokenType::Dot | TokenType::DotColon + ) + }) + }) +} + +/// Whether a row-selection clause is `LIMIT 0`, which selects no rows in any order. +fn limits_to_no_rows(tokens: &[Token], index: usize) -> bool { + tokens[index].token_type == TokenType::Limit + && tokens.get(index + 1).is_some_and(|count| { + count.token_type == TokenType::Number && count.text.parse::() == Ok(0) + }) +} + +pub(super) fn is_query_from(tokens: &[Token], index: usize) -> bool { + tokens[index].token_type == TokenType::From + && significant_before(tokens, index) + .is_none_or(|previous| tokens[previous].token_type != TokenType::Distinct) +} + +fn redundant_distinct_span(context: &QueryTokenContext<'_>) -> Option { + let QueryTokenContext { + tokens, + depths, + direct, + query_end, + depth, + } = context; + let group_position = group_by_position(tokens, direct)?; + let group_index = direct[group_position]; + let projection_end = direct + .iter() + .copied() + .find(|&index| is_query_from(tokens, index)) + .filter(|index| *index < group_index) + .unwrap_or(group_index); + let distinct_position = direct.iter().position(|&index| { + index < projection_end && tokens[index].token_type == TokenType::Distinct + })?; + if direct + .get(distinct_position + 1) + .is_some_and(|&index| tokens[index].token_type == TokenType::On) + { + return None; + } + let projection_start = direct[distinct_position] + 1; + let group_start = *direct.get(group_position + 2)?; + let group_end = direct[group_position + 2..] + .iter() + .copied() + .find(|&index| { + matches!( + tokens[index].token_type, + TokenType::Having + | TokenType::Qualify + | TokenType::Order + | TokenType::Limit + | TokenType::Offset + ) + }) + .unwrap_or(*query_end); + let projection_signatures = expression_signatures( + tokens, + depths, + &ExpressionRange { + start: projection_start, + end: projection_end, + depth: *depth, + strip_alias: true, + }, + ); + let group_signatures = expression_signatures( + tokens, + depths, + &ExpressionRange { + start: group_start, + end: group_end, + depth: *depth, + strip_alias: false, + }, + ); + if group_signatures.is_empty() + || !group_signatures + .iter() + .all(|group| projection_signatures.contains(group)) + { + return None; + } + Some(tokens[direct[distinct_position]].span) +} + +fn group_by_position(tokens: &[Token], direct: &[usize]) -> Option { + direct.windows(2).position(|window| { + tokens[window[0]].token_type == TokenType::Group + && tokens[window[1]].text.eq_ignore_ascii_case("BY") + }) +} + +fn expression_signatures( + tokens: &[Token], + depths: &[usize], + range: &ExpressionRange, +) -> Vec { + let mut boundaries: Vec = (range.start..range.end) + .filter(|&index| { + depths[index] == range.depth && tokens[index].token_type == TokenType::Comma + }) + .collect(); + boundaries.push(range.end); + let mut segment_start = range.start; + let mut signatures: Vec = Vec::new(); + for segment_end in boundaries { + let effective_end = alias_start(tokens, depths, range, segment_start..segment_end); + let parts: Vec = tokens[segment_start..effective_end] + .iter() + .map(|token| token.text.to_ascii_lowercase()) + .collect(); + let signature = parts.join(" "); + if !signature.is_empty() { + signatures.push(signature); + } + segment_start = segment_end + 1; + } + signatures +} + +fn alias_start( + tokens: &[Token], + depths: &[usize], + range: &ExpressionRange, + segment: std::ops::Range, +) -> usize { + if !range.strip_alias { + return segment.end; + } + segment + .clone() + .find(|&index| depths[index] == range.depth && tokens[index].token_type == TokenType::As) + .unwrap_or(segment.end) +} + +fn collect_from_and_join_facts(tokens: &[Token], direct: &[usize]) -> (Vec, Vec) { + let mut implicit: Vec = Vec::new(); + let mut missing_conditions: Vec = Vec::new(); + let Some(from_position) = direct + .iter() + .position(|&index| is_query_from(tokens, index)) + else { + return (implicit, missing_conditions); + }; + let clause_end = direct[from_position + 1..] + .iter() + .position(|&index| is_post_from_clause(tokens[index].token_type)) + .map_or(direct.len(), |offset| from_position + 1 + offset); + let relation_clause = &direct[from_position + 1..clause_end]; + let values_relation = relation_clause + .first() + .is_some_and(|&index| tokens[index].token_type == TokenType::Values); + for (position, &index) in relation_clause.iter().enumerate() { + if tokens[index].token_type == TokenType::Comma + && !values_relation + && relation_clause + .get(position + 1) + .is_none_or(|&next| tokens[next].token_type != TokenType::Lateral) + { + implicit.push(tokens[index].span); + } + } + for (position, &index) in direct.iter().enumerate() { + if tokens[index].token_type != TokenType::Join + || is_conditionless_join(tokens, direct, position) + { + continue; + } + let condition_end = direct[position + 1..] + .iter() + .position(|&candidate| { + tokens[candidate].token_type == TokenType::Join + || is_post_from_clause(tokens[candidate].token_type) + }) + .map_or(direct.len(), |offset| position + 1 + offset); + let condition = &direct[position + 1..condition_end]; + let on_position = condition + .iter() + .position(|&candidate| tokens[candidate].token_type == TokenType::On); + let has_using = condition + .iter() + .any(|&candidate| tokens[candidate].token_type == TokenType::Using); + if !has_using + && on_position + .is_none_or(|offset| trivial_on_condition(tokens, &condition[offset + 1..])) + { + missing_conditions.push(tokens[index].span); + } + } + (implicit, missing_conditions) +} + +fn is_post_from_clause(token_type: TokenType) -> bool { + matches!( + token_type, + TokenType::Join + | TokenType::Where + | TokenType::Group + | TokenType::Having + | TokenType::Qualify + | TokenType::Order + | TokenType::Limit + | TokenType::Offset + | TokenType::Fetch + ) +} + +fn is_conditionless_join(tokens: &[Token], direct: &[usize], join_position: usize) -> bool { + direct[..join_position] + .iter() + .rev() + .take_while(|&&index| { + !matches!( + tokens[index].token_type, + TokenType::From | TokenType::Join | TokenType::Comma + ) + }) + .any(|&index| { + matches!( + tokens[index].text.to_ascii_uppercase().as_str(), + "CROSS" | "NATURAL" | "APPLY" | "POSITIONAL" | "PASTE" + ) + }) +} + +fn trivial_on_condition(tokens: &[Token], condition: &[usize]) -> bool { + let significant: Vec<&Token> = condition + .iter() + .map(|&index| &tokens[index]) + .filter(|token| !matches!(token.token_type, TokenType::LParen | TokenType::RParen)) + .collect(); + significant.is_empty() + || significant.len() == 1 && significant[0].token_type == TokenType::True + || significant.len() == TRIVIAL_EQUALITY_TOKEN_COUNT + && significant[1].token_type == TokenType::Eq + && matches!( + significant[0].token_type, + TokenType::Number | TokenType::String | TokenType::True | TokenType::False + ) + && significant[0].token_type == significant[2].token_type + && significant[0].text == significant[2].text +} + +fn collect_set_operation_facts(tokens: &[Token], depths: &[usize]) -> Vec { + let mut spans: Vec = Vec::new(); + for (index, token) in tokens.iter().enumerate() { + if !matches!( + token.token_type, + TokenType::Union | TokenType::Intersect | TokenType::Except + ) { + continue; + } + let depth = depths[index]; + let by_name = tokens[index + 1..] + .iter() + .take_while(|candidate| candidate.token_type != TokenType::Select) + .any(|candidate| candidate.text.eq_ignore_ascii_case("BY")) + && tokens[index + 1..] + .iter() + .take_while(|candidate| candidate.token_type != TokenType::Select) + .any(|candidate| candidate.text.eq_ignore_ascii_case("NAME")); + if !by_name + && (select_before_projects_star(tokens, depths, index, depth) + || select_after_projects_star(tokens, depths, index, depth)) + { + spans.push(token.span); + } + } + spans +} + +fn select_before_projects_star( + tokens: &[Token], + depths: &[usize], + end: usize, + depth: usize, +) -> bool { + let Some(start) = (0..end) + .rev() + .find(|&index| depths[index] == depth && tokens[index].token_type == TokenType::Select) + .or_else(|| { + (0..end) + .rev() + .filter(|&index| tokens[index].token_type == TokenType::Select) + .min_by_key(|&index| depths[index]) + }) + else { + return false; + }; + projection_projects_star( + tokens, + depths, + &ExpressionRange { + start, + end, + depth: depths[start], + strip_alias: false, + }, + ) +} + +fn select_after_projects_star( + tokens: &[Token], + depths: &[usize], + start: usize, + depth: usize, +) -> bool { + let Some(select) = (start + 1..tokens.len()) + .find(|&index| depths[index] == depth && tokens[index].token_type == TokenType::Select) + .or_else(|| { + (start + 1..tokens.len()) + .filter(|&index| tokens[index].token_type == TokenType::Select) + .min_by_key(|&index| depths[index]) + }) + else { + return false; + }; + let select_depth = depths[select]; + projection_projects_star( + tokens, + depths, + &ExpressionRange { + start: select, + end: query_end(tokens, depths, select, select_depth), + depth: select_depth, + strip_alias: false, + }, + ) +} + +fn projection_projects_star(tokens: &[Token], depths: &[usize], range: &ExpressionRange) -> bool { + (range.start + 1..range.end) + .take_while(|&index| depths[index] != range.depth || !is_query_from(tokens, index)) + .any(|index| depths[index] == range.depth && tokens[index].token_type == TokenType::Star) +} + +fn collect_unused_ctes(expression: &Expression, options: &FactBuildOptions<'_>) -> Vec { + let mut unused: Vec = Vec::new(); + for node in expression.dfs() { + match node { + Expression::Select(select) if select.with.is_some() => { + let mut root = select.clone(); + if let Some(with) = root.with.take() { + unused.extend(collect_unused_for_with( + &with, + &Expression::Select(root), + options, + )); + } + } + Expression::Union(operation) if operation.with.is_some() => { + let mut root = operation.clone(); + if let Some(with) = root.with.take() { + unused.extend(collect_unused_for_with( + &with, + &Expression::Union(root), + options, + )); + } + } + Expression::Intersect(operation) if operation.with.is_some() => { + let mut root = operation.clone(); + if let Some(with) = root.with.take() { + unused.extend(collect_unused_for_with( + &with, + &Expression::Intersect(root), + options, + )); + } + } + Expression::Except(operation) if operation.with.is_some() => { + let mut root = operation.clone(); + if let Some(with) = root.with.take() { + unused.extend(collect_unused_for_with( + &with, + &Expression::Except(root), + options, + )); + } + } + _ => {} + }; + } + unused +} + +fn collect_unused_for_with( + with: &With, + root: &Expression, + options: &FactBuildOptions<'_>, +) -> Vec { + let names: HashSet = with + .ctes + .iter() + .map(|cte| cte.alias.name.to_ascii_lowercase()) + .collect(); + let dependencies: HashMap> = with + .ctes + .iter() + .map(|cte| { + ( + cte.alias.name.to_ascii_lowercase(), + table_names(&cte.this) + .intersection(&names) + .cloned() + .collect(), + ) + }) + .collect(); + let mut reachable: HashSet = table_names(root).intersection(&names).cloned().collect(); + reachable.extend( + options + .externally_referenced_ctes + .intersection(&names) + .cloned(), + ); + if options.allows_ceremonial_select { + reachable.extend( + names + .iter() + .filter(|name| is_ceremonial_cte_name(name)) + .cloned(), + ); + } + let mut queue: VecDeque = reachable.iter().cloned().collect(); + while let Some(name) = queue.pop_front() { + if let Some(required) = dependencies.get(&name) { + for dependency in required { + if reachable.insert(dependency.clone()) { + queue.push_back(dependency.clone()); + } + } + } + } + with.ctes + .iter() + .filter(|cte| !reachable.contains(&cte.alias.name.to_ascii_lowercase())) + .map(|cte| cte.alias.name.clone()) + .collect() +} + +fn table_names(expression: &Expression) -> HashSet { + expression + .dfs() + .filter_map(|node| match node { + Expression::Table(table) => Some(table.name.name.to_ascii_lowercase()), + _ => None, + }) + .collect() +} + +fn null_comparison_fix(tokens: &[Token], span: Span) -> Option { + let index = token_index_for_span(tokens, span)?; + let token = &tokens[index]; + if !adjacent_null(tokens, index, true) { + return None; + } + let replacement = match token.token_type { + TokenType::Eq => "IS", + TokenType::Neq => "IS NOT", + _ => return None, + }; + Some(LintEdit { + start: span.start, + end: span.end, + replacement: replacement.to_string(), + }) +} + +fn redundant_distinct_fix(tokens: &[Token], span: Span) -> LintEdit { + let end = token_index_for_span(tokens, span) + .and_then(|index| significant_after(tokens, index)) + .filter(|&index| !is_comment(&tokens[index])) + .map_or(span.end, |index| tokens[index].span.start); + LintEdit { + start: span.start, + end, + replacement: String::new(), + } +} + +fn implicit_cartesian_fix(sql: &str, tokens: &[Token], span: Span) -> Option { + let comma_index = token_index_for_span(tokens, span)?; + let depths = token_depths(tokens); + let depth = depths[comma_index]; + let from_index = (0..comma_index).rev().find(|&index| { + depths[index] == depth + && (is_query_from(tokens, index) || tokens[index].token_type == TokenType::Semicolon) + })?; + if !is_query_from(tokens, from_index) { + return None; + } + let clause_end = (comma_index + 1..tokens.len()) + .find(|&index| depths[index] == depth && is_post_from_clause(tokens[index].token_type)) + .unwrap_or(tokens.len()); + if tokens[from_index + 1..clause_end] + .iter() + .any(|token| token.token_type == TokenType::Join) + { + return None; + } + let next_index = significant_after(tokens, comma_index)?; + let end = tokens[next_index].span.start; + let replaced = char_slice(sql, span.start, end)?; + if contains_comment(replaced) { + return None; + } + Some(LintEdit { + start: span.start, + end, + replacement: " CROSS JOIN ".to_string(), + }) +} + +fn conditionless_join_fix(tokens: &[Token], span: Span) -> Option { + let join_index = token_index_for_span(tokens, span)?; + let depths = token_depths(tokens); + let depth = depths[join_index]; + if significant_before(tokens, join_index).is_some_and(|index| { + matches!( + tokens[index].text.to_ascii_uppercase().as_str(), + "INNER" + | "LEFT" + | "RIGHT" + | "FULL" + | "OUTER" + | "CROSS" + | "NATURAL" + | "ASOF" + | "SEMI" + | "ANTI" + ) + }) { + return None; + } + let condition_end = (join_index + 1..tokens.len()) + .find(|&index| { + depths[index] == depth + && (tokens[index].token_type == TokenType::Join + || is_post_from_clause(tokens[index].token_type)) + }) + .unwrap_or(tokens.len()); + if tokens[join_index + 1..condition_end] + .iter() + .any(|token| matches!(token.token_type, TokenType::On | TokenType::Using)) + { + return None; + } + Some(LintEdit { + start: span.start, + end: span.end, + replacement: "CROSS JOIN".to_string(), + }) +} + +fn unused_cte_fix(sql: &str, tokens: &[Token], name_span: Span) -> Option { + let name_index = token_index_for_span(tokens, name_span)?; + if tokens[name_index].text.starts_with("__") { + return None; + } + let as_index = significant_after(tokens, name_index)?; + if tokens[as_index].token_type != TokenType::As { + return None; + } + let open_index = significant_after(tokens, as_index)?; + if tokens[open_index].token_type != TokenType::LParen { + return None; + } + let depths = token_depths(tokens); + let close_depth = depths[open_index] + 1; + let close_index = (open_index + 1..tokens.len()).find(|&index| { + tokens[index].token_type == TokenType::RParen && depths[index] == close_depth + })?; + let body_tokens = &tokens[open_index + 1..close_index]; + let first_body = body_tokens.iter().find(|token| !is_layout(token))?; + if !matches!(first_body.token_type, TokenType::Select | TokenType::With) + || body_tokens.iter().any(|token| { + matches!( + token.text.to_ascii_uppercase().as_str(), + "INSERT" | "UPDATE" | "DELETE" | "MERGE" + ) + }) + { + return None; + } + let previous_index = significant_before(tokens, name_index)?; + let next_index = significant_after(tokens, close_index)?; + let (start, end) = if tokens[next_index].token_type == TokenType::Comma { + let following_index = significant_after(tokens, next_index)?; + (name_span.start, tokens[following_index].span.start) + } else if tokens[previous_index].token_type == TokenType::Comma { + ( + tokens[previous_index].span.start, + tokens[close_index].span.end, + ) + } else if tokens[previous_index].token_type == TokenType::With { + ( + tokens[previous_index].span.start, + tokens[next_index].span.start, + ) + } else { + return None; + }; + if contains_comment(char_slice(sql, start, end)?) { + return None; + } + Some(LintEdit { + start, + end, + replacement: String::new(), + }) +} + +fn token_index_for_span(tokens: &[Token], span: Span) -> Option { + tokens.iter().position(|token| token.span == span) +} + +pub(super) fn significant_before(tokens: &[Token], index: usize) -> Option { + (0..index) + .rev() + .find(|&candidate| !is_layout(&tokens[candidate])) +} + +pub(super) fn significant_after(tokens: &[Token], index: usize) -> Option { + (index + 1..tokens.len()).find(|&candidate| !is_layout(&tokens[candidate])) +} + +pub(super) fn is_layout(token: &Token) -> bool { + matches!(token.token_type, TokenType::Space | TokenType::Break) +} + +pub(super) fn is_comment(token: &Token) -> bool { + matches!( + token.token_type, + TokenType::LineComment | TokenType::BlockComment + ) +} + +fn diagnostics(context: &DiagnosticContext<'_>) -> Vec { + let mut output: Vec = core_diagnostics(context); + output.extend(additional_style_diagnostics(context)); + output.extend(additional_relation_diagnostics(context)); + output.extend(rule_migration_diagnostics(context)); + output +} + +fn core_diagnostics(context: &DiagnosticContext<'_>) -> Vec { + let DiagnosticContext { + sql, + dialect, + facts, + tokens, + enabled, + } = context; + let mut diagnostics: Vec = Vec::new(); + if enabled.contains(NULL_COMPARISON.code) { + diagnostics.extend(facts.null_comparisons.iter().map(|span| { + let fix = null_comparison_fix(tokens, *span); + diagnostic( + &NULL_COMPARISON, + Some(*span), + fix, + Some("only equality and inequality comparisons have a deterministic repair"), + ) + })); + } + if enabled.contains(IMPLICIT_CARTESIAN_JOIN.code) { + diagnostics.extend(facts.implicit_cartesian_joins.iter().map(|span| { + let fix = implicit_cartesian_fix(sql, tokens, *span); + diagnostic( + &IMPLICIT_CARTESIAN_JOIN, + Some(*span), + fix, + Some("mixed joins, comments, or generated SQL require an explicit authored repair"), + ) + })); + } + if enabled.contains(JOIN_WITHOUT_CONDITION.code) { + diagnostics.extend(facts.joins_without_condition.iter().map(|span| { + let fix = conditionless_join_fix(tokens, *span); + diagnostic( + &JOIN_WITHOUT_CONDITION, + Some(*span), + fix, + Some("qualified joins or joins with placeholder conditions require user intent"), + ) + })); + } + if enabled.contains(UNORDERED_LIMIT.code) { + diagnostics.extend(diagnostics_for_spans( + &UNORDERED_LIMIT, + &facts.unordered_limits, + Some("deterministic ordering columns and tie-breakers require user intent"), + )); + } + if enabled.contains(UNUSED_CTE.code) { + for span in unused_cte_spans(tokens, &facts.unused_cte_names) { + let fix = unused_cte_fix(sql, tokens, span); + diagnostics.push(diagnostic( + &UNUSED_CTE, + Some(span), + fix, + Some("only contiguous authored, comment-free, select-only CTEs can be removed"), + )); + } + } + if enabled.contains(REDUNDANT_DISTINCT.code) { + diagnostics.extend(facts.redundant_distincts.iter().map(|span| { + diagnostic( + &REDUNDANT_DISTINCT, + Some(*span), + Some(redundant_distinct_fix(tokens, *span)), + None, + ) + })); + } + if enabled.contains(POSITIONAL_SET_STAR.code) { + diagnostics.extend(diagnostics_for_spans( + &POSITIONAL_SET_STAR, + &facts.positional_set_stars, + Some( + "column names and their intended positional order require resolved schema evidence", + ), + )); + } + if enabled.contains(EXPLICIT_UNION.code) { + diagnostics.extend(facts.additional.bare_unions.iter().map(|span| { + let fix = explicit_union_fix(*dialect, *span); + diagnostic( + &EXPLICIT_UNION, + Some(*span), + fix, + Some("the active dialect does not accept explicit UNION DISTINCT"), + ) + })); + } + diagnostics +} + +fn additional_style_diagnostics(context: &DiagnosticContext<'_>) -> Vec { + let DiagnosticContext { + sql, + facts, + enabled, + .. + } = context; + let mut diagnostics: Vec = Vec::new(); + if enabled.contains(DUPLICATE_TABLE_ALIAS.code) { + diagnostics.extend(diagnostics_for_spans( + &DUPLICATE_TABLE_ALIAS, + &facts.additional.duplicate_table_aliases, + Some("choosing a new relation alias requires user intent"), + )); + } + if enabled.contains(DUPLICATE_OUTPUT_ALIAS.code) { + diagnostics.extend(diagnostics_for_spans( + &DUPLICATE_OUTPUT_ALIAS, + &facts.additional.duplicate_output_aliases, + Some("choosing a stable output name requires user intent"), + )); + } + if enabled.contains(MIXED_GROUP_ORDER_REFERENCES.code) { + diagnostics.extend(diagnostics_for_spans( + &MIXED_GROUP_ORDER_REFERENCES, + &facts.additional.mixed_group_order_references, + Some("normalization requires reliable projection-name resolution"), + )); + } + if enabled.contains(REDUNDANT_ELSE_NULL.code) { + diagnostics.extend(facts.additional.redundant_else_nulls.iter().map(|span| { + diagnostic( + &REDUNDANT_ELSE_NULL, + Some(*span), + Some(deletion(*span)), + None, + ) + })); + } + if enabled.contains(PARENTHESIZED_DISTINCT.code) { + diagnostics.extend(facts.additional.parenthesized_distincts.iter().map(|span| { + diagnostic( + &PARENTHESIZED_DISTINCT, + Some(*span), + parenthesized_distinct_fix(sql, *span), + Some("comments or malformed parentheses prevent a lossless rewrite"), + ) + })); + } + if enabled.contains(CONSTANT_PREDICATE.code) { + diagnostics.extend(diagnostics_for_spans( + &CONSTANT_PREDICATE, + &facts.additional.constant_predicates, + Some("removing or replacing a scaffold predicate requires user intent"), + )); + } + if enabled.contains(CONSECUTIVE_SEMICOLON.code) { + diagnostics.extend(facts.additional.consecutive_semicolons.iter().map(|span| { + diagnostic( + &CONSECUTIVE_SEMICOLON, + Some(*span), + Some(deletion(*span)), + None, + ) + })); + } + if enabled.contains(REDUNDANT_SELF_ALIAS.code) { + diagnostics.extend(facts.additional.redundant_self_aliases.iter().map(|span| { + diagnostic( + &REDUNDANT_SELF_ALIAS, + Some(*span), + None, + Some("removing the alias may change statically inferred output identity"), + ) + })); + } + if enabled.contains(COUNT_ONE.code) { + diagnostics.extend(facts.additional.count_one_literals.iter().map(|span| { + diagnostic( + &COUNT_ONE, + Some(*span), + Some(LintEdit { + start: span.start, + end: span.end, + replacement: "*".to_string(), + }), + None, + ) + })); + } + if enabled.contains(UNSTABLE_ROW_NUMBER.code) { + diagnostics.extend(diagnostics_for_spans( + &UNSTABLE_ROW_NUMBER, + &facts.additional.unstable_row_numbers, + Some("ordering columns and a stable tie-breaker require user intent"), + )); + } + diagnostics +} + +fn additional_relation_diagnostics(context: &DiagnosticContext<'_>) -> Vec { + let DiagnosticContext { + sql, + facts, + tokens, + enabled, + .. + } = context; + let mut diagnostics: Vec = Vec::new(); + if enabled.contains(NULL_NOT_IN.code) { + diagnostics.extend(diagnostics_for_spans( + &NULL_NOT_IN, + &facts.additional.null_not_in_predicates, + Some("the intended NULL and anti-join semantics require user intent"), + )); + } + if enabled.contains(SET_ARITY_MISMATCH.code) { + diagnostics.extend(diagnostics_for_spans( + &SET_ARITY_MISMATCH, + &facts.additional.set_arity_mismatches, + Some("the intended branch schema requires user intent"), + )); + } + if enabled.contains(PROJECTED_STAR.code) { + diagnostics.extend(diagnostics_for_spans( + &PROJECTED_STAR, + &facts.additional.projected_stars, + Some("column enumeration requires reliable resolved schema evidence"), + )); + } + if enabled.contains(UNALIASED_CALCULATION.code) { + diagnostics.extend(diagnostics_for_spans( + &UNALIASED_CALCULATION, + &facts.additional.unaliased_calculations, + Some("a meaningful stable output name requires user intent"), + )); + } + if enabled.contains(UNUSED_TABLE_ALIAS.code) { + diagnostics.extend(facts.additional.unused_table_aliases.iter().map(|span| { + diagnostic( + &UNUSED_TABLE_ALIAS, + Some(*span), + Some(deletion(*span)), + None, + ) + })); + } + if enabled.contains(NULL_REJECTED_LEFT_JOIN.code) { + diagnostics.extend(diagnostics_for_spans( + &NULL_REJECTED_LEFT_JOIN, + &facts.additional.null_rejected_left_joins, + Some("moving the predicate or changing join type requires user intent"), + )); + } + if enabled.contains(IMPLICIT_INNER_JOIN.code) { + diagnostics.extend(facts.additional.implicit_inner_joins.iter().map(|span| { + diagnostic( + &IMPLICIT_INNER_JOIN, + Some(*span), + Some(LintEdit { + start: span.start, + end: span.end, + replacement: "INNER JOIN".to_string(), + }), + None, + ) + })); + } + if enabled.contains(AMBIGUOUS_ORDER_DIRECTIONS.code) { + diagnostics.extend(diagnostics_for_spans( + &AMBIGUOUS_ORDER_DIRECTIONS, + &facts.additional.ambiguous_order_directions, + Some("normalizing every expression requires a multi-range authored rewrite"), + )); + } + if enabled.contains(UNQUALIFIED_MULTI_SOURCE_COLUMN.code) { + diagnostics.extend(diagnostics_for_spans( + &UNQUALIFIED_MULTI_SOURCE_COLUMN, + &facts.additional.unqualified_multi_source_columns, + Some("choosing the correct relation qualifier requires user intent"), + )); + } + if enabled.contains(INCONSISTENT_SINGLE_SOURCE_QUALIFICATION.code) { + diagnostics.extend(diagnostics_for_spans( + &INCONSISTENT_SINGLE_SOURCE_QUALIFICATION, + &facts.additional.inconsistent_single_source_qualification, + Some("qualification style is normalized by the canonical formatter only when safe"), + )); + } + if enabled.contains(UNKNOWN_RELATION_QUALIFIER.code) { + diagnostics.extend(diagnostics_for_spans( + &UNKNOWN_RELATION_QUALIFIER, + &facts.additional.unknown_relation_qualifiers, + Some("the intended relation or alias requires user intent"), + )); + } + if enabled.contains(SIMPLE_BOOLEAN_CASE.code) { + diagnostics.extend(facts.additional.simple_boolean_cases.iter().map(|span| { + diagnostic( + &SIMPLE_BOOLEAN_CASE, + Some(*span), + simple_boolean_case_fix(sql, tokens, *span), + Some("comments or a non-canonical CASE shape prevent a lossless rewrite"), + ) + })); + } + if enabled.contains(NESTED_ELSE_CASE.code) { + diagnostics.extend(diagnostics_for_spans( + &NESTED_ELSE_CASE, + &facts.additional.nested_else_cases, + Some("flattening nested branches requires a multi-range authored rewrite"), + )); + } + if enabled.contains(UNUSED_JOINED_RELATION.code) { + diagnostics.extend(diagnostics_for_spans( + &UNUSED_JOINED_RELATION, + &facts.additional.unused_joined_relations, + Some("the join may intentionally filter or multiply rows"), + )); + } + diagnostics +} + +fn rule_migration_diagnostics(context: &DiagnosticContext<'_>) -> Vec { + let DiagnosticContext { facts, enabled, .. } = context; + let mut diagnostics: Vec = Vec::new(); + if enabled.contains(COMMENT_ATTACHMENT.code) { + diagnostics.extend(diagnostics_for_spans( + &COMMENT_ATTACHMENT, + &facts.invalid_comment_attachments, + Some("comment attachment requires an authored placement decision"), + )); + } + if enabled.contains(CTE_ONLY_BODY.code) { + diagnostics.extend(diagnostics_for_spans( + &CTE_ONLY_BODY, + &facts.cte_only_bodies, + Some("extracting transformation logic into named CTEs requires user intent"), + )); + } + if enabled.contains(TERMINAL_SELECT.code) { + diagnostics.extend(diagnostics_for_spans( + &TERMINAL_SELECT, + &facts.terminal_selects, + Some("choosing the final logical CTE and projection requires user intent"), + )); + } + if enabled.contains(NESTED_CTE.code) { + diagnostics.extend(diagnostics_for_spans( + &NESTED_CTE, + &facts.nested_ctes, + Some("hoisting a nested CTE requires an authored query rewrite"), + )); + } + if enabled.contains(RECURSIVE_CTE.code) { + diagnostics.extend(diagnostics_for_spans( + &RECURSIVE_CTE, + &facts.recursive_ctes, + Some("replacing recursion requires an authored query design"), + )); + } + if enabled.contains(INLINE_QUERY_RELATION.code) { + diagnostics.extend(diagnostics_for_spans( + &INLINE_QUERY_RELATION, + &facts.inline_query_relations, + Some("extracting a derived relation can change correlation and scope"), + )); + } + diagnostics +} + +fn deletion(span: Span) -> LintEdit { + LintEdit { + start: span.start, + end: span.end, + replacement: String::new(), + } +} + +fn parenthesized_distinct_fix(sql: &str, span: Span) -> Option { + let source = char_slice(sql, span.start, span.end)?; + if contains_comment(source) { + return None; + } + let open = source.find('(')?; + let close = source.rfind(')')?; + (open < close).then(|| LintEdit { + start: span.start, + end: span.end, + replacement: format!("DISTINCT {}", &source[open + 1..close]), + }) +} + +fn simple_boolean_case_fix(sql: &str, tokens: &[Token], span: Span) -> Option { + let case_index = token_index_for_span(tokens, span).or_else(|| { + tokens + .iter() + .position(|token| token.span.start == span.start && token.token_type == TokenType::Case) + })?; + let end_index = tokens.iter().position(|token| token.span.end == span.end)?; + let source = char_slice(sql, span.start, span.end)?; + if contains_comment(source) { + return None; + } + let case_tokens = &tokens[case_index..=end_index]; + if case_tokens + .iter() + .filter(|token| token.token_type == TokenType::When) + .count() + != 1 + || case_tokens + .iter() + .filter(|token| token.token_type == TokenType::Then) + .count() + != 1 + || case_tokens + .iter() + .filter(|token| token.token_type == TokenType::Else) + .count() + != 1 + { + return None; + } + let when_index = + (case_index + 1..end_index).find(|&index| tokens[index].token_type == TokenType::When)?; + let then_index = + (when_index + 1..end_index).find(|&index| tokens[index].token_type == TokenType::Then)?; + let condition = char_slice( + sql, + tokens[when_index].span.end, + tokens[then_index].span.start, + )? + .trim(); + (!condition.is_empty()).then(|| LintEdit { + start: span.start, + end: span.end, + replacement: format!("COALESCE({condition}, FALSE)"), + }) +} + +fn explicit_union_fix(dialect: DialectType, span: Span) -> Option { + matches!( + dialect, + DialectType::Generic + | DialectType::PostgreSQL + | DialectType::MySQL + | DialectType::BigQuery + | DialectType::Snowflake + | DialectType::DuckDB + | DialectType::Hive + | DialectType::Spark + | DialectType::Trino + | DialectType::Presto + | DialectType::Redshift + | DialectType::ClickHouse + | DialectType::Databricks + | DialectType::Athena + ) + .then(|| LintEdit { + start: span.start, + end: span.end, + replacement: "UNION DISTINCT".to_string(), + }) +} + +fn char_slice(sql: &str, start: usize, end: usize) -> Option<&str> { + if start > end { + return None; + } + let byte_start = char_offset_to_byte_offset(sql, start)?; + let byte_end = char_offset_to_byte_offset(sql, end)?; + sql.get(byte_start..byte_end) +} + +fn char_offset_to_byte_offset(sql: &str, offset: usize) -> Option { + if offset == sql.chars().count() { + return Some(sql.len()); + } + sql.char_indices() + .nth(offset) + .map(|(byte_offset, _)| byte_offset) +} + +fn contains_comment(source: &str) -> bool { + source.contains("--") || source.contains("/*") +} + +fn diagnostics_for_spans( + rule: &LintRuleMetadata, + spans: &[Span], + fix_unavailable_reason: Option<&'static str>, +) -> Vec { + spans + .iter() + .map(|span| diagnostic(rule, Some(*span), None, fix_unavailable_reason)) + .collect() +} + +fn diagnostic( + rule: &LintRuleMetadata, + span: Option, + fix: Option, + fix_unavailable_reason: Option<&'static str>, +) -> LintDiagnostic { + let span = span.unwrap_or_default(); + LintDiagnostic { + code: rule.code, + message: Cow::Borrowed(rule.message), + remediation: Cow::Borrowed(rule.remediation), + start: span.start, + end: span.end, + fix_unavailable_reason: fix.is_none().then_some(fix_unavailable_reason).flatten(), + fix, + } +} + +fn unused_cte_spans(tokens: &[Token], unused_names: &[String]) -> Vec { + let mut remaining: Vec = unused_names + .iter() + .map(|name| name.to_ascii_lowercase()) + .collect(); + let mut spans: Vec = Vec::new(); + for (index, token) in tokens.iter().enumerate() { + let name = token.text.to_ascii_lowercase(); + let Some(position) = remaining.iter().position(|candidate| candidate == &name) else { + continue; + }; + if tokens + .get(index + 1) + .is_some_and(|next| next.token_type == TokenType::As) + { + remaining.remove(position); + spans.push(token.span); + } + } + spans +} + +fn null_comparison_spans(tokens: &[Token]) -> Vec { + let comparison_types = [ + TokenType::Eq, + TokenType::Neq, + TokenType::Lt, + TokenType::Lte, + TokenType::Gt, + TokenType::Gte, + ]; + let depths = token_depths(tokens); + tokens + .iter() + .enumerate() + .filter(|(_, token)| comparison_types.contains(&token.token_type)) + .filter(|(index, _)| !is_assignment_operator(tokens, &depths, *index)) + .filter(|(index, _)| { + adjacent_null(tokens, *index, false) || adjacent_null(tokens, *index, true) + }) + .map(|(_, token)| token.span) + .collect() +} + +fn is_assignment_operator(tokens: &[Token], depths: &[usize], operator_index: usize) -> bool { + let depth = depths[operator_index]; + for index in (0..operator_index).rev() { + if depths[index] != depth { + continue; + } + match tokens[index].token_type { + TokenType::Where | TokenType::Having | TokenType::Qualify | TokenType::On => { + return false; + } + TokenType::Set => return true, + TokenType::Select | TokenType::Semicolon => return false, + _ => {} + } + } + false +} + +fn adjacent_null(tokens: &[Token], operator_index: usize, forward: bool) -> bool { + let mut index = operator_index as isize + if forward { 1 } else { -1 }; + let mut wrappers = 0_usize; + loop { + if index < 0 { + return false; + } + let Some(token) = tokens.get(index as usize) else { + return false; + }; + if token.token_type == TokenType::Null { + break; + } + let wrapper = if forward { + TokenType::LParen + } else { + TokenType::RParen + }; + if token.token_type != wrapper { + return false; + } + wrappers += 1; + index += if forward { 1 } else { -1 }; + } + index += if forward { 1 } else { -1 }; + let closing_wrapper = if forward { + TokenType::RParen + } else { + TokenType::LParen + }; + for _ in 0..wrappers { + if index < 0 { + return false; + } + let Some(token) = tokens.get(index as usize) else { + return false; + }; + if token.token_type != closing_wrapper { + return false; + } + index += if forward { 1 } else { -1 }; + } + true +} diff --git a/crates/sqlclean/src/sql_lint/_helpers/formatter.rs b/crates/sqlclean/src/sql_lint/_helpers/formatter.rs new file mode 100644 index 0000000..1583848 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/_helpers/formatter.rs @@ -0,0 +1,352 @@ +use std::collections::{BTreeMap, HashMap}; +use std::str::FromStr; + +use polyglot_sql::tokens::{Token, TokenType}; +use polyglot_sql::{ComplexityGuardOptions, Dialect, DialectType, ParseOptions}; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; + +use crate::constants::LINT_SENTINEL_PREFIX; +use crate::sql_lint::_helpers::formatter_syntax::{cte_macro_indices, protect_syntax}; +use crate::sql_lint::_helpers::printed_layout::{PrintedSql, WrapOptions, wrap_lines}; +use crate::sql_lint::_helpers::token_layout::{ + AuthoredSql, KeywordRecase, OracleLayout, TokenTexts, comments_in, neutralize_comments, + oracle_layout, print_authored_tokens, tokenize, verify_printed_tokens, +}; +use crate::sql_lint::constants::{ + CAST_TYPE_SEPARATOR_KEYWORD, CLOSE_PARENTHESIS, LINT_API_VERSION, OPEN_PARENTHESIS, +}; +use crate::sql_lint::models::{FormatRequest, FormatResponse}; + +#[cfg(test)] +thread_local! { + /// Idempotency re-prints on this thread, for tests that assert which bodies are re-printed. + pub(crate) static RELAYOUTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Spaces per indent step when a request does not choose. +const DEFAULT_INDENT: usize = 2; +const NOT_IDEMPOTENT_FAILURE: &str = "native formatter output is not idempotent"; +const UNSUPPORTED_SQL_FAILURE: &str = + "native formatter could not safely restore literal or cast spelling"; + +pub(crate) fn format_json_impl(request_json: &str) -> Result { + let request: FormatRequest = + serde_json::from_str(request_json).map_err(|error| error.to_string())?; + let response = format_sql(request)?; + serde_json::to_string(&response).map_err(|error| error.to_string()) +} + +/// Format requests in parallel, in order; a request that fails is declined with its reason. +pub(crate) fn format_requests(requests: Vec) -> Vec { + requests + .into_par_iter() + .map(|request| { + let original: String = request.sql.clone(); + format_sql(request).unwrap_or_else(|reason| FormatResponse { + version: LINT_API_VERSION, + sql: original, + changed: false, + formatted: false, + reason: Some(reason), + }) + }) + .collect() +} + +fn format_sql(request: FormatRequest) -> Result { + let original = request.sql.clone(); + match try_format_sql(request) { + Ok(value) => Ok(value), + Err(reason) => Ok(FormatResponse { + version: LINT_API_VERSION, + sql: original, + changed: false, + formatted: false, + reason: Some(reason), + }), + } +} + +fn try_format_sql(request: FormatRequest) -> Result { + if request.version != LINT_API_VERSION { + return Err(format!( + "unsupported native format request version {}; expected {LINT_API_VERSION}", + request.version + )); + } + let original = request.sql; + let dialect_type = + DialectType::from_str(&request.dialect).map_err(|error| error.to_string())?; + let dialect = Dialect::get(dialect_type); + let parse_options = ParseOptions { + complexity_guard: Some(ComplexityGuardOptions { + max_function_call_depth: request.max_function_call_depth, + ..Default::default() + }), + }; + let wrap = request.line_width.map(|line_width| WrapOptions { + line_width, + token_widths: &request.token_widths, + }); + let layout = Layout { + dialect: &dialect, + wrap: wrap.as_ref(), + indent: request.indent.unwrap_or(DEFAULT_INDENT), + }; + let (formatted, formatted_tokens, oracle) = format_layout(&original, &layout, &parse_options)?; + let changed = formatted != original; + if changed && relayout((&formatted, &formatted_tokens), oracle.as_ref(), &layout)? != formatted + { + return Err(NOT_IDEMPOTENT_FAILURE.to_string()); + } + Ok(FormatResponse { + version: LINT_API_VERSION, + changed, + sql: formatted, + formatted: true, + reason: None, + }) +} + +/// The printed output of [`format_layout`], its tokens, and the oracle it was printed against. +type FormattedLayout = (String, Vec, Option); + +/// Print the tokens of `sql` with parse-tree whitespace; return the output and its oracle. +fn format_layout( + sql: &str, + layout: &Layout<'_>, + parse_options: &ParseOptions, +) -> Result { + let dialect = layout.dialect; + let tokens = dialect + .tokenize(sql) + .map_err(|error| format!("native formatter tokenization failed: {error}"))?; + if tokens.is_empty() { + return Ok((sql.to_string(), tokens, None)); + } + let comments = comments_in(sql, &tokens); + let neutral = neutralize_comments(sql, &comments); + let validation_sql = protect_syntax(&neutral, &tokens, &[], false)?; + dialect + .parse_with_options(&validation_sql.sql, parse_options) + .map_err(|error| format!("native formatter could not parse SQL: {error}"))?; + let semantic_tokens = without_statement_terminators(&tokens); + let oracle = oracle_layout( + &layout_oracle(&neutral, &semantic_tokens, dialect, parse_options)?, + dialect, + )?; + let authored = AuthoredSql { + sql, + tokens: &tokens, + comments: &comments, + }; + let (printed, recases, authored_texts) = layout.print(&authored, &oracle)?; + if printed == sql { + return Ok((printed, tokens, Some(oracle))); + } + let printed_tokens = tokenize(&printed, dialect)?; + verify_printed_tokens( + (sql, &tokens, &authored_texts), + (&printed, &printed_tokens), + dialect, + &recases, + )?; + Ok((printed, printed_tokens, Some(oracle))) +} + +/// Re-print formatted SQL against its oracle; unchanged tokens imply an unchanged layout. +fn relayout( + (formatted, tokens): (&str, &[Token]), + oracle: Option<&OracleLayout>, + layout: &Layout<'_>, +) -> Result { + #[cfg(test)] + RELAYOUTS.with(|relayouts| relayouts.set(relayouts.get() + 1)); + let Some(oracle) = oracle.filter(|_| !tokens.is_empty()) else { + return Ok(formatted.to_string()); + }; + let comments = comments_in(formatted, tokens); + let authored = AuthoredSql { + sql: formatted, + tokens, + comments: &comments, + }; + Ok(layout.print(&authored, oracle)?.0) +} + +/// The dialect, indent and optional line wrapping every printed layout uses. +struct Layout<'a> { + dialect: &'a Dialect, + wrap: Option<&'a WrapOptions<'a>>, + indent: usize, +} + +impl Layout<'_> { + fn print( + &self, + authored: &AuthoredSql<'_>, + oracle: &OracleLayout, + ) -> Result<(String, BTreeMap, TokenTexts), String> { + let (printed, recases, texts) = print_authored_tokens(authored, oracle, self.dialect)?; + let no_widths: HashMap = HashMap::new(); + let token_widths = self.wrap.map_or(&no_widths, |wrap| wrap.token_widths); + let Some(mut sql) = PrintedSql::parse(&printed, self.dialect, token_widths)? else { + return Ok((printed, recases, texts)); + }; + sql.set_indent(self.indent); + sql.attach_commas_before_comments(); + if let Some(wrap) = self.wrap { + sql.join_fitting_groups(wrap.line_width); + } + sql.arrange_clauses(self.wrap.map_or(usize::MAX, |wrap| wrap.line_width)); + if let Some(wrap) = self.wrap { + wrap_lines(&mut sql, wrap.line_width); + } + sql.lead_operators_of_multiline_operands(); + Ok((sql.render(), recases, texts)) + } +} + +/// Return the parse-tree layout of `neutral_sql`; only its whitespace is ever printed. +fn layout_oracle( + neutral_sql: &str, + semantic_tokens: &[Token], + dialect: &Dialect, + parse_options: &ParseOptions, +) -> Result { + let protected = protect_syntax( + neutral_sql, + semantic_tokens, + &cast_type_token_ranges(neutral_sql, semantic_tokens)?, + true, + )?; + let expressions = dialect + .parse_with_options(&protected.sql, parse_options) + .map_err(|error| error.to_string())?; + let statements: Result, String> = expressions + .iter() + .map(|expression| { + dialect + .generate_pretty(expression) + .map_err(|error| error.to_string()) + }) + .collect(); + let restored = protected.restore(statements?.join(";\n"), dialect)?; + layout_cte_macros(restored, dialect) +} + +fn layout_cte_macros(mut formatted: String, dialect: &Dialect) -> Result { + if !formatted.contains(LINT_SENTINEL_PREFIX) { + return Ok(formatted); + } + let tokens = dialect + .tokenize(&formatted) + .map_err(|error| error.to_string())?; + let macros = cte_macro_indices(&tokens); + if macros.is_empty() { + return Ok(formatted); + } + let depths = crate::sql_lint::_helpers::engine::token_depths(&tokens); + let chars: Vec = formatted.chars().collect(); + let mut gaps: BTreeMap<(usize, usize), String> = BTreeMap::new(); + for index in macros { + let with = (0..index) + .rev() + .find(|&previous| { + tokens[previous].token_type == TokenType::With && depths[previous] == depths[index] + }) + .ok_or_else(|| "native formatter lost a CTE macro scope".to_string())?; + let indentation = line_indentation(&chars, tokens[with].span.start); + let newline = format!("\n{}", " ".repeat(indentation)); + gaps.insert( + (tokens[index - 1].span.end, tokens[index].span.start), + newline.clone(), + ); + if tokens + .get(index + 1) + .is_some_and(|token| token.token_type == TokenType::Comma) + && let Some(next) = tokens.get(index + 2) + { + gaps.insert((tokens[index + 1].span.end, next.span.start), newline); + } + } + for ((start, end), replacement) in gaps.into_iter().rev() { + let start = char_to_byte(&formatted, start)?; + let end = char_to_byte(&formatted, end)?; + formatted.replace_range(start..end, &replacement); + } + Ok(formatted) +} + +fn line_indentation(characters: &[char], offset: usize) -> usize { + let line_start = characters[..offset] + .iter() + .rposition(|character| *character == '\n') + .map_or(0, |position| position + 1); + characters[line_start..offset] + .iter() + .take_while(|character| **character == ' ' || **character == '\t') + .count() +} + +fn cast_type_token_ranges(sql: &str, tokens: &[Token]) -> Result, String> { + let characters: Vec = sql.chars().collect(); + let mut ranges: Vec<(usize, usize)> = Vec::new(); + let mut cast_type_starts: Vec> = Vec::new(); + let mut previous: Option = None; + for (index, token) in tokens.iter().enumerate() { + let text: String = characters + .get(token.span.start..token.span.end) + .ok_or_else(|| UNSUPPORTED_SQL_FAILURE.to_string())? + .iter() + .collect(); + let normalized = text.to_ascii_uppercase(); + if normalized == OPEN_PARENTHESIS { + let opens_cast = previous + .as_deref() + .is_some_and(|value| matches!(value, "CAST" | "TRY_CAST")); + cast_type_starts.push(opens_cast.then_some(usize::MAX)); + } else if normalized == CLOSE_PARENTHESIS { + if let Some(Some(type_start)) = cast_type_starts.pop() + && type_start != usize::MAX + && type_start < index + { + ranges.push((type_start, index - 1)); + } + } else if normalized == CAST_TYPE_SEPARATOR_KEYWORD + && let Some(Some(type_start)) = cast_type_starts.last_mut() + && *type_start == usize::MAX + { + *type_start = index + 1; + } + previous = Some(normalized); + } + ranges.sort_unstable(); + Ok(ranges) +} + +fn without_statement_terminators(tokens: &[Token]) -> Vec { + tokens + .iter() + .enumerate() + .filter(|(index, token)| { + token.token_type != TokenType::Semicolon + && !(token.token_type == TokenType::Comma + && tokens + .get(index + 1) + .is_some_and(|next| next.token_type == TokenType::From)) + }) + .map(|(_, token)| token.clone()) + .collect() +} + +fn char_to_byte(value: &str, char_offset: usize) -> Result { + if char_offset == value.chars().count() { + return Ok(value.len()); + } + value + .char_indices() + .nth(char_offset) + .map(|(offset, _)| offset) + .ok_or_else(|| "native formatter produced an invalid character offset".to_string()) +} diff --git a/crates/sqlclean/src/sql_lint/_helpers/formatter_syntax.rs b/crates/sqlclean/src/sql_lint/_helpers/formatter_syntax.rs new file mode 100644 index 0000000..c53a540 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/_helpers/formatter_syntax.rs @@ -0,0 +1,459 @@ +use crate::constants::LINT_SENTINEL_PREFIX; +use crate::sql_lint::_helpers::engine::token_depths; +use crate::sql_lint::constants::{ + CTE_MACRO_PLACEHOLDER_LITERAL, POSITION_ARGUMENT_SEPARATOR, POSTFIX_CAST_PREFIX_TOKEN_COUNT, +}; +use polyglot_sql::Dialect; +use polyglot_sql::tokens::{Token, TokenType}; +use std::collections::{HashMap, HashSet}; + +pub(super) struct ProtectedSql { + pub sql: String, + /// The lower-case prefix every sentinel name starts with. + prefix: String, + fragments: Vec<(String, String)>, + in_functions: HashSet, +} + +impl ProtectedSql { + pub(super) fn restore(&self, formatted: String, dialect: &Dialect) -> Result { + let tokens = dialect + .tokenize(&formatted) + .map_err(|error| error.to_string())?; + let mut positions: HashMap> = HashMap::new(); + for (index, token) in tokens.iter().enumerate() { + let prefixed = token + .text + .as_bytes() + .get(..self.prefix.len()) + .is_some_and(|start| start.eq_ignore_ascii_case(self.prefix.as_bytes())); + if !prefixed { + continue; + } + positions + .entry(token.text.to_ascii_lowercase()) + .or_default() + .push(index); + } + let mut edits: Vec<(usize, usize, String)> = Vec::new(); + for (placeholder, original) in &self.fragments { + let key = cte_placeholder_sentinel(placeholder) + .unwrap_or(placeholder) + .trim_start_matches(':') + .trim_start_matches('.') + .trim_matches('\'') + .to_ascii_lowercase(); + let found = positions + .get(&key) + .filter(|found| found.len() == 1) + .ok_or_else(|| { + "native formatter could not restore a protected SQL fragment".to_string() + })?; + let index = found[0]; + if self.in_functions.contains(&key) { + let mut depth = 0; + let separator = (index + 1..tokens.len()) + .find(|&position| { + match tokens[position].token_type { + TokenType::LParen => depth += 1, + TokenType::RParen => depth -= 1, + _ => {} + } + depth == 1 && tokens[position].token_type == TokenType::Comma + }) + .ok_or_else(|| "native formatter lost a POSITION separator".to_string())?; + edits.push(( + tokens[separator].span.start, + tokens[separator].span.end, + " IN".to_string(), + )); + } + if let Some(sentinel) = cte_placeholder_sentinel(placeholder) { + let _ = sentinel; + edits.push(restore_cte_macro(&tokens, index, original)?); + continue; + } + if placeholder.starts_with("::") { + edits.extend(restore_postfix_cast(&tokens, index, original)?); + continue; + } + let start = if placeholder.starts_with('.') { + if index == 0 || tokens[index - 1].token_type != TokenType::Dot { + return Err("native formatter changed a protected path accessor".to_string()); + } + tokens[index - 1].span.start + } else { + tokens[index].span.start + }; + edits.push((start, tokens[index].span.end, original.clone())); + } + edits.sort_by_key(|edit| edit.0); + let chars: Vec = formatted.chars().collect(); + let mut result = String::new(); + let mut cursor = 0; + for (start, end, replacement) in edits { + if start < cursor { + return Err("native formatter produced overlapping syntax restorations".to_string()); + } + result.extend(&chars[cursor..start]); + result.push_str(&replacement); + cursor = end; + } + result.extend(&chars[cursor..]); + Ok(result) + } +} + +/// The sentinel of a CTE macro placeholder, with or without the comma added after it. +fn cte_placeholder_sentinel(placeholder: &str) -> Option<&str> { + let placeholder: &str = placeholder.strip_suffix(',').unwrap_or(placeholder); + placeholder.strip_suffix(" AS (SELECT 1)") +} + +pub(super) fn protect_syntax( + sql: &str, + tokens: &[Token], + cast_types: &[(usize, usize)], + expressions: bool, +) -> Result { + let mut prefix = "__sqlbuild_format_".to_string(); + let lowercase: String = sql.to_ascii_lowercase(); + while lowercase.contains(&prefix) { + prefix.push('_'); + } + let mut cast_type_ends: HashMap = HashMap::new(); + for (start, end) in cast_types { + let _ = cast_type_ends.entry(*start).or_insert(*end); + } + let mut ranges: Vec<(usize, usize, String)> = Vec::new(); + let cte_macros = cte_macro_indices(tokens); + let depths = token_depths(tokens); + let mut separators: HashSet = HashSet::new(); + let mut in_functions: HashSet = HashSet::new(); + let mut index = 0; + while index < tokens.len() { + let token = &tokens[index]; + let sentinel = || format!("{prefix}{}__", ranges.len()); + if separators.contains(&index) { + ranges.push((token.span.start, token.span.end, ",".to_string())); + index += 1; + continue; + } + let (end, replacement) = if cte_macros.contains(&index) { + let separator: &str = if cte_macro_needs_comma(tokens, index) { + "," + } else { + "" + }; + (index, format!("{} AS (SELECT 1){separator}", sentinel())) + } else if !expressions { + index += 1; + continue; + } else if let Some(end) = cast_type_ends.get(&index) { + (*end, sentinel()) + } else if token.token_type == TokenType::DColon { + ( + postfix_type_end(tokens, index + 1)?, + format!("::{}", sentinel()), + ) + } else if token.token_type == TokenType::Colon + && index > 0 + && tokens[index - 1].token_type != TokenType::String + && tokens.get(index + 1).is_some_and(|next| { + next.text + .chars() + .next() + .is_some_and(|character| character.is_alphanumeric() || character == '_') + || matches!( + next.token_type, + TokenType::QuotedIdentifier | TokenType::String + ) + }) + { + (index + 1, format!(".{}", sentinel())) + } else if is_typed_literal(tokens, index) + || (matches!(token.token_type, TokenType::Var | TokenType::Identifier) + && tokens.get(index + 1).is_some_and(|next| { + matches!(next.token_type, TokenType::Var | TokenType::Identifier) + }) + && tokens + .get(index + 2) + .is_some_and(|next| next.token_type == TokenType::Arrow)) + { + (index + 1, sentinel()) + } else if (token.text.eq_ignore_ascii_case("MOD") + || token.text.eq_ignore_ascii_case("POSITION")) + && tokens + .get(index + 1) + .is_some_and(|next| next.token_type == TokenType::LParen) + { + let sentinel: String = sentinel(); + if token.text.eq_ignore_ascii_case("POSITION") { + let separator = (index + 2..tokens.len()) + .take_while(|&position| { + !(tokens[position].token_type == TokenType::RParen + && depths[position] == depths[index] + 1) + }) + .find(|&position| { + tokens[position].token_type == TokenType::In + && depths[position] == depths[index] + 1 + }); + if let Some(separator) = separator { + separators.insert(separator); + in_functions.insert(sentinel.clone()); + } + } + (index, sentinel) + } else if matches!( + token.token_type, + TokenType::String | TokenType::DollarString + ) { + (index, format!("'{}'", sentinel())) + } else { + index += 1; + continue; + }; + ranges.push((token.span.start, tokens[end].span.end, replacement)); + index = end + 1; + } + let chars: Vec = sql.chars().collect(); + let token_indices: HashMap = tokens + .iter() + .enumerate() + .map(|(index, token)| (token.span.start, index)) + .collect(); + let mut prepared = String::new(); + let mut fragments: Vec<(String, String)> = Vec::new(); + let mut cursor = 0; + for (start, end, replacement) in ranges { + prepared.extend(&chars[cursor..start]); + prepared.push_str(&replacement); + if replacement != POSITION_ARGUMENT_SEPARATOR { + let first = token_indices[&start]; + let last = (first..tokens.len()) + .find(|&index| tokens[index].span.end == end) + .ok_or_else(|| "native formatter lost a source fragment boundary".to_string())?; + fragments.push(( + replacement, + canonical_fragment(&chars, &tokens[first..=last]), + )); + } + cursor = end; + } + prepared.extend(&chars[cursor..]); + Ok(ProtectedSql { + sql: prepared, + prefix, + fragments, + in_functions, + }) +} + +fn canonical_fragment(chars: &[char], tokens: &[Token]) -> String { + let mut result = String::new(); + for (index, token) in tokens.iter().enumerate() { + if index > 0 + && !matches!( + token.token_type, + TokenType::LParen + | TokenType::RParen + | TokenType::Comma + | TokenType::Dot + | TokenType::Colon + | TokenType::DColon + | TokenType::LBracket + | TokenType::RBracket + | TokenType::Lt + | TokenType::Gt + ) + && !matches!( + tokens[index - 1].token_type, + TokenType::LParen + | TokenType::Dot + | TokenType::Colon + | TokenType::DColon + | TokenType::LBracket + | TokenType::Lt + ) + { + result.push(' '); + } + result.extend(&chars[token.span.start..token.span.end]); + } + result +} + +pub(super) fn cte_macro_indices(tokens: &[Token]) -> HashSet { + let depths = token_depths(tokens); + let mut scopes: HashSet = HashSet::new(); + let mut indices: HashSet = HashSet::new(); + for (index, token) in tokens.iter().enumerate() { + if token.token_type == TokenType::With { + scopes.insert(depths[index]); + } else if token.token_type == TokenType::Select { + scopes.remove(&depths[index]); + } else if token.text.starts_with(LINT_SENTINEL_PREFIX) + && scopes.contains(&depths[index]) + && index > 0 + && (matches!( + tokens[index - 1].token_type, + TokenType::With | TokenType::Comma + ) || indices.contains(&(index - 1))) + && tokens.get(index + 1).is_some_and(|next| { + matches!(next.token_type, TokenType::Comma | TokenType::Select) + || starts_cte(tokens, index + 1) + }) + { + indices.insert(index); + } + } + indices +} + +/// Whether a CTE definition, `name AS (` or another macro sentinel, starts at token `index`. +fn starts_cte(tokens: &[Token], index: usize) -> bool { + let Some(token) = tokens.get(index) else { + return false; + }; + if token.text.starts_with(LINT_SENTINEL_PREFIX) { + return true; + } + let named: bool = matches!( + token.token_type, + TokenType::Var | TokenType::Identifier | TokenType::QuotedIdentifier + ) || token + .text + .chars() + .all(|character| character.is_alphanumeric() || character == '_'); + named + && tokens + .get(index + 1) + .is_some_and(|next| next.token_type == TokenType::As) + && tokens + .get(index + 2) + .is_some_and(|open| open.token_type == TokenType::LParen) +} + +/// Whether the CTE macro sentinel at token `index` needs a comma to separate it from the next CTE. +pub(super) fn cte_macro_needs_comma(tokens: &[Token], index: usize) -> bool { + starts_cte(tokens, index + 1) +} + +fn restore_cte_macro( + tokens: &[Token], + start: usize, + original: &str, +) -> Result<(usize, usize, String), String> { + let end = start + 5; + if tokens + .get(end) + .is_none_or(|token| token.token_type != TokenType::RParen) + || tokens[start + 1].token_type != TokenType::As + || tokens[start + 2].token_type != TokenType::LParen + || tokens[start + 3].token_type != TokenType::Select + || tokens[start + 4].text != CTE_MACRO_PLACEHOLDER_LITERAL + { + return Err("native formatter changed a CTE macro placeholder".to_string()); + } + Ok(( + tokens[start].span.start, + tokens[end].span.end, + original.to_string(), + )) +} + +fn restore_postfix_cast( + tokens: &[Token], + index: usize, + original: &str, +) -> Result, String> { + if index < POSTFIX_CAST_PREFIX_TOKEN_COUNT + || tokens[index - 1].token_type != TokenType::As + || tokens + .get(index + 1) + .is_none_or(|token| token.token_type != TokenType::RParen) + { + return Err("native formatter changed a postfix cast wrapper".to_string()); + } + let mut depth = 0; + let mut open = None; + for position in (0..index - 1).rev() { + match tokens[position].token_type { + TokenType::RParen => depth += 1, + TokenType::LParen if depth == 0 => { + open = Some(position); + break; + } + TokenType::LParen => depth -= 1, + _ => {} + } + } + let open = open + .filter(|&position| position > 0 && tokens[position - 1].token_type == TokenType::Cast) + .ok_or_else(|| "native formatter could not identify a postfix cast wrapper".to_string())?; + Ok(vec![ + ( + tokens[open - 1].span.start, + tokens[open + 1].span.start, + String::new(), + ), + ( + tokens[index - 2].span.end, + tokens[index + 1].span.end, + original.to_string(), + ), + ]) +} + +fn is_typed_literal(tokens: &[Token], index: usize) -> bool { + matches!( + tokens[index].text.to_ascii_uppercase().as_str(), + "DATE" + | "TIME" + | "TIMESTAMP" + | "TIMESTAMPTZ" + | "TIMESTAMP_NTZ" + | "TIMESTAMP_LTZ" + | "TIMESTAMP_TZ" + ) && tokens.get(index + 1).is_some_and(|token| { + matches!( + token.token_type, + TokenType::String | TokenType::DollarString + ) + }) +} + +fn postfix_type_end(tokens: &[Token], start: usize) -> Result { + let Some(_) = tokens.get(start) else { + return Err("native formatter could not locate a postfix cast type".to_string()); + }; + let mut end = start; + while tokens.get(end + 1).is_some_and(|token| { + matches!( + token.text.to_ascii_uppercase().as_str(), + "PRECISION" | "VARYING" | "WITH" | "WITHOUT" | "TIME" | "ZONE" + ) + }) { + end += 1; + } + if tokens + .get(end + 1) + .is_some_and(|token| token.token_type == TokenType::LParen) + { + let mut depth = 0; + for (index, token) in tokens.iter().enumerate().skip(end + 1) { + match token.token_type { + TokenType::LParen => depth += 1, + TokenType::RParen => { + depth -= 1; + if depth == 0 { + return Ok(index); + } + } + _ => {} + } + } + return Err("native formatter could not locate postfix cast parameters".to_string()); + } + Ok(end) +} diff --git a/crates/sqlclean/src/sql_lint/_helpers/inline_relations.rs b/crates/sqlclean/src/sql_lint/_helpers/inline_relations.rs new file mode 100644 index 0000000..7660a13 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/_helpers/inline_relations.rs @@ -0,0 +1,223 @@ +use polyglot_sql::tokens::{Span, Token, TokenType}; + +use crate::sql_lint::_helpers::engine::is_query_from; + +struct InlineRelationContext<'a> { + tokens: &'a [Token], + depths: &'a [usize], + scopes: Vec>, + significant: &'a [usize], +} + +pub(super) fn collect_inline_query_relation_spans( + tokens: &[Token], + depths: &[usize], + significant: &[usize], +) -> Vec { + let context = InlineRelationContext { + tokens, + depths, + scopes: token_scopes(tokens), + significant, + }; + (0..significant.len()) + .filter_map(|position| inline_query_relation_span(&context, position)) + .collect() +} + +fn inline_query_relation_span( + context: &InlineRelationContext<'_>, + position: usize, +) -> Option { + let &opening = context.significant.get(position)?; + if context.tokens[opening].token_type != TokenType::LParen { + return None; + } + let marker_position = position.checked_sub(1)?; + let previous = context.significant[marker_position]; + if context.tokens[previous].token_type == TokenType::Lateral + || context.tokens[previous].text.eq_ignore_ascii_case("apply") + || !is_query_relation_marker(context, marker_position) + { + return None; + } + + let mut candidate_position = position + 1; + let mut candidate_depth = context.depths[opening] + 1; + loop { + let &candidate = context.significant.get(candidate_position)?; + if context.depths[candidate] != candidate_depth { + return None; + } + if context.tokens[candidate].token_type == TokenType::LParen { + candidate_position += 1; + candidate_depth += 1; + continue; + } + return is_query_start(&context.tokens[candidate]) + .then_some(context.tokens[candidate].span); + } +} + +fn is_query_relation_marker(context: &InlineRelationContext<'_>, marker_position: usize) -> bool { + is_query_relation_marker_base(context, marker_position) + && !query_or_ancestor_is_ignored(context, marker_position) +} + +fn is_query_relation_marker_base( + context: &InlineRelationContext<'_>, + marker_position: usize, +) -> bool { + let marker = context.significant[marker_position]; + if !(is_query_from(context.tokens, marker) + || matches!( + context.tokens[marker].token_type, + TokenType::Join | TokenType::Comma + )) + { + return false; + } + let scope = context.scopes[marker]; + let Some(select_position) = (0..marker_position).rev().find(|&position| { + let index = context.significant[position]; + context.scopes[index] == scope && context.tokens[index].token_type == TokenType::Select + }) else { + return false; + }; + let query_clause = &context.significant[select_position + 1..marker_position]; + if query_clause.iter().any(|&index| { + context.scopes[index] == scope + && matches!( + context.tokens[index].token_type, + TokenType::Semicolon | TokenType::Union | TokenType::Intersect | TokenType::Except + ) + }) { + return false; + } + if is_query_from(context.tokens, marker) { + return true; + } + let Some(from_position) = query_clause + .iter() + .position(|&index| context.scopes[index] == scope && is_query_from(context.tokens, index)) + else { + return false; + }; + !query_clause[from_position + 1..].iter().any(|&index| { + context.scopes[index] == scope && is_post_relation_clause(context.tokens[index].token_type) + }) +} + +fn query_or_ancestor_is_ignored( + context: &InlineRelationContext<'_>, + marker_position: usize, +) -> bool { + let marker = context.significant[marker_position]; + let mut scope = context.scopes[marker]; + while let Some(opening) = scope { + if scope_starts_query(context, opening) && query_scope_is_ignored(context, opening) { + return true; + } + scope = context.scopes[opening]; + } + false +} + +fn scope_starts_query(context: &InlineRelationContext<'_>, opening: usize) -> bool { + let Ok(mut position) = context.significant.binary_search(&opening) else { + return false; + }; + position += 1; + let mut expected_depth = context.depths[opening] + 1; + loop { + let Some(&candidate) = context.significant.get(position) else { + return false; + }; + if context.depths[candidate] != expected_depth { + return false; + } + if context.tokens[candidate].token_type == TokenType::LParen { + position += 1; + expected_depth += 1; + continue; + } + return is_query_start(&context.tokens[candidate]); + } +} + +fn query_scope_is_ignored(context: &InlineRelationContext<'_>, opening: usize) -> bool { + let Ok(mut opening_position) = context.significant.binary_search(&opening) else { + return false; + }; + while opening_position > 0 + && context.tokens[context.significant[opening_position - 1]].token_type == TokenType::LParen + { + opening_position -= 1; + } + let Some(marker_position) = opening_position.checked_sub(1) else { + return false; + }; + let marker = context.significant[marker_position]; + match context.tokens[marker].token_type { + TokenType::Exists | TokenType::In => true, + TokenType::From | TokenType::Join | TokenType::As => false, + TokenType::Comma => !is_query_relation_marker_base(context, marker_position), + TokenType::Union + | TokenType::Intersect + | TokenType::Except + | TokenType::All + | TokenType::Distinct + | TokenType::Semicolon + | TokenType::Lateral => false, + _ if context.tokens[marker].text.eq_ignore_ascii_case("apply") + || context.tokens[marker] + .text + .eq_ignore_ascii_case("materialized") => + { + false + } + _ => true, + } +} + +fn is_query_start(token: &Token) -> bool { + matches!(token.token_type, TokenType::Select | TokenType::Values) + || token.text.eq_ignore_ascii_case("with") +} + +fn is_post_relation_clause(token_type: TokenType) -> bool { + matches!( + token_type, + TokenType::Where + | TokenType::Group + | TokenType::Having + | TokenType::Qualify + | TokenType::Order + | TokenType::Limit + | TokenType::Offset + | TokenType::Fetch + ) +} + +fn token_scopes(tokens: &[Token]) -> Vec> { + let mut openings: Vec = Vec::new(); + tokens + .iter() + .enumerate() + .map(|(index, token)| { + let scope = openings.last().copied(); + if matches!( + token.token_type, + TokenType::LParen | TokenType::LBracket | TokenType::LBrace + ) { + openings.push(index); + } else if matches!( + token.token_type, + TokenType::RParen | TokenType::RBracket | TokenType::RBrace + ) { + let _ = openings.pop(); + } + scope + }) + .collect() +} diff --git a/crates/sqlclean/src/sql_lint/_helpers/join_predicates.rs b/crates/sqlclean/src/sql_lint/_helpers/join_predicates.rs new file mode 100644 index 0000000..2b5453d --- /dev/null +++ b/crates/sqlclean/src/sql_lint/_helpers/join_predicates.rs @@ -0,0 +1,694 @@ +//! Join predicates, and whether an explicit cross join can multiply rows. + +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; + +use polyglot_sql::expressions::{Expression, JoinKind, Literal, Select, Subquery, TableRef, With}; +use polyglot_sql::parser::{Parser, ParserConfig}; +use polyglot_sql::tokens::{Span, Token, TokenType}; +use polyglot_sql::{DialectType, ExpressionWalk}; + +use crate::constants::SETTING_SNIPPET_INDENT; +use crate::sql_lint::constants::{ + CROSS_JOIN_CTE_MARKER, CROSS_JOIN_PATH_PLACEHOLDER, CROSS_JOIN_PREFIX, + CROSS_JOIN_REASON_PLACEHOLDER, CROSS_JOIN_SUFFIX, CROSS_JOIN_UNNAMED_MESSAGE, +}; +use crate::sql_lint::models::{CrossJoinRequest, LintDiagnostic, LintRuleMetadata}; + +const CROSS_JOIN_FIX_REASON: &str = "a keyed relationship requires user intent"; +const FIRST_ROW: &str = "1"; +const ROW_GENERATOR_FUNCTIONS: [&str; 16] = [ + "unnest", + "explode", + "explode_outer", + "posexplode", + "posexplode_outer", + "inline", + "inline_outer", + "flatten", + "generate_series", + "generate_subscripts", + "json_each", + "json_tree", + "json_array_elements", + "jsonb_array_elements", + "range", + "stack", +]; + +pub(super) fn join_expression_spans( + tokens: &[Token], + depths: &[usize], + dialect: DialectType, +) -> Vec { + let mut spans: Vec = Vec::new(); + for (index, token) in tokens.iter().enumerate() { + if token.token_type != TokenType::On { + continue; + } + let clause = (0..index).rev().find(|&previous| { + depths[previous] == depths[index] + && matches!( + tokens[previous].token_type, + TokenType::Join + | TokenType::Select + | TokenType::Where + | TokenType::From + | TokenType::On + ) + }); + if clause.is_none_or(|previous| tokens[previous].token_type != TokenType::Join) { + continue; + } + let end = (index + 1..tokens.len()) + .find(|&next| { + depths[next] < depths[index] + || (depths[next] == depths[index] + && matches!( + tokens[next].token_type, + TokenType::Join + | TokenType::Where + | TokenType::Group + | TokenType::Having + | TokenType::Qualify + | TokenType::Order + | TokenType::Limit + | TokenType::Union + | TokenType::Intersect + | TokenType::Except + | TokenType::Semicolon + )) + }) + .unwrap_or(tokens.len()); + let mut parser = Parser::with_config( + tokens[index + 1..end].to_vec(), + ParserConfig { + dialect: Some(dialect), + ..Default::default() + }, + ); + if !matches!(parser.parse_expressions(), Ok(Some(ref predicate)) if plain_predicate(predicate)) + { + spans.push(token.span); + } + } + spans +} + +fn plain_column(expression: &Expression) -> bool { + matches!( + expression, + Expression::Column(_) | Expression::Identifier(_) + ) || matches!(expression, Expression::Paren(value) if plain_column(&value.this)) +} + +fn literal(expression: &Expression) -> bool { + matches!( + expression, + Expression::Literal(_) | Expression::Boolean(_) | Expression::Null(_) + ) || matches!(expression, Expression::Neg(value) if matches!(&value.this, Expression::Literal(_))) +} + +fn operand(expression: &Expression) -> bool { + plain_column(expression) || literal(expression) +} + +fn comparison(left: &Expression, right: &Expression) -> bool { + operand(left) && operand(right) +} + +fn plain_predicate(expression: &Expression) -> bool { + match expression { + Expression::Boolean(_) => true, + Expression::Paren(value) => plain_predicate(&value.this), + Expression::And(value) | Expression::Or(value) => { + plain_predicate(&value.left) && plain_predicate(&value.right) + } + Expression::Eq(value) + | Expression::Neq(value) + | Expression::Lt(value) + | Expression::Lte(value) + | Expression::Gt(value) + | Expression::Gte(value) + | Expression::NullSafeEq(value) + | Expression::NullSafeNeq(value) + | Expression::Adjacent(value) + | Expression::ArrayContainsAll(value) + | Expression::ArrayContainedBy(value) + | Expression::ArrayOverlaps(value) + | Expression::ExtendsLeft(value) + | Expression::ExtendsRight(value) + | Expression::Match(value) + | Expression::TsMatch(value) => comparison(&value.left, &value.right), + Expression::Like(value) | Expression::ILike(value) => { + comparison(&value.left, &value.right) + && value.escape.as_ref().is_none_or(literal) + && value.quantifier.is_none() + } + Expression::Between(value) => { + plain_column(&value.this) && operand(&value.low) && operand(&value.high) + } + Expression::In(value) => { + plain_column(&value.this) + && value.query.is_none() + && value.unnest.is_none() + && !value.is_field + && !value.expressions.is_empty() + && value.expressions.iter().all(literal) + } + Expression::IsNull(value) => plain_column(&value.this), + _ => false, + } +} + +/// A `rule` diagnostic for every cross join that can multiply rows, naming its relation and CTE. +pub(super) fn cross_join_diagnostics( + request: &CrossJoinRequest<'_>, + rule: &LintRuleMetadata, +) -> Vec { + let one_row_relations: HashSet = request + .relation_keys + .iter() + .filter(|(_, keys)| keys.iter().any(Vec::is_empty)) + .map(|(name, _)| name.to_ascii_lowercase()) + .collect(); + let relation_names: HashMap = request + .relation_names + .iter() + .map(|(sentinel, name)| (sentinel.to_ascii_lowercase(), name.clone())) + .collect(); + let inputs = OneRowInputs { + one_row_relations: &one_row_relations, + relation_names: &relation_names, + }; + multiplying_cross_joins( + request.statements, + request.tokens, + request.cross_spans, + &inputs, + ) + .into_iter() + .map(|finding| LintDiagnostic { + code: rule.code, + message: finding + .relation + .as_deref() + .map_or(Cow::Borrowed(CROSS_JOIN_UNNAMED_MESSAGE), |relation| { + Cow::Owned(cross_join_message(relation, finding.cte.as_deref())) + }), + remediation: if finding.relation.is_some() { + Cow::Borrowed(rule.remediation) + } else { + Cow::Owned(file_wide_remediation(rule.code)) + }, + start: finding.span.start, + end: finding.span.end, + fix_unavailable_reason: Some(CROSS_JOIN_FIX_REASON), + fix: None, + }) + .collect() +} + +/// Help for a join no relation names: fix it, or record the file-wide exception it allows. +fn file_wide_remediation(code: &str) -> String { + let entry: String = [ + "[[rules.rule_exceptions]]".to_owned(), + format!("rule = \"{code}\""), + format!("path = \"{CROSS_JOIN_PATH_PLACEHOLDER}\""), + format!("reason = \"{CROSS_JOIN_REASON_PLACEHOLDER}\""), + ] + .iter() + .map(|line| format!("\n{SETTING_SNIPPET_INDENT}{line}")) + .collect(); + format!( + "Use a keyed JOIN, or make one side provably at most one row. This join reads no named \ + relation, so a relation-level exception cannot cover it; when the cartesian product is \ + intentional, record a reviewed file-wide exception in the configuration file:{entry}" + ) +} + +/// The finding message naming the joined relation and, inside a CTE, that CTE. +fn cross_join_message(relation: &str, cte: Option<&str>) -> String { + match cte { + Some(cte) => { + format!("{CROSS_JOIN_PREFIX}{relation}{CROSS_JOIN_CTE_MARKER}{cte}{CROSS_JOIN_SUFFIX}") + } + None => format!("{CROSS_JOIN_PREFIX}{relation}{CROSS_JOIN_SUFFIX}"), + } +} + +/// One explicit cross join found in the query tree. +struct CrossJoinTarget { + /// Character offsets where the joined relation may start in the token stream. + anchors: Vec, + /// Whether both joined sides may hold more than one row. + multiplies: bool, + /// The joined relation's name as authored, with dependency sentinels resolved. + relation: Option, + /// The top-level CTE whose query contains the join. + cte: Option, +} + +/// The cross join reported at one `CROSS` keyword. +struct CrossJoinFinding { + span: Span, + relation: Option, + cte: Option, +} + +/// What the analysis knows about relations named in the query. +struct OneRowInputs<'a> { + /// Lowercased dependency sentinels whose relation holds at most one row. + one_row_relations: &'a HashSet, + /// Lowercased dependency sentinels mapped to the relation names they stand for. + relation_names: &'a HashMap, +} + +struct Analysis<'a> { + inputs: &'a OneRowInputs<'a>, + ctes: HashMap, + targets: Vec, +} + +/// Cross joins at `cross_spans` that can multiply rows, each with its relation and CTE. +fn multiplying_cross_joins( + statements: &[Expression], + tokens: &[Token], + cross_spans: &[Span], + inputs: &OneRowInputs<'_>, +) -> Vec { + if cross_spans.is_empty() { + return Vec::new(); + } + let mut analysis = Analysis { + inputs, + ctes: HashMap::new(), + targets: Vec::new(), + }; + for statement in statements { + analysis.record_ctes(statement); + } + for statement in statements { + analysis.visit(statement, None, true); + } + let significant: Vec = (0..tokens.len()) + .filter(|&index| { + !matches!( + tokens[index].token_type, + TokenType::Space + | TokenType::Break + | TokenType::LineComment + | TokenType::BlockComment + ) + }) + .collect(); + let mut findings: Vec = Vec::new(); + for span in cross_spans { + let candidates: Vec = relation_anchor_candidates(tokens, &significant, *span); + let target: Option<&CrossJoinTarget> = analysis + .targets + .iter() + .find(|target| anchored_at(target, &candidates)); + match target { + Some(target) if !target.multiplies => {} + Some(target) => findings.push(CrossJoinFinding { + span: *span, + relation: target.relation.clone(), + cte: target.cte.clone(), + }), + None => findings.push(CrossJoinFinding { + span: *span, + relation: None, + cte: None, + }), + } + } + findings +} + +impl Analysis<'_> { + /// Record whether every CTE holds at most one row, in definition order. + fn record_ctes(&mut self, expression: &Expression) { + for node in expression.dfs() { + let Some(with) = with_clause(node) else { + continue; + }; + for cte in &with.ctes { + let one_row: bool = !with.recursive && self.at_most_one_row(&cte.this); + let name: String = cte.alias.name.to_ascii_lowercase(); + let known: bool = self.ctes.get(&name).copied().unwrap_or(one_row); + self.ctes.insert(name, known && one_row); + } + } + } + + /// Record joins under `expression`, labelled with the top-level CTE containing them. + fn visit(&mut self, expression: &Expression, cte: Option<&str>, top: bool) { + let mut bodies: Vec<&Expression> = Vec::new(); + if let Some(with) = with_clause(expression) { + for definition in &with.ctes { + bodies.push(&definition.this); + let label: Option<&str> = if top { + Some(&definition.alias.name) + } else { + cte + }; + self.visit(&definition.this, label, false); + } + } + if let Expression::Select(select) = expression { + self.record_joins(select, cte); + } + let branches_are_top: bool = top && is_set_operation(expression); + for child in expression.children() { + if bodies.iter().any(|body| std::ptr::eq(*body, child)) { + continue; + } + self.visit(child, cte, branches_are_top); + } + } + + fn record_joins(&mut self, select: &Select, cte: Option<&str>) { + let single_from: Option<&Expression> = select + .from + .as_ref() + .filter(|from| from.expressions.len() == 1) + .and_then(|from| from.expressions.first()); + for (index, join) in select.joins.iter().enumerate() { + if join.kind != JoinKind::Cross { + continue; + } + let left_one_row: bool = + index == 0 && single_from.is_some_and(|from| self.relation_one_row(from)); + let multiplies: bool = !(left_one_row || self.relation_one_row(&join.this)); + self.targets.push(CrossJoinTarget { + anchors: relation_anchors(&join.this), + multiplies, + relation: self.relation_name(&join.this), + cte: cte.map(str::to_owned), + }); + } + } + + fn relation_name(&self, relation: &Expression) -> Option { + match relation { + Expression::Table(table) => Some(self.table_name(table)), + Expression::Subquery(subquery) => { + subquery.alias.as_ref().map(|alias| alias.name.clone()) + } + Expression::Alias(alias) => Some(alias.alias.name.clone()), + _ => None, + } + } + + fn table_name(&self, table: &TableRef) -> String { + if table.schema.is_none() && table.catalog.is_none() { + let lowered: String = table.name.name.to_ascii_lowercase(); + if let Some(name) = self.inputs.relation_names.get(&lowered) { + return name.clone(); + } + } + [&table.catalog, &table.schema] + .into_iter() + .flatten() + .map(|part| part.name.as_str()) + .chain([table.name.name.as_str()]) + .collect::>() + .join(".") + } + + /// Whether a query expression returns at most one row. + fn at_most_one_row(&self, expression: &Expression) -> bool { + match expression { + Expression::Select(select) => self.select_one_row(select), + Expression::Subquery(subquery) => self.subquery_one_row(subquery), + Expression::Paren(paren) => self.at_most_one_row(&paren.this), + Expression::Values(values) => values.expressions.len() <= 1, + _ => false, + } + } + + fn subquery_one_row(&self, subquery: &Subquery) -> bool { + !subquery.lateral + && (subquery + .limit + .as_ref() + .is_some_and(|limit| !limit.percent && at_most_one(&limit.this)) + || self.at_most_one_row(&subquery.this)) + } + + fn select_one_row(&self, select: &Select) -> bool { + if select.limit_by.is_some() || select.connect.is_some() || !select.lateral_views.is_empty() + { + return false; + } + if limited_to_one_row(select) { + return true; + } + if select.expressions.iter().any(generates_rows) { + return false; + } + if select.group_by.is_none() && select.expressions.iter().any(aggregates) { + return true; + } + if select + .qualify + .as_ref() + .is_some_and(|qualify| first_unpartitioned_row(&qualify.this, select)) + { + return true; + } + if select.group_by.is_some() || !select.joins.is_empty() { + return false; + } + match &select.from { + None => true, + Some(from) => match from.expressions.as_slice() { + [relation] => self.relation_one_row(relation), + _ => false, + }, + } + } + + /// Whether a relation in FROM or JOIN holds at most one row. + fn relation_one_row(&self, relation: &Expression) -> bool { + match relation { + Expression::Table(table) => { + if table.schema.is_some() + || table.catalog.is_some() + || table.identifier_func.is_some() + || table.changes.is_some() + { + return false; + } + let name: String = table.name.name.to_ascii_lowercase(); + match self.ctes.get(&name) { + Some(one_row) => *one_row, + None => self.inputs.one_row_relations.contains(&name), + } + } + Expression::Subquery(subquery) => self.subquery_one_row(subquery), + Expression::Paren(paren) => self.relation_one_row(&paren.this), + Expression::Values(values) => values.expressions.len() <= 1, + _ => false, + } + } +} + +/// The WITH clause a query expression owns. +fn with_clause(expression: &Expression) -> Option<&With> { + match expression { + Expression::Select(select) => select.with.as_ref(), + Expression::Union(union) => union.with.as_ref(), + Expression::Intersect(intersect) => intersect.with.as_ref(), + Expression::Except(except) => except.with.as_ref(), + _ => None, + } +} + +fn is_set_operation(expression: &Expression) -> bool { + matches!( + expression, + Expression::Union(_) | Expression::Intersect(_) | Expression::Except(_) + ) +} + +fn limited_to_one_row(select: &Select) -> bool { + let limited: bool = select + .limit + .as_ref() + .is_some_and(|limit| !limit.percent && at_most_one(&limit.this)); + let fetched: bool = select.fetch.as_ref().is_some_and(|fetch| { + !fetch.percent && !fetch.with_ties && fetch.count.as_ref().is_some_and(at_most_one) + }); + let top: bool = select + .top + .as_ref() + .is_some_and(|top| !top.percent && !top.with_ties && at_most_one(&top.this)); + limited || fetched || top +} + +fn at_most_one(expression: &Expression) -> bool { + match expression { + Expression::Literal(literal) => match literal.as_ref() { + Literal::Number(text) => text.parse::().is_ok_and(|count| count <= 1), + _ => false, + }, + Expression::Paren(paren) => at_most_one(&paren.this), + _ => false, + } +} + +/// Whether a projected expression can return several rows per input row. +fn generates_rows(expression: &Expression) -> bool { + expression.dfs().any(|node| match node { + Expression::Unnest(_) + | Expression::Explode(_) + | Expression::ExplodeOuter(_) + | Expression::GenerateSeries(_) => true, + Expression::Function(function) => is_row_generator(&function.name), + _ => false, + }) +} + +fn is_row_generator(name: &str) -> bool { + let name: &str = name.rsplit('.').next().unwrap_or_default(); + ROW_GENERATOR_FUNCTIONS + .iter() + .any(|generator| generator.eq_ignore_ascii_case(name)) +} + +fn anchored_at(target: &CrossJoinTarget, candidates: &[usize]) -> bool { + target + .anchors + .iter() + .any(|anchor| candidates.contains(anchor)) +} + +/// Whether an expression aggregates outside any window function or subquery. +fn aggregates(expression: &Expression) -> bool { + if polyglot_sql::traversal::is_aggregate(expression) { + return true; + } + match expression { + Expression::WindowFunction(_) | Expression::Subquery(_) | Expression::Select(_) => false, + _ => expression.children().into_iter().any(aggregates), + } +} + +/// `QUALIFY ROW_NUMBER() OVER (ORDER BY ...) = 1` without a partition, directly or by alias. +fn first_unpartitioned_row(condition: &Expression, select: &Select) -> bool { + match condition { + Expression::And(operation) => { + first_unpartitioned_row(&operation.left, select) + || first_unpartitioned_row(&operation.right, select) + } + Expression::Paren(paren) => first_unpartitioned_row(&paren.this, select), + Expression::Eq(operation) => { + let compared: &Expression = match (&operation.left, &operation.right) { + (other, Expression::Literal(literal)) | (Expression::Literal(literal), other) + if matches!(literal.as_ref(), Literal::Number(text) if text == FIRST_ROW) => + { + other + } + _ => return false, + }; + match compared { + Expression::WindowFunction(_) => unpartitioned_row_number(compared), + Expression::Column(column) if column.table.is_none() => select + .expressions + .iter() + .any(|projection| match projection { + Expression::Alias(alias) => { + alias.alias.name.eq_ignore_ascii_case(&column.name.name) + && unpartitioned_row_number(&alias.this) + } + _ => false, + }), + _ => false, + } + } + _ => false, + } +} + +fn unpartitioned_row_number(expression: &Expression) -> bool { + matches!( + expression, + Expression::WindowFunction(window) + if matches!(window.this, Expression::RowNumber(_)) + && window.over.window_name.is_none() + && window.over.partition_by.is_empty() + ) +} + +/// Where a joined relation can start: its first name part, or a derived table's alias. +fn relation_anchors(relation: &Expression) -> Vec { + match relation { + Expression::Table(table) => { + let mut anchors: Vec = Vec::new(); + for identifier in [&table.catalog, &table.schema].into_iter().flatten() { + anchors.extend(identifier.span.map(|span| span.start)); + } + anchors.extend(table.name.span.map(|span| span.start)); + anchors.extend(table.span.map(|span| span.start)); + anchors + } + Expression::Subquery(subquery) => subquery + .alias + .as_ref() + .and_then(|alias| alias.span) + .map(|span| span.start) + .into_iter() + .collect(), + Expression::Alias(alias) => alias + .alias + .span + .map(|span| span.start) + .into_iter() + .collect(), + _ => Vec::new(), + } +} + +/// Where the relation after `CROSS JOIN` at `cross` starts, and a derived table's alias. +fn relation_anchor_candidates(tokens: &[Token], significant: &[usize], cross: Span) -> Vec { + let Some(position) = significant + .iter() + .position(|&index| tokens[index].span.start == cross.start) + else { + return Vec::new(); + }; + let Some(&first) = significant.get(position + 2) else { + return Vec::new(); + }; + let mut candidates: Vec = vec![tokens[first].span.start]; + if tokens[first].token_type == TokenType::LParen { + let mut depth: usize = 0; + let mut cursor: usize = position + 2; + while let Some(&index) = significant.get(cursor) { + match tokens[index].token_type { + TokenType::LParen => depth += 1, + TokenType::RParen => { + depth = depth.saturating_sub(1); + if depth == 0 { + break; + } + } + _ => {} + } + cursor += 1; + } + let mut alias: usize = cursor + 1; + if significant + .get(alias) + .is_some_and(|&index| tokens[index].token_type == TokenType::As) + { + alias += 1; + } + if let Some(&index) = significant.get(alias) { + candidates.push(tokens[index].span.start); + } + } + candidates +} diff --git a/crates/sqlclean/src/sql_lint/_helpers/mod.rs b/crates/sqlclean/src/sql_lint/_helpers/mod.rs new file mode 100644 index 0000000..4e13c95 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/_helpers/mod.rs @@ -0,0 +1,10 @@ +pub mod additional; +pub mod engine; +pub mod formatter; +pub mod formatter_syntax; +pub mod inline_relations; +pub mod join_predicates; +pub mod parsing; +pub mod printed_layout; +pub mod terminal_shape; +pub mod token_layout; diff --git a/crates/sqlclean/src/sql_lint/_helpers/parsing.rs b/crates/sqlclean/src/sql_lint/_helpers/parsing.rs new file mode 100644 index 0000000..d9611a1 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/_helpers/parsing.rs @@ -0,0 +1,44 @@ +//! The one tokenize-and-parse step every lint and fact request shares. + +use std::str::FromStr; + +use polyglot_sql::parser::ParserConfig; + +use crate::sql_lint::models::ParsedSql; +use polyglot_sql::{ + ComplexityGuardOptions, Dialect, DialectType, Expression, ExpressionWalk, Parser, +}; + +const MAX_LINT_FUNCTION_CALL_DEPTH: usize = 128; + +/// Tokenize and parse `sql`, rejecting statements the parser kept as opaque text. +pub(crate) fn parse(sql: &str, dialect_name: &str) -> Result { + let dialect_type = DialectType::from_str(dialect_name).map_err(|error| error.to_string())?; + let dialect = Dialect::get(dialect_type); + let tokens = dialect.tokenize(sql).map_err(|error| error.to_string())?; + let mut parser = Parser::with_config( + tokens.clone(), + ParserConfig { + dialect: Some(dialect_type), + complexity_guard: ComplexityGuardOptions { + max_function_call_depth: Some(MAX_LINT_FUNCTION_CALL_DEPTH), + ..Default::default() + }, + ..Default::default() + }, + ); + let statements = parser.parse().map_err(|error| error.to_string())?; + for statement in &statements { + if statement + .dfs() + .any(|node| matches!(node, Expression::Command(_) | Expression::Raw(_))) + { + return Err("unsupported syntax: native parser retained an opaque SQL node".to_owned()); + } + } + Ok(ParsedSql { + dialect: dialect_type, + tokens, + statements, + }) +} diff --git a/crates/sqlclean/src/sql_lint/_helpers/printed_layout.rs b/crates/sqlclean/src/sql_lint/_helpers/printed_layout.rs new file mode 100644 index 0000000..a57a34e --- /dev/null +++ b/crates/sqlclean/src/sql_lint/_helpers/printed_layout.rs @@ -0,0 +1,1086 @@ +//! Printed SQL as tokens and gaps, with clause layout rules and line-width wrapping. + +use std::collections::HashMap; + +use polyglot_sql::Dialect; +use polyglot_sql::tokens::TokenType; + +use crate::sql_lint::constants::OPEN_PARENTHESIS; + +/// The indent step of the parse-tree layout every printed statement starts from. +const PARSED_INDENT: usize = 2; +const COMMA: &str = ","; +const SINGLE_SPACE: &str = " "; +const BETWEEN_KEYWORD: &str = "BETWEEN"; +const BETWEEN_SEPARATOR: &str = "AND"; +const VALUES_KEYWORD: &str = "VALUES"; +const DECODE_FUNCTION: &str = "DECODE"; +const CASE_KEYWORD: &str = "CASE"; +const SENTINEL_PREFIX: &str = "__SQB_LINT_"; +const SINGLE_ITEM_CLAUSES: [&str; 4] = ["SELECT", "WHERE", "QUALIFY", "HAVING"]; +const SELECT_MODIFIERS: [&str; 10] = [ + "DISTINCT", "ALL", "TOP", "PERCENT", "WITH", "TIES", "ON", "AS", "STRUCT", "VALUE", +]; +const LEADING_OPERATORS: [&str; 8] = ["+", "-", "*", "/", "%", "||", "AND", "OR"]; +const CHAIN_KEYWORDS: [&str; 2] = ["AND", "OR"]; +const BETWEEN_BOUNDARIES: [&str; 12] = [ + "AND", "OR", "WHEN", "THEN", "ELSE", "WHERE", "ON", "HAVING", "QUALIFY", "SELECT", ",", "NOT", +]; +const ARITHMETIC_OPERATORS: [&str; 5] = ["+", "-", "||", "/", "%"]; +const WINDOW_CLAUSE_KEYWORDS: [&str; 4] = ["ORDER", "ROWS", "RANGE", "GROUPS"]; +const CASE_BRANCH_KEYWORDS: [&str; 3] = ["WHEN", "THEN", "ELSE"]; +const TYPE_PARAMETER_WORDS: [&str; 1] = ["MAX"]; +const TYPE_NAMES: [&str; 30] = [ + "BIGNUMERIC", + "BINARY", + "BIT", + "CHAR", + "CHARACTER", + "DATETIME", + "DATETIME2", + "DATETIMEOFFSET", + "DEC", + "DECIMAL", + "DOUBLE", + "FLOAT", + "INTERVAL", + "NCHAR", + "NUMBER", + "NUMERIC", + "NVARCHAR", + "STRING", + "TEXT", + "TIME", + "TIMESTAMP", + "TIMESTAMPTZ", + "TIMESTAMP_LTZ", + "TIMESTAMP_NTZ", + "TIMESTAMP_TZ", + "TIMETZ", + "VARBINARY", + "VARCHAR", + "BYTES", + "BPCHAR", +]; + +/// Printed SQL whose layout passes only ever rewrite the gaps between tokens. +pub(crate) struct PrintedSql<'a> { + tokens: Vec, + kinds: Vec, + /// The text before each token, plus the text after the last token. + gaps: Vec, + depths: Vec, + partners: Vec>, + /// Gaps that line wrapping must keep: inside VALUES rows and type parameter lists. + locked: Vec, + token_widths: &'a HashMap, + /// Spaces per indent step. + indent: usize, +} + +/// One printed line; end columns are absent when comments or multi-line tokens pin it. +struct Line { + first: usize, + last: usize, + indentation: usize, + end_columns: Option>, +} + +impl<'a> PrintedSql<'a> { + pub(crate) fn parse( + sql: &str, + dialect: &Dialect, + token_widths: &'a HashMap, + ) -> Result, String> { + let tokens = dialect + .tokenize(sql) + .map_err(|error| format!("native formatter tokenization failed: {error}"))?; + if tokens.is_empty() { + return Ok(None); + } + let characters: Vec = sql.chars().collect(); + let mut texts: Vec = Vec::with_capacity(tokens.len()); + let mut gaps: Vec = Vec::with_capacity(tokens.len() + 1); + let mut previous_end = 0_usize; + for token in &tokens { + gaps.push(characters[previous_end..token.span.start].iter().collect()); + texts.push( + characters[token.span.start..token.span.end] + .iter() + .collect(), + ); + previous_end = token.span.end; + } + gaps.push(characters[previous_end..].iter().collect()); + let (depths, partners) = bracket_structure(&texts); + let locked = vec![false; texts.len() + 1]; + Ok(Some(Self { + kinds: tokens.iter().map(|token| token.token_type).collect(), + tokens: texts, + gaps, + depths, + partners, + locked, + token_widths, + indent: PARSED_INDENT, + })) + } + + fn len(&self) -> usize { + self.tokens.len() + } + + fn upper(&self, index: usize) -> String { + self.tokens[index].to_ascii_uppercase() + } + + fn is(&self, index: usize, text: &str) -> bool { + self.tokens + .get(index) + .is_some_and(|token| token.eq_ignore_ascii_case(text)) + } + + fn line_ranges(&self, first: usize, last: usize) -> Vec<(usize, usize)> { + let mut ranges: Vec<(usize, usize)> = Vec::new(); + let mut start = first; + for index in first + 1..=last { + if self.gaps[index].contains('\n') { + ranges.push((start, index - 1)); + start = index; + } + } + ranges.push((start, last)); + ranges + } + + /// The first and last token of the printed line holding `index`. + fn line_of(&self, index: usize) -> (usize, usize) { + let first = (1..=index) + .rev() + .find(|&position| self.gaps[position].contains('\n')) + .unwrap_or(0); + let last = (index + 1..self.len()) + .find(|&position| self.gaps[position].contains('\n')) + .map_or(self.len() - 1, |position| position - 1); + (first, last) + } + + fn line(&self, first: usize, last: usize) -> Line { + let indentation = self.indentation(first); + let lead = self.gaps[first].rsplit('\n').next().unwrap_or_default(); + let trailing = self.gaps[last + 1].split('\n').next().unwrap_or_default(); + let commented = !lead.trim().is_empty() + || !trailing.trim().is_empty() + || (first + 1..=last).any(|index| !self.gaps[index].trim().is_empty()); + let multiline = (first..=last).any(|index| self.tokens[index].contains('\n')); + let end_columns = (!commented && !multiline).then(|| { + let mut column = indentation; + let mut ends: Vec = Vec::with_capacity(last - first + 1); + for index in first..=last { + if index > first { + column += self.gaps[index].chars().count(); + } + column += self.width(index); + ends.push(column); + } + ends + }); + Line { + first, + last, + indentation, + end_columns, + } + } + + /// Indentation of the line that starts at token `first`. + fn indentation(&self, first: usize) -> usize { + self.gaps[first] + .rsplit('\n') + .next() + .unwrap_or_default() + .chars() + .count() + } + + fn width(&self, index: usize) -> usize { + let token = &self.tokens[index]; + self.token_widths + .get(&token.to_ascii_uppercase()) + .copied() + .unwrap_or_else(|| token.chars().count()) + } + + /// Whether any gap of the tokens after `first` up to `last` holds a line break. + fn spans_lines(&self, first: usize, last: usize) -> bool { + (first + 1..=last).any(|index| self.gaps[index].contains('\n')) + } + + pub(crate) fn render(&self) -> String { + let mut output = String::new(); + for (gap, token) in self.gaps.iter().zip(&self.tokens) { + output.push_str(gap); + output.push_str(token); + } + output.push_str(&self.gaps[self.tokens.len()]); + output + } +} + +fn bracket_structure(tokens: &[String]) -> (Vec, Vec>) { + let mut depths: Vec = Vec::with_capacity(tokens.len()); + let mut partners: Vec> = vec![None; tokens.len()]; + let mut open: Vec = Vec::new(); + for (index, token) in tokens.iter().enumerate() { + match token.as_str() { + "(" | "[" | "{" => { + depths.push(open.len()); + open.push(index); + } + ")" | "]" | "}" => { + let opener = open.pop(); + depths.push(open.len()); + if let Some(opener) = opener { + partners[opener] = Some(index); + partners[index] = Some(opener); + } + } + _ => depths.push(open.len()), + } + } + (depths, partners) +} + +fn newline(indentation: usize) -> String { + format!("\n{}", " ".repeat(indentation)) +} + +impl PrintedSql<'_> { + /// Re-indent every line from the parse-tree indent step to `indent` spaces per step. + pub(crate) fn set_indent(&mut self, indent: usize) { + if indent == self.indent { + return; + } + let step = self.indent; + let scale = |spaces: usize| spaces / step * indent + spaces % step; + for gap in &mut self.gaps { + if !gap.contains('\n') { + continue; + } + let comment_start = if gap.contains("/*") { + gap.rfind('\n').unwrap_or(0) + } else { + 0 + }; + let (kept, scaled) = gap.split_at(comment_start); + let mut rebuilt = kept.to_string(); + for (position, segment) in scaled.split('\n').enumerate() { + if position > 0 { + rebuilt.push('\n'); + let spaces = segment.len() - segment.trim_start_matches(' ').len(); + rebuilt.push_str(&" ".repeat(scale(spaces))); + rebuilt.push_str(&segment[spaces..]); + } else { + rebuilt.push_str(segment); + } + } + *gap = rebuilt; + } + self.indent = indent; + } + + /// Rejoin each multi-line expression group, innermost first, whose joined line fits. + pub(crate) fn join_fitting_groups(&mut self, line_width: usize) { + let mut openers: Vec = (0..self.len()) + .filter(|&index| self.tokens[index] == OPEN_PARENTHESIS) + .collect(); + openers.sort_by_key(|&index| std::cmp::Reverse(self.depths[index])); + for opener in openers { + let Some(closer) = self.partners[opener] else { + continue; + }; + if !self.spans_lines(opener, closer) || !self.joinable_group(opener, closer) { + continue; + } + let (first, _) = self.line_of(opener); + let (_, last) = self.line_of(closer); + let indentation = self.indentation(first); + let fits = |start: usize| { + self.joined_width(start, last, indentation) + .is_some_and(|width| width <= line_width) + }; + let start = if fits(first) { + first + } else if let Some(operator) = self.leading_operator_before(first, opener) + && fits(operator) + { + operator + } else { + continue; + }; + for index in opener + 1..=closer { + if self.gaps[index].contains('\n') { + self.gaps[index] = self.joined_gap(index).to_string(); + } + } + if start != first { + self.gaps[start] = newline(indentation); + } + } + } + + /// The last binary operator on the line from `first` that applies to the group at `opener`. + fn leading_operator_before(&self, first: usize, opener: usize) -> Option { + (first + 1..opener).rev().find(|&index| { + self.depths[index] == self.depths[opener] + && LEADING_OPERATORS.contains(&self.upper(index).as_str()) + && self.gaps[index] == SINGLE_SPACE + && self.ends_operand(index - 1) + }) + } + + /// Move a comma that follows a line comment to the end of the commented line. + pub(crate) fn attach_commas_before_comments(&mut self) { + for index in 1..self.len().saturating_sub(1) { + if self.tokens[index] != COMMA { + continue; + } + let Some((comment, rest)) = self.gaps[index].split_once('\n') else { + continue; + }; + if !comment.trim_start().starts_with("--") || !rest.trim().is_empty() { + continue; + } + let (comment, rest) = (comment.to_string(), rest.to_string()); + let next = self.gaps[index + 1].trim_start_matches(' ').to_string(); + self.gaps[index] = String::new(); + self.gaps[index + 1] = if next.starts_with('\n') { + comment + &next + } else { + format!("{comment}\n{rest}{next}") + }; + } + } + + fn joinable_group(&self, opener: usize, closer: usize) -> bool { + (opener + 1..closer).all(|index| { + !(self.kinds[index] == TokenType::Select + || self.is(index, VALUES_KEYWORD) + || (self.is(index, CASE_KEYWORD) + && self + .case_end(index) + .is_none_or(|end| self.spans_lines(index, end)))) + }) + } + + /// The width of tokens `first..=last` on one line at `indentation`; `None` when comments pin. + fn joined_width(&self, first: usize, last: usize, indentation: usize) -> Option { + let trailing = self.gaps[last + 1].split('\n').next().unwrap_or_default(); + let pinned = !trailing.trim().is_empty() + || (first + 1..=last).any(|index| !self.gaps[index].trim().is_empty()) + || (first..=last).any(|index| self.tokens[index].contains('\n')); + if pinned { + return None; + } + let mut width = indentation; + for index in first..=last { + if index > first { + width += if self.gaps[index].contains('\n') { + self.joined_gap(index).len() + } else { + self.gaps[index].chars().count() + }; + } + width += self.width(index); + } + Some(width) + } + + /// The gap before `index` once its line break is removed. + fn joined_gap(&self, index: usize) -> &'static str { + if self.tokens[index - 1] == OPEN_PARENTHESIS + || matches!(self.tokens[index].as_str(), ")" | ",") + { + "" + } else { + SINGLE_SPACE + } + } + + /// Apply the layout rules that only move existing line breaks before wrapping. + pub(crate) fn arrange_clauses(&mut self, line_width: usize) { + for index in 0..self.len() { + if self.is(index, DECODE_FUNCTION) { + self.pair_decode_arguments(index); + } + } + for index in 1..self.len() { + if CHAIN_KEYWORDS.contains(&self.upper(index).as_str()) { + self.join_chain_break_in_group(index); + } + } + for index in 0..self.len() { + if self.is(index, VALUES_KEYWORD) { + self.break_values_rows_unless_fitting(index, line_width); + } + } + for index in 0..self.len() { + if self.kinds[index] == TokenType::With { + self.separate_common_table_expressions(index); + } + } + for index in 0..self.len() { + if SINGLE_ITEM_CLAUSES.contains(&self.upper(index).as_str()) { + self.join_single_line_body(index, line_width); + } + } + } + + /// Start the next line with any binary operator whose operand spans several lines. + pub(crate) fn lead_operators_of_multiline_operands(&mut self) { + for index in 1..self.len().saturating_sub(1) { + if !LEADING_OPERATORS.contains(&self.upper(index).as_str()) + || self.gaps[index] != SINGLE_SPACE + || self.gaps[index + 1].contains('\n') + || !self.ends_operand(index - 1) + || (self.is(index, "AND") && self.follows_between(index)) + { + continue; + } + let multiline = self + .left_operand_start(index - 1) + .is_some_and(|start| self.spans_lines(start, index - 1)) + || self + .right_operand_end(index + 1) + .is_some_and(|end| self.spans_lines(index + 1, end)); + if multiline { + let (first, _) = self.line_of(index); + self.gaps[index] = newline(self.indentation(first)); + } + } + } + + /// Put the first DECODE argument on its own line and each search/result pair on one line. + fn pair_decode_arguments(&mut self, name: usize) { + let opener = name + 1; + if !self.is(opener, OPEN_PARENTHESIS) || (name > 0 && self.is(name - 1, ".")) { + return; + } + let Some(closer) = self.partners[opener] else { + return; + }; + if !self.gaps[opener + 1].contains('\n') { + return; + } + let mut argument = 0_usize; + for index in opener + 2..closer { + if self.depths[index] != self.depths[opener] + 1 || self.tokens[index - 1] != COMMA { + continue; + } + argument += 1; + if argument.is_multiple_of(2) && self.gaps[index].trim().is_empty() { + self.gaps[index] = SINGLE_SPACE.to_string(); + } + } + } + + /// Join an AND or OR line break inside a bracket group whose arguments share its line. + fn join_chain_break_in_group(&mut self, index: usize) { + if !self.gaps[index].contains('\n') || !self.gaps[index].trim().is_empty() { + return; + } + let depth = self.depths[index]; + let Some(opener) = (0..index) + .rev() + .find(|&previous| self.depths[previous] < depth) + else { + return; + }; + if self.tokens[opener] == OPEN_PARENTHESIS && !self.spans_lines(opener, index - 1) { + self.gaps[index] = SINGLE_SPACE.to_string(); + } + } + + /// Start every row of a VALUES list on its own line unless the whole list fits on its line. + fn break_values_rows_unless_fitting(&mut self, values: usize, line_width: usize) { + let rows = self.values_rows(values); + let Some(&(_, last_closer)) = rows.last() else { + return; + }; + let (first, last) = self.line_of(values); + let fits = !self.spans_lines(values, last_closer) + && self + .line(first, last) + .end_columns + .is_some_and(|ends| ends.last().is_some_and(|end| *end <= line_width)); + if fits { + return; + } + let mut indentation = self.indentation(first); + if self.gaps[values].contains('\n') { + indentation = self.indentation(values); + } else if values > 0 + && self.tokens[values - 1] == OPEN_PARENTHESIS + && let Some(closer) = self.partners[values - 1] + { + self.set_break(values, &newline(indentation + self.indent)); + self.set_break(closer, &newline(indentation)); + indentation += self.indent; + } + let row_line = newline(indentation + self.indent); + for (opener, _) in rows { + self.set_break(opener, &row_line); + } + } + + /// Separate the CTEs of the WITH at `with`, and the statement after them, by one blank line. + fn separate_common_table_expressions(&mut self, with: usize) { + let (first, _) = self.line_of(with); + let indentation = self.indentation(first); + let mut item = with + 1; + if self.is(item, "RECURSIVE") { + item += 1; + } + while let Some(end) = self.common_table_expression_end(item) { + let next = end + 1; + if self.is(next, ",") { + self.blank_line_before(next + 1, indentation); + item = next + 1; + continue; + } + if next < self.len() && !self.is(next, ";") && !self.is(next, ")") { + self.blank_line_before(next, indentation); + } + break; + } + } + + /// The last token of the CTE, or CTE macro placeholder, that starts at `item`. + fn common_table_expression_end(&self, item: usize) -> Option { + let name = self.tokens.get(item)?; + if name.to_ascii_uppercase().starts_with(SENTINEL_PREFIX) + && !self.is(item + 1, "AS") + && !self.is(item + 1, OPEN_PARENTHESIS) + { + return Some(item); + } + if !matches!( + self.kinds[item], + TokenType::Var | TokenType::Identifier | TokenType::QuotedIdentifier + ) && !name.chars().all(|c| c.is_alphanumeric() || c == '_') + { + return None; + } + let mut index = item + 1; + if self.is(index, OPEN_PARENTHESIS) { + index = self.partners[index]? + 1; + } + if !self.is(index, "AS") { + return None; + } + index += 1; + if self.is(index, "NOT") { + index += 1; + } + if self.is(index, "MATERIALIZED") { + index += 1; + } + if !self.is(index, OPEN_PARENTHESIS) { + return None; + } + self.partners[index] + } + + /// Replace the line break before `index` with exactly one blank line, keeping comments. + fn blank_line_before(&mut self, index: usize, indentation: usize) { + let gap = &self.gaps[index]; + let (trailing, rest) = gap.split_once('\n').unwrap_or((gap.as_str(), "")); + let rest = rest.trim_start(); + let margin = " ".repeat(indentation); + self.gaps[index] = if rest.is_empty() { + format!("{}\n\n{margin}", trailing.trim_end()) + } else { + format!("{}\n\n{margin}{rest}", trailing.trim_end()) + }; + } + + /// Move a clause body that is one line under its clause keyword onto the keyword line. + fn join_single_line_body(&mut self, clause: usize, line_width: usize) { + let (first, last) = self.line_of(clause); + let body = last + 1; + let is_select = self.is(clause, "SELECT"); + let modifiers_only = (clause + 1..=last).all(|index| { + SELECT_MODIFIERS.contains(&self.upper(index).as_str()) + || self.kinds[index] == TokenType::Number + || matches!(self.tokens[index].as_str(), "(" | ")" | ",") + || self.depths[index] > self.depths[clause] + }); + if body >= self.len() + || clause != first + || (!is_select && clause != last) + || (is_select && !modifiers_only) + || !self.gaps[body].contains('\n') + || !self.gaps[body].trim().is_empty() + { + return; + } + let clause_indentation = self.indentation(first); + if self.indentation(body) <= clause_indentation { + return; + } + let (_, body_last) = self.line_of(body); + if body_last + 1 < self.len() && self.indentation(body_last + 1) > clause_indentation { + return; + } + let pinned = (body + 1..=body_last).any(|index| !self.gaps[index].trim().is_empty()) + || (first..=body_last).any(|index| self.tokens[index].contains('\n')); + if pinned || (!is_select && self.has_top_level_chain(body, body_last)) { + return; + } + let mut width = clause_indentation; + for index in first..=body_last { + if index > first { + width += if index == body { + 1 + } else { + self.gaps[index].chars().count() + }; + } + width += self.width(index); + } + width += self.gaps[body_last + 1] + .split('\n') + .next() + .unwrap_or_default() + .chars() + .count(); + if width <= line_width { + self.gaps[body] = SINGLE_SPACE.to_string(); + } + } + + fn has_top_level_chain(&self, first: usize, last: usize) -> bool { + let depth = (first..=last).map(|index| self.depths[index]).min(); + (first..=last).any(|index| { + Some(self.depths[index]) == depth + && CHAIN_KEYWORDS.contains(&self.upper(index).as_str()) + && !(self.is(index, "AND") && self.follows_between(index)) + }) + } + + /// Whether the AND at `index` separates the bounds of a BETWEEN. + fn follows_between(&self, index: usize) -> bool { + let depth = self.depths[index]; + for previous in (0..index).rev() { + if self.depths[previous] < depth { + return false; + } + if self.depths[previous] > depth { + continue; + } + let upper = self.upper(previous); + if upper == BETWEEN_KEYWORD { + return true; + } + if BETWEEN_BOUNDARIES.contains(&upper.as_str()) { + return false; + } + } + false + } + + fn ends_operand(&self, index: usize) -> bool { + matches!(self.tokens[index].as_str(), ")" | "]") + || self.is(index, "END") + || matches!( + self.kinds[index], + TokenType::Var + | TokenType::Identifier + | TokenType::QuotedIdentifier + | TokenType::Number + | TokenType::String + ) + } + + /// The first token of a bracket group or CASE expression that ends at `last`. + fn left_operand_start(&self, last: usize) -> Option { + if matches!(self.tokens[last].as_str(), ")" | "]") { + return self.partners[last]; + } + if !self.is(last, "END") { + return None; + } + let depth = self.depths[last]; + let mut nested = 0_usize; + for previous in (0..last).rev() { + if self.depths[previous] != depth { + continue; + } + if self.is(previous, "END") { + nested += 1; + } else if self.is(previous, "CASE") { + if nested == 0 { + return Some(previous); + } + nested -= 1; + } + } + None + } + + /// The last token of a bracket group, call, or CASE expression that starts at `first`. + fn right_operand_end(&self, first: usize) -> Option { + if self.tokens[first] == OPEN_PARENTHESIS { + return self.partners[first]; + } + let word = self.tokens[first] + .chars() + .all(|character| character.is_alphanumeric() || character == '_'); + if word && self.is(first + 1, OPEN_PARENTHESIS) { + return self.partners[first + 1]; + } + if !self.is(first, CASE_KEYWORD) { + return None; + } + self.case_end(first) + } +} + +/// Line width and the printed widths of tokens that stand in for longer authored text. +pub(crate) struct WrapOptions<'a> { + pub(crate) line_width: usize, + pub(crate) token_widths: &'a HashMap, +} + +/// Re-break every line of `printed` that is longer than the line width and can be shortened. +pub(crate) fn wrap_lines(printed: &mut PrintedSql<'_>, line_width: usize) { + printed.lock_unbreakable_groups(); + let mut pending: Vec<(usize, usize)> = printed.line_ranges(0, printed.len() - 1); + pending.reverse(); + while let Some((first, last)) = pending.pop() { + let line = printed.line(first, last); + if !overflows(&line, line_width) || !printed.break_line(&line) { + continue; + } + let mut lines = printed.line_ranges(first, last); + lines.reverse(); + pending.extend(lines); + } +} + +fn overflows(line: &Line, line_width: usize) -> bool { + line.end_columns + .as_ref() + .is_some_and(|ends| ends.last().is_some_and(|end| *end > line_width)) +} + +impl PrintedSql<'_> { + /// Lock the inside of VALUES rows and of type parameter lists such as `NUMBER(38, 2)`. + fn lock_unbreakable_groups(&mut self) { + for index in 0..self.len() { + if self.is(index, VALUES_KEYWORD) { + for (opener, closer) in self.values_rows(index) { + self.lock(opener, closer); + } + } else if let Some(closer) = self.type_parameters_closer(index) { + self.lock(index - 1, closer); + } + } + } + + fn lock(&mut self, first: usize, last: usize) { + for gap in first + 1..=last { + self.locked[gap] = true; + } + } + + /// The bracketed rows that directly follow the VALUES keyword at `values`. + fn values_rows(&self, values: usize) -> Vec<(usize, usize)> { + let mut rows: Vec<(usize, usize)> = Vec::new(); + let mut opener = values + 1; + while opener < self.len() && self.tokens[opener] == OPEN_PARENTHESIS { + let Some(closer) = self.partners[opener] else { + break; + }; + rows.push((opener, closer)); + if !(self.is(closer + 1, COMMA) && self.is(closer + 2, OPEN_PARENTHESIS)) { + break; + } + opener = closer + 2; + } + rows + } + + /// The closer of a type parameter list opened at `opener`, such as `(38, 2)` of `NUMBER`. + fn type_parameters_closer(&self, opener: usize) -> Option { + if opener < 1 || self.tokens[opener] != OPEN_PARENTHESIS { + return None; + } + let closer = self.partners[opener]?; + let name = &self.tokens[opener - 1]; + if closer == opener + 1 || !name.chars().all(|c| c.is_alphanumeric() || c == '_') { + return None; + } + let type_position = opener > 1 && (self.is(opener - 2, "::") || self.is(opener - 2, "AS")); + if !type_position && !TYPE_NAMES.contains(&name.to_ascii_uppercase().as_str()) { + return None; + } + let parameters = (opener + 1..closer).all(|index| { + let token = &self.tokens[index]; + token == COMMA + || is_digits(token) + || TYPE_PARAMETER_WORDS.contains(&token.to_ascii_uppercase().as_str()) + }); + parameters.then_some(closer) + } + + /// Apply the outermost applicable break: chains, VALUES rows, bracket groups, then commas. + fn break_line(&mut self, line: &Line) -> bool { + let values_row = self.tokens[line.first] == OPEN_PARENTHESIS && self.locked[line.first + 1]; + !values_row + && (self.break_before(line, &CHAIN_KEYWORDS, 0) + || self.break_before(line, &ARITHMETIC_OPERATORS, self.indent) + || self.break_values_rows(line) + || self.explode_group(line) + || self.break_after_commas(line)) + } + + /// Put each row of a VALUES list that starts this line on its own line. + fn break_values_rows(&mut self, line: &Line) -> bool { + let depth = self.line_depth(line); + let Some(values) = (line.first..=line.last).find(|&index| { + self.is(index, VALUES_KEYWORD) + && (self.depths[index] == depth || index == line.first) + && !self.locked[index + 1] + }) else { + return false; + }; + let rows = self.values_rows(values); + if rows.is_empty() + || rows + .iter() + .all(|(opener, _)| self.gaps[*opener].contains('\n')) + { + return false; + } + let row_line = newline(line.indentation + self.indent); + for (opener, _) in rows { + self.gaps[opener] = row_line.clone(); + } + true + } + + /// Put the arguments or CASE branches of the outermost, widest group on their own lines. + fn explode_group(&mut self, line: &Line) -> bool { + let Some(ends) = line.end_columns.as_ref() else { + return false; + }; + let start_column = |index: usize| { + if index == line.first { + line.indentation + } else { + ends[index - 1 - line.first] + } + }; + let line_end = ends[ends.len() - 1]; + let mut chosen: Option<(usize, usize, usize)> = None; + for opener in line.first..=line.last { + let Some(closer) = self.group_closer(opener, line) else { + continue; + }; + let end_column = if closer > line.last { + line_end + } else { + ends[closer - line.first] + }; + let width = end_column - start_column(opener); + let better = chosen.is_none_or(|(best, _, best_width)| { + self.depths[opener] < self.depths[best] + || (self.depths[opener] == self.depths[best] && width >= best_width) + }); + if better { + chosen = Some((opener, closer, width)); + } + } + let Some((opener, closer, _)) = chosen else { + return false; + }; + if closer > line.last { + self.indent_lines(opener + 1, closer - 1, self.indent); + } + let inner = newline(line.indentation + self.indent); + if self.is(opener, CASE_KEYWORD) { + self.explode_case(opener, closer, line.indentation); + return true; + } + self.set_break(opener + 1, &inner); + self.set_break(closer, &newline(line.indentation)); + if self.is(opener + 1, VALUES_KEYWORD) { + return true; + } + let window = opener > 0 && self.is(opener - 1, "OVER"); + let decode = opener > 0 && self.is(opener - 1, DECODE_FUNCTION); + let mut argument = 0_usize; + for index in opener + 2..closer { + if self.depths[index] != self.depths[opener] + 1 { + continue; + } + if window { + if WINDOW_CLAUSE_KEYWORDS.contains(&self.upper(index).as_str()) { + self.set_break(index, &inner); + } + } else if self.tokens[index - 1] == COMMA { + argument += 1; + if !decode || !argument.is_multiple_of(2) { + self.set_break(index, &inner); + } + } + } + true + } + + /// The closer of an explodable group opened at `opener` on `line`. + fn group_closer(&self, opener: usize, line: &Line) -> Option { + if self.is(opener, CASE_KEYWORD) { + let end = self.case_end(opener)?; + return (end <= line.last && end > opener + 2).then_some(end); + } + let closer = self.partners[opener]?; + let explodable = closer > opener + 2 + && matches!(self.tokens[opener].as_str(), "(" | "[" | "{") + && !self.locked[opener + 1] + && (closer <= line.last || opener < line.last); + explodable.then_some(closer) + } + + /// The END of the CASE expression at `case`. + fn case_end(&self, case: usize) -> Option { + let depth = self.depths[case]; + let mut nested = 0_usize; + for index in case + 1..self.len() { + if self.depths[index] < depth { + return None; + } + if self.depths[index] != depth { + continue; + } + if self.is(index, CASE_KEYWORD) { + nested += 1; + } else if self.is(index, "END") { + if nested == 0 { + return Some(index); + } + nested -= 1; + } + } + None + } + + /// Put each WHEN, THEN and ELSE of a one-line CASE on its own line and END under CASE. + fn explode_case(&mut self, case: usize, end: usize, indentation: usize) { + let inner = newline(indentation + self.indent); + let depth = self.depths[case]; + let mut nested = 0_usize; + for index in case + 1..end { + if self.depths[index] != depth { + continue; + } + if self.is(index, CASE_KEYWORD) { + nested += 1; + } else if self.is(index, "END") { + nested -= 1; + } else if nested == 0 && CASE_BRANCH_KEYWORDS.contains(&self.upper(index).as_str()) { + self.set_break(index, &inner); + } + } + self.set_break(end, &newline(indentation)); + } + + /// Replace a whitespace-only gap; comments keep their gaps. + fn set_break(&mut self, index: usize, gap: &str) { + if self.gaps[index].trim().is_empty() { + self.gaps[index] = gap.to_string(); + } + } + + /// Indent lines starting within `first..=last` by `extra`, except gaps with block comments. + fn indent_lines(&mut self, first: usize, last: usize, extra: usize) { + let margin = format!("\n{}", " ".repeat(extra)); + for index in first..=last { + if self.gaps[index].contains('\n') && !self.gaps[index].contains("/*") { + self.gaps[index] = self.gaps[index].replace('\n', &margin); + } + } + } + + /// Break before every operator of `operators` at the line's outermost depth. + fn break_before(&mut self, line: &Line, operators: &[&str], extra_indent: usize) -> bool { + let depth = self.line_depth(line); + let mut between = false; + let mut breaks: Vec = Vec::new(); + for index in line.first..=line.last { + if self.depths[index] != depth || self.locked[index] { + continue; + } + let upper = self.upper(index); + if upper == BETWEEN_KEYWORD { + between = true; + continue; + } + if upper == BETWEEN_SEPARATOR && between { + between = false; + continue; + } + if index > line.first + && operators.contains(&upper.as_str()) + && self.gaps[index] == SINGLE_SPACE + && ends_operand(&self.tokens[index - 1]) + { + breaks.push(index); + } + } + let indentation = newline(line.indentation + extra_indent); + for index in &breaks { + self.gaps[*index] = indentation.clone(); + } + !breaks.is_empty() + } + + /// Break after every comma at the outermost comma depth of the line. + fn break_after_commas(&mut self, line: &Line) -> bool { + let Some(depth) = (line.first..line.last) + .filter(|&index| self.tokens[index] == COMMA && !self.locked[index + 1]) + .map(|index| self.depths[index]) + .min() + else { + return false; + }; + let indentation = newline(line.indentation + self.indent); + let mut changed = false; + for index in line.first..line.last { + if self.depths[index] == depth && self.tokens[index] == COMMA && !self.locked[index + 1] + { + self.gaps[index + 1] = indentation.clone(); + changed = true; + } + } + changed + } + + fn line_depth(&self, line: &Line) -> usize { + (line.first..=line.last) + .map(|index| self.depths[index]) + .min() + .unwrap_or_default() + } +} + +fn is_digits(token: &str) -> bool { + token.chars().all(|character| character.is_ascii_digit()) +} + +fn ends_operand(token: &str) -> bool { + token.chars().last().is_some_and(|character| { + character.is_alphanumeric() || matches!(character, '_' | '\'' | '"' | '`' | ')' | ']') + }) +} diff --git a/crates/sqlclean/src/sql_lint/_helpers/terminal_shape.rs b/crates/sqlclean/src/sql_lint/_helpers/terminal_shape.rs new file mode 100644 index 0000000..73637fa --- /dev/null +++ b/crates/sqlclean/src/sql_lint/_helpers/terminal_shape.rs @@ -0,0 +1,303 @@ +use polyglot_sql::tokens::{Span, Token, TokenType}; + +const CEREMONIAL_SELECT_LITERAL: &str = "1"; + +pub(super) fn final_cte_name_spans(tokens: &[Token], depths: &[usize]) -> Vec { + let roots = root_with_indices(tokens, depths); + let mut names: Vec<(usize, bool)> = Vec::new(); + for (index, token) in tokens.iter().enumerate() { + if token.token_type != TokenType::With { + continue; + } + let depth = depths[index]; + let first = names.len(); + let mut next = index + 1; + if tokens + .get(next) + .is_some_and(|token| token.text.eq_ignore_ascii_case("recursive")) + { + next += 1; + } + loop { + let name = next; + let Some(open) = cte_body_open(tokens, depths, name) else { + break; + }; + let Some(close) = (open + 1..tokens.len()).find(|&position| { + depths[position] == depth + 1 && tokens[position].token_type == TokenType::RParen + }) else { + break; + }; + names.push((name, false)); + if tokens + .get(close + 1) + .is_none_or(|token| token.token_type != TokenType::Comma) + { + break; + } + next = close + 2; + } + if roots.contains(&index) + && names.len() > first + && let Some((_, terminal)) = names.last_mut() + { + *terminal = true; + } + } + names + .into_iter() + .filter_map(|(index, terminal)| { + let is_final = tokens[index].text.eq_ignore_ascii_case("final"); + (terminal != is_final).then_some(tokens[index].span) + }) + .collect() +} + +pub(super) fn root_with_indices(tokens: &[Token], depths: &[usize]) -> Vec { + let significant: Vec = (0..tokens.len()) + .filter(|&index| { + !matches!( + tokens[index].token_type, + TokenType::Space + | TokenType::Break + | TokenType::LineComment + | TokenType::BlockComment + ) + }) + .collect(); + significant + .split(|&index| tokens[index].token_type == TokenType::Semicolon) + .filter_map(|statement| { + root_query_tokens(tokens, depths, statement) + .first() + .copied() + }) + .filter(|&index| tokens[index].token_type == TokenType::With) + .collect() +} + +fn root_query_tokens<'a>( + tokens: &[Token], + depths: &[usize], + mut significant: &'a [usize], +) -> &'a [usize] { + while significant + .last() + .is_some_and(|&index| tokens[index].token_type == TokenType::Semicolon) + { + significant = &significant[..significant.len() - 1]; + } + while let Some((&first, rest)) = significant.split_first() { + if tokens[first].token_type != TokenType::LParen + || rest + .last() + .is_none_or(|&index| tokens[index].token_type != TokenType::RParen) + || rest.iter().any(|&index| depths[index] <= depths[first]) + { + break; + } + significant = &rest[..rest.len() - 1]; + } + significant +} + +fn cte_body_open(tokens: &[Token], depths: &[usize], name: usize) -> Option { + let mut position = name + 1; + if tokens.get(position)?.token_type == TokenType::LParen { + position = (position + 1..tokens.len()).find(|&index| { + depths[index] == depths[name] + 1 && tokens[index].token_type == TokenType::RParen + })? + 1; + } + if tokens.get(position)?.token_type != TokenType::As { + return None; + } + position += 1; + if tokens.get(position)?.token_type == TokenType::Not { + position += 1; + } + if tokens + .get(position)? + .text + .eq_ignore_ascii_case("materialized") + { + position += 1; + } + (tokens.get(position)?.token_type == TokenType::LParen).then_some(position) +} + +pub(super) fn collect_terminal_shape_facts( + tokens: &[Token], + depths: &[usize], + significant: &[usize], + allows_ceremonial_select: bool, +) -> (Vec, Vec) { + let mut cte_only_bodies: Vec = Vec::new(); + let mut terminal_selects: Vec = Vec::new(); + let significant = root_query_tokens(tokens, depths, significant); + let Some(&first) = significant.first() else { + return (cte_only_bodies, terminal_selects); + }; + let root_depth = depths[first]; + let has_top_level_with = significant.iter().any(|&index| { + depths[index] == root_depth && tokens[index].text.eq_ignore_ascii_case("with") + }); + let terminal_set_operator = significant.iter().find(|&&index| { + depths[index] == root_depth + && matches!( + tokens[index].token_type, + TokenType::Union | TokenType::Intersect | TokenType::Except + ) + }); + if let Some(&operator) = terminal_set_operator { + cte_only_bodies.push(tokens[operator].span); + if has_top_level_with { + terminal_selects.push(tokens[operator].span); + } + return (cte_only_bodies, terminal_selects); + } + let Some(root_select_position) = significant.iter().position(|&index| { + depths[index] == root_depth && tokens[index].token_type == TokenType::Select + }) else { + return (cte_only_bodies, terminal_selects); + }; + let root_select = significant[root_select_position]; + let tail: Vec = significant[root_select_position + 1..] + .iter() + .copied() + .take_while(|&index| tokens[index].token_type != TokenType::Semicolon) + .filter(|&index| depths[index] == root_depth) + .collect(); + let has_terminal_logic = tail.iter().any(|&index| { + matches!( + tokens[index].token_type, + TokenType::Join + | TokenType::Where + | TokenType::Group + | TokenType::Having + | TokenType::Qualify + | TokenType::Order + | TokenType::Limit + | TokenType::Offset + | TokenType::Union + | TokenType::Intersect + | TokenType::Except + ) + }); + if !has_top_level_with || has_terminal_logic { + cte_only_bodies.push(tokens[root_select].span); + } + if !has_top_level_with { + return (cte_only_bodies, terminal_selects); + } + + let final_cte = (0..root_select_position) + .filter(|&position| { + let index = significant[position]; + depths[index] == root_depth + && tokens[index].token_type == TokenType::As + && significant + .get(position + 1) + .is_some_and(|&next| tokens[next].token_type == TokenType::LParen) + }) + .filter_map(|position| cte_name_before_as(tokens, significant, position)) + .next_back(); + let from_position = tail + .iter() + .position(|&index| is_terminal_from(tokens, significant, index)); + let terminal_is_plain = from_position.is_some_and(|position| { + let projection = &tail[..position]; + let relation = tail.get(position + 1); + let remainder = &tail[position.saturating_add(2)..]; + !projection.is_empty() + && plain_terminal_projection(tokens, projection) + && relation.is_some_and(|&index| { + final_cte + .as_ref() + .is_some_and(|name| tokens[index].text.eq_ignore_ascii_case(name)) + }) + && remainder.is_empty() + }); + let terminal_is_ceremonial = allows_ceremonial_select + && tail.len() == 1 + && tokens[tail[0]].text == CEREMONIAL_SELECT_LITERAL; + if !terminal_is_plain && !terminal_is_ceremonial { + terminal_selects.push(tokens[root_select].span); + } + (cte_only_bodies, terminal_selects) +} + +fn cte_name_before_as( + tokens: &[Token], + significant: &[usize], + as_position: usize, +) -> Option { + let previous_position = as_position.checked_sub(1)?; + let previous = significant[previous_position]; + if is_lint_identifier(&tokens[previous]) || tokens[previous].text.eq_ignore_ascii_case("final") + { + return Some(tokens[previous].text.to_ascii_lowercase()); + } + if tokens[previous].token_type != TokenType::RParen { + return None; + } + + let mut balance = 1_usize; + for position in (0..previous_position).rev() { + let index = significant[position]; + match tokens[index].token_type { + TokenType::RParen => balance += 1, + TokenType::LParen => { + balance = balance.saturating_sub(1); + if balance == 0 { + let name = position.checked_sub(1).map(|value| significant[value])?; + return (is_lint_identifier(&tokens[name]) + || tokens[name].text.eq_ignore_ascii_case("final")) + .then(|| tokens[name].text.to_ascii_lowercase()); + } + } + _ => {} + } + } + None +} + +fn plain_terminal_projection(tokens: &[Token], projection: &[usize]) -> bool { + projection.iter().all(|&index| { + is_terminal_projection_identifier(&tokens[index]) + || matches!( + tokens[index].token_type, + TokenType::Star | TokenType::Dot | TokenType::Comma | TokenType::As + ) + }) +} + +fn is_terminal_projection_identifier(token: &Token) -> bool { + is_lint_identifier(token) + || matches!( + token.token_type, + TokenType::Comment + | TokenType::Date + | TokenType::Key + | TokenType::Sequence + | TokenType::Time + ) + || (token.token_type == TokenType::Percent && token.text.eq_ignore_ascii_case("percent")) +} + +fn is_lint_identifier(token: &Token) -> bool { + matches!( + token.token_type, + TokenType::Identifier | TokenType::QuotedIdentifier | TokenType::Var + ) +} + +fn is_terminal_from(tokens: &[Token], significant: &[usize], index: usize) -> bool { + if tokens[index].token_type != TokenType::From { + return false; + } + significant + .iter() + .position(|&candidate| candidate == index) + .and_then(|position| position.checked_sub(1)) + .is_none_or(|position| tokens[significant[position]].token_type != TokenType::Distinct) +} diff --git a/crates/sqlclean/src/sql_lint/_helpers/token_layout.rs b/crates/sqlclean/src/sql_lint/_helpers/token_layout.rs new file mode 100644 index 0000000..17ffd1d --- /dev/null +++ b/crates/sqlclean/src/sql_lint/_helpers/token_layout.rs @@ -0,0 +1,797 @@ +//! Print authored SQL tokens with whitespace taken from a parse-tree layout oracle. + +use std::collections::{BTreeMap, BTreeSet}; + +use polyglot_sql::Dialect; +use polyglot_sql::tokens::{Token, TokenType}; + +use crate::sql_lint::constants::{CLOSE_PARENTHESIS, OPEN_PARENTHESIS}; +use crate::sql_tokens::main::canonical_tokens::canonical_tokens; +use crate::sql_tokens::main::case_folding::foldable_tokens; +use crate::sql_tokens::main::is_unquoted_word::is_unquoted_word; +use crate::sql_tokens::main::name_positions::name_positions; +use crate::sql_tokens::main::token_texts::token_texts; + +const TOKEN_PRESERVATION_FAILURE: &str = + "native formatter would change authored SQL tokens, not only layout"; +const COMMENT_ATTACHMENT_FAILURE: &str = + "native formatter could not preserve comment token attachments"; +const STRING_BREAK_FAILURE: &str = + "native formatter would join adjacent string literals across a line break"; +const ALIGNMENT_FAILURE: &str = "native formatter could not align its layout with authored tokens"; +const RESYNC_WINDOW: usize = 48; +const RESYNC_CONFIRMATION: usize = 1; +const NO_SPACE_BEFORE: [&str; 7] = [",", ")", "]", ".", ";", "::", ":"]; +const NO_SPACE_AFTER: [&str; 5] = ["(", "[", ".", "::", ":"]; +const STRUCTURAL_PUNCTUATION: [&str; 6] = ["(", ")", "[", "]", ",", ";"]; + +#[derive(Debug, Clone)] +pub(crate) struct Comment { + pub(crate) start: usize, + pub(crate) end: usize, + pub(crate) text: String, + pub(crate) line: bool, + pub(crate) leading: bool, +} + +/// One authored or oracle token with its raw source text and alignment key. +struct Lexeme { + start: usize, + end: usize, + raw: String, + key: LexemeKey, + /// In the reserved-keyword and built-in fold set shared with the change fingerprint. + foldable: bool, + /// An unquoted authored word outside the name positions that keep reserved words exact. + recasable: bool, + /// An unquoted word the dialect tokenizer types as a keyword. + keyword: bool, +} + +/// How lexemes compare: words ignoring ASCII case, punctuation by type, the rest by raw text. +#[derive(Clone, Copy, PartialEq, Eq)] +enum LexemeKey { + Word, + Punctuation(TokenType), + Raw, +} + +impl Lexeme { + fn same_key(&self, other: &Lexeme) -> bool { + self.key == other.key + && match self.key { + LexemeKey::Word => self.raw.eq_ignore_ascii_case(&other.raw), + LexemeKey::Punctuation(_) => true, + LexemeKey::Raw => self.raw == other.raw, + } + } +} + +/// The source text and case-fold flag of every token, read once for the token check. +pub(crate) struct TokenTexts { + texts: Vec, + foldable: Vec, +} + +impl TokenTexts { + #[cfg(test)] + pub(crate) fn read(sql: &str, tokens: &[Token], dialect: &Dialect) -> Result { + let texts = token_texts(sql, tokens)?; + let foldable = foldable_tokens(&texts, tokens, dialect); + Ok(Self { texts, foldable }) + } + + /// Whether token `index` here, upper-cased when foldable, spells `canonical`. + fn same_canonical(&self, index: usize, canonical: &str) -> bool { + let (text, fold) = (&self.texts[index], self.foldable[index]); + text.len() == canonical.len() + && text.bytes().zip(canonical.bytes()).all(|(byte, expected)| { + expected + == if fold { + byte.to_ascii_uppercase() + } else { + byte + } + }) + } +} + +/// An authored token the printer recased to the layout guide's keyword spelling. +pub(crate) struct KeywordRecase { + pub(crate) generated: String, + pub(crate) keyword: bool, +} + +/// Comments attached to the authored token they precede or follow. +#[derive(Default)] +struct Attached<'a> { + leading: Vec<&'a Comment>, + trailing: Vec<&'a Comment>, +} + +pub(crate) fn comments_in(sql: &str, tokens: &[Token]) -> Vec { + let characters: Vec = sql.chars().collect(); + let mut comments: Vec = Vec::new(); + let mut gap_start = 0_usize; + for token in tokens { + comments.extend(comments_in_gap(&characters, gap_start, token.span.start)); + gap_start = token.span.end; + } + comments.extend(comments_in_gap(&characters, gap_start, characters.len())); + comments +} + +/// Split the non-whitespace text the tokenizer skipped between two tokens into comments. +fn comments_in_gap(characters: &[char], start: usize, end: usize) -> Vec { + let mut comments: Vec = Vec::new(); + let mut index = start; + while index < end { + if characters[index].is_whitespace() { + index += 1; + continue; + } + let start = index; + let line_start = (0..start) + .rev() + .find(|&position| characters[position] == '\n') + .map_or(0, |position| position + 1); + let leading = characters[line_start..start] + .iter() + .all(|character| character.is_whitespace()); + let block = characters[index] == '/' && characters.get(index + 1) == Some(&'*'); + index = if block { + block_comment_end(characters, start, end) + } else { + (start..end) + .find(|&position| characters[position] == '\n') + .unwrap_or(end) + }; + comments.push(Comment { + start, + end: index, + text: characters[start..index].iter().collect(), + line: !block, + leading, + }); + } + comments +} + +/// End of a block comment: nested comments when they close inside the gap, else the first `*/`. +fn block_comment_end(characters: &[char], start: usize, end: usize) -> usize { + let closes_at = |position: usize| { + position + 1 < end && characters[position] == '*' && characters[position + 1] == '/' + }; + let opens_at = |position: usize| { + position + 1 < end && characters[position] == '/' && characters[position + 1] == '*' + }; + let mut depth = 0_usize; + let mut position = start; + while position < end { + if opens_at(position) { + depth += 1; + position += 2; + } else if closes_at(position) { + depth -= 1; + position += 2; + if depth == 0 { + return position; + } + } else { + position += 1; + } + } + (start + 2..end) + .find(|&position| closes_at(position)) + .map_or(end, |position| position + 2) +} + +pub(crate) fn neutralize_comments(sql: &str, comments: &[Comment]) -> String { + let mut characters: Vec = sql.chars().collect(); + for comment in comments { + for character in &mut characters[comment.start..comment.end] { + if *character != '\n' && *character != '\r' { + *character = ' '; + } + } + } + characters.into_iter().collect() +} + +/// Authored SQL text with its dialect tokens and the comments between them. +pub(crate) struct AuthoredSql<'a> { + pub(crate) sql: &'a str, + pub(crate) tokens: &'a [Token], + pub(crate) comments: &'a [Comment], +} + +/// The lexemes and characters of a layout oracle, read once for every print against it. +pub(crate) struct OracleLayout { + generated: Vec, + characters: Vec, +} + +/// Tokenize `oracle` for printing against it. +pub(crate) fn oracle_layout(oracle: &str, dialect: &Dialect) -> Result { + let oracle_tokens = dialect + .tokenize(oracle) + .map_err(|error| error.to_string())?; + Ok(OracleLayout { + generated: lexemes(oracle, &oracle_tokens, None)?, + characters: oracle.chars().collect(), + }) +} + +/// Print every authored token in order, taking only whitespace and keyword case from `oracle`. +pub(crate) fn print_authored_tokens( + source: &AuthoredSql<'_>, + oracle: &OracleLayout, + dialect: &Dialect, +) -> Result<(String, BTreeMap, TokenTexts), String> { + let AuthoredSql { + sql, + tokens, + comments, + } = *source; + let authored = lexemes(sql, tokens, Some(dialect))?; + let generated = &oracle.generated; + let alignment = align(&authored, generated)?; + let separators = separators(&authored, generated, &alignment, &oracle.characters)?; + let attached = attach_comments(tokens, comments); + let mut output = String::with_capacity(sql.len() + sql.len() / 4); + let string_breaks = string_breaks(sql, tokens); + let mut recases: BTreeMap = BTreeMap::new(); + for (index, lexeme) in authored.iter().enumerate() { + let forced_break = string_breaks.contains(&index) && !separators[index].contains('\n'); + let forced: String; + let separator: &str = if forced_break { + forced = format!("\n{}", current_indentation(&output)); + &forced + } else { + &separators[index] + }; + let leading = &attached[index].leading; + if leading.is_empty() { + output.push_str(separator); + } else if index == 0 || separator.contains('\n') { + output.push_str(separator); + let indentation = current_indentation(&output); + for comment in leading { + output.push_str(&comment.text); + output.push('\n'); + output.push_str(&indentation); + } + } else { + let indentation = current_indentation(&output); + for comment in leading { + output.push('\n'); + output.push_str(&indentation); + output.push_str(&comment.text); + } + output.push('\n'); + output.push_str(&indentation); + } + let guide = alignment[index].map(|at| &generated[at]); + if let Some(guide) = guide.filter(|guide| confirms_keyword(lexeme, guide)) { + recases.insert( + index, + KeywordRecase { + generated: guide.raw.clone(), + keyword: guide.keyword, + }, + ); + } + output.push_str(&printed_text(lexeme, guide)); + let trailing = &attached[index].trailing; + for (position, comment) in trailing.iter().enumerate() { + output.push(' '); + output.push_str(&comment.text); + if !comment.line { + continue; + } + let next_starts_line = if position + 1 < trailing.len() { + false + } else { + separators + .get(index + 1) + .is_none_or(|next| next.starts_with('\n')) + }; + if !next_starts_line { + output.push('\n'); + if position + 1 < trailing.len() { + output.push_str(¤t_indentation(&output)); + } + } + } + } + let mut texts = TokenTexts { + texts: Vec::with_capacity(authored.len()), + foldable: Vec::with_capacity(authored.len()), + }; + for lexeme in authored { + texts.texts.push(lexeme.raw); + texts.foldable.push(lexeme.foldable); + } + Ok((output, recases, texts)) +} + +/// Refuse output that changes anything but layout, fold-set case, and recorded keyword recases. +pub(crate) fn verify_printed_tokens( + (before, before_tokens, before_texts): (&str, &[Token], &TokenTexts), + (after, after_tokens): (&str, &[Token]), + dialect: &Dialect, + recases: &BTreeMap, +) -> Result<(), String> { + let after_canonical = canonical_tokens(after, after_tokens, dialect)?; + if before_texts.texts.len() != after_canonical.len() { + return Err(TOKEN_PRESERVATION_FAILURE.to_string()); + } + let mut after_texts: Option> = None; + for (index, canonical) in after_canonical.iter().enumerate() { + if before_texts.same_canonical(index, &canonical.text) { + continue; + } + let Some(recase) = recases.get(&index) else { + return Err(TOKEN_PRESERVATION_FAILURE.to_string()); + }; + let texts: &Vec = match after_texts { + Some(ref texts) => texts, + None => after_texts.insert(token_texts(after, after_tokens)?), + }; + let printed: &str = &texts[index]; + if !recase.allows(&before_texts.texts[index], printed) { + return Err(TOKEN_PRESERVATION_FAILURE.to_string()); + } + } + let attached = comments_stay_attached( + &comment_positions(before, before_tokens), + &comment_positions(after, after_tokens), + after_tokens, + ); + if !attached { + return Err(COMMENT_ATTACHMENT_FAILURE.to_string()); + } + if !string_breaks(before, before_tokens).is_subset(&string_breaks(after, after_tokens)) { + return Err(STRING_BREAK_FAILURE.to_string()); + } + Ok(()) +} + +impl KeywordRecase { + /// Whether `authored` became `printed` only by taking the guide's upper-case keyword spelling. + fn allows(&self, authored: &str, printed: &str) -> bool { + self.keyword + && printed == self.generated + && printed == printed.to_ascii_uppercase() + && authored.eq_ignore_ascii_case(printed) + } +} + +pub(crate) fn tokenize(sql: &str, dialect: &Dialect) -> Result, String> { + dialect.tokenize(sql).map_err(|error| error.to_string()) +} + +/// Whether every comment keeps its place, allowing a line comment to move just past a comma. +fn comments_stay_attached( + before: &[(String, usize)], + after: &[(String, usize)], + after_tokens: &[Token], +) -> bool { + before.len() == after.len() + && before.iter().zip(after).all( + |((before_text, before_position), (after_text, after_position))| { + before_text == after_text + && (before_position == after_position + || (*after_position == before_position + 1 + && before_text.starts_with("--") + && after_tokens + .get(*before_position) + .is_some_and(|token| token.token_type == TokenType::Comma))) + }, + ) +} + +fn comment_positions(sql: &str, tokens: &[Token]) -> Vec<(String, usize)> { + let mut positions: Vec<(String, usize)> = Vec::new(); + for comment in comments_in(sql, tokens) { + let preceding = tokens.partition_point(|token| token.span.end <= comment.start); + positions.push((comment.text, preceding)); + } + positions +} + +/// Lexemes of `sql`; only authored lexemes carry the recase classification. +fn lexemes(sql: &str, tokens: &[Token], dialect: Option<&Dialect>) -> Result, String> { + let raws = token_texts(sql, tokens)?; + let (foldable, recasable) = match dialect { + Some(dialect) => { + let uppers: Vec = raws.iter().map(|raw| raw.to_ascii_uppercase()).collect(); + let names = name_positions(&uppers); + let foldable = foldable_tokens(&raws, tokens, dialect); + let recasable: Vec = raws + .iter() + .zip(names) + .map(|(raw, name)| !name && is_unquoted_word(raw)) + .collect(); + (foldable, recasable) + } + None => (vec![false; tokens.len()], vec![false; tokens.len()]), + }; + let mut lexemes = Vec::with_capacity(tokens.len()); + for (index, (raw, token)) in raws.into_iter().zip(tokens).enumerate() { + lexemes.push(lexeme(raw, token, foldable[index], recasable[index])); + } + Ok(lexemes) +} + +fn lexeme(raw: String, token: &Token, foldable: bool, recasable: bool) -> Lexeme { + let word = is_unquoted_word(&raw); + let key = if word { + LexemeKey::Word + } else if raw + .chars() + .all(|character| character.is_ascii_punctuation()) + { + LexemeKey::Punctuation(token.token_type) + } else { + LexemeKey::Raw + }; + let keyword = word + && !matches!( + token.token_type, + TokenType::Var | TokenType::Identifier | TokenType::QuotedIdentifier + ); + Lexeme { + start: token.span.start, + end: token.span.end, + raw, + key, + foldable, + recasable, + keyword, + } +} + +fn is_string_literal(token: &Token) -> bool { + matches!( + token.token_type, + TokenType::String + | TokenType::NationalString + | TokenType::EscapeString + | TokenType::RawString + | TokenType::ByteString + | TokenType::UnicodeString + | TokenType::HexString + | TokenType::BitString + | TokenType::DollarString + | TokenType::TripleSingleQuotedString + | TokenType::TripleDoubleQuotedString + ) +} + +/// Indices of string literals that follow another string literal across a line break. +fn string_breaks(sql: &str, tokens: &[Token]) -> BTreeSet { + let characters: Vec = sql.chars().collect(); + (1..tokens.len()) + .filter(|&index| { + is_string_literal(&tokens[index - 1]) + && is_string_literal(&tokens[index]) + && characters[tokens[index - 1].span.end..tokens[index].span.start].contains(&'\n') + }) + .collect() +} + +/// Map authored tokens to matching oracle tokens; a closer only matches its opener's partner. +fn align(authored: &[Lexeme], generated: &[Lexeme]) -> Result>, String> { + let mut state = Alignment { + authored, + generated, + authored_partners: bracket_partners(authored), + generated_partners: bracket_partners(generated), + forward: vec![None; authored.len()], + backward: vec![None; generated.len()], + }; + let (mut left, mut right) = (0_usize, 0_usize); + while left < authored.len() && right < generated.len() { + if state.matches(left, right) { + state.forward[left] = Some(right); + state.backward[right] = Some(left); + left += 1; + right += 1; + continue; + } + let (skip_left, skip_right) = state + .resync(left, right) + .ok_or_else(|| ALIGNMENT_FAILURE.to_string())?; + left += skip_left; + right += skip_right; + } + Ok(state.forward) +} + +struct Alignment<'a> { + authored: &'a [Lexeme], + generated: &'a [Lexeme], + authored_partners: Vec>, + generated_partners: Vec>, + forward: Vec>, + backward: Vec>, +} + +impl Alignment<'_> { + fn matches(&self, left: usize, right: usize) -> bool { + if !self.authored[left].same_key(&self.generated[right]) { + return false; + } + if !is_closer(&self.authored[left].raw) { + return true; + } + match (self.authored_partners[left], self.generated_partners[right]) { + (Some(opener), Some(generated_opener)) => match self.forward[opener] { + Some(aligned) => aligned == generated_opener, + None => self.backward[generated_opener].is_none(), + }, + (None, None) => true, + _ => false, + } + } + + fn resync(&self, left: usize, right: usize) -> Option<(usize, usize)> { + let remaining_left = self.authored.len() - left; + let remaining_right = self.generated.len() - right; + for cost in 1..=(2 * RESYNC_WINDOW) { + for skip_left in 0..=cost.min(RESYNC_WINDOW) { + let skip_right = cost - skip_left; + if skip_right > RESYNC_WINDOW { + continue; + } + if skip_left >= remaining_left || skip_right >= remaining_right { + if skip_left >= remaining_left && skip_right >= remaining_right { + return Some((remaining_left, remaining_right)); + } + continue; + } + if !self.matches(left + skip_left, right + skip_right) { + continue; + } + let anchor = &self.authored[left + skip_left]; + let confirmations = if matches!(anchor.key, LexemeKey::Punctuation(_)) { + RESYNC_CONFIRMATION + } else { + 0 + }; + let confirmed = (1..=confirmations).all(|offset| { + match ( + self.authored.get(left + skip_left + offset), + self.generated.get(right + skip_right + offset), + ) { + (Some(before), Some(after)) => before.same_key(after), + _ => true, + } + }); + if confirmed { + return Some((skip_left, skip_right)); + } + } + } + None + } +} + +fn is_closer(raw: &str) -> bool { + matches!(raw, ")" | "]" | "}") +} + +fn bracket_partners(lexemes: &[Lexeme]) -> Vec> { + let mut partners: Vec> = vec![None; lexemes.len()]; + let mut open: Vec = Vec::new(); + for (index, lexeme) in lexemes.iter().enumerate() { + match lexeme.raw.as_str() { + "(" | "[" | "{" => open.push(index), + ")" | "]" | "}" => { + if let Some(opener) = open.pop() { + partners[opener] = Some(index); + partners[index] = Some(opener); + } + } + _ => {} + } + } + partners +} + +fn separators( + authored: &[Lexeme], + generated: &[Lexeme], + alignment: &[Option], + oracle: &[char], +) -> Result, String> { + let mut separators: Vec = vec![String::new(); authored.len()]; + let anchors: Vec = (0..authored.len()) + .filter(|&index| alignment[index].is_some()) + .collect(); + if anchors.is_empty() { + return Err(ALIGNMENT_FAILURE.to_string()); + } + let gap = |left: usize, right: usize| -> String { + oracle[generated[left].end..generated[right].start] + .iter() + .collect() + }; + for index in 1..anchors[0] { + separators[index] = default_space(&authored[index - 1], &authored[index]).to_string(); + } + if anchors[0] > 0 { + separators[anchors[0]] = + default_space(&authored[anchors[0] - 1], &authored[anchors[0]]).to_string(); + } + for pair in anchors.windows(2) { + let (previous, next) = (pair[0], pair[1]); + let (previous_at, next_at) = ( + alignment[previous].ok_or(ALIGNMENT_FAILURE)?, + alignment[next].ok_or(ALIGNMENT_FAILURE)?, + ); + let merged = merged_gap( + &(previous_at..next_at) + .map(|at| gap(at, at + 1)) + .collect::>(), + ); + if previous + 1 == next { + separators[next] = merged; + continue; + } + for index in previous + 2..next { + separators[index] = default_space(&authored[index - 1], &authored[index]).to_string(); + } + let first = previous + 1; + let last = next - 1; + let substitution = next_at > previous_at + 1; + separators[first] = if default_space(&authored[previous], &authored[first]).is_empty() { + String::new() + } else if substitution { + gap(previous_at, previous_at + 1) + } else { + " ".to_string() + }; + separators[next] = if substitution { + let oracle_gap = (previous_at + 1..next_at) + .rev() + .find(|&at| generated[at].same_key(&authored[last])) + .map_or_else(|| gap(next_at - 1, next_at), |at| gap(at, at + 1)); + let unspaced = default_space(&authored[last], &authored[next]).is_empty(); + if unspaced && !oracle_gap.contains('\n') { + String::new() + } else { + oracle_gap + } + } else if merged.contains('\n') { + merged + } else { + default_space(&authored[last], &authored[next]).to_string() + }; + } + let last_anchor = anchors[anchors.len() - 1]; + for index in last_anchor + 1..authored.len() { + separators[index] = default_space(&authored[index - 1], &authored[index]).to_string(); + } + let last_at = alignment[last_anchor].ok_or(ALIGNMENT_FAILURE)?; + if last_anchor + 1 < authored.len() + && last_at + 1 < generated.len() + && !separators[last_anchor + 1].is_empty() + { + separators[last_anchor + 1] = gap(last_at, last_at + 1); + } + for index in 1..authored.len() { + let adjacent_in_source = authored[index - 1].end == authored[index].start; + let oracle_pair = matches!( + (alignment[index - 1], alignment[index]), + (Some(previous), Some(next)) if previous + 1 == next + ); + let structural = [&authored[index - 1], &authored[index]] + .iter() + .any(|lexeme| STRUCTURAL_PUNCTUATION.contains(&lexeme.raw.as_str())); + let call = + is_unquoted_word(&authored[index - 1].raw) && authored[index].raw == OPEN_PARENTHESIS; + let inside_parentheses = + authored[index - 1].raw == OPEN_PARENTHESIS || authored[index].raw == CLOSE_PARENTHESIS; + if (adjacent_in_source && !oracle_pair && (!structural || call)) + || (inside_parentheses && !separators[index].contains('\n')) + { + separators[index].clear(); + } else if separators[index].is_empty() + && !adjacent_in_source + && would_fuse(&authored[index - 1], &authored[index]) + { + separators[index] = " ".to_string(); + } + } + Ok(separators) +} + +fn merged_gap(gaps: &[String]) -> String { + if let [only] = gaps { + return only.clone(); + } + if let Some(newline) = gaps.iter().rev().find(|gap| gap.contains('\n')) { + return newline.clone(); + } + if gaps.iter().any(|gap| !gap.is_empty()) { + " ".to_string() + } else { + String::new() + } +} + +fn default_space(previous: &Lexeme, next: &Lexeme) -> &'static str { + if NO_SPACE_BEFORE.contains(&next.raw.as_str()) + || NO_SPACE_AFTER.contains(&previous.raw.as_str()) + { + "" + } else { + " " + } +} + +/// Return whether printing two separated tokens without a space could lex them differently. +fn would_fuse(previous: &Lexeme, next: &Lexeme) -> bool { + let (Some(left), Some(right)) = (previous.raw.chars().last(), next.raw.chars().next()) else { + return false; + }; + let word = |character: char| { + character.is_alphanumeric() || matches!(character, '_' | '$' | '\'' | '"' | '`' | '@') + }; + let operator = |character: char| "+-*/<>=!|&^%~:#?".contains(character); + (word(left) && word(right)) || (operator(left) && operator(right)) +} + +/// The authored text, recased only for keywords and built-in function names. +fn printed_text(authored: &Lexeme, generated: Option<&Lexeme>) -> String { + if let Some(generated) = generated.filter(|generated| confirms_keyword(authored, generated)) { + return generated.raw.clone(); + } + if !authored.foldable { + return authored.raw.clone(); + } + match generated { + Some(generated) if generated.raw.eq_ignore_ascii_case(&authored.raw) => { + generated.raw.clone() + } + Some(_) => authored.raw.clone(), + None => authored.raw.to_ascii_uppercase(), + } +} + +/// Whether the layout guide prints the aligned authored word as an upper-case keyword. +fn confirms_keyword(authored: &Lexeme, generated: &Lexeme) -> bool { + !authored.foldable + && authored.recasable + && generated.keyword + && generated.raw != authored.raw + && generated.raw == generated.raw.to_ascii_uppercase() + && generated.raw.eq_ignore_ascii_case(&authored.raw) +} + +fn attach_comments<'a>(tokens: &[Token], comments: &'a [Comment]) -> Vec> { + let mut attached: Vec> = (0..tokens.len()).map(|_| Attached::default()).collect(); + for comment in comments { + let next = tokens.partition_point(|token| token.span.start < comment.end); + let preceding = tokens.partition_point(|token| token.span.end <= comment.start); + let next = (next < tokens.len()).then_some(next); + let previous = preceding.checked_sub(1); + match (comment.leading, next, previous) { + (true, Some(next), _) | (false, Some(next), None) => { + attached[next].leading.push(comment); + } + (_, _, Some(previous)) => attached[previous].trailing.push(comment), + (_, None, None) => {} + } + } + attached +} + +fn current_indentation(output: &str) -> String { + let line = output.rsplit('\n').next().unwrap_or_default(); + line.chars() + .take_while(|character| *character == ' ' || *character == '\t') + .collect() +} diff --git a/crates/sqlclean/src/sql_lint/constants.rs b/crates/sqlclean/src/sql_lint/constants.rs new file mode 100644 index 0000000..4b329c0 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/constants.rs @@ -0,0 +1,61 @@ +pub(crate) const CAST_TYPE_SEPARATOR_KEYWORD: &str = "AS"; +pub(crate) const CLOSE_PARENTHESIS: &str = ")"; +pub const LINT_API_VERSION: u32 = 1; +pub(crate) const OPEN_PARENTHESIS: &str = "("; +pub(crate) const POSITION_ARGUMENT_SEPARATOR: &str = ","; +pub(crate) const CTE_MACRO_PLACEHOLDER_LITERAL: &str = "1"; +pub(crate) const POSTFIX_CAST_PREFIX_TOKEN_COUNT: usize = 2; +pub(crate) const RELATION_MODIFIER_KEYWORDS: [&str; 43] = [ + "ANTI", + "APPLY", + "ASOF", + "AT", + "BEFORE", + "CHANGES", + "CONNECT", + "CROSS", + "EXCEPT", + "FETCH", + "FINAL", + "FOR", + "FULL", + "GROUP", + "HAVING", + "INNER", + "INTERSECT", + "JOIN", + "LATERAL", + "LEFT", + "LIMIT", + "MATCH_RECOGNIZE", + "MINUS", + "NATURAL", + "OFFSET", + "ON", + "ORDER", + "OUTER", + "PASTE", + "PIVOT", + "POSITIONAL", + "QUALIFY", + "RETURNING", + "RIGHT", + "SAMPLE", + "SEMI", + "START", + "TABLESAMPLE", + "UNION", + "UNPIVOT", + "USING", + "WHERE", + "WINDOW", +]; +/// The parts of a `SQC038` message that names its joined relation and CTE. +pub(crate) const CROSS_JOIN_PREFIX: &str = "CROSS JOIN with `"; +pub(crate) const CROSS_JOIN_CTE_MARKER: &str = "` in CTE `"; +pub(crate) const CROSS_JOIN_SUFFIX: &str = "` creates an explicit cartesian product"; +/// The `SQC038` message for a join whose relation cannot be named. +pub(crate) const CROSS_JOIN_UNNAMED_MESSAGE: &str = "CROSS JOIN creates an explicit cartesian product with an unnamed relation; only a file-wide exception (rule and path) can cover it"; +/// Stands for the finding's project-relative path until the caller that knows it fills it in. +pub(crate) const CROSS_JOIN_PATH_PLACEHOLDER: &str = ""; +pub(crate) const CROSS_JOIN_REASON_PLACEHOLDER: &str = ""; diff --git a/crates/sqlclean/src/sql_lint/main/batch_engine.rs b/crates/sqlclean/src/sql_lint/main/batch_engine.rs new file mode 100644 index 0000000..2d7de06 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/main/batch_engine.rs @@ -0,0 +1,19 @@ +use crate::sql_lint::_helpers::engine::lint; +use crate::sql_lint::models::{LintRequest, LintResponse}; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; + +pub fn lint_batch_json(request_json: &str) -> Result { + let requests: Vec = + serde_json::from_str(request_json).map_err(|error| error.to_string())?; + if requests.is_empty() { + return Ok("[]".to_owned()); + } + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(requests.len().min(4)) + .stack_size(16 * 1024 * 1024) + .build() + .map_err(|error| error.to_string())?; + let results: Vec> = + pool.install(|| requests.into_par_iter().map(lint).collect()); + serde_json::to_string(&results).map_err(|error| error.to_string()) +} diff --git a/crates/sqlclean/src/sql_lint/main/catalogue.rs b/crates/sqlclean/src/sql_lint/main/catalogue.rs new file mode 100644 index 0000000..7b534d4 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/main/catalogue.rs @@ -0,0 +1,6 @@ +use crate::sql_lint::_helpers::engine::rule_metadata; +use crate::sql_lint::models::LintRuleMetadata; + +pub fn catalogue() -> Vec { + rule_metadata() +} diff --git a/crates/sqlclean/src/sql_lint/main/cross_join_subject.rs b/crates/sqlclean/src/sql_lint/main/cross_join_subject.rs new file mode 100644 index 0000000..524a580 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/main/cross_join_subject.rs @@ -0,0 +1,15 @@ +//! The relation and CTE a `SQC038` finding names, for relation-level Rule exceptions. + +use crate::sql_lint::constants::{CROSS_JOIN_CTE_MARKER, CROSS_JOIN_PREFIX, CROSS_JOIN_SUFFIX}; + +/// The `(relation, cte)` a cross join finding message names, `None` for the generic message. +#[must_use] +pub fn cross_join_subject(message: &str) -> Option<(String, Option)> { + let named: &str = message + .strip_prefix(CROSS_JOIN_PREFIX)? + .strip_suffix(CROSS_JOIN_SUFFIX)?; + match named.split_once(CROSS_JOIN_CTE_MARKER) { + Some((relation, cte)) => Some((relation.to_owned(), Some(cte.to_owned()))), + None => Some((named.to_owned(), None)), + } +} diff --git a/crates/sqlclean/src/sql_lint/main/default_rules.rs b/crates/sqlclean/src/sql_lint/main/default_rules.rs new file mode 100644 index 0000000..33d1cd1 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/main/default_rules.rs @@ -0,0 +1,6 @@ +//! The built-in Rules a family selector or an absent selection enables. + +/// The codes enabled when no Rule selection is given. +pub fn default_rules() -> Vec<&'static str> { + crate::sql_lint::_helpers::engine::DEFAULT_RULES.to_vec() +} diff --git a/crates/sqlclean/src/sql_lint/main/engine.rs b/crates/sqlclean/src/sql_lint/main/engine.rs new file mode 100644 index 0000000..2533f4f --- /dev/null +++ b/crates/sqlclean/src/sql_lint/main/engine.rs @@ -0,0 +1,5 @@ +use crate::sql_lint::_helpers::engine::lint_json_impl; + +pub fn lint_json(request_json: &str) -> Result { + lint_json_impl(request_json) +} diff --git a/crates/sqlclean/src/sql_lint/main/fill_finding_path.rs b/crates/sqlclean/src/sql_lint/main/fill_finding_path.rs new file mode 100644 index 0000000..d5a6580 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/main/fill_finding_path.rs @@ -0,0 +1,9 @@ +//! `fill_finding_path`: a SQL finding's help with its file's path written into the entry it prints. + +use crate::sql_lint::constants::CROSS_JOIN_PATH_PLACEHOLDER; + +/// `remediation` with the path placeholder of a printed exception entry replaced by `path`. +#[must_use] +pub fn fill_finding_path(remediation: &str, path: &str) -> String { + remediation.replace(CROSS_JOIN_PATH_PLACEHOLDER, path) +} diff --git a/crates/sqlclean/src/sql_lint/main/format_requests.rs b/crates/sqlclean/src/sql_lint/main/format_requests.rs new file mode 100644 index 0000000..286bebd --- /dev/null +++ b/crates/sqlclean/src/sql_lint/main/format_requests.rs @@ -0,0 +1,8 @@ +//! Typed, in-process batch formatting of neutralized SQL bodies. + +use crate::sql_lint::models::{FormatRequest, FormatResponse}; + +/// Format every request in parallel and return the responses in request order. +pub fn format_requests(requests: Vec) -> Vec { + crate::sql_lint::_helpers::formatter::format_requests(requests) +} diff --git a/crates/sqlclean/src/sql_lint/main/formatter.rs b/crates/sqlclean/src/sql_lint/main/formatter.rs new file mode 100644 index 0000000..42da9c9 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/main/formatter.rs @@ -0,0 +1,5 @@ +use crate::sql_lint::_helpers::formatter::format_json_impl; + +pub fn format_json(request_json: &str) -> Result { + format_json_impl(request_json) +} diff --git a/crates/sqlclean/src/sql_lint/main/lint.rs b/crates/sqlclean/src/sql_lint/main/lint.rs new file mode 100644 index 0000000..ab3be0b --- /dev/null +++ b/crates/sqlclean/src/sql_lint/main/lint.rs @@ -0,0 +1,8 @@ +//! Typed, in-process lint of one SQL text. + +use crate::sql_lint::models::{LintRequest, LintResponse}; + +/// Lint `request.sql` with the selected built-in Rules and return findings in source order. +pub fn lint(request: LintRequest) -> Result { + crate::sql_lint::_helpers::engine::lint(request) +} diff --git a/crates/sqlclean/src/sql_lint/main/mod.rs b/crates/sqlclean/src/sql_lint/main/mod.rs new file mode 100644 index 0000000..021ca2d --- /dev/null +++ b/crates/sqlclean/src/sql_lint/main/mod.rs @@ -0,0 +1,11 @@ +pub mod batch_engine; +pub mod catalogue; +pub mod cross_join_subject; +pub mod default_rules; +pub mod engine; +pub mod fill_finding_path; +pub mod format_requests; +pub mod formatter; +pub mod lint; +pub mod parse_sql; +pub mod validate_dialect; diff --git a/crates/sqlclean/src/sql_lint/main/parse_sql.rs b/crates/sqlclean/src/sql_lint/main/parse_sql.rs new file mode 100644 index 0000000..14408c0 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/main/parse_sql.rs @@ -0,0 +1,8 @@ +//! Tokenize and parse SQL the way every lint request does. + +use crate::sql_lint::models::ParsedSql; + +/// Parse `sql` under `dialect`, rejecting statements the parser kept as opaque text. +pub fn parse_sql(sql: &str, dialect: &str) -> Result { + crate::sql_lint::_helpers::parsing::parse(sql, dialect) +} diff --git a/crates/sqlclean/src/sql_lint/main/validate_dialect.rs b/crates/sqlclean/src/sql_lint/main/validate_dialect.rs new file mode 100644 index 0000000..6fe0097 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/main/validate_dialect.rs @@ -0,0 +1,13 @@ +//! Check a dialect name against the dialects the parser supports. + +use std::str::FromStr; + +use polyglot_sql::DialectType; + +/// `Ok` when `name` names a supported SQL dialect, such as `duckdb` or `snowflake`. +pub fn validate_dialect(name: &str) -> Result<(), String> { + match DialectType::from_str(name) { + Ok(_) => Ok(()), + Err(error) => Err(format!("unknown SQL dialect '{name}': {error}")), + } +} diff --git a/crates/sqlclean/src/sql_lint/mod.rs b/crates/sqlclean/src/sql_lint/mod.rs new file mode 100644 index 0000000..e3460e8 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/mod.rs @@ -0,0 +1,9 @@ +//! SQL lint Rules and the deterministic formatter over the Polyglot parser. + +pub mod _helpers; +pub mod constants; +pub mod main; +pub mod models; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlclean/src/sql_lint/models.rs b/crates/sqlclean/src/sql_lint/models.rs new file mode 100644 index 0000000..d6053e4 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/models.rs @@ -0,0 +1,191 @@ +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; + +use polyglot_sql::tokens::{Span, Token}; +use polyglot_sql::{DialectType, Expression}; +use serde::{Deserialize, Serialize}; + +/// SQL text tokenized and parsed under one dialect. +#[derive(Debug)] +pub struct ParsedSql { + pub dialect: DialectType, + pub tokens: Vec, + pub statements: Vec, +} + +#[derive(Debug, Default)] +pub(crate) struct QueryFacts { + pub null_comparisons: Vec, + pub implicit_cartesian_joins: Vec, + pub joins_without_condition: Vec, + pub unordered_limits: Vec, + pub redundant_distincts: Vec, + pub positional_set_stars: Vec, + pub unused_cte_names: Vec, + pub cross_joins: Vec, + pub cte_only_bodies: Vec, + pub terminal_selects: Vec, + pub nested_ctes: Vec, + pub recursive_ctes: Vec, + pub invalid_comment_attachments: Vec, + pub inline_query_relations: Vec, + pub additional: AdditionalQueryFacts, +} + +#[derive(Debug, Default)] +pub(crate) struct AdditionalQueryFacts { + pub bare_unions: Vec, + pub duplicate_table_aliases: Vec, + pub duplicate_output_aliases: Vec, + pub mixed_group_order_references: Vec, + pub redundant_else_nulls: Vec, + pub parenthesized_distincts: Vec, + pub constant_predicates: Vec, + pub consecutive_semicolons: Vec, + pub redundant_self_aliases: Vec, + pub count_one_literals: Vec, + pub unstable_row_numbers: Vec, + pub null_not_in_predicates: Vec, + pub set_arity_mismatches: Vec, + pub projected_stars: Vec, + pub unaliased_calculations: Vec, + pub unused_table_aliases: Vec, + pub null_rejected_left_joins: Vec, + pub implicit_inner_joins: Vec, + pub ambiguous_order_directions: Vec, + pub unqualified_multi_source_columns: Vec, + pub inconsistent_single_source_qualification: Vec, + pub unknown_relation_qualifiers: Vec, + pub simple_boolean_cases: Vec, + pub nested_else_cases: Vec, + pub unused_joined_relations: Vec, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LintRequest { + pub version: u32, + pub sql: String, + pub dialect: String, + #[serde(default)] + pub enabled_rules: Option>, + #[serde(default)] + pub ignored_rules: Vec, + #[serde(default)] + pub external_identifiers: Vec, + #[serde(default)] + pub dependency_identifiers: Vec, + #[serde(default)] + pub externally_referenced_ctes: Vec, + #[serde(default)] + pub allows_ceremonial_select: bool, + #[serde(default)] + pub allows_dynamic_output_star: bool, + #[serde(default)] + pub allows_empty_fixture_star: bool, + #[serde(default)] + pub max_literal_length: Option, + #[serde(default)] + pub max_ranking_order_by: Option, + #[serde(default)] + pub relation_keys: HashMap>>, + #[serde(default)] + pub relation_names: HashMap, + /// The SQL each `@sql.` call in the query generated, so column qualification is judged as the macro wrote it. + #[serde(default)] + pub sql_macro_outputs: Vec, + /// The configuration file remediation text names; SQLClean's own when absent. + #[serde(default)] + pub config_file: Option, +} + +/// The character range one `@sql.` call's output fills, and the names its arguments pass, which a plain slot read such as `@rel.amount` expands to qualify with. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +pub struct SqlMacroOutput { + pub start: usize, + pub end: usize, + #[serde(default)] + pub relations: Vec, +} + +#[derive(Debug)] +pub(crate) struct AdditionalFactOptions<'a> { + pub external_identifiers: &'a HashSet, + pub dependency_identifiers: &'a HashSet, + pub sql_macro_outputs: &'a [SqlMacroOutput], + pub allows_ceremonial_select: bool, + pub allows_dynamic_output_star: bool, + pub allows_empty_fixture_star: bool, +} + +#[derive(Debug, Serialize)] +pub struct LintResponse { + pub version: u32, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LintDiagnostic { + pub code: &'static str, + pub message: Cow<'static, str>, + pub remediation: Cow<'static, str>, + pub start: usize, + pub end: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub fix: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fix_unavailable_reason: Option<&'static str>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LintEdit { + pub start: usize, + pub end: usize, + pub replacement: String, +} + +#[derive(Debug, Clone, Copy)] +pub struct LintRuleMetadata { + pub code: &'static str, + pub message: &'static str, + pub remediation: &'static str, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FormatRequest { + pub version: u32, + pub sql: String, + pub dialect: String, + #[serde(default)] + pub max_function_call_depth: Option, + #[serde(default)] + pub line_width: Option, + /// Spaces per indent step; two when absent. + #[serde(default)] + pub indent: Option, + #[serde(default)] + pub token_widths: HashMap, +} + +#[derive(Debug, Serialize)] +pub struct FormatResponse { + pub version: u32, + pub sql: String, + pub changed: bool, + pub formatted: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// One lint request's explicit cross joins, with what is known about the relations they read. +#[derive(Debug)] +pub(crate) struct CrossJoinRequest<'a> { + pub(crate) statements: &'a [Expression], + pub(crate) tokens: &'a [Token], + pub(crate) cross_spans: &'a [Span], + /// Declared keys by dependency sentinel; the empty key proves at most one row. + pub(crate) relation_keys: &'a HashMap>>, + /// The relation name each dependency sentinel stands for. + pub(crate) relation_names: &'a HashMap, +} diff --git a/crates/sqlclean/src/sql_lint/tests.rs b/crates/sqlclean/src/sql_lint/tests.rs new file mode 100644 index 0000000..dde04c7 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests.rs @@ -0,0 +1,34 @@ +#[path = "tests/test_authored_tokens.rs"] +mod authored_tokens; +#[path = "tests/test_batch_lint.rs"] +mod batch_lint; +#[path = "tests/test_comment_formatter.rs"] +mod comment_formatter; +#[path = "tests/test_cross_joins.rs"] +mod cross_joins; +#[path = "tests/test_empty_fixture_lint.rs"] +mod empty_fixture_lint; +#[path = "tests/test_format_corpus.rs"] +mod format_corpus; +#[path = "tests/test_function_call_spacing.rs"] +mod function_call_spacing; +#[path = "tests/helpers.rs"] +mod helpers; +#[path = "tests/test_idempotency_reprint.rs"] +mod idempotency_reprint; +#[path = "tests/test_layout_rules.rs"] +mod layout_rules; +#[path = "tests/test_line_wrap.rs"] +mod line_wrap; +#[path = "tests/test_lint_exemptions.rs"] +mod lint_exemptions; +#[path = "tests/test_set_operation_formatter.rs"] +mod set_operation_formatter; +#[path = "tests/test_sql_lint.rs"] +mod sql_lint; +#[path = "tests/test_types.rs"] +mod test_types; +#[path = "tests/test_unused_table_alias.rs"] +mod unused_table_alias; +#[path = "tests/test_values_formatter.rs"] +mod values_formatter; diff --git a/crates/sqlclean/src/sql_lint/tests/fixtures/FORMAT_CORPUS_NOTICE.md b/crates/sqlclean/src/sql_lint/tests/fixtures/FORMAT_CORPUS_NOTICE.md new file mode 100644 index 0000000..39e0868 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/fixtures/FORMAT_CORPUS_NOTICE.md @@ -0,0 +1,32 @@ +# Formatter corpus notice + +`format_corpus.json` contains SELECT and WITH queries taken from the SQLGlot dialect identity test +fixtures for BigQuery, Databricks, DuckDB, PostgreSQL, Snowflake and T-SQL, as extracted by the +Polyglot project. One literal was reworded. The expected `formatted` and `previously_formatted` +values are SQLBuild formatter output. + +The queries are used under the following license: + +```text +MIT License + +Copyright (c) 2026 Toby Mao + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/crates/sqlclean/src/sql_lint/tests/fixtures/format_corpus.json b/crates/sqlclean/src/sql_lint/tests/fixtures/format_corpus.json new file mode 100644 index 0000000..b45dd71 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/fixtures/format_corpus.json @@ -0,0 +1,5354 @@ +[ + { + "dialect": "bigquery", + "sql": "SELECT 'foo' 'bar'", + "formatted": "SELECT 'foo' 'bar'" + }, + { + "dialect": "bigquery", + "sql": "SELECT 'foo'/* c */'bar'", + "formatted": "SELECT\n 'foo' /* c */ 'bar'" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM `domain.com:project-id.mydataset.mytable`", + "formatted": "SELECT *\nFROM `domain.com:project-id.mydataset.mytable`" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM `domain.com:project-id.region-us.INFORMATION_SCHEMA`.JOBS", + "formatted": "SELECT *\nFROM `domain.com:project-id.region-us.INFORMATION_SCHEMA`.JOBS" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM `domain.com:project-id.region-us.INFORMATION_SCHEMA.JOBS`", + "formatted": "SELECT *\nFROM `domain.com:project-id.region-us.INFORMATION_SCHEMA.JOBS`" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM `a.b.com:project-id.mydataset.mytable`", + "formatted": "SELECT *\nFROM `a.b.com:project-id.mydataset.mytable`" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM `a.b.com:project-id.region-us.INFORMATION_SCHEMA.JOBS`", + "formatted": "SELECT *\nFROM `a.b.com:project-id.region-us.INFORMATION_SCHEMA.JOBS`" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM x-0.y", + "formatted": "SELECT *\nFROM x-0.y" + }, + { + "dialect": "bigquery", + "sql": "SELECT `p.d.UdF`(data) FROM `p.d.t`", + "formatted": "SELECT `p.d.UdF`(data)\nFROM `p.d.t`" + }, + { + "dialect": "bigquery", + "sql": "SELECT EXP(1)", + "formatted": "SELECT EXP(1)" + }, + { + "dialect": "bigquery", + "sql": "SELECT ARRAY_CONCAT([1])", + "formatted": "SELECT ARRAY_CONCAT([1])" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM READ_CSV('bla.csv')", + "formatted": "SELECT *\nFROM READ_CSV('bla.csv')" + }, + { + "dialect": "bigquery", + "sql": "SELECT jsondoc['some_key']", + "formatted": "SELECT jsondoc['some_key']" + }, + { + "dialect": "bigquery", + "sql": "SELECT `p.d.UdF`(data).* FROM `p.d.t`", + "formatted": "SELECT `p.d.UdF`(data).*\nFROM `p.d.t`" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM `my-project.my-dataset.my-table`", + "formatted": "SELECT *\nFROM `my-project.my-dataset.my-table`" + }, + { + "dialect": "bigquery", + "sql": "SELECT x, 1 AS y GROUP BY 1 ORDER BY 1", + "formatted": "SELECT\n x,\n 1 AS y\nGROUP BY\n 1\nORDER BY\n 1" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM x.*", + "formatted": "SELECT *\nFROM x.*" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM x.y*", + "formatted": "SELECT *\nFROM x.y*" + }, + { + "dialect": "bigquery", + "sql": "SELECT SEARCH(data_to_search, 'search_query')", + "formatted": "SELECT SEARCH(data_to_search, 'search_query')" + }, + { + "dialect": "bigquery", + "sql": "SELECT SEARCH(data_to_search, 'search_query', json_scope => 'JSON_KEYS_AND_VALUES')", + "formatted": "SELECT SEARCH(data_to_search, 'search_query', json_scope => 'JSON_KEYS_AND_VALUES')" + }, + { + "dialect": "bigquery", + "sql": "SELECT SEARCH(data_to_search, 'search_query', analyzer => 'PATTERN_ANALYZER')", + "formatted": "SELECT SEARCH(data_to_search, 'search_query', analyzer => 'PATTERN_ANALYZER')" + }, + { + "dialect": "bigquery", + "sql": "SELECT SEARCH(data_to_search, 'search_query', analyzer_options => 'analyzer_options_values')", + "formatted": "SELECT SEARCH(data_to_search, 'search_query', analyzer_options => 'analyzer_options_values')" + }, + { + "dialect": "bigquery", + "sql": "SELECT SEARCH(data_to_search, 'search_query', json_scope => 'JSON_VALUES', analyzer => 'LOG_ANALYZER')", + "formatted": "SELECT\n SEARCH(\n data_to_search,\n 'search_query',\n json_scope => 'JSON_VALUES',\n analyzer => 'LOG_ANALYZER'\n )" + }, + { + "dialect": "bigquery", + "sql": "SELECT SEARCH(data_to_search, 'search_query', analyzer => 'PATTERN_ANALYZER', analyzer_options => 'options')", + "formatted": "SELECT\n SEARCH(\n data_to_search,\n 'search_query',\n analyzer => 'PATTERN_ANALYZER',\n analyzer_options => 'options'\n )" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM dataset.my_table TABLESAMPLE SYSTEM (10 PERCENT)", + "formatted": "SELECT *\nFROM dataset.my_table TABLESAMPLE SYSTEM (10 PERCENT)" + }, + { + "dialect": "bigquery", + "sql": "SELECT '\\n\\r\\a\\v\\f\\t'", + "formatted": "SELECT '\\n\\r\\a\\v\\f\\t'" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM tbl FOR SYSTEM_TIME AS OF z", + "formatted": "SELECT *\nFROM tbl FOR SYSTEM_TIME AS OF z" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM tbl FOR SYSTEM TIME AS OF z", + "formatted": "SELECT *\nFROM tbl FOR SYSTEM TIME AS OF z" + }, + { + "dialect": "bigquery", + "sql": "SELECT PARSE_TIMESTAMP('%c', 'Thu Dec 25 07:30:00 2008', 'UTC')", + "formatted": "SELECT PARSE_TIMESTAMP('%c', 'Thu Dec 25 07:30:00 2008', 'UTC')" + }, + { + "dialect": "bigquery", + "sql": "SELECT ANY_VALUE(fruit HAVING MAX sold) FROM fruits", + "formatted": "SELECT ANY_VALUE(fruit HAVING MAX sold)\nFROM fruits" + }, + { + "dialect": "bigquery", + "sql": "SELECT ANY_VALUE(fruit HAVING MIN sold) FROM fruits", + "formatted": "SELECT ANY_VALUE(fruit HAVING MIN sold)\nFROM fruits" + }, + { + "dialect": "bigquery", + "sql": "SELECT `project-id`.udfs.func(call.dir)", + "formatted": "SELECT `project-id`.udfs.func(call.dir)" + }, + { + "dialect": "bigquery", + "sql": "SELECT CAST(CURRENT_DATE AS STRING FORMAT 'DAY') AS current_day", + "formatted": "SELECT CAST(CURRENT_DATE AS STRING FORMAT 'DAY') AS current_day" + }, + { + "dialect": "bigquery", + "sql": "SELECT foo IN UNNEST(bar) AS bla", + "formatted": "SELECT foo IN UNNEST(bar) AS bla" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM x-0.a", + "formatted": "SELECT *\nFROM x-0.a" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM pivot CROSS JOIN foo", + "formatted": "SELECT *\nFROM pivot\nCROSS JOIN foo" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM a-b-c.mydataset.mytable", + "formatted": "SELECT *\nFROM a-b-c.mydataset.mytable" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM abc-def-ghi", + "formatted": "SELECT *\nFROM abc-def-ghi" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM a-b-c", + "formatted": "SELECT *\nFROM a-b-c" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM my-table", + "formatted": "SELECT *\nFROM my-table" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM my-project.mydataset.mytable", + "formatted": "SELECT *\nFROM my-project.mydataset.mytable" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM pro-ject_id.c.d CROSS JOIN foo-bar", + "formatted": "SELECT *\nFROM pro-ject_id.c.d\nCROSS JOIN foo-bar" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM foo.bar.25", + "formatted": "SELECT *\nFROM foo.bar.25" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM foo.bar.25_", + "formatted": "SELECT *\nFROM foo.bar.25_" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM foo.bar.25x a", + "formatted": "SELECT *\nFROM foo.bar.25x a" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM foo.bar.25ab c", + "formatted": "SELECT *\nFROM foo.bar.25ab c" + }, + { + "dialect": "bigquery", + "sql": "SELECT b'abc'", + "formatted": "SELECT b'abc'" + }, + { + "dialect": "bigquery", + "sql": "SELECT AS STRUCT 1 AS a, 2 AS b", + "formatted": "SELECT AS STRUCT\n 1 AS a,\n 2 AS b" + }, + { + "dialect": "bigquery", + "sql": "SELECT DISTINCT AS STRUCT 1 AS a, 2 AS b", + "formatted": "SELECT DISTINCT AS STRUCT\n 1 AS a,\n 2 AS b" + }, + { + "dialect": "bigquery", + "sql": "SELECT AS VALUE STRUCT(1 AS a, 2 AS b)", + "formatted": "SELECT AS VALUE STRUCT(1 AS a, 2 AS b)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM q UNPIVOT(values FOR quarter IN (b, c))", + "formatted": "SELECT *\nFROM q UNPIVOT(values FOR quarter IN (b, c))" + }, + { + "dialect": "bigquery", + "sql": "SELECT MAKE_INTERVAL(100, 11, 1, 12, 30, 10)", + "formatted": "SELECT MAKE_INTERVAL(100, 11, 1, 12, 30, 10)" + }, + { + "dialect": "bigquery", + "sql": "SELECT y + 1 FROM x GROUP BY y + 1 ORDER BY 1", + "formatted": "SELECT y + 1\nFROM x\nGROUP BY\n y + 1\nORDER BY\n 1" + }, + { + "dialect": "bigquery", + "sql": "SELECT TIMESTAMP_SECONDS(2) AS t", + "formatted": "SELECT TIMESTAMP_SECONDS(2) AS t" + }, + { + "dialect": "bigquery", + "sql": "SELECT TIMESTAMP_MILLIS(2) AS t", + "formatted": "SELECT TIMESTAMP_MILLIS(2) AS t" + }, + { + "dialect": "bigquery", + "sql": "SELECT COUNT(x RESPECT NULLS)", + "formatted": "SELECT COUNT(x RESPECT NULLS)" + }, + { + "dialect": "bigquery", + "sql": "SELECT LAST_VALUE(x IGNORE NULLS) OVER y AS x", + "formatted": "SELECT LAST_VALUE(x IGNORE NULLS) OVER y AS x" + }, + { + "dialect": "bigquery", + "sql": "SELECT ARRAY((SELECT AS STRUCT 1 AS a, 2 AS b))", + "formatted": "SELECT\n ARRAY((\n SELECT AS STRUCT\n 1 AS a,\n 2 AS b\n ))" + }, + { + "dialect": "bigquery", + "sql": "SELECT ARRAY((SELECT AS STRUCT 1 AS a, 2 AS b) LIMIT 10)", + "formatted": "SELECT\n ARRAY((\n SELECT AS STRUCT\n 1 AS a,\n 2 AS b\n ) LIMIT 10)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM x WHERE x.y >= (SELECT MAX(a) FROM b-c) - 20", + "formatted": "SELECT *\nFROM x\nWHERE\n x.y >= (\n SELECT MAX(a)\n FROM b-c\n )\n - 20" + }, + { + "dialect": "bigquery", + "sql": "SELECT FORMAT_TIMESTAMP('%F %T', CURRENT_TIMESTAMP(), 'Europe/Berlin') AS ts", + "formatted": "SELECT FORMAT_TIMESTAMP('%F %T', CURRENT_TIMESTAMP(), 'Europe/Berlin') AS ts" + }, + { + "dialect": "bigquery", + "sql": "SELECT cars, apples FROM some_table PIVOT(SUM(total_counts) FOR products IN ('general.cars' AS cars, 'food.apples' AS apples))", + "formatted": "SELECT\n cars,\n apples\nFROM some_table PIVOT(SUM(total_counts) FOR products IN ('general.cars' AS cars, 'food.apples' AS apples))" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM test QUALIFY a IS DISTINCT FROM b WINDOW c AS (PARTITION BY d)", + "formatted": "SELECT *\nFROM test\nQUALIFY a IS DISTINCT FROM b\nWINDOW\n c AS (PARTITION BY d)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM (SELECT * FROM `t`) AS a UNPIVOT((c) FOR c_name IN (v1, v2))", + "formatted": "SELECT *\nFROM (\n SELECT *\n FROM `t`\n) AS a UNPIVOT((c) FOR c_name IN (v1, v2))" + }, + { + "dialect": "bigquery", + "sql": "SELECT ROW() OVER (y ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) FROM x WINDOW y AS (PARTITION BY CATEGORY)", + "formatted": "SELECT ROW() OVER (y ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING)\nFROM x\nWINDOW\n y AS (PARTITION BY CATEGORY)" + }, + { + "dialect": "bigquery", + "sql": "SELECT item, purchases, LAST_VALUE(item) OVER (item_window ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING) AS most_popular FROM Produce WINDOW item_window AS (ORDER BY purchases)", + "formatted": "SELECT\n item,\n purchases,\n LAST_VALUE(item) OVER (item_window ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING) AS most_popular\nFROM Produce\nWINDOW\n item_window AS (ORDER BY purchases)" + }, + { + "dialect": "bigquery", + "sql": "SELECT LAST_VALUE(a IGNORE NULLS) OVER y FROM x WINDOW y AS (PARTITION BY CATEGORY)", + "formatted": "SELECT LAST_VALUE(a IGNORE NULLS) OVER y\nFROM x\nWINDOW\n y AS (PARTITION BY CATEGORY)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM foo AS t0 FOR SYSTEM_TIME AS OF '2026-02-12T23:22:21.743416+00:00'", + "formatted": "SELECT *\nFROM foo AS t0 FOR SYSTEM_TIME AS OF '2026-02-12T23:22:21.743416+00:00'" + }, + { + "dialect": "bigquery", + "sql": "SELECT b\"\\x0a$'x'00\"", + "formatted": "SELECT b\"\\x0a$'x'00\"" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM t1, t2", + "formatted": "SELECT *\nFROM t1, t2" + }, + { + "dialect": "bigquery", + "sql": "SELECT r\"\\t\"", + "formatted": "SELECT r\"\\t\"" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM `proj.dataset.INFORMATION_SCHEMA.SOME_VIEW`", + "formatted": "SELECT *\nFROM `proj.dataset.INFORMATION_SCHEMA.SOME_VIEW`" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM region_or_dataset.INFORMATION_SCHEMA.TABLES", + "formatted": "SELECT *\nFROM region_or_dataset.INFORMATION_SCHEMA.TABLES" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM region_or_dataset.INFORMATION_SCHEMA.TABLES AS some_name", + "formatted": "SELECT *\nFROM region_or_dataset.INFORMATION_SCHEMA.TABLES AS some_name" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM proj.region_or_dataset.INFORMATION_SCHEMA.TABLES", + "formatted": "SELECT *\nFROM proj.region_or_dataset.INFORMATION_SCHEMA.TABLES" + }, + { + "dialect": "bigquery", + "sql": "SELECT ARRAY(SELECT AS STRUCT 1 a, 2 b)", + "formatted": "SELECT\n ARRAY(SELECT AS STRUCT\n 1 a,\n 2 b)", + "previously_formatted": "SELECT\n ARRAY(SELECT AS STRUCT\n 1 AS a,\n 2 AS b)" + }, + { + "dialect": "bigquery", + "sql": "select array_contains([1, 2, 3], 1)", + "formatted": "SELECT array_contains([1, 2, 3], 1)" + }, + { + "dialect": "bigquery", + "sql": "SELECT SPLIT(foo)", + "formatted": "SELECT SPLIT(foo)" + }, + { + "dialect": "bigquery", + "sql": "SELECT 1 AS hash", + "formatted": "SELECT 1 AS hash" + }, + { + "dialect": "bigquery", + "sql": "SELECT 1 AS at", + "formatted": "SELECT 1 AS at" + }, + { + "dialect": "bigquery", + "sql": "SELECT \"\"\"ends with \\\"word\\\"\"\"\"", + "formatted": "SELECT \"\"\"ends with \\\"word\\\"\"\"\"" + }, + { + "dialect": "bigquery", + "sql": "SELECT '''ends with \\'word\\''''", + "formatted": "SELECT '''ends with \\'word\\''''" + }, + { + "dialect": "bigquery", + "sql": "SELECT \"\"\"a\\\"b\"\"\"", + "formatted": "SELECT \"\"\"a\\\"b\"\"\"" + }, + { + "dialect": "bigquery", + "sql": "SELECT r\"\"\"ends with \\\"\"\"\"", + "formatted": "SELECT r\"\"\"ends with \\\"\"\"\"" + }, + { + "dialect": "bigquery", + "sql": "SELECT r\"\"\"a\\\"b\"\"\"", + "formatted": "SELECT r\"\"\"a\\\"b\"\"\"" + }, + { + "dialect": "bigquery", + "sql": "SELECT a overlaps", + "formatted": "SELECT a overlaps", + "previously_formatted": "SELECT\n a AS overlaps" + }, + { + "dialect": "bigquery", + "sql": "SELECT y + 1 z FROM x GROUP BY y + 1 ORDER BY z", + "formatted": "SELECT y + 1 z\nFROM x\nGROUP BY\n y + 1\nORDER BY\n z", + "previously_formatted": "SELECT\n y + 1 AS z\nFROM x\nGROUP BY\n y + 1\nORDER BY\n z" + }, + { + "dialect": "bigquery", + "sql": "SELECT y + 1 z FROM x GROUP BY y + 1", + "formatted": "SELECT y + 1 z\nFROM x\nGROUP BY\n y + 1", + "previously_formatted": "SELECT\n y + 1 AS z\nFROM x\nGROUP BY\n y + 1" + }, + { + "dialect": "bigquery", + "sql": "SELECT JSON '\"foo\"' AS json_data", + "formatted": "SELECT JSON '\"foo\"' AS json_data" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM (SELECT a, b, c FROM test) PIVOT(SUM(b) d, COUNT(*) e FOR c IN ('x', 'y'))", + "formatted": "SELECT *\nFROM (\n SELECT\n a,\n b,\n c\n FROM test\n) PIVOT(SUM(b) d, COUNT(*) e FOR c IN ('x', 'y'))", + "previously_formatted": "SELECT\n *\nFROM (\n SELECT\n a,\n b,\n c\n FROM test\n) PIVOT(SUM(b) AS d, COUNT(*) AS e FOR c IN ('x', 'y'))" + }, + { + "dialect": "bigquery", + "sql": "SELECT CAST(1 AS BYTEINT)", + "formatted": "SELECT CAST(1 AS BYTEINT)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM a-b c", + "formatted": "SELECT *\nFROM a-b c", + "previously_formatted": "SELECT\n *\nFROM a-b AS c" + }, + { + "dialect": "bigquery", + "sql": "SELECT PARSE_DATETIME('%a %b %e %I:%M:%S %Y', 'Thu Dec 25 07:30:00 2008')", + "formatted": "SELECT PARSE_DATETIME('%a %b %e %I:%M:%S %Y', 'Thu Dec 25 07:30:00 2008')" + }, + { + "dialect": "bigquery", + "sql": "SELECT TRANSLATE(MODEL, 'in', 't') FROM (SELECT 'input' AS MODEL)", + "formatted": "SELECT TRANSLATE(MODEL, 'in', 't')\nFROM (\n SELECT 'input' AS MODEL\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT GRANT FROM (SELECT 'input' AS GRANT)", + "formatted": "SELECT GRANT\nFROM (\n SELECT 'input' AS GRANT\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT CAST(1 AS NUMERIC(10, 2))", + "formatted": "SELECT CAST(1 AS NUMERIC(10, 2))" + }, + { + "dialect": "bigquery", + "sql": "SELECT CAST('1' AS STRING(10)) UNION ALL SELECT CAST('2' AS STRING(10))", + "formatted": "SELECT CAST('1' AS STRING(10))\nUNION ALL\nSELECT CAST('2' AS STRING(10))" + }, + { + "dialect": "bigquery", + "sql": "SELECT cola FROM (SELECT CAST('1' AS STRING(10)) AS cola UNION ALL SELECT CAST('2' AS STRING(10)) AS cola)", + "formatted": "SELECT cola\nFROM (\n SELECT CAST('1' AS STRING(10)) AS cola\n UNION ALL\n SELECT CAST('2' AS STRING(10)) AS cola\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM GAP_FILL(TABLE device_data, ts_column => 'time', bucket_width => INTERVAL '1' MINUTE, value_columns => [('signal', 'locf')]) ORDER BY time", + "formatted": "SELECT *\nFROM GAP_FILL(\n TABLE device_data,\n ts_column => 'time',\n bucket_width => INTERVAL '1' MINUTE,\n value_columns => [('signal', 'locf')]\n)\nORDER BY\n time" + }, + { + "dialect": "bigquery", + "sql": "SELECT a, b, c, d, e FROM GAP_FILL(TABLE foo, ts_column => 'b', partitioning_columns => ['a'], value_columns => [('c', 'bar'), ('d', 'baz'), ('e', 'bla')], bucket_width => INTERVAL '1' DAY)", + "formatted": "SELECT\n a,\n b,\n c,\n d,\n e\nFROM GAP_FILL(\n TABLE foo,\n ts_column => 'b',\n partitioning_columns => ['a'],\n value_columns => [\n ('c', 'bar'),\n ('d', 'baz'),\n ('e', 'bla')\n ],\n bucket_width => INTERVAL '1' DAY\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM GAP_FILL(TABLE device_data, ts_column => 'time', bucket_width => INTERVAL '1' MINUTE, value_columns => [('signal', 'linear')], ignore_null_values => FALSE) ORDER BY time", + "formatted": "SELECT *\nFROM GAP_FILL(\n TABLE device_data,\n ts_column => 'time',\n bucket_width => INTERVAL '1' MINUTE,\n value_columns => [('signal', 'linear')],\n ignore_null_values => FALSE\n)\nORDER BY\n time" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM GAP_FILL(TABLE device_data, ts_column => 'time', bucket_width => INTERVAL '1' MINUTE) ORDER BY time", + "formatted": "SELECT *\nFROM GAP_FILL(\n TABLE device_data,\n ts_column => 'time',\n bucket_width => INTERVAL '1' MINUTE\n)\nORDER BY\n time" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM GAP_FILL(TABLE device_data, ts_column => 'time', bucket_width => INTERVAL '1' MINUTE, value_columns => [('signal', 'null')], origin => CAST('2023-11-01 09:30:01' AS DATETIME)) ORDER BY time", + "formatted": "SELECT *\nFROM GAP_FILL(\n TABLE device_data,\n ts_column => 'time',\n bucket_width => INTERVAL '1' MINUTE,\n value_columns => [('signal', 'null')],\n origin => CAST('2023-11-01 09:30:01' AS DATETIME)\n)\nORDER BY\n time" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM GAP_FILL(TABLE device_data, ts_column => 'time', bucket_width => INTERVAL '1' MINUTE, value_columns => [('signal', 'locf')]) ORDER BY time", + "formatted": "SELECT *\nFROM GAP_FILL(\n TABLE device_data,\n ts_column => 'time',\n bucket_width => INTERVAL '1' MINUTE,\n value_columns => [('signal', 'locf')]\n)\nORDER BY\n time" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM ML.PREDICT(MODEL mydataset.mymodel, (SELECT label, column1, column2 FROM mydataset.mytable))", + "formatted": "SELECT *\nFROM ML.PREDICT(MODEL mydataset.mymodel, (\n SELECT\n label,\n column1,\n column2\n FROM mydataset.mytable\n))" + }, + { + "dialect": "bigquery", + "sql": "SELECT label, predicted_label1, predicted_label AS predicted_label2 FROM ML.PREDICT(MODEL mydataset.mymodel2, (SELECT * EXCEPT (predicted_label), predicted_label AS predicted_label1 FROM ML.PREDICT(MODEL mydataset.mymodel1, TABLE mydataset.mytable)))", + "formatted": "SELECT\n label,\n predicted_label1,\n predicted_label AS predicted_label2\nFROM ML.PREDICT(\n MODEL mydataset.mymodel2,\n (\n SELECT\n * EXCEPT (predicted_label),\n predicted_label AS predicted_label1\n FROM ML.PREDICT(MODEL mydataset.mymodel1, TABLE mydataset.mytable)\n )\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM ML.PREDICT(MODEL mydataset.mymodel, (SELECT custom_label, column1, column2 FROM mydataset.mytable), STRUCT(0.55 AS threshold))", + "formatted": "SELECT *\nFROM ML.PREDICT(\n MODEL mydataset.mymodel,\n (\n SELECT\n custom_label,\n column1,\n column2\n FROM mydataset.mytable\n ),\n STRUCT(0.55 AS threshold)\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT COSH(1.5)", + "formatted": "SELECT COSH(1.5)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM ML.PREDICT(MODEL `my_project`.my_dataset.my_model, (SELECT * FROM input_data))", + "formatted": "SELECT *\nFROM ML.PREDICT(MODEL `my_project`.my_dataset.my_model, (\n SELECT *\n FROM input_data\n))" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM ML.PREDICT(MODEL my_dataset.vision_model, (SELECT uri, ML.RESIZE_IMAGE(ML.DECODE_IMAGE(data), 480, 480, FALSE) AS input FROM my_dataset.object_table))", + "formatted": "SELECT *\nFROM ML.PREDICT(\n MODEL my_dataset.vision_model,\n (\n SELECT\n uri,\n ML.RESIZE_IMAGE(ML.DECODE_IMAGE(data), 480, 480, FALSE) AS input\n FROM my_dataset.object_table\n )\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM ML.PREDICT(MODEL my_dataset.vision_model, (SELECT uri, ML.CONVERT_COLOR_SPACE(ML.RESIZE_IMAGE(ML.DECODE_IMAGE(data), 224, 280, TRUE), 'YIQ') AS input FROM my_dataset.object_table WHERE content_type = 'image/jpeg'))", + "formatted": "SELECT *\nFROM ML.PREDICT(\n MODEL my_dataset.vision_model,\n (\n SELECT\n uri,\n ML.CONVERT_COLOR_SPACE(ML.RESIZE_IMAGE(ML.DECODE_IMAGE(data), 224, 280, TRUE), 'YIQ') AS input\n FROM my_dataset.object_table\n WHERE content_type = 'image/jpeg'\n )\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM ML.FEATURES_AT_TIME((SELECT 1), num_rows => 1)", + "formatted": "SELECT *\nFROM ML.FEATURES_AT_TIME((\n SELECT 1\n), num_rows => 1)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM ML.FEATURES_AT_TIME(TABLE mydataset.feature_table, time => '2022-06-11 10:00:00+00', num_rows => 1, ignore_feature_nulls => TRUE)", + "formatted": "SELECT *\nFROM ML.FEATURES_AT_TIME(\n TABLE mydataset.feature_table,\n time => '2022-06-11 10:00:00+00',\n num_rows => 1,\n ignore_feature_nulls => TRUE\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM VECTOR_SEARCH(TABLE mydataset.base_table, 'column_to_search', TABLE mydataset.query_table, 'query_column_to_search', top_k => 2, distance_type => 'cosine', options => '{\"fraction_lists_to_search\":0.15}')", + "formatted": "SELECT *\nFROM VECTOR_SEARCH(\n TABLE mydataset.base_table,\n 'column_to_search',\n TABLE mydataset.query_table,\n 'query_column_to_search',\n top_k => 2,\n distance_type => 'cosine',\n options => '{\"fraction_lists_to_search\":0.15}'\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM VECTOR_SEARCH(TABLE mydataset.base_table, 'column_to_search', TABLE mydataset.query_table, query_column_to_search => 'query_column_to_search', top_k => 2, distance_type => 'cosine', options => '{\"fraction_lists_to_search\":0.15}')", + "formatted": "SELECT *\nFROM VECTOR_SEARCH(\n TABLE mydataset.base_table,\n 'column_to_search',\n TABLE mydataset.query_table,\n query_column_to_search => 'query_column_to_search',\n top_k => 2,\n distance_type => 'cosine',\n options => '{\"fraction_lists_to_search\":0.15}'\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM VECTOR_SEARCH((SELECT * FROM mydataset.base_table), 'column_to_search', (SELECT * FROM mydataset.query_table), 'query_column_to_search')", + "formatted": "SELECT *\nFROM VECTOR_SEARCH(\n (\n SELECT *\n FROM mydataset.base_table\n ),\n 'column_to_search',\n (\n SELECT *\n FROM mydataset.query_table\n ),\n 'query_column_to_search'\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM VECTOR_SEARCH(TABLE mydataset.base_table, 'column_to_search', TABLE mydataset.query_table)", + "formatted": "SELECT *\nFROM VECTOR_SEARCH(TABLE mydataset.base_table, 'column_to_search', TABLE mydataset.query_table)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM ML.TRANSLATE(MODEL `mydataset.mytranslatemodel`, TABLE `mydataset.mybqtable`, STRUCT('translate_text' AS translate_mode, 'zh-CN' AS target_language_code))", + "formatted": "SELECT *\nFROM ML.TRANSLATE(\n MODEL `mydataset.mytranslatemodel`,\n TABLE `mydataset.mybqtable`,\n STRUCT(\n 'translate_text' AS translate_mode,\n 'zh-CN' AS target_language_code\n )\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM ML.FORECAST(MODEL `mydataset.mymodel`, STRUCT(2 AS horizon))", + "formatted": "SELECT *\nFROM ML.FORECAST(MODEL `mydataset.mymodel`, STRUCT(2 AS horizon))" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM ML.FORECAST(MODEL `mydataset.mymodel`, TABLE `mydataset.mybqtable`, STRUCT(2 AS horizon, 4 AS confidence_level))", + "formatted": "SELECT *\nFROM ML.FORECAST(\n MODEL `mydataset.mymodel`,\n TABLE `mydataset.mybqtable`,\n STRUCT(2 AS horizon, 4 AS confidence_level)\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM ML.FORECAST(MODEL `mydataset.mymodel`, (SELECT * FROM mydataset.query_table), STRUCT())", + "formatted": "SELECT *\nFROM ML.FORECAST(MODEL `mydataset.mymodel`, (\n SELECT *\n FROM mydataset.query_table\n), STRUCT())" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM AI.FORECAST(TABLE citibike_trips, data_col => 'num_trips', timestamp_col => 'date', horizon => 30)", + "formatted": "SELECT *\nFROM AI.FORECAST(\n TABLE citibike_trips,\n data_col => 'num_trips',\n timestamp_col => 'date',\n horizon => 30\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM AI.FORECAST((SELECT * FROM citibike_trips), data_col => 'num_trips', timestamp_col => 'date', horizon => 30)", + "formatted": "SELECT *\nFROM AI.FORECAST(\n (\n SELECT *\n FROM citibike_trips\n ),\n data_col => 'num_trips',\n timestamp_col => 'date',\n horizon => 30\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM ML.GENERATE_TEXT(MODEL `mydataset.gemini_model`, TABLE `mydataset.prompt_table`, STRUCT(0.15 AS temperature))", + "formatted": "SELECT *\nFROM ML.GENERATE_TEXT(\n MODEL `mydataset.gemini_model`,\n TABLE `mydataset.prompt_table`,\n STRUCT(0.15 AS temperature)\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM AI.GENERATE_TEXT(MODEL `mydataset.gemini_model`, TABLE `mydataset.prompt_table`, STRUCT(0.15 AS temperature))", + "formatted": "SELECT *\nFROM AI.GENERATE_TEXT(\n MODEL `mydataset.gemini_model`,\n TABLE `mydataset.prompt_table`,\n STRUCT(0.15 AS temperature)\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM AI.GENERATE_TABLE(MODEL `mydataset.gemini_model`, (SELECT 'Q' AS prompt), STRUCT('name STRING' AS output_schema))", + "formatted": "SELECT *\nFROM AI.GENERATE_TABLE(\n MODEL `mydataset.gemini_model`,\n (\n SELECT 'Q' AS prompt\n ),\n STRUCT('name STRING' AS output_schema)\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT AI.GENERATE_BOOL(MODEL `mydataset.gemini_model`, 'Is sky blue?')", + "formatted": "SELECT AI.GENERATE_BOOL(MODEL `mydataset.gemini_model`, 'Is sky blue?')" + }, + { + "dialect": "bigquery", + "sql": "SELECT AI.EMBED('hello')", + "formatted": "SELECT AI.EMBED('hello')" + }, + { + "dialect": "bigquery", + "sql": "SELECT AI.SIMILARITY('a', 'b')", + "formatted": "SELECT AI.SIMILARITY('a', 'b')" + }, + { + "dialect": "bigquery", + "sql": "SELECT AI.GENERATE('Write a haiku')", + "formatted": "SELECT AI.GENERATE('Write a haiku')" + }, + { + "dialect": "bigquery", + "sql": "SELECT JSON_OBJECT() AS json_data", + "formatted": "SELECT JSON_OBJECT() AS json_data" + }, + { + "dialect": "bigquery", + "sql": "SELECT JSON_OBJECT('foo', 10, 'bar', TRUE) AS json_data", + "formatted": "SELECT JSON_OBJECT('foo', 10, 'bar', TRUE) AS json_data" + }, + { + "dialect": "bigquery", + "sql": "SELECT JSON_OBJECT('foo', 10, 'bar', ['a', 'b']) AS json_data", + "formatted": "SELECT JSON_OBJECT('foo', 10, 'bar', ['a', 'b']) AS json_data" + }, + { + "dialect": "bigquery", + "sql": "SELECT JSON_OBJECT('a', 10, 'a', 'foo') AS json_data", + "formatted": "SELECT JSON_OBJECT('a', 10, 'a', 'foo') AS json_data" + }, + { + "dialect": "bigquery", + "sql": "SELECT JSON_OBJECT(['a', 'b'], [10, NULL]) AS json_data", + "formatted": "SELECT JSON_OBJECT(['a', 'b'], [10, NULL]) AS json_data" + }, + { + "dialect": "bigquery", + "sql": "SELECT JSON_OBJECT(['a', 'b'], [JSON '10', JSON '\"foo\"']) AS json_data", + "formatted": "SELECT JSON_OBJECT(['a', 'b'], [JSON '10', JSON '\"foo\"']) AS json_data" + }, + { + "dialect": "bigquery", + "sql": "SELECT JSON_OBJECT(['a', 'b'], [STRUCT(10 AS id, 'Red' AS color), STRUCT(20 AS id, 'Blue' AS color)]) AS json_data", + "formatted": "SELECT\n JSON_OBJECT(['a', 'b'], [\n STRUCT(10 AS id, 'Red' AS color),\n STRUCT(20 AS id, 'Blue' AS color)\n ]) AS json_data" + }, + { + "dialect": "bigquery", + "sql": "SELECT JSON_OBJECT(['a', 'b'], [TO_JSON(10), TO_JSON(['foo', 'bar'])]) AS json_data", + "formatted": "SELECT JSON_OBJECT(['a', 'b'], [TO_JSON(10), TO_JSON(['foo', 'bar'])]) AS json_data" + }, + { + "dialect": "bigquery", + "sql": "SELECT MOD((SELECT 1), 2)", + "formatted": "SELECT\n MOD((\n SELECT 1\n ), 2)" + }, + { + "dialect": "bigquery", + "sql": "SELECT STRUCT>([\"2023-01-17\"])", + "formatted": "SELECT STRUCT> ([\"2023-01-17\"])" + }, + { + "dialect": "bigquery", + "sql": "SELECT STRUCT((SELECT 'foo')).*", + "formatted": "SELECT\n STRUCT ((\n SELECT 'foo'\n )) .*" + }, + { + "dialect": "bigquery", + "sql": "SELECT RANGE(CAST('2022-12-01' AS DATE), CAST('2022-12-31' AS DATE))", + "formatted": "SELECT\n RANGE(\n CAST('2022-12-01' AS DATE),\n CAST('2022-12-31' AS DATE)\n )" + }, + { + "dialect": "bigquery", + "sql": "SELECT RANGE(NULL, CAST('2022-12-31' AS DATE))", + "formatted": "SELECT RANGE(NULL, CAST('2022-12-31' AS DATE))" + }, + { + "dialect": "bigquery", + "sql": "SELECT RANGE(CAST('2022-10-01 14:53:27' AS DATETIME), CAST('2022-10-01 16:00:00' AS DATETIME))", + "formatted": "SELECT\n RANGE(\n CAST('2022-10-01 14:53:27' AS DATETIME),\n CAST('2022-10-01 16:00:00' AS DATETIME)\n )" + }, + { + "dialect": "bigquery", + "sql": "SELECT RANGE(CAST('2022-10-01 14:53:27 America/Los_Angeles' AS TIMESTAMP), CAST('2022-10-01 16:00:00 America/Los_Angeles' AS TIMESTAMP))", + "formatted": "SELECT\n RANGE(\n CAST('2022-10-01 14:53:27 America/Los_Angeles' AS TIMESTAMP),\n CAST('2022-10-01 16:00:00 America/Los_Angeles' AS TIMESTAMP)\n )" + }, + { + "dialect": "bigquery", + "sql": "SELECT CAST('2026-03-24' AS STRING FORMAT ('YYYY'))", + "formatted": "SELECT CAST('2026-03-24' AS STRING FORMAT('YYYY'))" + }, + { + "dialect": "bigquery", + "sql": "SELECT CAST(date AS STRING FORMAT ('YYYY')) FROM (SELECT DATE('2026-03-24') AS date)", + "formatted": "SELECT CAST(date AS STRING FORMAT('YYYY'))\nFROM (\n SELECT DATE('2026-03-24') AS date\n)" + }, + { + "dialect": "bigquery", + "sql": "SELECT CAST(date AS STRING FORMAT ('YYYY-MM-DD'))", + "formatted": "SELECT CAST(date AS STRING FORMAT('YYYY-MM-DD'))" + }, + { + "dialect": "bigquery", + "sql": "SELECT CAST(timestamp AS STRING FORMAT ('YYYY-MM-DD') AT TIME ZONE 'UTC')", + "formatted": "SELECT CAST(timestamp AS STRING FORMAT('YYYY-MM-DD') AT TIME ZONE 'UTC')" + }, + { + "dialect": "bigquery", + "sql": "SELECT CAST(date AS TIMESTAMP FORMAT ('YYYY-MM-DD HH24:MI:SS'))", + "formatted": "SELECT CAST(date AS TIMESTAMP FORMAT('YYYY-MM-DD HH24:MI:SS'))" + }, + { + "dialect": "bigquery", + "sql": "SELECT a, GROUP_CONCAT(b) FROM table GROUP BY a", + "formatted": "SELECT\n a,\n GROUP_CONCAT(b)\nFROM table\nGROUP BY\n a" + }, + { + "dialect": "bigquery", + "sql": "SELECT 1 AS foo INNER UNION ALL SELECT 3 AS foo, 4 AS bar", + "formatted": "SELECT 1 AS foo\nINNER UNION ALL\nSELECT\n 3 AS foo,\n 4 AS bar" + }, + { + "dialect": "bigquery", + "sql": "SELECT 1 AS x UNION ALL CORRESPONDING SELECT 2 AS x", + "formatted": "SELECT 1 AS x\nUNION ALL CORRESPONDING\nSELECT 2 AS x" + }, + { + "dialect": "bigquery", + "sql": "SELECT 1 AS x UNION ALL CORRESPONDING BY (foo, bar) SELECT 2 AS x", + "formatted": "SELECT 1 AS x\nUNION ALL CORRESPONDING BY (foo, bar)\nSELECT 2 AS x" + }, + { + "dialect": "bigquery", + "sql": "SELECT 1 AS x LEFT UNION ALL CORRESPONDING SELECT 2 AS x", + "formatted": "SELECT 1 AS x\nLEFT UNION ALL CORRESPONDING\nSELECT 2 AS x" + }, + { + "dialect": "bigquery", + "sql": "SELECT 1 AS x UNION ALL STRICT CORRESPONDING SELECT 2 AS x", + "formatted": "SELECT 1 AS x\nUNION ALL STRICT CORRESPONDING\nSELECT 2 AS x" + }, + { + "dialect": "bigquery", + "sql": "SELECT 1 AS x UNION ALL STRICT CORRESPONDING BY (foo, bar) SELECT 2 AS x", + "formatted": "SELECT 1 AS x\nUNION ALL STRICT CORRESPONDING BY (foo, bar)\nSELECT 2 AS x" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM UNNEST(x) WITH OFFSET EXCEPT DISTINCT SELECT * FROM UNNEST(y) WITH OFFSET", + "formatted": "SELECT *\nFROM UNNEST(x) WITH OFFSET\nEXCEPT DISTINCT\nSELECT *\nFROM UNNEST(y) WITH OFFSET" + }, + { + "dialect": "bigquery", + "sql": "SELECT col FROM t WHERE _PARTITIONTIME BETWEEN a AND b", + "formatted": "SELECT col\nFROM t\nWHERE _PARTITIONTIME BETWEEN a AND b" + }, + { + "dialect": "bigquery", + "sql": "SELECT _DBT_MAX_PARTITION FROM t", + "formatted": "SELECT _DBT_MAX_PARTITION\nFROM t" + }, + { + "dialect": "bigquery", + "sql": "WITH cte(c) AS (SELECT * FROM t) SELECT * FROM cte", + "formatted": "WITH cte(c) AS (\n SELECT *\n FROM t\n)\n\nSELECT *\nFROM cte" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM t AS t(c1, c2)", + "formatted": "SELECT *\nFROM t AS t(c1, c2)" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM t AS t(c1, c2)", + "formatted": "SELECT *\nFROM t AS t(c1, c2)" + }, + { + "dialect": "bigquery", + "sql": "WITH foo AS (SELECT [1, 2, 3] AS array_col) SELECT array_col[offset] FROM foo CROSS JOIN UNNEST(array_col) WITH OFFSET AS offset", + "formatted": "WITH foo AS (\n SELECT [1, 2, 3] AS array_col\n)\n\nSELECT array_col[offset]\nFROM foo\nCROSS JOIN UNNEST(array_col) WITH OFFSET AS offset" + }, + { + "dialect": "bigquery", + "sql": "SELECT `db.t`.`c` FROM `db.t`", + "formatted": "SELECT `db.t`.`c`\nFROM `db.t`" + }, + { + "dialect": "bigquery", + "sql": "SELECT `p.d.t`.`c`.`f` FROM `p.d.t`", + "formatted": "SELECT `p.d.t`.`c`.`f`\nFROM `p.d.t`" + }, + { + "dialect": "bigquery", + "sql": "SELECT * FROM ML.TRANSLATE(MODEL `mydataset.mymodel`, (SELECT comment AS text_content FROM mydataset.mytable), STRUCT('translate_text' AS translate_mode, 'en' AS target_language_code))", + "formatted": "SELECT *\nFROM ML.TRANSLATE(\n MODEL `mydataset.mymodel`,\n (\n SELECT comment AS text_content\n FROM mydataset.mytable\n ),\n STRUCT(\n 'translate_text' AS translate_mode,\n 'en' AS target_language_code\n )\n)" + }, + { + "dialect": "bigquery", + "sql": "WITH t AS (SELECT '{\"x-y\": \"z\"}' AS c) SELECT JSON_EXTRACT(c, '$.x-y') FROM t", + "formatted": "WITH t AS (\n SELECT '{\"x-y\": \"z\"}' AS c\n)\n\nSELECT JSON_EXTRACT(c, '$.x-y')\nFROM t" + }, + { + "dialect": "databricks", + "sql": "SELECT CAST('a' AS STRING COLLATE UTF8_BINARY)", + "formatted": "SELECT CAST('a' AS STRING COLLATE UTF8_BINARY)" + }, + { + "dialect": "databricks", + "sql": "SELECT COSH(1.5)", + "formatted": "SELECT COSH(1.5)" + }, + { + "dialect": "databricks", + "sql": "SELECT BITMAP_BIT_POSITION(10)", + "formatted": "SELECT BITMAP_BIT_POSITION(10)" + }, + { + "dialect": "databricks", + "sql": "SELECT BITMAP_BUCKET_NUMBER(32769)", + "formatted": "SELECT BITMAP_BUCKET_NUMBER(32769)" + }, + { + "dialect": "databricks", + "sql": "SELECT BITMAP_CONSTRUCT_AGG(value)", + "formatted": "SELECT BITMAP_CONSTRUCT_AGG(value)" + }, + { + "dialect": "databricks", + "sql": "SELECT EXP(1)", + "formatted": "SELECT EXP(1)" + }, + { + "dialect": "databricks", + "sql": "SELECT MODE(category)", + "formatted": "SELECT MODE(category)" + }, + { + "dialect": "databricks", + "sql": "SELECT MODE(price, TRUE) AS deterministic_mode FROM products", + "formatted": "SELECT MODE(price, TRUE) AS deterministic_mode\nFROM products" + }, + { + "dialect": "databricks", + "sql": "SELECT CAST(NULL AS VOID)", + "formatted": "SELECT CAST(NULL AS VOID)" + }, + { + "dialect": "databricks", + "sql": "SELECT void FROM t", + "formatted": "SELECT void\nFROM t" + }, + { + "dialect": "databricks", + "sql": "SELECT * FROM stream", + "formatted": "SELECT *\nFROM stream" + }, + { + "dialect": "databricks", + "sql": "SELECT * FROM STREAM t", + "formatted": "SELECT *\nFROM STREAM t" + }, + { + "dialect": "databricks", + "sql": "SELECT t.current_time FROM t", + "formatted": "SELECT t.current_time\nFROM t" + }, + { + "dialect": "databricks", + "sql": "SELECT col1 FROM t CLUSTER BY col1", + "formatted": "SELECT col1\nFROM t\nCLUSTER BY\n col1" + }, + { + "dialect": "databricks", + "sql": "SELECT col1, col2 FROM t CLUSTER BY col1, col2", + "formatted": "SELECT\n col1,\n col2\nFROM t\nCLUSTER BY\n col1,\n col2" + }, + { + "dialect": "databricks", + "sql": "SELECT CAST('11 23:4:0' AS INTERVAL DAY TO HOUR)", + "formatted": "SELECT CAST('11 23:4:0' AS INTERVAL DAY TO HOUR)" + }, + { + "dialect": "databricks", + "sql": "SELECT CAST('11 23:4:0' AS INTERVAL DAY TO MINUTE)", + "formatted": "SELECT CAST('11 23:4:0' AS INTERVAL DAY TO MINUTE)" + }, + { + "dialect": "databricks", + "sql": "SELECT CAST('11 23:4:0' AS INTERVAL DAY TO SECOND)", + "formatted": "SELECT CAST('11 23:4:0' AS INTERVAL DAY TO SECOND)" + }, + { + "dialect": "databricks", + "sql": "SELECT CAST('23:00:00' AS INTERVAL HOUR TO MINUTE)", + "formatted": "SELECT CAST('23:00:00' AS INTERVAL HOUR TO MINUTE)" + }, + { + "dialect": "databricks", + "sql": "SELECT CAST('23:00:00' AS INTERVAL HOUR TO SECOND)", + "formatted": "SELECT CAST('23:00:00' AS INTERVAL HOUR TO SECOND)" + }, + { + "dialect": "databricks", + "sql": "SELECT CAST('23:00:00' AS INTERVAL MINUTE TO SECOND)", + "formatted": "SELECT CAST('23:00:00' AS INTERVAL MINUTE TO SECOND)" + }, + { + "dialect": "databricks", + "sql": "SELECT ${x} FROM ${y} WHERE ${z} > 1", + "formatted": "SELECT ${x}\nFROM ${y}\nWHERE ${z} > 1" + }, + { + "dialect": "databricks", + "sql": "SELECT PARSE_JSON('{}')", + "formatted": "SELECT PARSE_JSON('{}')" + }, + { + "dialect": "databricks", + "sql": "SELECT RANDSTR(123)", + "formatted": "SELECT RANDSTR(123)" + }, + { + "dialect": "databricks", + "sql": "SELECT RANDSTR(123, 456)", + "formatted": "SELECT RANDSTR(123, 456)" + }, + { + "dialect": "databricks", + "sql": "SELECT * FROM sales UNPIVOT INCLUDE NULLS (sales FOR quarter IN (q1 AS `Jan-Mar`))", + "formatted": "SELECT *\nFROM sales UNPIVOT INCLUDE NULLS (sales FOR quarter IN (q1 AS `Jan-Mar`))" + }, + { + "dialect": "databricks", + "sql": "SELECT * FROM sales UNPIVOT EXCLUDE NULLS (sales FOR quarter IN (q1 AS `Jan-Mar`))", + "formatted": "SELECT *\nFROM sales UNPIVOT EXCLUDE NULLS (sales FOR quarter IN (q1 AS `Jan-Mar`))" + }, + { + "dialect": "databricks", + "sql": "SELECT * FROM t1, t2", + "formatted": "SELECT *\nFROM t1, t2" + }, + { + "dialect": "databricks", + "sql": "SELECT TIMESTAMP '2025-04-29 18.47.18'::DATE", + "formatted": "SELECT TIMESTAMP '2025-04-29 18.47.18'::DATE" + }, + { + "dialect": "databricks", + "sql": "SELECT DATE_FORMAT(CAST(FROM_UTC_TIMESTAMP(foo, 'America/Los_Angeles') AS TIMESTAMP), 'yyyy-MM-dd HH:mm:ss') AS foo FROM t", + "formatted": "SELECT\n DATE_FORMAT(\n CAST(FROM_UTC_TIMESTAMP(foo, 'America/Los_Angeles') AS TIMESTAMP),\n 'yyyy-MM-dd HH:mm:ss'\n ) AS foo\nFROM t" + }, + { + "dialect": "databricks", + "sql": "SELECT r\"\\\\foo.bar\\\"", + "formatted": "SELECT r\"\\\\foo.bar\\\"" + }, + { + "dialect": "databricks", + "sql": "SELECT SUBSTRING_INDEX(str, delim, count)", + "formatted": "SELECT SUBSTRING_INDEX(str, delim, count)" + }, + { + "dialect": "databricks", + "sql": "SELECT SUBSTR('Spark' FROM 5 FOR 1)", + "formatted": "SELECT SUBSTR('Spark' FROM 5 FOR 1)" + }, + { + "dialect": "databricks", + "sql": "SELECT SUBSTR('Spark SQL', 5)", + "formatted": "SELECT SUBSTR('Spark SQL', 5)" + }, + { + "dialect": "databricks", + "sql": "SELECT SUBSTR(ENCODE('Spark SQL', 'utf-8'), 5)", + "formatted": "SELECT SUBSTR(ENCODE('Spark SQL', 'utf-8'), 5)" + }, + { + "dialect": "databricks", + "sql": "SELECT test, LISTAGG(email, '') AS Email FROM organizations GROUP BY test", + "formatted": "SELECT\n test,\n LISTAGG(email, '') AS Email\nFROM organizations\nGROUP BY\n test" + }, + { + "dialect": "databricks", + "sql": "WITH t AS (VALUES ('foo_val') AS t(foo1)) SELECT foo1 FROM t", + "formatted": "WITH t AS (\n VALUES ('foo_val') AS t(foo1)\n)\n\nSELECT foo1\nFROM t" + }, + { + "dialect": "databricks", + "sql": "SELECT ELT(2, 'foo', 'bar', 'baz') AS Result", + "formatted": "SELECT ELT(2, 'foo', 'bar', 'baz') AS Result" + }, + { + "dialect": "databricks", + "sql": "SELECT MAKE_INTERVAL(100, 11, 12, 13, 14, 14, 15)", + "formatted": "SELECT MAKE_INTERVAL(100, 11, 12, 13, 14, 14, 15)" + }, + { + "dialect": "databricks", + "sql": "SELECT name, GROUPING_ID() FROM customer GROUP BY ROLLUP (name)", + "formatted": "SELECT\n name,\n GROUPING_ID()\nFROM customer\nGROUP BY\n ROLLUP (\n name\n )" + }, + { + "dialect": "databricks", + "sql": "SELECT CURDATE()", + "formatted": "SELECT CURDATE()" + }, + { + "dialect": "databricks", + "sql": "SELECT c1:price, c1:price.foo, c1:price.bar[1]", + "formatted": "SELECT\n c1:price,\n c1:price.foo,\n c1:price.bar[1]" + }, + { + "dialect": "databricks", + "sql": "SELECT TRY_CAST(c1:price AS ARRAY)", + "formatted": "SELECT TRY_CAST(c1:price AS ARRAY)" + }, + { + "dialect": "databricks", + "sql": "SELECT TRY_CAST(c1:[\"foo bar\"][\"baz qux\"] AS ARRAY)", + "formatted": "SELECT TRY_CAST(c1:[\"foo bar\"][\"baz qux\"] AS ARRAY)" + }, + { + "dialect": "databricks", + "sql": "SELECT c1:item[1].price FROM VALUES ('{ \"item\": [ { \"model\" : \"basic\", \"price\" : 6.12 }, { \"model\" : \"medium\", \"price\" : 9.24 } ] }') AS T(c1)", + "formatted": "SELECT c1:item[1].price\nFROM VALUES ('{ \"item\": [ { \"model\" : \"basic\", \"price\" : 6.12 }, { \"model\" : \"medium\", \"price\" : 9.24 } ] }') AS T(c1)" + }, + { + "dialect": "databricks", + "sql": "SELECT c1:item[*].price FROM VALUES ('{ \"item\": [ { \"model\" : \"basic\", \"price\" : 6.12 }, { \"model\" : \"medium\", \"price\" : 9.24 } ] }') AS T(c1)", + "formatted": "SELECT c1:item[*].price\nFROM VALUES ('{ \"item\": [ { \"model\" : \"basic\", \"price\" : 6.12 }, { \"model\" : \"medium\", \"price\" : 9.24 } ] }') AS T(c1)" + }, + { + "dialect": "databricks", + "sql": "SELECT FROM_JSON(c1:item[*].price, 'ARRAY')[0] FROM VALUES ('{ \"item\": [ { \"model\" : \"basic\", \"price\" : 6.12 }, { \"model\" : \"medium\", \"price\" : 9.24 } ] }') AS T(c1)", + "formatted": "SELECT FROM_JSON(c1:item[*].price, 'ARRAY')[0]\nFROM VALUES ('{ \"item\": [ { \"model\" : \"basic\", \"price\" : 6.12 }, { \"model\" : \"medium\", \"price\" : 9.24 } ] }') AS T(c1)" + }, + { + "dialect": "databricks", + "sql": "SELECT INLINE(FROM_JSON(c1:item[*], 'ARRAY>')) FROM VALUES ('{ \"item\": [ { \"model\" : \"basic\", \"price\" : 6.12 }, { \"model\" : \"medium\", \"price\" : 9.24 } ] }') AS T(c1)", + "formatted": "SELECT INLINE(FROM_JSON(c1:item[*], 'ARRAY>'))\nFROM VALUES ('{ \"item\": [ { \"model\" : \"basic\", \"price\" : 6.12 }, { \"model\" : \"medium\", \"price\" : 9.24 } ] }') AS T(c1)" + }, + { + "dialect": "databricks", + "sql": "SELECT c1:['price'] FROM VALUES ('{ \"price\": 5 }') AS T(c1)", + "formatted": "SELECT c1:['price']\nFROM VALUES ('{ \"price\": 5 }') AS T(c1)" + }, + { + "dialect": "databricks", + "sql": "SELECT GET_JSON_OBJECT(c1, '$.price') FROM VALUES ('{ \"price\": 5 }') AS T(c1)", + "formatted": "SELECT GET_JSON_OBJECT(c1, '$.price')\nFROM VALUES ('{ \"price\": 5 }') AS T(c1)" + }, + { + "dialect": "databricks", + "sql": "SELECT GET_JSON_OBJECT(col, path_col)", + "formatted": "SELECT GET_JSON_OBJECT(col, path_col)" + }, + { + "dialect": "databricks", + "sql": "SELECT GET_JSON_OBJECT(col, CONCAT('$.', field_name))", + "formatted": "SELECT GET_JSON_OBJECT(col, CONCAT('$.', field_name))" + }, + { + "dialect": "databricks", + "sql": "SELECT GET_JSON_OBJECT(GET_JSON_OBJECT(col, '$[0]'), '$.a')", + "formatted": "SELECT GET_JSON_OBJECT(GET_JSON_OBJECT(col, '$[0]'), '$.a')" + }, + { + "dialect": "databricks", + "sql": "SELECT raw:`zip code`, raw:`fb:testid`, raw:store['bicycle'], raw:store[\"zip code\"]", + "formatted": "SELECT\n raw:`zip code`,\n raw:`fb:testid`,\n raw:store['bicycle'],\n raw:store[\"zip code\"]" + }, + { + "dialect": "databricks", + "sql": "SELECT DATE_ADD(MONTH, 1, '2020-01-01')", + "formatted": "SELECT DATE_ADD(MONTH, 1, '2020-01-01')" + }, + { + "dialect": "databricks", + "sql": "SELECT DATEADD(e, 24) FROM t", + "formatted": "SELECT DATEADD(e, 24)\nFROM t" + }, + { + "dialect": "databricks", + "sql": "SELECT '20'?::INTEGER", + "formatted": "SELECT '20'?::INTEGER" + }, + { + "dialect": "databricks", + "sql": "SELECT OVERLAY('Spark SQL', 'ANSI ', 7, 0)", + "formatted": "SELECT OVERLAY('Spark SQL', 'ANSI ', 7, 0)" + }, + { + "dialect": "databricks", + "sql": "SELECT OVERLAY('Spark SQL' PLACING 'CORE' FROM 7)", + "formatted": "SELECT OVERLAY('Spark SQL' PLACING 'CORE' FROM 7)" + }, + { + "dialect": "databricks", + "sql": "SELECT OVERLAY(ENCODE('Spark SQL', 'utf-8') PLACING ENCODE('_', 'utf-8') FROM 6)", + "formatted": "SELECT OVERLAY(ENCODE('Spark SQL', 'utf-8') PLACING ENCODE('_', 'utf-8') FROM 6)" + }, + { + "dialect": "databricks", + "sql": "SELECT OVERLAY('Spark SQL' PLACING 'ANSI ' FROM 7 FOR 0)", + "formatted": "SELECT OVERLAY('Spark SQL' PLACING 'ANSI ' FROM 7 FOR 0)" + }, + { + "dialect": "databricks", + "sql": "WITH t AS (SELECT '{\"x-y\": \"z\"}' AS c) SELECT get_json_object(c, '$.x-y') FROM t", + "formatted": "WITH t AS (\n SELECT '{\"x-y\": \"z\"}' AS c\n)\n\nSELECT get_json_object(c, '$.x-y')\nFROM t" + }, + { + "dialect": "duckdb", + "sql": "SELECT ([1,2,3])[:-:-1]", + "formatted": "SELECT\n (\n [1, 2, 3]\n )[:-:-1]" + }, + { + "dialect": "duckdb", + "sql": "SELECT INTERVAL '1 hour'::VARCHAR", + "formatted": "SELECT INTERVAL '1 hour'::VARCHAR" + }, + { + "dialect": "duckdb", + "sql": "SELECT str[0:1]", + "formatted": null + }, + { + "dialect": "duckdb", + "sql": "SELECT COSH(1.5)", + "formatted": "SELECT COSH(1.5)" + }, + { + "dialect": "duckdb", + "sql": "SELECT MODE(category)", + "formatted": "SELECT MODE(category)" + }, + { + "dialect": "duckdb", + "sql": "SELECT e'\\n'", + "formatted": "SELECT e'\\n'" + }, + { + "dialect": "duckdb", + "sql": "SELECT e'\\t'", + "formatted": "SELECT e'\\t'" + }, + { + "dialect": "duckdb", + "sql": "SELECT e'update table_name set a = \\'foo\\' where 1 = 0' AS x FROM tab", + "formatted": "SELECT e'update table_name set a = \\'foo\\' where 1 = 0' AS x\nFROM tab" + }, + { + "dialect": "duckdb", + "sql": "SELECT GET_BIT(CAST('0110010' AS BIT), 2)", + "formatted": "SELECT GET_BIT(CAST('0110010' AS BIT), 2)" + }, + { + "dialect": "duckdb", + "sql": "SELECT 1 WHERE x > $1", + "formatted": "SELECT 1\nWHERE x > $1" + }, + { + "dialect": "duckdb", + "sql": "SELECT 1 WHERE x > $name", + "formatted": "SELECT 1\nWHERE x > $name" + }, + { + "dialect": "duckdb", + "sql": "SELECT 1 AS a$b", + "formatted": "SELECT 1 AS a$b" + }, + { + "dialect": "duckdb", + "sql": "SELECT '{\"x\": 1}' -> c FROM t", + "formatted": "SELECT '{\"x\": 1}' -> c\nFROM t" + }, + { + "dialect": "duckdb", + "sql": "SELECT EXP(1)", + "formatted": "SELECT EXP(1)" + }, + { + "dialect": "duckdb", + "sql": "SELECT '{\"duck\": [1, 2, 3]}' -> '$.duck[#-1]'", + "formatted": "SELECT '{\"duck\": [1, 2, 3]}' -> '$.duck[#-1]'" + }, + { + "dialect": "duckdb", + "sql": "SELECT tbl.x*1e4+tbl.y FROM tbl", + "formatted": "SELECT tbl.x * 1e4 + tbl.y\nFROM tbl" + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_TRANSFORM([5, NULL, 6], (x, y) -> COALESCE(x, y, 0) + 1)", + "formatted": "SELECT LIST_TRANSFORM([5, NULL, 6], (x, y) -> COALESCE(x, y, 0) + 1)" + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_TRANSFORM([5, NULL, 6], LAMBDA x, y : COALESCE(x, y, 0) + 1)", + "formatted": null + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_TRANSFORM(LIST_FILTER([0, 1, 2, 3, 4, 5], LAMBDA x : x % 2 = 0), LAMBDA y : y * y)", + "formatted": null + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_TRANSFORM([5, NULL, 6], LAMBDA x : COALESCE(x, 0) + 1)", + "formatted": null + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_TRANSFORM(nbr, LAMBDA x : x + 1) FROM article AS a", + "formatted": null + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM my_ducklake.demo AT (VERSION => 2)", + "formatted": "SELECT *\nFROM my_ducklake.demo AT (VERSION => 2)" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM t1 AS a AT (VERSION => 3) JOIN t2 AS b AT (TIMESTAMP => NOW() - INTERVAL '1' WEEK) ON a.id = b.id", + "formatted": "SELECT *\nFROM t1 AS a AT (VERSION => 3)\nJOIN t2 AS b AT (TIMESTAMP => NOW() - INTERVAL '1' WEEK)\n ON a.id = b.id" + }, + { + "dialect": "duckdb", + "sql": "SELECT TO_BINARY('test')", + "formatted": "SELECT TO_BINARY('test')" + }, + { + "dialect": "duckdb", + "sql": "SELECT UUIDV7()", + "formatted": "SELECT UUIDV7()" + }, + { + "dialect": "duckdb", + "sql": "SELECT TRY(LOG(0))", + "formatted": "SELECT TRY(LOG(0))" + }, + { + "dialect": "duckdb", + "sql": "SELECT 1 AS x UNION ALL BY NAME SELECT 2 AS x", + "formatted": "SELECT 1 AS x\nUNION ALL BY NAME\nSELECT 2 AS x" + }, + { + "dialect": "duckdb", + "sql": "SELECT SUM(x) FILTER (x = 1)", + "formatted": "SELECT SUM(x) FILTER(x = 1)" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM GLOB(x)", + "formatted": "SELECT *\nFROM GLOB(x)" + }, + { + "dialect": "duckdb", + "sql": "SELECT MAP(['key1', 'key2', 'key3'], [10, 20, 30])", + "formatted": "SELECT\n MAP(\n ['key1', 'key2', 'key3'],\n [10, 20, 30]\n )" + }, + { + "dialect": "duckdb", + "sql": "SELECT MAP {'x': 1}", + "formatted": "SELECT MAP {'x': 1}" + }, + { + "dialect": "duckdb", + "sql": "SELECT (MAP {'x': 1})['x']", + "formatted": "SELECT\n (\n MAP {'x': 1}\n )['x']" + }, + { + "dialect": "duckdb", + "sql": "SELECT df1.*, df2.* FROM df1 POSITIONAL JOIN df2", + "formatted": "SELECT\n df1.*,\n df2.*\nFROM df1\nPOSITIONAL JOIN df2" + }, + { + "dialect": "duckdb", + "sql": "SELECT EPOCH_MS(10) AS t", + "formatted": "SELECT EPOCH_MS(10) AS t" + }, + { + "dialect": "duckdb", + "sql": "SELECT MAKE_TIMESTAMP(10) AS t", + "formatted": "SELECT MAKE_TIMESTAMP(10) AS t" + }, + { + "dialect": "duckdb", + "sql": "SELECT TO_TIMESTAMP(10) AS t", + "formatted": "SELECT TO_TIMESTAMP(10) AS t" + }, + { + "dialect": "duckdb", + "sql": "SELECT UNNEST(col, recursive := TRUE) FROM t", + "formatted": "SELECT UNNEST(col, recursive := TRUE)\nFROM t" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM foo ASOF LEFT JOIN bar ON a = b", + "formatted": "SELECT *\nFROM foo\nASOF LEFT JOIN bar\n ON a = b" + }, + { + "dialect": "duckdb", + "sql": "SELECT {'a': 1} AS x", + "formatted": "SELECT {'a': 1} AS x" + }, + { + "dialect": "duckdb", + "sql": "SELECT {'a': {'b': {'c': 1}}, 'd': {'e': 2}} AS x", + "formatted": "SELECT {'a': {'b': {'c': 1}}, 'd': {'e': 2}} AS x" + }, + { + "dialect": "duckdb", + "sql": "SELECT {'x': 1, 'y': 2, 'z': 3}", + "formatted": "SELECT {'x': 1, 'y': 2, 'z': 3}" + }, + { + "dialect": "duckdb", + "sql": "SELECT {'key1': 'string', 'key2': 1, 'key3': 12.345}", + "formatted": "SELECT {'key1': 'string', 'key2': 1, 'key3': 12.345}" + }, + { + "dialect": "duckdb", + "sql": "SELECT ROW(x, x + 1, y) FROM (SELECT 1 AS x, 'a' AS y)", + "formatted": "SELECT ROW(x, x + 1, y)\nFROM (\n SELECT\n 1 AS x,\n 'a' AS y\n)" + }, + { + "dialect": "duckdb", + "sql": "SELECT (x, x + 1, y) FROM (SELECT 1 AS x, 'a' AS y)", + "formatted": "SELECT (x, x + 1, y)\nFROM (\n SELECT\n 1 AS x,\n 'a' AS y\n)" + }, + { + "dialect": "duckdb", + "sql": "SELECT a.x FROM (SELECT {'x': 1, 'y': 2, 'z': 3} AS a)", + "formatted": "SELECT a.x\nFROM (\n SELECT {'x': 1, 'y': 2, 'z': 3} AS a\n)" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM t1 WHERE NOT EXISTS(FROM t2 WHERE t2.id = t1.id)", + "formatted": "SELECT *\nFROM t1\nWHERE\n NOT EXISTS(\n FROM t2\n WHERE t2.id = t1.id\n )" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM foo WHERE bar > $baz AND bla = $bob", + "formatted": "SELECT *\nFROM foo\nWHERE\n bar > $baz AND bla = $bob" + }, + { + "dialect": "duckdb", + "sql": "SELECT STAR(tbl, exclude := [foo])", + "formatted": "SELECT STAR(tbl, exclude := [foo])" + }, + { + "dialect": "duckdb", + "sql": "SELECT species, island, COUNT(*) FROM t GROUP BY GROUPING SETS (species), GROUPING SETS (island)", + "formatted": "SELECT\n species,\n island,\n COUNT(*)\nFROM t\nGROUP BY\n GROUPING SETS (\n species\n ),\n GROUPING SETS (\n island\n )" + }, + { + "dialect": "duckdb", + "sql": "SELECT species, island, COUNT(*) FROM t GROUP BY CUBE (species), CUBE (island)", + "formatted": "SELECT\n species,\n island,\n COUNT(*)\nFROM t\nGROUP BY\n CUBE (\n species\n ),\n CUBE (\n island\n )" + }, + { + "dialect": "duckdb", + "sql": "SELECT species, island, COUNT(*) FROM t GROUP BY ROLLUP (species), ROLLUP (island)", + "formatted": "SELECT\n species,\n island,\n COUNT(*)\nFROM t\nGROUP BY\n ROLLUP (\n species\n ),\n ROLLUP (\n island\n )" + }, + { + "dialect": "duckdb", + "sql": "SELECT '{ \"family\": \"anatidae\", \"species\": [ \"duck\", \"goose\", \"swan\", null ] }' ->> ['$.family', '$.species']", + "formatted": "SELECT '{ \"family\": \"anatidae\", \"species\": [ \"duck\", \"goose\", \"swan\", null ] }' ->> ['$.family', '$.species']" + }, + { + "dialect": "duckdb", + "sql": "SELECT $🦆$foo$🦆$", + "formatted": "SELECT $🦆$foo$🦆$" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM t PIVOT(FIRST(t) AS t, FOR quarter IN ('Q1', 'Q2'))", + "formatted": "SELECT *\nFROM t PIVOT(FIRST(t) AS t, FOR quarter IN ('Q1', 'Q2'))" + }, + { + "dialect": "duckdb", + "sql": "SELECT JSON_EXTRACT_STRING('{ \"family\": \"anatidae\", \"species\": [ \"duck\", \"goose\", \"swan\", null ] }', ['$.family', '$.species'])", + "formatted": "SELECT JSON_EXTRACT_STRING('{ \"family\": \"anatidae\", \"species\": [ \"duck\", \"goose\", \"swan\", null ] }', ['$.family', '$.species'])" + }, + { + "dialect": "duckdb", + "sql": "SELECT col FROM t WHERE JSON_EXTRACT_STRING(col, '$.id') NOT IN ('b')", + "formatted": "SELECT col\nFROM t\nWHERE JSON_EXTRACT_STRING(col, '$.id') NOT IN ('b')" + }, + { + "dialect": "duckdb", + "sql": "SELECT a, LOGICAL_OR(b) FROM foo GROUP BY a", + "formatted": "SELECT\n a,\n LOGICAL_OR(b)\nFROM foo\nGROUP BY\n a" + }, + { + "dialect": "duckdb", + "sql": "SELECT JSON_EXTRACT_STRING(c, '$.k1') = 'v1'", + "formatted": "SELECT JSON_EXTRACT_STRING(c, '$.k1') = 'v1'" + }, + { + "dialect": "duckdb", + "sql": "SELECT JSON_EXTRACT(c, '$.k1') = 'v1'", + "formatted": "SELECT JSON_EXTRACT(c, '$.k1') = 'v1'" + }, + { + "dialect": "duckdb", + "sql": "SELECT JSON_EXTRACT(c, '$[*].id')[0:2]", + "formatted": null + }, + { + "dialect": "duckdb", + "sql": "SELECT JSON_EXTRACT_STRING(c, '$[*].id')[0:2]", + "formatted": null + }, + { + "dialect": "duckdb", + "sql": "SELECT '{\"foo\": [1, 2, 3]}' -> 'foo' -> 0", + "formatted": "SELECT '{\"foo\": [1, 2, 3]}' -> 'foo' -> 0" + }, + { + "dialect": "duckdb", + "sql": "SELECT ($$hello)'world$$)", + "formatted": "SELECT\n (\n $$hello)'world$$\n )" + }, + { + "dialect": "duckdb", + "sql": "SELECT $$foo$$", + "formatted": "SELECT $$foo$$" + }, + { + "dialect": "duckdb", + "sql": "SELECT $tag$foo$tag$", + "formatted": "SELECT $tag$foo$tag$" + }, + { + "dialect": "duckdb", + "sql": "SELECT {'yes': 'duck', 'maybe': 'goose', 'huh': NULL, 'no': 'heron'}", + "formatted": "SELECT {'yes': 'duck', 'maybe': 'goose', 'huh': NULL, 'no': 'heron'}" + }, + { + "dialect": "duckdb", + "sql": "SELECT a['x space'] FROM (SELECT {'x space': 1, 'y': 2, 'z': 3} AS a)", + "formatted": "SELECT a['x space']\nFROM (\n SELECT {'x space': 1, 'y': 2, 'z': 3} AS a\n)" + }, + { + "dialect": "duckdb", + "sql": "WITH pivot_alias AS (PIVOT Cities ON Year USING SUM(Population) GROUP BY Country) SELECT * FROM pivot_alias", + "formatted": "WITH pivot_alias AS (\n PIVOT Cities ON Year USING SUM(Population) GROUP BY Country\n)\n\nSELECT *\nFROM pivot_alias" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM (PIVOT Cities ON Year USING SUM(Population) GROUP BY Country) AS pivot_alias", + "formatted": "SELECT *\nFROM (PIVOT Cities ON Year USING SUM(Population) GROUP BY Country) AS pivot_alias" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM cities PIVOT(SUM(population) FOR year IN (2000, 2010, 2020) GROUP BY country)", + "formatted": "SELECT *\nFROM cities PIVOT(SUM(population) FOR year IN (2000, 2010, 2020) GROUP BY country)" + }, + { + "dialect": "duckdb", + "sql": "SELECT schema_name, function_name, ROW_NUMBER() OVER my_window AS function_rank FROM DUCKDB_FUNCTIONS() WINDOW my_window AS (PARTITION BY schema_name ORDER BY function_name) QUALIFY ROW_NUMBER() OVER my_window < 3", + "formatted": "SELECT\n schema_name,\n function_name,\n ROW_NUMBER() OVER my_window AS function_rank\nFROM DUCKDB_FUNCTIONS()\nWINDOW\n my_window AS (PARTITION BY schema_name ORDER BY function_name)\nQUALIFY ROW_NUMBER() OVER my_window < 3" + }, + { + "dialect": "duckdb", + "sql": "SELECT SHA256('abc')", + "formatted": "SELECT SHA256('abc')" + }, + { + "dialect": "duckdb", + "sql": "SELECT SPLIT_PART('11.22.33', '.', 1)", + "formatted": "SELECT SPLIT_PART('11.22.33', '.', 1)" + }, + { + "dialect": "duckdb", + "sql": "SELECT NTH_VALUE(is_deleted, 2) OVER (PARTITION BY id) AS nth_is_deleted FROM my_table", + "formatted": "SELECT NTH_VALUE(is_deleted, 2) OVER (PARTITION BY id) AS nth_is_deleted\nFROM my_table" + }, + { + "dialect": "duckdb", + "sql": "SELECT NTH_VALUE(is_deleted, 2 IGNORE NULLS) OVER (PARTITION BY id) AS nth_is_deleted FROM my_table", + "formatted": "SELECT NTH_VALUE(is_deleted, 2 IGNORE NULLS) OVER (PARTITION BY id) AS nth_is_deleted\nFROM my_table" + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_MAX(values) FROM table1", + "formatted": "SELECT LIST_MAX(values)\nFROM table1" + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_MIN(values) FROM table1", + "formatted": "SELECT LIST_MIN(values)\nFROM table1" + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_SORT(x, 'ASC')", + "formatted": "SELECT LIST_SORT(x, 'ASC')" + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_SORT(x, 'DESC')", + "formatted": "SELECT LIST_SORT(x, 'DESC')" + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_SORT(x, 'ASC', 'NULLS FIRST')", + "formatted": "SELECT LIST_SORT(x, 'ASC', 'NULLS FIRST')" + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_SORT(x, 'ASC', 'NULLS LAST')", + "formatted": "SELECT LIST_SORT(x, 'ASC', 'NULLS LAST')" + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_SORT(x, 'DESC', 'NULLS FIRST')", + "formatted": "SELECT LIST_SORT(x, 'DESC', 'NULLS FIRST')" + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_SORT(x, 'DESC', 'NULLS LAST')", + "formatted": "SELECT LIST_SORT(x, 'DESC', 'NULLS LAST')" + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_SORT(x, 'DE' || 'SC')", + "formatted": "SELECT LIST_SORT(x, 'DE' || 'SC')" + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_SORT(x, 'DESC', 'NULLS' || ' FIRST')", + "formatted": "SELECT LIST_SORT(x, 'DESC', 'NULLS' || ' FIRST')" + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_SORT(x, 'DE' || 'SC', 'NULLS' || ' FIRST')", + "formatted": "SELECT\n LIST_SORT(\n x,\n 'DE' || 'SC',\n 'NULLS' || ' FIRST'\n )" + }, + { + "dialect": "duckdb", + "sql": "SELECT PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY y DESC) FROM t", + "formatted": "SELECT PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY y DESC)\nFROM t" + }, + { + "dialect": "duckdb", + "sql": "SELECT PERCENTILE_DISC(0.25) WITHIN GROUP (ORDER BY y DESC) FROM t", + "formatted": "SELECT PERCENTILE_DISC(0.25) WITHIN GROUP (ORDER BY y DESC)\nFROM t" + }, + { + "dialect": "duckdb", + "sql": "SELECT REGEXP_EXTRACT(a, 'pattern', 0)", + "formatted": "SELECT REGEXP_EXTRACT(a, 'pattern', 0)" + }, + { + "dialect": "duckdb", + "sql": "SELECT REGEXP_EXTRACT(a, 'pattern', 0, 'i')", + "formatted": "SELECT REGEXP_EXTRACT(a, 'pattern', 0, 'i')" + }, + { + "dialect": "duckdb", + "sql": "SELECT REGEXP_EXTRACT(a, 'pattern', 1, 'i')", + "formatted": "SELECT REGEXP_EXTRACT(a, 'pattern', 1, 'i')" + }, + { + "dialect": "duckdb", + "sql": "SELECT REGEXP_EXTRACT_ALL(s, 'pattern', 0)", + "formatted": "SELECT REGEXP_EXTRACT_ALL(s, 'pattern', 0)" + }, + { + "dialect": "duckdb", + "sql": "SELECT REGEXP_EXTRACT_ALL(s, 'pattern', 1)", + "formatted": "SELECT REGEXP_EXTRACT_ALL(s, 'pattern', 1)" + }, + { + "dialect": "duckdb", + "sql": "SELECT REGEXP_EXTRACT_ALL(s, 'pattern', 0, 'i')", + "formatted": "SELECT REGEXP_EXTRACT_ALL(s, 'pattern', 0, 'i')" + }, + { + "dialect": "duckdb", + "sql": "SELECT REGEXP_EXTRACT_ALL(s, 'pattern', 1, 'im')", + "formatted": "SELECT REGEXP_EXTRACT_ALL(s, 'pattern', 1, 'im')" + }, + { + "dialect": "duckdb", + "sql": "SELECT REGEXP_EXTRACT_ALL(s, 'pattern', 0)[2:]", + "formatted": "SELECT REGEXP_EXTRACT_ALL(s, 'pattern', 0)[2:]" + }, + { + "dialect": "duckdb", + "sql": "SELECT ISNAN(x)", + "formatted": "SELECT ISNAN(x)" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM RANGE(1, 5, 10)", + "formatted": "SELECT *\nFROM RANGE(1, 5, 10)" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM GENERATE_SERIES(2, 13, 4)", + "formatted": "SELECT *\nFROM GENERATE_SERIES(2, 13, 4)" + }, + { + "dialect": "duckdb", + "sql": "SELECT i FROM RANGE(5) AS _(i) ORDER BY i ASC", + "formatted": "SELECT i\nFROM RANGE(5) AS _(i)\nORDER BY\n i ASC" + }, + { + "dialect": "duckdb", + "sql": "SELECT i FROM GENERATE_SERIES(12) AS _(i) ORDER BY i ASC", + "formatted": "SELECT i\nFROM GENERATE_SERIES(12) AS _(i)\nORDER BY\n i ASC" + }, + { + "dialect": "duckdb", + "sql": "SELECT LENGTH(foo)", + "formatted": "SELECT LENGTH(foo)" + }, + { + "dialect": "duckdb", + "sql": "SELECT ARRAY[1, 2, 3]", + "formatted": "SELECT ARRAY[1, 2, 3]" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM (DESCRIBE t)", + "formatted": "SELECT *\nFROM (DESCRIBE t)" + }, + { + "dialect": "duckdb", + "sql": "SELECT UNNEST([*COLUMNS('alias_.*')]) AS column_name", + "formatted": "SELECT UNNEST([*COLUMNS('alias_.*')]) AS column_name" + }, + { + "dialect": "duckdb", + "sql": "SELECT COALESCE(*COLUMNS(*)) FROM (SELECT NULL, 2, 3) AS t(a, b, c)", + "formatted": "SELECT COALESCE(*COLUMNS(*))\nFROM (\n SELECT\n NULL,\n 2,\n 3\n) AS t(a, b, c)" + }, + { + "dialect": "duckdb", + "sql": "SELECT id, STRUCT_PACK(*COLUMNS('m\\d')) AS measurements FROM many_measurements", + "formatted": "SELECT\n id,\n STRUCT_PACK(*COLUMNS('m\\d')) AS measurements\nFROM many_measurements" + }, + { + "dialect": "duckdb", + "sql": "SELECT COLUMNS(c -> c LIKE '%num%') FROM numbers", + "formatted": "SELECT COLUMNS(c -> c LIKE '%num%')\nFROM numbers" + }, + { + "dialect": "duckdb", + "sql": "SELECT MIN(COLUMNS(* REPLACE (number + id AS number))), COUNT(COLUMNS(* EXCLUDE (number))) FROM numbers", + "formatted": "SELECT\n MIN(COLUMNS(* REPLACE (number + id AS number))),\n COUNT(COLUMNS(* EXCLUDE (number)))\nFROM numbers" + }, + { + "dialect": "duckdb", + "sql": "SELECT COLUMNS(*) + COLUMNS(*) FROM numbers", + "formatted": "SELECT COLUMNS(*) + COLUMNS(*)\nFROM numbers" + }, + { + "dialect": "duckdb", + "sql": "SELECT COLUMNS('(id|numbers?)') FROM numbers", + "formatted": "SELECT COLUMNS('(id|numbers?)')\nFROM numbers" + }, + { + "dialect": "duckdb", + "sql": "SELECT COALESCE(COLUMNS(['a', 'b', 'c'])) AS result FROM (SELECT NULL AS a, 42 AS b, TRUE AS c)", + "formatted": "SELECT COALESCE(COLUMNS(['a', 'b', 'c'])) AS result\nFROM (\n SELECT\n NULL AS a,\n 42 AS b,\n TRUE AS c\n)" + }, + { + "dialect": "duckdb", + "sql": "SELECT COALESCE(*COLUMNS(['a', 'b', 'c'])) AS result FROM (SELECT NULL AS a, 42 AS b, TRUE AS c)", + "formatted": "SELECT COALESCE(*COLUMNS(['a', 'b', 'c'])) AS result\nFROM (\n SELECT\n NULL AS a,\n 42 AS b,\n TRUE AS c\n)" + }, + { + "dialect": "duckdb", + "sql": "SELECT DATE_ADD(CAST('2020-01-01' AS DATE), INTERVAL 1 DAY)", + "formatted": "SELECT DATE_ADD(CAST('2020-01-01' AS DATE), INTERVAL 1 DAY)" + }, + { + "dialect": "duckdb", + "sql": "SELECT #2, #1 FROM (VALUES (1, 'foo'))", + "formatted": "SELECT\n #2,\n #1\nFROM (VALUES (1, 'foo'))" + }, + { + "dialect": "duckdb", + "sql": "SELECT #2 AS a, #1 AS b FROM (VALUES (1, 'foo'))", + "formatted": "SELECT\n #2 AS a,\n #1 AS b\nFROM (VALUES (1, 'foo'))" + }, + { + "dialect": "duckdb", + "sql": "SELECT CUME_DIST( ORDER BY foo) OVER (ORDER BY 1) FROM (SELECT 1 AS foo)", + "formatted": "SELECT CUME_DIST(ORDER BY foo) OVER (ORDER BY 1)\nFROM (\n SELECT 1 AS foo\n)" + }, + { + "dialect": "duckdb", + "sql": "SELECT NTILE(1 ORDER BY foo) OVER (ORDER BY 1) FROM (SELECT 1 AS foo)", + "formatted": "SELECT NTILE(1 ORDER BY foo) OVER (ORDER BY 1)\nFROM (\n SELECT 1 AS foo\n)" + }, + { + "dialect": "duckdb", + "sql": "SELECT RANK( ORDER BY foo) OVER (ORDER BY 1) FROM (SELECT 1 AS foo)", + "formatted": "SELECT RANK(ORDER BY foo) OVER (ORDER BY 1)\nFROM (\n SELECT 1 AS foo\n)" + }, + { + "dialect": "duckdb", + "sql": "SELECT PERCENT_RANK( ORDER BY foo) OVER (ORDER BY 1) FROM (SELECT 1 AS foo)", + "formatted": "SELECT PERCENT_RANK(ORDER BY foo) OVER (ORDER BY 1)\nFROM (\n SELECT 1 AS foo\n)" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM t LIMIT 10 PERCENT", + "formatted": "SELECT *\nFROM t\nLIMIT 10 PERCENT" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM t LIMIT 10%", + "formatted": "SELECT *\nFROM t\nLIMIT 10%", + "previously_formatted": "SELECT\n *\nFROM t\nLIMIT 10 %" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM t LIMIT 10 PERCENT OFFSET 1", + "formatted": "SELECT *\nFROM t\nLIMIT 10 PERCENT\nOFFSET 1" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM t LIMIT 10% OFFSET 1", + "formatted": "SELECT *\nFROM t\nLIMIT 10%\nOFFSET 1", + "previously_formatted": "SELECT\n *\nFROM t\nLIMIT 10 %\nOFFSET 1" + }, + { + "dialect": "duckdb", + "sql": "SELECT CAST(ROW(1, 2) AS ROW(a INTEGER, b INTEGER))", + "formatted": "SELECT CAST(ROW(1, 2) AS ROW(a INTEGER, b INTEGER))" + }, + { + "dialect": "duckdb", + "sql": "SELECT row", + "formatted": "SELECT row" + }, + { + "dialect": "duckdb", + "sql": "SELECT TRY_STRPTIME('2013-04-28T20:57:01.123456789+07:00', '%Y-%m-%dT%H:%M:%S.%n%z')", + "formatted": "SELECT TRY_STRPTIME('2013-04-28T20:57:01.123456789+07:00', '%Y-%m-%dT%H:%M:%S.%n%z')" + }, + { + "dialect": "duckdb", + "sql": "SELECT REPLACE('apple pie', 'pie', 'cobbler') AS result", + "formatted": "SELECT REPLACE('apple pie', 'pie', 'cobbler') AS result" + }, + { + "dialect": "duckdb", + "sql": "SELECT REPLACE(CAST(CAST('apple pie' AS BLOB) AS TEXT), CAST(CAST('pie' AS BLOB) AS TEXT), CAST(CAST('cobbler' AS BLOB) AS TEXT)) AS result", + "formatted": "SELECT\n REPLACE(\n CAST(CAST('apple pie' AS BLOB) AS TEXT),\n CAST(CAST('pie' AS BLOB) AS TEXT),\n CAST(CAST('cobbler' AS BLOB) AS TEXT)\n ) AS result" + }, + { + "dialect": "duckdb", + "sql": "SELECT TRIM('***apple***', '*') AS result", + "formatted": "SELECT TRIM('***apple***', '*') AS result" + }, + { + "dialect": "duckdb", + "sql": "SELECT CAST(TRIM(CAST(CAST('***apple***' AS BLOB) AS TEXT), CAST(CAST('*' AS BLOB) AS TEXT)) AS BLOB) AS result", + "formatted": "SELECT CAST(TRIM(CAST(CAST('***apple***' AS BLOB) AS TEXT), CAST(CAST('*' AS BLOB) AS TEXT)) AS BLOB) AS result" + }, + { + "dialect": "duckdb", + "sql": "SELECT GREATEST(1.0, 2.5, NULL, 3.7)", + "formatted": "SELECT GREATEST(1.0, 2.5, NULL, 3.7)" + }, + { + "dialect": "duckdb", + "sql": "SELECT MAKE_DATE(DATE_PART(['year', 'month', 'day'], CURRENT_DATE))", + "formatted": "SELECT\n MAKE_DATE(\n DATE_PART(\n ['year', 'month', 'day'],\n CURRENT_DATE\n )\n )" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM t PIVOT(SUM(y) FOR foo IN y_enum)", + "formatted": "SELECT *\nFROM t PIVOT(SUM(y) FOR foo IN y_enum)" + }, + { + "dialect": "duckdb", + "sql": "SELECT 20_000 AS literal", + "formatted": "SELECT 20_000 AS literal" + }, + { + "dialect": "duckdb", + "sql": "SELECT 1_2E+1_0::FLOAT", + "formatted": "SELECT 1_2E+1_0::FLOAT" + }, + { + "dialect": "duckdb", + "sql": "SELECT [1, 2, 3][1 + 1:LENGTH([1, 2, 3]) + -1]", + "formatted": null + }, + { + "dialect": "duckdb", + "sql": "SELECT TODAY()", + "formatted": "SELECT TODAY()" + }, + { + "dialect": "duckdb", + "sql": "SELECT GET_CURRENT_TIME()", + "formatted": "SELECT GET_CURRENT_TIME()" + }, + { + "dialect": "duckdb", + "sql": "SELECT SUM(x) OVER (ORDER BY x GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW) FROM t", + "formatted": "SELECT SUM(x) OVER (ORDER BY x GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW)\nFROM t" + }, + { + "dialect": "duckdb", + "sql": "SELECT LAST_VALUE(x ORDER BY x IGNORE NULLS) OVER (ORDER BY x) FROM t", + "formatted": "SELECT LAST_VALUE(x ORDER BY x IGNORE NULLS) OVER (ORDER BY x)\nFROM t" + }, + { + "dialect": "duckdb", + "sql": "SELECT LAST_VALUE(x ORDER BY x RESPECT NULLS) OVER (ORDER BY x) FROM t", + "formatted": "SELECT LAST_VALUE(x ORDER BY x RESPECT NULLS) OVER (ORDER BY x)\nFROM t" + }, + { + "dialect": "duckdb", + "sql": "SELECT CURRENT_DATE", + "formatted": "SELECT CURRENT_DATE" + }, + { + "dialect": "duckdb", + "sql": "SELECT CURRENT_TIMESTAMP", + "formatted": "SELECT CURRENT_TIMESTAMP" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM tbl USING SAMPLE 5", + "formatted": "SELECT *\nFROM tbl\nUSING SAMPLE 5" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM tbl USING SAMPLE 10%", + "formatted": "SELECT *\nFROM tbl\nUSING SAMPLE 10%" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM tbl USING SAMPLE 10 PERCENT (bernoulli)", + "formatted": "SELECT *\nFROM tbl\nUSING SAMPLE 10 PERCENT (bernoulli)" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM tbl USING SAMPLE reservoir(50 ROWS) REPEATABLE (100)", + "formatted": "SELECT *\nFROM tbl\nUSING SAMPLE reservoir (50 ROWS) REPEATABLE (100)" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM tbl USING SAMPLE 10% (system, 377)", + "formatted": "SELECT *\nFROM tbl\nUSING SAMPLE 10% (system, 377)" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM tbl TABLESAMPLE RESERVOIR(20%), tbl2 WHERE tbl.i=tbl2.i", + "formatted": "SELECT *\nFROM tbl TABLESAMPLE RESERVOIR (20%), tbl2\nWHERE tbl.i = tbl2.i", + "previously_formatted": "SELECT\n *\nFROM tbl TABLESAMPLE RESERVOIR (20 %), tbl2\nWHERE\n tbl.i = tbl2.i" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM tbl, tbl2 WHERE tbl.i=tbl2.i USING SAMPLE RESERVOIR(20%)", + "formatted": "SELECT *\nFROM tbl, tbl2\nWHERE tbl.i = tbl2.i\nUSING SAMPLE RESERVOIR (20%)", + "previously_formatted": "SELECT\n *\nFROM tbl, tbl2\nWHERE\n tbl.i = tbl2.i\nUSING SAMPLE RESERVOIR (20 %)" + }, + { + "dialect": "duckdb", + "sql": "SELECT x::INT[3][3]", + "formatted": null + }, + { + "dialect": "duckdb", + "sql": "SELECT ARRAY[[[1]]]::INT[1][1][1]", + "formatted": null + }, + { + "dialect": "duckdb", + "sql": "WITH unpivot_alias AS (UNPIVOT monthly_sales ON COLUMNS(* EXCLUDE (empid, dept)) INTO NAME month VALUE sales) SELECT * FROM unpivot_alias", + "formatted": "WITH unpivot_alias AS (\n UNPIVOT monthly_sales ON COLUMNS(* EXCLUDE (empid, dept)) INTO NAME month VALUE sales\n)\n\nSELECT *\nFROM unpivot_alias" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM (UNPIVOT monthly_sales ON COLUMNS(* EXCLUDE (empid, dept)) INTO NAME month VALUE sales) AS unpivot_alias", + "formatted": "SELECT *\nFROM (UNPIVOT monthly_sales ON COLUMNS(* EXCLUDE (empid, dept)) INTO NAME month VALUE sales) AS unpivot_alias" + }, + { + "dialect": "duckdb", + "sql": "WITH cities(country, name, year, population) AS (SELECT 'NL', 'Amsterdam', 2000, 1005 UNION ALL SELECT 'US', 'Seattle', 2020, 738) PIVOT cities ON year USING SUM(population)", + "formatted": "WITH cities(country, name, year, population) AS (\n SELECT\n 'NL',\n 'Amsterdam',\n 2000,\n 1005\n UNION ALL\n SELECT\n 'US',\n 'Seattle',\n 2020,\n 738\n)\n\nPIVOT cities ON year USING SUM(population)" + }, + { + "dialect": "duckdb", + "sql": "WITH t1 AS (FROM (FROM t2 SELECT foo1, foo2)) FROM t1", + "formatted": "WITH t1 AS (\n FROM (FROM t2\n SELECT\n foo1,\n foo2\n )\n)\n\nFROM t1" + }, + { + "dialect": "duckdb", + "sql": "SELECT foo: 1", + "formatted": "SELECT foo:1" + }, + { + "dialect": "duckdb", + "sql": "SELECT foo: bar", + "formatted": "SELECT foo:bar" + }, + { + "dialect": "duckdb", + "sql": "SELECT foo: t.col FROM t", + "formatted": "SELECT foo:t.col\nFROM t" + }, + { + "dialect": "duckdb", + "sql": "SELECT \"foo\" /* bla */: 1", + "formatted": "SELECT\n \"foo\" /* bla */:1" + }, + { + "dialect": "duckdb", + "sql": "SELECT \"foo\": 1 /* bla */", + "formatted": "SELECT \"foo\":1 /* bla */" + }, + { + "dialect": "duckdb", + "sql": "SELECT \"foo\": /* bla */ 1", + "formatted": "SELECT\n \"foo\": /* bla */1" + }, + { + "dialect": "duckdb", + "sql": "SELECT \"foo\": /* bla */ 1 /* foo */", + "formatted": "SELECT\n \"foo\": /* bla */1 /* foo */" + }, + { + "dialect": "duckdb", + "sql": "SELECT \"foo\": 1", + "formatted": "SELECT \"foo\":1" + }, + { + "dialect": "duckdb", + "sql": "SELECT foo: 1, bar: 2, baz: 3", + "formatted": "SELECT\n foo:1,\n bar:2,\n baz:3" + }, + { + "dialect": "duckdb", + "sql": "SELECT e: 1 + 2, f: len('asdf'), s: (SELECT 42)", + "formatted": "SELECT\n e:1 + 2,\n f:len('asdf'), s:\n (\n SELECT 42\n )" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM foo: bar", + "formatted": "SELECT *\nFROM foo:bar" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM foo: c.db.tbl", + "formatted": "SELECT *\nFROM foo:c.db.tbl" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM foo /* bla */: bar", + "formatted": "SELECT *\nFROM foo /* bla */:bar" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM foo /* bla */: bar /* baz */", + "formatted": "SELECT *\nFROM foo /* bla */:bar /* baz */" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM foo /* bla */: /* baz */ bar /* boo */", + "formatted": "SELECT *\nFROM foo /* bla */: /* baz */bar /* boo */" + }, + { + "dialect": "duckdb", + "sql": "SELECT * FROM r: range(10), v: (VALUES (42)), s: (FROM range(10))", + "formatted": "SELECT *\nFROM r:range(10), v:(VALUES (42)), s:(\n FROM RANGE(10)\n)" + }, + { + "dialect": "duckdb", + "sql": "\n SELECT\n l_returnflag,\n l_linestatus,\n sum_qty: sum(l_quantity),\n sum_base_price: sum(l_extendedprice),\n sum_disc_price: sum(l_extendedprice * (1-l_discount)),\n sum_charge: sum(l_extendedprice * (1-l_discount) * (1+l_tax)),\n avg_qty: avg(l_quantity),\n avg_price: avg(l_extendedprice),\n avg_disc: avg(l_discount),\n count_order: count(*)\n ", + "formatted": "SELECT\n l_returnflag,\n l_linestatus,\n sum_qty:sum(l_quantity),\n sum_base_price:sum(l_extendedprice),\n sum_disc_price:sum(l_extendedprice\n * (\n 1 - l_discount\n )),\n sum_charge:sum(l_extendedprice\n * (\n 1 - l_discount\n )\n * (\n 1 + l_tax\n )),\n avg_qty:avg(l_quantity),\n avg_price:avg(l_extendedprice),\n avg_disc:avg(l_discount),\n count_order:count(*)" + }, + { + "dialect": "duckdb", + "sql": "SELECT @col FROM t", + "formatted": "SELECT @col\nFROM t" + }, + { + "dialect": "duckdb", + "sql": "SELECT @col + 1 FROM t", + "formatted": "SELECT @col + 1\nFROM t" + }, + { + "dialect": "duckdb", + "sql": "SELECT (@col) + 1 FROM t", + "formatted": "SELECT\n (@col\n )\n + 1\nFROM t" + }, + { + "dialect": "duckdb", + "sql": "SELECT @(-1)", + "formatted": "SELECT\n @(\n -1\n )" + }, + { + "dialect": "duckdb", + "sql": "SELECT @(-1) + 1", + "formatted": "SELECT\n @(\n -1\n )\n + 1" + }, + { + "dialect": "duckdb", + "sql": "SELECT (@-1) + 1", + "formatted": "SELECT\n (@-1\n )\n + 1" + }, + { + "dialect": "duckdb", + "sql": "WITH RECURSIVE tbl(a, b) USING KEY (a) AS (SELECT a, b FROM (VALUES (1, 3), (2, 4)) AS t(a, b) UNION SELECT a + 1, b FROM tbl WHERE a < 3) SELECT * FROM tbl", + "formatted": "WITH RECURSIVE tbl(a, b) USING KEY (a) AS (\n SELECT\n a,\n b\n FROM (VALUES (1, 3), (2, 4)) AS t(a, b)\n UNION\n SELECT\n a + 1,\n b\n FROM tbl\n WHERE a < 3\n)\nSELECT *\nFROM tbl" + }, + { + "dialect": "duckdb", + "sql": "WITH RECURSIVE tbl(a, b) USING KEY (a, b) AS (SELECT a, b FROM (VALUES (1, 3), (2, 4)) AS t(a, b) UNION SELECT a + 1, b FROM tbl WHERE a < 3) SELECT * FROM tbl", + "formatted": "WITH RECURSIVE tbl(a, b) USING KEY (a, b) AS (\n SELECT\n a,\n b\n FROM (VALUES (1, 3), (2, 4)) AS t(a, b)\n UNION\n SELECT\n a + 1,\n b\n FROM tbl\n WHERE a < 3\n)\nSELECT *\nFROM tbl" + }, + { + "dialect": "duckdb", + "sql": "WITH _data AS (SELECT [{'a': 1, 'b': 2}, {'a': 2, 'b': 3}] AS col) SELECT t.col['b'] FROM _data, UNNEST(_data.col) AS t(col) WHERE t.col['a'] = 1", + "formatted": "WITH _data AS (\n SELECT\n [\n {'a': 1, 'b': 2},\n {'a': 2, 'b': 3}\n ] AS col\n)\n\nSELECT t.col['b']\nFROM _data, UNNEST(_data.col) AS t(col)\nWHERE t.col['a'] = 1" + }, + { + "dialect": "duckdb", + "sql": "SELECT [4, 5, 6] AS l, [x FOR x, i IN l IF i = 2] AS filtered", + "formatted": "SELECT\n [4, 5, 6] AS l,\n [x FOR x, i IN l IF i = 2] AS filtered" + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_VALUE(1)[i]", + "formatted": "SELECT LIST_VALUE(1)[i]" + }, + { + "dialect": "duckdb", + "sql": "SELECT LIST_APPLY(RANGE(1, 4), i -> {'f1': LIST_VALUE(1, 2, 3)[i], 'f2': LIST_VALUE(1, 2, 3)[i]})", + "formatted": "SELECT\n LIST_APPLY(\n RANGE(1, 4),\n i -> {'f1': LIST_VALUE(1, 2, 3)[i], 'f2': LIST_VALUE(1, 2, 3)[i]}\n )" + }, + { + "dialect": "duckdb", + "sql": "SELECT UNNEST([1, 2])", + "formatted": "SELECT UNNEST([1, 2])" + }, + { + "dialect": "duckdb", + "sql": "SELECT file[:256] FROM GLOB('*')", + "formatted": null + }, + { + "dialect": "duckdb", + "sql": "SELECT file[256] FROM GLOB('*')", + "formatted": "SELECT file[256]\nFROM GLOB('*')" + }, + { + "dialect": "postgres", + "sql": "WITH t AS (SELECT 1 AS \"null\") SELECT t.null FROM t", + "formatted": "WITH t AS (\n SELECT 1 AS \"null\"\n)\n\nSELECT t.null\nFROM t" + }, + { + "dialect": "postgres", + "sql": "WITH t AS (SELECT 1 AS \"true\") SELECT t.true FROM t", + "formatted": "WITH t AS (\n SELECT 1 AS \"true\"\n)\n\nSELECT t.true\nFROM t" + }, + { + "dialect": "postgres", + "sql": "SELECT '%' SIMILAR TO '^%' ESCAPE '^'", + "formatted": "SELECT '%' SIMILAR TO '^%' ESCAPE '^'" + }, + { + "dialect": "postgres", + "sql": "SELECT GET_BIT(CAST(44 AS BIT(10)), 6)", + "formatted": "SELECT GET_BIT(CAST(44 AS BIT(10)), 6)" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM t GROUP BY ROLLUP (a || '^' || b)", + "formatted": "SELECT *\nFROM t\nGROUP BY\n ROLLUP (\n a || '^' || b\n )" + }, + { + "dialect": "postgres", + "sql": "SELECT COSH(1.5)", + "formatted": "SELECT COSH(1.5)" + }, + { + "dialect": "postgres", + "sql": "SELECT EXP(1)", + "formatted": "SELECT EXP(1)" + }, + { + "dialect": "postgres", + "sql": "SELECT MODE() WITHIN GROUP (ORDER BY status DESC) AS most_common FROM orders", + "formatted": "SELECT MODE() WITHIN GROUP (ORDER BY status DESC) AS most_common\nFROM orders" + }, + { + "dialect": "postgres", + "sql": "SELECT ST_DISTANCE(gg1, gg2, FALSE) AS sphere_dist", + "formatted": "SELECT ST_DISTANCE(gg1, gg2, FALSE) AS sphere_dist" + }, + { + "dialect": "postgres", + "sql": "SELECT EXTRACT(QUARTER FROM CAST('2025-04-26' AS DATE))", + "formatted": "SELECT EXTRACT(QUARTER FROM CAST('2025-04-26' AS DATE))" + }, + { + "dialect": "postgres", + "sql": "SELECT DATE_TRUNC('QUARTER', CAST('2025-04-26' AS DATE))", + "formatted": "SELECT DATE_TRUNC('QUARTER', CAST('2025-04-26' AS DATE))" + }, + { + "dialect": "postgres", + "sql": "SELECT x FROM t WHERE CAST($1 AS TEXT) = 'ok'", + "formatted": "SELECT x\nFROM t\nWHERE CAST($1 AS TEXT) = 'ok'" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM t TABLESAMPLE SYSTEM (50) REPEATABLE (55)", + "formatted": "SELECT *\nFROM t TABLESAMPLE SYSTEM (50) REPEATABLE (55)" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM events WHERE doc @? '$.a[*] ? (@ > 2)'", + "formatted": "SELECT *\nFROM events\nWHERE doc @? '$.a[*] ? (@ > 2)'" + }, + { + "dialect": "postgres", + "sql": "SELECT ARRAY[1, 2, 3]", + "formatted": "SELECT ARRAY[1, 2, 3]" + }, + { + "dialect": "postgres", + "sql": "SELECT ARRAY(SELECT 1)", + "formatted": "SELECT\n ARRAY(SELECT\n 1)" + }, + { + "dialect": "postgres", + "sql": "SELECT CASE WHEN SUBSTRING('abcdefg') IN ('ab') THEN 1 ELSE 0 END", + "formatted": "SELECT\n CASE\n WHEN SUBSTRING('abcdefg') IN ('ab')\n THEN 1\n ELSE 0\n END" + }, + { + "dialect": "postgres", + "sql": "SELECT e'\\xDEADBEEF'", + "formatted": "SELECT e'\\xDEADBEEF'" + }, + { + "dialect": "postgres", + "sql": "SELECT CAST(e'\\176' AS BYTEA)", + "formatted": "SELECT CAST(e'\\176' AS BYTEA)" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM x WHERE SUBSTRING('Thomas' FROM '...$') IN ('mas')", + "formatted": "SELECT *\nFROM x\nWHERE SUBSTRING('Thomas' FROM '...$') IN ('mas')" + }, + { + "dialect": "postgres", + "sql": "SELECT TRIM(' X' FROM ' XXX ')", + "formatted": "SELECT TRIM(' X' FROM ' XXX ')" + }, + { + "dialect": "postgres", + "sql": "SELECT TRIM(LEADING 'bla' FROM ' XXX ' COLLATE utf8_bin)", + "formatted": "SELECT TRIM(LEADING 'bla' FROM ' XXX ' COLLATE utf8_bin)" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM JSON_TO_RECORDSET(z) AS y(\"rank\" INT)", + "formatted": "SELECT *\nFROM JSON_TO_RECORDSET(z) AS y(\"rank\" INT)" + }, + { + "dialect": "postgres", + "sql": "SELECT ~x", + "formatted": "SELECT ~x" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM r CROSS JOIN LATERAL UNNEST(ARRAY[1]) AS s(location)", + "formatted": "SELECT *\nFROM r\nCROSS JOIN LATERAL UNNEST(ARRAY[1]) AS s(location)" + }, + { + "dialect": "postgres", + "sql": "SELECT CURRENT_SCHEMA", + "formatted": "SELECT CURRENT_SCHEMA" + }, + { + "dialect": "postgres", + "sql": "SELECT CURRENT_USER", + "formatted": "SELECT CURRENT_USER" + }, + { + "dialect": "postgres", + "sql": "SELECT CURRENT_ROLE", + "formatted": "SELECT CURRENT_ROLE" + }, + { + "dialect": "postgres", + "sql": "SELECT VERSION()", + "formatted": "SELECT VERSION()" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM ONLY t1", + "formatted": "SELECT *\nFROM ONLY t1" + }, + { + "dialect": "postgres", + "sql": "SELECT INTERVAL '-1 MONTH'", + "formatted": "SELECT INTERVAL '-1 MONTH'" + }, + { + "dialect": "postgres", + "sql": "SELECT INTERVAL '4.1 DAY'", + "formatted": "SELECT INTERVAL '4.1 DAY'" + }, + { + "dialect": "postgres", + "sql": "SELECT INTERVAL '3.14159 HOUR'", + "formatted": "SELECT INTERVAL '3.14159 HOUR'" + }, + { + "dialect": "postgres", + "sql": "SELECT INTERVAL '2.5 MONTH'", + "formatted": "SELECT INTERVAL '2.5 MONTH'" + }, + { + "dialect": "postgres", + "sql": "SELECT INTERVAL '-10.75 MINUTE'", + "formatted": "SELECT INTERVAL '-10.75 MINUTE'" + }, + { + "dialect": "postgres", + "sql": "SELECT INTERVAL '0.123456789 SECOND'", + "formatted": "SELECT INTERVAL '0.123456789 SECOND'" + }, + { + "dialect": "postgres", + "sql": "SELECT date_col - INTERVAL '30' FROM t", + "formatted": "SELECT date_col - INTERVAL '30'\nFROM t" + }, + { + "dialect": "postgres", + "sql": "SELECT date_col - INTERVAL '1' AS one_second_later", + "formatted": "SELECT date_col - INTERVAL '1' AS one_second_later" + }, + { + "dialect": "postgres", + "sql": "SELECT date_col - INTERVAL '30' DAY FROM t", + "formatted": null + }, + { + "dialect": "postgres", + "sql": "SELECT date_col - INTERVAL '1' HOUR AS one_hour_later", + "formatted": null + }, + { + "dialect": "postgres", + "sql": "SELECT SUM(x) OVER (PARTITION BY y ORDER BY interval ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) - SUM(x) OVER (PARTITION BY y ORDER BY interval ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS total", + "formatted": "SELECT SUM(x) OVER (PARTITION BY y ORDER BY interval ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) - SUM(x) OVER (PARTITION BY y ORDER BY interval ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS total" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM test_data, LATERAL JSONB_ARRAY_ELEMENTS(data) WITH ORDINALITY AS elem(value, ordinality)", + "formatted": "SELECT *\nFROM test_data, LATERAL JSONB_ARRAY_ELEMENTS(data) WITH ORDINALITY AS elem(value, ordinality)" + }, + { + "dialect": "postgres", + "sql": "SELECT id, name FROM xml_data AS t, XMLTABLE('/root/user' PASSING t.xml COLUMNS id INT PATH '@id', name TEXT PATH 'name/text()') AS x", + "formatted": "SELECT\n id,\n name\nFROM xml_data AS t, XMLTABLE(\n '/root/user'\n PASSING\n t.xml\n COLUMNS\n id INT PATH '@id',\n name TEXT PATH 'name/text()'\n) AS x" + }, + { + "dialect": "postgres", + "sql": "SELECT id, value FROM xml_content AS t, XMLTABLE(XMLNAMESPACES('http://example.com/ns1' AS ns1, 'http://example.com/ns2' AS ns2), '/root/data' PASSING t.xml COLUMNS id INT PATH '@ns1:id', value TEXT PATH 'ns2:value/text()') AS x", + "formatted": null + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM t WHERE some_column >= CURRENT_DATE + INTERVAL '1 day 1 hour' AND some_another_column IS TRUE", + "formatted": "SELECT *\nFROM t\nWHERE\n some_column >= CURRENT_DATE + INTERVAL '1 day 1 hour' AND some_another_column IS TRUE" + }, + { + "dialect": "postgres", + "sql": "WITH t1 AS MATERIALIZED (SELECT 1), t2 AS NOT MATERIALIZED (SELECT 2) SELECT * FROM t1, t2", + "formatted": "WITH t1 AS MATERIALIZED (\n SELECT 1\n),\n\nt2 AS NOT MATERIALIZED (\n SELECT 2\n)\n\nSELECT *\nFROM t1, t2" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM JSON_ARRAY_ELEMENTS('[1,true, [2,false]]') WITH ORDINALITY", + "formatted": "SELECT *\nFROM JSON_ARRAY_ELEMENTS('[1,true, [2,false]]') WITH ORDINALITY" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM JSON_ARRAY_ELEMENTS('[1,true, [2,false]]') WITH ORDINALITY AS kv_json", + "formatted": "SELECT *\nFROM JSON_ARRAY_ELEMENTS('[1,true, [2,false]]') WITH ORDINALITY AS kv_json" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM JSON_ARRAY_ELEMENTS('[1,true, [2,false]]') WITH ORDINALITY AS kv_json(a, b)", + "formatted": "SELECT *\nFROM JSON_ARRAY_ELEMENTS('[1,true, [2,false]]') WITH ORDINALITY AS kv_json(a, b)" + }, + { + "dialect": "postgres", + "sql": "SELECT SUM(x) OVER a, SUM(y) OVER b FROM c WINDOW a AS (PARTITION BY d), b AS (PARTITION BY e)", + "formatted": "SELECT\n SUM(x) OVER a,\n SUM(y) OVER b\nFROM c\nWINDOW\n a AS (PARTITION BY d),\n b AS (PARTITION BY e)" + }, + { + "dialect": "postgres", + "sql": "SELECT CASE WHEN SUBSTRING('abcdefg' FROM 1) IN ('ab') THEN 1 ELSE 0 END", + "formatted": "SELECT\n CASE\n WHEN SUBSTRING('abcdefg' FROM 1) IN ('ab')\n THEN 1\n ELSE 0\n END" + }, + { + "dialect": "postgres", + "sql": "SELECT CASE WHEN SUBSTRING('abcdefg' FROM 1 FOR 2) IN ('ab') THEN 1 ELSE 0 END", + "formatted": "SELECT\n CASE\n WHEN SUBSTRING('abcdefg' FROM 1 FOR 2) IN ('ab')\n THEN 1\n ELSE 0\n END" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM \"x\" WHERE SUBSTRING(\"x\".\"foo\" FROM 1 FOR 2) IN ('mas')", + "formatted": "SELECT *\nFROM \"x\"\nWHERE SUBSTRING(\"x\".\"foo\" FROM 1 FOR 2) IN ('mas')" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM x WHERE SUBSTRING('Thomas' FROM '%#\"o_a#\"_' FOR '#') IN ('mas')", + "formatted": "SELECT *\nFROM x\nWHERE SUBSTRING('Thomas' FROM '%#\"o_a#\"_' FOR '#') IN ('mas')" + }, + { + "dialect": "postgres", + "sql": "SELECT SUBSTRING('bla' + 'foo' || 'bar' FROM 3 - 1 + 5 FOR 4 + SOME_FUNC(arg1, arg2))", + "formatted": "SELECT SUBSTRING('bla' + 'foo' || 'bar' FROM 3 - 1 + 5 FOR 4 + SOME_FUNC(arg1, arg2))" + }, + { + "dialect": "postgres", + "sql": "SELECT TO_TIMESTAMP(1284352323.5), TO_TIMESTAMP('05 Dec 2000', 'DD Mon YYYY')", + "formatted": "SELECT\n TO_TIMESTAMP(1284352323.5),\n TO_TIMESTAMP('05 Dec 2000', 'DD Mon YYYY')" + }, + { + "dialect": "postgres", + "sql": "SELECT TO_TIMESTAMP('05 Dec 2000 10:00 AM', 'DD Mon YYYY HH:MI AM')", + "formatted": "SELECT TO_TIMESTAMP('05 Dec 2000 10:00 AM', 'DD Mon YYYY HH:MI AM')" + }, + { + "dialect": "postgres", + "sql": "SELECT TO_TIMESTAMP('05 Dec 2000 10:00 PM', 'DD Mon YYYY HH:MI PM')", + "formatted": "SELECT TO_TIMESTAMP('05 Dec 2000 10:00 PM', 'DD Mon YYYY HH:MI PM')" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM foo, LATERAL (SELECT * FROM bar WHERE bar.id = foo.bar_id) AS ss", + "formatted": "SELECT *\nFROM foo, LATERAL (\n SELECT *\n FROM bar\n WHERE bar.id = foo.bar_id\n) AS ss" + }, + { + "dialect": "postgres", + "sql": "SELECT c.oid, n.nspname, c.relname FROM pg_catalog.pg_class AS c LEFT JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace WHERE c.relname OPERATOR(pg_catalog.~) '^(courses)$' COLLATE pg_catalog.default AND pg_catalog.PG_TABLE_IS_VISIBLE(c.oid) ORDER BY 2, 3", + "formatted": "SELECT\n c.oid,\n n.nspname,\n c.relname\nFROM pg_catalog.pg_class AS c\nLEFT JOIN pg_catalog.pg_namespace AS n\n ON n.oid = c.relnamespace\nWHERE\n c.relname OPERATOR(pg_catalog.~) '^(courses)$' COLLATE pg_catalog.default AND pg_catalog.PG_TABLE_IS_VISIBLE(c.oid)\nORDER BY\n 2,\n 3" + }, + { + "dialect": "postgres", + "sql": "SELECT date_add(current_date, interval '7' day)", + "formatted": null + }, + { + "dialect": "postgres", + "sql": "SELECT e'foo \\' bar'", + "formatted": "SELECT e'foo \\' bar'" + }, + { + "dialect": "postgres", + "sql": "SELECT e'\\n'", + "formatted": "SELECT e'\\n'" + }, + { + "dialect": "postgres", + "sql": "SELECT e'\\t'", + "formatted": "SELECT e'\\t'" + }, + { + "dialect": "postgres", + "sql": "SELECT e'update table_name set a = \\'foo\\' where 1 = 0' AS x FROM tab", + "formatted": "SELECT e'update table_name set a = \\'foo\\' where 1 = 0' AS x\nFROM tab" + }, + { + "dialect": "postgres", + "sql": "select count() OVER(partition by a order by a range offset preceding exclude current row)", + "formatted": "SELECT COUNT() OVER (PARTITION BY a ORDER BY a range offset preceding exclude CURRENT ROW)" + }, + { + "dialect": "postgres", + "sql": "SELECT SUBSTRING('Thomas' FOR 3 FROM 2)", + "formatted": "SELECT SUBSTRING('Thomas' FOR 3 FROM 2)" + }, + { + "dialect": "postgres", + "sql": "SELECT DATE_PART('isodow'::varchar(6), current_date)", + "formatted": "SELECT DATE_PART('isodow'::varchar(6), CURRENT_DATE)" + }, + { + "dialect": "postgres", + "sql": "SELECT $$a$$", + "formatted": "SELECT $$a$$" + }, + { + "dialect": "postgres", + "sql": "SELECT $$Dianne's order$$", + "formatted": "SELECT $$Dianne's order$$" + }, + { + "dialect": "postgres", + "sql": "SELECT $$The price is $9.95$$ AS msg", + "formatted": "SELECT $$The price is $9.95$$ AS msg" + }, + { + "dialect": "postgres", + "sql": "SELECT 'testa 1' NOT LIKE ALL (ARRAY['testa%', 'testb%'])", + "formatted": "SELECT\n 'testa 1' NOT LIKE ALL (\n ARRAY['testa%', 'testb%']\n )" + }, + { + "dialect": "postgres", + "sql": "SELECT 'testa 1' NOT LIKE ANY(ARRAY['testa%', 'testb%'])", + "formatted": "SELECT\n 'testa 1' NOT LIKE ANY(\n ARRAY['testa%', 'testb%']\n )" + }, + { + "dialect": "postgres", + "sql": "SELECT 'testa 1' NOT ILIKE ALL (ARRAY['testa%', 'testb%'])", + "formatted": "SELECT\n 'testa 1' NOT ILIKE ALL (\n ARRAY['testa%', 'testb%']\n )" + }, + { + "dialect": "postgres", + "sql": "SELECT 'testa 1' NOT ILIKE ANY(ARRAY['testa%', 'testb%'])", + "formatted": "SELECT\n 'testa 1' NOT ILIKE ANY(\n ARRAY['testa%', 'testb%']\n )" + }, + { + "dialect": "postgres", + "sql": "SELECT NOT 'testa 1' LIKE ALL (ARRAY['testa%', 'testb%'])", + "formatted": "SELECT\n NOT 'testa 1' LIKE ALL (\n ARRAY['testa%', 'testb%']\n )" + }, + { + "dialect": "postgres", + "sql": "SELECT NOT ('testa 1' LIKE ALL (ARRAY['testa%', 'testb%']))", + "formatted": "SELECT\n NOT (\n 'testa 1' LIKE ALL (\n ARRAY['testa%', 'testb%']\n )\n )" + }, + { + "dialect": "postgres", + "sql": "SELECT to_timestamp(123)::time without time zone", + "formatted": "SELECT to_timestamp(123)::time without time zone" + }, + { + "dialect": "postgres", + "sql": "SELECT SUM(x) OVER (PARTITION BY a ORDER BY d ROWS 1 PRECEDING)", + "formatted": "SELECT SUM(x) OVER (PARTITION BY a ORDER BY d ROWS 1 PRECEDING)" + }, + { + "dialect": "postgres", + "sql": "SELECT SUBSTRING(2022::CHAR(4) || LPAD(3::CHAR(2), 2, '0') FROM 3 FOR 4)", + "formatted": "SELECT SUBSTRING(2022::CHAR(4) || LPAD(3::CHAR(2), 2, '0') FROM 3 FOR 4)" + }, + { + "dialect": "postgres", + "sql": "SELECT m.name FROM manufacturers AS m LEFT JOIN LATERAL GET_PRODUCT_NAMES(m.id) pname ON TRUE WHERE pname IS NULL", + "formatted": "SELECT m.name\nFROM manufacturers AS m\nLEFT JOIN LATERAL GET_PRODUCT_NAMES(m.id) pname\n ON TRUE\nWHERE pname IS NULL", + "previously_formatted": "SELECT\n m.name\nFROM manufacturers AS m\nLEFT JOIN LATERAL GET_PRODUCT_NAMES(m.id) AS pname\n ON TRUE\nWHERE\n pname IS NULL" + }, + { + "dialect": "postgres", + "sql": "SELECT p1.id, p2.id, v1, v2 FROM polygons AS p1, polygons AS p2, LATERAL VERTICES(p1.poly) v1, LATERAL VERTICES(p2.poly) v2 WHERE (v1 <-> v2) < 10 AND p1.id <> p2.id", + "formatted": "SELECT\n p1.id,\n p2.id,\n v1,\n v2\nFROM polygons AS p1, polygons AS p2, LATERAL VERTICES(p1.poly) v1, LATERAL VERTICES(p2.poly) v2\nWHERE\n (\n v1 <-> v2\n ) < 10 AND p1.id <> p2.id", + "previously_formatted": "SELECT\n p1.id,\n p2.id,\n v1,\n v2\nFROM polygons AS p1, polygons AS p2, LATERAL VERTICES(p1.poly) AS v1, LATERAL VERTICES(p2.poly) AS v2\nWHERE\n (\n v1 <-> v2\n ) < 10 AND p1.id <> p2.id" + }, + { + "dialect": "postgres", + "sql": "SELECT id, email, CAST(deleted AS TEXT) FROM users WHERE deleted NOTNULL", + "formatted": "SELECT\n id,\n email,\n CAST(deleted AS TEXT)\nFROM users\nWHERE deleted NOTNULL" + }, + { + "dialect": "postgres", + "sql": "SELECT id, email, CAST(deleted AS TEXT) FROM users WHERE NOT deleted ISNULL", + "formatted": "SELECT\n id,\n email,\n CAST(deleted AS TEXT)\nFROM users\nWHERE NOT deleted ISNULL" + }, + { + "dialect": "postgres", + "sql": "SELECT JSON_ARRAY_ELEMENTS((foo->'sections')::JSON) AS sections", + "formatted": "SELECT\n JSON_ARRAY_ELEMENTS((\n foo -> 'sections'\n )::JSON) AS sections" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM t1*", + "formatted": "SELECT *\nFROM t1*" + }, + { + "dialect": "postgres", + "sql": "SELECT SUBSTRING('afafa' for 1)", + "formatted": "SELECT SUBSTRING('afafa' FOR 1)" + }, + { + "dialect": "postgres", + "sql": "\n WITH\n json_data AS (SELECT '{\"field_id\": [1, 2, 3]}'::JSON AS data),\n field_ids AS (SELECT 'field_id' AS field_id)\n\n SELECT\n JSON_ARRAY_ELEMENTS(json_data.data -> field_ids.field_id) AS element\n FROM json_data, field_ids\n ", + "formatted": "WITH json_data AS (\n SELECT '{\"field_id\": [1, 2, 3]}'::JSON AS data\n),\n\nfield_ids AS (\n SELECT 'field_id' AS field_id\n)\n\nSELECT JSON_ARRAY_ELEMENTS(json_data.data -> field_ids.field_id) AS element\nFROM json_data, field_ids" + }, + { + "dialect": "postgres", + "sql": "SELECT TRIM(LEADING ' XXX ' COLLATE \"de_DE\")", + "formatted": "SELECT TRIM(LEADING ' XXX ' COLLATE \"de_DE\")" + }, + { + "dialect": "postgres", + "sql": "SELECT TRIM(TRAILING ' XXX ' COLLATE \"de_DE\")", + "formatted": "SELECT TRIM(TRAILING ' XXX ' COLLATE \"de_DE\")" + }, + { + "dialect": "postgres", + "sql": "SELECT js, js IS JSON AS \"json?\", js IS JSON VALUE AS \"scalar?\", js IS JSON SCALAR AS \"scalar?\", js IS JSON OBJECT AS \"object?\", js IS JSON ARRAY AS \"array?\" FROM t", + "formatted": "SELECT\n js,\n js IS JSON AS \"json?\",\n js IS JSON VALUE AS \"scalar?\",\n js IS JSON SCALAR AS \"scalar?\",\n js IS JSON OBJECT AS \"object?\",\n js IS JSON ARRAY AS \"array?\"\nFROM t" + }, + { + "dialect": "postgres", + "sql": "SELECT js, js IS JSON ARRAY WITH UNIQUE KEYS AS \"array w. UK?\", js IS JSON ARRAY WITHOUT UNIQUE KEYS AS \"array w/o UK?\", js IS JSON ARRAY UNIQUE KEYS AS \"array w UK 2?\" FROM t", + "formatted": "SELECT\n js,\n js IS JSON ARRAY WITH UNIQUE KEYS AS \"array w. UK?\",\n js IS JSON ARRAY WITHOUT UNIQUE KEYS AS \"array w/o UK?\",\n js IS JSON ARRAY UNIQUE KEYS AS \"array w UK 2?\"\nFROM t" + }, + { + "dialect": "postgres", + "sql": "SELECT 1 FROM ((VALUES (1)) AS vals(id) LEFT OUTER JOIN tbl ON vals.id = tbl.id)", + "formatted": "SELECT 1\nFROM ((VALUES (1)) AS vals(id)\nLEFT OUTER JOIN tbl\n ON vals.id = tbl.id)" + }, + { + "dialect": "postgres", + "sql": "SELECT OVERLAY(a PLACING b FROM 1)", + "formatted": "SELECT OVERLAY(a PLACING b FROM 1)" + }, + { + "dialect": "postgres", + "sql": "SELECT OVERLAY(a PLACING b FROM 1 FOR 1)", + "formatted": "SELECT OVERLAY(a PLACING b FROM 1 FOR 1)" + }, + { + "dialect": "postgres", + "sql": "SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY a) FILTER(WHERE CAST(b AS BOOLEAN)) AS mean_value FROM (VALUES (0, 't')) AS fake_data(a, b)", + "formatted": "SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY a) FILTER(WHERE CAST(b AS BOOLEAN)) AS mean_value\nFROM (VALUES (0, 't')) AS fake_data(a, b)" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM foo WHERE id = %s", + "formatted": "SELECT *\nFROM foo\nWHERE id = %s" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM foo WHERE id = %(id_param)s", + "formatted": "SELECT *\nFROM foo\nWHERE id = %(id_param)s" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM foo WHERE id = ?", + "formatted": "SELECT *\nFROM foo\nWHERE id = ?" + }, + { + "dialect": "postgres", + "sql": "SELECT '{\"a\":1, \"b\":2, \"c\":3}'::jsonb ?| array['b', 'c']", + "formatted": "SELECT '{\"a\":1, \"b\":2, \"c\":3}'::jsonb ?| ARRAY['b', 'c']" + }, + { + "dialect": "postgres", + "sql": "SELECT '[\"a\", \"b\"]'::jsonb ?& array['a', 'b']", + "formatted": "SELECT '[\"a\", \"b\"]'::jsonb ?& ARRAY['a', 'b']" + }, + { + "dialect": "postgres", + "sql": "SELECT '[\"a\", {\"b\":1}]'::jsonb #- '{1,b}'", + "formatted": "SELECT '[\"a\", {\"b\":1}]'::jsonb #- '{1,b}'" + }, + { + "dialect": "postgres", + "sql": "SELECT JSON_AGG(DISTINCT name) FROM users", + "formatted": "SELECT JSON_AGG(DISTINCT name)\nFROM users" + }, + { + "dialect": "postgres", + "sql": "SELECT JSON_AGG(c1 ORDER BY c1) FROM (VALUES ('c'), ('b'), ('a')) AS t(c1)", + "formatted": "SELECT JSON_AGG(c1 ORDER BY c1)\nFROM (VALUES ('c'), ('b'), ('a')) AS t(c1)" + }, + { + "dialect": "postgres", + "sql": "SELECT JSON_AGG(DISTINCT c1 ORDER BY c1) FROM (VALUES ('c'), ('b'), ('a')) AS t(c1)", + "formatted": "SELECT JSON_AGG(DISTINCT c1 ORDER BY c1)\nFROM (VALUES ('c'), ('b'), ('a')) AS t(c1)" + }, + { + "dialect": "postgres", + "sql": "SELECT NUMRANGE(1.1, 2.2) -|- NUMRANGE(2.2, 3.3)", + "formatted": "SELECT NUMRANGE(1.1, 2.2) -|- NUMRANGE(2.2, 3.3)" + }, + { + "dialect": "postgres", + "sql": "SELECT SLOPE(point '(4,4)', point '(0,0)')", + "formatted": "SELECT SLOPE(point '(4,4)', point '(0,0)')" + }, + { + "dialect": "postgres", + "sql": "SELECT MLEAST(VARIADIC ARRAY[10, -1, 5, 4.4])", + "formatted": "SELECT MLEAST(VARIADIC ARRAY[10, -1, 5, 4.4])" + }, + { + "dialect": "postgres", + "sql": "SELECT MLEAST(VARIADIC ARRAY[]::numeric[])", + "formatted": null + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM schema_name.table_name st WHERE JSON_EXTRACT_PATH_TEXT((st.data)::json, variadic array['test'::text]) = 'test'::text", + "formatted": "SELECT *\nFROM schema_name.table_name st\nWHERE\n JSON_EXTRACT_PATH_TEXT(\n (\n st.data\n )::json,\n VARIADIC ARRAY['test'::text]\n ) = 'test'::text", + "previously_formatted": "SELECT\n *\nFROM schema_name.table_name AS st\nWHERE\n JSON_EXTRACT_PATH_TEXT(\n (\n st.data\n )::json,\n VARIADIC ARRAY['test'::text]\n ) = 'test'::text" + }, + { + "dialect": "postgres", + "sql": "WITH t(c) AS (SELECT 1) SELECT * INTO UNLOGGED foo FROM (SELECT c AS c FROM t) AS temp", + "formatted": "WITH t(c) AS (\n SELECT 1\n)\n\nSELECT *\nINTO UNLOGGED foo\nFROM (\n SELECT c AS c\n FROM t\n) AS temp" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM UNNEST(ARRAY[1, 2], ARRAY['foo', 'bar', 'baz']) AS x(a, b)", + "formatted": "SELECT *\nFROM UNNEST(ARRAY[1, 2], ARRAY['foo', 'bar', 'baz']) AS x(a, b)" + }, + { + "dialect": "postgres", + "sql": "SELECT TRIM(ARRAY_TO_STRING(ARRAY(SELECT val FROM UNNEST(ARRAY['a', 'b']) WITH ORDINALITY AS u(val, rn)), ' '))", + "formatted": "SELECT\n TRIM(ARRAY_TO_STRING(\n ARRAY(\n SELECT val\n FROM UNNEST(ARRAY['a', 'b']) WITH ORDINALITY AS u(val, rn)\n ),\n ' '\n ))" + }, + { + "dialect": "postgres", + "sql": "SELECT a, LOGICAL_OR(b) FROM table GROUP BY a", + "formatted": "SELECT\n a,\n LOGICAL_OR(b)\nFROM table\nGROUP BY\n a" + }, + { + "dialect": "postgres", + "sql": "SELECT CONCAT('abcde', 2, NULL, 22)", + "formatted": "SELECT CONCAT('abcde', 2, NULL, 22)" + }, + { + "dialect": "postgres", + "sql": "SELECT u & 5 FROM t", + "formatted": "SELECT u & 5\nFROM t" + }, + { + "dialect": "postgres", + "sql": "SELECT (U&'\\FE01' || 'Test literal') AS label FROM data", + "formatted": "SELECT\n (\n U&'\\FE01' || 'Test literal'\n ) AS label\nFROM data" + }, + { + "dialect": "postgres", + "sql": "SELECT U&'d!0061t!+000061' UESCAPE '!' AS label", + "formatted": "SELECT U&'d!0061t!+000061' UESCAPE '!' AS label" + }, + { + "dialect": "postgres", + "sql": "SELECT u&'\\0441\\043B\\043E\\043D'", + "formatted": "SELECT u&'\\0441\\043B\\043E\\043D'" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM ROWS FROM (FUNC1(col1, col2))", + "formatted": "SELECT *\nFROM ROWS FROM (FUNC1(col1, col2))" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM ROWS FROM (FUNC1(col1) AS alias1(\"col1\" TEXT), FUNC2(col2) AS alias2(\"col2\" INT)) WITH ORDINALITY", + "formatted": "SELECT *\nFROM ROWS FROM (FUNC1(col1) AS alias1(\"col1\" TEXT), FUNC2(col2) AS alias2(\"col2\" INT)) WITH ORDINALITY" + }, + { + "dialect": "postgres", + "sql": "SELECT * FROM table1, ROWS FROM (FUNC1(col1) AS alias1(\"col1\" TEXT)) WITH ORDINALITY AS alias3(\"col3\" INT, \"col4\" TEXT)", + "formatted": "SELECT *\nFROM table1, ROWS FROM (FUNC1(col1) AS alias1(\"col1\" TEXT)) WITH ORDINALITY AS alias3(\"col3\" INT, \"col4\" TEXT)" + }, + { + "dialect": "postgres", + "sql": "SELECT ARRAY_LENGTH(ARRAY[1, 2, 3], 1)", + "formatted": "SELECT ARRAY_LENGTH(ARRAY[1, 2, 3], 1)" + }, + { + "dialect": "postgres", + "sql": "SELECT XMLELEMENT(NAME foo)", + "formatted": "SELECT XMLELEMENT(NAME foo)" + }, + { + "dialect": "postgres", + "sql": "SELECT XMLELEMENT(NAME foo, XMLATTRIBUTES('xyz' AS bar))", + "formatted": "SELECT XMLELEMENT(NAME foo, XMLATTRIBUTES('xyz' AS bar))" + }, + { + "dialect": "postgres", + "sql": "SELECT XMLELEMENT(NAME test, XMLATTRIBUTES(a, b)) FROM test", + "formatted": "SELECT XMLELEMENT(NAME test, XMLATTRIBUTES(a, b))\nFROM test" + }, + { + "dialect": "postgres", + "sql": "SELECT XMLELEMENT(NAME foo, XMLATTRIBUTES(CURRENT_DATE AS bar), 'cont', 'ent')", + "formatted": "SELECT XMLELEMENT(NAME foo, XMLATTRIBUTES(CURRENT_DATE AS bar), 'cont', 'ent')" + }, + { + "dialect": "postgres", + "sql": "SELECT XMLELEMENT(NAME \"foo$bar\", XMLATTRIBUTES('xyz' AS \"a&b\"))", + "formatted": "SELECT XMLELEMENT(NAME \"foo$bar\", XMLATTRIBUTES('xyz' AS \"a&b\"))" + }, + { + "dialect": "postgres", + "sql": "SELECT XMLELEMENT(NAME foo, XMLATTRIBUTES('xyz' AS bar), XMLELEMENT(NAME abc), XMLCOMMENT('test'), XMLELEMENT(NAME xyz))", + "formatted": "SELECT XMLELEMENT(NAME foo, XMLATTRIBUTES('xyz' AS bar), XMLELEMENT(NAME abc), XMLCOMMENT('test'), XMLELEMENT(NAME xyz))" + }, + { + "dialect": "postgres", + "sql": "WITH RECURSIVE search_graph(id, link, data, depth) AS (SELECT g.id, g.link, g.data, 1 FROM graph AS g UNION ALL SELECT g.id, g.link, g.data, sg.depth + 1 FROM graph AS g, search_graph AS sg WHERE g.id = sg.link) CYCLE id SET is_cycle USING path SELECT * FROM search_graph", + "formatted": "WITH RECURSIVE search_graph(id, link, data, depth) AS (\n SELECT\n g.id,\n g.link,\n g.data,\n 1\n FROM graph AS g\n UNION ALL\n SELECT\n g.id,\n g.link,\n g.data,\n sg.depth + 1\n FROM graph AS g, search_graph AS sg\n WHERE g.id = sg.link\n)\n\nCYCLE id SET is_cycle USING path\nSELECT *\nFROM search_graph" + }, + { + "dialect": "postgres", + "sql": "SELECT a <<->> b", + "formatted": "SELECT a <<->> b" + }, + { + "dialect": "postgres", + "sql": "SELECT r IS NOT NULL FROM t", + "formatted": "SELECT r IS NOT NULL\nFROM t" + }, + { + "dialect": "postgres", + "sql": "SELECT NOT r IS NULL FROM t", + "formatted": "SELECT NOT r IS NULL\nFROM t" + }, + { + "dialect": "postgres", + "sql": "SELECT NOT r IS NOT NULL FROM t", + "formatted": "SELECT NOT r IS NOT NULL\nFROM t" + }, + { + "dialect": "postgres", + "sql": "SELECT r NOTNULL FROM t", + "formatted": "SELECT r NOTNULL\nFROM t" + }, + { + "dialect": "postgres", + "sql": "SELECT r ISNULL FROM t", + "formatted": "SELECT r ISNULL\nFROM t" + }, + { + "dialect": "postgres", + "sql": "SELECT ARRAY[1, 2, 3] <@ ARRAY[1, 2]", + "formatted": "SELECT ARRAY[1, 2, 3] <@ ARRAY[1, 2]" + }, + { + "dialect": "snowflake", + "sql": "WITH t AS (SELECT PARSE_JSON('{\"level1\": {\"level2\": {\"level3\": \"value\"}}}') AS data) SELECT data: level1 : level2 : level3::VARIANT FROM t", + "formatted": "WITH t AS (\n SELECT PARSE_JSON('{\"level1\": {\"level2\": {\"level3\": \"value\"}}}') AS data\n)\n\nSELECT data:level1:level2:level3::VARIANT\nFROM t" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM x ASOF JOIN y OFFSET MATCH_CONDITION (x.a > y.a)", + "formatted": "SELECT *\nFROM x\nASOF JOIN y OFFSET MATCH_CONDITION (x.a > y.a)", + "previously_formatted": "SELECT\n *\nFROM x\nASOF JOIN y AS OFFSET MATCH_CONDITION (x.a > y.a)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM x ASOF JOIN y LIMIT MATCH_CONDITION (x.a > y.a)", + "formatted": "SELECT *\nFROM x\nASOF JOIN y LIMIT MATCH_CONDITION (x.a > y.a)", + "previously_formatted": "SELECT\n *\nFROM x\nASOF JOIN y AS LIMIT MATCH_CONDITION (x.a > y.a)" + }, + { + "dialect": "snowflake", + "sql": "SELECT session", + "formatted": "SELECT session" + }, + { + "dialect": "snowflake", + "sql": "SELECT DATE_PART(EPOCH_MILLISECOND, CURRENT_TIMESTAMP()) AS a", + "formatted": "SELECT DATE_PART(EPOCH_MILLISECOND, CURRENT_TIMESTAMP()) AS a" + }, + { + "dialect": "snowflake", + "sql": "SELECT GET(a, b)", + "formatted": "SELECT GET(a, b)" + }, + { + "dialect": "snowflake", + "sql": "SELECT HASH_AGG(a, b, c, d)", + "formatted": "SELECT HASH_AGG(a, b, c, d)" + }, + { + "dialect": "snowflake", + "sql": "SELECT GREATEST(1, 2, 3, NULL)", + "formatted": "SELECT GREATEST(1, 2, 3, NULL)" + }, + { + "dialect": "snowflake", + "sql": "SELECT GREATEST_IGNORE_NULLS(1, 2, 3, NULL)", + "formatted": "SELECT GREATEST_IGNORE_NULLS(1, 2, 3, NULL)" + }, + { + "dialect": "snowflake", + "sql": "SELECT LEAST(5, NULL, 7, 3)", + "formatted": "SELECT LEAST(5, NULL, 7, 3)" + }, + { + "dialect": "snowflake", + "sql": "SELECT LEAST_IGNORE_NULLS(5, NULL, 7, 3)", + "formatted": "SELECT LEAST_IGNORE_NULLS(5, NULL, 7, 3)" + }, + { + "dialect": "snowflake", + "sql": "SELECT MAX(x)", + "formatted": "SELECT MAX(x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT COUNT(x)", + "formatted": "SELECT COUNT(x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT MIN(amount)", + "formatted": "SELECT MIN(amount)" + }, + { + "dialect": "snowflake", + "sql": "SELECT MODE(x)", + "formatted": "SELECT MODE(x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT MODE(status) OVER (PARTITION BY region) FROM orders", + "formatted": "SELECT MODE(status) OVER (PARTITION BY region)\nFROM orders" + }, + { + "dialect": "snowflake", + "sql": "SELECT TAN(x)", + "formatted": "SELECT TAN(x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT COS(x)", + "formatted": "SELECT COS(x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT SINH(1.5)", + "formatted": "SELECT SINH(1.5)" + }, + { + "dialect": "snowflake", + "sql": "SELECT MOD(x, y)", + "formatted": "SELECT MOD(x, y)" + }, + { + "dialect": "snowflake", + "sql": "SELECT ROUND(x)", + "formatted": "SELECT ROUND(x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT ROUND(123.456, -1)", + "formatted": "SELECT ROUND(123.456, -1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT ROUND(123.456, 2, 'HALF_AWAY_FROM_ZERO')", + "formatted": "SELECT ROUND(123.456, 2, 'HALF_AWAY_FROM_ZERO')" + }, + { + "dialect": "snowflake", + "sql": "SELECT FLOOR(x)", + "formatted": "SELECT FLOOR(x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT FLOOR(135.135, 1)", + "formatted": "SELECT FLOOR(135.135, 1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT FLOOR(x, -1)", + "formatted": "SELECT FLOOR(x, -1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY salary) FROM employees", + "formatted": "SELECT PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY salary)\nFROM employees" + }, + { + "dialect": "snowflake", + "sql": "SELECT APPROX_TOP_K(col) FROM t", + "formatted": "SELECT APPROX_TOP_K(col)\nFROM t" + }, + { + "dialect": "snowflake", + "sql": "SELECT APPROX_TOP_K(category, 3) FROM t", + "formatted": "SELECT APPROX_TOP_K(category, 3)\nFROM t" + }, + { + "dialect": "snowflake", + "sql": "SELECT MINHASH(5, col)", + "formatted": "SELECT MINHASH(5, col)" + }, + { + "dialect": "snowflake", + "sql": "SELECT MINHASH(5, col1, col2)", + "formatted": "SELECT MINHASH(5, col1, col2)" + }, + { + "dialect": "snowflake", + "sql": "SELECT MINHASH(5, *)", + "formatted": "SELECT MINHASH(5, *)" + }, + { + "dialect": "snowflake", + "sql": "SELECT MINHASH_COMBINE(minhash_col)", + "formatted": "SELECT MINHASH_COMBINE(minhash_col)" + }, + { + "dialect": "snowflake", + "sql": "SELECT APPROXIMATE_SIMILARITY(minhash_col)", + "formatted": "SELECT APPROXIMATE_SIMILARITY(minhash_col)" + }, + { + "dialect": "snowflake", + "sql": "SELECT APPROXIMATE_JACCARD_INDEX(minhash_col)", + "formatted": "SELECT APPROXIMATE_JACCARD_INDEX(minhash_col)" + }, + { + "dialect": "snowflake", + "sql": "SELECT APPROX_PERCENTILE_ACCUMULATE(col)", + "formatted": "SELECT APPROX_PERCENTILE_ACCUMULATE(col)" + }, + { + "dialect": "snowflake", + "sql": "SELECT APPROX_PERCENTILE_ESTIMATE(state, 0.5)", + "formatted": "SELECT APPROX_PERCENTILE_ESTIMATE(state, 0.5)" + }, + { + "dialect": "snowflake", + "sql": "SELECT APPROX_TOP_K_ACCUMULATE(col, 10)", + "formatted": "SELECT APPROX_TOP_K_ACCUMULATE(col, 10)" + }, + { + "dialect": "snowflake", + "sql": "SELECT APPROX_TOP_K_COMBINE(state, 2)", + "formatted": "SELECT APPROX_TOP_K_COMBINE(state, 2)" + }, + { + "dialect": "snowflake", + "sql": "SELECT APPROX_TOP_K_COMBINE(state)", + "formatted": "SELECT APPROX_TOP_K_COMBINE(state)" + }, + { + "dialect": "snowflake", + "sql": "SELECT APPROX_TOP_K_ESTIMATE(state_column, 4)", + "formatted": "SELECT APPROX_TOP_K_ESTIMATE(state_column, 4)" + }, + { + "dialect": "snowflake", + "sql": "SELECT APPROX_TOP_K_ESTIMATE(state_column)", + "formatted": "SELECT APPROX_TOP_K_ESTIMATE(state_column)" + }, + { + "dialect": "snowflake", + "sql": "SELECT APPROX_PERCENTILE_COMBINE(state_column)", + "formatted": "SELECT APPROX_PERCENTILE_COMBINE(state_column)" + }, + { + "dialect": "snowflake", + "sql": "SELECT EQUAL_NULL(1, 2)", + "formatted": "SELECT EQUAL_NULL(1, 2)" + }, + { + "dialect": "snowflake", + "sql": "SELECT EXP(1)", + "formatted": "SELECT EXP(1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT FACTORIAL(5)", + "formatted": "SELECT FACTORIAL(5)" + }, + { + "dialect": "snowflake", + "sql": "SELECT BIT_LENGTH('abc')", + "formatted": "SELECT BIT_LENGTH('abc')" + }, + { + "dialect": "snowflake", + "sql": "SELECT BIT_LENGTH(x'A1B2')", + "formatted": "SELECT BIT_LENGTH(x'A1B2')" + }, + { + "dialect": "snowflake", + "sql": "SELECT BITMAP_BUCKET_NUMBER(32769)", + "formatted": "SELECT BITMAP_BUCKET_NUMBER(32769)" + }, + { + "dialect": "snowflake", + "sql": "SELECT BITMAP_CONSTRUCT_AGG(value)", + "formatted": "SELECT BITMAP_CONSTRUCT_AGG(value)" + }, + { + "dialect": "snowflake", + "sql": "SELECT BITMAP_COUNT(BITMAP_CONSTRUCT_AGG(value)) FROM TABLE(FLATTEN(INPUT => ARRAY_CONSTRUCT(1, 2, 3, 5)))", + "formatted": "SELECT BITMAP_COUNT(BITMAP_CONSTRUCT_AGG(value))\nFROM TABLE(FLATTEN(INPUT => ARRAY_CONSTRUCT(1, 2, 3, 5)))" + }, + { + "dialect": "snowflake", + "sql": "SELECT BOOLAND(1, -2)", + "formatted": "SELECT BOOLAND(1, -2)" + }, + { + "dialect": "snowflake", + "sql": "SELECT BOOLXOR(2, 0)", + "formatted": "SELECT BOOLXOR(2, 0)" + }, + { + "dialect": "snowflake", + "sql": "SELECT BOOLOR(1, 0)", + "formatted": "SELECT BOOLOR(1, 0)" + }, + { + "dialect": "snowflake", + "sql": "SELECT TO_BOOLEAN('true')", + "formatted": "SELECT TO_BOOLEAN('true')" + }, + { + "dialect": "snowflake", + "sql": "SELECT TO_BOOLEAN(1)", + "formatted": "SELECT TO_BOOLEAN(1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT TO_VARIANT(123)", + "formatted": "SELECT TO_VARIANT(123)" + }, + { + "dialect": "snowflake", + "sql": "SELECT IS_NULL_VALUE(GET_PATH(payload, 'field'))", + "formatted": "SELECT IS_NULL_VALUE(GET_PATH(payload, 'field'))" + }, + { + "dialect": "snowflake", + "sql": "SELECT RTRIMMED_LENGTH(' ABCD ')", + "formatted": "SELECT RTRIMMED_LENGTH(' ABCD ')" + }, + { + "dialect": "snowflake", + "sql": "SELECT HEX_DECODE_STRING('48656C6C6F')", + "formatted": "SELECT HEX_DECODE_STRING('48656C6C6F')" + }, + { + "dialect": "snowflake", + "sql": "SELECT HEX_ENCODE('Hello World')", + "formatted": "SELECT HEX_ENCODE('Hello World')" + }, + { + "dialect": "snowflake", + "sql": "SELECT HEX_ENCODE('Hello World', 1)", + "formatted": "SELECT HEX_ENCODE('Hello World', 1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT HEX_ENCODE('Hello World', 0)", + "formatted": "SELECT HEX_ENCODE('Hello World', 0)" + }, + { + "dialect": "snowflake", + "sql": "SELECT IFNULL(col1, col2)", + "formatted": "SELECT IFNULL(col1, col2)" + }, + { + "dialect": "snowflake", + "sql": "SELECT NEXT_DAY('2025-10-15', 'FRIDAY')", + "formatted": "SELECT NEXT_DAY('2025-10-15', 'FRIDAY')" + }, + { + "dialect": "snowflake", + "sql": "SELECT NVL2(col1, col2, col3)", + "formatted": "SELECT NVL2(col1, col2, col3)" + }, + { + "dialect": "snowflake", + "sql": "SELECT NVL(col1, col2)", + "formatted": "SELECT NVL(col1, col2)" + }, + { + "dialect": "snowflake", + "sql": "SELECT CHR(8364)", + "formatted": "SELECT CHR(8364)" + }, + { + "dialect": "snowflake", + "sql": "SELECT CHECK_JSON('{\"key\": \"value\"}')", + "formatted": "SELECT CHECK_JSON('{\"key\": \"value\"}')" + }, + { + "dialect": "snowflake", + "sql": "SELECT CHECK_XML('value')", + "formatted": "SELECT CHECK_XML('value')" + }, + { + "dialect": "snowflake", + "sql": "SELECT CHECK_XML('value', TRUE)", + "formatted": "SELECT CHECK_XML('value', TRUE)" + }, + { + "dialect": "snowflake", + "sql": "SELECT COMPRESS('Hello World', 'ZLIB')", + "formatted": "SELECT COMPRESS('Hello World', 'ZLIB')" + }, + { + "dialect": "snowflake", + "sql": "SELECT DECOMPRESS_BINARY('compressed_data', 'SNAPPY')", + "formatted": "SELECT DECOMPRESS_BINARY('compressed_data', 'SNAPPY')" + }, + { + "dialect": "snowflake", + "sql": "SELECT DECOMPRESS_STRING('compressed_data', 'ZSTD')", + "formatted": "SELECT DECOMPRESS_STRING('compressed_data', 'ZSTD')" + }, + { + "dialect": "snowflake", + "sql": "SELECT LPAD('Hello', 10, '*')", + "formatted": "SELECT LPAD('Hello', 10, '*')" + }, + { + "dialect": "snowflake", + "sql": "SELECT LPAD(tbl.bin_col, 10)", + "formatted": "SELECT LPAD(tbl.bin_col, 10)" + }, + { + "dialect": "snowflake", + "sql": "SELECT RPAD('Hello', 10, '*')", + "formatted": "SELECT RPAD('Hello', 10, '*')" + }, + { + "dialect": "snowflake", + "sql": "SELECT RPAD(tbl.bin_col, 10)", + "formatted": "SELECT RPAD(tbl.bin_col, 10)" + }, + { + "dialect": "snowflake", + "sql": "SELECT RPAD(TO_BINARY('Hi', 'UTF8'), 10, TO_BINARY('_', 'UTF8'))", + "formatted": "SELECT\n RPAD(\n TO_BINARY('Hi', 'UTF8'),\n 10,\n TO_BINARY('_', 'UTF8')\n )" + }, + { + "dialect": "snowflake", + "sql": "SELECT SOUNDEX(column_name)", + "formatted": "SELECT SOUNDEX(column_name)" + }, + { + "dialect": "snowflake", + "sql": "SELECT SOUNDEX_P123(column_name)", + "formatted": "SELECT SOUNDEX_P123(column_name)" + }, + { + "dialect": "snowflake", + "sql": "SELECT ABS(x)", + "formatted": "SELECT ABS(x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT ASIN(0.5)", + "formatted": "SELECT ASIN(0.5)" + }, + { + "dialect": "snowflake", + "sql": "SELECT ASINH(0.5)", + "formatted": "SELECT ASINH(0.5)" + }, + { + "dialect": "snowflake", + "sql": "SELECT ATAN(0.5)", + "formatted": "SELECT ATAN(0.5)" + }, + { + "dialect": "snowflake", + "sql": "SELECT ATAN2(0.5, 0.3)", + "formatted": "SELECT ATAN2(0.5, 0.3)" + }, + { + "dialect": "snowflake", + "sql": "SELECT ATANH(0.5)", + "formatted": "SELECT ATANH(0.5)" + }, + { + "dialect": "snowflake", + "sql": "SELECT CBRT(27.0)", + "formatted": "SELECT CBRT(27.0)" + }, + { + "dialect": "snowflake", + "sql": "SELECT POW(2, 3)", + "formatted": "SELECT POW(2, 3)" + }, + { + "dialect": "snowflake", + "sql": "SELECT POW(2.5, 3.0)", + "formatted": "SELECT POW(2.5, 3.0)" + }, + { + "dialect": "snowflake", + "sql": "SELECT SQUARE(2.5)", + "formatted": "SELECT SQUARE(2.5)" + }, + { + "dialect": "snowflake", + "sql": "SELECT SIGN(x)", + "formatted": "SELECT SIGN(x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT COSH(1.5)", + "formatted": "SELECT COSH(1.5)" + }, + { + "dialect": "snowflake", + "sql": "SELECT TANH(0.5)", + "formatted": "SELECT TANH(0.5)" + }, + { + "dialect": "snowflake", + "sql": "SELECT TRANSLATE(column_name, 'abc', '123')", + "formatted": "SELECT TRANSLATE(column_name, 'abc', '123')" + }, + { + "dialect": "snowflake", + "sql": "SELECT UNICODE(column_name)", + "formatted": "SELECT UNICODE(column_name)" + }, + { + "dialect": "snowflake", + "sql": "SELECT WIDTH_BUCKET(col, 0, 100, 10)", + "formatted": "SELECT WIDTH_BUCKET(col, 0, 100, 10)" + }, + { + "dialect": "snowflake", + "sql": "SELECT PI()", + "formatted": "SELECT PI()" + }, + { + "dialect": "snowflake", + "sql": "SELECT DEGREES(PI() / 3)", + "formatted": "SELECT DEGREES(PI() / 3)" + }, + { + "dialect": "snowflake", + "sql": "SELECT DEGREES(1)", + "formatted": "SELECT DEGREES(1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT RADIANS(180)", + "formatted": "SELECT RADIANS(180)" + }, + { + "dialect": "snowflake", + "sql": "SELECT REGR_AVGX(y, x)", + "formatted": "SELECT REGR_AVGX(y, x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT REGR_AVGY(y, x)", + "formatted": "SELECT REGR_AVGY(y, x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT REGR_COUNT(y, x)", + "formatted": "SELECT REGR_COUNT(y, x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT REGR_INTERCEPT(y, x)", + "formatted": "SELECT REGR_INTERCEPT(y, x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT REGR_R2(y, x)", + "formatted": "SELECT REGR_R2(y, x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT REGR_SXX(y, x)", + "formatted": "SELECT REGR_SXX(y, x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT REGR_SXY(y, x)", + "formatted": "SELECT REGR_SXY(y, x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT REGR_SYY(y, x)", + "formatted": "SELECT REGR_SYY(y, x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT REGR_SLOPE(y, x)", + "formatted": "SELECT REGR_SLOPE(y, x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT RANDSTR(123, 456)", + "formatted": "SELECT RANDSTR(123, 456)" + }, + { + "dialect": "snowflake", + "sql": "SELECT RANDSTR(123, RANDOM())", + "formatted": "SELECT RANDSTR(123, RANDOM())" + }, + { + "dialect": "snowflake", + "sql": "SELECT NORMAL(0, 1, RANDOM())", + "formatted": "SELECT NORMAL(0, 1, RANDOM())" + }, + { + "dialect": "snowflake", + "sql": "SELECT GROUPING_ID(a, b) AS g_id FROM x GROUP BY ROLLUP (a, b)", + "formatted": "SELECT GROUPING_ID(a, b) AS g_id\nFROM x\nGROUP BY\n ROLLUP (\n a,\n b\n )" + }, + { + "dialect": "snowflake", + "sql": "SELECT XMLGET(object_col, 'level2')", + "formatted": "SELECT XMLGET(object_col, 'level2')" + }, + { + "dialect": "snowflake", + "sql": "SELECT XMLGET(object_col, 'level3', 1)", + "formatted": "SELECT XMLGET(object_col, 'level3', 1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT {*} FROM my_table", + "formatted": "SELECT {*}\nFROM my_table" + }, + { + "dialect": "snowflake", + "sql": "SELECT {my_table.*} FROM my_table", + "formatted": "SELECT {my_table.*}\nFROM my_table" + }, + { + "dialect": "snowflake", + "sql": "SELECT {* ILIKE 'col1%'} FROM my_table", + "formatted": "SELECT {* ILIKE 'col1%'}\nFROM my_table" + }, + { + "dialect": "snowflake", + "sql": "SELECT {* EXCLUDE (col1)} FROM my_table", + "formatted": "SELECT {* EXCLUDE (col1)}\nFROM my_table" + }, + { + "dialect": "snowflake", + "sql": "SELECT {* EXCLUDE (col1, col2)} FROM my_table", + "formatted": "SELECT {* EXCLUDE (col1, col2)}\nFROM my_table" + }, + { + "dialect": "snowflake", + "sql": "SELECT a, b, COUNT(*) FROM x GROUP BY ALL LIMIT 100", + "formatted": "SELECT\n a,\n b,\n COUNT(*)\nFROM x\nGROUP BY ALL\nLIMIT 100" + }, + { + "dialect": "snowflake", + "sql": "SELECT STAR(tbl, exclude := [foo])", + "formatted": "SELECT STAR(tbl, exclude := [foo])" + }, + { + "dialect": "snowflake", + "sql": "SELECT CAST([1, 2, 3] AS VECTOR(FLOAT, 3))", + "formatted": "SELECT CAST([1, 2, 3] AS VECTOR(FLOAT, 3))" + }, + { + "dialect": "snowflake", + "sql": "SELECT VECTOR_COSINE_SIMILARITY(a, b)", + "formatted": "SELECT VECTOR_COSINE_SIMILARITY(a, b)" + }, + { + "dialect": "snowflake", + "sql": "SELECT VECTOR_INNER_PRODUCT(a, b)", + "formatted": "SELECT VECTOR_INNER_PRODUCT(a, b)" + }, + { + "dialect": "snowflake", + "sql": "SELECT VECTOR_L1_DISTANCE(a, b)", + "formatted": "SELECT VECTOR_L1_DISTANCE(a, b)" + }, + { + "dialect": "snowflake", + "sql": "SELECT VECTOR_L2_DISTANCE(a, b)", + "formatted": "SELECT VECTOR_L2_DISTANCE(a, b)" + }, + { + "dialect": "snowflake", + "sql": "SELECT CONNECT_BY_ROOT test AS test_column_alias", + "formatted": "SELECT CONNECT_BY_ROOT test AS test_column_alias" + }, + { + "dialect": "snowflake", + "sql": "SELECT rename, replace", + "formatted": "SELECT\n rename,\n replace" + }, + { + "dialect": "snowflake", + "sql": "SELECT TIMEADD(HOUR, 2, CAST('09:05:03' AS TIME))", + "formatted": "SELECT TIMEADD(HOUR, 2, CAST('09:05:03' AS TIME))" + }, + { + "dialect": "snowflake", + "sql": "SELECT CAST(OBJECT_CONSTRUCT('a', 1) AS MAP(VARCHAR, INT))", + "formatted": "SELECT CAST(OBJECT_CONSTRUCT('a', 1) AS MAP(VARCHAR, INT))" + }, + { + "dialect": "snowflake", + "sql": "SELECT MAP_CAT(CAST(col AS MAP(VARCHAR, VARCHAR)), CAST(col AS MAP(VARCHAR, VARCHAR)))", + "formatted": "SELECT MAP_CAT(CAST(col AS MAP(VARCHAR, VARCHAR)), CAST(col AS MAP(VARCHAR, VARCHAR)))" + }, + { + "dialect": "snowflake", + "sql": "SELECT MAP_CONTAINS_KEY('k1', CAST(col AS MAP(VARCHAR, VARCHAR)))", + "formatted": "SELECT MAP_CONTAINS_KEY('k1', CAST(col AS MAP(VARCHAR, VARCHAR)))" + }, + { + "dialect": "snowflake", + "sql": "SELECT MAP_DELETE(CAST(col AS MAP(VARCHAR, VARCHAR)), 'k1')", + "formatted": "SELECT MAP_DELETE(CAST(col AS MAP(VARCHAR, VARCHAR)), 'k1')" + }, + { + "dialect": "snowflake", + "sql": "SELECT MAP_INSERT(CAST(col AS MAP(VARCHAR, VARCHAR)), 'b', '2')", + "formatted": "SELECT MAP_INSERT(CAST(col AS MAP(VARCHAR, VARCHAR)), 'b', '2')" + }, + { + "dialect": "snowflake", + "sql": "SELECT MAP_KEYS(CAST(col AS MAP(VARCHAR, VARCHAR)))", + "formatted": "SELECT MAP_KEYS(CAST(col AS MAP(VARCHAR, VARCHAR)))" + }, + { + "dialect": "snowflake", + "sql": "SELECT MAP_PICK(CAST(col AS MAP(VARCHAR, VARCHAR)), 'a', 'c')", + "formatted": "SELECT MAP_PICK(CAST(col AS MAP(VARCHAR, VARCHAR)), 'a', 'c')" + }, + { + "dialect": "snowflake", + "sql": "SELECT MAP_SIZE(CAST(col AS MAP(VARCHAR, VARCHAR)))", + "formatted": "SELECT MAP_SIZE(CAST(col AS MAP(VARCHAR, VARCHAR)))" + }, + { + "dialect": "snowflake", + "sql": "SELECT CAST(OBJECT_CONSTRUCT('a', 1) AS OBJECT(a CHAR NOT NULL))", + "formatted": "SELECT CAST(OBJECT_CONSTRUCT('a', 1) AS OBJECT(a CHAR NOT NULL))" + }, + { + "dialect": "snowflake", + "sql": "SELECT CAST([1, 2, 3] AS ARRAY(INT))", + "formatted": "SELECT CAST([1, 2, 3] AS ARRAY(INT))" + }, + { + "dialect": "snowflake", + "sql": "SELECT CAST(obj AS OBJECT(x CHAR) RENAME FIELDS)", + "formatted": "SELECT CAST(obj AS OBJECT(x CHAR) RENAME FIELDS)" + }, + { + "dialect": "snowflake", + "sql": "SELECT CAST(obj AS OBJECT(x CHAR, y VARCHAR) ADD FIELDS)", + "formatted": "SELECT CAST(obj AS OBJECT(x CHAR, y VARCHAR) ADD FIELDS)" + }, + { + "dialect": "snowflake", + "sql": "SELECT TO_TIMESTAMP(x) FROM t", + "formatted": "SELECT TO_TIMESTAMP(x)\nFROM t" + }, + { + "dialect": "snowflake", + "sql": "SELECT TO_TIMESTAMP_NTZ(x) FROM t", + "formatted": "SELECT TO_TIMESTAMP_NTZ(x)\nFROM t" + }, + { + "dialect": "snowflake", + "sql": "SELECT TO_TIMESTAMP_LTZ(x) FROM t", + "formatted": "SELECT TO_TIMESTAMP_LTZ(x)\nFROM t" + }, + { + "dialect": "snowflake", + "sql": "SELECT TO_TIMESTAMP_TZ(x) FROM t", + "formatted": "SELECT TO_TIMESTAMP_TZ(x)\nFROM t" + }, + { + "dialect": "snowflake", + "sql": "SELECT TIMESTAMP_FROM_PARTS(2024, 5, 9, 14, 30, 45)", + "formatted": "SELECT TIMESTAMP_FROM_PARTS(2024, 5, 9, 14, 30, 45)" + }, + { + "dialect": "snowflake", + "sql": "SELECT TIMESTAMP_FROM_PARTS(2024, 5, 9, 14, 30, 45, 123)", + "formatted": "SELECT TIMESTAMP_FROM_PARTS(2024, 5, 9, 14, 30, 45, 123)" + }, + { + "dialect": "snowflake", + "sql": "SELECT TIMESTAMP_LTZ_FROM_PARTS(2013, 4, 5, 12, 00, 00)", + "formatted": "SELECT TIMESTAMP_LTZ_FROM_PARTS(2013, 4, 5, 12, 00, 00)" + }, + { + "dialect": "snowflake", + "sql": "SELECT TIMESTAMP_TZ_FROM_PARTS(2013, 4, 5, 12, 00, 00)", + "formatted": "SELECT TIMESTAMP_TZ_FROM_PARTS(2013, 4, 5, 12, 00, 00)" + }, + { + "dialect": "snowflake", + "sql": "SELECT TIMESTAMP_TZ_FROM_PARTS(2013, 4, 5, 12, 00, 00, 0, 'America/Los_Angeles')", + "formatted": "SELECT TIMESTAMP_TZ_FROM_PARTS(2013, 4, 5, 12, 00, 00, 0, 'America/Los_Angeles')" + }, + { + "dialect": "snowflake", + "sql": "SELECT TIMESTAMP_FROM_PARTS(CAST('2024-05-09' AS DATE), CAST('14:30:45' AS TIME))", + "formatted": "SELECT\n TIMESTAMP_FROM_PARTS(\n CAST('2024-05-09' AS DATE),\n CAST('14:30:45' AS TIME)\n )" + }, + { + "dialect": "snowflake", + "sql": "SELECT TIMESTAMP_NTZ_FROM_PARTS(TO_DATE('2013-04-05'), TO_TIME('12:00:00'))", + "formatted": "SELECT TIMESTAMP_NTZ_FROM_PARTS(TO_DATE('2013-04-05'), TO_TIME('12:00:00'))" + }, + { + "dialect": "snowflake", + "sql": "SELECT TIMESTAMP_NTZ_FROM_PARTS(2013, 4, 5, 12, 00, 00, 987654321)", + "formatted": "SELECT TIMESTAMP_NTZ_FROM_PARTS(2013, 4, 5, 12, 00, 00, 987654321)" + }, + { + "dialect": "snowflake", + "sql": "SELECT DATE_FROM_PARTS(1977, 8, 7)", + "formatted": "SELECT DATE_FROM_PARTS(1977, 8, 7)" + }, + { + "dialect": "snowflake", + "sql": "SELECT GET_PATH(v, 'attr[0].name') FROM vartab", + "formatted": "SELECT GET_PATH(v, 'attr[0].name')\nFROM vartab" + }, + { + "dialect": "snowflake", + "sql": "SELECT TO_ARRAY(CAST(x AS ARRAY))", + "formatted": "SELECT TO_ARRAY(CAST(x AS ARRAY))" + }, + { + "dialect": "snowflake", + "sql": "SELECT TO_ARRAY(CAST(['test'] AS VARIANT))", + "formatted": "SELECT TO_ARRAY(CAST(['test'] AS VARIANT))" + }, + { + "dialect": "snowflake", + "sql": "SELECT ARRAY_UNIQUE_AGG(x)", + "formatted": "SELECT ARRAY_UNIQUE_AGG(x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT ARRAY_APPEND([1, 2, 3], 4)", + "formatted": "SELECT ARRAY_APPEND([1, 2, 3], 4)" + }, + { + "dialect": "snowflake", + "sql": "SELECT ARRAY_CAT([1, 2], [3, 4])", + "formatted": "SELECT ARRAY_CAT([1, 2], [3, 4])" + }, + { + "dialect": "snowflake", + "sql": "SELECT ARRAY_PREPEND([2, 3, 4], 1)", + "formatted": "SELECT ARRAY_PREPEND([2, 3, 4], 1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT ARRAY_REMOVE([1, 2, 3], 2)", + "formatted": "SELECT ARRAY_REMOVE([1, 2, 3], 2)" + }, + { + "dialect": "snowflake", + "sql": "SELECT ARRAYS_ZIP([1, 2, 3])", + "formatted": "SELECT ARRAYS_ZIP([1, 2, 3])" + }, + { + "dialect": "snowflake", + "sql": "SELECT ARRAYS_ZIP([1, 2, 3], ['a', 'b', 'c'], [10, 20, 30])", + "formatted": "SELECT\n ARRAYS_ZIP(\n [1, 2, 3],\n ['a', 'b', 'c'],\n [10, 20, 30]\n )" + }, + { + "dialect": "snowflake", + "sql": "SELECT AI_AGG(review, 'Summarize the reviews')", + "formatted": "SELECT AI_AGG(review, 'Summarize the reviews')" + }, + { + "dialect": "snowflake", + "sql": "SELECT AI_SUMMARIZE_AGG(review)", + "formatted": "SELECT AI_SUMMARIZE_AGG(review)" + }, + { + "dialect": "snowflake", + "sql": "SELECT AI_CLASSIFY('text', ['travel', 'cooking'])", + "formatted": "SELECT AI_CLASSIFY('text', ['travel', 'cooking'])" + }, + { + "dialect": "snowflake", + "sql": "SELECT OBJECT_CONSTRUCT()", + "formatted": "SELECT OBJECT_CONSTRUCT()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_ACCOUNT()", + "formatted": "SELECT CURRENT_ACCOUNT()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_ACCOUNT_NAME()", + "formatted": "SELECT CURRENT_ACCOUNT_NAME()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_AVAILABLE_ROLES()", + "formatted": "SELECT CURRENT_AVAILABLE_ROLES()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_CLIENT()", + "formatted": "SELECT CURRENT_CLIENT()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_IP_ADDRESS()", + "formatted": "SELECT CURRENT_IP_ADDRESS()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_DATABASE()", + "formatted": "SELECT CURRENT_DATABASE()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_SCHEMAS()", + "formatted": "SELECT CURRENT_SCHEMAS()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_SECONDARY_ROLES()", + "formatted": "SELECT CURRENT_SECONDARY_ROLES()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_SESSION()", + "formatted": "SELECT CURRENT_SESSION()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_STATEMENT()", + "formatted": "SELECT CURRENT_STATEMENT()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_VERSION()", + "formatted": "SELECT CURRENT_VERSION()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_TRANSACTION()", + "formatted": "SELECT CURRENT_TRANSACTION()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_WAREHOUSE()", + "formatted": "SELECT CURRENT_WAREHOUSE()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_ORGANIZATION_USER()", + "formatted": "SELECT CURRENT_ORGANIZATION_USER()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_REGION()", + "formatted": "SELECT CURRENT_REGION()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_ROLE()", + "formatted": "SELECT CURRENT_ROLE()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_ROLE_TYPE()", + "formatted": "SELECT CURRENT_ROLE_TYPE()" + }, + { + "dialect": "snowflake", + "sql": "SELECT DAY(CURRENT_TIMESTAMP())", + "formatted": "SELECT DAY(CURRENT_TIMESTAMP())" + }, + { + "dialect": "snowflake", + "sql": "SELECT DAYOFMONTH(CURRENT_TIMESTAMP())", + "formatted": "SELECT DAYOFMONTH(CURRENT_TIMESTAMP())" + }, + { + "dialect": "snowflake", + "sql": "SELECT DAYOFYEAR(CURRENT_TIMESTAMP())", + "formatted": "SELECT DAYOFYEAR(CURRENT_TIMESTAMP())" + }, + { + "dialect": "snowflake", + "sql": "SELECT MONTH(CURRENT_TIMESTAMP())", + "formatted": "SELECT MONTH(CURRENT_TIMESTAMP())" + }, + { + "dialect": "snowflake", + "sql": "SELECT QUARTER(CURRENT_TIMESTAMP())", + "formatted": "SELECT QUARTER(CURRENT_TIMESTAMP())" + }, + { + "dialect": "snowflake", + "sql": "SELECT WEEK(CURRENT_TIMESTAMP())", + "formatted": "SELECT WEEK(CURRENT_TIMESTAMP())" + }, + { + "dialect": "snowflake", + "sql": "SELECT WEEKISO(CURRENT_TIMESTAMP())", + "formatted": "SELECT WEEKISO(CURRENT_TIMESTAMP())" + }, + { + "dialect": "snowflake", + "sql": "SELECT YEAR(CURRENT_TIMESTAMP())", + "formatted": "SELECT YEAR(CURRENT_TIMESTAMP())" + }, + { + "dialect": "snowflake", + "sql": "SELECT YEAROFWEEK(CURRENT_TIMESTAMP())", + "formatted": "SELECT YEAROFWEEK(CURRENT_TIMESTAMP())" + }, + { + "dialect": "snowflake", + "sql": "SELECT YEAROFWEEKISO(CURRENT_TIMESTAMP())", + "formatted": "SELECT YEAROFWEEKISO(CURRENT_TIMESTAMP())" + }, + { + "dialect": "snowflake", + "sql": "SELECT SUM(amount) FROM mytable GROUP BY ALL", + "formatted": "SELECT SUM(amount)\nFROM mytable\nGROUP BY ALL" + }, + { + "dialect": "snowflake", + "sql": "SELECT STDDEV(x)", + "formatted": "SELECT STDDEV(x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT STDDEV(x) OVER (PARTITION BY 1)", + "formatted": "SELECT STDDEV(x) OVER (PARTITION BY 1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT STDDEV_POP(x)", + "formatted": "SELECT STDDEV_POP(x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT STDDEV_POP(x) OVER (PARTITION BY 1)", + "formatted": "SELECT STDDEV_POP(x) OVER (PARTITION BY 1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT STDDEV_SAMP(x)", + "formatted": "SELECT STDDEV_SAMP(x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT STDDEV_SAMP(x) OVER (PARTITION BY 1)", + "formatted": "SELECT STDDEV_SAMP(x) OVER (PARTITION BY 1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT KURTOSIS(x)", + "formatted": "SELECT KURTOSIS(x)" + }, + { + "dialect": "snowflake", + "sql": "SELECT KURTOSIS(x) OVER (PARTITION BY 1)", + "formatted": "SELECT KURTOSIS(x) OVER (PARTITION BY 1)" + }, + { + "dialect": "snowflake", + "sql": "WITH x AS (SELECT 1 AS foo) SELECT foo FROM IDENTIFIER('x')", + "formatted": "WITH x AS (\n SELECT 1 AS foo\n)\n\nSELECT foo\nFROM IDENTIFIER('x')" + }, + { + "dialect": "snowflake", + "sql": "WITH x AS (SELECT 1 AS foo) SELECT IDENTIFIER('foo') FROM x", + "formatted": "WITH x AS (\n SELECT 1 AS foo\n)\n\nSELECT IDENTIFIER('foo')\nFROM x" + }, + { + "dialect": "snowflake", + "sql": "SELECT IDENTIFIER($my_function_name)()", + "formatted": "SELECT IDENTIFIER($my_function_name)()" + }, + { + "dialect": "snowflake", + "sql": "SELECT IDENTIFIER('speed_of_light')()", + "formatted": "SELECT IDENTIFIER('speed_of_light')()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CAST('2021-01-01' AS DATE) + INTERVAL '1 DAY'", + "formatted": "SELECT CAST('2021-01-01' AS DATE) + INTERVAL '1 DAY'" + }, + { + "dialect": "snowflake", + "sql": "SELECT HLL(*)", + "formatted": "SELECT HLL(*)" + }, + { + "dialect": "snowflake", + "sql": "SELECT HLL(a)", + "formatted": "SELECT HLL(a)" + }, + { + "dialect": "snowflake", + "sql": "SELECT HLL(DISTINCT t.a)", + "formatted": "SELECT HLL(DISTINCT t.a)" + }, + { + "dialect": "snowflake", + "sql": "SELECT HLL(a, b, c)", + "formatted": "SELECT HLL(a, b, c)" + }, + { + "dialect": "snowflake", + "sql": "SELECT HLL(DISTINCT a, b, c)", + "formatted": "SELECT HLL(DISTINCT a, b, c)" + }, + { + "dialect": "snowflake", + "sql": "SELECT REGEXP_LIKE(a, b, c)", + "formatted": "SELECT REGEXP_LIKE(a, b, c)" + }, + { + "dialect": "snowflake", + "sql": "SELECT CONVERT_TIMEZONE('UTC', 'America/Los_Angeles', col)", + "formatted": "SELECT CONVERT_TIMEZONE('UTC', 'America/Los_Angeles', col)" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_ORGANIZATION_NAME()", + "formatted": "SELECT CURRENT_ORGANIZATION_NAME()" + }, + { + "dialect": "snowflake", + "sql": "SELECT MATCH_CONDITION", + "formatted": "SELECT MATCH_CONDITION" + }, + { + "dialect": "snowflake", + "sql": "SELECT OBJECT_AGG(key, value) FROM tbl", + "formatted": "SELECT OBJECT_AGG(key, value)\nFROM tbl" + }, + { + "dialect": "snowflake", + "sql": "SELECT TO_CHAR(CAST('12:05:05' AS TIME))", + "formatted": "SELECT TO_CHAR(CAST('12:05:05' AS TIME))" + }, + { + "dialect": "snowflake", + "sql": "SELECT TRIM(COALESCE(TO_CHAR(CAST(c AS TIME)), '')) FROM t", + "formatted": "SELECT TRIM(COALESCE(TO_CHAR(CAST(c AS TIME)), ''))\nFROM t" + }, + { + "dialect": "snowflake", + "sql": "SELECT GET_PATH(PARSE_JSON(foo), 'bar')", + "formatted": "SELECT GET_PATH(PARSE_JSON(foo), 'bar')" + }, + { + "dialect": "snowflake", + "sql": "SELECT PARSE_IP('192.168.1.1', 'INET')", + "formatted": "SELECT PARSE_IP('192.168.1.1', 'INET')" + }, + { + "dialect": "snowflake", + "sql": "SELECT PARSE_IP('192.168.1.1', 'INET', 0)", + "formatted": "SELECT PARSE_IP('192.168.1.1', 'INET', 0)" + }, + { + "dialect": "snowflake", + "sql": "SELECT GET_PATH(foo, 'bar')", + "formatted": "SELECT GET_PATH(foo, 'bar')" + }, + { + "dialect": "snowflake", + "sql": "SELECT a, exclude, b FROM xxx", + "formatted": "SELECT\n a,\n exclude,\n b\nFROM xxx" + }, + { + "dialect": "snowflake", + "sql": "SELECT ARRAY_SORT(x, TRUE, FALSE)", + "formatted": "SELECT ARRAY_SORT(x, TRUE, FALSE)" + }, + { + "dialect": "snowflake", + "sql": "SELECT BOOLXOR_AGG(col) FROM tbl", + "formatted": "SELECT BOOLXOR_AGG(col)\nFROM tbl" + }, + { + "dialect": "snowflake", + "sql": "SELECT PERCENTILE_DISC(0.9) WITHIN GROUP (ORDER BY col) OVER (PARTITION BY category)", + "formatted": "SELECT PERCENTILE_DISC(0.9) WITHIN GROUP (ORDER BY col) OVER (PARTITION BY category)" + }, + { + "dialect": "snowflake", + "sql": "SELECT AI_CLASSIFY('text', ['travel', 'cooking'], OBJECT_CONSTRUCT('output_mode', 'multi'))", + "formatted": "SELECT\n AI_CLASSIFY(\n 'text',\n ['travel', 'cooking'],\n OBJECT_CONSTRUCT('output_mode', 'multi')\n )" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM table AT (TIMESTAMP => '2024-07-24') UNPIVOT(a FOR b IN (c)) AS pivot_table", + "formatted": "SELECT *\nFROM table AT (TIMESTAMP => '2024-07-24') UNPIVOT(a FOR b IN (c)) AS pivot_table" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM quarterly_sales PIVOT(SUM(amount) FOR quarter IN ('2023_Q1', '2023_Q2', '2023_Q3', '2023_Q4', '2024_Q1') DEFAULT ON NULL (0)) ORDER BY empid", + "formatted": "SELECT *\nFROM quarterly_sales PIVOT(SUM(amount) FOR quarter IN ('2023_Q1', '2023_Q2', '2023_Q3', '2023_Q4', '2024_Q1') DEFAULT ON NULL (0))\nORDER BY\n empid" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM quarterly_sales PIVOT(SUM(amount) FOR quarter IN (SELECT DISTINCT quarter FROM ad_campaign_types_by_quarter WHERE television = TRUE ORDER BY quarter)) ORDER BY empid", + "formatted": "SELECT *\nFROM quarterly_sales PIVOT(SUM(amount) FOR quarter IN (SELECT DISTINCT\n quarter\nFROM ad_campaign_types_by_quarter\nWHERE television = TRUE\nORDER BY\n quarter))\nORDER BY\n empid" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM quarterly_sales PIVOT(SUM(amount) FOR quarter IN (ANY ORDER BY quarter)) ORDER BY empid", + "formatted": "SELECT *\nFROM quarterly_sales PIVOT(SUM(amount) FOR quarter IN (ANY ORDER BY quarter))\nORDER BY\n empid" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM quarterly_sales PIVOT(SUM(amount) FOR quarter IN (ANY)) ORDER BY empid", + "formatted": "SELECT *\nFROM quarterly_sales PIVOT(SUM(amount) FOR quarter IN (ANY))\nORDER BY\n empid" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM DATA AS DATA_L ASOF JOIN DATA AS DATA_R MATCH_CONDITION (DATA_L.VAL > DATA_R.VAL) ON DATA_L.ID = DATA_R.ID", + "formatted": "SELECT *\nFROM DATA AS DATA_L\nASOF JOIN DATA AS DATA_R MATCH_CONDITION (DATA_L.VAL > DATA_R.VAL)\n ON DATA_L.ID = DATA_R.ID" + }, + { + "dialect": "snowflake", + "sql": "SELECT TO_TIMESTAMP('2025-01-16T14:45:30.123+0500', 'yyyy-mm-DDThh24:mi:ss.ff9tzhtzm')", + "formatted": "SELECT TO_TIMESTAMP('2025-01-16T14:45:30.123+0500', 'yyyy-mm-DDThh24:mi:ss.ff9tzhtzm')" + }, + { + "dialect": "snowflake", + "sql": "SELECT * REPLACE (CAST(col AS TEXT) AS scol) FROM t", + "formatted": "SELECT * REPLACE (CAST(col AS TEXT) AS scol)\nFROM t" + }, + { + "dialect": "snowflake", + "sql": "SELECT 1 put", + "formatted": "SELECT 1 put", + "previously_formatted": "SELECT\n 1 AS put" + }, + { + "dialect": "snowflake", + "sql": "SELECT 1 get", + "formatted": "SELECT 1 get", + "previously_formatted": "SELECT\n 1 AS get" + }, + { + "dialect": "snowflake", + "sql": "WITH t (SELECT 1 AS c) SELECT c FROM t", + "formatted": "WITH t (\n SELECT 1 AS c\n)\nSELECT c\nFROM t", + "previously_formatted": "WITH t AS (\n SELECT\n 1 AS c\n)\nSELECT\n c\nFROM t" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM s WHERE c NOT IN (1, 2, 3)", + "formatted": "SELECT *\nFROM s\nWHERE c NOT IN (1, 2, 3)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM s WHERE c NOT IN (SELECT * FROM t)", + "formatted": "SELECT *\nFROM s\nWHERE\n c NOT IN (\n SELECT *\n FROM t\n )" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM t1 INNER JOIN t2 USING (t1.col)", + "formatted": "SELECT *\nFROM t1\nINNER JOIN t2\n USING (t1.col)" + }, + { + "dialect": "snowflake", + "sql": "SELECT a:from::STRING, a:from || ' test' ", + "formatted": "SELECT\n a:from::STRING,\n a:from || ' test'" + }, + { + "dialect": "snowflake", + "sql": "SELECT a:select", + "formatted": "SELECT a:select" + }, + { + "dialect": "snowflake", + "sql": "SELECT GET_PATH(PARSE_JSON('{\"y\": [{\"z\": 1}]}'), 'y[0]:z')", + "formatted": "SELECT GET_PATH(PARSE_JSON('{\"y\": [{\"z\": 1}]}'), 'y[0]:z')" + }, + { + "dialect": "snowflake", + "sql": "SELECT p FROM t WHERE p:val NOT IN ('2')", + "formatted": "SELECT p\nFROM t\nWHERE p:val NOT IN ('2')" + }, + { + "dialect": "snowflake", + "sql": "SELECT PARSE_JSON('{\"x\": \"hello\"}'):x LIKE 'hello'", + "formatted": "SELECT PARSE_JSON('{\"x\": \"hello\"}'):x LIKE 'hello'" + }, + { + "dialect": "snowflake", + "sql": "SELECT data:x LIKE 'hello' FROM some_table", + "formatted": "SELECT data:x LIKE 'hello'\nFROM some_table" + }, + { + "dialect": "snowflake", + "sql": "SELECT SUM({ fn CONVERT(123, SQL_DOUBLE) })", + "formatted": "SELECT SUM({ fn CONVERT(123, SQL_DOUBLE) })" + }, + { + "dialect": "snowflake", + "sql": "SELECT SUM({ fn CONVERT(123, SQL_VARCHAR) })", + "formatted": "SELECT SUM({ fn CONVERT(123, SQL_VARCHAR) })" + }, + { + "dialect": "snowflake", + "sql": "SELECT TIMESTAMPFROMPARTS(d, t)", + "formatted": "SELECT TIMESTAMPFROMPARTS(d, t)" + }, + { + "dialect": "snowflake", + "sql": "SELECT v:attr[0].name FROM vartab", + "formatted": "SELECT v:attr[0].name\nFROM vartab" + }, + { + "dialect": "snowflake", + "sql": "SELECT v:\"fruit\" FROM vartab", + "formatted": "SELECT v:\"fruit\"\nFROM vartab" + }, + { + "dialect": "snowflake", + "sql": "SELECT PARSE_JSON('{\"food\":{\"fruit\":\"banana\"}}'):food.fruit::VARCHAR", + "formatted": "SELECT PARSE_JSON('{\"food\":{\"fruit\":\"banana\"}}'):food.fruit::VARCHAR" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM t, UNNEST(x) WITH ORDINALITY", + "formatted": "SELECT *\nFROM t, UNNEST(x) WITH ORDINALITY" + }, + { + "dialect": "snowflake", + "sql": "SELECT state, city, SUM(retail_price * quantity) AS gross_revenue FROM sales GROUP BY ALL", + "formatted": "SELECT\n state,\n city,\n SUM(retail_price * quantity) AS gross_revenue\nFROM sales\nGROUP BY ALL" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM foo window", + "formatted": "SELECT *\nFROM foo window", + "previously_formatted": "SELECT\n *\nFROM foo AS window" + }, + { + "dialect": "snowflake", + "sql": "SELECT RLIKE(a, $$regular expression with \\ characters: \\d{2}-\\d{3}-\\d{4}$$, 'i') FROM log_source", + "formatted": "SELECT RLIKE(a, $$regular expression with \\ characters: \\d{2}-\\d{3}-\\d{4}$$, 'i')\nFROM log_source" + }, + { + "dialect": "snowflake", + "sql": "SELECT $$a ' \\ \\t \\x21 z $ $$", + "formatted": "SELECT $$a ' \\ \\t \\x21 z $ $$" + }, + { + "dialect": "snowflake", + "sql": "SELECT $$a\\$b$$", + "formatted": "SELECT $$a\\$b$$" + }, + { + "dialect": "snowflake", + "sql": "SELECT $$a\\$$", + "formatted": "SELECT $$a\\$$" + }, + { + "dialect": "snowflake", + "sql": "SELECT {'test': 'best'}::VARIANT", + "formatted": "SELECT {'test': 'best'}::VARIANT" + }, + { + "dialect": "snowflake", + "sql": "SELECT {fn DAYNAME('2022-5-13')}", + "formatted": "SELECT {fn\n DAYNAME('2022-5-13') }" + }, + { + "dialect": "snowflake", + "sql": "SELECT {fn LOG(5)}", + "formatted": "SELECT {fn\n LOG(5) }" + }, + { + "dialect": "snowflake", + "sql": "SELECT {fn CEILING(5.3)}", + "formatted": "SELECT {fn\n CEILING(5.3) }" + }, + { + "dialect": "snowflake", + "sql": "SELECT CEIL(3.14)", + "formatted": "SELECT CEIL(3.14)" + }, + { + "dialect": "snowflake", + "sql": "SELECT CEIL(3.14, 1)", + "formatted": "SELECT CEIL(3.14, 1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT DAYOFWEEK('2016-01-02T23:39:20.123-07:00'::TIMESTAMP)", + "formatted": "SELECT DAYOFWEEK('2016-01-02T23:39:20.123-07:00'::TIMESTAMP)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM xxx WHERE col ilike '%Don''t%'", + "formatted": "SELECT *\nFROM xxx\nWHERE col ILIKE '%Don''t%'" + }, + { + "dialect": "snowflake", + "sql": "SELECT * EXCLUDE a, b FROM xxx", + "formatted": "SELECT\n * EXCLUDE a,\n b\nFROM xxx" + }, + { + "dialect": "snowflake", + "sql": "SELECT * RENAME a AS b, c AS d FROM xxx", + "formatted": "SELECT\n * RENAME a AS b,\n c AS d\nFROM xxx" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM xxx, yyy, zzz,", + "formatted": "SELECT *\nFROM xxx, yyy, zzz," + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM xxx, yyy, zzz, WHERE foo = bar", + "formatted": "SELECT *\nFROM xxx, yyy, zzz,\nWHERE foo = bar" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM xxx, yyy, zzz", + "formatted": "SELECT *\nFROM xxx, yyy, zzz" + }, + { + "dialect": "snowflake", + "sql": "SELECT TIMESTAMPNTZFROMPARTS(2013, 4, 5, 12, 00, 00)", + "formatted": "SELECT TIMESTAMPNTZFROMPARTS(2013, 4, 5, 12, 00, 00)" + }, + { + "dialect": "snowflake", + "sql": "SELECT NTH_VALUE(a, 2) FROM t", + "formatted": "SELECT NTH_VALUE(a, 2)\nFROM t" + }, + { + "dialect": "snowflake", + "sql": "SELECT ARRAY_CONSTRUCT('foo')::VARIANT[0]", + "formatted": "SELECT ARRAY_CONSTRUCT('foo')::VARIANT[0]" + }, + { + "dialect": "snowflake", + "sql": "SELECT UUID_STRING(), UUID_STRING('fe971b24-9572-4005-b22f-351e9c09274d', 'foo')", + "formatted": "SELECT\n UUID_STRING(),\n UUID_STRING('fe971b24-9572-4005-b22f-351e9c09274d', 'foo')" + }, + { + "dialect": "snowflake", + "sql": "SELECT BITNOT(a)", + "formatted": "SELECT BITNOT(a)" + }, + { + "dialect": "snowflake", + "sql": "SELECT BIT_NOT(a)", + "formatted": "SELECT BIT_NOT(a)" + }, + { + "dialect": "snowflake", + "sql": "SELECT BITAND(a, b)", + "formatted": "SELECT BITAND(a, b)" + }, + { + "dialect": "snowflake", + "sql": "SELECT BITAND(a, b, 'LEFT')", + "formatted": "SELECT BITAND(a, b, 'LEFT')" + }, + { + "dialect": "snowflake", + "sql": "SELECT BIT_AND(a, b)", + "formatted": "SELECT BIT_AND(a, b)" + }, + { + "dialect": "snowflake", + "sql": "SELECT BIT_AND(a, b, 'LEFT')", + "formatted": "SELECT BIT_AND(a, b, 'LEFT')" + }, + { + "dialect": "snowflake", + "sql": "SELECT BITOR(a, b)", + "formatted": "SELECT BITOR(a, b)" + }, + { + "dialect": "snowflake", + "sql": "SELECT BITOR(a, b, 'LEFT')", + "formatted": "SELECT BITOR(a, b, 'LEFT')" + }, + { + "dialect": "snowflake", + "sql": "SELECT BIT_OR(a, b)", + "formatted": "SELECT BIT_OR(a, b)" + }, + { + "dialect": "snowflake", + "sql": "SELECT BIT_OR(a, b, 'RIGHT')", + "formatted": "SELECT BIT_OR(a, b, 'RIGHT')" + }, + { + "dialect": "snowflake", + "sql": "SELECT BITXOR(a, b)", + "formatted": "SELECT BITXOR(a, b)" + }, + { + "dialect": "snowflake", + "sql": "SELECT BITXOR(a, b, 'LEFT')", + "formatted": "SELECT BITXOR(a, b, 'LEFT')" + }, + { + "dialect": "snowflake", + "sql": "SELECT BIT_XOR(a, b)", + "formatted": "SELECT BIT_XOR(a, b)" + }, + { + "dialect": "snowflake", + "sql": "SELECT BIT_XOR(a, b, 'LEFT')", + "formatted": "SELECT BIT_XOR(a, b, 'LEFT')" + }, + { + "dialect": "snowflake", + "sql": "SELECT HOUR(CAST('08:50:57' AS TIME))", + "formatted": "SELECT HOUR(CAST('08:50:57' AS TIME))" + }, + { + "dialect": "snowflake", + "sql": "SELECT MINUTE(CAST('08:50:57' AS TIME))", + "formatted": "SELECT MINUTE(CAST('08:50:57' AS TIME))" + }, + { + "dialect": "snowflake", + "sql": "SELECT SECOND(CAST('08:50:57' AS TIME))", + "formatted": "SELECT SECOND(CAST('08:50:57' AS TIME))" + }, + { + "dialect": "snowflake", + "sql": "SELECT HOUR(CAST('2024-05-09 08:50:57' AS TIMESTAMP))", + "formatted": "SELECT HOUR(CAST('2024-05-09 08:50:57' AS TIMESTAMP))" + }, + { + "dialect": "snowflake", + "sql": "SELECT MONTHNAME(CAST('2024-05-09' AS DATE))", + "formatted": "SELECT MONTHNAME(CAST('2024-05-09' AS DATE))" + }, + { + "dialect": "snowflake", + "sql": "SELECT PREVIOUS_DAY(CAST('2024-05-09' AS DATE), 'MONDAY')", + "formatted": "SELECT PREVIOUS_DAY(CAST('2024-05-09' AS DATE), 'MONDAY')" + }, + { + "dialect": "snowflake", + "sql": "SELECT TIME_FROM_PARTS(14, 30, 45)", + "formatted": "SELECT TIME_FROM_PARTS(14, 30, 45)" + }, + { + "dialect": "snowflake", + "sql": "SELECT TIME_FROM_PARTS(14, 30, 45, 123)", + "formatted": "SELECT TIME_FROM_PARTS(14, 30, 45, 123)" + }, + { + "dialect": "snowflake", + "sql": "SELECT MONTHS_BETWEEN(CAST('2019-03-15' AS DATE), CAST('2019-02-15' AS DATE))", + "formatted": "SELECT MONTHS_BETWEEN(CAST('2019-03-15' AS DATE), CAST('2019-02-15' AS DATE))" + }, + { + "dialect": "snowflake", + "sql": "SELECT MONTHS_BETWEEN(CAST('2019-03-01 02:00:00' AS TIMESTAMP), CAST('2019-02-15 01:00:00' AS TIMESTAMP))", + "formatted": "SELECT MONTHS_BETWEEN(CAST('2019-03-01 02:00:00' AS TIMESTAMP), CAST('2019-02-15 01:00:00' AS TIMESTAMP))" + }, + { + "dialect": "snowflake", + "sql": "SELECT TIME_SLICE(CAST('2024-05-09 08:50:57.891' AS TIMESTAMP), 15, 'MINUTE')", + "formatted": "SELECT TIME_SLICE(CAST('2024-05-09 08:50:57.891' AS TIMESTAMP), 15, 'MINUTE')" + }, + { + "dialect": "snowflake", + "sql": "SELECT TIME_SLICE(CAST('2024-05-09' AS DATE), 1, 'DAY')", + "formatted": "SELECT TIME_SLICE(CAST('2024-05-09' AS DATE), 1, 'DAY')" + }, + { + "dialect": "snowflake", + "sql": "SELECT TIME_SLICE(CAST('2024-05-09 08:50:57.891' AS TIMESTAMP), 1, 'HOUR', 'start')", + "formatted": "SELECT\n TIME_SLICE(\n CAST('2024-05-09 08:50:57.891' AS TIMESTAMP),\n 1,\n 'HOUR',\n 'start'\n )" + }, + { + "dialect": "snowflake", + "sql": "SELECT * EXCLUDE foo RENAME bar AS baz FROM tbl", + "formatted": "SELECT * EXCLUDE foo RENAME bar AS baz\nFROM tbl" + }, + { + "dialect": "snowflake", + "sql": "SELECT LIKE(col, 'pattern')", + "formatted": "SELECT LIKE(col, 'pattern')" + }, + { + "dialect": "snowflake", + "sql": "SELECT ILIKE(col, 'pattern')", + "formatted": "SELECT ILIKE(col, 'pattern')" + }, + { + "dialect": "snowflake", + "sql": "SELECT LIKE(col, 'pattern', '\\\\')", + "formatted": "SELECT LIKE(col, 'pattern', '\\\\')" + }, + { + "dialect": "snowflake", + "sql": "SELECT ILIKE(col, 'pattern', '\\\\')", + "formatted": "SELECT ILIKE(col, 'pattern', '\\\\')" + }, + { + "dialect": "snowflake", + "sql": "SELECT LIKE(col, 'pattern', '!')", + "formatted": "SELECT LIKE(col, 'pattern', '!')" + }, + { + "dialect": "snowflake", + "sql": "SELECT ILIKE(col, 'pattern', '!')", + "formatted": "SELECT ILIKE(col, 'pattern', '!')" + }, + { + "dialect": "snowflake", + "sql": "SELECT BASE64_ENCODE('Hello World')", + "formatted": "SELECT BASE64_ENCODE('Hello World')" + }, + { + "dialect": "snowflake", + "sql": "SELECT TRY_HEX_DECODE_BINARY('48656C6C6F')", + "formatted": "SELECT TRY_HEX_DECODE_BINARY('48656C6C6F')" + }, + { + "dialect": "snowflake", + "sql": "SELECT TRY_HEX_DECODE_STRING('48656C6C6F')", + "formatted": "SELECT TRY_HEX_DECODE_STRING('48656C6C6F')" + }, + { + "dialect": "snowflake", + "sql": "SELECT ARRAY_CONTAINS(1, [1])", + "formatted": "SELECT ARRAY_CONTAINS(1, [1])" + }, + { + "dialect": "snowflake", + "sql": "SELECT ARRAY_DISTINCT(['A', 'B', 'A'])", + "formatted": "SELECT ARRAY_DISTINCT(['A', 'B', 'A'])" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_DATABASE()", + "formatted": "SELECT CURRENT_DATABASE()" + }, + { + "dialect": "snowflake", + "sql": "SELECT CURRENT_SCHEMA()", + "formatted": "SELECT CURRENT_SCHEMA()" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM @\"mystage\"", + "formatted": "SELECT *\nFROM @\"mystage\"" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM @\"myschema\".\"mystage\"/file.gz", + "formatted": "SELECT *\nFROM @\"myschema\".\"mystage\"/file.gz" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM @\"my_DB\".\"schEMA1\".mystage/file.gz", + "formatted": "SELECT *\nFROM @\"my_DB\".\"schEMA1\".mystage/file.gz" + }, + { + "dialect": "snowflake", + "sql": "SELECT metadata$filename FROM @s1/", + "formatted": "SELECT metadata$filename\nFROM @s1/" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM @~", + "formatted": "SELECT *\nFROM @~" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM @~/some/path/to/file.csv", + "formatted": "SELECT *\nFROM @~/some/path/to/file.csv" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM @mystage", + "formatted": "SELECT *\nFROM @mystage" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM '@mystage'", + "formatted": "SELECT *\nFROM '@mystage'" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM @namespace.mystage/path/to/file.json.gz", + "formatted": "SELECT *\nFROM @namespace.mystage/path/to/file.json.gz" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM @namespace.%table_name/path/to/file.json.gz", + "formatted": "SELECT *\nFROM @namespace.%table_name/path/to/file.json.gz" + }, + { + "dialect": "snowflake", + "sql": "SELECT $1, $2, metadata$filename FROM @mystage (PATTERN => '.*data-100.*')", + "formatted": "SELECT\n $1,\n $2,\n metadata$filename\nFROM @mystage (PATTERN => '.*data-100.*')" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM '@external/location' (FILE_FORMAT => 'path.to.csv')", + "formatted": null + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM (SELECT a FROM @foo)", + "formatted": "SELECT *\nFROM (\n SELECT a\n FROM @foo\n)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM (SELECT * FROM '@external/location' (FILE_FORMAT => 'path.to.csv'))", + "formatted": null + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM @foo/bar (FILE_FORMAT => ds_sandbox.test.my_csv_format, PATTERN => 'test') AS bla", + "formatted": "SELECT *\nFROM @foo/bar (FILE_FORMAT => ds_sandbox.test.my_csv_format, PATTERN => 'test') AS bla" + }, + { + "dialect": "snowflake", + "sql": "SELECT t.$1, t.$2 FROM @mystage1 (FILE_FORMAT => 'myformat', PATTERN => '.*data.*[.]csv.gz') AS t", + "formatted": "SELECT\n t.$1,\n t.$2\nFROM @mystage1 (FILE_FORMAT => 'myformat', PATTERN => '.*data.*[.]csv.gz') AS t" + }, + { + "dialect": "snowflake", + "sql": "SELECT parse_json($1):a.b FROM @mystage2/data1.json.gz", + "formatted": "SELECT parse_json($1):a.b\nFROM @mystage2/data1.json.gz" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM @mystage t (c1)", + "formatted": "SELECT *\nFROM @mystage t(c1)", + "previously_formatted": "SELECT\n *\nFROM @mystage AS t(c1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM @foo/bar (PATTERN => 'test', FILE_FORMAT => ds_sandbox.test.my_csv_format) AS bla", + "formatted": "SELECT *\nFROM @foo/bar (PATTERN => 'test', FILE_FORMAT => ds_sandbox.test.my_csv_format) AS bla" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM @test.public.thing/location/somefile.csv( FILE_FORMAT => 'fmt' )", + "formatted": "SELECT *\nFROM @test.public.thing/location/somefile.csv (FILE_FORMAT => 'fmt')" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM testtable TABLESAMPLE BERNOULLI (20.3)", + "formatted": "SELECT *\nFROM testtable TABLESAMPLE BERNOULLI (20.3)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM testtable TABLESAMPLE SYSTEM (3) SEED (82)", + "formatted": "SELECT *\nFROM testtable TABLESAMPLE SYSTEM (3) SEED (82)" + }, + { + "dialect": "snowflake", + "sql": "SELECT a FROM test PIVOT(SUM(x) FOR y IN ('z', 'q')) AS x TABLESAMPLE BERNOULLI (0.1)", + "formatted": "SELECT a\nFROM test PIVOT(SUM(x) FOR y IN ('z', 'q')) AS x TABLESAMPLE BERNOULLI (0.1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT i, j FROM table1 AS t1 INNER JOIN table2 AS t2 TABLESAMPLE BERNOULLI (50) WHERE t2.j = t1.i", + "formatted": "SELECT\n i,\n j\nFROM table1 AS t1\nINNER JOIN table2 AS t2 TABLESAMPLE BERNOULLI (50)\nWHERE t2.j = t1.i" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM (SELECT * FROM t1 JOIN t2 ON t1.a = t2.c) TABLESAMPLE BERNOULLI (1)", + "formatted": "SELECT *\nFROM (\n SELECT *\n FROM t1\n JOIN t2\n ON t1.a = t2.c\n) TABLESAMPLE BERNOULLI (1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM testtable TABLESAMPLE (10 ROWS)", + "formatted": "SELECT *\nFROM testtable TABLESAMPLE (10 ROWS)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM testtable TABLESAMPLE (100)", + "formatted": "SELECT *\nFROM testtable TABLESAMPLE (100)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM testtable SAMPLE (10)", + "formatted": "SELECT *\nFROM testtable SAMPLE (10)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM testtable SAMPLE ROW (0)", + "formatted": "SELECT *\nFROM testtable SAMPLE ROW (0)" + }, + { + "dialect": "snowflake", + "sql": "SELECT a FROM test SAMPLE BLOCK (0.5) SEED (42)", + "formatted": "SELECT a\nFROM test SAMPLE BLOCK (0.5) SEED (42)" + }, + { + "dialect": "snowflake", + "sql": "SELECT user_id, value FROM table_name SAMPLE BERNOULLI ($s) SEED (0)", + "formatted": "SELECT\n user_id,\n value\nFROM table_name SAMPLE BERNOULLI ($s) SEED (0)" + }, + { + "dialect": "snowflake", + "sql": "SELECT CAST('12:00:00' AS TIME)", + "formatted": "SELECT CAST('12:00:00' AS TIME)" + }, + { + "dialect": "snowflake", + "sql": "SELECT DATE_PART(month, a)", + "formatted": "SELECT DATE_PART(month, a)" + }, + { + "dialect": "snowflake", + "sql": "SELECT TO_TIME(x) FROM t", + "formatted": "SELECT TO_TIME(x)\nFROM t" + }, + { + "dialect": "snowflake", + "sql": "SELECT TO_DATE('2019-02-28') + INTERVAL '1 day, 1 year'", + "formatted": "SELECT TO_DATE('2019-02-28') + INTERVAL '1 day, 1 year'" + }, + { + "dialect": "snowflake", + "sql": "SELECT CAST(a AS VARIANT)", + "formatted": "SELECT CAST(a AS VARIANT)" + }, + { + "dialect": "snowflake", + "sql": "SELECT CAST(a AS ARRAY)", + "formatted": "SELECT CAST(a AS ARRAY)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM my_table AT (STATEMENT => $query_id_var)", + "formatted": "SELECT *\nFROM my_table AT (STATEMENT => $query_id_var)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM my_table AT (OFFSET => -60 * 5)", + "formatted": "SELECT *\nFROM my_table AT (OFFSET => -60 * 5)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM my_table BEFORE (STATEMENT => $query_id_var)", + "formatted": "SELECT *\nFROM my_table BEFORE (STATEMENT => $query_id_var)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM my_table BEFORE (OFFSET => -60 * 5)", + "formatted": "SELECT *\nFROM my_table BEFORE (OFFSET => -60 * 5)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM my_table AT (TIMESTAMP => TO_TIMESTAMP(1432669154242, 3))", + "formatted": "SELECT *\nFROM my_table AT (TIMESTAMP => TO_TIMESTAMP(1432669154242, 3))" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM my_table AT (OFFSET => -60 * 5) AS T WHERE T.flag = 'valid'", + "formatted": "SELECT *\nFROM my_table AT (OFFSET => -60 * 5) AS T\nWHERE T.flag = 'valid'" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM my_table AT (STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726')", + "formatted": "SELECT *\nFROM my_table AT (STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726')" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM my_table BEFORE (STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726')", + "formatted": "SELECT *\nFROM my_table BEFORE (STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726')" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM my_table AT (TIMESTAMP => 'Fri, 01 May 2015 16:20:00 -0700'::timestamp)", + "formatted": "SELECT *\nFROM my_table AT (TIMESTAMP => 'Fri, 01 May 2015 16:20:00 -0700'::timestamp)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM my_table AT(TIMESTAMP => 'Fri, 01 May 2015 16:20:00 -0700'::timestamp_tz)", + "formatted": "SELECT *\nFROM my_table AT (TIMESTAMP => 'Fri, 01 May 2015 16:20:00 -0700'::timestamp_tz)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM my_table BEFORE (TIMESTAMP => 'Fri, 01 May 2015 16:20:00 -0700'::timestamp_tz);", + "formatted": "SELECT *\nFROM my_table BEFORE (TIMESTAMP => 'Fri, 01 May 2015 16:20:00 -0700'::timestamp_tz);", + "previously_formatted": "SELECT\n *\nFROM my_table BEFORE (TIMESTAMP => 'Fri, 01 May 2015 16:20:00 -0700'::timestamp_tz)" + }, + { + "dialect": "snowflake", + "sql": "\n SELECT oldt.* , newt.*\n FROM my_table BEFORE(STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726') AS oldt\n FULL OUTER JOIN my_table AT(STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726') AS newt\n ON oldt.id = newt.id\n WHERE oldt.id IS NULL OR newt.id IS NULL;\n ", + "formatted": "SELECT\n oldt.*,\n newt.*\nFROM my_table BEFORE (STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726') AS oldt\nFULL OUTER JOIN my_table AT (STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726') AS newt\n ON oldt.id = newt.id\nWHERE\n oldt.id IS NULL\n OR newt.id IS NULL;", + "previously_formatted": "SELECT\n oldt.*,\n newt.*\nFROM my_table BEFORE (STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726') AS oldt\nFULL OUTER JOIN my_table AT (STATEMENT => '8e5d0ca9-005e-44e6-b858-a8f5b37c5726') AS newt\n ON oldt.id = newt.id\nWHERE\n oldt.id IS NULL\n OR newt.id IS NULL" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM TABLE('MYTABLE')", + "formatted": "SELECT *\nFROM TABLE('MYTABLE')" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM TABLE($MYVAR)", + "formatted": "SELECT *\nFROM TABLE($MYVAR)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM TABLE(?)", + "formatted": "SELECT *\nFROM TABLE(?)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM TABLE(:BINDING)", + "formatted": null + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM TABLE($MYVAR) WHERE COL1 = 10", + "formatted": "SELECT *\nFROM TABLE($MYVAR)\nWHERE COL1 = 10" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM TABLE('t1') AS f", + "formatted": "SELECT *\nFROM TABLE('t1') AS f" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM (TABLE('t1') CROSS JOIN TABLE('t2'))", + "formatted": "SELECT *\nFROM (TABLE('t1')\nCROSS JOIN TABLE('t2'))" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM TABLE('t1'), LATERAL (SELECT * FROM t2)", + "formatted": "SELECT *\nFROM TABLE('t1'), LATERAL (\n SELECT *\n FROM t2\n)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM TABLE('t1') UNION ALL SELECT * FROM TABLE('t2')", + "formatted": "SELECT *\nFROM TABLE('t1')\nUNION ALL\nSELECT *\nFROM TABLE('t2')" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM TABLE('t1') TABLESAMPLE BERNOULLI (20.3)", + "formatted": "SELECT *\nFROM TABLE('t1') TABLESAMPLE BERNOULLI (20.3)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM TABLE('MYDB.\"MYSCHEMA\".\"MYTABLE\"')", + "formatted": "SELECT *\nFROM TABLE('MYDB.\"MYSCHEMA\".\"MYTABLE\"')" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM TABLE($$MYDB. \"MYSCHEMA\".\"MYTABLE\"$$)", + "formatted": "SELECT *\nFROM TABLE($$MYDB. \"MYSCHEMA\".\"MYTABLE\"$$)" + }, + { + "dialect": "snowflake", + "sql": "SELECT value FROM TABLE(FLATTEN(input => SELECT PARSE_JSON('[1, 2]')))", + "formatted": "SELECT value\nFROM TABLE(FLATTEN(input => SELECT\n PARSE_JSON('[1, 2]')))" + }, + { + "dialect": "snowflake", + "sql": "SELECT SEARCH((play, line), 'dream')", + "formatted": "SELECT SEARCH((play, line), 'dream')" + }, + { + "dialect": "snowflake", + "sql": "SELECT SEARCH(line, 'king', ANALYZER => 'UNICODE_ANALYZER')", + "formatted": "SELECT SEARCH(line, 'king', ANALYZER => 'UNICODE_ANALYZER')" + }, + { + "dialect": "snowflake", + "sql": "SELECT SEARCH(character, 'king queen', SEARCH_MODE => 'AND')", + "formatted": "SELECT SEARCH(character, 'king queen', SEARCH_MODE => 'AND')" + }, + { + "dialect": "snowflake", + "sql": "SELECT SEARCH(line, 'king', ANALYZER => 'UNICODE_ANALYZER', SEARCH_MODE => 'OR')", + "formatted": "SELECT\n SEARCH(\n line,\n 'king',\n ANALYZER => 'UNICODE_ANALYZER',\n SEARCH_MODE => 'OR'\n )" + }, + { + "dialect": "snowflake", + "sql": "SELECT SEARCH(line, 'king')", + "formatted": "SELECT SEARCH(line, 'king')" + }, + { + "dialect": "snowflake", + "sql": "SELECT SEARCH(line, 'king', ANALYZER => 'UNICODE_ANALYZER')", + "formatted": "SELECT SEARCH(line, 'king', ANALYZER => 'UNICODE_ANALYZER')" + }, + { + "dialect": "snowflake", + "sql": "SELECT SEARCH(character, 'king queen', SEARCH_MODE => 'AND')", + "formatted": "SELECT SEARCH(character, 'king queen', SEARCH_MODE => 'AND')" + }, + { + "dialect": "snowflake", + "sql": "SELECT SEARCH(line, 'king', SEARCH_MODE => 'AND', ANALYZER => 'PATTERN_ANALYZER')", + "formatted": "SELECT\n SEARCH(\n line,\n 'king',\n SEARCH_MODE => 'AND',\n ANALYZER => 'PATTERN_ANALYZER'\n )" + }, + { + "dialect": "snowflake", + "sql": "SELECT REGEXP_COUNT('hello world', 'l ')", + "formatted": "SELECT REGEXP_COUNT('hello world', 'l ')" + }, + { + "dialect": "snowflake", + "sql": "SELECT REGEXP_COUNT('hello world', 'l', 1)", + "formatted": "SELECT REGEXP_COUNT('hello world', 'l', 1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT REGEXP_COUNT('hello world', 'l', 1, 'i')", + "formatted": "SELECT REGEXP_COUNT('hello world', 'l', 1, 'i')" + }, + { + "dialect": "snowflake", + "sql": "SELECT REGEXP_INSTR('abc', 'a')", + "formatted": "SELECT REGEXP_INSTR('abc', 'a')" + }, + { + "dialect": "snowflake", + "sql": "SELECT REGEXP_INSTR('abc', 'a', 1, 1, 0, 'i')", + "formatted": "SELECT REGEXP_INSTR('abc', 'a', 1, 1, 0, 'i')" + }, + { + "dialect": "snowflake", + "sql": "SELECT TRY_CAST(x AS DOUBLE)", + "formatted": "SELECT TRY_CAST(x AS DOUBLE)" + }, + { + "dialect": "snowflake", + "sql": "SELECT TRY_CAST(FOO() AS TEXT)", + "formatted": "SELECT TRY_CAST(FOO() AS TEXT)" + }, + { + "dialect": "snowflake", + "sql": "SELECT $1", + "formatted": "SELECT $1" + }, + { + "dialect": "snowflake", + "sql": "SELECT $1.elem", + "formatted": "SELECT $1.elem" + }, + { + "dialect": "snowflake", + "sql": "SELECT $1:a.b", + "formatted": "SELECT $1:a.b" + }, + { + "dialect": "snowflake", + "sql": "SELECT t.$23:a.b", + "formatted": "SELECT t.$23:a.b" + }, + { + "dialect": "snowflake", + "sql": "SELECT t.$17:a[0].b[0].c", + "formatted": "SELECT t.$17:a[0].b[0].c" + }, + { + "dialect": "snowflake", + "sql": "SELECT C1 FROM t1 CHANGES (INFORMATION => APPEND_ONLY) AT (STREAM => 's1') END (TIMESTAMP => $ts2)", + "formatted": "SELECT C1\nFROM t1 CHANGES (INFORMATION => APPEND_ONLY) AT (STREAM => 's1') END (TIMESTAMP => $ts2)" + }, + { + "dialect": "snowflake", + "sql": "SELECT C1 FROM t1 CHANGES (INFORMATION => APPEND_ONLY) BEFORE (STATEMENT => 'STMT_ID') END (TIMESTAMP => $ts2)", + "formatted": "SELECT C1\nFROM t1 CHANGES (INFORMATION => APPEND_ONLY) BEFORE (STATEMENT => 'STMT_ID') END (TIMESTAMP => $ts2)" + }, + { + "dialect": "snowflake", + "sql": "SELECT 1 FROM some_table CHANGES (INFORMATION => APPEND_ONLY) AT (TIMESTAMP => TO_TIMESTAMP_TZ('2024-07-01 00:00:00+00:00')) END (TIMESTAMP => TO_TIMESTAMP_TZ('2024-07-01 14:28:59.999999+00:00'))", + "formatted": "SELECT 1\nFROM some_table CHANGES (INFORMATION => APPEND_ONLY) AT (TIMESTAMP => TO_TIMESTAMP_TZ('2024-07-01 00:00:00+00:00')) END (TIMESTAMP => TO_TIMESTAMP_TZ('2024-07-01 14:28:59.999999+00:00'))" + }, + { + "dialect": "snowflake", + "sql": "SELECT :1", + "formatted": null + }, + { + "dialect": "snowflake", + "sql": "SELECT :1, :2", + "formatted": null + }, + { + "dialect": "snowflake", + "sql": "SELECT :1 + :2", + "formatted": null + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM SEMANTIC_VIEW(foo METRICS a.b, a.c DIMENSIONS a.b, a.c WHERE a.b > '1995-01-01')", + "formatted": "SELECT *\nFROM SEMANTIC_VIEW(\n foo\n METRICS a.b, a.c\n DIMENSIONS a.b, a.c\n WHERE a.b > '1995-01-01'\n)" + }, + { + "dialect": "snowflake", + "sql": "SELECT col1, col2, metric1 FROM SEMANTIC_VIEW(mydb.myschema.my_semantic_view METRICS metric1 DIMENSIONS col1, DATE_TRUNC('MONTH', timestamp_col) AS col2) ORDER BY col1, col2 DESC", + "formatted": "SELECT\n col1,\n col2,\n metric1\nFROM SEMANTIC_VIEW(\n mydb.myschema.my_semantic_view\n METRICS metric1\n DIMENSIONS col1, DATE_TRUNC('MONTH', timestamp_col) AS col2\n)\nORDER BY\n col1,\n col2 DESC" + }, + { + "dialect": "snowflake", + "sql": "SELECT model!mladmin", + "formatted": "SELECT model!mladmin" + }, + { + "dialect": "snowflake", + "sql": "SELECT model!PREDICT(1)", + "formatted": "SELECT model!PREDICT(1)" + }, + { + "dialect": "snowflake", + "sql": "SELECT m!PREDICT(INPUT_DATA => {*}) AS p FROM tbl", + "formatted": "SELECT m!PREDICT(INPUT_DATA => {*}) AS p\nFROM tbl" + }, + { + "dialect": "snowflake", + "sql": "SELECT m!PREDICT(INPUT_DATA => {tbl.*}) AS p FROM tbl", + "formatted": "SELECT m!PREDICT(INPUT_DATA => {tbl.*}) AS p\nFROM tbl" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM TABLE(model_trained_with_labeled_data!DETECT_ANOMALIES(INPUT_DATA => TABLE(view_with_data_to_analyze), TIMESTAMP_COLNAME => 'date', TARGET_COLNAME => 'sales', CONFIG_OBJECT => OBJECT_CONSTRUCT('prediction_interval', 0.99)))", + "formatted": "SELECT *\nFROM TABLE(model_trained_with_labeled_data!DETECT_ANOMALIES(\n INPUT_DATA => TABLE(view_with_data_to_analyze),\n TIMESTAMP_COLNAME => 'date',\n TARGET_COLNAME => 'sales',\n CONFIG_OBJECT => OBJECT_CONSTRUCT('prediction_interval', 0.99)\n))" + }, + { + "dialect": "snowflake", + "sql": "SELECT 1 FROM TABLE(GENERATOR(ROWCOUNT => 10))", + "formatted": "SELECT 1\nFROM TABLE(GENERATOR(ROWCOUNT => 10))" + }, + { + "dialect": "snowflake", + "sql": "SELECT 1 FROM TABLE(GENERATOR(TIMELIMIT => 5))", + "formatted": "SELECT 1\nFROM TABLE(GENERATOR(TIMELIMIT => 5))" + }, + { + "dialect": "snowflake", + "sql": "SELECT 1 FROM TABLE(GENERATOR(ROWCOUNT => 10, TIMELIMIT => 5))", + "formatted": "SELECT 1\nFROM TABLE(GENERATOR(ROWCOUNT => 10, TIMELIMIT => 5))" + }, + { + "dialect": "snowflake", + "sql": "SELECT 1 FROM TABLE(GENERATOR(10))", + "formatted": "SELECT 1\nFROM TABLE(GENERATOR(10))" + }, + { + "dialect": "snowflake", + "sql": "SELECT 1 FROM TABLE(GENERATOR(10, 5))", + "formatted": "SELECT 1\nFROM TABLE(GENERATOR(10, 5))" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM a CROSS DIRECTED JOIN b USING (id)", + "formatted": "SELECT *\nFROM a\nCROSS DIRECTED JOIN b\n USING (id)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM a INNER DIRECTED JOIN b USING (id)", + "formatted": "SELECT *\nFROM a\nINNER DIRECTED JOIN b\n USING (id)" + }, + { + "dialect": "snowflake", + "sql": "SELECT * FROM a NATURAL INNER DIRECTED JOIN b USING (id)", + "formatted": "SELECT *\nFROM a\nNATURAL INNER DIRECTED JOIN b\n USING (id)" + }, + { + "dialect": "snowflake", + "sql": "SELECT TRY_PARSE_JSON('{\"x: 1}')", + "formatted": "SELECT TRY_PARSE_JSON('{\"x: 1}')" + }, + { + "dialect": "snowflake", + "sql": "SELECT number", + "formatted": "SELECT number" + }, + { + "dialect": "snowflake", + "sql": "SELECT TO_TIMESTAMP(123.4)", + "formatted": "SELECT TO_TIMESTAMP(123.4)" + }, + { + "dialect": "snowflake", + "sql": "SELECT DATE_PART(year FROM CAST('2024-04-08' AS DATE))", + "formatted": "SELECT DATE_PART(year FROM CAST('2024-04-08' AS DATE))" + }, + { + "dialect": "snowflake", + "sql": "SELECT DATE_PART('month' FROM CAST('2024-04-08' AS DATE))", + "formatted": "SELECT DATE_PART('month' FROM CAST('2024-04-08' AS DATE))" + }, + { + "dialect": "snowflake", + "sql": "SELECT DATE_PART(day FROM a)", + "formatted": "SELECT DATE_PART(day FROM a)" + }, + { + "dialect": "snowflake", + "sql": "SELECT SEARCH_IP(col, '192.168.0.0')", + "formatted": "SELECT SEARCH_IP(col, '192.168.0.0')" + }, + { + "dialect": "snowflake", + "sql": "SELECT DATEADD(DAY, -7, DATEADD(t.m, 1, CAST('2023-01-03' AS DATE))) FROM (SELECT 'month' AS m) AS t", + "formatted": "SELECT DATEADD(DAY, -7, DATEADD(t.m, 1, CAST('2023-01-03' AS DATE)))\nFROM (\n SELECT 'month' AS m\n) AS t" + }, + { + "dialect": "snowflake", + "sql": "SELECT FILE_URL FROM DIRECTORY(@mystage) WHERE SIZE > 100000", + "formatted": "SELECT FILE_URL\nFROM DIRECTORY(@mystage)\nWHERE SIZE > 100000" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM a..b", + "formatted": "SELECT *\nFROM a..b" + }, + { + "dialect": "tsql", + "sql": "SELECT TOP (SELECT 1) * FROM t", + "formatted": "SELECT TOP (\n SELECT 1\n)\n *\nFROM t" + }, + { + "dialect": "tsql", + "sql": "SELECT ATN2(x, y)", + "formatted": "SELECT ATN2(x, y)" + }, + { + "dialect": "tsql", + "sql": "SELECT EXP(1)", + "formatted": "SELECT EXP(1)" + }, + { + "dialect": "tsql", + "sql": "SELECT SYSDATETIMEOFFSET()", + "formatted": "SELECT SYSDATETIMEOFFSET()" + }, + { + "dialect": "tsql", + "sql": "SELECT COMPRESS('Hello World')", + "formatted": "SELECT COMPRESS('Hello World')" + }, + { + "dialect": "tsql", + "sql": "SELECT TRIM(' test ') AS Result", + "formatted": "SELECT TRIM(' test ') AS Result" + }, + { + "dialect": "tsql", + "sql": "SELECT TRIM('.,! ' FROM ' # test .') AS Result", + "formatted": "SELECT TRIM('.,! ' FROM ' # test .') AS Result" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM t TABLESAMPLE (10 PERCENT)", + "formatted": "SELECT *\nFROM t TABLESAMPLE (10 PERCENT)" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM t TABLESAMPLE (20 ROWS)", + "formatted": "SELECT *\nFROM t TABLESAMPLE (20 ROWS)" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM t TABLESAMPLE (10 PERCENT) REPEATABLE (123)", + "formatted": "SELECT *\nFROM t TABLESAMPLE (10 PERCENT) REPEATABLE (123)" + }, + { + "dialect": "tsql", + "sql": "SELECT CONCAT(column1, column2)", + "formatted": "SELECT CONCAT(column1, column2)" + }, + { + "dialect": "tsql", + "sql": "SELECT TestSpecialChar.Test# FROM TestSpecialChar", + "formatted": "SELECT TestSpecialChar.Test#\nFROM TestSpecialChar" + }, + { + "dialect": "tsql", + "sql": "SELECT TestSpecialChar.Test@ FROM TestSpecialChar", + "formatted": "SELECT TestSpecialChar.Test@\nFROM TestSpecialChar" + }, + { + "dialect": "tsql", + "sql": "SELECT TestSpecialChar.Test$ FROM TestSpecialChar", + "formatted": "SELECT TestSpecialChar.Test$\nFROM TestSpecialChar" + }, + { + "dialect": "tsql", + "sql": "SELECT TestSpecialChar.Test_ FROM TestSpecialChar", + "formatted": "SELECT TestSpecialChar.Test_\nFROM TestSpecialChar" + }, + { + "dialect": "tsql", + "sql": "SELECT TOP (2 + 1) 1", + "formatted": "SELECT TOP (2 + 1) 1" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM t WHERE NOT c", + "formatted": "SELECT *\nFROM t\nWHERE NOT c" + }, + { + "dialect": "tsql", + "sql": "WITH t1 AS (SELECT 1 AS a), t2 AS (SELECT 1 AS a) SELECT TOP 10 a FROM t1 UNION ALL SELECT TOP 10 a FROM t2", + "formatted": "WITH t1 AS (\n SELECT 1 AS a\n),\n\nt2 AS (\n SELECT 1 AS a\n)\n\nSELECT TOP 10 a\nFROM t1\nUNION ALL\nSELECT TOP 10 a\nFROM t2" + }, + { + "dialect": "tsql", + "sql": "SELECT TOP 10 s.RECORDID, n.c.VALUE('(/*:FORM_ROOT/*:SOME_TAG)[1]', 'float') AS SOME_TAG_VALUE FROM source_table.dbo.source_data AS s(nolock) CROSS APPLY FormContent.nodes('/*:FORM_ROOT') AS N(C)", + "formatted": "SELECT TOP 10\n s.RECORDID,\n n.c.VALUE('(/*:FORM_ROOT/*:SOME_TAG)[1]', 'float') AS SOME_TAG_VALUE\nFROM source_table.dbo.source_data AS s(nolock)\nCROSS APPLY FormContent.nodes('/*:FORM_ROOT') AS N(C)" + }, + { + "dialect": "tsql", + "sql": "WITH t1 AS (SELECT 1 AS a), t2 AS (SELECT 1 AS a) SELECT TOP 10 a FROM t1 UNION ALL SELECT TOP 10 a FROM t2 ORDER BY a DESC", + "formatted": "WITH t1 AS (\n SELECT 1 AS a\n),\n\nt2 AS (\n SELECT 1 AS a\n)\n\nSELECT TOP 10 a\nFROM t1\nUNION ALL\nSELECT TOP 10 a\nFROM t2\nORDER BY a DESC" + }, + { + "dialect": "tsql", + "sql": "WITH t1 AS (SELECT 1 AS a), t2 AS (SELECT 1 AS a) SELECT COUNT(*) FROM (SELECT TOP 10 a FROM t1 UNION ALL SELECT TOP 10 a FROM t2 ORDER BY a DESC) AS t", + "formatted": "WITH t1 AS (\n SELECT 1 AS a\n),\n\nt2 AS (\n SELECT 1 AS a\n)\n\nSELECT COUNT(*)\nFROM (\n SELECT TOP 10 a\n FROM t1\n UNION ALL\n SELECT TOP 10 a\n FROM t2\nORDER BY a DESC\n) AS t" + }, + { + "dialect": "tsql", + "sql": "SELECT 1 AS \"[x]\"", + "formatted": "SELECT 1 AS \"[x]\"" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM t WITH (TABLOCK, INDEX(myindex))", + "formatted": "SELECT *\nFROM t WITH (TABLOCK, INDEX(myindex))" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM t WITH (NOWAIT)", + "formatted": "SELECT *\nFROM t WITH (NOWAIT)" + }, + { + "dialect": "tsql", + "sql": "SELECT CASE WHEN a > 1 THEN b END", + "formatted": "SELECT CASE WHEN a > 1 THEN b END" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM taxi ORDER BY 1 OFFSET 0 ROWS FETCH NEXT 3 ROWS ONLY", + "formatted": "SELECT *\nFROM taxi\nORDER BY\n 1\nOFFSET 0 ROWS\nFETCH NEXT 3 ROWS ONLY" + }, + { + "dialect": "tsql", + "sql": "SELECT Employee_ID, Department_ID FROM @MyTableVar", + "formatted": "SELECT\n Employee_ID,\n Department_ID\nFROM @MyTableVar" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM #foo", + "formatted": "SELECT *\nFROM #foo" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM ##foo", + "formatted": "SELECT *\nFROM ##foo" + }, + { + "dialect": "tsql", + "sql": "SELECT a = 1", + "formatted": "SELECT a = 1" + }, + { + "dialect": "tsql", + "sql": "SELECT a = 1 UNION ALL SELECT a = b", + "formatted": "SELECT a = 1\nUNION ALL\nSELECT a = b" + }, + { + "dialect": "tsql", + "sql": "SELECT x FROM @MyTableVar AS m JOIN Employee ON m.EmployeeID = Employee.EmployeeID", + "formatted": "SELECT x\nFROM @MyTableVar AS m\nJOIN Employee\n ON m.EmployeeID = Employee.EmployeeID" + }, + { + "dialect": "tsql", + "sql": "SELECT DISTINCT DepartmentName, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY BaseRate) OVER (PARTITION BY DepartmentName) AS MedianCont FROM dbo.DimEmployee", + "formatted": "SELECT DISTINCT\n DepartmentName,\n PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY BaseRate) OVER (PARTITION BY DepartmentName) AS MedianCont\nFROM dbo.DimEmployee" + }, + { + "dialect": "tsql", + "sql": "SELECT \"x\".\"y\" FROM foo", + "formatted": "SELECT \"x\".\"y\"\nFROM foo" + }, + { + "dialect": "tsql", + "sql": "SELECT val FROM (VALUES ((TRUE), (FALSE), (NULL))) AS t(val)", + "formatted": "SELECT val\nFROM (\n VALUES\n ((TRUE\n), (FALSE\n), (\n NULL\n))\n) AS t(val)" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM t FOR XML PATH, BINARY BASE64, ELEMENTS XSINIL", + "formatted": "SELECT *\nFROM t\nFOR XML\n PATH,\n BINARY BASE64,\n ELEMENTS XSINIL" + }, + { + "dialect": "tsql", + "sql": "SELECT (SELECT TOP 5 a, b FROM t ORDER BY b DESC FOR JSON PATH) AS j FROM x", + "formatted": "SELECT\n (\n SELECT TOP 5\n a,\n b\n FROM t\n ORDER BY\n b DESC\n FOR JSON\n PATH\n ) AS j\nFROM x" + }, + { + "dialect": "tsql", + "sql": "SELECT j FROM (SELECT a AS j FROM t FOR JSON PATH) AS x", + "formatted": "SELECT j\nFROM (\n SELECT a AS j\n FROM t\n FOR JSON\n PATH\n) AS x" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM t FOR JSON PATH, ROOT('Root'), INCLUDE_NULL_VALUES", + "formatted": "SELECT *\nFROM t\nFOR JSON\n PATH,\n ROOT('Root'),\n INCLUDE_NULL_VALUES" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM t FOR BROWSE", + "formatted": "SELECT *\nFROM t FOR BROWSE" + }, + { + "dialect": "tsql", + "sql": "SELECT CAST(SUBSTRING('ABCD~1234', CHARINDEX('~', 'ABCD~1234') + 1, LEN('ABCD~1234')) AS BIGINT)", + "formatted": "SELECT CAST(SUBSTRING('ABCD~1234', CHARINDEX('~', 'ABCD~1234') + 1, LEN('ABCD~1234')) AS BIGINT)" + }, + { + "dialect": "tsql", + "sql": "SELECT DATEPART(\"dd\", x)", + "formatted": "SELECT DATEPART(\"dd\", x)" + }, + { + "dialect": "tsql", + "sql": "SELECT DATEADD(YEAR, 1, '2017/08/25')", + "formatted": "SELECT DATEADD(YEAR, 1, '2017/08/25')" + }, + { + "dialect": "tsql", + "sql": "SELECT DATEDIFF(HOUR, 1.5, '2021-01-01')", + "formatted": "SELECT DATEDIFF(HOUR, 1.5, '2021-01-01')" + }, + { + "dialect": "tsql", + "sql": "SELECT DATEDIFF_BIG(HOUR, 1.5, '2021-01-01')", + "formatted": "SELECT DATEDIFF_BIG(HOUR, 1.5, '2021-01-01')" + }, + { + "dialect": "tsql", + "sql": "SELECT TOP 10 PERCENT", + "formatted": "SELECT TOP 10 PERCENT" + }, + { + "dialect": "tsql", + "sql": "SELECT TOP 10 PERCENT WITH TIES", + "formatted": "SELECT TOP 10 PERCENT WITH TIES" + }, + { + "dialect": "tsql", + "sql": "SELECT FORMAT(foo, 'dddd', 'de-CH')", + "formatted": "SELECT FORMAT(foo, 'dddd', 'de-CH')" + }, + { + "dialect": "tsql", + "sql": "SELECT FORMAT(EndOfDayRate, 'N', 'en-us')", + "formatted": "SELECT FORMAT(EndOfDayRate, 'N', 'en-us')" + }, + { + "dialect": "tsql", + "sql": "SELECT FORMAT('01-01-1991', 'd.mm.yyyy')", + "formatted": "SELECT FORMAT('01-01-1991', 'd.mm.yyyy')" + }, + { + "dialect": "tsql", + "sql": "SELECT FORMAT(12345, '###.###.###')", + "formatted": "SELECT FORMAT(12345, '###.###.###')" + }, + { + "dialect": "tsql", + "sql": "SELECT FORMAT(1234567, 'f')", + "formatted": "SELECT FORMAT(1234567, 'f')" + }, + { + "dialect": "tsql", + "sql": "SELECT [x] FROM [a].[b] FOR SYSTEM_TIME AS OF 'foo'", + "formatted": "SELECT [x]\nFROM [a].[b] FOR SYSTEM_TIME AS OF 'foo'" + }, + { + "dialect": "tsql", + "sql": "SELECT [x] FROM [a].[b] FOR SYSTEM_TIME AS OF 'foo' AS alias", + "formatted": "SELECT [x]\nFROM [a].[b] FOR SYSTEM_TIME AS OF 'foo' AS alias" + }, + { + "dialect": "tsql", + "sql": "SELECT [x] FROM [a].[b] FOR SYSTEM_TIME FROM c TO d", + "formatted": "SELECT [x]\nFROM [a].[b] FOR SYSTEM_TIME FROM c TO d" + }, + { + "dialect": "tsql", + "sql": "SELECT [x] FROM [a].[b] FOR SYSTEM_TIME BETWEEN c AND d", + "formatted": "SELECT [x]\nFROM [a].[b] FOR SYSTEM_TIME BETWEEN c AND d" + }, + { + "dialect": "tsql", + "sql": "SELECT [x] FROM [a].[b] FOR SYSTEM_TIME CONTAINED IN (c, d)", + "formatted": "SELECT [x]\nFROM [a].[b] FOR SYSTEM_TIME CONTAINED IN (c, d)" + }, + { + "dialect": "tsql", + "sql": "SELECT [x] FROM [a].[b] FOR SYSTEM_TIME ALL AS alias", + "formatted": "SELECT [x]\nFROM [a].[b] FOR SYSTEM_TIME ALL AS alias" + }, + { + "dialect": "tsql", + "sql": "SELECT x FROM a INNER LOOP JOIN b ON b.id = a.id", + "formatted": "SELECT x\nFROM a\nINNER LOOP JOIN b\n ON b.id = a.id" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM OPENJSON(@json)", + "formatted": "SELECT *\nFROM OPENJSON(@json)" + }, + { + "dialect": "tsql", + "sql": "WITH t AS (SELECT 1) SELECT * FROM t", + "formatted": "WITH t AS (\n SELECT 1\n)\n\nSELECT *\nFROM t" + }, + { + "dialect": "tsql", + "sql": "WITH t AS (SELECT \"c\") SELECT * FROM t", + "formatted": "WITH t AS (\n SELECT \"c\"\n)\n\nSELECT *\nFROM t" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM (SELECT 1) AS subq", + "formatted": "SELECT *\nFROM (\n SELECT 1\n) AS subq" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM (SELECT \"c\") AS subq", + "formatted": "SELECT *\nFROM (\n SELECT \"c\"\n) AS subq" + }, + { + "dialect": "tsql", + "sql": "SELECT NEXT VALUE FOR db.schema.sequence_name OVER (ORDER BY foo), col", + "formatted": "SELECT\n NEXT VALUE FOR db.schema.sequence_name OVER (ORDER BY foo),\n col" + }, + { + "dialect": "tsql", + "sql": "SELECT COUNT(1) FROM x", + "formatted": "SELECT COUNT(1)\nFROM x" + }, + { + "dialect": "tsql", + "sql": "SELECT COUNT_BIG(1) FROM x", + "formatted": "SELECT COUNT_BIG(1)\nFROM x" + }, + { + "dialect": "tsql", + "sql": "SELECT 1; SELECT 2", + "formatted": "SELECT 1;\nSELECT 2" + }, + { + "dialect": "tsql", + "sql": "SELECT go", + "formatted": "SELECT go" + }, + { + "dialect": "tsql", + "sql": "SELECT 1 WHERE EXISTS(SELECT 1)", + "formatted": "SELECT 1\nWHERE\n EXISTS(\n SELECT 1\n )" + }, + { + "dialect": "tsql", + "sql": "SELECT a = 1", + "formatted": "SELECT a = 1" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM [#temp_table]", + "formatted": "SELECT *\nFROM [#temp_table]" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM [##temp_table]", + "formatted": "SELECT *\nFROM [##temp_table]" + }, + { + "dialect": "tsql", + "sql": "SELECT * FROM @x", + "formatted": "SELECT *\nFROM @x" + } +] diff --git a/crates/sqlclean/src/sql_lint/tests/helpers.rs b/crates/sqlclean/src/sql_lint/tests/helpers.rs new file mode 100644 index 0000000..bb4c1b9 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/helpers.rs @@ -0,0 +1,304 @@ +use std::str::FromStr; + +use polyglot_sql::{Dialect, DialectType}; +use serde_json::{Map, Value, json}; + +use crate::sql_lint::_helpers::formatter::format_requests; +use crate::sql_lint::main::engine::lint_json; +use crate::sql_lint::main::formatter::format_json; +use crate::sql_lint::models::FormatRequest; +use crate::sql_lint::tests::test_types::CrossJoinTestCase; +use crate::sql_tokens::constants::CALL_SYNTAX_FUNCTIONS; +use crate::sql_tokens::main::builtin_function_names::builtin_function_names; +use crate::sql_tokens::main::canonical_tokens::canonical_tokens; +use crate::sql_tokens::main::query_fingerprint::query_fingerprint; + +/// The dependency sentinel the cross join cases name `order_policy`. +pub(crate) const POLICY_SENTINEL: &str = "__sqb_lint_7__"; + +/// Format a JSON array of format requests in parallel, as a JSON array of responses. +pub(crate) fn format_batch_json(request_json: &str) -> Result { + let requests: Vec = + serde_json::from_str(request_json).map_err(|error| error.to_string())?; + serde_json::to_string(&format_requests(requests)).map_err(|error| error.to_string()) +} + +pub(crate) fn nested_function_sql(depth: usize) -> String { + format!("SELECT {}1{}", "F(".repeat(depth), ")".repeat(depth)) +} + +pub(crate) fn diagnostics(sql: &str) -> Result, String> { + let response = lint_json( + &json!({ + "version": 1, + "sql": sql, + "dialect": "snowflake" + }) + .to_string(), + )?; + diagnostic_values(&response) +} + +pub(crate) fn diagnostics_for_rules(sql: &str, rules: &[&str]) -> Result, String> { + diagnostics_for_dialect(sql, "snowflake", rules) +} + +pub(crate) fn diagnostics_for_dialect( + sql: &str, + dialect: &str, + rules: &[&str], +) -> Result, String> { + let request = json!({ + "version": 1, + "sql": sql, + "dialect": dialect, + "enabled_rules": rules, + }); + let response = lint_json(&request.to_string())?; + diagnostic_values(&response) +} + +fn diagnostic_values(response: &str) -> Result, String> { + let payload: Value = serde_json::from_str(response).map_err(|error| error.to_string())?; + payload["diagnostics"] + .as_array() + .cloned() + .ok_or_else(|| "diagnostics should be an array".to_string()) +} + +/// A narrow width so that wrapping is exercised on most corpus queries. +const CORPUS_LINE_WIDTH: usize = 40; + +/// Format every corpus query of one dialect and return the formatted and refused counts. +pub(crate) fn check_dialect_corpus( + corpus: &[Value], + dialect: &str, +) -> Result<(usize, usize), String> { + let mut outcomes: Vec = Vec::new(); + for entry in corpus.iter().filter(|entry| entry["dialect"] == dialect) { + let sql = entry["sql"].as_str().unwrap_or_default(); + let response = format_once(sql, dialect, None)?; + let formatted = response["formatted"] == true; + let actual = response + .get("sql") + .filter(|_| formatted) + .cloned() + .unwrap_or(Value::Null); + let expected = entry["formatted"].as_str().map_or(Value::Null, Value::from); + assert_eq!(actual, expected, "{dialect}: {sql}"); + let printed = actual.as_str().unwrap_or(sql); + assert!( + only_keyword_case_differs(sql, printed, dialect)?, + "{dialect} fingerprint changed beyond keyword case: {sql}" + ); + let previous_layout: Option = entry["previously_formatted"] + .as_str() + .map(|previous| relaid_previous_layout(previous, dialect)) + .transpose()?; + let layouts = [ + entry["formatted"].as_str().map(str::to_string), + previous_layout, + ]; + for stable in layouts.iter().flatten() { + let stable = stable.as_str(); + let again = format_once(stable, dialect, None)?; + assert_eq!(again["sql"], stable, "{dialect} layout drifted: {stable}"); + assert_eq!( + again["changed"], false, + "{dialect} layout drifted: {stable}" + ); + assert_eq!( + query_fingerprint(again["sql"].as_str().unwrap_or_default(), dialect)?, + query_fingerprint(stable, dialect)?, + "{dialect} formatting changed the fingerprint of formatted SQL: {stable}" + ); + } + let wrapped = format_once(sql, dialect, Some(CORPUS_LINE_WIDTH))?; + assert_eq!(wrapped["formatted"], formatted, "{dialect} wrapped: {sql}"); + let wrapped_sql = wrapped["sql"].as_str().unwrap_or_default(); + let rewrapped = format_once(wrapped_sql, dialect, Some(CORPUS_LINE_WIDTH))?; + assert_eq!( + rewrapped["sql"], wrapped_sql, + "{dialect} wrapping drifted: {sql}" + ); + outcomes.push(formatted); + } + let formatted = outcomes.iter().filter(|outcome| **outcome).count(); + Ok((formatted, outcomes.len() - formatted)) +} + +/// Whether `printed` has the fingerprint tokens of `sql` except for words upper-cased by format. +fn only_keyword_case_differs(sql: &str, printed: &str, dialect: &str) -> Result { + let parser = Dialect::get(DialectType::from_str(dialect).map_err(|error| error.to_string())?); + let left_tokens = parser.tokenize(sql).map_err(|error| error.to_string())?; + let right_tokens = parser + .tokenize(printed) + .map_err(|error| error.to_string())?; + let left = canonical_tokens(sql, &left_tokens, &parser)?; + let right = canonical_tokens(printed, &right_tokens, &parser)?; + Ok(left.len() == right.len() + && left.iter().zip(&right).all(|(before, after)| { + before == after + || (before.text.eq_ignore_ascii_case(&after.text) + && after.text == after.text.to_ascii_uppercase()) + })) +} + +/// Re-format a layout of an earlier formatter release, which must keep its fingerprint. +fn relaid_previous_layout(previous: &str, dialect: &str) -> Result { + let relaid = format_once(previous, dialect, None)?; + let sql = relaid["sql"].as_str().unwrap_or_default().to_string(); + assert_eq!( + query_fingerprint(&sql, dialect)?, + query_fingerprint(previous, dialect)?, + "{dialect} formatting changed the fingerprint of a previous layout: {previous}" + ); + Ok(sql) +} + +pub(crate) fn format_at_width( + sql: &str, + dialect: &str, + line_width: usize, +) -> Result { + format_once(sql, dialect, Some(line_width)) +} + +fn format_once(sql: &str, dialect: &str, line_width: Option) -> Result { + let request = json!({"version": 1, "sql": sql, "dialect": dialect, "line_width": line_width}); + let response = format_json(&request.to_string())?; + serde_json::from_str(&response).map_err(|error| error.to_string()) +} + +/// Call names the formatter treats as built-ins in `dialect`, plus keyword-like call names. +const KEYWORD_LIKE_CALLS: [&str; 11] = [ + "if", + "iff", + "left", + "right", + "replace", + "insert", + "format", + "filter", + "date", + "time", + "timestamp", +]; + +/// Return names no 2/1/0-argument call could format, and calls printed with misplaced spaces. +pub(crate) fn function_call_spacing( + dialect: &str, + line_width: usize, + argument: &str, +) -> Result<(Vec, Vec), String> { + let dialect_type = DialectType::from_str(dialect).map_err(|error| error.to_string())?; + let mut names: Vec = builtin_function_names(dialect_type) + .into_iter() + .chain(KEYWORD_LIKE_CALLS.iter().map(|name| (*name).to_string())) + .filter(|name| name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')) + .filter(|name| !CALL_SYNTAX_FUNCTIONS.contains(&name.to_ascii_uppercase().as_str())) + .collect(); + names.sort_unstable(); + names.dedup(); + let mut misplaced: Vec = Vec::new(); + for arguments in [ + format!("{argument}, 1"), + argument.to_string(), + String::new(), + ] { + let responses = format_calls(&names, &arguments, dialect, line_width)?; + misplaced.extend( + names + .iter() + .zip(&responses) + .filter(|(_, response)| response["formatted"] == true) + .filter(|(name, response)| { + !is_tight_call( + name, + response["sql"].as_str().unwrap_or_default(), + &arguments, + ) + }) + .map(|(name, response)| format!("{name}: {}", response["sql"])), + ); + names = names + .into_iter() + .zip(&responses) + .filter(|(_, response)| response["formatted"] != true) + .map(|(name, _)| name) + .collect(); + } + Ok((names, misplaced)) +} + +fn format_calls( + names: &[String], + arguments: &str, + dialect: &str, + line_width: usize, +) -> Result, String> { + let requests: Vec = names + .iter() + .map(|name| { + json!({ + "version": 1, + "sql": format!("SELECT {name}({arguments}) AS x FROM t"), + "dialect": dialect, + "line_width": line_width, + }) + }) + .collect(); + serde_json::from_str(&format_batch_json(&Value::from(requests).to_string())?) + .map_err(|error| error.to_string()) +} + +/// Whether `sql` prints `name(` in authored or upper case with no space inside the call. +fn is_tight_call(name: &str, sql: &str, arguments: &str) -> bool { + [name.to_string(), name.to_ascii_uppercase()] + .iter() + .any(|printed| { + sql.contains(&format!("{printed}({arguments})")) + || sql.contains(&format!("{printed}(\n")) + }) + && !sql.contains("( ") + && !sql + .to_ascii_uppercase() + .contains(&format!("{} (", name.to_ascii_uppercase())) +} + +/// The `SQC038` messages for one cross join case. +pub(crate) fn cross_join_messages(test_case: &CrossJoinTestCase) -> Result, String> { + cross_join_texts(test_case, "message") +} + +/// The `SQC038` remediations for one cross join case. +pub(crate) fn cross_join_remediations( + test_case: &CrossJoinTestCase, +) -> Result, String> { + cross_join_texts(test_case, "remediation") +} + +fn cross_join_texts(test_case: &CrossJoinTestCase, field: &str) -> Result, String> { + let keys: Map = test_case + .one_row_relations + .iter() + .map(|name| ((*name).to_owned(), json!([[]]))) + .collect(); + let request = json!({ + "version": 1, + "sql": test_case.sql, + "dialect": test_case.dialect, + "enabled_rules": ["SQC038"], + "relation_keys": keys, + "relation_names": { POLICY_SENTINEL: "order_policy" }, + }); + let response: Value = serde_json::from_str(&lint_json(&request.to_string())?) + .map_err(|error| error.to_string())?; + Ok(response["diagnostics"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .map(|diagnostic| diagnostic[field].as_str().unwrap_or_default().to_owned()) + .collect()) +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_authored_tokens.rs b/crates/sqlclean/src/sql_lint/tests/test_authored_tokens.rs new file mode 100644 index 0000000..cb29e9c --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/test_authored_tokens.rs @@ -0,0 +1,416 @@ +use serde_json::{Value, json}; + +use std::collections::BTreeMap; + +use crate::sql_lint::_helpers::token_layout::{ + KeywordRecase, TokenTexts, tokenize, verify_printed_tokens, +}; +use crate::sql_lint::main::formatter::format_json; +use crate::sql_lint::tests::test_types; + +#[test] +fn given_generator_respellings_when_formatting_then_authored_spellings_are_kept() +-> Result<(), String> { + let test_cases = [ + test_types::FormatTestCase { + description: "Snowflake STARTSWITH keeps its authored name", + sql: "select startswith(order_code, 'EU') as is_eu_order from orders", + expected_sql: "SELECT startswith(order_code, 'EU') AS is_eu_order\nFROM orders", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "function synonyms keep their authored names", + sql: "select SUBSTR(order_code, 1, 2) as region, TIMESTAMPDIFF(day, ordered_at, shipped_at) as days_to_ship, TRY_TO_DECIMAL(amount_text, 10, 2) as amount from orders", + expected_sql: "SELECT\n SUBSTR(order_code, 1, 2) AS region,\n TIMESTAMPDIFF(day, ordered_at, shipped_at) AS days_to_ship,\n TRY_TO_DECIMAL(amount_text, 10, 2) AS amount\nFROM orders", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "operator and function spellings stay authored", + sql: "select IFNULL(status, 'open') as status from orders where quantity != 0", + expected_sql: "SELECT IFNULL(status, 'open') AS status\nFROM orders\nWHERE quantity != 0", + expected_changed: true, + }, + ]; + for test_case in &test_cases { + let response = format_json( + &json!({"version": 1, "sql": test_case.sql, "dialect": "snowflake"}).to_string(), + )?; + let payload: Value = serde_json::from_str(&response).map_err(|error| error.to_string())?; + assert_eq!( + payload["sql"], test_case.expected_sql, + "{}", + test_case.description + ); + assert_eq!( + payload["changed"], test_case.expected_changed, + "{}", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_generator_restructuring_when_formatting_then_authored_tokens_are_laid_out() +-> Result<(), String> { + let test_cases = [ + test_types::DialectFormatTestCase { + description: "PostgreSQL comma SUBSTRING arguments are not rewritten to FROM/FOR", + dialect: "postgres", + sql: "select substring(code, 1, 2) as region from orders", + expected_sql: "SELECT SUBSTRING(code, 1, 2) AS region\nFROM orders", + }, + test_types::DialectFormatTestCase { + description: "DuckDB adjacent string literals after a quoted-in-oracle alias keep comma spacing", + dialect: "duckdb", + sql: "select sku as replace, 'north' 'east' as region, sku from products", + expected_sql: "SELECT\n sku AS replace,\n 'north' 'east' AS region,\n sku\nFROM products", + }, + test_types::DialectFormatTestCase { + description: "DuckDB adjacent string literals after a plain column keep comma spacing", + dialect: "duckdb", + sql: "select sku, 'north' 'east' as region from products", + expected_sql: "SELECT\n sku,\n 'north' 'east' AS region\nFROM products", + }, + test_types::DialectFormatTestCase { + description: "DuckDB SELECT ALL keeps ALL", + dialect: "duckdb", + sql: "select all order_id from orders", + expected_sql: "SELECT ALL order_id\nFROM orders", + }, + test_types::DialectFormatTestCase { + description: "Snowflake SELECT ALL keeps ALL", + dialect: "snowflake", + sql: "select all order_id from orders", + expected_sql: "SELECT ALL order_id\nFROM orders", + }, + test_types::DialectFormatTestCase { + description: "PostgreSQL SELECT ALL keeps ALL", + dialect: "postgres", + sql: "select all order_id from orders", + expected_sql: "SELECT ALL order_id\nFROM orders", + }, + test_types::DialectFormatTestCase { + description: "BigQuery SELECT ALL keeps ALL", + dialect: "bigquery", + sql: "select all order_id from orders", + expected_sql: "SELECT ALL order_id\nFROM orders", + }, + test_types::DialectFormatTestCase { + description: "Databricks SELECT ALL keeps ALL", + dialect: "databricks", + sql: "select all order_id from orders", + expected_sql: "SELECT all order_id\nFROM orders", + }, + test_types::DialectFormatTestCase { + description: "T-SQL SELECT ALL keeps ALL", + dialect: "tsql", + sql: "select all order_id from orders", + expected_sql: "SELECT ALL order_id\nFROM orders", + }, + test_types::DialectFormatTestCase { + description: "implicit aliases stay implicit", + dialect: "duckdb", + sql: "select order_id id, amount from orders o", + expected_sql: "SELECT\n order_id id,\n amount\nFROM orders o", + }, + test_types::DialectFormatTestCase { + description: "a trailing select comma and statement terminator are kept", + dialect: "duckdb", + sql: "select order_id, amount, from orders;", + expected_sql: "SELECT\n order_id,\n amount,\nFROM orders;", + }, + test_types::DialectFormatTestCase { + description: "adjacent authored tokens are never split", + dialect: "duckdb", + sql: "select $name as v from orders where status != 1", + expected_sql: "SELECT $name AS v\nFROM orders\nWHERE status != 1", + }, + test_types::DialectFormatTestCase { + description: "BigQuery numeric-prefixed path parts stay joined", + dialect: "bigquery", + sql: "select * from orders.archive.25_", + expected_sql: "SELECT *\nFROM orders.archive.25_", + }, + ]; + for test_case in &test_cases { + let response = format_json( + &json!({"version": 1, "sql": test_case.sql, "dialect": test_case.dialect}).to_string(), + )?; + let payload: Value = serde_json::from_str(&response).map_err(|error| error.to_string())?; + assert_eq!( + payload["sql"], test_case.expected_sql, + "{}", + test_case.description + ); + let again = format_json( + &json!({"version": 1, "sql": test_case.expected_sql, "dialect": test_case.dialect}) + .to_string(), + )?; + let repeated: Value = serde_json::from_str(&again).map_err(|error| error.to_string())?; + assert_eq!(repeated["changed"], false, "{}", test_case.description); + } + Ok(()) +} + +#[test] +fn given_case_or_line_sensitive_tokens_when_formatting_then_they_are_kept_exactly() +-> Result<(), String> { + let test_cases = [ + test_types::DialectFormatTestCase { + description: "DuckDB keyword-named aliases and qualified names keep their case", + dialect: "duckdb", + sql: "select pos as index, sku as replace, t.view from orders t", + expected_sql: "SELECT\n pos AS index,\n sku AS replace,\n t.view\nFROM orders t", + }, + test_types::DialectFormatTestCase { + description: "BigQuery keyword-named table names keep their case", + dialect: "bigquery", + sql: "select * from inventory.view", + expected_sql: "SELECT *\nFROM inventory.view", + }, + test_types::DialectFormatTestCase { + description: "Snowflake path keys keep their case", + dialect: "snowflake", + sql: "select payload:Customer.Name as name, payload:Date::string as day from orders", + expected_sql: "SELECT\n payload:Customer.Name AS name,\n payload:Date::string AS day\nFROM orders", + }, + test_types::DialectFormatTestCase { + description: "user-defined functions keep their case, built-ins are uppercased", + dialect: "bigquery", + sql: "select MyFunc(a), ds.MyUdf(b), coalesce(a, b) from orders", + expected_sql: "SELECT\n MyFunc(a),\n ds.MyUdf(b),\n COALESCE(a, b)\nFROM orders", + }, + test_types::DialectFormatTestCase { + description: "DuckDB non-reserved keyword operands keep their case before any operator", + dialect: "duckdb", + sql: "select a, Rows << 1, Rows == 1, Rows ^ 2 from t", + expected_sql: "SELECT\n a,\n Rows << 1,\n Rows == 1,\n Rows ^ 2\nFROM t", + }, + test_types::DialectFormatTestCase { + description: "BigQuery comma-joined keyword-named tables keep their case", + dialect: "bigquery", + sql: "select * from ds.a, View v", + expected_sql: "SELECT *\nFROM ds.a, View v", + }, + test_types::DialectFormatTestCase { + description: "DuckDB adjacent string literals keep their line break", + dialect: "duckdb", + sql: "select 'north'\n'east' as region from orders", + expected_sql: "SELECT\n 'north'\n 'east' AS region\nFROM orders", + }, + test_types::DialectFormatTestCase { + description: "PostgreSQL adjacent string literals keep their line break", + dialect: "postgres", + sql: "select 'north'\n 'east' as region from orders", + expected_sql: "SELECT\n 'north'\n 'east' AS region\nFROM orders", + }, + test_types::DialectFormatTestCase { + description: "Snowflake double-slash comments are kept", + dialect: "snowflake", + sql: "select order_id, // keep this note\n customer_id from orders", + expected_sql: "SELECT\n order_id, // keep this note\n customer_id\nFROM orders", + }, + test_types::DialectFormatTestCase { + description: "BigQuery hash comments are kept", + dialect: "bigquery", + sql: "select order_id, # keep this note\n customer_id from orders", + expected_sql: "SELECT\n order_id, # keep this note\n customer_id\nFROM orders", + }, + test_types::DialectFormatTestCase { + description: "T-SQL temporary table hashes are not comments", + dialect: "tsql", + sql: "select #orders.order_id from #orders", + expected_sql: "SELECT #orders.order_id\nFROM #orders", + }, + test_types::DialectFormatTestCase { + description: "DuckDB integer division and slashes inside strings are not comments", + dialect: "duckdb", + sql: "select 7 // 2 as half, 'https://example.com/a' as url from orders", + expected_sql: "SELECT\n 7 // 2 AS half,\n 'https://example.com/a' AS url\nFROM orders", + }, + test_types::DialectFormatTestCase { + description: "Snowflake double slashes inside strings are not comments", + dialect: "snowflake", + sql: "select 'https://example.com//a' as url from orders", + expected_sql: "SELECT 'https://example.com//a' AS url\nFROM orders", + }, + ]; + for test_case in &test_cases { + let response = format_json( + &json!({"version": 1, "sql": test_case.sql, "dialect": test_case.dialect}).to_string(), + )?; + let payload: Value = serde_json::from_str(&response).map_err(|error| error.to_string())?; + assert_eq!( + payload["sql"], test_case.expected_sql, + "{}", + test_case.description + ); + let again = format_json( + &json!({"version": 1, "sql": test_case.expected_sql, "dialect": test_case.dialect}) + .to_string(), + )?; + let repeated: Value = serde_json::from_str(&again).map_err(|error| error.to_string())?; + assert_eq!(repeated["changed"], false, "{}", test_case.description); + } + Ok(()) +} + +#[test] +fn given_formatted_candidates_when_checking_token_invariant_then_only_layout_may_differ() { + let test_cases = [ + test_types::TokenInvariantTestCase { + description: "whitespace and word case may change", + before: "select a,b from orders -- note", + after: "SELECT\n a,\n b\nFROM orders -- note", + recases: &[], + expected_error: None, + }, + test_types::TokenInvariantTestCase { + description: "blank lines between CTEs keep comments on their tokens", + before: "WITH orders AS (SELECT 1), -- note\n-- paid only\npaid AS (SELECT 2) SELECT * FROM paid", + after: "WITH orders AS (\n SELECT 1\n), -- note\n\n-- paid only\npaid AS (\n SELECT 2\n)\n\nSELECT *\nFROM paid", + recases: &[], + expected_error: None, + }, + test_types::TokenInvariantTestCase { + description: "a comment moved across a CTE blank line is refused", + before: "WITH orders AS (SELECT 1), -- note\npaid AS (SELECT 2) SELECT * FROM paid", + after: "WITH orders AS (\n SELECT 1\n)\n-- note\n,\n\npaid AS (\n SELECT 2\n)\n\nSELECT *\nFROM paid", + recases: &[], + expected_error: Some("native formatter could not preserve comment token attachments"), + }, + test_types::TokenInvariantTestCase { + description: "a renamed function is refused", + before: "select startswith(code, 'EU') from orders", + after: "SELECT STARTS_WITH(code, 'EU') FROM orders", + recases: &[], + expected_error: Some( + "native formatter would change authored SQL tokens, not only layout", + ), + }, + test_types::TokenInvariantTestCase { + description: "an added alias keyword is refused", + before: "select a b from orders", + after: "SELECT a AS b FROM orders", + recases: &[], + expected_error: Some( + "native formatter would change authored SQL tokens, not only layout", + ), + }, + test_types::TokenInvariantTestCase { + description: "string literal whitespace is refused", + before: "select 'a b' from orders", + after: "SELECT 'a b' FROM orders", + recases: &[], + expected_error: Some( + "native formatter would change authored SQL tokens, not only layout", + ), + }, + test_types::TokenInvariantTestCase { + description: "a recorded keyword recase from the layout guide is allowed", + before: "select a from orders order by a nulls last", + after: "SELECT a FROM orders ORDER BY a NULLS LAST", + recases: &[(7, "NULLS", true), (8, "LAST", true)], + expected_error: None, + }, + test_types::TokenInvariantTestCase { + description: "an unrecorded non-reserved keyword recase is refused", + before: "select a from orders order by a nulls last", + after: "SELECT a FROM orders ORDER BY a NULLS LAST", + recases: &[], + expected_error: Some( + "native formatter would change authored SQL tokens, not only layout", + ), + }, + test_types::TokenInvariantTestCase { + description: "a recase whose guide token is not a keyword is refused", + before: "select a from orders order by a nulls last", + after: "SELECT a FROM orders ORDER BY a NULLS LAST", + recases: &[(7, "NULLS", false), (8, "LAST", false)], + expected_error: Some( + "native formatter would change authored SQL tokens, not only layout", + ), + }, + test_types::TokenInvariantTestCase { + description: "an identifier case change is refused", + before: "select a from orders", + after: "SELECT a FROM Orders", + recases: &[], + expected_error: Some( + "native formatter would change authored SQL tokens, not only layout", + ), + }, + test_types::TokenInvariantTestCase { + description: "a path key case change is refused", + before: "select payload:customerId from orders", + after: "SELECT payload:CUSTOMERID FROM orders", + recases: &[], + expected_error: Some( + "native formatter would change authored SQL tokens, not only layout", + ), + }, + test_types::TokenInvariantTestCase { + description: "a built-in function may be recased", + before: "select coalesce(a, 0) from orders", + after: "SELECT COALESCE(a, 0) FROM orders", + recases: &[], + expected_error: None, + }, + test_types::TokenInvariantTestCase { + description: "joining adjacent string literals across a line break is refused", + before: "select 'a'\n'b' from orders", + after: "SELECT 'a' 'b' FROM orders", + recases: &[], + expected_error: Some( + "native formatter would join adjacent string literals across a line break", + ), + }, + test_types::TokenInvariantTestCase { + description: "a dropped double-slash comment is refused", + before: "select a // note\nfrom orders", + after: "SELECT a FROM orders", + recases: &[], + expected_error: Some("native formatter could not preserve comment token attachments"), + }, + test_types::TokenInvariantTestCase { + description: "a comment moved to another token is refused", + before: "select a, -- note\n b from orders", + after: "SELECT a, b -- note\nFROM orders", + recases: &[], + expected_error: Some("native formatter could not preserve comment token attachments"), + }, + ]; + let dialect = polyglot_sql::Dialect::get(polyglot_sql::DialectType::Snowflake); + for test_case in test_cases { + let recases: BTreeMap = test_case + .recases + .iter() + .map(|(index, generated, keyword)| { + ( + *index, + KeywordRecase { + generated: (*generated).to_string(), + keyword: *keyword, + }, + ) + }) + .collect(); + let before_tokens = tokenize(test_case.before, &dialect).expect("before tokenizes"); + let after_tokens = tokenize(test_case.after, &dialect).expect("after tokenizes"); + let before_texts = + TokenTexts::read(test_case.before, &before_tokens, &dialect).expect("before reads"); + let actual = verify_printed_tokens( + (test_case.before, &before_tokens, &before_texts), + (test_case.after, &after_tokens), + &dialect, + &recases, + ); + assert_eq!( + actual.err().as_deref(), + test_case.expected_error, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_batch_lint.rs b/crates/sqlclean/src/sql_lint/tests/test_batch_lint.rs new file mode 100644 index 0000000..5e4bf9c --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/test_batch_lint.rs @@ -0,0 +1,43 @@ +use crate::sql_lint::main::batch_engine::lint_batch_json; +use crate::sql_lint::main::engine::lint_json; +use crate::sql_lint::tests::test_types::BatchLintTestCase; +use serde_json::{Value, json}; + +#[test] +fn given_mixed_lint_inputs_when_batching_then_preserves_order_errors_and_findings() { + let test_cases = [BatchLintTestCase { + description: "mixed valid and invalid SQL retains standalone evidence", + queries: &[ + "SELECT 1", + "SELECT FROM", + "SELECT order_id FROM orders WHERE order_id = NULL", + ], + expected_responses: 3, + }]; + for test_case in test_cases { + let requests: Vec = test_case + .queries + .iter() + .map(|sql| json!({"version": 1, "sql": sql, "dialect": "duckdb"})) + .collect(); + let expected: Vec = requests + .iter() + .map(|request| { + serde_json::to_value(lint_json(&request.to_string()).map(|response| { + serde_json::from_str::(&response).expect("valid response") + })) + .expect("serializable result") + }) + .collect(); + let response = lint_batch_json(&serde_json::to_string(&requests).expect("valid requests")) + .expect("valid batch"); + let actual: Vec = serde_json::from_str(&response).expect("valid response"); + assert_eq!( + actual.len(), + test_case.expected_responses, + "{}", + test_case.description + ); + assert_eq!(actual, expected, "{}", test_case.description); + } +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_comment_formatter.rs b/crates/sqlclean/src/sql_lint/tests/test_comment_formatter.rs new file mode 100644 index 0000000..adc81ce --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/test_comment_formatter.rs @@ -0,0 +1,95 @@ +use serde_json::{Value, json}; + +use crate::sql_lint::main::formatter::format_json; +use crate::sql_lint::tests::test_types; + +#[test] +fn given_leading_cte_comments_when_formatting_then_comments_stay_above_their_cte() +-> Result<(), String> { + let test_cases = [ + test_types::FormatTestCase { + description: "leading comment before a middle CTE stays above it", + sql: "WITH\nupstream AS (\n SELECT * FROM orders\n),\n\n-- Explains the next CTE: line one,\n-- line two.\ndistinct_rows AS (\n SELECT order_id FROM upstream GROUP BY ALL\n)\n\nSELECT order_id FROM distinct_rows", + expected_sql: "WITH upstream AS (\n SELECT *\n FROM orders\n),\n\n-- Explains the next CTE: line one,\n-- line two.\ndistinct_rows AS (\n SELECT order_id\n FROM upstream\n GROUP BY ALL\n)\n\nSELECT order_id\nFROM distinct_rows", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "formatted leading CTE comment is idempotent", + sql: "WITH upstream AS (\n SELECT *\n FROM orders\n),\n\n-- Explains the next CTE: line one,\n-- line two.\ndistinct_rows AS (\n SELECT order_id\n FROM upstream\n GROUP BY ALL\n)\n\nSELECT order_id\nFROM distinct_rows", + expected_sql: "WITH upstream AS (\n SELECT *\n FROM orders\n),\n\n-- Explains the next CTE: line one,\n-- line two.\ndistinct_rows AS (\n SELECT order_id\n FROM upstream\n GROUP BY ALL\n)\n\nSELECT order_id\nFROM distinct_rows", + expected_changed: false, + }, + test_types::FormatTestCase { + description: "leading comment before the first CTE stays above it", + sql: "WITH\n-- Explains the first CTE.\nupstream AS (SELECT * FROM orders)\nSELECT order_id FROM upstream", + expected_sql: "WITH\n-- Explains the first CTE.\nupstream AS (\n SELECT *\n FROM orders\n)\n\nSELECT order_id\nFROM upstream", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "leading comment before the last of three CTEs stays above it", + sql: "WITH a AS (SELECT 1 AS x),\nb AS (SELECT x FROM a),\n /* Explains the last CTE. */\nc AS (SELECT x FROM b)\nSELECT x FROM c", + expected_sql: "WITH a AS (\n SELECT 1 AS x\n),\n\nb AS (\n SELECT x\n FROM a\n),\n\n/* Explains the last CTE. */\nc AS (\n SELECT x\n FROM b\n)\n\nSELECT x\nFROM c", + expected_changed: true, + }, + ]; + + for test_case in &test_cases { + let response = format_json( + &json!({"version": 1, "sql": test_case.sql, "dialect": "snowflake"}).to_string(), + )?; + let payload: Value = serde_json::from_str(&response).map_err(|error| error.to_string())?; + assert_eq!( + payload["sql"], test_case.expected_sql, + "{}", + test_case.description + ); + assert_eq!( + payload["changed"], test_case.expected_changed, + "{}", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_trailing_comments_after_the_last_token_when_formatting_then_no_newline_is_added() +-> Result<(), String> { + let test_cases = [ + test_types::FormatTestCase { + description: "a trailing line comment ends the output without a newline", + sql: "SELECT 1 AS order_id\nFROM orders -- latest orders", + expected_sql: "SELECT 1 AS order_id\nFROM orders -- latest orders", + expected_changed: false, + }, + test_types::FormatTestCase { + description: "line comments after the last token keep no trailing newline", + sql: "select 1 as order_id from orders\n\n-- first note\n\n-- second note", + expected_sql: "SELECT 1 AS order_id\nFROM orders -- first note\n -- second note", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "a trailing block comment ends the output", + sql: "SELECT 1 AS order_id\nFROM orders /* latest orders */", + expected_sql: "SELECT 1 AS order_id\nFROM orders /* latest orders */", + expected_changed: false, + }, + ]; + for test_case in &test_cases { + let response = format_json( + &json!({"version": 1, "sql": test_case.sql, "dialect": "duckdb"}).to_string(), + )?; + let payload: Value = serde_json::from_str(&response).map_err(|error| error.to_string())?; + assert_eq!( + payload["sql"], test_case.expected_sql, + "{}", + test_case.description + ); + assert_eq!( + payload["changed"], test_case.expected_changed, + "{}", + test_case.description + ); + } + Ok(()) +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_cross_joins.rs b/crates/sqlclean/src/sql_lint/tests/test_cross_joins.rs new file mode 100644 index 0000000..eb0645d --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/test_cross_joins.rs @@ -0,0 +1,286 @@ +use crate::sql_lint::main::cross_join_subject::cross_join_subject; +use crate::sql_lint::tests::helpers::{ + POLICY_SENTINEL, cross_join_messages, cross_join_remediations, +}; +use crate::sql_lint::tests::test_types::{CrossJoinSubjectTestCase, CrossJoinTestCase}; + +const GENERIC_MESSAGE: &str = "CROSS JOIN creates an explicit cartesian product"; +const UNNAMED_MESSAGE: &str = "CROSS JOIN creates an explicit cartesian product with an unnamed relation; only a file-wide exception (rule and path) can cover it"; + +#[test] +fn given_cross_joins_when_one_side_is_provably_one_row_then_only_multiplying_joins_are_reported() +-> Result<(), String> { + let test_cases = [ + CrossJoinTestCase { + description: "two unknown relations multiply rows", + sql: "SELECT o.order_id FROM orders AS o CROSS JOIN customers AS c", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &[ + "CROSS JOIN with `customers` creates an explicit cartesian product", + ], + }, + CrossJoinTestCase { + description: "a one-row seed on the right", + sql: "SELECT o.order_id FROM orders AS o CROSS JOIN __sqb_lint_7__ AS p", + dialect: "duckdb", + one_row_relations: &[POLICY_SENTINEL], + expected_messages: &[], + }, + CrossJoinTestCase { + description: "a seed with several rows names its relation", + sql: "SELECT o.order_id FROM orders AS o CROSS JOIN __sqb_lint_7__ AS p", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &[ + "CROSS JOIN with `order_policy` creates an explicit cartesian product", + ], + }, + CrossJoinTestCase { + description: "a one-row seed on the left of the first join", + sql: "SELECT o.order_id FROM __sqb_lint_7__ AS p CROSS JOIN orders AS o", + dialect: "duckdb", + one_row_relations: &[POLICY_SENTINEL], + expected_messages: &[], + }, + CrossJoinTestCase { + description: "an aggregate without GROUP BY carried through a filtering CTE", + sql: "WITH totals AS (SELECT COUNT(*) AS order_count FROM orders), \ + kept AS (SELECT order_count FROM totals WHERE order_count > 0) \ + SELECT o.order_id, k.order_count FROM orders AS o CROSS JOIN kept AS k", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &[], + }, + CrossJoinTestCase { + description: "an aggregate with GROUP BY can return many rows", + sql: "WITH totals AS (SELECT customer_id, COUNT(*) AS order_count FROM orders GROUP BY customer_id) \ + SELECT o.order_id FROM orders AS o CROSS JOIN totals AS t", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &["CROSS JOIN with `totals` creates an explicit cartesian product"], + }, + CrossJoinTestCase { + description: "a windowed count is not an aggregate", + sql: "WITH totals AS (SELECT COUNT(*) OVER () AS order_count FROM orders) \ + SELECT o.order_id FROM orders AS o CROSS JOIN totals AS t", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &["CROSS JOIN with `totals` creates an explicit cartesian product"], + }, + CrossJoinTestCase { + description: "LIMIT 1", + sql: "WITH latest AS (SELECT order_id FROM orders ORDER BY order_id DESC LIMIT 1) \ + SELECT c.customer_id FROM customers AS c CROSS JOIN latest AS l", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &[], + }, + CrossJoinTestCase { + description: "LIMIT 2 can return two rows", + sql: "WITH latest AS (SELECT order_id FROM orders ORDER BY order_id DESC LIMIT 2) \ + SELECT c.customer_id FROM customers AS c CROSS JOIN latest AS l", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &["CROSS JOIN with `latest` creates an explicit cartesian product"], + }, + CrossJoinTestCase { + description: "one-row VALUES", + sql: "WITH settings AS (SELECT * FROM (VALUES (1, 'EUR')) AS v(version, currency)) \ + SELECT o.order_id FROM orders AS o CROSS JOIN settings AS s", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &[], + }, + CrossJoinTestCase { + description: "two-row VALUES", + sql: "WITH settings AS (SELECT * FROM (VALUES (1), (2)) AS v(version)) \ + SELECT o.order_id FROM orders AS o CROSS JOIN settings AS s", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &[ + "CROSS JOIN with `settings` creates an explicit cartesian product", + ], + }, + CrossJoinTestCase { + description: "QUALIFY ROW_NUMBER() = 1 without a partition", + sql: "WITH newest AS (SELECT order_id FROM orders QUALIFY ROW_NUMBER() OVER (ORDER BY order_id DESC) = 1) \ + SELECT c.customer_id FROM customers AS c CROSS JOIN newest AS n", + dialect: "snowflake", + one_row_relations: &[], + expected_messages: &[], + }, + CrossJoinTestCase { + description: "QUALIFY ROW_NUMBER() = 1 with a partition keeps one row per partition", + sql: "WITH newest AS (SELECT order_id FROM orders QUALIFY ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_id DESC) = 1) \ + SELECT c.customer_id FROM customers AS c CROSS JOIN newest AS n", + dialect: "snowflake", + one_row_relations: &[], + expected_messages: &["CROSS JOIN with `newest` creates an explicit cartesian product"], + }, + CrossJoinTestCase { + description: "a join inside the carrying CTE stops the proof", + sql: "WITH totals AS (SELECT COUNT(*) AS n FROM orders), \ + joined AS (SELECT t.n FROM totals AS t INNER JOIN customers AS c ON c.n = t.n) \ + SELECT o.order_id FROM orders AS o CROSS JOIN joined AS j", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &["CROSS JOIN with `joined` creates an explicit cartesian product"], + }, + CrossJoinTestCase { + description: "UNNEST in a carrying CTE stops the proof", + sql: "WITH totals AS (SELECT LIST(order_id) AS ids FROM orders), \ + expanded AS (SELECT UNNEST(ids) AS order_id FROM totals) \ + SELECT c.customer_id FROM customers AS c CROSS JOIN expanded AS e", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &[ + "CROSS JOIN with `expanded` creates an explicit cartesian product", + ], + }, + CrossJoinTestCase { + description: "a set operation stops the proof", + sql: "WITH both_totals AS (SELECT COUNT(*) AS n FROM orders UNION ALL SELECT COUNT(*) AS n FROM customers) \ + SELECT o.order_id FROM orders AS o CROSS JOIN both_totals AS b", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &[ + "CROSS JOIN with `both_totals` creates an explicit cartesian product", + ], + }, + CrossJoinTestCase { + description: "the finding names the CTE that contains the join", + sql: "WITH checks AS (SELECT o.order_id FROM orders AS o CROSS JOIN customers AS c) \ + SELECT order_id FROM checks", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &[ + "CROSS JOIN with `customers` in CTE `checks` creates an explicit cartesian product", + ], + }, + CrossJoinTestCase { + description: "a later join still multiplies the rows before it", + sql: "SELECT o.order_id FROM __sqb_lint_7__ AS p CROSS JOIN orders AS o CROSS JOIN customers AS c", + dialect: "duckdb", + one_row_relations: &[POLICY_SENTINEL], + expected_messages: &[ + "CROSS JOIN with `customers` creates an explicit cartesian product", + ], + }, + CrossJoinTestCase { + description: "an unnamed relation says only a file-wide exception covers it", + sql: "SELECT o.order_id FROM orders AS o CROSS JOIN UNNEST([1, 2]) AS u(n)", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &[UNNAMED_MESSAGE], + }, + CrossJoinTestCase { + description: "a named window is never treated as unpartitioned", + sql: "WITH newest AS (SELECT order_id FROM orders WINDOW w AS (PARTITION BY customer_id ORDER BY order_id DESC) QUALIFY ROW_NUMBER() OVER w = 1) \ + SELECT c.customer_id FROM customers AS c CROSS JOIN newest AS n", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &["CROSS JOIN with `newest` creates an explicit cartesian product"], + }, + CrossJoinTestCase { + description: "a join under a nested WITH names the top-level CTE", + sql: "WITH outer_checks AS (WITH inner_checks AS (SELECT o.order_id FROM orders AS o CROSS JOIN customers AS c) SELECT order_id FROM inner_checks) \ + SELECT order_id FROM outer_checks", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &[ + "CROSS JOIN with `customers` in CTE `outer_checks` creates an explicit cartesian product", + ], + }, + CrossJoinTestCase { + description: "a WITH inside a subquery of the final select names no CTE", + sql: "SELECT s.order_id FROM (WITH inner_checks AS (SELECT o.order_id FROM orders AS o CROSS JOIN customers AS c) SELECT order_id FROM inner_checks) AS s", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &[ + "CROSS JOIN with `customers` creates an explicit cartesian product", + ], + }, + CrossJoinTestCase { + description: "a WITH owned by a set operation names its CTE", + sql: "WITH checks AS (SELECT o.order_id FROM orders AS o CROSS JOIN customers AS c) \ + SELECT order_id FROM checks UNION ALL SELECT order_id FROM orders", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &[ + "CROSS JOIN with `customers` in CTE `checks` creates an explicit cartesian product", + ], + }, + ]; + + for test_case in test_cases { + let messages: Vec = cross_join_messages(&test_case)?; + + assert_eq!( + messages, test_case.expected_messages, + "{}", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_unnamed_cross_join_when_linting_then_help_prints_a_file_wide_exception() +-> Result<(), String> { + let test_cases = [CrossJoinTestCase { + description: "CROSS JOIN UNNEST", + sql: "SELECT o.order_id FROM orders AS o CROSS JOIN UNNEST([1, 2]) AS u(n)", + dialect: "duckdb", + one_row_relations: &[], + expected_messages: &[ + "[[rules.rule_exceptions]]\n rule = \"SQC038\"\n path = \"\"\n reason = \"\"", + ], + }]; + + for test_case in test_cases { + let remediations: Vec = cross_join_remediations(&test_case)?; + + assert_eq!(remediations.len(), 1, "{}", test_case.description); + assert!( + remediations[0].contains(test_case.expected_messages[0]), + "{}: {remediations:?}", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_cross_join_messages_when_reading_their_subject_then_relation_and_cte_are_named() { + let test_cases = [ + CrossJoinSubjectTestCase { + description: "a top-level join", + message: "CROSS JOIN with `order_policy` creates an explicit cartesian product", + expected_subject: Some(("order_policy", None)), + }, + CrossJoinSubjectTestCase { + description: "a join inside a CTE", + message: "CROSS JOIN with `order_policy` in CTE `order_checks` creates an explicit cartesian product", + expected_subject: Some(("order_policy", Some("order_checks"))), + }, + CrossJoinSubjectTestCase { + description: "the generic message names nothing", + message: GENERIC_MESSAGE, + expected_subject: None, + }, + ]; + + for test_case in test_cases { + let subject = cross_join_subject(test_case.message); + + assert_eq!( + subject + .as_ref() + .map(|(relation, cte)| (relation.as_str(), cte.as_deref())), + test_case.expected_subject, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_empty_fixture_lint.rs b/crates/sqlclean/src/sql_lint/tests/test_empty_fixture_lint.rs new file mode 100644 index 0000000..db4988b --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/test_empty_fixture_lint.rs @@ -0,0 +1,61 @@ +use serde_json::{Value, json}; + +use crate::sql_lint::main::engine::lint_json; +use crate::sql_lint::tests::test_types; + +#[test] +fn given_empty_fixture_context_when_linting_projected_star_then_only_canonical_inputs_pass() +-> Result<(), String> { + let test_cases = [ + test_types::EmptyFixtureLintTestCase { + description: "canonical ref fixture", + sql: "WITH __ref__orders AS (SELECT * FROM __empty_fixture()) SELECT 1", + allows_empty_fixture_star: true, + expected_diagnostic_count: 0, + }, + test_types::EmptyFixtureLintTestCase { + description: "canonical source fixture", + sql: "WITH __source__web__orders AS (SELECT * FROM __empty_fixture()) SELECT 1", + allows_empty_fixture_star: true, + expected_diagnostic_count: 0, + }, + test_types::EmptyFixtureLintTestCase { + description: "ordinary SQL has no intrinsic exemption", + sql: "SELECT * FROM __empty_fixture()", + allows_empty_fixture_star: false, + expected_diagnostic_count: 1, + }, + test_types::EmptyFixtureLintTestCase { + description: "expected fixture remains controlled", + sql: "WITH __expected__orders AS (SELECT * FROM __empty_fixture()) SELECT 1", + allows_empty_fixture_star: true, + expected_diagnostic_count: 1, + }, + test_types::EmptyFixtureLintTestCase { + description: "additional projection is not canonical", + sql: "WITH __ref__orders AS (SELECT *, 1 AS extra FROM __empty_fixture()) SELECT 1", + allows_empty_fixture_star: true, + expected_diagnostic_count: 1, + }, + ]; + for test_case in test_cases { + let response = lint_json( + &json!({ + "version": 1, + "sql": test_case.sql, + "dialect": "snowflake", + "enabled_rules": ["SQC021"], + "allows_empty_fixture_star": test_case.allows_empty_fixture_star + }) + .to_string(), + )?; + let payload: Value = serde_json::from_str(&response).map_err(|error| error.to_string())?; + assert_eq!( + payload["diagnostics"].as_array().map_or(0, Vec::len), + test_case.expected_diagnostic_count, + "{}", + test_case.description + ); + } + Ok(()) +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_format_corpus.rs b/crates/sqlclean/src/sql_lint/tests/test_format_corpus.rs new file mode 100644 index 0000000..c40d2ff --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/test_format_corpus.rs @@ -0,0 +1,61 @@ +use serde_json::Value; + +use crate::sql_lint::tests::helpers::check_dialect_corpus; +use crate::sql_lint::tests::test_types; + +const FORMAT_CORPUS: &str = include_str!("fixtures/format_corpus.json"); + +#[test] +fn given_dialect_corpus_when_formatting_then_output_layout_and_refusals_are_stable() +-> Result<(), String> { + let test_cases = [ + test_types::FormatCorpusTestCase { + description: "BigQuery identity corpus", + dialect: "bigquery", + expected_formatted: 168, + expected_refused: 0, + }, + test_types::FormatCorpusTestCase { + description: "Databricks identity corpus", + dialect: "databricks", + expected_formatted: 62, + expected_refused: 0, + }, + test_types::FormatCorpusTestCase { + description: "DuckDB identity corpus", + dialect: "duckdb", + expected_formatted: 178, + expected_refused: 11, + }, + test_types::FormatCorpusTestCase { + description: "PostgreSQL identity corpus", + dialect: "postgres", + expected_formatted: 132, + expected_refused: 5, + }, + test_types::FormatCorpusTestCase { + description: "Snowflake identity corpus", + dialect: "snowflake", + expected_formatted: 429, + expected_refused: 6, + }, + test_types::FormatCorpusTestCase { + description: "T-SQL identity corpus", + dialect: "tsql", + expected_formatted: 75, + expected_refused: 0, + }, + ]; + let corpus: Vec = + serde_json::from_str(FORMAT_CORPUS).map_err(|error| error.to_string())?; + for test_case in test_cases { + let (formatted, refused) = check_dialect_corpus(&corpus, test_case.dialect)?; + assert_eq!( + (formatted, refused), + (test_case.expected_formatted, test_case.expected_refused), + "{}", + test_case.description + ); + } + Ok(()) +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_function_call_spacing.rs b/crates/sqlclean/src/sql_lint/tests/test_function_call_spacing.rs new file mode 100644 index 0000000..4acd30c --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/test_function_call_spacing.rs @@ -0,0 +1,117 @@ +use crate::sql_lint::tests::helpers::function_call_spacing; +use crate::sql_lint::tests::test_types; + +#[test] +fn given_builtin_call_names_when_formatting_then_no_space_enters_the_call() -> Result<(), String> { + let test_cases = [ + test_types::FunctionCallSpacingTestCase { + description: "duckdb compact calls", + dialect: "duckdb", + line_width: 100, + argument: "a", + expected_refused: &[], + }, + test_types::FunctionCallSpacingTestCase { + description: "duckdb breaking calls", + dialect: "duckdb", + line_width: 40, + argument: "order_amount_with_a_long_descriptive_name", + expected_refused: &[], + }, + test_types::FunctionCallSpacingTestCase { + description: "snowflake compact calls", + dialect: "snowflake", + line_width: 100, + argument: "a", + expected_refused: &[], + }, + test_types::FunctionCallSpacingTestCase { + description: "snowflake breaking calls", + dialect: "snowflake", + line_width: 40, + argument: "order_amount_with_a_long_descriptive_name", + expected_refused: &[], + }, + test_types::FunctionCallSpacingTestCase { + description: "bigquery compact calls", + dialect: "bigquery", + line_width: 100, + argument: "a", + expected_refused: &[], + }, + test_types::FunctionCallSpacingTestCase { + description: "bigquery breaking calls", + dialect: "bigquery", + line_width: 40, + argument: "order_amount_with_a_long_descriptive_name", + expected_refused: &[], + }, + test_types::FunctionCallSpacingTestCase { + description: "postgres compact calls", + dialect: "postgres", + line_width: 100, + argument: "a", + expected_refused: &[], + }, + test_types::FunctionCallSpacingTestCase { + description: "postgres breaking calls", + dialect: "postgres", + line_width: 40, + argument: "order_amount_with_a_long_descriptive_name", + expected_refused: &[], + }, + test_types::FunctionCallSpacingTestCase { + description: "tsql compact calls", + dialect: "tsql", + line_width: 100, + argument: "a", + expected_refused: &[], + }, + test_types::FunctionCallSpacingTestCase { + description: "tsql breaking calls", + dialect: "tsql", + line_width: 40, + argument: "order_amount_with_a_long_descriptive_name", + expected_refused: &[], + }, + test_types::FunctionCallSpacingTestCase { + description: "databricks compact calls", + dialect: "databricks", + line_width: 100, + argument: "a", + expected_refused: &[], + }, + test_types::FunctionCallSpacingTestCase { + description: "databricks breaking calls", + dialect: "databricks", + line_width: 40, + argument: "order_amount_with_a_long_descriptive_name", + expected_refused: &[], + }, + test_types::FunctionCallSpacingTestCase { + description: "generic compact calls", + dialect: "generic", + line_width: 100, + argument: "a", + expected_refused: &[], + }, + test_types::FunctionCallSpacingTestCase { + description: "generic breaking calls", + dialect: "generic", + line_width: 40, + argument: "order_amount_with_a_long_descriptive_name", + expected_refused: &[], + }, + ]; + for test_case in &test_cases { + let (refused, misplaced) = + function_call_spacing(test_case.dialect, test_case.line_width, test_case.argument)?; + assert_eq!( + refused, test_case.expected_refused, + "{}", + test_case.description + ); + assert_eq!(misplaced, Vec::::new(), "{}", test_case.description); + } + Ok(()) +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_idempotency_reprint.rs b/crates/sqlclean/src/sql_lint/tests/test_idempotency_reprint.rs new file mode 100644 index 0000000..edf70b5 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/test_idempotency_reprint.rs @@ -0,0 +1,48 @@ +use serde_json::{Value, json}; + +use crate::sql_lint::_helpers::formatter::RELAYOUTS; +use crate::sql_lint::main::formatter::format_json; +use crate::sql_lint::tests::test_types; + +#[test] +fn given_formatted_and_unformatted_bodies_when_formatting_then_only_changed_bodies_are_reprinted() +-> Result<(), String> { + let test_cases = [ + test_types::ReprintTestCase { + description: "an already formatted body is not re-printed", + sql: "SELECT\n order_id,\n amount\nFROM orders\nWHERE amount > 0", + expected_changed: false, + expected_reprints: 0, + }, + test_types::ReprintTestCase { + description: "a body the formatter changes is re-printed once", + sql: "select order_id, amount from orders where amount > 0", + expected_changed: true, + expected_reprints: 1, + }, + ]; + for test_case in &test_cases { + RELAYOUTS.with(|relayouts| relayouts.set(0)); + let response = format_json( + &json!({"version": 1, "sql": test_case.sql, "dialect": "duckdb"}).to_string(), + )?; + let payload: Value = serde_json::from_str(&response).map_err(|error| error.to_string())?; + assert_eq!( + payload["formatted"], true, + "{}: {payload}", + test_case.description + ); + assert_eq!( + payload["changed"], test_case.expected_changed, + "{}: {payload}", + test_case.description + ); + assert_eq!( + RELAYOUTS.with(std::cell::Cell::get), + test_case.expected_reprints, + "{}", + test_case.description + ); + } + Ok(()) +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_layout_rules.rs b/crates/sqlclean/src/sql_lint/tests/test_layout_rules.rs new file mode 100644 index 0000000..15dd495 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/test_layout_rules.rs @@ -0,0 +1,294 @@ +use serde_json::Value; + +use crate::sql_lint::tests::helpers::format_at_width; +use crate::sql_lint::tests::test_types; +use crate::sql_tokens::main::query_fingerprint::query_fingerprint; + +#[test] +fn given_clause_layout_rules_when_formatting_then_each_rule_applies_idempotently() +-> Result<(), String> { + let test_cases = [ + test_types::LayoutRuleTestCase { + description: "a leading comma after a line comment moves to the end of that line", + dialect: "duckdb", + sql: "select order_id -- the key\n,amount from orders", + line_width: 100, + expected_sql: "SELECT\n order_id, -- the key\n amount\nFROM orders", + }, + test_types::LayoutRuleTestCase { + description: "a long Snowflake FROM VALUES puts each unsplit row on its own line", + dialect: "snowflake", + sql: "SELECT * FROM VALUES (1, '2024-01-15', 'PAID', 'north warehouse', 'priority shipping'), (2, '2024-01-16', 'OPEN', 'south warehouse', 'standard shipping')", + line_width: 60, + expected_sql: "SELECT *\nFROM VALUES\n (1, '2024-01-15', 'PAID', 'north warehouse', 'priority shipping'),\n (2, '2024-01-16', 'OPEN', 'south warehouse', 'standard shipping')", + }, + test_types::LayoutRuleTestCase { + description: "a short VALUES list stays on one line", + dialect: "snowflake", + sql: "SELECT * FROM VALUES ('a', 'A'), ('b', 'B')", + line_width: 100, + expected_sql: "SELECT *\nFROM VALUES ('a', 'A'), ('b', 'B')", + }, + test_types::LayoutRuleTestCase { + description: "a long parenthesized PostgreSQL VALUES moves under its parenthesis", + dialect: "postgres", + sql: "SELECT order_id, status FROM (VALUES (1, 'paid and shipped to customer'), (2, 'cancelled by customer')) AS orders(order_id, status)", + line_width: 60, + expected_sql: "SELECT\n order_id,\n status\nFROM (\n VALUES\n (1, 'paid and shipped to customer'),\n (2, 'cancelled by customer')\n) AS orders(order_id, status)", + }, + test_types::LayoutRuleTestCase { + description: "a long T-SQL VALUES relation keeps rows whole", + dialect: "tsql", + sql: "SELECT order_id FROM (VALUES (1, 'paid and shipped to customer'), (2, 'cancelled by customer')) AS orders(order_id, status)", + line_width: 60, + expected_sql: "SELECT order_id\nFROM (\n VALUES\n (1, 'paid and shipped to customer'),\n (2, 'cancelled by customer')\n) AS orders(order_id, status)", + }, + test_types::LayoutRuleTestCase { + description: "DuckDB INSERT VALUES rows stay whole when a row is over the width", + dialect: "duckdb", + sql: "INSERT INTO orders VALUES (1, 'paid and shipped to the customer by priority courier'), (2, 'open')", + line_width: 40, + expected_sql: "INSERT INTO orders\nVALUES\n (1, 'paid and shipped to the customer by priority courier'),\n (2, 'open')", + }, + test_types::LayoutRuleTestCase { + description: "Snowflake CAST breaks around IFF, not inside NUMBER(38, 2)", + dialect: "snowflake", + sql: "SELECT CAST(IFF(amount > 100000000, amount * 1000000000000, amount / 100000000000000) AS NUMBER(38, 2)) AS total FROM orders", + line_width: 80, + expected_sql: "SELECT\n CAST(\n IFF(\n amount > 100000000,\n amount * 1000000000000,\n amount / 100000000000000\n ) AS NUMBER(38, 2)\n ) AS total\nFROM orders", + }, + test_types::LayoutRuleTestCase { + description: "a DuckDB :: cast keeps DECIMAL(18, 4) whole", + dialect: "duckdb", + sql: "SELECT COALESCE(first_order_amount, second_order_amount)::DECIMAL(18, 4) AS amount FROM orders", + line_width: 50, + expected_sql: "SELECT\n COALESCE(\n first_order_amount,\n second_order_amount\n )::DECIMAL(18, 4) AS amount\nFROM orders", + }, + test_types::LayoutRuleTestCase { + description: "a BigQuery NUMERIC(38, 9) cast keeps its parameters", + dialect: "bigquery", + sql: "SELECT CAST(COALESCE(first_order_amount, second_order_amount) AS NUMERIC(38, 9)) AS amount FROM orders", + line_width: 50, + expected_sql: "SELECT\n CAST(\n COALESCE(\n first_order_amount,\n second_order_amount\n ) AS NUMERIC(38, 9)\n ) AS amount\nFROM orders", + }, + test_types::LayoutRuleTestCase { + description: "a T-SQL VARCHAR(MAX) cast keeps its parameter", + dialect: "tsql", + sql: "SELECT CAST(COALESCE(first_order_note, second_order_note) AS VARCHAR(MAX)) AS note FROM orders", + line_width: 50, + expected_sql: "SELECT\n CAST(\n COALESCE(\n first_order_note,\n second_order_note\n ) AS VARCHAR(MAX)\n ) AS note\nFROM orders", + }, + test_types::LayoutRuleTestCase { + description: "a Databricks DECIMAL(10, 2) cast keeps its parameters", + dialect: "databricks", + sql: "SELECT CAST(COALESCE(first_order_amount, second_order_amount) AS DECIMAL(10, 2)) AS amount FROM orders", + line_width: 50, + expected_sql: "SELECT\n CAST(\n COALESCE(\n first_order_amount,\n second_order_amount\n ) AS DECIMAL(10, 2)\n ) AS amount\nFROM orders", + }, + test_types::LayoutRuleTestCase { + description: "a long Snowflake DECODE puts each pair and the default on its own line", + dialect: "snowflake", + sql: "SELECT DECODE(status, 1, 'new', 2, 'open', 3, 'paid', 4, 'shipped', 5, 'cancelled', 'unknown') AS status_name FROM orders", + line_width: 60, + expected_sql: "SELECT\n DECODE(\n status,\n 1, 'new',\n 2, 'open',\n 3, 'paid',\n 4, 'shipped',\n 5, 'cancelled',\n 'unknown'\n ) AS status_name\nFROM orders", + }, + test_types::LayoutRuleTestCase { + description: "a DECODE without a default ends with a pair", + dialect: "snowflake", + sql: "SELECT DECODE(status, 1, 'new', 2, 'open', 3, 'paid', 4, 'shipped', 5, 'cancelled') AS status_name FROM orders", + line_width: 60, + expected_sql: "SELECT\n DECODE(\n status,\n 1, 'new',\n 2, 'open',\n 3, 'paid',\n 4, 'shipped',\n 5, 'cancelled'\n ) AS status_name\nFROM orders", + }, + test_types::LayoutRuleTestCase { + description: "a DECODE the layout guide breaks is regrouped into pairs", + dialect: "snowflake", + sql: "SELECT DECODE(status, 1, 'new', 2, 'open', 3, 'paid', 4, 'shipped', 5, 'cancelled', 6, 'returned', 'unknown') AS status_name FROM orders", + line_width: 100, + expected_sql: "SELECT\n DECODE(\n status,\n 1, 'new',\n 2, 'open',\n 3, 'paid',\n 4, 'shipped',\n 5, 'cancelled',\n 6, 'returned',\n 'unknown'\n ) AS status_name\nFROM orders", + }, + test_types::LayoutRuleTestCase { + description: "a short DECODE stays on one line", + dialect: "snowflake", + sql: "SELECT DECODE(status, 1, 'new', 'other') AS status_name FROM orders", + line_width: 100, + expected_sql: "SELECT DECODE(status, 1, 'new', 'other') AS status_name\nFROM orders", + }, + test_types::LayoutRuleTestCase { + description: "an operator after a multi-line CASE starts the next line", + dialect: "duckdb", + sql: "SELECT CASE WHEN status = 'paid' THEN 'customer paid in full' WHEN status = 'open' THEN 'customer has not paid yet' ELSE 'other' END || CASE WHEN rush THEN '!' ELSE '' END AS label FROM orders", + line_width: 100, + expected_sql: "SELECT\n CASE\n WHEN status = 'paid'\n THEN 'customer paid in full'\n WHEN status = 'open'\n THEN 'customer has not paid yet'\n ELSE 'other'\n END\n || CASE WHEN rush THEN '!' ELSE '' END AS label\nFROM orders", + }, + test_types::LayoutRuleTestCase { + description: "AND after a multi-line subquery starts the next line", + dialect: "postgres", + sql: "SELECT order_id FROM orders WHERE customer_id IN (SELECT customer_id FROM customers WHERE region = 'north') AND EXISTS (SELECT 1 FROM shipments)", + line_width: 100, + expected_sql: "SELECT order_id\nFROM orders\nWHERE\n customer_id IN (\n SELECT customer_id\n FROM customers\n WHERE region = 'north'\n )\n AND EXISTS(\n SELECT 1\n FROM shipments\n )", + }, + test_types::LayoutRuleTestCase { + description: "BETWEEN bounds keep their AND", + dialect: "snowflake", + sql: "SELECT order_id FROM orders WHERE amount BETWEEN 1 AND 9", + line_width: 100, + expected_sql: "SELECT order_id\nFROM orders\nWHERE amount BETWEEN 1 AND 9", + }, + test_types::LayoutRuleTestCase { + description: "CTEs and the final statement are separated by one blank line", + dialect: "duckdb", + sql: "with orders as (select * from raw_orders),\n\n\n-- paid only\npaid as (select * from orders where amount > 0)\n\n\nselect * from paid", + line_width: 100, + expected_sql: "WITH orders AS (\n SELECT *\n FROM raw_orders\n),\n\n-- paid only\npaid AS (\n SELECT *\n FROM orders\n WHERE amount > 0\n)\n\nSELECT *\nFROM paid", + }, + test_types::LayoutRuleTestCase { + description: "a trailing comment after a CTE comma keeps its line", + dialect: "snowflake", + sql: "WITH orders AS (SELECT * FROM raw_orders), -- raw\npaid AS (SELECT * FROM orders) SELECT * FROM paid", + line_width: 100, + expected_sql: "WITH orders AS (\n SELECT *\n FROM raw_orders\n), -- raw\n\npaid AS (\n SELECT *\n FROM orders\n)\n\nSELECT *\nFROM paid", + }, + test_types::LayoutRuleTestCase { + description: "a nested WITH is separated at its own indentation", + dialect: "postgres", + sql: "SELECT * FROM (WITH a AS MATERIALIZED (SELECT 1 AS x), b AS (SELECT x FROM a) SELECT x FROM b) AS nested", + line_width: 100, + expected_sql: "SELECT *\nFROM (\n WITH a AS MATERIALIZED (\n SELECT 1 AS x\n ),\n\n b AS (\n SELECT x\n FROM a\n )\n\n SELECT x\n FROM b\n) AS nested", + }, + test_types::LayoutRuleTestCase { + description: "BigQuery recursive CTEs with column lists are separated", + dialect: "bigquery", + sql: "WITH RECURSIVE numbers (n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 3), doubled AS (SELECT n * 2 AS n FROM numbers) SELECT n FROM doubled", + line_width: 100, + expected_sql: "WITH RECURSIVE numbers (n) AS (\n SELECT 1\n UNION ALL\n SELECT n + 1\n FROM numbers\n WHERE n < 3\n),\n\ndoubled AS (\n SELECT n * 2 AS n\n FROM numbers\n)\n\nSELECT n\nFROM doubled", + }, + test_types::LayoutRuleTestCase { + description: "a T-SQL WITH is separated before INSERT", + dialect: "tsql", + sql: "WITH paid AS (SELECT order_id FROM orders) INSERT INTO paid_orders SELECT order_id FROM paid", + line_width: 100, + expected_sql: "WITH paid AS (\n SELECT order_id\n FROM orders\n)\n\nINSERT INTO paid_orders\nSELECT order_id\nFROM paid", + }, + test_types::LayoutRuleTestCase { + description: "a CTE macro placeholder is separated like a CTE", + dialect: "duckdb", + sql: "WITH __sqb_lint_0__, paid AS (SELECT * FROM orders) SELECT * FROM paid", + line_width: 100, + expected_sql: "WITH\n__sqb_lint_0__,\n\npaid AS (\n SELECT *\n FROM orders\n)\n\nSELECT *\nFROM paid", + }, + test_types::LayoutRuleTestCase { + description: "comma-less CTE macro placeholders in a row are separated like CTEs", + dialect: "duckdb", + sql: "WITH a AS (SELECT 1 AS x), __sqb_lint_0__ __sqb_lint_1__ paid AS (SELECT * FROM a) SELECT * FROM paid", + line_width: 100, + expected_sql: "WITH a AS (\n SELECT 1 AS x\n),\n\n__sqb_lint_0__\n\n__sqb_lint_1__\npaid AS (\n SELECT *\n FROM a\n)\nSELECT *\nFROM paid", + }, + test_types::LayoutRuleTestCase { + description: "an alias and comma that push a call over the width break the call", + dialect: "snowflake", + sql: "SELECT ARRAY_CONTAINS('web'::VARIANT, COALESCE(o.order_channels, ARRAY_CONSTRUCT())) AS has_web_channel, o.order_id FROM orders o", + line_width: 80, + expected_sql: "SELECT\n ARRAY_CONTAINS(\n 'web'::VARIANT,\n COALESCE(o.order_channels, ARRAY_CONSTRUCT())\n ) AS has_web_channel,\n o.order_id\nFROM orders o", + }, + test_types::LayoutRuleTestCase { + description: "an AND chain inside a call argument joins its line, then wraps", + dialect: "snowflake", + sql: "SELECT CAST(MAX(IFF(line_number = 1 AND shipped_after_days = 960, order_amount, NULL)) AS NUMBER(38, 10)) AS first_line_amount_shipped_late FROM orders", + line_width: 100, + expected_sql: "SELECT\n CAST(\n MAX(IFF(line_number = 1 AND shipped_after_days = 960, order_amount, NULL)) AS NUMBER(38, 10)\n ) AS first_line_amount_shipped_late\nFROM orders", + }, + test_types::LayoutRuleTestCase { + description: "a one-line CASE over the width breaks at its branches", + dialect: "duckdb", + sql: "SELECT CASE WHEN o.shipped_at IS NULL THEN NULL WHEN o.refund_amount IS NULL THEN 0 ELSE 1 END AS has_refund FROM orders o", + line_width: 80, + expected_sql: "SELECT\n CASE\n WHEN o.shipped_at IS NULL\n THEN NULL\n WHEN o.refund_amount IS NULL\n THEN 0\n ELSE 1\n END AS has_refund\nFROM orders o", + }, + test_types::LayoutRuleTestCase { + description: "a WITHIN GROUP line breaks the aggregate call", + dialect: "snowflake", + sql: "SELECT customer_id, LISTAGG(DISTINCT LOWER(product_category_name), ', ') WITHIN GROUP (ORDER BY product_category_name) AS categories FROM orders GROUP BY customer_id", + line_width: 80, + expected_sql: "SELECT\n customer_id,\n LISTAGG(\n DISTINCT LOWER(product_category_name),\n ', '\n ) WITHIN GROUP (ORDER BY product_category_name) AS categories\nFROM orders\nGROUP BY\n customer_id", + }, + test_types::LayoutRuleTestCase { + description: "a window that continues on later lines breaks at its clauses", + dialect: "snowflake", + sql: "SELECT ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY CASE o.status WHEN 'paid' THEN 0 WHEN 'open' THEN 1 ELSE 2 END, o.ordered_at DESC) AS rn FROM orders o", + line_width: 60, + expected_sql: "SELECT\n ROW_NUMBER() OVER (\n PARTITION BY o.customer_id\n ORDER BY CASE o.status\n WHEN 'paid'\n THEN 0\n WHEN 'open'\n THEN 1\n ELSE 2\n END, o.ordered_at DESC\n ) AS rn\nFROM orders o", + }, + test_types::LayoutRuleTestCase { + description: "SELECT * and a single WHERE condition share their clause lines", + dialect: "duckdb", + sql: "select * from orders where amount > 0", + line_width: 100, + expected_sql: "SELECT *\nFROM orders\nWHERE amount > 0", + }, + test_types::LayoutRuleTestCase { + description: "SELECT DISTINCT keeps its single item, several items break", + dialect: "snowflake", + sql: "SELECT DISTINCT order_id FROM orders WHERE amount > 0 AND status = 'paid' UNION ALL SELECT order_id, customer_id FROM returns", + line_width: 100, + expected_sql: "SELECT DISTINCT order_id\nFROM orders\nWHERE\n amount > 0\n AND status = 'paid'\nUNION ALL\nSELECT\n order_id,\n customer_id\nFROM returns", + }, + test_types::LayoutRuleTestCase { + description: "T-SQL TOP, HAVING and Snowflake-style single items share lines", + dialect: "tsql", + sql: "SELECT TOP 10 COUNT(*) AS n FROM orders GROUP BY customer_id HAVING COUNT(*) > 1", + line_width: 100, + expected_sql: "SELECT TOP 10 COUNT(*) AS n\nFROM orders\nGROUP BY\n customer_id\nHAVING COUNT(*) > 1", + }, + test_types::LayoutRuleTestCase { + description: "QUALIFY with one condition shares its line", + dialect: "databricks", + sql: "SELECT order_id, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ordered_at) AS rn FROM orders QUALIFY rn = 1", + line_width: 100, + expected_sql: "SELECT\n order_id,\n ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ordered_at) AS rn\nFROM orders\nQUALIFY rn = 1", + }, + test_types::LayoutRuleTestCase { + description: "a single item that does not fit stays under SELECT", + dialect: "bigquery", + sql: "SELECT COALESCE(first_order_amount, second_order_amount, third_order_amount) AS amount FROM orders WHERE first_order_amount + second_order_amount + third_order_amount > 100", + line_width: 60, + expected_sql: "SELECT\n COALESCE(\n first_order_amount,\n second_order_amount,\n third_order_amount\n ) AS amount\nFROM orders\nWHERE\n first_order_amount\n + second_order_amount\n + third_order_amount > 100", + }, + test_types::LayoutRuleTestCase { + description: "a short CASE item shares the SELECT line", + dialect: "duckdb", + sql: "SELECT CASE WHEN amount > 0 THEN 'paid' ELSE 'open' END AS status FROM orders", + line_width: 100, + expected_sql: "SELECT CASE WHEN amount > 0 THEN 'paid' ELSE 'open' END AS status\nFROM orders", + }, + test_types::LayoutRuleTestCase { + description: "a multi-line single item stays under SELECT", + dialect: "duckdb", + sql: "SELECT CASE WHEN amount > 1000 THEN 'large paid order' WHEN amount > 0 THEN 'paid order' ELSE 'open order' END AS status FROM orders", + line_width: 100, + expected_sql: "SELECT\n CASE\n WHEN amount > 1000\n THEN 'large paid order'\n WHEN amount > 0\n THEN 'paid order'\n ELSE 'open order'\n END AS status\nFROM orders", + }, + ]; + for test_case in &test_cases { + let response: Value = + format_at_width(test_case.sql, test_case.dialect, test_case.line_width)?; + assert_eq!(response["formatted"], true, "{}", test_case.description); + assert_eq!( + response["sql"], test_case.expected_sql, + "{}", + test_case.description + ); + assert_eq!( + query_fingerprint(test_case.expected_sql, test_case.dialect)?, + query_fingerprint(test_case.sql, test_case.dialect)?, + "{}: layout changed the fingerprint", + test_case.description + ); + let again: Value = format_at_width( + test_case.expected_sql, + test_case.dialect, + test_case.line_width, + )?; + assert_eq!(again["changed"], false, "{}", test_case.description); + } + Ok(()) +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_line_wrap.rs b/crates/sqlclean/src/sql_lint/tests/test_line_wrap.rs new file mode 100644 index 0000000..3a3d365 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/test_line_wrap.rs @@ -0,0 +1,121 @@ +use std::collections::HashMap; + +use serde_json::{Value, json}; + +use crate::sql_lint::main::formatter::format_json; +use crate::sql_lint::tests::test_types; + +#[test] +fn given_lines_over_the_width_when_formatting_then_each_wrap_rule_applies_idempotently() +-> Result<(), String> { + let test_cases = [ + test_types::LineWrapTestCase { + description: "function arguments go one per line", + sql: "select coalesce(first_order_amount, second_order_amount, third_order_amount, fallback_amount) as amount from orders", + line_width: 60, + token_widths: &[], + expected_sql: "SELECT\n COALESCE(\n first_order_amount,\n second_order_amount,\n third_order_amount,\n fallback_amount\n ) AS amount\nFROM orders", + }, + test_types::LineWrapTestCase { + description: "IN lists go one element per line", + sql: "select order_id from orders where region in ('north', 'south', 'east', 'west', 'central', 'remote')", + line_width: 50, + token_widths: &[], + expected_sql: "SELECT order_id\nFROM orders\nWHERE\n region IN (\n 'north',\n 'south',\n 'east',\n 'west',\n 'central',\n 'remote'\n )", + }, + test_types::LineWrapTestCase { + description: "window PARTITION BY and ORDER BY go on separate lines", + sql: "select row_number() over (partition by customer_id, region_code order by ordered_at desc, order_id desc) as rank_in_customer from orders", + line_width: 60, + token_widths: &[], + expected_sql: "SELECT\n ROW_NUMBER() OVER (\n PARTITION BY customer_id, region_code\n ORDER BY ordered_at DESC, order_id DESC\n ) AS rank_in_customer\nFROM orders", + }, + test_types::LineWrapTestCase { + description: "join conditions break before AND but not inside BETWEEN", + sql: "select o.order_id from orders o join customers c on o.customer_id = c.customer_id and o.amount between 1 and 9 and o.store = c.store", + line_width: 60, + token_widths: &[], + expected_sql: "SELECT o.order_id\nFROM orders o\nJOIN customers c\n ON o.customer_id = c.customer_id\n AND o.amount BETWEEN 1 AND 9\n AND o.store = c.store", + }, + test_types::LineWrapTestCase { + description: "arithmetic chains break before operators", + sql: "select first_amount + second_amount + third_amount + fourth_amount as total from orders", + line_width: 50, + token_widths: &[], + expected_sql: "SELECT\n first_amount\n + second_amount\n + third_amount\n + fourth_amount AS total\nFROM orders", + }, + test_types::LineWrapTestCase { + description: "a top-level AND chain breaks before a nested group", + sql: "select order_id from orders where cast(ordered_at as DATE) >= cast(@start as DATE) and cast(ordered_at as DATE) < cast(@end as DATE)", + line_width: 60, + token_widths: &[], + expected_sql: "SELECT order_id\nFROM orders\nWHERE\n CAST(ordered_at AS DATE) >= CAST(@start AS DATE)\n AND CAST(ordered_at AS DATE) < CAST(@end AS DATE)", + }, + test_types::LineWrapTestCase { + description: "a long string literal is never broken", + sql: "select 'a long literal that is wider than the configured line width' as note from orders", + line_width: 40, + token_widths: &[], + expected_sql: "SELECT\n 'a long literal that is wider than the configured line width' AS note\nFROM orders", + }, + test_types::LineWrapTestCase { + description: "a line with a comment is never broken", + sql: "select coalesce(first_amount, second_amount, third_amount) as amount -- keep\nfrom orders", + line_width: 40, + token_widths: &[], + expected_sql: "SELECT\n COALESCE(first_amount, second_amount, third_amount) AS amount -- keep\nFROM orders", + }, + test_types::LineWrapTestCase { + description: "a sentinel counts with the width of the text it stands for", + sql: "select coalesce(__sqb_lint_0__, b) as amount from orders", + line_width: 40, + token_widths: &[("__SQB_LINT_0__", 30)], + expected_sql: "SELECT\n COALESCE(\n __sqb_lint_0__,\n b\n ) AS amount\nFROM orders", + }, + test_types::LineWrapTestCase { + description: "a nested expression breaks only the levels that overflow", + sql: "select round(100 * ((sum(discount_amount) / nullif(sum(gross_amount), 0))::numeric(38, 6)), 2)::numeric(9, 4) as discount_share from orders", + line_width: 100, + token_widths: &[], + expected_sql: "SELECT\n ROUND(\n 100 * ((SUM(discount_amount) / NULLIF(SUM(gross_amount), 0))::numeric(38, 6)),\n 2\n )::numeric(9, 4) AS discount_share\nFROM orders", + }, + test_types::LineWrapTestCase { + description: "a call that fits after a leading operator stays on one line", + sql: "select (budget_amount_minor / nullif((power(10, minor_unit_exponent)::numeric(38, 0)), 0))::numeric(38, 6) as budget_amount from orders", + line_width: 80, + token_widths: &[], + expected_sql: "SELECT\n (\n budget_amount_minor\n / NULLIF((POWER(10, minor_unit_exponent)::numeric(38, 0)), 0)\n )::numeric(38, 6) AS budget_amount\nFROM orders", + }, + test_types::LineWrapTestCase { + description: "lines within the width are unchanged", + sql: "select coalesce(first_amount, second_amount) as amount from orders", + line_width: 100, + token_widths: &[], + expected_sql: "SELECT COALESCE(first_amount, second_amount) AS amount\nFROM orders", + }, + ]; + for test_case in &test_cases { + let token_widths: HashMap<&str, usize> = test_case.token_widths.iter().copied().collect(); + let request = |sql: &str| { + json!({ + "version": 1, + "sql": sql, + "dialect": "duckdb", + "line_width": test_case.line_width, + "token_widths": token_widths, + }) + .to_string() + }; + let response: Value = serde_json::from_str(&format_json(&request(test_case.sql))?) + .map_err(|error| error.to_string())?; + assert_eq!( + response["sql"], test_case.expected_sql, + "{}", + test_case.description + ); + let again: Value = serde_json::from_str(&format_json(&request(test_case.expected_sql))?) + .map_err(|error| error.to_string())?; + assert_eq!(again["changed"], false, "{}", test_case.description); + } + Ok(()) +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_lint_exemptions.rs b/crates/sqlclean/src/sql_lint/tests/test_lint_exemptions.rs new file mode 100644 index 0000000..5ed8b24 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/test_lint_exemptions.rs @@ -0,0 +1,130 @@ +use serde_json::{Value, json}; + +use crate::sql_lint::main::engine::lint_json; +use crate::sql_lint::tests::test_types::{ + DynamicOutputStarLintTestCase, SqlMacroOutputLintTestCase, +}; + +#[test] +fn given_sql_macro_output_when_linting_qualification_then_each_column_counts_as_written() +-> Result<(), String> { + let test_cases = [ + SqlMacroOutputLintTestCase { + description: "a bare column from an expression macro beside a qualified column fails", + sql: "SELECT o.order_id, status AS s FROM orders AS o", + output: "status", + relations: &[], + expected_count: 1, + }, + SqlMacroOutputLintTestCase { + description: "reads through a passed relation pass beside a bare caller column", + sql: "SELECT order_id, o.amount - o.discount AS net FROM orders AS o", + output: "o.amount - o.discount", + relations: &["o"], + expected_count: 0, + }, + SqlMacroOutputLintTestCase { + description: "reads through a passed relation pass beside a hand-qualified column", + sql: "SELECT o.order_id, o.amount - o.discount AS net FROM orders AS o", + output: "o.amount - o.discount", + relations: &["o"], + expected_count: 0, + }, + SqlMacroOutputLintTestCase { + description: "the caller's own mix beside reads through a passed relation fails", + sql: "SELECT o.order_id, status, o.amount AS a FROM orders AS o", + output: "o.amount", + relations: &["o"], + expected_count: 1, + }, + SqlMacroOutputLintTestCase { + description: "an aliased read counts as qualified beside a bare caller column", + sql: "SELECT order_id, s.amount AS a FROM orders AS s", + output: "s.amount", + relations: &["orders"], + expected_count: 1, + }, + SqlMacroOutputLintTestCase { + description: "a query scope the macro generated whole is judged in the macro file", + sql: "WITH c AS (SELECT a, w.b FROM w) SELECT * FROM c", + output: "SELECT a, w.b FROM w", + relations: &[], + expected_count: 0, + }, + ]; + for test_case in test_cases { + let start: usize = test_case + .sql + .find(test_case.output) + .ok_or("the output should be in the SQL")?; + let response = lint_json( + &json!({ + "version": 1, + "sql": test_case.sql, + "dialect": "duckdb", + "enabled_rules": ["SQC028"], + "sql_macro_outputs": [{ + "start": start, + "end": start + test_case.output.len(), + "relations": test_case.relations, + }], + }) + .to_string(), + )?; + let payload: Value = serde_json::from_str(&response).map_err(|error| error.to_string())?; + let diagnostics = payload["diagnostics"] + .as_array() + .ok_or("diagnostics should be an array")?; + + assert_eq!( + diagnostics.len(), + test_case.expected_count, + "{}", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_compiler_proven_dynamic_output_when_linting_then_only_terminal_star_is_exempt() +-> Result<(), String> { + let test_cases = [DynamicOutputStarLintTestCase { + description: "unrelated CTE star remains controlled", + sql: "WITH pivot_input AS (SELECT customer_id, category, amount FROM orders), \ +final AS (SELECT * FROM pivot_input PIVOT(MAX(amount) FOR category IN (ANY))), \ +unrelated AS (SELECT * FROM pivot_input) SELECT * FROM final", + expected_count: 1, + expected_prefix: "unrelated AS (SELECT ", + }]; + for test_case in test_cases { + let response = lint_json( + &json!({ + "version": 1, + "sql": test_case.sql, + "dialect": "snowflake", + "enabled_rules": ["SQC021"], + "allows_dynamic_output_star": true, + }) + .to_string(), + )?; + let payload: Value = serde_json::from_str(&response).map_err(|error| error.to_string())?; + let diagnostics = payload["diagnostics"] + .as_array() + .ok_or("diagnostics should be an array")?; + + assert_eq!( + diagnostics.len(), + test_case.expected_count, + "{}", + test_case.description + ); + let start = diagnostics[0]["start"].as_u64().unwrap_or_default() as usize; + assert!( + test_case.sql[..start].ends_with(test_case.expected_prefix), + "{}", + test_case.description + ); + } + Ok(()) +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_set_operation_formatter.rs b/crates/sqlclean/src/sql_lint/tests/test_set_operation_formatter.rs new file mode 100644 index 0000000..9f8e7fb --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/test_set_operation_formatter.rs @@ -0,0 +1,59 @@ +use serde_json::{Value, json}; + +use crate::sql_lint::main::formatter::format_json; +use crate::sql_lint::tests::test_types; + +const DIALECTS: [&str; 6] = [ + "bigquery", + "databricks", + "duckdb", + "postgres", + "snowflake", + "tsql", +]; + +#[test] +fn given_set_operation_after_scalar_subquery_operand_when_formatting_then_keyword_stays_outer() +-> Result<(), String> { + let test_cases = [ + test_types::FormatTestCase { + description: "UNION ALL after WHERE scalar subquery", + sql: "SELECT o.id, o.total FROM orders AS o WHERE o.total < (SELECT MAX(l.amount) FROM limits AS l) UNION ALL SELECT o.id, o.total FROM orders AS o", + expected_sql: "SELECT\n o.id,\n o.total\nFROM orders AS o\nWHERE\n o.total < (\n SELECT MAX(l.amount)\n FROM limits AS l\n )\nUNION ALL\nSELECT\n o.id,\n o.total\nFROM orders AS o", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "EXCEPT after WHERE scalar subquery", + sql: "SELECT o.id FROM orders AS o WHERE o.total > (SELECT MAX(l.amount) FROM limits AS l) EXCEPT SELECT o.id FROM orders AS o", + expected_sql: "SELECT o.id\nFROM orders AS o\nWHERE\n o.total > (\n SELECT MAX(l.amount)\n FROM limits AS l\n )\nEXCEPT\nSELECT o.id\nFROM orders AS o", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "INTERSECT after WHERE scalar subquery", + sql: "SELECT o.id FROM orders AS o WHERE o.total > (SELECT MAX(l.amount) FROM limits AS l) INTERSECT SELECT o.id FROM orders AS o", + expected_sql: "SELECT o.id\nFROM orders AS o\nWHERE\n o.total > (\n SELECT MAX(l.amount)\n FROM limits AS l\n )\nINTERSECT\nSELECT o.id\nFROM orders AS o", + expected_changed: true, + }, + ]; + + for dialect in DIALECTS { + for test_case in &test_cases { + let response = format_json( + &json!({"version": 1, "sql": test_case.sql, "dialect": dialect}).to_string(), + )?; + let payload: Value = + serde_json::from_str(&response).map_err(|error| error.to_string())?; + assert_eq!( + payload["sql"], test_case.expected_sql, + "{dialect}: {}", + test_case.description + ); + assert_eq!( + payload["changed"], test_case.expected_changed, + "{dialect}: {}", + test_case.description + ); + } + } + Ok(()) +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_sql_lint.rs b/crates/sqlclean/src/sql_lint/tests/test_sql_lint.rs new file mode 100644 index 0000000..4171cd0 --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/test_sql_lint.rs @@ -0,0 +1,1969 @@ +use serde_json::{Value, json}; + +use crate::sql_lint::main::engine::lint_json; +use crate::sql_lint::main::formatter::format_json; +use crate::sql_lint::tests::helpers::format_batch_json; +use crate::sql_lint::tests::{helpers, test_types}; + +#[test] +fn given_production_function_depth_when_linting_then_bounded_parser_accepts_it() { + let test_cases = [test_types::FunctionDepthTestCase { + description: "production depth remains supported", + depth: 65, + expected_diagnostic_count: 0, + }]; + for test_case in test_cases { + let sql = helpers::nested_function_sql(test_case.depth); + let diagnostics = helpers::diagnostics(&sql).unwrap_or_else(|error| { + panic!("{}: {error}", test_case.description); + }); + assert_eq!( + diagnostics.len(), + test_case.expected_diagnostic_count, + "{}", + test_case.description + ); + } +} + +#[test] +fn given_excessive_function_depth_when_linting_then_complexity_guard_rejects_it() { + let test_cases = [test_types::FunctionDepthFailureTestCase { + description: "excessive depth remains bounded", + depth: 129, + expected_code: "E_GUARD_FUNCTION_NESTING_DEPTH_EXCEEDED", + expected_limit: "configured limit 128", + }]; + for test_case in test_cases { + let sql = helpers::nested_function_sql(test_case.depth); + let error = + helpers::diagnostics(&sql).expect_err("excessive nested calls should remain bounded"); + assert!( + error.contains(test_case.expected_code), + "{}", + test_case.description + ); + assert!( + error.contains(test_case.expected_limit), + "{}", + test_case.description + ); + } +} + +#[test] +fn given_sql_cases_when_linting_then_diagnostics_match() -> Result<(), String> { + let test_cases = [ + test_types::LintTestCase { + description: "NULL comparison", + sql: "SELECT value FROM a WHERE value = NULL", + expected_codes: &["SQC001"], + expected_anchors: &[("SQC001", "=")], + }, + test_types::LintTestCase { + description: "wrapped NULL comparison", + sql: "SELECT value FROM a WHERE (NULL) = value", + expected_codes: &["SQC001"], + expected_anchors: &[("SQC001", "=")], + }, + test_types::LintTestCase { + description: "implicit cartesian join", + sql: "SELECT a.id FROM a, b", + expected_codes: &["SQC002"], + expected_anchors: &[("SQC002", ",")], + }, + test_types::LintTestCase { + description: "unconditioned join", + sql: "SELECT a.id FROM a JOIN b", + expected_codes: &["SQC003"], + expected_anchors: &[("SQC003", "JOIN")], + }, + test_types::LintTestCase { + description: "unordered limit", + sql: "SELECT id FROM items LIMIT 1", + expected_codes: &["SQC004"], + expected_anchors: &[("SQC004", "LIMIT")], + }, + test_types::LintTestCase { + description: "limit zero selects no rows in any order", + sql: "SELECT id FROM items LIMIT 0", + expected_codes: &[], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "limit zero with an offset still selects no rows", + sql: "SELECT id FROM items LIMIT 0 OFFSET 5", + expected_codes: &[], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "semi-structured limit and offset keys are not row selection clauses", + sql: "SELECT payload:Limit::VARCHAR AS order_limit, payload:Offset::NUMBER AS order_offset FROM orders", + expected_codes: &[], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "semi-structured limit key does not hide a later row limit clause", + sql: "SELECT payload:Limit::VARCHAR AS order_limit FROM orders LIMIT 1", + expected_codes: &["SQC004"], + expected_anchors: &[("SQC004", "LIMIT")], + }, + test_types::LintTestCase { + description: "semi-structured limit key in a filter is not a row limit clause", + sql: "SELECT order_id FROM orders WHERE payload:Limit::NUMBER > 3", + expected_codes: &[], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "unused CTE", + sql: "WITH unused AS (SELECT 1) SELECT 1", + expected_codes: &["SQC005"], + expected_anchors: &[("SQC005", "unused")], + }, + test_types::LintTestCase { + description: "unreachable CTE chain", + sql: "WITH first AS (SELECT 1), second AS (SELECT * FROM first) SELECT 1", + expected_codes: &["SQC005", "SQC005"], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "unused CTE on set operation", + sql: "WITH unused AS (SELECT 1 AS id) SELECT id FROM left_table UNION ALL SELECT id FROM right_table", + expected_codes: &["SQC005"], + expected_anchors: &[("SQC005", "unused")], + }, + test_types::LintTestCase { + description: "redundant distinct", + sql: "SELECT DISTINCT id FROM items GROUP BY id", + expected_codes: &["SQC006"], + expected_anchors: &[("SQC006", "DISTINCT")], + }, + test_types::LintTestCase { + description: "aggregate distinct is not a select modifier", + sql: "SELECT category, COUNT(DISTINCT id) FROM items GROUP BY category", + expected_codes: &[], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "positional set star", + sql: "SELECT * FROM a UNION ALL SELECT * FROM b", + expected_codes: &["SQC007"], + expected_anchors: &[("SQC007", "UNION")], + }, + test_types::LintTestCase { + description: "parenthesized positional set star", + sql: "(SELECT * FROM a) UNION ALL (SELECT id FROM b)", + expected_codes: &["SQC007"], + expected_anchors: &[("SQC007", "UNION")], + }, + test_types::LintTestCase { + description: "nested set wildcard does not contaminate outer set operation", + sql: "SELECT 'actual' WHERE EXISTS (SELECT id FROM actual EXCEPT SELECT * FROM expected) UNION ALL SELECT 'expected'", + expected_codes: &["SQC007"], + expected_anchors: &[("SQC007", "EXCEPT")], + }, + test_types::LintTestCase { + description: "multiple statements", + sql: "SELECT value FROM first LIMIT 1; SELECT value FROM second OFFSET 1", + expected_codes: &["SQC004", "SQC004"], + expected_anchors: &[("SQC004", "LIMIT"), ("SQC004", "OFFSET")], + }, + test_types::LintTestCase { + description: "nested query anchors", + sql: "SELECT * FROM (SELECT id FROM inner_items ORDER BY id LIMIT 1) safe JOIN outer_items ON TRUE OFFSET 2", + expected_codes: &["SQC039", "SQC003", "SQC004"], + expected_anchors: &[ + ("SQC039", "SELECT"), + ("SQC003", "JOIN"), + ("SQC004", "OFFSET"), + ], + }, + test_types::LintTestCase { + description: "safe NULL predicate", + sql: "SELECT value FROM a WHERE value IS NULL", + expected_codes: &[], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "NULL inside function", + sql: "SELECT value FROM a WHERE COALESCE(value, NULL) = 1", + expected_codes: &[], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "NULL assignment", + sql: "UPDATE items SET value = NULL", + expected_codes: &[], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "cross join", + sql: "SELECT a.id FROM a CROSS JOIN b", + expected_codes: &[], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "keyed join", + sql: "SELECT a.id FROM a JOIN b ON a.id = b.id", + expected_codes: &[], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "ordered limit", + sql: "SELECT id FROM items ORDER BY id LIMIT 1", + expected_codes: &[], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "used CTE", + sql: "WITH used AS (SELECT 1 AS id) SELECT id FROM used", + expected_codes: &[], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "group without distinct", + sql: "SELECT id FROM items GROUP BY id", + expected_codes: &[], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "nonredundant distinct", + sql: "SELECT DISTINCT a FROM items GROUP BY a, b", + expected_codes: &[], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "within group", + sql: "SELECT DISTINCT LISTAGG(x, ',') WITHIN GROUP (ORDER BY x) FROM items", + expected_codes: &[], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "explicit positional set", + sql: "SELECT id FROM a UNION ALL SELECT id FROM b", + expected_codes: &[], + expected_anchors: &[], + }, + test_types::LintTestCase { + description: "nested star outside set arms", + sql: "SELECT id, name FROM (SELECT * FROM base) b UNION ALL SELECT id, name FROM other", + expected_codes: &["SQC039"], + expected_anchors: &[("SQC039", "SELECT")], + }, + test_types::LintTestCase { + description: "nested star inside wrapped set arm", + sql: "(SELECT id FROM (SELECT * FROM base) b) UNION ALL (SELECT id FROM other)", + expected_codes: &["SQC039"], + expected_anchors: &[("SQC039", "SELECT")], + }, + ]; + + for test_case in &test_cases { + let diagnostics = helpers::diagnostics(test_case.sql)?; + let codes: Vec<&str> = diagnostics + .iter() + .filter_map(|item| item["code"].as_str()) + .collect(); + let anchors: Vec<(&str, &str)> = diagnostics + .iter() + .filter_map(|item| { + let code = item["code"].as_str()?; + let start = item["start"].as_u64()? as usize; + let end = item["end"].as_u64()? as usize; + Some((code, &test_case.sql[start..end])) + }) + .collect(); + assert_eq!(codes, test_case.expected_codes, "{}", test_case.description); + for expected_anchor in test_case.expected_anchors { + assert!( + anchors.contains(expected_anchor), + "{}", + test_case.description + ); + } + } + Ok(()) +} + +#[test] +fn given_plain_sql_rules_when_linting_then_project_context_is_not_required() -> Result<(), String> { + let test_cases = [ + test_types::PlainSqlLintTestCase { + description: "trailing comment", + sql: "SELECT id -- why\nFROM items", + rule: "SQC033", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "detached comment", + sql: "SELECT id\n-- why\n\nFROM items", + rule: "SQC033", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "attached predicate comment", + sql: "SELECT id FROM items WHERE active\n-- retain settled rows\nAND settled", + rule: "SQC033", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "attached CTE comment", + sql: "WITH\n-- normalized rows\nnormalized AS (SELECT id FROM items)\nSELECT id FROM normalized", + rule: "SQC033", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "attached join comment", + sql: "SELECT left_rows.id FROM left_rows\n-- match stable identities\nJOIN right_rows ON left_rows.id = right_rows.id", + rule: "SQC033", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "attached filter comment", + sql: "SELECT id FROM items\n-- retain active rows\nWHERE active", + rule: "SQC033", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "attached projection comment", + sql: "SELECT\n-- stable identity\nid\nFROM items", + rule: "SQC033", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "attached CASE branch comment", + sql: "SELECT CASE\n-- settled outcome\nWHEN settled THEN 1 ELSE 0 END FROM items", + rule: "SQC033", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "attached ordering comment with Unicode and CRLF", + sql: "SELECT id FROM items\r\n-- deterministic café order\r\nORDER BY id", + rule: "SQC033", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "block comment", + sql: "SELECT id\n/* selected identity */\nFROM items", + rule: "SQC033", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "body without CTE", + sql: "SELECT id FROM items", + rule: "SQC034", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "logic in terminal select", + sql: "WITH final_rows AS (SELECT id FROM items) SELECT id FROM final_rows WHERE id > 0", + rule: "SQC034", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "terminal select reads wrong CTE", + sql: "WITH first_rows AS (SELECT 1 AS id), final_rows AS (SELECT id FROM first_rows) SELECT id FROM first_rows", + rule: "SQC035", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "plain terminal select", + sql: "WITH first_rows AS (SELECT 1 AS id), final_rows AS (SELECT id FROM first_rows) SELECT id FROM final_rows", + rule: "SQC035", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "plain terminal select with keyword-named columns", + sql: "WITH final_rows AS (SELECT 1 AS date, 2 AS comment, 3 AS key, 4 AS percent, 5 AS sequence, 6 AS time) SELECT date, comment, key, percent, sequence, time FROM final_rows", + rule: "SQC035", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "terminal date literal remains an expression", + sql: "WITH final_rows AS (SELECT 1 AS id) SELECT DATE '2026-01-01' FROM final_rows", + rule: "SQC035", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "terminal modulo calculation remains an expression", + sql: "WITH final_rows AS (SELECT 10 AS quantity, 3 AS pack_size) SELECT quantity % pack_size AS remainder FROM final_rows", + rule: "SQC035", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "plain terminal select from CTE with output column list", + sql: "WITH final_rows(id) AS (SELECT 1) SELECT id FROM final_rows", + rule: "SQC035", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "CTE output column list names its calculation", + sql: "WITH final_rows(id) AS (SELECT 1) SELECT id FROM final_rows", + rule: "SQC022", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "terminal union remains outside final CTE", + sql: "WITH final_rows AS (SELECT 1 AS id) SELECT id FROM final_rows UNION ALL SELECT id FROM final_rows", + rule: "SQC034", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "terminal union is not a plain final CTE read", + sql: "WITH final_rows AS (SELECT 1 AS id) SELECT id FROM final_rows UNION ALL SELECT id FROM final_rows", + rule: "SQC035", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "parenthesized terminal union remains outside final CTE", + sql: "WITH final_rows AS (SELECT 1 AS id) (SELECT id FROM final_rows) UNION ALL (SELECT id FROM final_rows)", + rule: "SQC034", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "parenthesized terminal union is not a plain final CTE read", + sql: "WITH final_rows AS (SELECT 1 AS id) (SELECT id FROM final_rows) UNION ALL (SELECT id FROM final_rows)", + rule: "SQC035", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "mixed parenthesized terminal union remains outside final CTE", + sql: "WITH final_rows AS (SELECT 1 AS id) (SELECT id FROM final_rows) UNION ALL SELECT id FROM final_rows", + rule: "SQC034", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "mixed parenthesized terminal union is not a plain final CTE read", + sql: "WITH final_rows AS (SELECT 1 AS id) (SELECT id FROM final_rows) UNION ALL SELECT id FROM final_rows", + rule: "SQC035", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "mixed parenthesized terminal intersect is not a plain final CTE read", + sql: "WITH final_rows AS (SELECT 1 AS id) (SELECT id FROM final_rows) INTERSECT SELECT id FROM final_rows", + rule: "SQC035", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "mixed parenthesized terminal except is not a plain final CTE read", + sql: "WITH final_rows AS (SELECT 1 AS id) (SELECT id FROM final_rows) EXCEPT SELECT id FROM final_rows", + rule: "SQC035", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "nested CTE", + sql: "SELECT id FROM (WITH nested_rows AS (SELECT 1 AS id) SELECT id FROM nested_rows)", + rule: "SQC036", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "Snowflake hierarchy START WITH is not a nested CTE", + sql: "WITH entities AS (SELECT id, parent_id FROM source_rows), hierarchy AS (SELECT id FROM entities START WITH parent_id IS NULL CONNECT BY parent_id = PRIOR id) SELECT id FROM hierarchy", + rule: "SQC036", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "recursive CTE", + sql: "WITH RECURSIVE numbers AS (SELECT 1 UNION ALL SELECT 2) SELECT * FROM numbers", + rule: "SQC037", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "cross join", + sql: "SELECT left_rows.id FROM left_rows CROSS JOIN right_rows", + rule: "SQC038", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "inline query after from", + sql: "SELECT derived.order_id FROM (SELECT order_id FROM orders) AS derived", + rule: "SQC039", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "inline query after join", + sql: "SELECT orders.order_id FROM orders INNER JOIN (SELECT order_id FROM shipments) AS shipped_orders ON orders.order_id = shipped_orders.order_id", + rule: "SQC039", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "inline values query after full outer join", + sql: "SELECT COALESCE(actual.order_id, expected.order_id) AS order_id FROM actual FULL OUTER JOIN (SELECT column1 AS order_id FROM VALUES (1), (2)) AS expected ON actual.order_id = expected.order_id", + rule: "SQC039", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "inline values relation after join", + sql: "SELECT orders.order_id FROM orders INNER JOIN (VALUES (1), (2)) AS expected(order_id) ON orders.order_id = expected.order_id", + rule: "SQC039", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "inline nested with query", + sql: "SELECT derived.order_id FROM (WITH imported_orders AS (SELECT order_id FROM orders) SELECT order_id FROM imported_orders) AS derived", + rule: "SQC039", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "redundantly parenthesized inline query", + sql: "SELECT derived.order_id FROM (((SELECT order_id FROM orders))) AS derived", + rule: "SQC039", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "comma-separated inline query", + sql: "SELECT orders.order_id FROM orders, (SELECT order_id FROM shipments) AS shipped_orders", + rule: "SQC039", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "scalar subquery", + sql: "SELECT (SELECT MAX(order_id) FROM orders) AS latest_order_id", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "scalar subquery containing a derived relation", + sql: "SELECT (SELECT derived.order_id FROM (SELECT nested.order_id FROM (SELECT 1 AS order_id) AS nested) AS derived) AS order_id", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "exists subquery containing a derived relation", + sql: "SELECT order_id FROM orders WHERE EXISTS (SELECT 1 FROM (SELECT nested.order_id FROM (SELECT order_id FROM shipments) AS nested) AS shipped_orders WHERE shipped_orders.order_id = orders.order_id)", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "exists set branch containing a derived relation", + sql: "SELECT EXISTS (SELECT 1 UNION ALL SELECT derived.order_id FROM (SELECT 1 AS order_id) AS derived)", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "scalar with query containing a derived relation in a CTE", + sql: "SELECT (WITH derived AS (SELECT nested.order_id FROM (SELECT 1 AS order_id) AS nested) SELECT order_id FROM derived) AS order_id", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "scalar with query containing a derived terminal relation", + sql: "SELECT (WITH orders AS (SELECT 1 AS order_id) SELECT derived.order_id FROM (SELECT order_id FROM orders) AS derived) AS order_id", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "nested derived relations outside an excluded subquery", + sql: "SELECT derived.order_id FROM (SELECT nested.order_id FROM (SELECT 1 AS order_id) AS nested) AS derived", + rule: "SQC039", + expected_count: 2, + }, + test_types::PlainSqlLintTestCase { + description: "nested derived relations inside a parenthesized ordinary relation", + sql: "SELECT derived.order_id FROM ((SELECT nested.order_id FROM (SELECT 1 AS order_id) AS nested)) AS derived", + rule: "SQC039", + expected_count: 2, + }, + test_types::PlainSqlLintTestCase { + description: "scalar subquery after projection comma", + sql: "SELECT 1 AS constant_value, (SELECT MAX(order_id) FROM orders) AS latest_order_id", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "scalar subquery after expression from", + sql: "SELECT EXTRACT(YEAR FROM (SELECT MAX(order_date) FROM orders)) AS latest_order_year", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "scalar subquery after sibling projection subquery", + sql: "SELECT (SELECT MAX(order_id) FROM orders), COALESCE(NULL, (SELECT MAX(order_id) FROM orders))", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "expression from after sibling projection subquery", + sql: "SELECT (SELECT MAX(order_id) FROM orders), EXTRACT(YEAR FROM (SELECT MAX(order_date) FROM orders))", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "scalar subquery after order by comma", + sql: "SELECT order_id FROM orders ORDER BY order_id, (SELECT MAX(status_id) FROM statuses)", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "scalar subquery with a derived relation after order by comma", + sql: "SELECT order_id FROM orders ORDER BY order_id, (SELECT derived.status_id FROM (SELECT 1 AS status_id) AS derived)", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "exists subquery", + sql: "SELECT order_id FROM orders WHERE EXISTS (SELECT 1 FROM shipments WHERE shipments.order_id = orders.order_id)", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "in subquery", + sql: "SELECT order_id FROM orders WHERE order_id IN (SELECT order_id FROM shipments)", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "named CTE relation", + sql: "WITH shipped_orders AS (SELECT order_id FROM shipments) SELECT orders.order_id FROM orders INNER JOIN shipped_orders ON orders.order_id = shipped_orders.order_id", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "materialized CTE containing a derived relation", + sql: "WITH orders AS MATERIALIZED (SELECT derived.order_id FROM (SELECT 1 AS order_id) AS derived) SELECT order_id FROM orders", + rule: "SQC039", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "not materialized CTE containing a derived relation", + sql: "WITH orders AS NOT MATERIALIZED (SELECT derived.order_id FROM (SELECT 1 AS order_id) AS derived) SELECT order_id FROM orders", + rule: "SQC039", + expected_count: 1, + }, + test_types::PlainSqlLintTestCase { + description: "lateral query relation", + sql: "SELECT orders.order_id FROM orders, LATERAL (SELECT orders.order_id) AS derived", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "apply query relation", + sql: "SELECT orders.order_id FROM orders CROSS APPLY (SELECT orders.order_id) AS derived", + rule: "SQC039", + expected_count: 0, + }, + test_types::PlainSqlLintTestCase { + description: "table function relation", + sql: "SELECT flattened.value FROM TABLE(FLATTEN(input => PARSE_JSON('[1]'))) AS flattened", + rule: "SQC039", + expected_count: 0, + }, + ]; + + for test_case in test_cases { + let diagnostics = helpers::diagnostics_for_rules(test_case.sql, &[test_case.rule])?; + assert_eq!( + diagnostics.len(), + test_case.expected_count, + "{}", + test_case.description + ); + assert!( + diagnostics + .iter() + .all(|diagnostic| diagnostic["code"] == test_case.rule), + "{}", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_native_rules_when_linting_then_diagnosis_and_remediation_are_actionable() +-> Result<(), String> { + let test_cases = [ + test_types::LintMetadataTestCase { + description: "NULL comparison guidance", + sql: "SELECT value FROM a WHERE value = NULL", + expected_code: "SQC001", + expected_message: "Comparison with NULL is never true", + expected_remediation: "Use IS NULL or IS NOT NULL when testing for NULL.", + }, + test_types::LintMetadataTestCase { + description: "implicit join guidance", + sql: "SELECT a.id FROM a, b", + expected_code: "SQC002", + expected_message: "Comma-separated sources create an implicit cartesian join", + expected_remediation: "Replace comma-separated sources with an explicit keyed join, or use CROSS JOIN when the cartesian product is intentional.", + }, + test_types::LintMetadataTestCase { + description: "unconditioned join guidance", + sql: "SELECT a.id FROM a JOIN b", + expected_code: "SQC003", + expected_message: "Non-cross join has no meaningful condition", + expected_remediation: "Add a meaningful ON or USING condition, or declare an intentional cartesian product with CROSS JOIN.", + }, + test_types::LintMetadataTestCase { + description: "unordered limit guidance", + sql: "SELECT id FROM items LIMIT 1", + expected_code: "SQC004", + expected_message: "Row selection is nondeterministic", + expected_remediation: "Add ORDER BY with a deterministic tie-breaker before LIMIT or OFFSET.", + }, + test_types::LintMetadataTestCase { + description: "unused CTE guidance", + sql: "WITH unused AS (SELECT 1) SELECT 1", + expected_code: "SQC005", + expected_message: "CTE is unreachable from the final query", + expected_remediation: "Reference the CTE from the final query or another reachable CTE, or remove it.", + }, + test_types::LintMetadataTestCase { + description: "redundant distinct guidance", + sql: "SELECT DISTINCT id FROM items GROUP BY id", + expected_code: "SQC006", + expected_message: "DISTINCT is redundant with the grouped output", + expected_remediation: "Remove DISTINCT; the equivalent GROUP BY already determines the output groups.", + }, + test_types::LintMetadataTestCase { + description: "positional set star guidance", + sql: "SELECT * FROM a UNION ALL SELECT * FROM b", + expected_code: "SQC007", + expected_message: "Positional set operation is vulnerable to column-order drift", + expected_remediation: "Enumerate columns in the same order in every set-operation branch.", + }, + ]; + + for test_case in test_cases { + let diagnostics = helpers::diagnostics(test_case.sql)?; + let diagnostic = diagnostics + .first() + .ok_or_else(|| format!("{} should report a diagnostic", test_case.description))?; + assert_eq!( + diagnostic["code"], test_case.expected_code, + "{}", + test_case.description + ); + assert_eq!( + diagnostic["message"], test_case.expected_message, + "{}", + test_case.description + ); + assert_eq!( + diagnostic["remediation"], test_case.expected_remediation, + "{}", + test_case.description + ); + let start = diagnostic["start"].as_u64().unwrap_or_default(); + let end = diagnostic["end"].as_u64().unwrap_or_default(); + assert!( + end > start, + "{} should report a non-empty range", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_fixable_and_ambiguous_findings_when_linting_then_only_proven_edits_are_returned() +-> Result<(), String> { + let test_cases = [ + test_types::LintFixTestCase { + description: "right-hand NULL equality", + sql: "SELECT value FROM a WHERE value = NULL", + expected_code: "SQC001", + expected_replacement: Some("IS"), + }, + test_types::LintFixTestCase { + description: "right-hand NULL inequality", + sql: "SELECT value FROM a WHERE value <> NULL", + expected_code: "SQC001", + expected_replacement: Some("IS NOT"), + }, + test_types::LintFixTestCase { + description: "left-hand NULL comparison requires authored intent", + sql: "SELECT value FROM a WHERE NULL = value", + expected_code: "SQC001", + expected_replacement: None, + }, + test_types::LintFixTestCase { + description: "plain conditionless join", + sql: "SELECT a.id FROM a JOIN b", + expected_code: "SQC003", + expected_replacement: Some("CROSS JOIN"), + }, + test_types::LintFixTestCase { + description: "qualified conditionless join requires intent", + sql: "SELECT a.id FROM a LEFT JOIN b", + expected_code: "SQC003", + expected_replacement: None, + }, + test_types::LintFixTestCase { + description: "conditionless semi join is never rewritten as cross join", + sql: "SELECT a.id FROM a SEMI JOIN b", + expected_code: "SQC003", + expected_replacement: None, + }, + test_types::LintFixTestCase { + description: "redundant distinct", + sql: "SELECT DISTINCT id FROM items GROUP BY id", + expected_code: "SQC006", + expected_replacement: Some(""), + }, + test_types::LintFixTestCase { + description: "single unused select CTE", + sql: "WITH unused AS (SELECT 1) SELECT 1", + expected_code: "SQC005", + expected_replacement: Some(""), + }, + test_types::LintFixTestCase { + description: "reserved framework CTE is never deleted", + sql: "WITH __expected__items AS (SELECT 1) SELECT 1", + expected_code: "SQC005", + expected_replacement: None, + }, + ]; + + for test_case in test_cases { + let diagnostics = helpers::diagnostics(test_case.sql)?; + let diagnostic = diagnostics + .iter() + .find(|item| item["code"] == test_case.expected_code) + .ok_or_else(|| format!("{} should report", test_case.description))?; + assert_eq!( + diagnostic["fix"]["replacement"].as_str(), + test_case.expected_replacement, + "{}", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_additional_rule_cases_when_linting_then_findings_and_fixes_match() -> Result<(), String> { + let test_cases = [ + test_types::AdditionalLintRuleTestCase { + description: "bare union", + sql: "SELECT 1 UNION SELECT 2", + rule: "SQC008", + expected_anchor: Some("UNION"), + expected_replacement: Some("UNION DISTINCT"), + }, + test_types::AdditionalLintRuleTestCase { + description: "duplicate relation alias", + sql: "SELECT x.id FROM alpha AS x JOIN beta AS x ON x.id = x.id", + rule: "SQC009", + expected_anchor: Some("x"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "duplicate output alias", + sql: "SELECT a AS value, b AS value FROM items", + rule: "SQC010", + expected_anchor: Some("value"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "mixed grouping references", + sql: "SELECT a, b FROM items GROUP BY 1, b", + rule: "SQC011", + expected_anchor: Some("GROUP"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "redundant else null", + sql: "SELECT CASE WHEN a THEN b ELSE NULL END FROM items", + rule: "SQC012", + expected_anchor: Some("ELSE NULL"), + expected_replacement: Some(""), + }, + test_types::AdditionalLintRuleTestCase { + description: "function-like distinct", + sql: "SELECT DISTINCT(a) FROM items", + rule: "SQC013", + expected_anchor: Some("DISTINCT(a)"), + expected_replacement: Some("DISTINCT a"), + }, + test_types::AdditionalLintRuleTestCase { + description: "constant scaffold predicate", + sql: "SELECT a FROM items WHERE 1 = 1", + rule: "SQC014", + expected_anchor: Some("="), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "consecutive statement terminator", + sql: "SELECT 1;;", + rule: "SQC015", + expected_anchor: Some(";"), + expected_replacement: Some(""), + }, + test_types::AdditionalLintRuleTestCase { + description: "redundant self alias", + sql: "SELECT value AS value FROM items", + rule: "SQC016", + expected_anchor: Some(" AS value"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "qualified column self alias is redundant", + sql: "SELECT items.value AS value FROM items", + rule: "SQC016", + expected_anchor: Some(" AS value"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "qualified column self alias after an expression is redundant", + sql: "SELECT item_id + 1 AS next_item_id, items.value AS value FROM items", + rule: "SQC016", + expected_anchor: Some(" AS value"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "top-modified column self alias is redundant", + sql: "SELECT TOP 5 value AS value FROM items", + rule: "SQC016", + expected_anchor: Some(" AS value"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "terminal operand name does not make a calculation self-aliased", + sql: "SELECT price + value AS value FROM items", + rule: "SQC016", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "cast type syntax is not an output self alias", + sql: "SELECT CAST(order_id::INTEGER AS INTEGER) AS order_id FROM orders", + rule: "SQC016", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "relation self alias is not an output self alias", + sql: "SELECT orders.order_id FROM orders AS orders", + rule: "SQC016", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "semi-structured path alias is not redundant", + sql: "SELECT event.payload:data:order_id AS order_id FROM events AS event", + rule: "SQC016", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "constant row count", + sql: "SELECT COUNT(1) FROM items", + rule: "SQC017", + expected_anchor: Some("1"), + expected_replacement: Some("*"), + }, + test_types::AdditionalLintRuleTestCase { + description: "unordered row number", + sql: "SELECT ROW_NUMBER() OVER (PARTITION BY id) FROM items", + rule: "SQC018", + expected_anchor: Some("ROW_NUMBER"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "literal null in not-in list", + sql: "SELECT id FROM items WHERE id NOT IN (1, NULL)", + rule: "SQC019", + expected_anchor: Some("NOT"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "set branch arity mismatch", + sql: "SELECT a, b FROM first UNION ALL SELECT c FROM second", + rule: "SQC020", + expected_anchor: Some("UNION"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "uncontrolled projection star", + sql: "SELECT * FROM items", + rule: "SQC021", + expected_anchor: Some("*"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "projection star without from remains uncontrolled", + sql: "SELECT *", + rule: "SQC021", + expected_anchor: Some("*"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "mixed dynamic pivot projection remains uncontrolled", + sql: "SELECT *, customer_id FROM orders PIVOT(MAX(amount) FOR category IN (ANY))", + rule: "SQC021", + expected_anchor: Some("*"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "static pivot star remains uncontrolled", + sql: "SELECT * FROM orders PIVOT(MAX(amount) FOR category IN ('books'))", + rule: "SQC021", + expected_anchor: Some("*"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "static pivot any string remains uncontrolled", + sql: "SELECT * FROM orders PIVOT(MAX(amount) FOR category IN ('ANY'))", + rule: "SQC021", + expected_anchor: Some("*"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "nested dynamic pivot does not exempt outer projection star", + sql: "SELECT * FROM orders WHERE EXISTS (SELECT 1 FROM items PIVOT(MAX(amount) FOR category IN (ANY)))", + rule: "SQC021", + expected_anchor: Some("*"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "unrelated any does not exempt static pivot star", + sql: "SELECT * FROM orders PIVOT(MAX(amount) FOR category IN ('books')) WHERE customer_id = ANY (SELECT customer_id FROM customers)", + rule: "SQC021", + expected_anchor: Some("*"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "comma joined dynamic pivot star remains uncontrolled", + sql: "SELECT * FROM orders PIVOT(MAX(amount) FOR category IN (ANY)) AS p, customers AS c", + rule: "SQC021", + expected_anchor: Some("*"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "joined audit relation parameter star remains uncontrolled", + sql: "SELECT * FROM __sqlbuild_audit_parameter_0__ AS a CROSS JOIN customers AS c", + rule: "SQC021", + expected_anchor: Some("*"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "terminal framework dependency star remains uncontrolled", + sql: "SELECT * FROM __sqb_lint_0__", + rule: "SQC021", + expected_anchor: Some("*"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "transformed framework dependency import star remains uncontrolled", + sql: "WITH imported_orders AS (SELECT *, CURRENT_TIMESTAMP AS loaded_at FROM __sqb_lint_0__) SELECT order_id FROM imported_orders", + rule: "SQC021", + expected_anchor: Some("*"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "ordinary relation import star remains uncontrolled", + sql: "WITH imported_orders AS (SELECT * FROM orders) SELECT order_id FROM imported_orders", + rule: "SQC021", + expected_anchor: Some("*"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "nested framework dependency import star remains uncontrolled", + sql: "WITH outer_query AS (WITH imported_orders AS (SELECT * FROM __sqb_lint_0__) SELECT order_id FROM imported_orders) SELECT order_id FROM outer_query", + rule: "SQC021", + expected_anchor: Some("*"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "unaliased calculated projection", + sql: "SELECT price * quantity FROM items", + rule: "SQC022", + expected_anchor: Some("price"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "unused table alias", + sql: "SELECT id FROM items AS unused", + rule: "SQC023", + expected_anchor: Some("AS unused"), + expected_replacement: Some(""), + }, + test_types::AdditionalLintRuleTestCase { + description: "left join rejected in where", + sql: "SELECT a.id FROM a LEFT JOIN b AS right_side ON a.id = right_side.id WHERE right_side.active = TRUE", + rule: "SQC024", + expected_anchor: Some("LEFT"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "implicit inner join", + sql: "SELECT a.id FROM a JOIN b ON a.id = b.id", + rule: "SQC025", + expected_anchor: Some("JOIN"), + expected_replacement: Some("INNER JOIN"), + }, + test_types::AdditionalLintRuleTestCase { + description: "mixed order directions", + sql: "SELECT a, b FROM items ORDER BY a, b DESC", + rule: "SQC026", + expected_anchor: Some("ORDER"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "unqualified multi-source column", + sql: "SELECT id FROM a JOIN b ON a.id = b.id", + rule: "SQC027", + expected_anchor: Some("id"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "mixed single-source qualification", + sql: "SELECT items.id, name FROM items", + rule: "SQC028", + expected_anchor: Some("name"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "plain macro slot reads count as unqualified beside a plain group read", + sql: "SELECT __sqlbuild_macro_parameter_1__, __sqlbuild_macro_parameter_0__.__sqlbuild_macro_parameter_2__ AS orders FROM __sqlbuild_macro_parameter_0__", + rule: "SQC028", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "a plain macro slot read beside a hand-qualified column is mixed", + sql: "SELECT __sqlbuild_macro_parameter_0__.__sqlbuild_macro_parameter_1__ AS orders, s.refunds FROM __sqlbuild_macro_parameter_0__ AS s", + rule: "SQC028", + expected_anchor: Some("__sqlbuild_macro_parameter_0__"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "unknown relation qualifier", + sql: "SELECT missing.id FROM items AS present", + rule: "SQC029", + expected_anchor: Some("missing"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "Snowflake shorthand cast type is not an unqualified column", + sql: "SELECT src.value::STRING AS value FROM items AS src", + rule: "SQC028", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "Snowflake JSON path and shorthand cast type are not columns", + sql: "SELECT src.payload:item::TEXT AS item FROM items AS src", + rule: "SQC028", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "Snowflake shorthand cast type is not a multi-source column", + sql: "SELECT first.value::TEXT AS value FROM first JOIN second ON first.id = second.id", + rule: "SQC027", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "implicit table alias is known in query scope", + sql: "SELECT present.id FROM items present", + rule: "SQC029", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "implicit joined alias contributes a selected value", + sql: "SELECT left_side.id, right_side.name FROM alpha left_side LEFT JOIN beta right_side ON left_side.id = right_side.id", + rule: "SQC032", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "Snowflake lateral flatten comma is explicit lateral syntax", + sql: "SELECT flattened.value FROM items, LATERAL FLATTEN(input => items.payload) flattened", + rule: "SQC002", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "Snowflake VALUES row separator is not a relation separator", + sql: "SELECT column1 FROM VALUES (1), (2), (3)", + rule: "SQC002", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "arithmetic multiplication is not a projected wildcard", + sql: "SELECT price * quantity AS total FROM items", + rule: "SQC021", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "number-led multiplication is not a projected wildcard", + sql: "SELECT 100 * ratio AS percentage FROM items", + rule: "SQC021", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "TOP projection wildcard remains controlled by star rule", + sql: "SELECT TOP 10 * FROM items", + rule: "SQC021", + expected_anchor: Some("*"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "qualified wildcard modifier does not require an alias", + sql: "SELECT src.* EXCLUDE (internal_id) FROM items AS src", + rule: "SQC022", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "EXISTS literal projection does not require an alias", + sql: "SELECT parent.id FROM parent WHERE EXISTS (SELECT 1 FROM child WHERE child.id = parent.id)", + rule: "SQC022", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "correlated subquery can reference an outer relation", + sql: "SELECT parent.id FROM parent WHERE EXISTS (SELECT 1 FROM child WHERE child.id = parent.id)", + rule: "SQC029", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "Snowflake JSON path key is not a relation qualifier", + sql: "SELECT PARSE_JSON(src.payload):item.name::VARCHAR AS item_name FROM items AS src", + rule: "SQC029", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "Snowflake JSON path starts from a qualified relation value", + sql: "SELECT parsed:item::VARCHAR AS item FROM items AS src, LATERAL FLATTEN(input => src.payload) AS parsed", + rule: "SQC027", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "Snowflake ASOF JOIN alias is known in query scope", + sql: "SELECT matched.ts FROM requested request ASOF JOIN available matched MATCH_CONDITION (request.ts >= matched.ts) ON request.id = matched.id", + rule: "SQC029", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "later set branches inherit the first branch output name", + sql: "SELECT 0 AS offset_value UNION ALL SELECT 1 UNION ALL SELECT 2", + rule: "SQC022", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "scalar subquery output does not require an alias inside its scope", + sql: "SELECT (SELECT MAX(child.value) FROM child WHERE child.id = parent.id) AS maximum_value FROM parent", + rule: "SQC022", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "later scalar function argument does not require an inner alias", + sql: "SELECT COALESCE((SELECT MAX(change_id) FROM first_changes), (SELECT MAX(change_id) FROM second_changes), 0) AS latest_change_id FROM support_tickets", + rule: "SQC022", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "scalar aggregate nested in conditional coalesce does not require an inner alias", + sql: "SELECT CASE WHEN batch.layout = 'fallback' THEN COALESCE((SELECT MAX(change.line_id) FROM first_changes AS change WHERE change.batch_id = batch.batch_id AND change.line_id < batch.line_id), (SELECT MAX(archive.line_id) FROM archived_changes AS archive WHERE archive.batch_id = batch.batch_id AND archive.line_id < batch.line_id), header.line_id) + 1 ELSE batch.start_line END AS start_line FROM support_batches AS batch LEFT JOIN support_headers AS header ON header.batch_id = batch.batch_id", + rule: "SQC022", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "prior nested relation does not contaminate a later scalar function argument", + sql: "WITH prior_changes AS (SELECT (SELECT MAX(change_id) FROM support_changes) AS latest_change_id FROM support_tickets), current_changes AS (SELECT COALESCE((SELECT MAX(change_id) FROM first_changes), (SELECT MAX(change_id) FROM second_changes), 0) AS latest_change_id FROM current_tickets) SELECT * FROM current_changes", + rule: "SQC022", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "scalar subquery after a prior CTE relation remains in projection scope", + sql: "WITH products_used AS (SELECT product_id FROM products), ticket_totals AS (SELECT ticket.ticket_id, (SELECT MAX(change_id) FROM support_changes) AS latest_change_id FROM support_tickets AS ticket) SELECT * FROM ticket_totals", + rule: "SQC022", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "comma-derived table calculation still requires an alias", + sql: "SELECT derived.value FROM items, (SELECT amount + tax FROM totals) AS derived", + rule: "SQC022", + expected_anchor: Some("amount"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "scalar subquery scope does not cross a prior statement", + sql: "SELECT order_id FROM orders; SELECT invoice_id, (SELECT MAX(amount + tax) FROM totals) AS maximum_total FROM invoices", + rule: "SQC022", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "contextual keyword remains a simple qualified projection", + sql: "SELECT flattened.index FROM items AS src, LATERAL FLATTEN(input => src.payload) AS flattened", + rule: "SQC022", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "generated expression sentinel is not an authored column", + sql: "SELECT first.id FROM first JOIN second ON first.id = second.id WHERE __sqb_lint_0__", + rule: "SQC027", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "null-safe comparison FROM is not a relation clause", + sql: "SELECT CASE WHEN src.actual IS DISTINCT FROM src.expected THEN 1 ELSE 0 END AS differs FROM items AS src", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "earlier projection alias is not a relation column", + sql: "SELECT flattened.value AS item_index, item_index + 1 AS next_index FROM items AS src, LATERAL FLATTEN(input => src.payload) AS flattened", + rule: "SQC027", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "current date context value is not a multi-source column", + sql: "SELECT first_side.id FROM first_side JOIN second_side ON first_side.id = second_side.id WHERE first_side.created_at >= CURRENT_DATE", + rule: "SQC027", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "current date context value does not mix qualification styles", + sql: "SELECT first_side.id FROM first_side WHERE first_side.created_at >= CURRENT_DATE", + rule: "SQC028", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "simple boolean case", + sql: "SELECT CASE WHEN amount > 0 THEN TRUE ELSE FALSE END AS positive FROM items", + rule: "SQC030", + expected_anchor: Some("CASE WHEN amount > 0 THEN TRUE ELSE FALSE END"), + expected_replacement: Some("COALESCE(amount > 0, FALSE)"), + }, + test_types::AdditionalLintRuleTestCase { + description: "unicode before simple boolean case preserves replacement text", + sql: "SELECT 'é', CASE WHEN amount > 0 THEN TRUE ELSE FALSE END AS positive FROM items", + rule: "SQC030", + expected_anchor: Some("CASE WHEN amount > 0 THEN TRUE ELSE FALSE END"), + expected_replacement: Some("COALESCE(amount > 0, FALSE)"), + }, + test_types::AdditionalLintRuleTestCase { + description: "commented boolean case is diagnosed without a fix", + sql: "SELECT CASE WHEN amount /* reason */ > 0 THEN TRUE ELSE FALSE END FROM items", + rule: "SQC030", + expected_anchor: Some("CASE WHEN amount /* reason */ > 0 THEN TRUE ELSE FALSE END"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "multi-branch boolean case is diagnosed without an unsafe partial fix", + sql: "SELECT CASE WHEN status = 'open' THEN TRUE WHEN priority = 'high' THEN TRUE ELSE FALSE END AS actionable FROM support_tickets", + rule: "SQC030", + expected_anchor: Some( + "CASE WHEN status = 'open' THEN TRUE WHEN priority = 'high' THEN TRUE ELSE FALSE END", + ), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "case nested directly in else", + sql: "SELECT CASE WHEN a THEN 1 ELSE CASE WHEN b THEN 2 END END FROM items", + rule: "SQC031", + expected_anchor: Some("CASE"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "joined relation contributes no values", + sql: "SELECT a.id FROM a LEFT JOIN b ON a.id = b.id", + rule: "SQC032", + expected_anchor: Some("JOIN"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "joined relation contributes through an aggregate expression", + sql: "SELECT ARRAY_AGG(b.value) AS values FROM a LEFT JOIN b ON a.id = b.id", + rule: "SQC032", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "bare projected star consumes every joined relation", + sql: "SELECT * FROM orders AS o INNER JOIN customers AS c ON o.customer_id = c.customer_id", + rule: "SQC032", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "unqualified projection prevents an unsupported unused relation claim", + sql: "SELECT customer_name FROM orders AS o INNER JOIN customers AS c ON o.customer_id = c.customer_id", + rule: "SQC032", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "unqualified aggregate argument prevents an unsupported unused relation claim", + sql: "SELECT MAX(customer_name) FROM orders AS o INNER JOIN customers AS c ON o.customer_id = c.customer_id", + rule: "SQC032", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "left qualified star does not consume joined relation", + sql: "SELECT o.* FROM orders AS o INNER JOIN customers AS c ON o.customer_id = c.customer_id", + rule: "SQC032", + expected_anchor: Some("JOIN"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "cross joined relation contributes through a filter predicate", + sql: "SELECT orders.order_id FROM orders CROSS JOIN processing_cutoff AS cutoff WHERE orders.created_at < cutoff.created_at", + rule: "SQC032", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "lateral relation contributes through a downstream join condition", + sql: "SELECT orders.order_id, products.name FROM orders CROSS JOIN LATERAL UNNEST(orders.product_ids) AS item INNER JOIN products ON (products.product_id = item.product_id)", + rule: "SQC032", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "explicit union is clean", + sql: "SELECT 1 UNION ALL SELECT 2", + rule: "SQC008", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "ordered row number without a declared key is unproven", + sql: "SELECT ROW_NUMBER() OVER (PARTITION BY id ORDER BY created_at, id) FROM items", + rule: "SQC018", + expected_anchor: Some("ROW_NUMBER"), + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "equal set arity is clean", + sql: "SELECT a, b FROM first UNION ALL SELECT c, d FROM second", + rule: "SQC020", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "dynamic pivot star has data-dependent columns", + sql: "SELECT * FROM orders PIVOT(MAX(amount) FOR category IN (ANY ORDER BY category))", + rule: "SQC021", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "audit relation parameter star has invocation-dependent columns", + sql: "SELECT * FROM __sqlbuild_audit_parameter_0__", + rule: "SQC021", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "qualified alias is used", + sql: "SELECT kept.id FROM items AS kept", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "left join predicate retained in on is clean", + sql: "SELECT a.id FROM a LEFT JOIN b ON a.id = b.id AND b.active = TRUE", + rule: "SQC024", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "explicit inner join is clean", + sql: "SELECT a.id FROM a INNER JOIN b ON a.id = b.id", + rule: "SQC025", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "null literal inside not-in subquery expression is not a literal list", + sql: "SELECT id FROM items WHERE id NOT IN (SELECT COALESCE(id, NULL) FROM other)", + rule: "SQC019", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "left join null probe preserves unmatched rows", + sql: "SELECT a.id FROM a LEFT JOIN b ON a.id = b.id WHERE b.id IS NULL", + rule: "SQC024", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "left join predicate guarded by null probe preserves unmatched rows", + sql: "SELECT a.id FROM a LEFT JOIN b ON a.id = b.id WHERE b.active = TRUE OR b.id IS NULL", + rule: "SQC024", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "qualified catalog path is not treated as an unknown relation alias", + sql: "SELECT catalog.schema.items.id FROM catalog.schema.items", + rule: "SQC029", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "correlated subquery keeps outer table alias", + sql: "SELECT id FROM items AS kept WHERE EXISTS (SELECT 1 FROM other WHERE other.id = kept.id)", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "asof join is not rewritten as an inner join", + sql: "SELECT a.id FROM a ASOF JOIN b ON a.id = b.id", + rule: "SQC025", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "semi join is not rewritten as an inner join", + sql: "SELECT a.id FROM a SEMI JOIN b ON a.id = b.id", + rule: "SQC025", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "table alias carrying a column list is retained", + sql: "SELECT a, b FROM items AS t(a, b)", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "bracket list commas do not change set projection arity", + sql: "SELECT a, [1, 2, 3] AS arr FROM t UNION ALL SELECT a, [4] AS arr FROM t", + rule: "SQC020", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "quoted output alias is not redundant with an unquoted identifier", + sql: "SELECT value AS \"value\" FROM items", + rule: "SQC016", + expected_anchor: None, + expected_replacement: None, + }, + ]; + + for test_case in test_cases { + let diagnostics = helpers::diagnostics_for_rules(test_case.sql, &[test_case.rule])?; + assert_eq!( + diagnostics.len(), + usize::from(test_case.expected_anchor.is_some()), + "{}", + test_case.description + ); + let _ = test_case.expected_anchor.map(|expected_anchor| { + let diagnostic = &diagnostics[0]; + let start = diagnostic["start"].as_u64().unwrap_or_default() as usize; + let end = diagnostic["end"].as_u64().unwrap_or_default() as usize; + let source: String = test_case + .sql + .chars() + .skip(start) + .take(end - start) + .collect(); + assert_eq!(source, expected_anchor, "{}", test_case.description); + assert_eq!( + diagnostic["fix"]["replacement"].as_str(), + test_case.expected_replacement, + "{}", + test_case.description + ); + }); + } + Ok(()) +} + +#[test] +fn given_ceremonial_cte_contexts_when_linting_then_only_harness_dependencies_are_reachable() +-> Result<(), String> { + let test_cases = [ + test_types::CeremonialCteLintTestCase { + description: "macro harness outputs keep their helper dependency reachable", + sql: "WITH input_values AS (SELECT 7 AS amount), __macro_actual__ AS (SELECT amount FROM input_values), __macro_expected__ AS (SELECT 7 AS amount) SELECT 1", + expected_count: 0, + expected_start: None, + }, + test_types::CeremonialCteLintTestCase { + description: "non-harness double-underscore CTE remains diagnosable", + sql: "WITH __scratch AS (SELECT 1), __macro_actual__ AS (SELECT 7 AS amount), __macro_expected__ AS (SELECT 7 AS amount) SELECT 1", + expected_count: 1, + expected_start: Some(5), + }, + ]; + for test_case in test_cases { + let response = lint_json( + &json!({ + "version": 1, + "sql": test_case.sql, + "dialect": "duckdb", + "enabled_rules": ["SQC005"], + "allows_ceremonial_select": true, + }) + .to_string(), + )?; + let payload: Value = serde_json::from_str(&response).map_err(|error| error.to_string())?; + let diagnostics = payload["diagnostics"] + .as_array() + .ok_or_else(|| "diagnostics should be an array".to_string())?; + + assert_eq!( + diagnostics.len(), + test_case.expected_count, + "{}", + test_case.description + ); + assert_eq!( + diagnostics + .first() + .and_then(|diagnostic| diagnostic["start"].as_u64()), + test_case.expected_start, + "{}", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_dependency_identity_when_linting_import_stars_then_only_plain_top_level_import_is_accepted() +-> Result<(), String> { + let test_cases = [ + test_types::DependencyImportLintTestCase { + description: "plain top-level dependency import", + sql: "WITH imported_orders AS (SELECT * FROM __sqb_lint_0__) SELECT order_id FROM imported_orders", + dependency_identifiers: &["__sqb_lint_0__"], + expected_count: 0, + }, + test_types::DependencyImportLintTestCase { + description: "star without from remains diagnosable", + sql: "WITH imported_orders AS (SELECT *) SELECT order_id FROM imported_orders", + dependency_identifiers: &["__sqb_lint_0__"], + expected_count: 1, + }, + test_types::DependencyImportLintTestCase { + description: "set operation import is transformed", + sql: "WITH imported_orders AS (SELECT * FROM __sqb_lint_0__ UNION ALL SELECT order_id FROM orders) SELECT order_id FROM imported_orders", + dependency_identifiers: &["__sqb_lint_0__"], + expected_count: 1, + }, + test_types::DependencyImportLintTestCase { + description: "dynamic relation is not a dependency", + sql: "WITH imported_orders AS (SELECT * FROM __sqb_lint_0__) SELECT order_id FROM imported_orders", + dependency_identifiers: &[], + expected_count: 1, + }, + test_types::DependencyImportLintTestCase { + description: "terminal dependency star remains uncontrolled", + sql: "SELECT * FROM __sqb_lint_0__", + dependency_identifiers: &["__sqb_lint_0__"], + expected_count: 1, + }, + test_types::DependencyImportLintTestCase { + description: "transformed dependency import remains uncontrolled", + sql: "WITH imported_orders AS (SELECT *, CURRENT_TIMESTAMP AS loaded_at FROM __sqb_lint_0__) SELECT order_id FROM imported_orders", + dependency_identifiers: &["__sqb_lint_0__"], + expected_count: 1, + }, + test_types::DependencyImportLintTestCase { + description: "nested dependency import remains uncontrolled", + sql: "WITH outer_query AS (WITH imported_orders AS (SELECT * FROM __sqb_lint_0__) SELECT order_id FROM imported_orders) SELECT order_id FROM outer_query", + dependency_identifiers: &["__sqb_lint_0__"], + expected_count: 1, + }, + ]; + for test_case in test_cases { + let response = lint_json( + &json!({ + "version": 1, + "sql": test_case.sql, + "dialect": "duckdb", + "enabled_rules": ["SQC021"], + "dependency_identifiers": test_case.dependency_identifiers, + }) + .to_string(), + )?; + let payload: Value = serde_json::from_str(&response).map_err(|error| error.to_string())?; + let diagnostics = payload["diagnostics"] + .as_array() + .ok_or_else(|| "diagnostics should be an array".to_owned())?; + assert_eq!( + diagnostics.len(), + test_case.expected_count, + "{}", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_ceremonial_select_context_when_linting_terminal_shape_then_rule_035_allows_it() +-> Result<(), String> { + let test_cases = [test_types::PlainSqlLintTestCase { + description: "direct ceremonial select is valid in harness context", + sql: "WITH __source__raw_orders AS (SELECT 1 AS order_id), __expected__orders AS (SELECT 1 AS order_id) SELECT 1", + rule: "SQC035", + expected_count: 0, + }]; + for test_case in test_cases { + let response = lint_json( + &json!({ + "version": 1, + "sql": test_case.sql, + "dialect": "duckdb", + "enabled_rules": [test_case.rule], + "allows_ceremonial_select": true, + }) + .to_string(), + )?; + let payload: Value = serde_json::from_str(&response).map_err(|error| error.to_string())?; + let diagnostics = payload["diagnostics"] + .as_array() + .ok_or_else(|| "diagnostics should be an array".to_owned())?; + assert_eq!( + diagnostics.len(), + test_case.expected_count, + "{}", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_tsql_bare_union_when_linting_then_diagnostic_has_no_invalid_fix() -> Result<(), String> { + let test_cases = [test_types::DialectLintRuleTestCase { + description: "T-SQL bare UNION is diagnosed without an invalid fix", + sql: "SELECT 1 UNION SELECT 2", + dialect: "tsql", + rule: "SQC008", + expected_count: 1, + expected_fix: false, + expected_reason: "the active dialect does not accept explicit UNION DISTINCT", + }]; + + for test_case in test_cases { + let diagnostics = + helpers::diagnostics_for_dialect(test_case.sql, test_case.dialect, &[test_case.rule])?; + assert_eq!( + diagnostics.len(), + test_case.expected_count, + "{}", + test_case.description + ); + assert_eq!( + diagnostics[0].get("fix").is_some(), + test_case.expected_fix, + "{}", + test_case.description + ); + assert_eq!( + diagnostics[0]["fix_unavailable_reason"], test_case.expected_reason, + "{}", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_format_cases_when_formatting_then_output_matches() -> Result<(), String> { + let test_cases = [ + test_types::FormatTestCase { + description: "comment-free canonical SQL", + sql: "select a,b from items where a=1", + expected_sql: "SELECT\n a,\n b\nFROM items\nWHERE a = 1", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "lowercase function canonicalization preserves structure", + sql: "select count(1) from items", + expected_sql: "SELECT COUNT(1)\nFROM items", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "block comment attachment", + sql: "SELECT a, /* preserve */ b FROM items", + expected_sql: "SELECT\n a, /* preserve */\n b\nFROM items", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "line and leading comments", + sql: "-- lead\nselect a,b from items -- tail\nwhere a=1", + expected_sql: "-- lead\nSELECT\n a,\n b\nFROM items -- tail\nWHERE a = 1", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "standalone projection comments remain attached to following expressions", + sql: "SELECT\n -- first value\n a,\n -- second value\n b\nFROM items", + expected_sql: "SELECT\n -- first value\n a,\n -- second value\n b\nFROM items", + expected_changed: false, + }, + test_types::FormatTestCase { + description: "comment marker string", + sql: "select '-- not a comment' as value", + expected_sql: "SELECT '-- not a comment' AS value", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "doubled apostrophe string remains compiler compatible", + sql: "select 'Customer''s order' as value", + expected_sql: "SELECT 'Customer''s order' AS value", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "explicit null ordering remains explicit", + sql: "select id from items order by category nulls last, created_at desc nulls first", + expected_sql: "SELECT id\nFROM items\nORDER BY\n category NULLS LAST,\n created_at DESC NULLS FIRST", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "explicit cast type spellings remain authored", + sql: "select cast(label as TEXT) as label, cast(id as INTEGER) as id, cast(observed_at as TIMESTAMP_NTZ) as observed_at, cast(amount as NUMERIC(10, 2)) as amount from items", + expected_sql: "SELECT\n CAST(label AS TEXT) AS label,\n CAST(id AS INTEGER) AS id,\n CAST(observed_at AS TIMESTAMP_NTZ) AS observed_at,\n CAST(amount AS NUMERIC(10, 2)) AS amount\nFROM items", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "nested and try cast type spellings remain authored", + sql: "select cast(coalesce(try_cast(raw_id as INTEGER), 0) as NUMERIC(10, 2)) as amount, cast(tags as ARRAY) as tags from items", + expected_sql: "SELECT\n CAST(COALESCE(TRY_CAST(raw_id AS INTEGER), 0) AS NUMERIC(10, 2)) AS amount,\n CAST(tags AS ARRAY) AS tags\nFROM items", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "commented cast type remains idempotent", + sql: "select cast(amount as NUMERIC(/* precision */ 10, 2)) as amount from items", + expected_sql: "SELECT\n CAST(amount AS NUMERIC( /* precision */10, 2)) AS amount\nFROM items", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "cast-free token expansion remains supported", + sql: "select id label from items", + expected_sql: "SELECT id label\nFROM items", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "cast-free multiple statements remain supported", + sql: "select 1; select 2", + expected_sql: "SELECT 1;\nSELECT 2", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "trailing comment after a kept statement terminator", + sql: "select a from items; -- retained", + expected_sql: "SELECT a\nFROM items; -- retained", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "unparenthesized values relation remains unparenthesized", + sql: "select column1::varchar as customer_key, column2::integer as order_count from values ('c1', 1), ('c2', 2)", + expected_sql: "SELECT\n column1::varchar AS customer_key,\n column2::integer AS order_count\nFROM VALUES ('c1', 1), ('c2', 2)", + expected_changed: true, + }, + ]; + + for test_case in &test_cases { + let response = format_json( + &json!({"version": 1, "sql": test_case.sql, "dialect": "snowflake"}).to_string(), + )?; + let payload: Value = serde_json::from_str(&response).map_err(|error| error.to_string())?; + assert_eq!( + payload["sql"], test_case.expected_sql, + "{}", + test_case.description + ); + assert_eq!( + payload["changed"], test_case.expected_changed, + "{}", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_ordered_sql_batch_when_formatting_then_outputs_are_parallel_and_ordered() +-> Result<(), String> { + let test_cases = [ + test_types::FormatTestCase { + description: "first order query", + sql: "select order_id from orders where order_id=1", + expected_sql: "SELECT order_id\nFROM orders\nWHERE order_id = 1", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "second product query", + sql: "select product_id from products where product_id=2", + expected_sql: "SELECT product_id\nFROM products\nWHERE product_id = 2", + expected_changed: true, + }, + ]; + let requests: Vec = test_cases + .iter() + .map(|test_case| json!({"version": 1, "sql": test_case.sql, "dialect": "snowflake"})) + .collect(); + + let response = + format_batch_json(&serde_json::to_string(&requests).map_err(|error| error.to_string())?)?; + let payloads: Vec = + serde_json::from_str(&response).map_err(|error| error.to_string())?; + + assert_eq!(payloads.len(), test_cases.len()); + let mut payload_iter = payloads.iter(); + for test_case in &test_cases { + let payload = payload_iter + .next() + .ok_or_else(|| format!("{}: batch response omitted an item", test_case.description))?; + assert_eq!( + payload["sql"], test_case.expected_sql, + "{}", + test_case.description + ); + assert_eq!(payload["changed"], test_case.expected_changed); + } + Ok(()) +} + +#[test] +fn given_unknown_rule_when_linting_then_request_is_rejected() -> Result<(), String> { + let test_cases = [test_types::InvalidRuleTestCase { + description: "unknown native lint rule", + rule: "SQC999", + expected_message: "unknown native lint rule 'SQC999'", + }]; + + for test_case in &test_cases { + let request = json!({"version": 1, "sql": "SELECT 1", "dialect": "snowflake", "enabled_rules": [test_case.rule]}).to_string(); + let error = lint_json(&request) + .err() + .ok_or_else(|| "unknown rule should fail".to_string())?; + assert!( + error.contains(test_case.expected_message), + "{}", + test_case.description + ); + } + Ok(()) +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_types.rs b/crates/sqlclean/src/sql_lint/tests/test_types.rs new file mode 100644 index 0000000..6a3348b --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/test_types.rs @@ -0,0 +1,182 @@ +pub(crate) struct LintTestCase { + pub description: &'static str, + pub sql: &'static str, + pub expected_codes: &'static [&'static str], + pub expected_anchors: &'static [(&'static str, &'static str)], +} + +pub(crate) struct EmptyFixtureLintTestCase { + pub(crate) description: &'static str, + pub(crate) sql: &'static str, + pub(crate) allows_empty_fixture_star: bool, + pub(crate) expected_diagnostic_count: usize, +} + +pub(crate) struct BatchLintTestCase { + pub(crate) description: &'static str, + pub(crate) queries: &'static [&'static str], + pub(crate) expected_responses: usize, +} + +pub(crate) struct LintMetadataTestCase { + pub description: &'static str, + pub sql: &'static str, + pub expected_code: &'static str, + pub expected_message: &'static str, + pub expected_remediation: &'static str, +} + +pub(crate) struct LintFixTestCase { + pub description: &'static str, + pub sql: &'static str, + pub expected_code: &'static str, + pub expected_replacement: Option<&'static str>, +} + +pub(crate) struct AdditionalLintRuleTestCase { + pub description: &'static str, + pub sql: &'static str, + pub rule: &'static str, + pub expected_anchor: Option<&'static str>, + pub expected_replacement: Option<&'static str>, +} + +pub(crate) struct CeremonialCteLintTestCase { + pub description: &'static str, + pub sql: &'static str, + pub expected_count: usize, + pub expected_start: Option, +} + +pub(crate) struct PlainSqlLintTestCase { + pub description: &'static str, + pub sql: &'static str, + pub rule: &'static str, + pub expected_count: usize, +} + +pub(crate) struct SqlMacroOutputLintTestCase { + pub(crate) description: &'static str, + pub(crate) sql: &'static str, + /// The text a `@sql.` call generated, and the names the call passed. + pub(crate) output: &'static str, + pub(crate) relations: &'static [&'static str], + pub(crate) expected_count: usize, +} + +pub(crate) struct DynamicOutputStarLintTestCase { + pub(crate) description: &'static str, + pub(crate) sql: &'static str, + pub(crate) expected_count: usize, + pub(crate) expected_prefix: &'static str, +} + +pub(crate) struct DependencyImportLintTestCase { + pub(crate) description: &'static str, + pub(crate) sql: &'static str, + pub(crate) dependency_identifiers: &'static [&'static str], + pub(crate) expected_count: usize, +} + +pub(crate) struct DialectLintRuleTestCase { + pub description: &'static str, + pub sql: &'static str, + pub dialect: &'static str, + pub rule: &'static str, + pub expected_count: usize, + pub expected_fix: bool, + pub expected_reason: &'static str, +} + +pub(crate) struct LineWrapTestCase { + pub description: &'static str, + pub sql: &'static str, + pub line_width: usize, + pub token_widths: &'static [(&'static str, usize)], + pub expected_sql: &'static str, +} + +pub(crate) struct FunctionCallSpacingTestCase { + pub description: &'static str, + pub dialect: &'static str, + pub line_width: usize, + pub argument: &'static str, + pub expected_refused: &'static [&'static str], +} + +pub(crate) struct LayoutRuleTestCase { + pub description: &'static str, + pub dialect: &'static str, + pub sql: &'static str, + pub line_width: usize, + pub expected_sql: &'static str, +} + +pub(crate) struct FormatCorpusTestCase { + pub description: &'static str, + pub dialect: &'static str, + pub expected_formatted: usize, + pub expected_refused: usize, +} + +pub(crate) struct DialectFormatTestCase { + pub description: &'static str, + pub dialect: &'static str, + pub sql: &'static str, + pub expected_sql: &'static str, +} + +pub(crate) struct TokenInvariantTestCase { + pub description: &'static str, + pub before: &'static str, + pub after: &'static str, + pub recases: &'static [(usize, &'static str, bool)], + pub expected_error: Option<&'static str>, +} + +pub(crate) struct FormatTestCase { + pub description: &'static str, + pub sql: &'static str, + pub expected_sql: &'static str, + pub expected_changed: bool, +} + +pub(crate) struct ReprintTestCase { + pub description: &'static str, + pub sql: &'static str, + pub expected_changed: bool, + pub expected_reprints: usize, +} + +pub(crate) struct InvalidRuleTestCase { + pub description: &'static str, + pub rule: &'static str, + pub expected_message: &'static str, +} + +pub(crate) struct FunctionDepthTestCase { + pub description: &'static str, + pub depth: usize, + pub expected_diagnostic_count: usize, +} + +pub(crate) struct FunctionDepthFailureTestCase { + pub description: &'static str, + pub depth: usize, + pub expected_code: &'static str, + pub expected_limit: &'static str, +} + +pub(crate) struct CrossJoinTestCase { + pub description: &'static str, + pub sql: &'static str, + pub dialect: &'static str, + pub one_row_relations: &'static [&'static str], + pub expected_messages: &'static [&'static str], +} + +pub(crate) struct CrossJoinSubjectTestCase { + pub description: &'static str, + pub message: &'static str, + pub expected_subject: Option<(&'static str, Option<&'static str>)>, +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_unused_table_alias.rs b/crates/sqlclean/src/sql_lint/tests/test_unused_table_alias.rs new file mode 100644 index 0000000..95a86de --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/test_unused_table_alias.rs @@ -0,0 +1,317 @@ +use crate::sql_lint::tests::{helpers, test_types}; + +#[test] +fn given_implicit_and_explicit_table_aliases_when_linting_then_unused_aliases_match() +-> Result<(), String> { + let test_cases = [ + test_types::AdditionalLintRuleTestCase { + description: "unused implicit table alias", + sql: "SELECT id FROM items unused", + rule: "SQC023", + expected_anchor: Some("unused"), + expected_replacement: Some(""), + }, + test_types::AdditionalLintRuleTestCase { + description: "unused implicit joined table alias", + sql: "SELECT o.id FROM orders o INNER JOIN customers unused ON o.customer_id = o.id", + rule: "SQC023", + expected_anchor: Some("unused"), + expected_replacement: Some(""), + }, + test_types::AdditionalLintRuleTestCase { + description: "unused implicit qualified table alias", + sql: "SELECT id FROM warehouse.analytics.items unused", + rule: "SQC023", + expected_anchor: Some("unused"), + expected_replacement: Some(""), + }, + test_types::AdditionalLintRuleTestCase { + description: "unused implicit subquery alias", + sql: "SELECT id FROM (SELECT id FROM items) unused", + rule: "SQC023", + expected_anchor: Some("unused"), + expected_replacement: Some(""), + }, + test_types::AdditionalLintRuleTestCase { + description: "unused implicit lateral table function alias", + sql: "SELECT src.id FROM items src, LATERAL FLATTEN(input => src.payload) unused", + rule: "SQC023", + expected_anchor: Some("unused"), + expected_replacement: Some(""), + }, + test_types::AdditionalLintRuleTestCase { + description: "used implicit table alias", + sql: "SELECT kept.id FROM items kept", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "used implicit joined table aliases", + sql: "SELECT o.id, c.name FROM orders o INNER JOIN customers c ON o.customer_id = c.id", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "used implicit qualified table alias", + sql: "SELECT kept.id FROM warehouse.analytics.items kept", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "used implicit subquery alias", + sql: "SELECT sub.id FROM (SELECT id FROM items) sub", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "used implicit lateral table function alias", + sql: "SELECT flattened.value FROM items src, LATERAL FLATTEN(input => src.payload) flattened", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "implicit alias used by correlated subquery", + sql: "SELECT id FROM items kept WHERE EXISTS (SELECT 1 FROM other WHERE other.id = kept.id)", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "implicit alias carrying a column list is retained", + sql: "SELECT a FROM items t(a)", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "qualified relation name is not an implicit alias", + sql: "SELECT id FROM warehouse.analytics.items", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "join modifier keywords are not implicit aliases", + sql: "SELECT o.id, c.name FROM orders o POSITIONAL JOIN customers c", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "join condition identifiers are not implicit aliases", + sql: "SELECT o.id, c.name FROM orders o INNER JOIN customers c USING (customer_id)", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + ]; + + for test_case in test_cases { + let diagnostics = helpers::diagnostics_for_rules(test_case.sql, &[test_case.rule])?; + assert_eq!( + diagnostics.len(), + usize::from(test_case.expected_anchor.is_some()), + "{}", + test_case.description + ); + let _ = test_case.expected_anchor.map(|expected_anchor| { + let diagnostic = &diagnostics[0]; + let start = diagnostic["start"].as_u64().unwrap_or_default() as usize; + let end = diagnostic["end"].as_u64().unwrap_or_default() as usize; + assert_eq!( + &test_case.sql[start..end], + expected_anchor, + "{}", + test_case.description + ); + assert_eq!( + diagnostic["fix"]["replacement"].as_str(), + test_case.expected_replacement, + "{}", + test_case.description + ); + }); + } + Ok(()) +} + +#[test] +fn given_join_modifiers_and_whole_row_uses_when_linting_duckdb_then_aliases_are_kept() +-> Result<(), String> { + let test_cases = [ + test_types::AdditionalLintRuleTestCase { + description: "unaliased relation before positional join has no alias", + sql: "SELECT * FROM orders POSITIONAL JOIN customers", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "unaliased relation before asof join has no alias", + sql: "SELECT * FROM orders ASOF JOIN prices ON orders.item_id = prices.item_id AND orders.ordered_at >= prices.valid_from", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "unaliased relation before natural join has no alias", + sql: "SELECT * FROM orders NATURAL JOIN customers", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "unaliased relation before anti join has no alias", + sql: "SELECT * FROM orders ANTI JOIN refunds USING (order_id)", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "unaliased relation before semi join has no alias", + sql: "SELECT * FROM orders SEMI JOIN refunds USING (order_id)", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "unaliased relation before cross join has no alias", + sql: "SELECT * FROM orders CROSS JOIN customers", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "implicit alias repeating the relation name is unused", + sql: "SELECT id FROM orders orders", + rule: "SQC023", + expected_anchor: Some("orders"), + expected_replacement: Some(""), + }, + test_types::AdditionalLintRuleTestCase { + description: "explicit alias repeating the relation name is unused", + sql: "SELECT id FROM orders AS orders", + rule: "SQC023", + expected_anchor: Some("AS orders"), + expected_replacement: Some(""), + }, + test_types::AdditionalLintRuleTestCase { + description: "alias repeating the relation name is used when referenced", + sql: "SELECT orders.id FROM orders orders", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "unaliased relation before window clause has no alias", + sql: "SELECT SUM(amount) OVER w FROM orders WINDOW w AS (ORDER BY id)", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "unaliased relation before tablesample has no alias", + sql: "SELECT * FROM orders TABLESAMPLE BERNOULLI (10)", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "unaliased relation before unpivot has no alias", + sql: "SELECT * FROM orders UNPIVOT (amount FOR month IN (jan, feb))", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "implicit alias used as whole-row function argument", + sql: "SELECT row_to_json(o) AS payload FROM orders o", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "explicit alias used as whole-row function argument", + sql: "SELECT row_to_json(o) AS payload FROM orders AS o", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "implicit alias used as bare projection", + sql: "SELECT o FROM orders o", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "explicit alias used as bare projection", + sql: "SELECT o FROM orders AS o", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "implicit alias used in whole-row comparison", + sql: "SELECT 1 AS present FROM orders o WHERE o IS NOT NULL", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "explicit alias used in whole-row comparison", + sql: "SELECT 1 AS present FROM orders AS o WHERE o IS NOT NULL", + rule: "SQC023", + expected_anchor: None, + expected_replacement: None, + }, + test_types::AdditionalLintRuleTestCase { + description: "unused implicit alias beside an unrelated qualified column is reported", + sql: "SELECT c.o FROM customers c CROSS JOIN orders o", + rule: "SQC023", + expected_anchor: Some("o"), + expected_replacement: Some(""), + }, + test_types::AdditionalLintRuleTestCase { + description: "unused explicit alias shadowed by an output alias is reported", + sql: "SELECT order_id AS o FROM orders AS o", + rule: "SQC023", + expected_anchor: Some("AS o"), + expected_replacement: Some(""), + }, + ]; + + for test_case in test_cases { + let diagnostics = + helpers::diagnostics_for_dialect(test_case.sql, "duckdb", &[test_case.rule])?; + assert_eq!( + diagnostics.len(), + usize::from(test_case.expected_anchor.is_some()), + "{}", + test_case.description + ); + let _ = test_case.expected_anchor.map(|expected_anchor| { + let diagnostic = &diagnostics[0]; + let start = diagnostic["start"].as_u64().unwrap_or_default() as usize; + let end = diagnostic["end"].as_u64().unwrap_or_default() as usize; + assert_eq!( + &test_case.sql[start..end], + expected_anchor, + "{}", + test_case.description + ); + assert_eq!( + diagnostic["fix"]["replacement"].as_str(), + test_case.expected_replacement, + "{}", + test_case.description + ); + }); + } + Ok(()) +} diff --git a/crates/sqlclean/src/sql_lint/tests/test_values_formatter.rs b/crates/sqlclean/src/sql_lint/tests/test_values_formatter.rs new file mode 100644 index 0000000..6963aad --- /dev/null +++ b/crates/sqlclean/src/sql_lint/tests/test_values_formatter.rs @@ -0,0 +1,53 @@ +use serde_json::{Value, json}; + +use crate::sql_lint::main::formatter::format_json; +use crate::sql_lint::tests::test_types; + +#[test] +fn given_mixed_values_relations_when_formatting_then_each_authored_form_is_preserved() +-> Result<(), String> { + let test_cases = [ + test_types::FormatTestCase { + description: "mixed values relations preserve parenthesized relation first", + sql: "SELECT * FROM (VALUES (1, 2)) AS first_values(a, b) UNION ALL SELECT * FROM VALUES (3, 4)", + expected_sql: "SELECT *\nFROM (VALUES (1, 2)) AS first_values(a, b)\nUNION ALL\nSELECT *\nFROM VALUES (3, 4)", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "mixed values relations preserve unparenthesized relation first", + sql: "SELECT * FROM VALUES (1, 2) UNION ALL SELECT * FROM (VALUES (3, 4)) AS second_values(a, b)", + expected_sql: "SELECT *\nFROM VALUES (1, 2)\nUNION ALL\nSELECT *\nFROM (VALUES (3, 4)) AS second_values(a, b)", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "mixed values relations inside joined subquery preserve authored forms", + sql: "SELECT base.id FROM items AS base JOIN (SELECT * FROM (VALUES (1)) AS first_values(id) UNION ALL SELECT * FROM VALUES (2)) AS mixed ON base.id = mixed.id", + expected_sql: "SELECT base.id\nFROM items AS base\nJOIN (\n SELECT *\n FROM (VALUES (1)) AS first_values(id)\n UNION ALL\n SELECT *\n FROM VALUES (2)\n) AS mixed\n ON base.id = mixed.id", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "values text in string and comment is ignored", + sql: "-- VALUES and FROM VALUES are examples\nSELECT 'VALUES' AS label FROM (VALUES (1)) AS first_values(id) UNION ALL SELECT * FROM VALUES (2)", + expected_sql: "-- VALUES and FROM VALUES are examples\nSELECT 'VALUES' AS label\nFROM (VALUES (1)) AS first_values(id)\nUNION ALL\nSELECT *\nFROM VALUES (2)", + expected_changed: true, + }, + ]; + + for test_case in &test_cases { + let response = format_json( + &json!({"version": 1, "sql": test_case.sql, "dialect": "snowflake"}).to_string(), + )?; + let payload: Value = serde_json::from_str(&response).map_err(|error| error.to_string())?; + assert_eq!( + payload["sql"], test_case.expected_sql, + "{}", + test_case.description + ); + assert_eq!( + payload["changed"], test_case.expected_changed, + "{}", + test_case.description + ); + } + Ok(()) +} diff --git a/crates/sqlclean/src/sql_quality/_helpers/cte_columns.rs b/crates/sqlclean/src/sql_quality/_helpers/cte_columns.rs new file mode 100644 index 0000000..8f48d4b --- /dev/null +++ b/crates/sqlclean/src/sql_quality/_helpers/cte_columns.rs @@ -0,0 +1,457 @@ +//! SQC042: output columns of non-final CTEs that no later step reads. + +use std::borrow::Cow; + +use polyglot_sql::expressions::{Cte, JoinKind, Select}; +use polyglot_sql::scope::walk_in_scope; +use polyglot_sql::{Expression, ExpressionWalk}; + +use crate::sql_lint::models::{LintDiagnostic, LintEdit}; +use crate::sql_quality::constants::{ + DISTINCT_UNUSED_REMEDIATION, GROUP_BY_ALL_UNUSED_REMEDIATION, GROUP_KEY_UNUSED_REMEDIATION, + INTERPOLATION_SENTINEL_PREFIXES, UNUSED_CTE_OUTPUT, +}; +use crate::sql_quality::models::QualityRequest; +use crate::sql_quality::removal_safety::{expression_refusal, reads_whole_rows, select_refusal}; +use crate::sql_quality::syntax::{ + Projection, columns, cte_select_tokens, lower, projection_removal, projections, qualifier, + sources, +}; +use crate::sql_tokens::main::projection_spans::projection_spans; +use rustc_hash::{FxHashMap, FxHashSet}; + +const HARNESS_PREFIX: &str = "__"; +const COLUMNS_FUNCTION: &str = "COLUMNS"; +const LAYOUT_FIX_REFUSAL: &str = "The projection list could not be mapped to authored tokens"; + +/// What one later step reads from a CTE. +#[derive(Default)] +struct Usage { + names: FxHashSet, + everything: bool, + passes_to: Vec, + read: bool, +} + +impl Usage { + fn merged(mut self, other: Self) -> Self { + self.names.extend(other.names); + self.everything |= other.everything; + self.passes_to.extend(other.passes_to); + self.read |= other.read; + self + } +} + +/// One CTE whose unused outputs are being reported. +struct CteOutputs<'a> { + cte: &'a Cte, + name: &'a str, + usage: Option<&'a Usage>, +} + +pub(crate) fn diagnostics(request: &QualityRequest<'_>) -> Vec { + if request.fixture_body { + return Vec::new(); + } + let mut found: Vec = Vec::new(); + for statement in request.statements { + let Expression::Select(root) = statement else { + continue; + }; + let Some(with) = root.with.as_ref().filter(|with| !with.recursive) else { + continue; + }; + let mut terminal: Select = root.as_ref().clone(); + terminal.with = None; + let terminal: Expression = Expression::Select(Box::new(terminal)); + let names: Vec = with.ctes.iter().map(|cte| lower(&cte.alias.name)).collect(); + let used: FxHashMap = reads(&with.ctes, &names, &terminal); + let select_tokens: FxHashMap = cte_select_tokens(request.tokens); + for (index, cte) in with + .ctes + .iter() + .enumerate() + .take(with.ctes.len().saturating_sub(1)) + { + let name: &str = &names[index]; + if name.starts_with(HARNESS_PREFIX) || request.externally_referenced_ctes.contains(name) + { + continue; + } + found.extend(unused_outputs( + request, + &select_tokens, + CteOutputs { + cte, + name, + usage: used.get(name), + }, + )); + } + } + found +} + +/// Names each CTE's later readers use, following `SELECT *` pass-through to the reader. +fn reads(ctes: &[Cte], names: &[String], terminal: &Expression) -> FxHashMap { + let bodies: Vec<(Option, &Expression)> = ctes + .iter() + .map(|cte| (Some(lower(&cte.alias.name)), &cte.this)) + .chain(std::iter::once((None, terminal))) + .collect(); + let known: FxHashSet<&str> = names.iter().map(String::as_str).collect(); + let outputs: Vec>> = ctes.iter().map(output_names).collect(); + let mut readers: FxHashMap> = FxHashMap::default(); + for (index, (_, body)) in bodies.iter().enumerate() { + for name in read_relations(body) { + if known.contains(name.as_str()) { + readers.entry(name).or_default().push(index); + } + } + } + let mut usage: FxHashMap = FxHashMap::default(); + for (index, name) in names.iter().enumerate() { + let mut current: Usage = Usage::default(); + if index + 1 == names.len() { + current.everything = true; + } + for &reader in readers.get(name.as_str()).into_iter().flatten() { + if reader > index { + let (reader_name, body) = &bodies[reader]; + let target: Target<'_> = Target { + name, + outputs: outputs[index].as_ref(), + }; + current = current.merged(reader_usage(&target, reader_name.clone(), body)); + } + } + current.names.extend(self_reads(&ctes[index].this)); + usage.insert(name.clone(), current); + } + for index in (0..names.len()).rev() { + let passes: Vec = usage[&names[index]].passes_to.clone(); + for reader in passes { + let (everything, inherited): (bool, Vec) = + usage.get(&reader).map_or((true, Vec::new()), |next| { + (next.everything, next.names.iter().cloned().collect()) + }); + if let Some(current) = usage.get_mut(&names[index]) { + current.everything |= everything; + current.names.extend(inherited); + } + } + } + usage +} + +/// The output names of a CTE, or None when a star or unnamed projection hides them. +fn output_names(cte: &Cte) -> Option> { + let Expression::Select(select) = &cte.this else { + return None; + }; + projections(select) + .into_iter() + .map(|item| match item { + Projection::Named { name, .. } => Some(name), + _ => None, + }) + .collect() +} + +fn read_relations(body: &Expression) -> FxHashSet { + body.dfs() + .filter_map(|node| match node { + Expression::Table(table) => Some(lower(&table.name.name)), + _ => None, + }) + .collect() +} + +/// The CTE whose readers are being inspected. +struct Target<'a> { + name: &'a str, + outputs: Option<&'a FxHashSet>, +} + +fn reader_usage(target: &Target<'_>, reader: Option, body: &Expression) -> Usage { + let name: &str = target.name; + let mut usage: Usage = Usage::default(); + let mut aliases: FxHashSet = FxHashSet::default(); + let mut top_level_star: bool = false; + for node in body.dfs() { + let Expression::Select(select) = node else { + continue; + }; + let local: Vec = sources(select) + .into_iter() + .filter(|(_, relation, _)| relation.as_deref() == Some(name)) + .map(|(alias, _, _)| alias) + .collect(); + if local.is_empty() { + continue; + } + if select.joins.iter().any(|join| { + matches!( + join.kind, + JoinKind::Natural + | JoinKind::NaturalLeft + | JoinKind::NaturalRight + | JoinKind::NaturalFull + ) + }) { + usage.everything = true; + } + for join in &select.joins { + usage + .names + .extend(join.using.iter().map(|identifier| lower(&identifier.name))); + } + let top: bool = std::ptr::eq(node, body); + if reads_as_row(select, &local, target.outputs) { + usage.everything = true; + } + for projection in projections(select) { + if let Projection::Star(star) = projection + && star + .table + .as_ref() + .is_none_or(|table| local.contains(&lower(&table.name))) + { + if top + && reader.is_some() + && star.except.is_none() + && star.replace.is_none() + && !reads_whole_rows(select) + { + top_level_star = true; + } else { + usage.everything = true; + } + } + } + aliases.extend(local); + } + if aliases.is_empty() { + return usage; + } + usage.read = true; + usage.everything |= columns(body) + .into_iter() + .any(|column| is_interpolation(&column.name.name)); + if top_level_star && let Some(reader) = reader { + usage.passes_to.push(reader); + } + for column in body.dfs().filter_map(|node| match node { + Expression::Column(column) => Some(column), + _ => None, + }) { + if qualifier(column).is_none_or(|table| aliases.contains(&table)) { + usage.names.insert(lower(&column.name.name)); + } + } + usage +} + +/// Whether a reader consumes whole rows: a nested star, `COLUMNS(...)`, or the alias itself. +fn reads_as_row(select: &Select, local: &[String], outputs: Option<&FxHashSet>) -> bool { + for clause in clause_roots(select) { + for node in walk_in_scope(clause, false) { + if reads_row_at(select, node, local, outputs) { + return true; + } + } + } + false +} + +fn reads_row_at( + select: &Select, + node: &Expression, + local: &[String], + outputs: Option<&FxHashSet>, +) -> bool { + match node { + Expression::Star(star) => { + let projected: bool = select + .expressions + .iter() + .any(|item| std::ptr::eq(item, node)); + let table: Option = star.table.as_ref().map(|table| lower(&table.name)); + !projected && table.is_none_or(|table| local.contains(&table)) + } + Expression::BracedWildcard(_) => true, + Expression::Function(function) => function.name.eq_ignore_ascii_case(COLUMNS_FUNCTION), + Expression::Column(column) => { + let read: String = lower(&column.name.name); + let named_output: bool = outputs.is_some_and(|names| names.contains(&read)); + column.table.is_none() && local.contains(&read) && !named_output + } + _ => false, + } +} + +/// Root expressions of every clause of one select, without its FROM relations. +fn clause_roots(select: &Select) -> Vec<&Expression> { + let mut roots: Vec<&Expression> = select.expressions.iter().collect(); + roots.extend(select.where_clause.iter().map(|clause| &clause.this)); + roots.extend(select.having.iter().map(|clause| &clause.this)); + roots.extend(select.qualify.iter().map(|clause| &clause.this)); + if let Some(group) = &select.group_by { + roots.extend(group.expressions.iter()); + } + if let Some(order) = &select.order_by { + roots.extend(order.expressions.iter().map(|ordered| &ordered.this)); + } + roots.extend(select.joins.iter().filter_map(|join| join.on.as_ref())); + roots +} + +/// Lateral alias reuse: a later projection or a clause of the CTE reading an earlier output. +fn self_reads(body: &Expression) -> FxHashSet { + let mut reads: FxHashSet = FxHashSet::default(); + let Expression::Select(select) = body else { + return reads; + }; + let mut earlier: FxHashSet = FxHashSet::default(); + for item in projections(select) { + if let Projection::Named { name, expression } = item { + for read in unqualified_reads(expression) { + if earlier.contains(&read) && read != name { + reads.insert(read); + } + } + earlier.insert(name); + } + } + let mut clauses: Vec<&Expression> = Vec::new(); + clauses.extend(select.where_clause.iter().map(|clause| &clause.this)); + clauses.extend( + select + .group_by + .iter() + .flat_map(|group| group.expressions.iter()), + ); + clauses.extend(select.having.iter().map(|clause| &clause.this)); + clauses.extend(select.qualify.iter().map(|clause| &clause.this)); + if let Some(order) = &select.order_by { + clauses.extend(order.expressions.iter().map(|ordered| &ordered.this)); + } + for clause in clauses { + reads.extend( + unqualified_reads(clause) + .into_iter() + .filter(|read| earlier.contains(read)), + ); + } + reads +} + +fn unqualified_reads(expression: &Expression) -> Vec { + columns(expression) + .into_iter() + .filter(|column| qualifier(column).is_none()) + .map(|column| lower(&column.name.name)) + .collect() +} + +fn unused_outputs( + request: &QualityRequest<'_>, + select_tokens: &FxHashMap, + outputs: CteOutputs<'_>, +) -> Vec { + let CteOutputs { cte, name, usage } = outputs; + let Expression::Select(select) = &cte.this else { + return Vec::new(); + }; + let Some(usage) = usage.filter(|usage| !usage.everything && usage.read) else { + return Vec::new(); + }; + if !cte.columns.is_empty() { + return Vec::new(); + } + let output: Vec> = projections(select); + if !output + .iter() + .all(|item| matches!(item, Projection::Named { .. })) + { + return Vec::new(); + } + let spans: Vec<(usize, usize)> = select_tokens + .get(name) + .map(|&select_token| projection_spans(request.tokens, select_token)) + .unwrap_or_default(); + let grouped: FxHashSet = select + .group_by + .iter() + .flat_map(|group| group.expressions.iter()) + .flat_map(columns) + .map(|column| lower(&column.name.name)) + .collect(); + let select_refused: Option<&'static str> = select_refusal(select, output.len()); + let mut found: Vec = Vec::new(); + for (index, item) in output.iter().enumerate() { + let Projection::Named { + name: column, + expression, + } = item + else { + continue; + }; + if usage.names.contains(column) || is_interpolation(column) { + continue; + } + let grouping_key: bool = matches!(expression, Expression::Column(source) if grouped.contains(&lower(&source.name.name))); + let (start, end) = spans + .get(index) + .copied() + .filter(|_| spans.len() == output.len()) + .unwrap_or((0, 0)); + let refusal: Option<&'static str> = select_refused + .or_else(|| expression_refusal(expression)) + .or_else(|| (spans.len() != output.len()).then_some(LAYOUT_FIX_REFUSAL)); + let fix: Option = refusal + .is_none() + .then(|| projection_removal(&spans, index)) + .flatten() + .map(|(start, end)| LintEdit { + start, + end, + replacement: String::new(), + }); + found.push(LintDiagnostic { + code: UNUSED_CTE_OUTPUT.code, + message: Cow::Borrowed(UNUSED_CTE_OUTPUT.message), + remediation: Cow::Borrowed(if select.distinct { + DISTINCT_UNUSED_REMEDIATION + } else if select + .group_by + .as_ref() + .is_some_and(|group| group.all == Some(true)) + { + GROUP_BY_ALL_UNUSED_REMEDIATION + } else if grouping_key { + GROUP_KEY_UNUSED_REMEDIATION + } else { + UNUSED_CTE_OUTPUT.remediation + }), + start, + end, + fix_unavailable_reason: if fix.is_none() { + Some(refusal.unwrap_or(LAYOUT_FIX_REFUSAL)) + } else { + None + }, + fix, + }); + } + found +} + +/// Neutralized `@macro` / `@variable` interpolations can stand for any columns. +fn is_interpolation(name: &str) -> bool { + let name: String = lower(name); + INTERPOLATION_SENTINEL_PREFIXES + .iter() + .any(|prefix| name.starts_with(prefix)) +} diff --git a/crates/sqlclean/src/sql_quality/_helpers/declared_keys.rs b/crates/sqlclean/src/sql_quality/_helpers/declared_keys.rs new file mode 100644 index 0000000..02607a9 --- /dev/null +++ b/crates/sqlclean/src/sql_quality/_helpers/declared_keys.rs @@ -0,0 +1,50 @@ +//! Unique keys one relation declares through its incremental config and column audits. + +use crate::sql_quality::constants::{ + INCREMENTAL_MATERIALIZATION, KEY_PRESERVING_STRATEGIES, KEYED_DELETE_INSERT_STRATEGY, + MICROBATCH_MODE, NOT_NULL_AUDIT, UNIQUE_AUDIT, +}; +use crate::sql_quality::models::{DeclaredKeyColumn, DeclaredRelation}; + +/// `unique_key` only where the incremental strategy keeps one row per key. +pub(crate) fn trusted_unique_key(relation: &DeclaredRelation) -> Vec> { + let strategy: String = relation.incremental_strategy.to_lowercase(); + let trusted: bool = relation.materialized.to_lowercase() == INCREMENTAL_MATERIALIZATION + && relation.incremental_mode.to_lowercase() != MICROBATCH_MODE + && KEY_PRESERVING_STRATEGIES.contains(&strategy.as_str()) + && !(strategy == KEYED_DELETE_INSERT_STRATEGY && relation.has_cursor); + match &relation.unique_key { + Some(key) if trusted && !key.is_empty() => vec![key.clone()], + _ => Vec::new(), + } +} + +/// Columns with a `unique` audit that are also non-null by audit or `nullable false`. +pub(crate) fn non_null_unique_columns(relation: &DeclaredRelation) -> Vec { + let mut unique: Vec = Vec::new(); + let mut not_null: Vec = Vec::new(); + for column in &relation.columns { + if has_audit(column, UNIQUE_AUDIT) { + unique.push(column.name.clone()); + } + if has_audit(column, NOT_NULL_AUDIT) || column.not_nullable { + not_null.push(str::to_lowercase(&column.name)); + } + } + for (definition, argument) in &relation.audits { + let Some(argument) = argument else { + continue; + }; + if definition == NOT_NULL_AUDIT { + not_null.push(str::to_lowercase(argument)); + } else if definition == UNIQUE_AUDIT { + unique.push(argument.clone()); + } + } + unique.retain(|column| not_null.contains(&str::to_lowercase(column))); + unique +} + +fn has_audit(column: &DeclaredKeyColumn, name: &str) -> bool { + column.audits.iter().any(|audit| audit == name) +} diff --git a/crates/sqlclean/src/sql_quality/_helpers/json_parses.rs b/crates/sqlclean/src/sql_quality/_helpers/json_parses.rs new file mode 100644 index 0000000..ef7a3d3 --- /dev/null +++ b/crates/sqlclean/src/sql_quality/_helpers/json_parses.rs @@ -0,0 +1,201 @@ +//! SQC045: the same JSON payload column parsed more than once in one SELECT scope. + +use std::borrow::Cow; +use std::collections::HashMap; + +use polyglot_sql::expressions::{Column, Identifier}; +use polyglot_sql::scope::walk_in_scope; +use polyglot_sql::tokens::Token; +use polyglot_sql::{DialectType, Expression, ExpressionWalk}; + +use crate::sql_lint::models::LintDiagnostic; +use crate::sql_quality::constants::REPEATED_JSON_PARSE; +use crate::sql_quality::models::QualityRequest; +use crate::sql_quality::syntax::{is_trivia, lower}; + +/// Significant tokens searched backwards from the column for the function name. +const MAX_FUNCTION_NAME_DISTANCE: usize = 32; +const PARSE_ONCE_FIX_REFUSAL: &str = + "Parsing the payload once requires naming a column in the CTE that reads it"; + +/// An identifier normalised the way the dialect resolves it. +type IdentifierKey = String; + +/// One JSON parse call whose argument resolves to a plain column. +struct ParseCall<'a> { + function: &'static str, + column: &'a Column, + column_start: usize, +} + +pub(crate) fn diagnostics(request: &QualityRequest<'_>) -> Vec { + let functions: &[&'static str] = parse_functions(request.dialect); + if functions.is_empty() { + return Vec::new(); + } + let mut found: Vec = Vec::new(); + for statement in request.statements { + for node in statement.dfs() { + if matches!(node, Expression::Select(_)) { + found.extend(scope_diagnostics( + request.tokens, + node, + functions, + request.dialect, + )); + } + } + } + found +} + +/// JSON parse functions that take one string argument, by dialect, in upper case. +fn parse_functions(dialect: DialectType) -> &'static [&'static str] { + match dialect { + DialectType::Snowflake | DialectType::Databricks => &["PARSE_JSON", "TRY_PARSE_JSON"], + DialectType::BigQuery => &["PARSE_JSON", "SAFE.PARSE_JSON"], + DialectType::DuckDB => &["JSON"], + _ => &[], + } +} + +fn scope_diagnostics( + tokens: &[Token], + select: &Expression, + functions: &[&'static str], + dialect: DialectType, +) -> Vec { + let quote: char = identifier_quote(dialect); + let mut calls: Vec> = walk_in_scope(select, false) + .filter_map(|node| parse_call(node, functions)) + .collect(); + calls.sort_by_key(|call| call.column_start); + let mut groups: HashMap< + (&'static str, Option, IdentifierKey), + Vec<&ParseCall<'_>>, + > = HashMap::new(); + for call in &calls { + let qualifier: Option = call + .column + .table + .as_ref() + .map(|table| identifier_key(table, dialect)); + groups + .entry(( + call.function, + qualifier, + identifier_key(&call.column.name, dialect), + )) + .or_default() + .push(call); + } + let mut found: Vec = Vec::new(); + for group in groups.values().filter(|group| group.len() > 1) { + let second: &ParseCall<'_> = group[1]; + let (start, end) = function_span(tokens, second); + found.push(LintDiagnostic { + code: REPEATED_JSON_PARSE.code, + message: Cow::Borrowed(REPEATED_JSON_PARSE.message), + remediation: Cow::Owned(remediation(second, group.len(), quote)), + start, + end, + fix: None, + fix_unavailable_reason: Some(PARSE_ONCE_FIX_REFUSAL), + }); + } + found +} + +/// Normalises a name the way the dialect resolves it (Snowflake folds unquoted names upward). +fn identifier_key(identifier: &Identifier, dialect: DialectType) -> IdentifierKey { + match dialect { + DialectType::Snowflake if identifier.quoted => identifier.name.clone(), + DialectType::Snowflake => identifier.name.to_uppercase(), + _ => lower(&identifier.name), + } +} + +fn parse_call<'a>(node: &'a Expression, functions: &[&'static str]) -> Option> { + let (name, argument): (&str, &Expression) = match node { + Expression::ParseJson(function) => ("PARSE_JSON", &function.this), + Expression::Function(function) if function.args.len() == 1 => { + (function.name.as_str(), &function.args[0]) + } + _ => return None, + }; + let function: &'static str = functions + .iter() + .copied() + .find(|candidate| candidate.eq_ignore_ascii_case(name))?; + let column: &Column = unwrap_column(argument)?; + let column_start: usize = column.span.or(column.name.span)?.start; + Some(ParseCall { + function, + column, + column_start, + }) +} + +/// The column under wrappers that keep the parsed value tied to that column. +fn unwrap_column(expression: &Expression) -> Option<&Column> { + let mut current: &Expression = expression; + loop { + current = match current { + Expression::Column(column) => return Some(column), + Expression::Paren(paren) => &paren.this, + Expression::Lower(function) | Expression::Upper(function) => &function.this, + Expression::Trim(trim) if trim.characters.is_none() => &trim.this, + Expression::Cast(cast) | Expression::TryCast(cast) | Expression::SafeCast(cast) => { + &cast.this + } + Expression::Coalesce(coalesce) if coalesce.expressions.len() == 1 => { + &coalesce.expressions[0] + } + _ => return None, + }; + } +} + +/// Span of the function-name token that opens `call`, or of its column when not found. +fn function_span(tokens: &[Token], call: &ParseCall<'_>) -> (usize, usize) { + let bare: &str = call.function.rsplit('.').next().unwrap_or(call.function); + let before: usize = tokens.partition_point(|token| token.span.start < call.column_start); + tokens[..before] + .iter() + .rev() + .filter(|token| !is_trivia(token)) + .take(MAX_FUNCTION_NAME_DISTANCE) + .find(|token| token.text.eq_ignore_ascii_case(bare)) + .map_or((call.column_start, call.column_start), |token| { + (token.span.start, token.span.end) + }) +} + +fn remediation(call: &ParseCall<'_>, count: usize, quote: char) -> String { + let name: String = written(&call.column.name, quote); + let column: String = call.column.table.as_ref().map_or_else( + || name.clone(), + |table| format!("{}.{name}", written(table, quote)), + ); + let function: &str = call.function; + format!( + "{function}({column}) is parsed {count} times in one SELECT. Parse it once in the CTE that reads it, for example `{function}({name}) AS payload`, and read every field from that column in a later step." + ) +} + +/// The identifier as authored, re-quoted with the dialect's identifier quote when it was quoted. +fn written(identifier: &Identifier, quote: char) -> String { + if identifier.quoted { + let escaped: String = identifier.name.replace(quote, &format!("{quote}{quote}")); + format!("{quote}{escaped}{quote}") + } else { + identifier.name.clone() + } +} + +fn identifier_quote(dialect: DialectType) -> char { + match dialect { + DialectType::BigQuery | DialectType::Databricks => '`', + _ => '"', + } +} diff --git a/crates/sqlclean/src/sql_quality/_helpers/keys.rs b/crates/sqlclean/src/sql_quality/_helpers/keys.rs new file mode 100644 index 0000000..2e006e5 --- /dev/null +++ b/crates/sqlclean/src/sql_quality/_helpers/keys.rs @@ -0,0 +1,437 @@ +//! Unique-key propagation through one model's relations, for ranking determinism proofs. + +use std::collections::HashMap; + +use polyglot_sql::Expression; +use polyglot_sql::expressions::{JoinKind, Literal, Select}; + +use crate::sql_quality::constants::FIRST_ROW; +use crate::sql_quality::models::QualityRequest; +use crate::sql_quality::syntax::{Projection, columns, lower, projections, qualifier, sources}; +use crate::sql_quality::types::{InputKey, Keys}; + +/// Keys known for relations by name: declared upstream keys and earlier CTE outputs. +pub(crate) struct KeyEnvironment { + pub(crate) declared: HashMap, + pub(crate) ctes: HashMap, + /// Per CTE, `(row number output, partition output columns)` for deduplication filters. + pub(crate) row_numbers: HashMap)>>, +} + +impl KeyEnvironment { + /// Output keys of a query expression, by output column name. + pub(crate) fn query_keys(&self, expression: &Expression) -> Keys { + match expression { + Expression::Select(select) => self.select_keys(select), + Expression::Subquery(subquery) => self.query_keys(&subquery.this), + Expression::Paren(paren) => self.query_keys(&paren.this), + Expression::Union(union) if !union.all => all_columns_key(&union.left), + Expression::Intersect(intersect) => self.query_keys(&intersect.left), + Expression::Except(except) => self.query_keys(&except.left), + _ => Vec::new(), + } + } + + fn relation_keys(&self, relation: &Expression) -> Keys { + match relation { + Expression::Table(table) => { + let name: String = lower(&table.name.name); + self.ctes + .get(&name) + .cloned() + .or_else(|| self.declared.get(&name).cloned()) + .unwrap_or_default() + } + Expression::Subquery(subquery) => self.query_keys(&subquery.this), + _ => Vec::new(), + } + } + + /// Keys of a select's joined and filtered input rows, before projection. + pub(crate) fn input_keys(&self, select: &Select) -> Vec { + if select + .from + .as_ref() + .is_none_or(|from| from.expressions.len() != 1) + { + return Vec::new(); + } + let found: Vec<(String, Option, &Expression)> = sources(select); + let Some((first_alias, _, first)) = found.first() else { + return Vec::new(); + }; + let mut keys: Vec = qualify(first_alias, self.relation_keys(first)); + keys.extend(qualify(first_alias, self.deduplicated_keys(select, first))); + for (join, (alias, _, relation)) in select.joins.iter().zip(found.iter().skip(1)) { + let right: Vec = qualify(alias, self.relation_keys(relation)); + let equated: Vec<(String, String)> = join_equalities(join, alias); + let left_columns: Vec<&str> = equated.iter().map(|(_, left)| left.as_str()).collect(); + let right_columns: Vec<&str> = + equated.iter().map(|(right, _)| right.as_str()).collect(); + let left_unique: bool = keys.iter().any(|key| key_within(key, &left_columns)); + let right_unique: bool = right.iter().any(|key| key_within(key, &right_columns)); + let mut joined: Vec = Vec::new(); + if right_unique + && !matches!( + join.kind, + JoinKind::Right | JoinKind::Full | JoinKind::Cross + ) + { + joined.extend(keys.iter().cloned()); + } + if left_unique && matches!(join.kind, JoinKind::Inner | JoinKind::Right) { + joined.extend(right.iter().cloned()); + } + keys = joined; + } + keys + } + + /// Input keys plus a `QUALIFY ROW_NUMBER() ... = n` partition, which holds only for output rows. + fn filtered_input_keys(&self, select: &Select) -> Vec { + let mut keys: Vec = self.input_keys(select); + if let Some(qualify_clause) = &select.qualify { + keys.extend(qualified_rank_keys(&qualify_clause.this, &sources(select))); + } + keys + } + + /// Partition keys of a `ROW_NUMBER()` output that this select filters to `= 1`. + fn deduplicated_keys(&self, select: &Select, relation: &Expression) -> Keys { + let Expression::Table(table) = relation else { + return Vec::new(); + }; + let Some(ranks) = self.row_numbers.get(&lower(&table.name.name)) else { + return Vec::new(); + }; + let filtered: Vec = select + .where_clause + .as_ref() + .map(|clause| first_row_filters(&clause.this)) + .unwrap_or_default(); + ranks + .iter() + .filter(|(rank, _)| filtered.contains(rank)) + .map(|(_, key)| key.clone()) + .collect() + } + + fn select_keys(&self, select: &Select) -> Keys { + let output: Vec> = projections(select); + let mut keys: Keys = Vec::new(); + if select.distinct + && output + .iter() + .all(|item| matches!(item, Projection::Named { .. })) + { + keys.push(output_names(&output)); + } + if let Some(group_by) = &select.group_by + && group_by.all.is_none() + && let Some(grain) = output_key_of(&group_by.expressions, &output) + { + keys.push(grain); + return keys; + } + let star: bool = output + .iter() + .any(|item| matches!(item, Projection::Star(_))); + for input in self.filtered_input_keys(select) { + if let Some(key) = project_key(&input, &output, star) { + keys.push(key); + } + } + keys + } +} + +fn qualify(alias: &str, keys: Keys) -> Vec { + keys.into_iter() + .map(|key| qualified_key(alias, key)) + .collect() +} + +fn qualified_key(alias: &str, key: Vec) -> InputKey { + key.into_iter() + .map(|column| (alias.to_owned(), column)) + .collect() +} + +fn key_within(key: &InputKey, columns: &[&str]) -> bool { + key.iter() + .all(|(_, column)| columns.contains(&column.as_str())) +} + +/// `(right column, left column)` pairs equated by a join's ON or USING condition. +fn join_equalities( + join: &polyglot_sql::expressions::Join, + right_alias: &str, +) -> Vec<(String, String)> { + let mut pairs: Vec<(String, String)> = join + .using + .iter() + .map(|identifier| (lower(&identifier.name), lower(&identifier.name))) + .collect(); + let mut pending: Vec<&Expression> = join.on.iter().collect(); + while let Some(expression) = pending.pop() { + match expression { + Expression::And(operation) => { + pending.push(&operation.left); + pending.push(&operation.right); + } + Expression::Paren(paren) => pending.push(&paren.this), + Expression::Eq(operation) => { + if let (Expression::Column(left), Expression::Column(right)) = + (&operation.left, &operation.right) + { + let (inner, outer) = if qualifier(left).as_deref() == Some(right_alias) { + (left, right) + } else { + (right, left) + }; + pairs.push((lower(&inner.name.name), lower(&outer.name.name))); + } + } + _ => {} + } + } + pairs +} + +fn qualified_rank_keys( + condition: &Expression, + found: &[(String, Option, &Expression)], +) -> Vec { + let Expression::Eq(operation) = condition else { + return Vec::new(); + }; + let window = match (&operation.left, &operation.right) { + (Expression::WindowFunction(window), Expression::Literal(_)) + | (Expression::Literal(_), Expression::WindowFunction(window)) => window, + _ => return Vec::new(), + }; + if !matches!(window.this, Expression::RowNumber(_)) || window.over.partition_by.is_empty() { + return Vec::new(); + } + let mut key: InputKey = InputKey::new(); + for expression in &window.over.partition_by { + let Expression::Column(column) = expression else { + return Vec::new(); + }; + let alias: String = qualifier(column) + .or_else(|| (found.len() == 1).then(|| found[0].0.clone())) + .unwrap_or_default(); + key.insert((alias, lower(&column.name.name))); + } + vec![key] +} + +fn output_names(output: &[Projection<'_>]) -> Vec { + output + .iter() + .filter_map(|item| match item { + Projection::Named { name, .. } => Some(name.clone()), + _ => None, + }) + .collect() +} + +fn output_key_of(expressions: &[Expression], output: &[Projection<'_>]) -> Option> { + let mut key: Vec = Vec::new(); + for expression in expressions { + if let Expression::Literal(literal) = expression + && let Literal::Number(text) = literal.as_ref() + { + let Ok(ordinal) = text.parse::() else { + return None; + }; + let Some(Projection::Named { name, .. }) = ordinal + .checked_sub(1) + .and_then(|position| output.get(position)) + else { + return None; + }; + key.push(name.clone()); + continue; + } + let Expression::Column(column) = expression else { + return None; + }; + let name: String = lower(&column.name.name); + let projected: Option = output.iter().find_map(|item| match item { + Projection::Named { + name: output_name, + expression: Expression::Column(source), + } if lower(&source.name.name) == name => Some(output_name.clone()), + Projection::Named { + name: output_name, .. + } if *output_name == name => Some(output_name.clone()), + _ => None, + }); + key.push(projected?); + } + Some(key) +} + +fn project_key(input: &InputKey, output: &[Projection<'_>], star: bool) -> Option> { + let mut key: Vec = Vec::new(); + for (alias, column) in input { + let projected: Option = output.iter().find_map(|item| match item { + Projection::Named { + name, + expression: Expression::Column(source), + } if lower(&source.name.name) == *column + && qualifier(source).is_none_or(|table| table == *alias) => + { + Some(name.clone()) + } + Projection::Star(star) + if star + .table + .as_ref() + .is_none_or(|table| lower(&table.name) == *alias) => + { + Some(column.clone()) + } + _ => None, + }); + match projected { + Some(name) => key.push(name), + None if star => key.push(column.clone()), + None => return None, + } + } + Some(key) +} + +fn all_columns_key(left: &Expression) -> Keys { + let Expression::Select(select) = left else { + return Vec::new(); + }; + let output: Vec> = projections(select); + if output + .iter() + .all(|item| matches!(item, Projection::Named { .. })) + { + vec![output_names(&output)] + } else { + Vec::new() + } +} + +/// Columns a ranking window partitions and orders by, generously resolved to input aliases. +pub(crate) fn window_columns( + partition_by: &[Expression], + order_by: &[&Expression], +) -> Vec<(Option, String)> { + partition_by + .iter() + .chain(order_by.iter().copied()) + .flat_map(columns) + .map(|column| (qualifier(column), lower(&column.name.name))) + .collect() +} + +/// Whether some input key is covered by the window's partition and order columns. +pub(crate) fn covers(keys: &[InputKey], window: &[(Option, String)]) -> bool { + keys.iter().any(|key| key_covered(key, window)) +} + +fn key_covered(key: &InputKey, window: &[(Option, String)]) -> bool { + key.iter() + .all(|(alias, column)| window_has(window, alias, column)) +} + +fn window_has(window: &[(Option, String)], alias: &str, column: &str) -> bool { + window.iter().any(|(qualifier, name)| { + name == column && qualifier.as_deref().is_none_or(|table| table == alias) + }) +} + +/// Keys of declared relations and of each top-level CTE, in definition order. +pub(crate) fn key_environment(request: &QualityRequest<'_>) -> KeyEnvironment { + let mut environment: KeyEnvironment = KeyEnvironment { + declared: lowered_keys(request.relation_keys), + ctes: HashMap::new(), + row_numbers: HashMap::new(), + }; + for statement in request.statements { + let Expression::Select(select) = statement else { + continue; + }; + for cte in select.with.iter().flat_map(|with| with.ctes.iter()) { + let keys = environment.query_keys(&cte.this); + environment.ctes.insert(lower(&cte.alias.name), keys); + if let Expression::Select(body) = &cte.this { + environment + .row_numbers + .insert(lower(&cte.alias.name), row_number_outputs(body)); + } + } + } + environment +} + +fn lowered_keys(keys: &HashMap) -> HashMap { + let mut lowered: HashMap = HashMap::new(); + for (name, relation_keys) in keys { + let mut converted: Keys = Vec::new(); + for key in relation_keys { + let mut columns: Vec = Vec::new(); + for column in key { + columns.push(lower(column)); + } + converted.push(columns); + } + lowered.insert(lower(name), converted); + } + lowered +} + +/// `(output name, partition output columns)` for each `ROW_NUMBER() OVER (PARTITION BY ...)` output. +fn row_number_outputs(select: &Select) -> Vec<(String, Vec)> { + let output: Vec> = projections(select); + let mut found: Vec<(String, Vec)> = Vec::new(); + for item in &output { + let Projection::Named { + name, + expression: Expression::WindowFunction(window), + } = item + else { + continue; + }; + if !matches!(window.this, Expression::RowNumber(_)) || window.over.partition_by.is_empty() { + continue; + } + if let Some(key) = output_key_of(&window.over.partition_by, &output) { + found.push((name.clone(), key)); + } + } + found +} + +/// Columns a filter pins to the first row, as in `WHERE rn = 1 AND ...`. +fn first_row_filters(condition: &Expression) -> Vec { + let mut found: Vec = Vec::new(); + let mut pending: Vec<&Expression> = vec![condition]; + while let Some(expression) = pending.pop() { + match expression { + Expression::And(operation) => { + pending.push(&operation.left); + pending.push(&operation.right); + } + Expression::Paren(paren) => pending.push(&paren.this), + Expression::Eq(operation) => { + if let (Expression::Column(column), Expression::Literal(literal)) + | (Expression::Literal(literal), Expression::Column(column)) = + (&operation.left, &operation.right) + && matches!(literal.as_ref(), Literal::Number(text) if text == FIRST_ROW) + { + found.push(lower(&column.name.name)); + } + } + _ => {} + } + } + found +} diff --git a/crates/sqlclean/src/sql_quality/_helpers/literals.rs b/crates/sqlclean/src/sql_quality/_helpers/literals.rs new file mode 100644 index 0000000..25ca489 --- /dev/null +++ b/crates/sqlclean/src/sql_quality/_helpers/literals.rs @@ -0,0 +1,71 @@ +//! SQC044: string literals longer than the project limit. + +use std::borrow::Cow; + +use polyglot_sql::tokens::TokenType; + +use crate::sql_lint::models::LintDiagnostic; +use crate::sql_quality::constants::{ + LONG_LITERAL, LONG_LITERAL_GUIDANCE, MAX_LITERAL_LENGTH_KEY, RULES_THRESHOLDS_SECTION, +}; +use crate::sql_quality::models::QualityRequest; +use crate::sql_quality::setting_help::{Setting, setting_help}; + +const CONSTANT_FIX_REFUSAL: &str = + "Moving the value to a constant requires choosing its name and placement"; + +pub(crate) fn diagnostics(request: &QualityRequest<'_>) -> Vec { + request + .tokens + .iter() + .filter(|token| is_string(token.token_type)) + .filter_map(|token| { + let length: usize = token.text.chars().count(); + (length > request.max_literal_length).then(|| LintDiagnostic { + code: LONG_LITERAL.code, + message: Cow::Borrowed(LONG_LITERAL.message), + remediation: Cow::Owned(remediation( + length, + request.max_literal_length, + request.config_file, + )), + start: token.span.start, + end: token.span.end, + fix: None, + fix_unavailable_reason: Some(CONSTANT_FIX_REFUSAL), + }) + }) + .collect() +} + +fn remediation(length: usize, limit: usize, config_file: &str) -> String { + format!( + "{LONG_LITERAL_GUIDANCE} {}", + setting_help(&Setting { + purpose: &format!( + "To allow literals of {length} characters (the current value is {limit})" + ), + config_file, + section: RULES_THRESHOLDS_SECTION, + key: MAX_LITERAL_LENGTH_KEY, + value: &length.to_string(), + }) + ) +} + +fn is_string(kind: TokenType) -> bool { + matches!( + kind, + TokenType::String + | TokenType::DollarString + | TokenType::EscapeString + | TokenType::RawString + | TokenType::NationalString + | TokenType::UnicodeString + | TokenType::ByteString + | TokenType::TripleSingleQuotedString + | TokenType::TripleDoubleQuotedString + | TokenType::HeredocString + | TokenType::HeredocStringAlternative + ) +} diff --git a/crates/sqlclean/src/sql_quality/_helpers/ranking.rs b/crates/sqlclean/src/sql_quality/_helpers/ranking.rs new file mode 100644 index 0000000..b1088df --- /dev/null +++ b/crates/sqlclean/src/sql_quality/_helpers/ranking.rs @@ -0,0 +1,218 @@ +//! SQC043 ranking sort caps and SQC018 ranking determinism proofs. + +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; + +use polyglot_sql::expressions::{Over, Select, WindowFunction}; +use polyglot_sql::scope::walk_in_scope; +use polyglot_sql::tokens::Token; +use polyglot_sql::{Expression, ExpressionWalk}; + +use crate::sql_lint::models::LintDiagnostic; +use crate::sql_quality::constants::{ + MAX_RANKING_ORDER_BY_KEY, RANKING_SORT_CAP, RANKING_SORT_GUIDANCE, RULES_THRESHOLDS_SECTION, + TIE_SENSITIVE_FUNCTIONS, UNPROVEN_RANKING_MESSAGE, UNPROVEN_RANKING_REMEDIATION, + UNSTABLE_ROW_NUMBER_CODE, +}; +use crate::sql_quality::keys::{KeyEnvironment, covers, window_columns}; +use crate::sql_quality::models::QualityRequest; +use crate::sql_quality::setting_help::{Setting, setting_help}; +use crate::sql_quality::syntax::{columns, lower}; +use crate::sql_quality::types::InputKey; + +const PRIORITY_FIX_REFUSAL: &str = + "Choosing a priority column and tie-breaker requires author intent"; +const KEY_FIX_REFUSAL: &str = "Declaring a key or adding a tie-breaker requires author intent"; + +struct RankingWindow<'a> { + function: &'static str, + window: &'a WindowFunction, + select: &'a Select, +} + +pub(crate) fn diagnostics( + request: &QualityRequest<'_>, + environment: &KeyEnvironment, +) -> Vec { + let cap: bool = request.enabled.contains(RANKING_SORT_CAP.code); + let proof: bool = request.enabled.contains(UNSTABLE_ROW_NUMBER_CODE); + let mut used: HashSet = HashSet::new(); + let mut function_tokens: HashMap> = HashMap::new(); + for (index, token) in request.tokens.iter().enumerate() { + if TIE_SENSITIVE_FUNCTIONS + .iter() + .chain(["RANK", "DENSE_RANK"].iter()) + .any(|name| token.text.eq_ignore_ascii_case(name)) + { + function_tokens + .entry(token.text.to_ascii_uppercase()) + .or_default() + .push(index); + } + } + let mut found: Vec = Vec::new(); + for ranking in ranking_windows(request.statements) { + let over: Option<&Over> = effective_over(&ranking.window.over, ranking.select); + let order: Vec<&Expression> = over.map_or_else(Vec::new, |over| { + over.order_by.iter().map(|ordered| &ordered.this).collect() + }); + let partition: &[Expression] = over.map_or(&[], |over| over.partition_by.as_slice()); + let exceeds: bool = cap && order.len() > request.max_ranking_order_by; + let unproven: bool = proof + && !order.is_empty() + && TIE_SENSITIVE_FUNCTIONS.contains(&ranking.function) + && !proven(environment, ranking.select, partition, &order); + if !exceeds && !unproven { + continue; + } + let candidates: &[usize] = function_tokens + .get(ranking.function) + .map_or(&[], Vec::as_slice); + let token: Option = function_token( + request.tokens, + candidates, + window_anchor(partition, &order), + &used, + ); + let (start, end) = token.map_or((0, 0), |index| { + ( + request.tokens[index].span.start, + request.tokens[index].span.end, + ) + }); + used.extend(token); + if exceeds { + found.push(LintDiagnostic { + code: RANKING_SORT_CAP.code, + message: Cow::Borrowed(RANKING_SORT_CAP.message), + remediation: Cow::Owned(sort_cap_remediation( + order.len(), + request.max_ranking_order_by, + request.config_file, + )), + start, + end, + fix: None, + fix_unavailable_reason: Some(PRIORITY_FIX_REFUSAL), + }); + } + if unproven { + found.push(LintDiagnostic { + code: UNSTABLE_ROW_NUMBER_CODE, + message: Cow::Borrowed(UNPROVEN_RANKING_MESSAGE), + remediation: Cow::Borrowed(UNPROVEN_RANKING_REMEDIATION), + start, + end, + fix: None, + fix_unavailable_reason: Some(KEY_FIX_REFUSAL), + }); + } + } + found +} + +fn sort_cap_remediation(sort_keys: usize, limit: usize, config_file: &str) -> String { + format!( + "{RANKING_SORT_GUIDANCE} {}", + setting_help(&Setting { + purpose: &format!("To allow {sort_keys} sort keys (the current value is {limit})"), + config_file, + section: RULES_THRESHOLDS_SECTION, + key: MAX_RANKING_ORDER_BY_KEY, + value: &sort_keys.to_string(), + }) + ) +} + +fn proven( + environment: &KeyEnvironment, + select: &Select, + partition: &[Expression], + order: &[&Expression], +) -> bool { + let keys: Vec = environment.input_keys(select); + covers(&keys, &window_columns(partition, order)) +} + +fn ranking_windows(statements: &[Expression]) -> Vec> { + let mut found: Vec> = Vec::new(); + for statement in statements { + for node in statement.dfs() { + let Expression::Select(select) = node else { + continue; + }; + for local in walk_in_scope(node, false) { + let Expression::WindowFunction(window) = local else { + continue; + }; + let function: &'static str = match &window.this { + Expression::RowNumber(_) => "ROW_NUMBER", + Expression::Rank(_) => "RANK", + Expression::DenseRank(_) => "DENSE_RANK", + Expression::FirstValue(_) => "FIRST_VALUE", + Expression::LastValue(_) => "LAST_VALUE", + _ => continue, + }; + found.push(RankingWindow { + function, + window, + select, + }); + } + } + } + found +} + +fn effective_over<'a>(over: &'a Over, select: &'a Select) -> Option<&'a Over> { + let mut current: &Over = over; + let mut seen: HashSet = HashSet::new(); + loop { + if !current.order_by.is_empty() || current.window_name.is_none() { + return Some(current); + } + let name: String = lower(¤t.window_name.as_ref()?.name); + if !seen.insert(name.clone()) { + return None; + } + current = &select + .windows + .iter() + .flatten() + .find(|window| lower(&window.name.name) == name)? + .spec; + } +} + +/// Start offset of the first located column a window partitions or orders by. +fn window_anchor(partition: &[Expression], order: &[&Expression]) -> Option { + let mut expressions: Vec<&Expression> = partition.iter().collect(); + expressions.extend(order.iter().copied()); + for expression in expressions { + for column in columns(expression) { + if let Some(span) = column.span { + return Some(span.start); + } + } + } + None +} + +/// The function-name token before the anchor, or the first token not reported yet. +fn function_token( + tokens: &[Token], + candidates: &[usize], + anchor: Option, + used: &HashSet, +) -> Option { + if let Some(anchor) = anchor { + let before: usize = candidates.partition_point(|&index| tokens[index].span.start < anchor); + if let Some(position) = before.checked_sub(1) { + return Some(candidates[position]); + } + } + candidates + .iter() + .copied() + .find(|index| !used.contains(index)) +} diff --git a/crates/sqlclean/src/sql_quality/_helpers/removal_safety.rs b/crates/sqlclean/src/sql_quality/_helpers/removal_safety.rs new file mode 100644 index 0000000..92f6025 --- /dev/null +++ b/crates/sqlclean/src/sql_quality/_helpers/removal_safety.rs @@ -0,0 +1,102 @@ +//! SQC042 allow-list: when removing an unused CTE output provably keeps every row. + +use polyglot_sql::expressions::Select; +use polyglot_sql::traversal::contains_aggregate; +use polyglot_sql::{Expression, ExpressionWalk}; + +use crate::sql_quality::constants::{ + DETERMINISTIC_SCALAR_FUNCTIONS, MIN_REMOVABLE_OUTPUTS, ROW_NEUTRAL_EXPRESSIONS, +}; + +const ORDER_BY_ALL: &str = "all"; +const DISTINCT_REFUSAL: &str = "SELECT DISTINCT depends on every output column"; +const DISTINCT_ON_REFUSAL: &str = "SELECT DISTINCT ON keeps one row per key chosen by the select"; +const GROUP_BY_ALL_REFUSAL: &str = "GROUP BY ALL groups by every non-aggregate output column"; +const ORDER_BY_ALL_REFUSAL: &str = "ORDER BY ALL sorts by every output column"; +const UNGROUPED_AGGREGATE_REFUSAL: &str = + "An aggregate select without GROUP BY depends on its whole projection"; +const POSITIONAL_REFUSAL: &str = "Positional GROUP BY or ORDER BY would shift"; +const ONLY_OUTPUT_REFUSAL: &str = "Removing the only output column leaves an empty select"; +const EXPRESSION_REFUSAL: &str = "Only column references and deterministic scalar expressions are removed automatically; this expression may change the rows the CTE returns"; + +/// Why removing any output of `select` could change its rows, if it could. +pub(super) fn select_refusal(select: &Select, outputs: usize) -> Option<&'static str> { + if select.distinct { + Some(DISTINCT_REFUSAL) + } else if select.distinct_on.is_some() { + Some(DISTINCT_ON_REFUSAL) + } else if select + .group_by + .as_ref() + .is_some_and(|group| group.all == Some(true)) + { + Some(GROUP_BY_ALL_REFUSAL) + } else if orders_by_all(select) { + Some(ORDER_BY_ALL_REFUSAL) + } else if select.group_by.is_none() + && (select.having.is_some() || select.expressions.iter().any(contains_aggregate)) + { + Some(UNGROUPED_AGGREGATE_REFUSAL) + } else if positional_clauses(select) { + Some(POSITIONAL_REFUSAL) + } else if outputs < MIN_REMOVABLE_OUTPUTS { + Some(ONLY_OUTPUT_REFUSAL) + } else { + None + } +} + +/// Why removing this one projection could change the rows, if it could. +pub(super) fn expression_refusal(expression: &Expression) -> Option<&'static str> { + (!row_neutral(expression)).then_some(EXPRESSION_REFUSAL) +} + +/// Whether a star select depends on every input column or on their positions. +pub(super) fn reads_whole_rows(select: &Select) -> bool { + select.distinct + || select.distinct_on.is_some() + || select.group_by.is_some() + || orders_by_all(select) + || positional_clauses(select) +} + +/// A column reference or deterministic scalar expression (no aggregate, window, SRF or subquery). +fn row_neutral(expression: &Expression) -> bool { + expression.dfs().all(|node| match node { + Expression::Function(function) => { + let name: String = function.name.to_ascii_uppercase(); + !function.distinct && DETERMINISTIC_SCALAR_FUNCTIONS.contains(&name.as_str()) + } + other => ROW_NEUTRAL_EXPRESSIONS.contains(&other.variant_name()), + }) +} + +fn orders_by_all(select: &Select) -> bool { + select + .order_by + .iter() + .flat_map(|order| order.expressions.iter()) + .any(|ordered| match &ordered.this { + Expression::Column(column) => { + column.table.is_none() + && !column.name.quoted + && column.name.name.eq_ignore_ascii_case(ORDER_BY_ALL) + } + Expression::Var(var) => var.this.eq_ignore_ascii_case(ORDER_BY_ALL), + _ => false, + }) +} + +fn positional_clauses(select: &Select) -> bool { + let numeric = |expression: &Expression| matches!(expression, Expression::Literal(_)); + select + .group_by + .iter() + .flat_map(|group| group.expressions.iter()) + .any(numeric) + || select + .order_by + .iter() + .flat_map(|order| order.expressions.iter()) + .any(|ordered| numeric(&ordered.this)) +} diff --git a/crates/sqlclean/src/sql_quality/_helpers/setting_help.rs b/crates/sqlclean/src/sql_quality/_helpers/setting_help.rs new file mode 100644 index 0000000..a456ab7 --- /dev/null +++ b/crates/sqlclean/src/sql_quality/_helpers/setting_help.rs @@ -0,0 +1,26 @@ +//! Remediation text that shows the exact configuration setting to write. + +use crate::constants::SETTING_SNIPPET_INDENT; + +/// One setting a remediation tells the user to write. +pub(crate) struct Setting<'a> { + pub(crate) purpose: &'a str, + pub(crate) config_file: &'a str, + pub(crate) section: &'a str, + pub(crate) key: &'a str, + pub(crate) value: &'a str, +} + +/// `, set this in :` followed by the section and `key = value`. +pub(crate) fn setting_help(setting: &Setting<'_>) -> String { + let Setting { + purpose, + config_file, + section, + key, + value, + } = setting; + format!( + "{purpose}, set this in {config_file}:\n{SETTING_SNIPPET_INDENT}[{section}]\n{SETTING_SNIPPET_INDENT}{key} = {value}" + ) +} diff --git a/crates/sqlclean/src/sql_quality/_helpers/syntax.rs b/crates/sqlclean/src/sql_quality/_helpers/syntax.rs new file mode 100644 index 0000000..7f79990 --- /dev/null +++ b/crates/sqlclean/src/sql_quality/_helpers/syntax.rs @@ -0,0 +1,124 @@ +//! Shared syntax helpers for SQL quality Rules. + +use polyglot_sql::expressions::{Column, Select, Star}; +use polyglot_sql::tokens::{Token, TokenType}; +use polyglot_sql::{Expression, ExpressionWalk}; + +use crate::sql_quality::constants::MIN_REMOVABLE_OUTPUTS; +use rustc_hash::FxHashMap; + +/// One projection of a select list. +pub(super) enum Projection<'a> { + Named { + name: String, + expression: &'a Expression, + }, + Star(&'a Star), + Unnamed, +} + +pub(super) fn lower(name: &str) -> String { + name.to_ascii_lowercase() +} + +pub(super) fn projections(select: &Select) -> Vec> { + select + .expressions + .iter() + .map(|expression| match expression { + Expression::Alias(alias) => Projection::Named { + name: lower(&alias.alias.name), + expression: &alias.this, + }, + Expression::Column(column) => Projection::Named { + name: lower(&column.name.name), + expression, + }, + Expression::Star(star) => Projection::Star(star), + _ => Projection::Unnamed, + }) + .collect() +} + +pub(super) fn columns(expression: &Expression) -> Vec<&Column> { + expression + .dfs() + .filter_map(|node| match node { + Expression::Column(column) => Some(column.as_ref()), + _ => None, + }) + .collect() +} + +pub(super) fn qualifier(column: &Column) -> Option { + column.table.as_ref().map(|table| lower(&table.name)) +} + +/// Relations read directly by one select: `(alias, relation name, expression)`. +pub(super) fn sources(select: &Select) -> Vec<(String, Option, &Expression)> { + let mut found: Vec<(String, Option, &Expression)> = Vec::new(); + let from = select.from.iter().flat_map(|from| from.expressions.iter()); + for expression in from.chain(select.joins.iter().map(|join| &join.this)) { + match expression { + Expression::Table(table) => { + let name: String = lower(&table.name.name); + let alias: String = table + .alias + .as_ref() + .map_or_else(|| name.clone(), |alias| lower(&alias.name)); + found.push((alias, Some(name), expression)); + } + Expression::Subquery(subquery) => { + let alias: String = subquery + .alias + .as_ref() + .map_or_else(String::new, |alias| lower(&alias.name)); + found.push((alias, None, expression)); + } + _ => found.push((String::new(), None, expression)), + } + } + found +} + +/// Token index of the SELECT opening each `name AS (SELECT` CTE body, by lower-cased name. +pub(super) fn cte_select_tokens(tokens: &[Token]) -> FxHashMap { + let significant: Vec = (0..tokens.len()) + .filter(|&index| !is_trivia(&tokens[index])) + .collect(); + let mut found: FxHashMap = FxHashMap::default(); + for window in significant.windows(4) { + let [name_index, as_index, paren_index, select_index] = window else { + continue; + }; + if tokens[*as_index].token_type == TokenType::As + && tokens[*paren_index].token_type == TokenType::LParen + && tokens[*select_index].token_type == TokenType::Select + { + found + .entry(lower(&tokens[*name_index].text)) + .or_insert(*select_index); + } + } + found +} + +/// The comma-inclusive deletion span that removes projection `index` from `spans`. +pub(super) fn projection_removal(spans: &[(usize, usize)], index: usize) -> Option<(usize, usize)> { + if spans.len() < MIN_REMOVABLE_OUTPUTS { + return None; + } + let (start, end) = spans[index]; + if index + 1 < spans.len() { + Some((start, spans[index + 1].0)) + } else { + Some((spans[index - 1].1, end)) + } +} + +pub(super) fn is_trivia(token: &Token) -> bool { + matches!( + token.token_type, + TokenType::Space | TokenType::Break | TokenType::LineComment | TokenType::BlockComment + ) +} diff --git a/crates/sqlclean/src/sql_quality/constants.rs b/crates/sqlclean/src/sql_quality/constants.rs new file mode 100644 index 0000000..25ae410 --- /dev/null +++ b/crates/sqlclean/src/sql_quality/constants.rs @@ -0,0 +1,202 @@ +use crate::sql_lint::models::LintRuleMetadata; + +pub(crate) const UNUSED_CTE_OUTPUT: LintRuleMetadata = LintRuleMetadata { + code: "SQC042", + message: "CTE output column is never read later in the query", + remediation: "Remove the column from the CTE output, or read it in a later CTE or the final query.", +}; +pub(crate) const RANKING_SORT_CAP: LintRuleMetadata = LintRuleMetadata { + code: "SQC043", + message: "Ranking window sorts by more expressions than max_ranking_order_by", + remediation: "Order by the one or two columns that decide which row wins, then a unique row key as the final tie-breaker.", +}; +pub(crate) const LONG_LITERAL: LintRuleMetadata = LintRuleMetadata { + code: "SQC044", + message: "String literal is longer than max_literal_length", + remediation: "Shorten the literal, or move the value into a reference table or a query parameter.", +}; +pub(crate) const REPEATED_JSON_PARSE: LintRuleMetadata = LintRuleMetadata { + code: "SQC045", + message: "JSON payload is parsed more than once in one SELECT", + remediation: "Parse the payload once in the CTE that reads it, for example `TRY_PARSE_JSON(value) AS payload`, and read every field from that column in a later step.", +}; +pub(crate) const UNSTABLE_ROW_NUMBER_CODE: &str = "SQC018"; +pub(crate) const UNPROVEN_RANKING_MESSAGE: &str = + "Ranking window order is not proven unique within its partition"; +pub(crate) const UNPROVEN_RANKING_REMEDIATION: &str = "PARTITION BY and ORDER BY must together cover a unique key of the ranked rows. End the ORDER BY with a unique, non-null tie-breaker column. If the rows have no unique column, build one upstream from the columns that identify a row (for example `HASH(source_partition, source_offset, line_index) AS row_key`) and order by it last."; +/// SQC043 guidance before the `max_ranking_order_by` setting snippet. +pub(crate) const RANKING_SORT_GUIDANCE: &str = "Order by the one or two columns that decide which row wins, then a unique row key as the final tie-breaker. If the rows have no unique column, build one upstream from the columns that identify a row (for example `HASH(source_partition, source_offset, line_index) AS row_key`), so SQC018 can prove the order is deterministic. If the order genuinely needs more keys, raise `max_ranking_order_by`."; +/// SQC044 guidance before the `max_literal_length` setting snippet. +pub(crate) const LONG_LITERAL_GUIDANCE: &str = "Shorten the literal, or move the value into a reference table or a query parameter so it is defined once."; +pub(crate) const RULES_THRESHOLDS_SECTION: &str = "rules.thresholds"; +pub(crate) const MAX_RANKING_ORDER_BY_KEY: &str = "max_ranking_order_by"; +pub(crate) const MAX_LITERAL_LENGTH_KEY: &str = "max_literal_length"; +pub(crate) const DISTINCT_UNUSED_REMEDIATION: &str = "This CTE uses SELECT DISTINCT, so the column still decides which rows are kept. Make the deduplication independent of the column, or restructure the CTE so it outputs only what later steps read."; +pub(crate) const GROUP_KEY_UNUSED_REMEDIATION: &str = "Drop the column from the CTE output but keep it in GROUP BY; the grouping grain stays the same."; +pub(crate) const GROUP_BY_ALL_UNUSED_REMEDIATION: &str = "This CTE uses GROUP BY ALL, so the column is part of the grouping grain. List the grouping columns explicitly and keep this one in GROUP BY, then drop it from the output."; +/// Expression kinds that compute one value per input row without changing the row set. +pub(crate) const ROW_NEUTRAL_EXPRESSIONS: &[&str] = &[ + "literal", + "boolean", + "null", + "identifier", + "column", + "paren", + "dot", + "subscript", + "data_type", + "interval", + "cast", + "try_cast", + "safe_cast", + "collation", + "case", + "and", + "or", + "not", + "neg", + "add", + "sub", + "mul", + "div", + "mod", + "eq", + "neq", + "lt", + "lte", + "gt", + "gte", + "like", + "i_like", + "is", + "is_null", + "is_true", + "is_false", + "in", + "between", + "null_safe_eq", + "bitwise_and", + "bitwise_or", + "bitwise_xor", + "concat", + "concat_ws", + "substring", + "upper", + "lower", + "length", + "trim", + "replace", + "left", + "right", + "lpad", + "rpad", + "split", + "split_part", + "regexp_replace", + "initcap", + "position", + "contains", + "starts_with", + "ends_with", + "abs", + "round", + "floor", + "ceil", + "greatest", + "least", + "coalesce", + "null_if", + "if_func", + "if_null", + "nvl", + "nvl2", + "decode", + "date_add", + "date_diff", + "date_trunc", + "timestamp_trunc", + "extract", + "to_date", + "to_timestamp", + "to_char", + "to_number", + "date", + "year", + "month", + "last_day", + "struct_extract", +]; +/// Generic function names known to be deterministic scalar functions in supported dialects. +pub(crate) const DETERMINISTIC_SCALAR_FUNCTIONS: &[&str] = &[ + "ABS", + "ARRAY_SIZE", + "CEIL", + "CHARINDEX", + "COALESCE", + "CONCAT", + "CONCAT_WS", + "DATEADD", + "DATEDIFF", + "DATE_ADD", + "DATE_DIFF", + "DATE_PART", + "DATE_TRUNC", + "DAY", + "DIV0", + "FLOOR", + "GREATEST", + "HOUR", + "IFF", + "IFNULL", + "LEAST", + "LEFT", + "LENGTH", + "LOWER", + "LPAD", + "LTRIM", + "MD5", + "MOD", + "MONTH", + "NULLIF", + "NVL", + "NVL2", + "POWER", + "REGEXP_REPLACE", + "REPLACE", + "RIGHT", + "ROUND", + "RPAD", + "RTRIM", + "SPLIT_PART", + "SQRT", + "STRPOS", + "SUBSTR", + "SUBSTRING", + "TO_CHAR", + "TO_DATE", + "TO_VARCHAR", + "TRIM", + "TRUNC", + "UPPER", + "YEAR", + "ZEROIFNULL", +]; +pub(crate) const DEFAULT_MAX_LITERAL_LENGTH: usize = 100; +pub(crate) const DEFAULT_MAX_RANKING_ORDER_BY: usize = 5; +pub(crate) const TIE_SENSITIVE_FUNCTIONS: [&str; 3] = ["ROW_NUMBER", "FIRST_VALUE", "LAST_VALUE"]; +pub(crate) const MIN_REMOVABLE_OUTPUTS: usize = 2; +pub(crate) const INTERPOLATION_SENTINEL_PREFIXES: [&str; 3] = [ + "__sqb_lint_", + "__sqlbuild_audit_parameter_", + "__sqlbuild_context_parameter_", +]; +pub(crate) const FIRST_ROW: &str = "1"; + +pub(crate) const UNIQUE_AUDIT: &str = "unique"; +pub(crate) const NOT_NULL_AUDIT: &str = "not_null"; +pub(crate) const INCREMENTAL_MATERIALIZATION: &str = "incremental"; +pub(crate) const MICROBATCH_MODE: &str = "microbatch"; +pub(crate) const KEYED_DELETE_INSERT_STRATEGY: &str = "delete_insert"; +pub(crate) const KEY_PRESERVING_STRATEGIES: [&str; 2] = ["merge", KEYED_DELETE_INSERT_STRATEGY]; +pub(crate) const RELATION_KIND_REF: &str = "ref"; +pub(crate) const RELATION_KIND_SEED: &str = "seed"; diff --git a/crates/sqlclean/src/sql_quality/main/declared_relation_keys.rs b/crates/sqlclean/src/sql_quality/main/declared_relation_keys.rs new file mode 100644 index 0000000..f999212 --- /dev/null +++ b/crates/sqlclean/src/sql_quality/main/declared_relation_keys.rs @@ -0,0 +1,38 @@ +use std::collections::BTreeMap; + +use crate::sql_quality::constants::{RELATION_KIND_REF, RELATION_KIND_SEED}; +use crate::sql_quality::declared_keys::{non_null_unique_columns, trusted_unique_key}; +use crate::sql_quality::models::DeclaredRelation; + +/// Keys proving one row per key by `kind:name`; seeds are also refs, one-row relations key `[]`. +pub fn declared_relation_keys( + relations: &[DeclaredRelation], +) -> BTreeMap>> { + let mut keys: BTreeMap>> = BTreeMap::new(); + for relation in relations { + let mut found: Vec> = trusted_unique_key(relation); + for column in non_null_unique_columns(relation) { + found.push(vec![column]); + } + if relation.at_most_one_row { + found.push(Vec::new()); + } + if found.is_empty() { + continue; + } + let mut kinds: Vec<&str> = vec![relation.kind.as_str()]; + if relation.kind == RELATION_KIND_SEED { + kinds.push(RELATION_KIND_REF); + } + for kind in kinds { + let entry: &mut Vec> = + keys.entry(format!("{kind}:{}", relation.name)).or_default(); + for key in &found { + if !entry.contains(key) { + entry.push(key.clone()); + } + } + } + } + keys +} diff --git a/crates/sqlclean/src/sql_quality/main/diagnostics.rs b/crates/sqlclean/src/sql_quality/main/diagnostics.rs new file mode 100644 index 0000000..76848cc --- /dev/null +++ b/crates/sqlclean/src/sql_quality/main/diagnostics.rs @@ -0,0 +1,32 @@ +use crate::sql_lint::models::LintDiagnostic; +use crate::sql_quality::constants::{ + LONG_LITERAL, RANKING_SORT_CAP, REPEATED_JSON_PARSE, UNSTABLE_ROW_NUMBER_CODE, + UNUSED_CTE_OUTPUT, +}; +use crate::sql_quality::keys::{KeyEnvironment, key_environment}; +use crate::sql_quality::models::QualityRequest; +use crate::sql_quality::{cte_columns, json_parses, literals, ranking}; + +/// Run every enabled SQL quality Rule over one parsed body. +pub(crate) fn diagnostics(request: &QualityRequest<'_>) -> Result, String> { + if request.max_literal_length == 0 || request.max_ranking_order_by == 0 { + return Err("max_literal_length and max_ranking_order_by must be positive".to_owned()); + } + let mut found: Vec = Vec::new(); + if request.enabled.contains(LONG_LITERAL.code) { + found.extend(literals::diagnostics(request)); + } + if request.enabled.contains(REPEATED_JSON_PARSE.code) { + found.extend(json_parses::diagnostics(request)); + } + if request.enabled.contains(UNUSED_CTE_OUTPUT.code) { + found.extend(cte_columns::diagnostics(request)); + } + if request.enabled.contains(RANKING_SORT_CAP.code) + || request.enabled.contains(UNSTABLE_ROW_NUMBER_CODE) + { + let environment: KeyEnvironment = key_environment(request); + found.extend(ranking::diagnostics(request, &environment)); + } + Ok(found) +} diff --git a/crates/sqlclean/src/sql_quality/main/mod.rs b/crates/sqlclean/src/sql_quality/main/mod.rs new file mode 100644 index 0000000..f8c958d --- /dev/null +++ b/crates/sqlclean/src/sql_quality/main/mod.rs @@ -0,0 +1,2 @@ +pub mod declared_relation_keys; +pub mod diagnostics; diff --git a/crates/sqlclean/src/sql_quality/mod.rs b/crates/sqlclean/src/sql_quality/mod.rs new file mode 100644 index 0000000..b47f562 --- /dev/null +++ b/crates/sqlclean/src/sql_quality/mod.rs @@ -0,0 +1,27 @@ +//! Enforceable SQL quality Rules with project-wide limits and ranking determinism proofs. + +pub mod constants; +#[path = "_helpers/cte_columns.rs"] +mod cte_columns; +#[path = "_helpers/declared_keys.rs"] +mod declared_keys; +#[path = "_helpers/json_parses.rs"] +mod json_parses; +#[path = "_helpers/keys.rs"] +mod keys; +#[path = "_helpers/literals.rs"] +mod literals; +pub mod main; +pub mod models; +#[path = "_helpers/ranking.rs"] +mod ranking; +#[path = "_helpers/removal_safety.rs"] +mod removal_safety; +#[path = "_helpers/setting_help.rs"] +mod setting_help; +#[path = "_helpers/syntax.rs"] +mod syntax; +pub mod types; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlclean/src/sql_quality/models.rs b/crates/sqlclean/src/sql_quality/models.rs new file mode 100644 index 0000000..577d6a0 --- /dev/null +++ b/crates/sqlclean/src/sql_quality/models.rs @@ -0,0 +1,47 @@ +use std::collections::{HashMap, HashSet}; + +use polyglot_sql::tokens::Token; +use polyglot_sql::{DialectType, Expression}; + +/// Inputs for one body's quality Rules, borrowed from the native lint request. +#[derive(Debug)] +pub(crate) struct QualityRequest<'a> { + pub(crate) statements: &'a [Expression], + pub(crate) tokens: &'a [Token], + pub(crate) dialect: DialectType, + pub(crate) enabled: &'a HashSet, + pub(crate) max_literal_length: usize, + pub(crate) max_ranking_order_by: usize, + pub(crate) relation_keys: &'a HashMap>>, + pub(crate) externally_referenced_ctes: &'a HashSet, + pub(crate) fixture_body: bool, + /// The configuration file remediation text tells the user to edit. + pub(crate) config_file: &'a str, +} + +/// One project relation's declared key facts: its incremental config and audited columns. +#[derive(Debug, Clone, Default)] +pub struct DeclaredRelation { + /// `ref`, `seed` or `source`. + pub kind: String, + pub name: String, + pub materialized: String, + pub incremental_mode: String, + pub incremental_strategy: String, + pub has_cursor: bool, + /// The configured `unique_key` columns, when it is a name or a list of names. + pub unique_key: Option>, + pub columns: Vec, + /// Table-level audits as `(definition name, its string column argument)`. + pub audits: Vec<(String, Option)>, + /// Whether the relation is known to hold at most one row, which the empty key proves. + pub at_most_one_row: bool, +} + +/// One declared column: its name, its audit definition names and whether it is declared non-null. +#[derive(Debug, Clone, Default)] +pub struct DeclaredKeyColumn { + pub name: String, + pub audits: Vec, + pub not_nullable: bool, +} diff --git a/crates/sqlclean/src/sql_quality/tests.rs b/crates/sqlclean/src/sql_quality/tests.rs new file mode 100644 index 0000000..2643da6 --- /dev/null +++ b/crates/sqlclean/src/sql_quality/tests.rs @@ -0,0 +1,10 @@ +#[path = "tests/helpers.rs"] +mod helpers; +#[path = "tests/test_json_parses.rs"] +mod json_parses; +#[path = "tests/test_quality_rules.rs"] +mod quality_rules; +#[path = "tests/test_quality_scale.rs"] +mod quality_scale; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlclean/src/sql_quality/tests/helpers.rs b/crates/sqlclean/src/sql_quality/tests/helpers.rs new file mode 100644 index 0000000..3b8a43d --- /dev/null +++ b/crates/sqlclean/src/sql_quality/tests/helpers.rs @@ -0,0 +1,116 @@ +use serde_json::{Value, json}; + +use crate::sql_lint::main::engine::lint_json; +use crate::sql_quality::tests::test_types::QualityRuleTestCase; + +pub(crate) fn lint(test_case: &QualityRuleTestCase) -> Result, String> { + let relation_keys: Value = + serde_json::from_str(test_case.relation_keys).map_err(|error| error.to_string())?; + let request: Value = json!({ + "version": 1, + "sql": test_case.sql, + "dialect": "duckdb", + "enabled_rules": [test_case.rule], + "max_literal_length": 24, + "max_ranking_order_by": 2, + "relation_keys": relation_keys, + }); + let response: Value = serde_json::from_str(&lint_json(&request.to_string())?) + .map_err(|error| error.to_string())?; + Ok(response["diagnostics"] + .as_array() + .cloned() + .unwrap_or_default()) +} + +/// Lint `sql` in `dialect` with only `rule` enabled. +pub(crate) fn lint_dialect(sql: &str, dialect: &str, rule: &str) -> Result, String> { + let request: Value = json!({ + "version": 1, + "sql": sql, + "dialect": dialect, + "enabled_rules": [rule], + }); + let response: Value = serde_json::from_str(&lint_json(&request.to_string())?) + .map_err(|error| error.to_string())?; + Ok(response["diagnostics"] + .as_array() + .cloned() + .unwrap_or_default()) +} + +/// One DuckDB SELECT that parses each of `column_count` payload columns twice. +pub(crate) fn wide_json_select(column_count: usize) -> String { + let projections: Vec = (0..column_count * 2) + .map(|index| format!("json(e.c{})->>'f{index}' AS f{index}", index % column_count)) + .collect(); + format!("SELECT {} FROM events AS e", projections.join(", ")) +} + +pub(crate) fn anchors(sql: &str, diagnostics: &[Value]) -> Vec { + diagnostics + .iter() + .map(|diagnostic| { + let start: usize = diagnostic["start"].as_u64().unwrap_or(0) as usize; + let end: usize = diagnostic["end"].as_u64().unwrap_or(0) as usize; + sql.chars() + .skip(start) + .take(end.saturating_sub(start)) + .collect() + }) + .collect() +} + +pub(crate) fn fixed(sql: &str, diagnostics: &[Value]) -> Option { + let mut edits: Vec<(usize, usize, String)> = diagnostics + .iter() + .filter_map(|diagnostic| { + let fix: &Value = diagnostic.get("fix")?; + Some(( + fix["start"].as_u64()? as usize, + fix["end"].as_u64()? as usize, + fix["replacement"].as_str()?.to_owned(), + )) + }) + .collect(); + edits.sort_by_key(|edit| std::cmp::Reverse(edit.0)); + let applied: bool = !edits.is_empty(); + let mut characters: Vec = sql.chars().collect(); + for (start, end, replacement) in edits { + characters.splice(start..end, replacement.chars()); + } + applied.then(|| characters.into_iter().collect()) +} + +/// A chain of CTEs that each drop their last column and rank without a declared key. +pub(crate) fn wide_cte_chain(cte_count: usize, column_count: usize) -> String { + let columns: Vec = (0..column_count).map(|index| format!("c{index}")).collect(); + let mut ctes: Vec = vec![format!( + "step_0 AS (SELECT {} FROM source_rows AS s)", + columns + .iter() + .map(|column| format!("s.{column}")) + .collect::>() + .join(", ") + )]; + for step in 1..cte_count { + let mut outputs: Vec = columns[..column_count - 1] + .iter() + .map(|column| format!("p.{column}")) + .collect(); + outputs.push(format!( + "ROW_NUMBER() OVER (PARTITION BY p.c0 ORDER BY p.c1) AS c{}", + column_count - 1 + )); + ctes.push(format!( + "step_{step} AS (SELECT {} FROM step_{} AS p)", + outputs.join(", "), + step - 1 + )); + } + format!( + "WITH {} SELECT * FROM step_{}", + ctes.join(",\n"), + cte_count - 1 + ) +} diff --git a/crates/sqlclean/src/sql_quality/tests/test_json_parses.rs b/crates/sqlclean/src/sql_quality/tests/test_json_parses.rs new file mode 100644 index 0000000..570a272 --- /dev/null +++ b/crates/sqlclean/src/sql_quality/tests/test_json_parses.rs @@ -0,0 +1,282 @@ +use std::time::Instant; + +use serde_json::Value; + +use crate::sql_quality::tests::helpers::{anchors, lint_dialect, wide_json_select}; +use crate::sql_quality::tests::test_types::{JsonParseScaleTestCase, JsonParseTestCase}; + +const RULE: &str = "SQC045"; + +#[test] +fn given_json_parse_calls_when_linting_then_repeats_in_one_select_are_reported() +-> Result<(), String> { + let test_cases = [ + JsonParseTestCase { + description: "same column parsed twice reports the second call", + dialect: "snowflake", + sql: "SELECT TRY_PARSE_JSON(e.value):order_id AS order_id, TRY_PARSE_JSON(e.value):status AS status FROM events AS e", + expected_anchors: &["TRY_PARSE_JSON"], + expected_details: &["TRY_PARSE_JSON(e.value) is parsed 2 times in one SELECT."], + }, + JsonParseTestCase { + description: "the finding is located at the second call", + dialect: "snowflake", + sql: "SELECT try_parse_json(value):a AS a, TRY_PARSE_JSON(value):b AS b FROM events", + expected_anchors: &["TRY_PARSE_JSON"], + expected_details: &["TRY_PARSE_JSON(value) is parsed 2 times in one SELECT."], + }, + JsonParseTestCase { + description: "three parses give one finding with the count", + dialect: "snowflake", + sql: "SELECT PARSE_JSON(value):a AS a, PARSE_JSON(value):b AS b FROM events WHERE PARSE_JSON(value):c IS NOT NULL", + expected_anchors: &["PARSE_JSON"], + expected_details: &["PARSE_JSON(value) is parsed 3 times in one SELECT."], + }, + JsonParseTestCase { + description: "column casing and wrappers do not hide a repeat", + dialect: "snowflake", + sql: "SELECT TRY_PARSE_JSON(LOWER(E.Value)):a AS a, TRY_PARSE_JSON((CAST(TRIM(e.value) AS VARCHAR))):b AS b, TRY_PARSE_JSON(COALESCE(UPPER(e.VALUE))):c AS c FROM events AS e", + expected_anchors: &["TRY_PARSE_JSON"], + expected_details: &["TRY_PARSE_JSON(e.value) is parsed 3 times in one SELECT."], + }, + JsonParseTestCase { + description: "multi-argument COALESCE is not a plain column", + dialect: "snowflake", + sql: "SELECT TRY_PARSE_JSON(COALESCE(e.value, e.backup)):a AS a, TRY_PARSE_JSON(COALESCE(e.value, e.fallback)):b AS b, TRY_PARSE_JSON(e.value):c AS c FROM events AS e", + expected_anchors: &[], + expected_details: &[], + }, + JsonParseTestCase { + description: "TRIM with characters is not a plain column", + dialect: "snowflake", + sql: "SELECT TRY_PARSE_JSON(TRIM(e.value, ' ')):a AS a, TRY_PARSE_JSON(e.value):b AS b FROM events AS e", + expected_anchors: &[], + expected_details: &[], + }, + JsonParseTestCase { + description: "quoted names differing in case are different columns", + dialect: "snowflake", + sql: "SELECT TRY_PARSE_JSON(e.\"Value\"):a AS a, TRY_PARSE_JSON(e.\"value\"):b AS b FROM events AS e", + expected_anchors: &[], + expected_details: &[], + }, + JsonParseTestCase { + description: "quoted and unquoted names are not merged", + dialect: "snowflake", + sql: "SELECT TRY_PARSE_JSON(e.\"value\"):a AS a, TRY_PARSE_JSON(e.value):b AS b FROM events AS e", + expected_anchors: &[], + expected_details: &[], + }, + JsonParseTestCase { + description: "quoted qualifiers differing in case are different relations", + dialect: "snowflake", + sql: "SELECT TRY_PARSE_JSON(\"E\".value):a AS a, TRY_PARSE_JSON(\"e\".value):b AS b FROM events AS \"E\" INNER JOIN events AS \"e\" ON \"E\".id = \"e\".id", + expected_anchors: &[], + expected_details: &[], + }, + JsonParseTestCase { + description: "identical quoted names are reported as written", + dialect: "snowflake", + sql: "SELECT TRY_PARSE_JSON(\"E\".\"Value\"):a AS a, TRY_PARSE_JSON(\"E\".\"Value\"):b AS b FROM events AS \"E\"", + expected_anchors: &["TRY_PARSE_JSON"], + expected_details: &["TRY_PARSE_JSON(\"E\".\"Value\") is parsed 2 times in one SELECT."], + }, + JsonParseTestCase { + description: "bigquery quoted names keep backticks", + dialect: "bigquery", + sql: "SELECT JSON_VALUE(PARSE_JSON(e.`Value`), '$.a') AS a, JSON_VALUE(PARSE_JSON(e.`Value`), '$.b') AS b FROM events AS e", + expected_anchors: &["PARSE_JSON"], + expected_details: &["PARSE_JSON(e.`Value`) is parsed 2 times in one SELECT."], + }, + JsonParseTestCase { + description: "snowflake quoted upper case matches the folded unquoted name", + dialect: "snowflake", + sql: "SELECT TRY_PARSE_JSON(e.\"VALUE\"):a AS a, TRY_PARSE_JSON(e.value):b AS b FROM events AS e", + expected_anchors: &["TRY_PARSE_JSON"], + expected_details: &["TRY_PARSE_JSON(e.value) is parsed 2 times in one SELECT."], + }, + JsonParseTestCase { + description: "duckdb quoted names are case-insensitive", + dialect: "duckdb", + sql: "SELECT json(e.\"Value\") -> 'a' AS a, json(e.value) -> 'b' AS b FROM events AS e", + expected_anchors: &["json"], + expected_details: &["JSON(e.value) is parsed 2 times in one SELECT."], + }, + JsonParseTestCase { + description: "bigquery backticked names are case-insensitive", + dialect: "bigquery", + sql: "SELECT JSON_VALUE(PARSE_JSON(e.`Value`), '$.a') AS a, JSON_VALUE(PARSE_JSON(e.value), '$.b') AS b FROM events AS e", + expected_anchors: &["PARSE_JSON"], + expected_details: &["PARSE_JSON(e.value) is parsed 2 times in one SELECT."], + }, + JsonParseTestCase { + description: "databricks backticked names are case-insensitive", + dialect: "databricks", + sql: "SELECT try_parse_json(e.`v`) AS a, try_parse_json(e.V) AS b FROM events AS e", + expected_anchors: &["try_parse_json"], + expected_details: &["TRY_PARSE_JSON(e.V) is parsed 2 times in one SELECT."], + }, + JsonParseTestCase { + description: "references are grouped as written", + dialect: "snowflake", + sql: "SELECT TRY_PARSE_JSON(value):a AS a, TRY_PARSE_JSON(e.value):b AS b, TRY_PARSE_JSON(events.value):c AS c FROM events AS e", + expected_anchors: &[], + expected_details: &[], + }, + JsonParseTestCase { + description: "different columns pass", + dialect: "snowflake", + sql: "SELECT TRY_PARSE_JSON(e.payload):a AS a, TRY_PARSE_JSON(e.metadata):b AS b FROM events AS e", + expected_anchors: &[], + expected_details: &[], + }, + JsonParseTestCase { + description: "same column name on different tables passes", + dialect: "snowflake", + sql: "SELECT TRY_PARSE_JSON(o.payload):a AS a, TRY_PARSE_JSON(c.payload):b AS b FROM orders AS o INNER JOIN customers AS c ON o.customer_id = c.customer_id", + expected_anchors: &[], + expected_details: &[], + }, + JsonParseTestCase { + description: "different parse functions are not merged", + dialect: "snowflake", + sql: "SELECT PARSE_JSON(value):a AS a, TRY_PARSE_JSON(value):b AS b FROM events", + expected_anchors: &[], + expected_details: &[], + }, + JsonParseTestCase { + description: "one parse per CTE scope passes", + dialect: "snowflake", + sql: "WITH parsed AS (SELECT TRY_PARSE_JSON(value) AS payload FROM events), final AS (SELECT TRY_PARSE_JSON(value) AS payload FROM events) SELECT payload FROM final", + expected_anchors: &[], + expected_details: &[], + }, + JsonParseTestCase { + description: "nested SELECT is its own scope", + dialect: "snowflake", + sql: "SELECT TRY_PARSE_JSON(e.value):a AS a, (SELECT MAX(TRY_PARSE_JSON(x.value):b) FROM events AS x) AS b FROM events AS e", + expected_anchors: &[], + expected_details: &[], + }, + JsonParseTestCase { + description: "repeat inside a subquery is reported in that scope", + dialect: "snowflake", + sql: "SELECT e.order_id FROM events AS e WHERE e.order_id IN (SELECT TRY_PARSE_JSON(value):id FROM events WHERE TRY_PARSE_JSON(value):ok)", + expected_anchors: &["TRY_PARSE_JSON"], + expected_details: &["TRY_PARSE_JSON(value) is parsed 2 times in one SELECT."], + }, + JsonParseTestCase { + description: "union branches are separate scopes", + dialect: "snowflake", + sql: "SELECT TRY_PARSE_JSON(value) AS payload FROM events UNION ALL SELECT TRY_PARSE_JSON(value) AS payload FROM events", + expected_anchors: &[], + expected_details: &[], + }, + JsonParseTestCase { + description: "a repeat inside one CTE body is reported", + dialect: "snowflake", + sql: "WITH parsed AS (SELECT TRY_PARSE_JSON(value):a AS a, TRY_PARSE_JSON(value):b AS b FROM events) SELECT a, b FROM parsed", + expected_anchors: &["TRY_PARSE_JSON"], + expected_details: &["TRY_PARSE_JSON(value) is parsed 2 times in one SELECT."], + }, + JsonParseTestCase { + description: "non-column arguments pass", + dialect: "snowflake", + sql: "SELECT TRY_PARSE_JSON(value || '') AS a, TRY_PARSE_JSON(value || '') AS b FROM events", + expected_anchors: &[], + expected_details: &[], + }, + JsonParseTestCase { + description: "bigquery safe parse is matched", + dialect: "bigquery", + sql: "SELECT JSON_VALUE(SAFE.PARSE_JSON(e.value), '$.a') AS a, JSON_VALUE(SAFE.PARSE_JSON(e.value), '$.b') AS b FROM events AS e", + expected_anchors: &["PARSE_JSON"], + expected_details: &["SAFE.PARSE_JSON(e.value) is parsed 2 times in one SELECT."], + }, + JsonParseTestCase { + description: "databricks try parse is matched", + dialect: "databricks", + sql: "SELECT try_parse_json(value) AS a, try_parse_json(value) AS b FROM events", + expected_anchors: &["try_parse_json"], + expected_details: &["TRY_PARSE_JSON(value) is parsed 2 times in one SELECT."], + }, + JsonParseTestCase { + description: "duckdb json is matched", + dialect: "duckdb", + sql: "SELECT json(e.value)->>'a' AS a, json(e.value)->>'b' AS b FROM events AS e", + expected_anchors: &["json"], + expected_details: &["JSON(e.value) is parsed 2 times in one SELECT."], + }, + JsonParseTestCase { + description: "dialects without an equivalent are not checked", + dialect: "postgres", + sql: "SELECT CAST(value AS JSONB) AS a, CAST(value AS JSONB) AS b FROM events", + expected_anchors: &[], + expected_details: &[], + }, + ]; + for test_case in &test_cases { + let diagnostics: Vec = lint_dialect(test_case.sql, test_case.dialect, RULE)?; + assert_eq!( + anchors(test_case.sql, &diagnostics), + test_case.expected_anchors, + "{}", + test_case.description + ); + let details: Vec = diagnostics + .iter() + .map(|diagnostic| { + diagnostic["remediation"] + .as_str() + .unwrap_or_default() + .split_inclusive(". ") + .next() + .unwrap_or_default() + .trim_end() + .to_owned() + }) + .collect(); + assert_eq!( + details, test_case.expected_details, + "{}", + test_case.description + ); + assert!( + diagnostics + .iter() + .all(|diagnostic| diagnostic["fix"].is_null()), + "{}", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_wide_select_with_many_repeated_parses_when_linting_then_it_stays_linear() +-> Result<(), String> { + let test_cases = [JsonParseScaleTestCase { + description: "3,000 projections parsing 1,500 payload columns twice each", + column_count: 1_500, + expected_findings: 1_500, + expected_max_seconds: 10.0, + }]; + for test_case in &test_cases { + let started: Instant = Instant::now(); + let diagnostics: Vec = + lint_dialect(&wide_json_select(test_case.column_count), "duckdb", RULE)?; + let elapsed: f64 = started.elapsed().as_secs_f64(); + assert_eq!( + diagnostics.len(), + test_case.expected_findings, + "{}", + test_case.description + ); + assert!( + elapsed <= test_case.expected_max_seconds, + "{}: {elapsed:.2}s", + test_case.description + ); + } + Ok(()) +} diff --git a/crates/sqlclean/src/sql_quality/tests/test_quality_rules.rs b/crates/sqlclean/src/sql_quality/tests/test_quality_rules.rs new file mode 100644 index 0000000..7ad2349 --- /dev/null +++ b/crates/sqlclean/src/sql_quality/tests/test_quality_rules.rs @@ -0,0 +1,403 @@ +use serde_json::Value; + +use crate::sql_quality::tests::helpers::{anchors, fixed, lint}; +use crate::sql_quality::tests::test_types::{QualityRemediationTestCase, QualityRuleTestCase}; + +#[test] +fn given_quality_rule_inputs_when_linting_then_findings_and_fixes_match() -> Result<(), String> { + let test_cases = [ + QualityRuleTestCase { + description: "long literal is reported without a split fix", + sql: "SELECT 'pending orders awaiting shipment' AS label", + rule: "SQC044", + relation_keys: "{}", + expected_anchors: &["'pending orders awaiting shipment'"], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "short literal passes", + sql: "SELECT 'pending' AS label", + rule: "SQC044", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "typed literal is reported without a fix", + sql: "SELECT TIMESTAMP '2026-04-01 10:00:00.000000000' AS ordered_at", + rule: "SQC044", + relation_keys: "{}", + expected_anchors: &["'2026-04-01 10:00:00.000000000'"], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "ranking sort above the cap", + sql: "SELECT ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.a, o.b, o.order_id) AS rn FROM orders AS o", + rule: "SQC043", + relation_keys: "{}", + expected_anchors: &["ROW_NUMBER"], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "ranking sort within the cap", + sql: "SELECT RANK() OVER (ORDER BY o.a, o.order_id) AS rn FROM orders AS o", + rule: "SQC043", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "ordering covers a declared key", + sql: "SELECT ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.ordered_at, o.order_id) AS rn FROM orders AS o", + rule: "SQC018", + relation_keys: r#"{"orders": [["order_id"]]}"#, + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "ordering without a key is unproven", + sql: "SELECT ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.ordered_at) AS rn FROM orders AS o", + rule: "SQC018", + relation_keys: r#"{"orders": [["order_id"]]}"#, + expected_anchors: &["ROW_NUMBER"], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "key proven through a CTE and a one-to-one join", + sql: "WITH lines AS (SELECT l.line_id, l.order_id FROM order_lines AS l), final AS (SELECT l.line_id, o.customer_id, ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY l.line_id) AS rn FROM lines AS l INNER JOIN orders AS o ON l.order_id = o.order_id) SELECT f.line_id, f.customer_id, f.rn FROM final AS f", + rule: "SQC018", + relation_keys: r#"{"orders": [["order_id"]], "order_lines": [["line_id"]]}"#, + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "key lost through a fan-out join", + sql: "WITH final AS (SELECT o.order_id, ROW_NUMBER() OVER (ORDER BY o.order_id) AS rn FROM orders AS o INNER JOIN order_lines AS l ON l.order_id = o.order_id) SELECT f.order_id, f.rn FROM final AS f", + rule: "SQC018", + relation_keys: r#"{"orders": [["order_id"]], "order_lines": [["line_id"]]}"#, + expected_anchors: &["ROW_NUMBER"], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "GROUP BY grain is a key", + sql: "WITH totals AS (SELECT o.customer_id, SUM(o.amount) AS amount FROM orders AS o GROUP BY o.customer_id), final AS (SELECT t.customer_id, ROW_NUMBER() OVER (ORDER BY t.amount, t.customer_id) AS rn FROM totals AS t) SELECT f.customer_id, f.rn FROM final AS f", + rule: "SQC018", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "positional GROUP BY grain is a key", + sql: "WITH totals AS (SELECT o.customer_id, SUM(o.amount) AS amount FROM orders AS o GROUP BY 1), final AS (SELECT t.customer_id, ROW_NUMBER() OVER (ORDER BY t.amount, t.customer_id) AS rn FROM totals AS t) SELECT f.customer_id, f.rn FROM final AS f", + rule: "SQC018", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "a later rn = 1 filter makes the partition a key", + sql: "WITH ranked AS (SELECT o.order_id, o.customer_id, ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.order_id) AS rn FROM orders AS o), latest AS (SELECT r.customer_id, r.order_id FROM ranked AS r WHERE r.rn = 1), final AS (SELECT l.customer_id, ROW_NUMBER() OVER (ORDER BY l.customer_id) AS position FROM latest AS l) SELECT f.customer_id, f.position FROM final AS f", + rule: "SQC018", + relation_keys: r#"{"orders": [["order_id"]]}"#, + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "a QUALIFY window cannot prove itself", + sql: "SELECT o.customer_id, o.order_id FROM orders AS o QUALIFY ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.amount DESC) = 1", + rule: "SQC018", + relation_keys: r#"{"orders": [["order_id"]]}"#, + expected_anchors: &["ROW_NUMBER"], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "a QUALIFY partition is a key for later steps", + sql: "WITH latest AS (SELECT o.customer_id, o.order_id FROM orders AS o QUALIFY ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.order_id) = 1), final AS (SELECT l.customer_id, ROW_NUMBER() OVER (ORDER BY l.customer_id) AS position FROM latest AS l) SELECT f.customer_id, f.position FROM final AS f", + rule: "SQC018", + relation_keys: r#"{"orders": [["order_id"]]}"#, + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "RANK ties are deterministic", + sql: "SELECT RANK() OVER (ORDER BY o.ordered_at) AS rn FROM orders AS o", + rule: "SQC018", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "unused CTE output is removed", + sql: "WITH orders AS (SELECT o.order_id, o.status, o.note FROM raw_orders AS o), final AS (SELECT r.order_id, r.status FROM orders AS r) SELECT f.order_id, f.status FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &["o.note"], + expected_fixed_sql: Some( + "WITH orders AS (SELECT o.order_id, o.status FROM raw_orders AS o), final AS (SELECT r.order_id, r.status FROM orders AS r) SELECT f.order_id, f.status FROM final AS f", + ), + }, + QualityRuleTestCase { + description: "star pass-through into the final CTE keeps every column", + sql: "WITH orders AS (SELECT o.order_id, o.note FROM raw_orders AS o), final AS (SELECT * FROM orders) SELECT * FROM final", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "star pass-through follows the reader's own reads", + sql: "WITH orders AS (SELECT o.order_id, o.note FROM raw_orders AS o), staged AS (SELECT * FROM orders), final AS (SELECT s.order_id FROM staged AS s) SELECT f.order_id FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &["o.note"], + expected_fixed_sql: Some( + "WITH orders AS (SELECT o.order_id FROM raw_orders AS o), staged AS (SELECT * FROM orders), final AS (SELECT s.order_id FROM staged AS s) SELECT f.order_id FROM final AS f", + ), + }, + QualityRuleTestCase { + description: "a same-named cast of a source column is not a lateral read", + sql: "WITH orders AS (SELECT o.order_id, CAST(note AS VARCHAR) AS note, o.note || 'x' AS tagged FROM raw_orders AS o), final AS (SELECT r.order_id, r.tagged FROM orders AS r) SELECT f.order_id, f.tagged FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &["CAST(note AS VARCHAR) AS note"], + expected_fixed_sql: Some( + "WITH orders AS (SELECT o.order_id, o.note || 'x' AS tagged FROM raw_orders AS o), final AS (SELECT r.order_id, r.tagged FROM orders AS r) SELECT f.order_id, f.tagged FROM final AS f", + ), + }, + QualityRuleTestCase { + description: "an interpolated expression in a reader may read any column", + sql: "WITH orders AS (SELECT o.order_id, o.status FROM raw_orders AS o), final AS (SELECT r.order_id FROM orders AS r WHERE __sqlbuild_audit_parameter_0__) SELECT f.order_id FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "an interpolated output is never reported", + sql: "WITH orders AS (SELECT o.order_id, __SQB_LINT_0002 FROM raw_orders AS o), final AS (SELECT r.order_id FROM orders AS r) SELECT f.order_id FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "DISTINCT CTE reports without a fix", + sql: "WITH orders AS (SELECT DISTINCT o.order_id, o.note FROM raw_orders AS o), final AS (SELECT r.order_id FROM orders AS r) SELECT f.order_id FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &["o.note"], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "columns read in later join and filter clauses are used", + sql: "WITH orders AS (SELECT o.order_id, o.customer_id, o.status FROM raw_orders AS o), final AS (SELECT c.name FROM orders AS r INNER JOIN customers AS c ON r.customer_id = c.customer_id WHERE r.status = 'open' QUALIFY ROW_NUMBER() OVER (PARTITION BY r.order_id ORDER BY c.name) = 1) SELECT f.name FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "a column read only through a nested star is used", + sql: "WITH g AS (SELECT r.order_id, r.amount FROM raw_orders AS r), final AS (SELECT g.order_id, HASH(g.*) AS row_hash FROM g) SELECT f.order_id, f.row_hash FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "a whole-row reference reads every column", + sql: "WITH g AS (SELECT r.order_id, r.amount FROM raw_orders AS r), final AS (SELECT g.order_id, HASH(g) AS row_hash FROM g) SELECT f.order_id, f.row_hash FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "COUNT(DISTINCT row) reads every column", + sql: "WITH g AS (SELECT r.order_id, r.amount FROM raw_orders AS r), final AS (SELECT COUNT(DISTINCT g) AS n FROM g) SELECT f.n FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "COLUMNS(*) reads every column", + sql: "WITH g AS (SELECT r.order_id, r.amount FROM raw_orders AS r), final AS (SELECT g.order_id FROM g WHERE COLUMNS(*) IS NOT NULL) SELECT f.order_id FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "COLUMNS with a pattern reads every column", + sql: "WITH g AS (SELECT r.order_id, r.amount FROM raw_orders AS r), final AS (SELECT MAX(COLUMNS('order.*')) FROM g) SELECT * FROM final", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "DISTINCT star pass-through reads every column", + sql: "WITH g AS (SELECT r.order_id, r.amount FROM raw_orders AS r), staged AS (SELECT DISTINCT * FROM g), final AS (SELECT s.order_id FROM staged AS s) SELECT f.order_id FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "a positional ORDER BY over a star pass-through reads every column", + sql: "WITH g AS (SELECT r.order_id, r.amount FROM raw_orders AS r), staged AS (SELECT * FROM g ORDER BY 2 LIMIT 1), final AS (SELECT s.order_id FROM staged AS s) SELECT f.order_id FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "an output alias read in WHERE or GROUP BY is used", + sql: "WITH g AS (SELECT r.order_id, r.amount * 2 AS doubled, r.customer_id % 2 AS bucket FROM raw_orders AS r WHERE doubled > 1 GROUP BY r.order_id, doubled, bucket), final AS (SELECT g.order_id FROM g) SELECT f.order_id FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "GROUP BY ALL grouping column is reported without a fix", + sql: "WITH g AS (SELECT r.customer_id, r.status, SUM(r.amount) AS total FROM raw_orders AS r GROUP BY ALL), final AS (SELECT g.customer_id, g.total FROM g) SELECT f.customer_id, f.total FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &["r.status"], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "an aggregate in an ungrouped select is reported without a fix", + sql: "WITH g AS (SELECT 'all' AS label, COUNT(*) AS n FROM raw_orders AS r), final AS (SELECT g.label FROM g) SELECT f.label FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &["COUNT(*) AS n"], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "a set-returning projection is reported without a fix", + sql: "WITH g AS (SELECT r.order_id, UNNEST(r.tags) AS tag FROM raw_orders AS r), final AS (SELECT g.order_id FROM g) SELECT f.order_id FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &["UNNEST(r.tags) AS tag"], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "ORDER BY ALL with LIMIT is reported without a fix", + sql: "WITH g AS (SELECT r.order_id, r.customer_id FROM raw_orders AS r ORDER BY ALL LIMIT 1), final AS (SELECT g.order_id FROM g) SELECT f.order_id FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &["r.customer_id"], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "DISTINCT ON is reported without a fix", + sql: "WITH g AS (SELECT DISTINCT ON (r.customer_id) r.order_id, r.amount FROM raw_orders AS r ORDER BY r.customer_id, r.order_id), final AS (SELECT g.order_id FROM g) SELECT f.order_id FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &["r.amount"], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "a volatile projection is reported without a fix", + sql: "WITH g AS (SELECT r.order_id, RANDOM() AS draw FROM raw_orders AS r), final AS (SELECT g.order_id FROM g) SELECT f.order_id FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &["RANDOM() AS draw"], + expected_fixed_sql: None, + }, + QualityRuleTestCase { + description: "a deterministic scalar projection is removed", + sql: "WITH g AS (SELECT r.order_id, COALESCE(UPPER(r.status), 'none') AS label FROM raw_orders AS r), final AS (SELECT g.order_id FROM g) SELECT f.order_id FROM final AS f", + rule: "SQC042", + relation_keys: "{}", + expected_anchors: &["COALESCE(UPPER(r.status), 'none') AS label"], + expected_fixed_sql: Some( + "WITH g AS (SELECT r.order_id FROM raw_orders AS r), final AS (SELECT g.order_id FROM g) SELECT f.order_id FROM final AS f", + ), + }, + ]; + for test_case in &test_cases { + let diagnostics: Vec = lint(test_case)?; + assert_eq!( + anchors(test_case.sql, &diagnostics), + test_case.expected_anchors, + "{}", + test_case.description + ); + assert_eq!( + fixed(test_case.sql, &diagnostics).as_deref(), + test_case.expected_fixed_sql, + "{}", + test_case.description + ); + } + Ok(()) +} + +#[test] +fn given_threshold_findings_when_linting_then_remediation_shows_the_exact_setting() +-> Result<(), String> { + let test_cases = [ + QualityRemediationTestCase { + description: "long literal points at shortening it and the length setting", + sql: "SELECT 'pending orders awaiting shipment' AS label", + rule: "SQC044", + expected_fragments: &[ + "Shorten the literal", + "set this in sqlclean.toml:", + "the current value is 24", + "[rules.thresholds]\n max_literal_length = 32", + ], + }, + QualityRemediationTestCase { + description: "long ranking sort points at a row key and the sort setting", + sql: "SELECT ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.a, o.b, o.order_id) AS rn FROM orders AS o", + rule: "SQC043", + expected_fragments: &[ + "unique row key as the final tie-breaker", + "HASH(source_partition, source_offset, line_index) AS row_key", + "the current value is 2", + "[rules.thresholds]\n max_ranking_order_by = 3", + ], + }, + QualityRemediationTestCase { + description: "unproven ranking points at a unique tie-breaker and a built row key", + sql: "SELECT ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.a) AS rn FROM orders AS o", + rule: "SQC018", + expected_fragments: &[ + "cover a unique key of the ranked rows", + "a unique, non-null tie-breaker column", + "HASH(source_partition, source_offset, line_index) AS row_key", + ], + }, + ]; + for test_case in &test_cases { + let diagnostics: Vec = lint(&QualityRuleTestCase { + description: test_case.description, + sql: test_case.sql, + rule: test_case.rule, + relation_keys: "{}", + expected_anchors: &[], + expected_fixed_sql: None, + })?; + let remediation: &str = diagnostics + .first() + .and_then(|diagnostic| diagnostic["remediation"].as_str()) + .ok_or_else(|| format!("{}: no finding", test_case.description))?; + let present: Vec<&str> = test_case + .expected_fragments + .iter() + .copied() + .filter(|fragment| remediation.contains(fragment)) + .collect(); + assert_eq!( + present, test_case.expected_fragments, + "{}: {remediation}", + test_case.description + ); + } + Ok(()) +} diff --git a/crates/sqlclean/src/sql_quality/tests/test_quality_scale.rs b/crates/sqlclean/src/sql_quality/tests/test_quality_scale.rs new file mode 100644 index 0000000..46f8ad3 --- /dev/null +++ b/crates/sqlclean/src/sql_quality/tests/test_quality_scale.rs @@ -0,0 +1,43 @@ +use std::time::Instant; + +use serde_json::{Value, json}; + +use crate::sql_lint::main::engine::lint_json; +use crate::sql_quality::tests::helpers::wide_cte_chain; +use crate::sql_quality::tests::test_types::QualityScaleTestCase; + +#[test] +fn given_many_wide_ctes_with_findings_when_linting_then_quality_rules_stay_linear() +-> Result<(), String> { + let test_cases = [QualityScaleTestCase { + description: "1,500 wide CTEs, each dropping a column and ranking without a key", + cte_count: 1_500, + column_count: 24, + expected_findings: 2_998, + expected_max_seconds: 20.0, + }]; + for test_case in &test_cases { + let request: Value = json!({ + "version": 1, + "sql": wide_cte_chain(test_case.cte_count, test_case.column_count), + "dialect": "duckdb", + "enabled_rules": ["SQC018", "SQC042", "SQC043", "SQC044"], + }); + let started: Instant = Instant::now(); + let response: Value = serde_json::from_str(&lint_json(&request.to_string())?) + .map_err(|error| error.to_string())?; + let elapsed: f64 = started.elapsed().as_secs_f64(); + assert_eq!( + response["diagnostics"].as_array().map_or(0, Vec::len), + test_case.expected_findings, + "{}", + test_case.description + ); + assert!( + elapsed <= test_case.expected_max_seconds, + "{}: {elapsed:.2}s", + test_case.description + ); + } + Ok(()) +} diff --git a/crates/sqlclean/src/sql_quality/tests/test_types.rs b/crates/sqlclean/src/sql_quality/tests/test_types.rs new file mode 100644 index 0000000..bc80b42 --- /dev/null +++ b/crates/sqlclean/src/sql_quality/tests/test_types.rs @@ -0,0 +1,38 @@ +pub(crate) struct QualityRuleTestCase { + pub(crate) description: &'static str, + pub(crate) sql: &'static str, + pub(crate) rule: &'static str, + pub(crate) relation_keys: &'static str, + pub(crate) expected_anchors: &'static [&'static str], + pub(crate) expected_fixed_sql: Option<&'static str>, +} + +pub(crate) struct QualityScaleTestCase { + pub(crate) description: &'static str, + pub(crate) cte_count: usize, + pub(crate) column_count: usize, + pub(crate) expected_findings: usize, + pub(crate) expected_max_seconds: f64, +} + +pub(crate) struct QualityRemediationTestCase { + pub(crate) description: &'static str, + pub(crate) sql: &'static str, + pub(crate) rule: &'static str, + pub(crate) expected_fragments: &'static [&'static str], +} + +pub(crate) struct JsonParseTestCase { + pub(crate) description: &'static str, + pub(crate) dialect: &'static str, + pub(crate) sql: &'static str, + pub(crate) expected_anchors: &'static [&'static str], + pub(crate) expected_details: &'static [&'static str], +} + +pub(crate) struct JsonParseScaleTestCase { + pub(crate) description: &'static str, + pub(crate) column_count: usize, + pub(crate) expected_findings: usize, + pub(crate) expected_max_seconds: f64, +} diff --git a/crates/sqlclean/src/sql_quality/types.rs b/crates/sqlclean/src/sql_quality/types.rs new file mode 100644 index 0000000..717e03a --- /dev/null +++ b/crates/sqlclean/src/sql_quality/types.rs @@ -0,0 +1,6 @@ +use std::collections::BTreeSet; + +/// Column sets that are unique for one relation, by lower-cased output column name. +pub(crate) type Keys = Vec>; +/// A unique column set over a select's joined input, as `(source alias, column)` pairs. +pub(crate) type InputKey = BTreeSet<(String, String)>; diff --git a/crates/sqlclean/src/sql_tokens/_helpers/builtin_functions.rs b/crates/sqlclean/src/sql_tokens/_helpers/builtin_functions.rs new file mode 100644 index 0000000..852563f --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/_helpers/builtin_functions.rs @@ -0,0 +1,151 @@ +//! Function names a dialect recognises as built-ins, from the SQL library's own metadata. + +use std::collections::{HashMap, HashSet}; +use std::sync::LazyLock; + +use crate::sql_tokens::constants::CALL_SYNTAX_FUNCTIONS; +use polyglot_sql::{Dialect, DialectType, Expression}; +use polyglot_sql_function_catalogs::types::{builtin_arity, type_signature}; +use polyglot_sql_function_catalogs::{ + CatalogSink, FunctionNameCase, FunctionSignature, register_enabled_catalogs, +}; + +const DUCKDB_CATALOG: &str = "duckdb"; + +static CATALOG_FUNCTIONS: LazyLock>> = LazyLock::new(|| { + let mut sink = NameSink::default(); + register_enabled_catalogs(&mut sink); + sink.names +}); +static SNOWFLAKE_TYPED: LazyLock> = + LazyLock::new(|| parser_typed_names(DialectType::Snowflake)); +static BIGQUERY_TYPED: LazyLock> = + LazyLock::new(|| parser_typed_names(DialectType::BigQuery)); +static POSTGRES_TYPED: LazyLock> = + LazyLock::new(|| parser_typed_names(DialectType::PostgreSQL)); +static DATABRICKS_TYPED: LazyLock> = + LazyLock::new(|| parser_typed_names(DialectType::Databricks)); +static TSQL_TYPED: LazyLock> = + LazyLock::new(|| parser_typed_names(DialectType::TSQL)); +static GENERIC_TYPED: LazyLock> = + LazyLock::new(|| parser_typed_names(DialectType::Generic)); + +/// Return whether `upper_name(...)` calls a function the dialect defines. +pub(crate) fn is_builtin_function(dialect: DialectType, upper_name: &str) -> bool { + if CALL_SYNTAX_FUNCTIONS.contains(&upper_name) { + return true; + } + let lower_name = upper_name.to_ascii_lowercase(); + let typed = match dialect { + DialectType::DuckDB => return catalogued(DUCKDB_CATALOG, &lower_name), + DialectType::Snowflake => &SNOWFLAKE_TYPED, + DialectType::BigQuery => &BIGQUERY_TYPED, + DialectType::PostgreSQL => &POSTGRES_TYPED, + DialectType::Databricks => &DATABRICKS_TYPED, + DialectType::TSQL => &TSQL_TYPED, + _ => &GENERIC_TYPED, + }; + typed.contains(&lower_name) || catalogued(catalog_key(dialect), &lower_name) +} + +/// Every catalogued or parser-typed built-in function name of `dialect`, lower-cased. +#[cfg(any(test, feature = "test-support"))] +pub(crate) fn builtin_function_names(dialect: DialectType) -> Vec { + let typed: &HashSet = match dialect { + DialectType::DuckDB => &HashSet::new(), + DialectType::Snowflake => &SNOWFLAKE_TYPED, + DialectType::BigQuery => &BIGQUERY_TYPED, + DialectType::PostgreSQL => &POSTGRES_TYPED, + DialectType::Databricks => &DATABRICKS_TYPED, + DialectType::TSQL => &TSQL_TYPED, + _ => &GENERIC_TYPED, + }; + let key = match dialect { + DialectType::DuckDB => DUCKDB_CATALOG, + _ => catalog_key(dialect), + }; + let catalogued = CATALOG_FUNCTIONS.get(key).into_iter().flatten(); + let mut names: Vec = typed.iter().chain(catalogued).cloned().collect(); + names.sort_unstable(); + names.dedup(); + names +} + +fn catalog_key(dialect: DialectType) -> &'static str { + match dialect { + DialectType::Snowflake => "snowflake", + DialectType::BigQuery => "bigquery", + DialectType::PostgreSQL => "postgres", + _ => "", + } +} + +fn catalogued(key: &str, lower_name: &str) -> bool { + CATALOG_FUNCTIONS + .get(key) + .is_some_and(|names| names.contains(lower_name)) + || builtin_arity(key, lower_name).is_some() + || type_signature(key, lower_name).is_some() +} + +/// Catalogued function names whose call the dialect's parser turns into a specific expression. +fn parser_typed_names(dialect: DialectType) -> HashSet { + let parser = Dialect::get(dialect); + let mut typed: HashSet = HashSet::new(); + for name in CATALOG_FUNCTIONS.get(DUCKDB_CATALOG).into_iter().flatten() { + if is_typed_call(&parser, name) { + typed.insert(name.clone()); + } + } + typed +} + +fn is_typed_call(parser: &Dialect, lower_name: &str) -> bool { + if !lower_name + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_') + { + return false; + } + let Ok(statements) = parser.parse(&format!("SELECT {lower_name}(probe)")) else { + return false; + }; + let Some(Expression::Select(select)) = statements.first() else { + return false; + }; + select.expressions.first().is_some_and(|call| { + !matches!( + call, + Expression::Function(_) | Expression::Anonymous(_) | Expression::Column(_) + ) + }) +} + +#[derive(Default)] +struct NameSink { + names: HashMap<&'static str, HashSet>, +} + +impl CatalogSink for NameSink { + fn set_dialect_name_case(&mut self, _dialect: &'static str, _name_case: FunctionNameCase) {} + + fn set_function_name_case( + &mut self, + _dialect: &'static str, + _function_name: &str, + _name_case: FunctionNameCase, + ) { + } + + fn register( + &mut self, + dialect: &'static str, + function_name: &str, + _signatures: Vec, + ) { + self.names + .entry(dialect) + .or_default() + .insert(function_name.to_ascii_lowercase()); + } +} diff --git a/crates/sqlclean/src/sql_tokens/_helpers/mod.rs b/crates/sqlclean/src/sql_tokens/_helpers/mod.rs new file mode 100644 index 0000000..a2df8fb --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/_helpers/mod.rs @@ -0,0 +1,3 @@ +pub mod builtin_functions; +pub mod reserved_keywords; +pub mod token_context; diff --git a/crates/sqlclean/src/sql_tokens/_helpers/reserved_keywords.rs b/crates/sqlclean/src/sql_tokens/_helpers/reserved_keywords.rs new file mode 100644 index 0000000..ccf9004 --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/_helpers/reserved_keywords.rs @@ -0,0 +1,43 @@ +//! Per-dialect reserved keywords, which can never be unquoted identifiers in that dialect. + +use std::collections::HashSet; +use std::sync::LazyLock; + +use polyglot_sql::DialectType; + +static DUCKDB: LazyLock> = + LazyLock::new(|| keyword_set(include_str!("../reserved_keywords/duckdb.txt"))); +static POSTGRES: LazyLock> = + LazyLock::new(|| keyword_set(include_str!("../reserved_keywords/postgres.txt"))); +static BIGQUERY: LazyLock> = + LazyLock::new(|| keyword_set(include_str!("../reserved_keywords/bigquery.txt"))); +static SNOWFLAKE: LazyLock> = + LazyLock::new(|| keyword_set(include_str!("../reserved_keywords/snowflake.txt"))); +static TSQL: LazyLock> = + LazyLock::new(|| keyword_set(include_str!("../reserved_keywords/tsql.txt"))); +static DATABRICKS: LazyLock> = + LazyLock::new(|| keyword_set(include_str!("../reserved_keywords/databricks.txt"))); +static GENERIC: LazyLock> = + LazyLock::new(|| keyword_set(include_str!("../reserved_keywords/generic.txt"))); + +/// Return whether the upper-cased word is a reserved keyword of `dialect`. +pub(crate) fn is_reserved_keyword(dialect: DialectType, upper_word: &str) -> bool { + let keywords: &HashSet = match dialect { + DialectType::DuckDB => &DUCKDB, + DialectType::PostgreSQL => &POSTGRES, + DialectType::BigQuery => &BIGQUERY, + DialectType::Snowflake => &SNOWFLAKE, + DialectType::TSQL => &TSQL, + DialectType::Databricks => &DATABRICKS, + _ => &GENERIC, + }; + keywords.contains(upper_word) +} + +fn keyword_set(data: &str) -> HashSet { + data.lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(str::to_ascii_uppercase) + .collect() +} diff --git a/crates/sqlclean/src/sql_tokens/_helpers/token_context.rs b/crates/sqlclean/src/sql_tokens/_helpers/token_context.rs new file mode 100644 index 0000000..fabb747 --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/_helpers/token_context.rs @@ -0,0 +1,30 @@ +//! Positional context of one authored token among its neighbours. + +use polyglot_sql::DialectType; + +use crate::sql_tokens::_helpers::builtin_functions::is_builtin_function; +use crate::sql_tokens::_helpers::reserved_keywords::is_reserved_keyword; +use crate::sql_tokens::constants::CALL_OPENER; +use crate::sql_tokens::main::is_unquoted_word::is_unquoted_word; + +pub(crate) struct TokenContext<'a> { + pub(crate) raws: &'a [String], + pub(crate) uppers: &'a [String], + pub(crate) index: usize, +} + +impl TokenContext<'_> { + /// Whether the token is a reserved keyword or a built-in function name at a call site. + pub(crate) fn is_foldable(&self, dialect: DialectType) -> bool { + if !is_unquoted_word(&self.raws[self.index]) { + return false; + } + let upper = self.uppers[self.index].as_str(); + is_reserved_keyword(dialect, upper) + || (self.next() == Some(CALL_OPENER) && is_builtin_function(dialect, upper)) + } + + fn next(&self) -> Option<&str> { + self.uppers.get(self.index + 1).map(String::as_str) + } +} diff --git a/crates/sqlclean/src/sql_tokens/constants.rs b/crates/sqlclean/src/sql_tokens/constants.rs new file mode 100644 index 0000000..6036eb9 --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/constants.rs @@ -0,0 +1,35 @@ +//! Token positions that decide which authored words may change letter case. + +use polyglot_sql::tokens::TokenType; + +/// Column aliases after `AS` may be any keyword in DuckDB and PostgreSQL. +pub(crate) const ALIAS_KEYWORD: &str = "AS"; +/// The token that turns a preceding name into a function call. +pub(crate) const CALL_OPENER: &str = "("; +/// Field names after `.`, path keys after `:`, and parameter names after `@` or `$`. +pub(crate) const NAME_PREFIXES: [&str; 4] = [".", ":", "@", "$"]; +/// Built-in calls with keyword syntax inside their parentheses, which catalogues do not list. +pub const CALL_SYNTAX_FUNCTIONS: [&str; 6] = [ + "CAST", + "TRY_CAST", + "SAFE_CAST", + "EXTRACT", + "CONVERT", + "TRY_CONVERT", +]; + +pub(crate) const PROJECTION_ENDS: [TokenType; 13] = [ + TokenType::From, + TokenType::Where, + TokenType::Group, + TokenType::GroupBy, + TokenType::Having, + TokenType::Qualify, + TokenType::Order, + TokenType::OrderBy, + TokenType::Limit, + TokenType::Window, + TokenType::Union, + TokenType::Except, + TokenType::Intersect, +]; diff --git a/crates/sqlclean/src/sql_tokens/main/builtin_function_names.rs b/crates/sqlclean/src/sql_tokens/main/builtin_function_names.rs new file mode 100644 index 0000000..ec0c9e4 --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/main/builtin_function_names.rs @@ -0,0 +1,6 @@ +use polyglot_sql::DialectType; + +/// Every catalogued or parser-typed built-in function name of `dialect`, lower-cased. +pub fn builtin_function_names(dialect: DialectType) -> Vec { + crate::sql_tokens::_helpers::builtin_functions::builtin_function_names(dialect) +} diff --git a/crates/sqlclean/src/sql_tokens/main/canonical_tokens.rs b/crates/sqlclean/src/sql_tokens/main/canonical_tokens.rs new file mode 100644 index 0000000..d1bfb83 --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/main/canonical_tokens.rs @@ -0,0 +1,25 @@ +//! Canonical token stream of SQL text under one dialect tokenizer. + +use polyglot_sql::Dialect; +use polyglot_sql::tokens::Token; + +use crate::sql_tokens::main::case_folding::foldable_tokens; +use crate::sql_tokens::main::token_texts::token_texts; +use crate::sql_tokens::models::CanonicalToken; + +/// Drop whitespace and comments from tokenized `sql` and upper-case case-insensitive tokens. +pub fn canonical_tokens( + sql: &str, + tokens: &[Token], + dialect: &Dialect, +) -> Result, String> { + let raws = token_texts(sql, tokens)?; + let foldable = foldable_tokens(&raws, tokens, dialect); + Ok(raws + .into_iter() + .zip(foldable) + .map(|(raw, fold)| CanonicalToken { + text: if fold { raw.to_ascii_uppercase() } else { raw }, + }) + .collect()) +} diff --git a/crates/sqlclean/src/sql_tokens/main/case_folding.rs b/crates/sqlclean/src/sql_tokens/main/case_folding.rs new file mode 100644 index 0000000..fc29290 --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/main/case_folding.rs @@ -0,0 +1,23 @@ +//! Which authored tokens are case-insensitive keywords or built-in function names. + +use polyglot_sql::Dialect; +use polyglot_sql::tokens::Token; + +use crate::sql_tokens::_helpers::token_context::TokenContext; +use crate::sql_tokens::main::name_positions::name_positions; + +/// Return, for every token, whether the formatter may recase it and the fingerprint folds it. +pub fn foldable_tokens(raws: &[String], tokens: &[Token], dialect: &Dialect) -> Vec { + let uppers: Vec = raws.iter().map(|raw| raw.to_ascii_uppercase()).collect(); + let names = name_positions(&uppers); + let mut foldable = Vec::with_capacity(tokens.len()); + for (index, name) in names.iter().enumerate().take(tokens.len()) { + let context = TokenContext { + raws, + uppers: &uppers, + index, + }; + foldable.push(!name && context.is_foldable(dialect.dialect_type())); + } + foldable +} diff --git a/crates/sqlclean/src/sql_tokens/main/is_unquoted_word.rs b/crates/sqlclean/src/sql_tokens/main/is_unquoted_word.rs new file mode 100644 index 0000000..6ea92ef --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/main/is_unquoted_word.rs @@ -0,0 +1,11 @@ +//! Unquoted SQL word recognition shared by canonical tokens and the formatter. + +/// Return whether `text` is an unquoted ASCII word whose letter case carries no meaning. +pub fn is_unquoted_word(text: &str) -> bool { + let mut characters = text.chars(); + characters + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == '_') + && characters + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '$')) +} diff --git a/crates/sqlclean/src/sql_tokens/main/mod.rs b/crates/sqlclean/src/sql_tokens/main/mod.rs new file mode 100644 index 0000000..02cb65b --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/main/mod.rs @@ -0,0 +1,10 @@ +#[cfg(any(test, feature = "test-support"))] +pub mod builtin_function_names; +pub mod canonical_tokens; +pub mod case_folding; +pub mod is_unquoted_word; +pub mod name_positions; +pub mod projection_spans; +#[cfg(any(test, feature = "test-support"))] +pub mod query_fingerprint; +pub mod token_texts; diff --git a/crates/sqlclean/src/sql_tokens/main/name_positions.rs b/crates/sqlclean/src/sql_tokens/main/name_positions.rs new file mode 100644 index 0000000..b056a9b --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/main/name_positions.rs @@ -0,0 +1,16 @@ +//! Token positions where some dialect accepts even a reserved word as an unquoted name. + +use crate::sql_tokens::constants::{ALIAS_KEYWORD, NAME_PREFIXES}; + +/// Return, for every upper-cased token, whether it follows `.`, `:`, `@`, `$` or `AS`. +pub fn name_positions(uppers: &[String]) -> Vec { + let mut positions = Vec::with_capacity(uppers.len()); + let mut previous: Option<&str> = None; + for upper in uppers { + positions.push( + previous.is_some_and(|word| word == ALIAS_KEYWORD || NAME_PREFIXES.contains(&word)), + ); + previous = Some(upper.as_str()); + } + positions +} diff --git a/crates/sqlclean/src/sql_tokens/main/projection_spans.rs b/crates/sqlclean/src/sql_tokens/main/projection_spans.rs new file mode 100644 index 0000000..5965bd7 --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/main/projection_spans.rs @@ -0,0 +1,41 @@ +//! Authored spans of the top-level items of one select list. + +use polyglot_sql::tokens::{Token, TokenType}; + +use crate::sql_tokens::constants::PROJECTION_ENDS; + +/// Spans of each top-level projection item of the select starting at token `select`. +pub fn projection_spans(tokens: &[Token], select: usize) -> Vec<(usize, usize)> { + let mut spans: Vec<(usize, usize)> = Vec::new(); + let mut depth: usize = 0; + let mut start: Option = None; + let mut end: usize = 0; + for token in tokens.iter().skip(select + 1) { + if matches!( + token.token_type, + TokenType::Space | TokenType::Break | TokenType::LineComment | TokenType::BlockComment + ) { + continue; + } + match token.token_type { + TokenType::LParen => depth += 1, + TokenType::RParen if depth == 0 => break, + TokenType::RParen => depth -= 1, + TokenType::Comma if depth == 0 => { + if let Some(first) = start.take() { + spans.push((first, end)); + } + continue; + } + kind if depth == 0 && PROJECTION_ENDS.contains(&kind) => break, + TokenType::Distinct | TokenType::All if depth == 0 && start.is_none() => continue, + _ => {} + } + start.get_or_insert(token.span.start); + end = token.span.end; + } + if let Some(first) = start { + spans.push((first, end)); + } + spans +} diff --git a/crates/sqlclean/src/sql_tokens/main/query_fingerprint.rs b/crates/sqlclean/src/sql_tokens/main/query_fingerprint.rs new file mode 100644 index 0000000..4cf7751 --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/main/query_fingerprint.rs @@ -0,0 +1,25 @@ +//! Whitespace-, comment- and keyword-case-insensitive SQL change fingerprints. + +use std::str::FromStr; + +use polyglot_sql::{Dialect, DialectType}; +use sha2::{Digest, Sha256}; + +use crate::sql_tokens::main::canonical_tokens::canonical_tokens; + +/// Return the SHA-256 hex digest of the canonical token stream of `sql` under `dialect_name`. +pub fn query_fingerprint(sql: &str, dialect_name: &str) -> Result { + let dialect_type = DialectType::from_str(dialect_name) + .map_err(|_| format!("unknown SQL fingerprint dialect '{dialect_name}'"))?; + let dialect = Dialect::get(dialect_type); + let mut digest = Sha256::new(); + let tokens = dialect + .tokenize(sql) + .map_err(|error| format!("SQL tokenization failed: {error}"))?; + for token in canonical_tokens(sql, &tokens, &dialect)? { + digest.update(token.text.len().to_string().as_bytes()); + digest.update(b":"); + digest.update(token.text.as_bytes()); + } + Ok(format!("{:x}", digest.finalize())) +} diff --git a/crates/sqlclean/src/sql_tokens/main/token_texts.rs b/crates/sqlclean/src/sql_tokens/main/token_texts.rs new file mode 100644 index 0000000..8467369 --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/main/token_texts.rs @@ -0,0 +1,16 @@ +//! Authored text of tokens, taken from their character spans. + +use polyglot_sql::tokens::Token; + +/// Return the authored text of every token in `sql`. +pub fn token_texts(sql: &str, tokens: &[Token]) -> Result, String> { + let characters: Vec = sql.chars().collect(); + let mut texts = Vec::with_capacity(tokens.len()); + for token in tokens { + let slice = characters + .get(token.span.start..token.span.end) + .ok_or_else(|| "SQL tokenizer returned an invalid token span".to_string())?; + texts.push(slice.iter().collect()); + } + Ok(texts) +} diff --git a/crates/sqlclean/src/sql_tokens/mod.rs b/crates/sqlclean/src/sql_tokens/mod.rs new file mode 100644 index 0000000..6b100c6 --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/mod.rs @@ -0,0 +1,9 @@ +//! The canonical SQL token stream: authored tokens without layout or comments. + +pub mod _helpers; +pub mod constants; +pub mod main; +pub mod models; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlclean/src/sql_tokens/models.rs b/crates/sqlclean/src/sql_tokens/models.rs new file mode 100644 index 0000000..b18ebb1 --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/models.rs @@ -0,0 +1,7 @@ +//! Canonical token values shared by the formatter invariant and change fingerprints. + +/// One authored token with layout removed: keywords and built-in function names upper-cased. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CanonicalToken { + pub text: String, +} diff --git a/crates/sqlclean/src/sql_tokens/reserved_keywords/bigquery.txt b/crates/sqlclean/src/sql_tokens/reserved_keywords/bigquery.txt new file mode 100644 index 0000000..ded970a --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/reserved_keywords/bigquery.txt @@ -0,0 +1,99 @@ +# BigQuery (GoogleSQL) reserved keywords: 'Reserved keywords' in +# https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical +all +and +any +array +as +asc +assert_rows_modified +at +between +by +case +cast +collate +contains +create +cross +cube +current +default +define +desc +distinct +else +end +enum +escape +except +exclude +exists +extract +false +fetch +following +for +from +full +graph_table +group +grouping +groups +hash +having +if +ignore +in +inner +intersect +interval +into +is +join +lateral +left +like +limit +lookup +merge +natural +new +no +not +null +nulls +of +on +or +order +outer +over +partition +preceding +proto +qualify +range +recursive +respect +right +rollup +rows +select +set +some +struct +tablesample +then +to +treat +true +unbounded +union +unnest +using +when +where +window +with +within diff --git a/crates/sqlclean/src/sql_tokens/reserved_keywords/databricks.txt b/crates/sqlclean/src/sql_tokens/reserved_keywords/databricks.txt new file mode 100644 index 0000000..dc43281 --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/reserved_keywords/databricks.txt @@ -0,0 +1,4 @@ +# Databricks reserves no keywords as identifiers: +# https://docs.databricks.com/sql/language-manual/sql-ref-reserved-words +# ('Databricks does not formally disallow any specific literals from being used as identifiers'). + diff --git a/crates/sqlclean/src/sql_tokens/reserved_keywords/duckdb.txt b/crates/sqlclean/src/sql_tokens/reserved_keywords/duckdb.txt new file mode 100644 index 0000000..4374c4e --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/reserved_keywords/duckdb.txt @@ -0,0 +1,77 @@ +# DuckDB reserved keywords: SELECT keyword_name FROM duckdb_keywords() +# WHERE keyword_category = 'reserved' (DuckDB 1.5.2). +all +analyse +analyze +and +any +array +as +asc +asymmetric +both +case +cast +check +collate +column +constraint +create +default +deferrable +desc +describe +distinct +do +else +end +except +false +fetch +for +foreign +from +group +having +in +initially +intersect +into +lambda +lateral +leading +limit +not +null +offset +on +only +or +order +pivot +pivot_longer +pivot_wider +placing +primary +qualify +references +returning +select +show +some +summarize +symmetric +table +then +to +trailing +true +union +unique +unpivot +using +variadic +when +where +window +with diff --git a/crates/sqlclean/src/sql_tokens/reserved_keywords/generic.txt b/crates/sqlclean/src/sql_tokens/reserved_keywords/generic.txt new file mode 100644 index 0000000..05f8ee4 --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/reserved_keywords/generic.txt @@ -0,0 +1,28 @@ +# Generic dialect: keywords reserved in every dialect file above except Databricks +# (DuckDB, PostgreSQL, BigQuery, Snowflake and T-SQL). +all +and +any +as +create +distinct +else +for +from +group +having +in +intersect +into +not +null +on +or +order +select +some +then +to +union +where +with diff --git a/crates/sqlclean/src/sql_tokens/reserved_keywords/postgres.txt b/crates/sqlclean/src/sql_tokens/reserved_keywords/postgres.txt new file mode 100644 index 0000000..7a57911 --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/reserved_keywords/postgres.txt @@ -0,0 +1,80 @@ +# PostgreSQL reserved keywords (non-function): RESERVED_KEYWORD entries of +# src/include/parser/kwlist.h (REL_17_STABLE); Appendix C 'reserved'. +all +analyse +analyze +and +any +array +as +asc +asymmetric +both +case +cast +check +collate +column +constraint +create +current_catalog +current_date +current_role +current_time +current_timestamp +current_user +default +deferrable +desc +distinct +do +else +end +except +false +fetch +for +foreign +from +grant +group +having +in +initially +intersect +into +lateral +leading +limit +localtime +localtimestamp +not +null +offset +on +only +or +order +placing +primary +references +returning +select +session_user +some +symmetric +system_user +table +then +to +trailing +true +union +unique +user +using +variadic +when +where +window +with diff --git a/crates/sqlclean/src/sql_tokens/reserved_keywords/snowflake.txt b/crates/sqlclean/src/sql_tokens/reserved_keywords/snowflake.txt new file mode 100644 index 0000000..511b646 --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/reserved_keywords/snowflake.txt @@ -0,0 +1,65 @@ +# Snowflake reserved keywords: entries marked 'Reserved by ANSI', 'Reserved by Snowflake' +# or 'Reserved by Snowflake and others' in https://docs.snowflake.com/en/sql-reference/reserved-keywords. +# Limited keywords (usable as identifiers in some positions) are excluded. +all +alter +and +any +as +between +by +check +column +connect +create +current +delete +distinct +drop +else +exists +following +for +from +grant +group +having +ilike +in +increment +insert +intersect +into +is +like +minus +not +null +of +on +or +order +qualify +regexp +revoke +rlike +row +rows +sample +select +set +some +start +table +tablesample +then +to +trigger +union +unique +update +values +whenever +where +window +with diff --git a/crates/sqlclean/src/sql_tokens/reserved_keywords/tsql.txt b/crates/sqlclean/src/sql_tokens/reserved_keywords/tsql.txt new file mode 100644 index 0000000..b83a35b --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/reserved_keywords/tsql.txt @@ -0,0 +1,186 @@ +# T-SQL reserved keywords (SQL Server and Azure Synapse table) in +# https://learn.microsoft.com/sql/t-sql/language-elements/reserved-keywords-transact-sql +add +all +alter +and +any +as +asc +authorization +backup +begin +between +break +browse +bulk +by +cascade +case +check +checkpoint +close +clustered +coalesce +collate +column +commit +compute +constraint +contains +containstable +continue +convert +create +cross +current +current_date +current_time +current_timestamp +current_user +cursor +database +dbcc +deallocate +declare +default +delete +deny +desc +disk +distinct +distributed +double +drop +dump +else +end +errlvl +escape +except +exec +execute +exists +exit +external +fetch +file +fillfactor +for +foreign +freetext +freetexttable +from +full +function +goto +grant +group +having +holdlock +identity +identity_insert +identitycol +if +in +index +inner +insert +intersect +into +is +join +key +kill +left +like +lineno +load +merge +national +nocheck +nonclustered +not +null +nullif +of +off +offsets +on +open +opendatasource +openquery +openrowset +openxml +option +or +order +outer +over +percent +pivot +plan +precision +primary +print +proc +procedure +public +raiserror +read +readtext +reconfigure +references +replication +restore +restrict +return +revert +revoke +right +rollback +rowcount +rowguidcol +rule +save +schema +securityaudit +select +semantickeyphrasetable +semanticsimilaritydetailstable +semanticsimilaritytable +session_user +set +setuser +shutdown +some +statistics +system_user +table +tablesample +textsize +then +to +top +tran +transaction +trigger +truncate +try_convert +tsequal +union +unique +unpivot +update +updatetext +use +user +values +varying +view +waitfor +when +where +while +with +writetext diff --git a/crates/sqlclean/src/sql_tokens/tests.rs b/crates/sqlclean/src/sql_tokens/tests.rs new file mode 100644 index 0000000..5468fc5 --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/tests.rs @@ -0,0 +1,4 @@ +#[path = "tests/test_canonical_tokens.rs"] +mod canonical_tokens; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlclean/src/sql_tokens/tests/test_canonical_tokens.rs b/crates/sqlclean/src/sql_tokens/tests/test_canonical_tokens.rs new file mode 100644 index 0000000..2dec3bf --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/tests/test_canonical_tokens.rs @@ -0,0 +1,218 @@ +use polyglot_sql::{Dialect, DialectType}; + +use crate::sql_tokens::main::canonical_tokens::canonical_tokens; +use crate::sql_tokens::tests::test_types::CanonicalTokensTestCase; + +#[test] +fn given_sql_pairs_when_canonicalizing_then_only_layout_comments_and_word_case_are_ignored() +-> Result<(), String> { + let test_cases = [ + CanonicalTokensTestCase { + description: "whitespace between tokens is ignored", + dialect: DialectType::DuckDB, + left: "select a,b from orders", + right: "SELECT\n a, b\nFROM orders", + expected_equal: true, + }, + CanonicalTokensTestCase { + description: "comments are ignored", + dialect: DialectType::Snowflake, + left: "select a -- note\nfrom orders /* block */", + right: "select a from orders", + expected_equal: true, + }, + CanonicalTokensTestCase { + description: "keyword and built-in function case is ignored", + dialect: DialectType::Snowflake, + left: "select count(*) from orders", + right: "SELECT COUNT(*) FROM orders", + expected_equal: true, + }, + CanonicalTokensTestCase { + description: "unquoted identifier case is significant", + dialect: DialectType::Snowflake, + left: "select count(*) from orders", + right: "SELECT COUNT(*) FROM ORDERS", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "Snowflake path key case is significant", + dialect: DialectType::Snowflake, + left: "select payload:customerId from t", + right: "select payload:customerid from t", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "BigQuery qualified table case is significant", + dialect: DialectType::BigQuery, + left: "select * from ds.Orders", + right: "select * from ds.orders", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "DuckDB alias case is significant", + dialect: DialectType::DuckDB, + left: "select 1 as Foo", + right: "select 1 as foo", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "keyword-named qualified name case is significant", + dialect: DialectType::BigQuery, + left: "select * from inventory.view", + right: "select * from inventory.VIEW", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "keyword-named alias case is significant", + dialect: DialectType::DuckDB, + left: "select pos as index from t", + right: "select pos AS INDEX from t", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "user-defined function case is significant", + dialect: DialectType::DuckDB, + left: "select my_udf(a) from t", + right: "select MY_UDF(a) from t", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "built-in function case is ignored", + dialect: DialectType::DuckDB, + left: "select coalesce(a, 0) from t", + right: "select COALESCE(a, 0) from t", + expected_equal: true, + }, + CanonicalTokensTestCase { + description: "Snowflake double-slash comments are ignored", + dialect: DialectType::Snowflake, + left: "select a // note\nfrom t", + right: "select a from t", + expected_equal: true, + }, + CanonicalTokensTestCase { + description: "BigQuery hash comments are ignored", + dialect: DialectType::BigQuery, + left: "select a # note\nfrom t", + right: "select a from t", + expected_equal: true, + }, + CanonicalTokensTestCase { + description: "DuckDB struct-literal key case is significant", + dialect: DialectType::DuckDB, + left: "select {Key: 1}", + right: "select {key: 1}", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "a non-reserved keyword operand before an operator keeps its case", + dialect: DialectType::DuckDB, + left: "select a, Rows ^ 2 from t", + right: "select a, rows ^ 2 from t", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "a non-reserved keyword table in a comma join keeps its case", + dialect: DialectType::BigQuery, + left: "select * from ds.a, View v", + right: "select * from ds.a, view v", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "a reserved word after a dot is a field name", + dialect: DialectType::BigQuery, + left: "select abc.Group from abc", + right: "select abc.group from abc", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "a reserved keyword column alias keeps its case", + dialect: DialectType::DuckDB, + left: "select 1 as Select", + right: "select 1 as select", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "reserved keyword case is ignored", + dialect: DialectType::DuckDB, + left: "select a from t where a in (1) limit 1", + right: "SELECT a FROM t WHERE a IN (1) LIMIT 1", + expected_equal: true, + }, + CanonicalTokensTestCase { + description: "Databricks reserves no keywords", + dialect: DialectType::Databricks, + left: "select a from t", + right: "SELECT a FROM t", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "whitespace inside a string literal is significant", + dialect: DialectType::DuckDB, + left: "select 'a b' as code", + right: "select 'a b' as code", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "string literal case is significant", + dialect: DialectType::DuckDB, + left: "select 'Open' as status", + right: "select 'OPEN' as status", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "quoted identifier case is significant", + dialect: DialectType::Snowflake, + left: "select \"Order\" from orders", + right: "select \"ORDER\" from orders", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "dollar-quoted literal content is significant", + dialect: DialectType::PostgreSQL, + left: "select $$a b$$ as note", + right: "select $$a b$$ as note", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "number spelling is significant", + dialect: DialectType::DuckDB, + left: "select 1.0 as amount", + right: "select 1.00 as amount", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "a renamed function is significant", + dialect: DialectType::Snowflake, + left: "select startswith(code, 'EU')", + right: "select starts_with(code, 'EU')", + expected_equal: false, + }, + CanonicalTokensTestCase { + description: "an added alias keyword is significant", + dialect: DialectType::DuckDB, + left: "select a b from orders", + right: "select a as b from orders", + expected_equal: false, + }, + ]; + for test_case in test_cases { + let dialect = Dialect::get(test_case.dialect); + let left_tokens = dialect + .tokenize(test_case.left) + .map_err(|error| error.to_string())?; + let right_tokens = dialect + .tokenize(test_case.right) + .map_err(|error| error.to_string())?; + let left = canonical_tokens(test_case.left, &left_tokens, &dialect)?; + let right = canonical_tokens(test_case.right, &right_tokens, &dialect)?; + assert_eq!( + left == right, + test_case.expected_equal, + "{}", + test_case.description + ); + } + Ok(()) +} diff --git a/crates/sqlclean/src/sql_tokens/tests/test_types.rs b/crates/sqlclean/src/sql_tokens/tests/test_types.rs new file mode 100644 index 0000000..2e7671d --- /dev/null +++ b/crates/sqlclean/src/sql_tokens/tests/test_types.rs @@ -0,0 +1,7 @@ +pub(crate) struct CanonicalTokensTestCase { + pub(crate) description: &'static str, + pub(crate) dialect: polyglot_sql::DialectType, + pub(crate) left: &'static str, + pub(crate) right: &'static str, + pub(crate) expected_equal: bool, +} diff --git a/fensu.toml b/fensu.toml new file mode 100644 index 0000000..7443fe2 --- /dev/null +++ b/fensu.toml @@ -0,0 +1,53 @@ +[targets.python] +analyzer = "python" +roots = ["src/sqlclean"] +tests = ["tests"] +tooling = ["scripts"] +select = ["FF"] + +[[targets.python.rule_exceptions]] +rule = "FFL105" +path = "src/sqlclean/hosting/main/serve.py" +reason = "The native sqlclean command starts the custom Rule host with `python -c` and imports this entry by path." + +[targets.rust] +analyzer = "rust" +roots = ["crates"] +tests = [] +tooling = [] +rule_packs = ["rust"] +select = ["FPRS"] + +[[targets.rust.rule_exceptions]] +rule = "FPRSL105" +path = "crates/sqlclean/src/sql_lint/main/engine.rs" +reason = "Integration surface: SQLBuild's Python bindings lint through `lint_json`; the sqlclean CLI uses `lint` instead." + +[[targets.rust.rule_exceptions]] +rule = "FPRSL105" +path = "crates/sqlclean/src/sql_lint/main/batch_engine.rs" +reason = "Integration surface: SQLBuild's lint_text domain lints many SQL texts in one call through `lint_batch_json`." + +[[targets.rust.rule_exceptions]] +rule = "FPRSL105" +path = "crates/sqlclean/src/sql_lint/main/formatter.rs" +reason = "Integration surface: SQLBuild's Python bindings format through `format_json`." + +[[targets.rust.rule_exceptions]] +rule = "FPRSL105" +path = "crates/sqlclean/src/sql_quality/main/declared_relation_keys.rs" +reason = "Integration surface: SQLBuild's compiled views derive the relation keys its models declare through this entry." + +[[targets.rust.rule_exceptions]] +rule = "FPRSL105" +path = "crates/sqlclean/src/sql_tokens/main/builtin_function_names.rs" +reason = "This entry exists for the sql_lint tests, which the analyzer does not count as importers." + +[[targets.rust.rule_exceptions]] +rule = "FPRSL105" +path = "crates/sqlclean/src/sql_tokens/main/query_fingerprint.rs" +reason = "This test-support entry checks that fixes preserve meaning in the sql_lint tests, which the analyzer does not count as importers." + +[targets.rust.rule_options.FPRSR311] +# `skill/` is the agent skill the `sqlclean skills` command embeds; scripts.generate_docs_skill writes its references. +allowed_entries = ["Cargo.toml", "build.rs", "src/", "tests/", "benches/", "examples/", "README*", "LICENSE*", "skill/"] diff --git a/packages/sqlclean-cli/README.md b/packages/sqlclean-cli/README.md new file mode 100644 index 0000000..3521b68 --- /dev/null +++ b/packages/sqlclean-cli/README.md @@ -0,0 +1,3 @@ +# sqlclean-cli + +The native `sqlclean` executable. Install [`sqlclean`](https://pypi.org/project/sqlclean/), which depends on this package and adds the Python API for custom Rules. diff --git a/packages/sqlclean-cli/pyproject.toml b/packages/sqlclean-cli/pyproject.toml new file mode 100644 index 0000000..8fbd46e --- /dev/null +++ b/packages/sqlclean-cli/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["maturin>=1.9,<2"] +build-backend = "maturin" + +[project] +name = "sqlclean-cli" +version = "0.0.1" # x-release-please-version +description = "Native command-line executable for SQLClean" +readme = "README.md" +requires-python = ">=3.12" +license = "Apache-2.0" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Rust", + "Topic :: Software Development :: Quality Assurance", +] + +[project.urls] +Homepage = "https://github.com/sqlstack-io/sqlclean" +Issues = "https://github.com/sqlstack-io/sqlclean/issues" +Repository = "https://github.com/sqlstack-io/sqlclean" + +[tool.maturin] +bindings = "bin" +manifest-path = "../../crates/sqlclean-cli/Cargo.toml" + +[tool.uv] +cache-keys = [ + { file = "pyproject.toml" }, + { file = "../../Cargo.toml" }, + { file = "../../Cargo.lock" }, + { file = "../../crates/*/Cargo.toml" }, + { file = "../../crates/*/src/**/*.rs" }, + { file = "../../crates/sqlclean-cli/skill/**/*.md" }, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..60ae1a5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,85 @@ +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[project] +name = "sqlclean" +version = "0.0.1" +description = "Fast, opinionated SQL linting and deterministic formatting, with custom Rules in Python" +readme = "README.md" +requires-python = ">=3.12" +license = "Apache-2.0" +license-files = ["LICENSE"] +keywords = ["sql", "lint", "linter", "formatter", "duckdb", "snowflake"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Rust", + "Programming Language :: SQL", + "Topic :: Software Development :: Quality Assurance", +] +dependencies = [ + "sqlclean-cli==0.0.1", # x-release-please-version +] + +[project.optional-dependencies] +ast = [ + "polyglot-sql-chio==0.13.10", +] + +[project.urls] +Homepage = "https://github.com/sqlstack-io/sqlclean" +Issues = "https://github.com/sqlstack-io/sqlclean/issues" +Repository = "https://github.com/sqlstack-io/sqlclean" + +[tool.hatch.build.targets.wheel] +packages = ["src/sqlclean"] + +[dependency-groups] +dev = [ + "commitizen>=4.9.1", + "fensu>=0.28.0", + "pre-commit>=4.3.0", + "pytest>=8.4.2", + "pytest-xdist>=3.8.0", + "ruff>=0.12.0", + "ty>=0.0.1a19", +] + +[tool.uv] +cache-keys = [ + { file = "pyproject.toml" }, +] + +[tool.uv.sources] +sqlclean-cli = { path = "packages/sqlclean-cli" } + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = ["--import-mode=importlib"] +pythonpath = ["."] + +[tool.ruff] +target-version = "py312" +line-length = 100 +# Generated from the website docs by `make skills`; ruff would reformat their Python examples. +extend-exclude = ["crates/sqlclean-cli/skill"] + +[tool.ruff.lint] +select = ["B", "E", "F", "I", "PLR0913", "PLR0915", "UP"] + +[tool.ruff.lint.pylint] +max-args = 10 +max-statements = 70 + +[tool.ruff.lint.per-file-ignores] +"tests/**/test_*.py" = ["E501"] + +[tool.commitizen] +name = "cz_conventional_commits" +version_provider = "pep621" +version_scheme = "semver2" +update_changelog_on_bump = false diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..e6eeb12 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,75 @@ +{ + "packages": { + ".": { + "release-type": "python", + "package-name": "sqlclean", + "include-v-in-tag": true, + "include-component-in-tag": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "changelog-sections": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "revert", + "section": "Bug Fixes" + }, + { + "type": "perf", + "section": "Performance Improvements" + }, + { + "type": "refactor", + "section": "Refactoring" + }, + { + "type": "build", + "section": "Build System" + }, + { + "type": "docs", + "section": "Documentation" + }, + { + "type": "chore", + "section": "Maintenance" + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": true + }, + { + "type": "test", + "section": "Tests", + "hidden": true + } + ], + "extra-files": [ + { + "type": "generic", + "path": "pyproject.toml" + }, + { + "type": "generic", + "path": "Cargo.toml" + }, + { + "type": "generic", + "path": "packages/sqlclean-cli/pyproject.toml" + } + ], + "exclude-paths": [ + "website", + "benchmarks", + ".github" + ] + } + } +} diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/check_docs_skill.py b/scripts/check_docs_skill.py new file mode 100644 index 0000000..4fbe11f --- /dev/null +++ b/scripts/check_docs_skill.py @@ -0,0 +1,15 @@ +"""Direct wrapper for the docs skill freshness check.""" + +from __future__ import annotations + +from scripts.docs_skill.main.check import check_docs_skill + + +def main(argv: list[str] | None = None) -> int: + """Check that the SQLClean documentation skill is up to date.""" + + return check_docs_skill(argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/docs_skill/__init__.py b/scripts/docs_skill/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/docs_skill/_helpers/__init__.py b/scripts/docs_skill/_helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/docs_skill/_helpers/mdx.py b/scripts/docs_skill/_helpers/mdx.py new file mode 100644 index 0000000..94c0424 --- /dev/null +++ b/scripts/docs_skill/_helpers/mdx.py @@ -0,0 +1,85 @@ +"""MDX parsing and cleanup for skill generation.""" + +import re + +from scripts.docs_skill.constants import COMPONENT_TAG_END, FRONTMATTER_DELIMITER +from scripts.docs_skill.models import MdxPage + + +def parse_mdx_page(contents: str) -> MdxPage: + """Parse frontmatter and Markdown-compatible content from one MDX page.""" + + frontmatter, body = _split_frontmatter(contents) + return MdxPage( + title=frontmatter.get("title") or "Untitled", + description=frontmatter.get("description", ""), + body=_clean_mdx_body(body), + ) + + +def _split_frontmatter(contents: str) -> tuple[dict[str, str], str]: + lines: list[str] = contents.splitlines() + if not lines or lines[0].strip() != FRONTMATTER_DELIMITER: + return {}, contents + + frontmatter: dict[str, str] = {} + end_index: int = 0 + for index, line in enumerate(lines[1:], start=1): + if line.strip() == FRONTMATTER_DELIMITER: + end_index = index + break + key, separator, value = line.partition(":") + if separator: + frontmatter[key.strip()] = value.strip().strip('"') + + if end_index == 0: + return {}, contents + + return frontmatter, "\n".join(lines[end_index + 1 :]) + + +def _clean_mdx_body(body: str) -> str: + cleaned_lines: list[str] = [] + in_code_block: bool = False + in_component_tag: bool = False + + for raw_line in body.splitlines(): + line: str = raw_line.rstrip() + if line.startswith("```"): + in_code_block = not in_code_block + cleaned_lines.append(line) + continue + if in_code_block: + cleaned_lines.append(line) + continue + + stripped: str = line.strip() + if in_component_tag: + in_component_tag = COMPONENT_TAG_END not in stripped + continue + if re.match(r"<[A-Z][\w.]*\b", stripped) and COMPONENT_TAG_END not in stripped: + in_component_tag = True + continue + if _is_mdx_only_line(stripped): + continue + cleaned_lines.append(line) + + return normalize_blank_lines("\n".join(cleaned_lines)).strip() + + +def _is_mdx_only_line(stripped: str) -> bool: + if not stripped: + return False + if stripped.startswith(("import ", "export ")): + return True + if re.fullmatch(r"]*>", stripped): + return True + if re.fullmatch(r"<[A-Z][^>]*/>", stripped): + return True + return bool(re.fullmatch(r"]*/>", stripped)) + + +def normalize_blank_lines(contents: str) -> str: + """Collapse runs of blank lines to one blank line.""" + + return re.sub(r"\n{3,}", "\n\n", contents) diff --git a/scripts/docs_skill/_helpers/navigation.py b/scripts/docs_skill/_helpers/navigation.py new file mode 100644 index 0000000..e72c6cb --- /dev/null +++ b/scripts/docs_skill/_helpers/navigation.py @@ -0,0 +1,65 @@ +"""Documentation navigation discovery for skill generation.""" + +import json +from pathlib import Path +from typing import Any + +from scripts.docs_skill.constants import DOCS_SLUG, INDEX_PAGE, UNGROUPED_LABEL +from scripts.docs_skill.models import NavigationGroup + + +def list_ordered_page_paths(*, docs_root: Path, sidebar_path: Path | None = None) -> list[Path]: + """Return existing pages in sidebar order followed by unlisted pages.""" + + paths: list[Path] = [] + for group in list_navigation_groups(docs_root=docs_root, sidebar_path=sidebar_path): + paths.extend(group.page_paths) + return paths + + +def _collect_navigation_pages(node: Any) -> list[str]: + if isinstance(node, str): + return [INDEX_PAGE if node == DOCS_SLUG else node.removeprefix(f"{DOCS_SLUG}/")] + if isinstance(node, list): + pages: list[str] = [] + for item in node: + pages.extend(_collect_navigation_pages(item)) + return pages + if isinstance(node, dict): + return _collect_navigation_pages(node.get("slug", node.get("items", []))) + return [] + + +def list_navigation_groups( + *, docs_root: Path, sidebar_path: Path | None = None +) -> list[NavigationGroup]: + """Flatten nested sidebar groups within each top-level group, then add unlisted pages.""" + + sidebar_path = sidebar_path or docs_root.resolve().parents[2] / "sidebar.json" + nodes: list[Any] = ( + json.loads(sidebar_path.read_text(encoding="utf-8")) if sidebar_path.exists() else [] + ) + available: dict[str, Path] = {} + for suffix in ("*.md", "*.mdx"): + for path in sorted(docs_root.rglob(suffix)): + relative_path: Path = path.relative_to(docs_root) + available[relative_path.with_suffix("").as_posix()] = relative_path + groups: list[NavigationGroup] = [] + seen: set[str] = set() + for node in nodes: + members: list[Path] = [] + for page in _collect_navigation_pages(node): + if page in available and page not in seen: + members.append(available[page]) + seen.add(page) + if members: + label: str = ( + node.get("label", UNGROUPED_LABEL) if isinstance(node, dict) else UNGROUPED_LABEL + ) + groups.append(NavigationGroup(label=label, page_paths=tuple(members))) + remaining: tuple[Path, ...] = tuple( + available[page] for page in sorted(available) if page not in seen + ) + if remaining: + groups.append(NavigationGroup(label=UNGROUPED_LABEL, page_paths=remaining)) + return groups diff --git a/scripts/docs_skill/_helpers/output.py b/scripts/docs_skill/_helpers/output.py new file mode 100644 index 0000000..4c07d74 --- /dev/null +++ b/scripts/docs_skill/_helpers/output.py @@ -0,0 +1,47 @@ +"""Generated docs reference output handling.""" + +from pathlib import Path + +from scripts.docs_skill.constants import GENERATED_MARKER + + +def write_reference_pages(*, output_dir: Path, pages: dict[str, str]) -> None: + """Write generated reference pages and remove stale generated pages.""" + + output_dir.mkdir(parents=True, exist_ok=True) + for relative_path, contents in pages.items(): + destination: Path = output_dir / relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(contents, encoding="utf-8") + stale_paths: list[Path] = [ + path + for path in output_dir.rglob("*.md") + if path.relative_to(output_dir).as_posix() not in pages + and GENERATED_MARKER in path.read_text(encoding="utf-8") + ] + for stale_path in stale_paths: + stale_path.unlink() + for directory in sorted(output_dir.rglob("*"), key=lambda path: len(path.parts), reverse=True): + if directory.is_dir() and not any(directory.iterdir()): + directory.rmdir() + print(f"Wrote {len(pages)} reference pages to {output_dir} ({len(stale_paths)} removed)") + + +def outdated_reference_pages(*, output_dir: Path, pages: dict[str, str]) -> tuple[str, ...]: + """Return generated pages that are missing, differ from `pages`, or are no longer generated.""" + + existing: dict[str, str] = { + path.relative_to(output_dir).as_posix(): path.read_text(encoding="utf-8") + for path in output_dir.rglob("*.md") + } + changed: set[str] = { + relative_path + for relative_path, contents in pages.items() + if existing.get(relative_path) != contents + } + stale: set[str] = { + relative_path + for relative_path, contents in existing.items() + if relative_path not in pages and GENERATED_MARKER in contents + } + return tuple(sorted(changed | stale)) diff --git a/scripts/docs_skill/_helpers/render.py b/scripts/docs_skill/_helpers/render.py new file mode 100644 index 0000000..257b3b2 --- /dev/null +++ b/scripts/docs_skill/_helpers/render.py @@ -0,0 +1,110 @@ +"""Markdown rendering for bundled SQLClean docs reference pages.""" + +import os +import re +from pathlib import Path, PurePosixPath + +from scripts.docs_skill._helpers.mdx import normalize_blank_lines, parse_mdx_page +from scripts.docs_skill._helpers.navigation import list_navigation_groups +from scripts.docs_skill.constants import ( + DOCS_BASE_URL, + DOCS_URL_PATH, + GENERATED_MARKER, + INDEX_FILENAME, + INDEX_PAGE, + SITE_BASE_URL, + TABLE_OF_CONTENTS_MIN_LINES, +) +from scripts.docs_skill.models import MdxPage, NavigationGroup + +_INTERNAL_LINK: re.Pattern[str] = re.compile(r"\]\((/[^)\s#]*)(#[^)\s]*)?\)") +_SECTION_HEADING: re.Pattern[str] = re.compile(r"^## (.+)$", re.MULTILINE) + + +def build_reference_pages(*, docs_root: Path, sidebar_path: Path | None = None) -> dict[str, str]: + """Render every documentation page plus a grouped index, keyed by relative output path.""" + + groups: list[NavigationGroup] = list_navigation_groups( + docs_root=docs_root, sidebar_path=sidebar_path + ) + page_names: set[str] = set() + for group in groups: + page_names.update(path.with_suffix("").as_posix() for path in group.page_paths) + known_pages: frozenset[str] = frozenset(page_names) + pages: dict[str, MdxPage] = {} + rendered: dict[str, str] = {} + for group in groups: + for page_path in group.page_paths: + page_name: str = page_path.with_suffix("").as_posix() + page: MdxPage = parse_mdx_page((docs_root / page_path).read_text(encoding="utf-8")) + pages[page_name] = page + rendered[f"{page_name}.md"] = _render_page( + page=page, page_name=page_name, known_pages=known_pages + ) + rendered[INDEX_FILENAME] = _render_index(groups=groups, pages=pages) + return rendered + + +def _render_page(*, page: MdxPage, page_name: str, known_pages: frozenset[str]) -> str: + body: str = _rewrite_internal_links( + body=page.body, page_name=page_name, known_pages=known_pages + ) + description_block: str = f"> {page.description}\n\n" if page.description else "" + contents_block: str = _table_of_contents(body=body) + return ( + normalize_blank_lines( + f"{GENERATED_MARKER}\n\n# {page.title}\n\n{description_block}" + f"Online: {DOCS_BASE_URL}/{'' if page_name == INDEX_PAGE else page_name + '/'}" + f"\n\n{contents_block}{body}" + ).strip() + + "\n" + ) + + +def _table_of_contents(*, body: str) -> str: + if body.count("\n") < TABLE_OF_CONTENTS_MIN_LINES: + return "" + headings: list[str] = _SECTION_HEADING.findall(_without_code_blocks(body)) + if not headings: + return "" + return "## Contents\n\n" + "".join(f"- {heading}\n" for heading in headings) + "\n" + + +def _without_code_blocks(body: str) -> str: + return re.sub(r"```.*?```", "", body, flags=re.DOTALL) + + +def _rewrite_internal_links(*, body: str, page_name: str, known_pages: frozenset[str]) -> str: + source_dir: PurePosixPath = PurePosixPath(page_name).parent + + def replace(match: re.Match[str]) -> str: + path: str = match.group(1) + anchor: str = match.group(2) or "" + if path != DOCS_URL_PATH and not path.startswith(f"{DOCS_URL_PATH}/"): + return f"]({SITE_BASE_URL}{path}{anchor})" + target: str = path.removeprefix(DOCS_URL_PATH).strip("/") or INDEX_PAGE + if target not in known_pages: + return f"]({DOCS_BASE_URL}/{target}/{anchor})" + relative: str = os.path.relpath(f"{target}.md", start=source_dir.as_posix() or ".") + return f"]({PurePosixPath(relative).as_posix()}{anchor})" + + return _INTERNAL_LINK.sub(replace, body) + + +def _render_index(*, groups: list[NavigationGroup], pages: dict[str, MdxPage]) -> str: + sections: list[str] = [] + for group in groups: + lines: list[str] = [f"## {group.label}", ""] + for page_path in group.page_paths: + page_name: str = page_path.with_suffix("").as_posix() + page: MdxPage = pages[page_name] + summary: str = f" - {page.description}" if page.description else "" + lines.append(f"- [{page.title}]({page_name}.md) (`{page_name}`){summary}") + sections.append("\n".join(lines)) + return ( + f"{GENERATED_MARKER}\n\n# SQLClean documentation index\n\n" + "Bundled copies of every page on the SQLClean documentation site, matching the " + "installed SQLClean version. Open only the page you need.\n\n" + + "\n\n".join(sections) + + "\n" + ) diff --git a/scripts/docs_skill/constants.py b/scripts/docs_skill/constants.py new file mode 100644 index 0000000..80c049e --- /dev/null +++ b/scripts/docs_skill/constants.py @@ -0,0 +1,18 @@ +"""Stable constants for SQLClean docs reference generation.""" + +from pathlib import Path + +REPOSITORY_ROOT: Path = Path(__file__).resolve().parents[2] +DEFAULT_DOCS_ROOT: Path = REPOSITORY_ROOT / "website/src/content/docs/docs" +DEFAULT_OUTPUT_DIR: Path = REPOSITORY_ROOT / "crates/sqlclean-cli/skill/references/docs" +SITE_BASE_URL: str = "https://sqlclean.io" +DOCS_BASE_URL: str = f"{SITE_BASE_URL}/docs" +DOCS_SLUG: str = "docs" +DOCS_URL_PATH: str = f"/{DOCS_SLUG}" +INDEX_PAGE: str = "index" +COMPONENT_TAG_END: str = ">" +GENERATED_MARKER: str = "" +INDEX_FILENAME: str = "CONTENTS.md" +FRONTMATTER_DELIMITER: str = "---" +TABLE_OF_CONTENTS_MIN_LINES: int = 100 +UNGROUPED_LABEL: str = "Other pages" diff --git a/scripts/docs_skill/main/__init__.py b/scripts/docs_skill/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/docs_skill/main/check.py b/scripts/docs_skill/main/check.py new file mode 100644 index 0000000..4202fd7 --- /dev/null +++ b/scripts/docs_skill/main/check.py @@ -0,0 +1,41 @@ +"""Focused entry that fails when the bundled docs reference pages are out of date.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from scripts.docs_skill._helpers.output import outdated_reference_pages +from scripts.docs_skill._helpers.render import build_reference_pages +from scripts.docs_skill.constants import DEFAULT_DOCS_ROOT, DEFAULT_OUTPUT_DIR + + +def check_docs_skill(argv: list[str] | None = None) -> int: + """Compare freshly generated reference pages with the committed ones.""" + + args: argparse.Namespace = _parse_args(argv) + outdated: tuple[str, ...] = outdated_reference_pages( + output_dir=args.output_dir, + pages=build_reference_pages(docs_root=args.docs_root), + ) + if not outdated: + print(f"Docs skill reference pages in {args.output_dir} are up to date") + return 0 + print( + f"{len(outdated)} docs skill reference page(s) in {args.output_dir} are out of date " + "with the website docs. Run `make skills` and commit the result:", + file=sys.stderr, + ) + for relative_path in outdated: + print(f" {relative_path}", file=sys.stderr) + return 1 + + +def _parse_args(argv: list[str] | None) -> argparse.Namespace: + parser: argparse.ArgumentParser = argparse.ArgumentParser( + description="Fail when bundled SQLClean skill reference pages differ from the website docs." + ) + parser.add_argument("--docs-root", type=Path, default=DEFAULT_DOCS_ROOT) + parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR) + return parser.parse_args(argv) diff --git a/scripts/docs_skill/main/generate.py b/scripts/docs_skill/main/generate.py new file mode 100644 index 0000000..eee8b95 --- /dev/null +++ b/scripts/docs_skill/main/generate.py @@ -0,0 +1,30 @@ +"""Focused entry for SQLClean docs reference generation.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from scripts.docs_skill._helpers.output import write_reference_pages +from scripts.docs_skill._helpers.render import build_reference_pages +from scripts.docs_skill.constants import DEFAULT_DOCS_ROOT, DEFAULT_OUTPUT_DIR + + +def generate_docs_skill(argv: list[str] | None = None) -> int: + """Generate the bundled documentation reference pages for the SQLClean skill.""" + + args: argparse.Namespace = _parse_args(argv) + write_reference_pages( + output_dir=args.output_dir, + pages=build_reference_pages(docs_root=args.docs_root), + ) + return 0 + + +def _parse_args(argv: list[str] | None) -> argparse.Namespace: + parser: argparse.ArgumentParser = argparse.ArgumentParser( + description="Convert the SQLClean website docs into bundled skill reference pages." + ) + parser.add_argument("--docs-root", type=Path, default=DEFAULT_DOCS_ROOT) + parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR) + return parser.parse_args(argv) diff --git a/scripts/docs_skill/models.py b/scripts/docs_skill/models.py new file mode 100644 index 0000000..79fa7fe --- /dev/null +++ b/scripts/docs_skill/models.py @@ -0,0 +1,21 @@ +"""Structured models for SQLClean docs reference generation.""" + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class MdxPage: + """Content extracted from one MDX documentation page.""" + + title: str + description: str + body: str + + +@dataclass(frozen=True, slots=True) +class NavigationGroup: + """One navigation group label and its pages in documentation order.""" + + label: str + page_paths: tuple[Path, ...] diff --git a/scripts/generate_docs_skill.py b/scripts/generate_docs_skill.py new file mode 100644 index 0000000..360aede --- /dev/null +++ b/scripts/generate_docs_skill.py @@ -0,0 +1,15 @@ +"""Direct wrapper for SQLClean docs skill generation.""" + +from __future__ import annotations + +from scripts.docs_skill.main.generate import generate_docs_skill + + +def main(argv: list[str] | None = None) -> int: + """Generate the SQLClean documentation skill.""" + + return generate_docs_skill(argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/sqlclean/__init__.py b/src/sqlclean/__init__.py new file mode 100644 index 0000000..c1193c0 --- /dev/null +++ b/src/sqlclean/__init__.py @@ -0,0 +1,39 @@ +"""SQLClean: SQL linting and deterministic formatting, with custom Rules in Python.""" + +from sqlclean.rules.classes.rule_context import RuleContext +from sqlclean.rules.classes.rule_option import RuleOption +from sqlclean.rules.main.define import rule +from sqlclean.rules.models import ( + ColumnReference, + Cte, + Edit, + Finding, + FunctionCall, + Join, + Literal, + Repository, + SqlFile, + Star, + Statement, + Table, +) +from sqlclean.rules.types import FixSafety + +__all__ = [ + "ColumnReference", + "Cte", + "Edit", + "Finding", + "FixSafety", + "FunctionCall", + "Join", + "Literal", + "Repository", + "RuleContext", + "RuleOption", + "SqlFile", + "Star", + "Statement", + "Table", + "rule", +] diff --git a/src/sqlclean/hosting/__init__.py b/src/sqlclean/hosting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sqlclean/hosting/_helpers/__init__.py b/src/sqlclean/hosting/_helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sqlclean/hosting/_helpers/evaluation.py b/src/sqlclean/hosting/_helpers/evaluation.py new file mode 100644 index 0000000..26d4f3a --- /dev/null +++ b/src/sqlclean/hosting/_helpers/evaluation.py @@ -0,0 +1,109 @@ +"""Run one Rule over its subjects and check what it returns.""" + +import traceback +from collections.abc import Iterator, Mapping +from typing import cast + +from sqlclean.hosting.exceptions import RuleEvaluationError +from sqlclean.rules.classes.rule_context import RuleContext +from sqlclean.rules.classes.rule_option import RuleOption +from sqlclean.rules.exceptions import RuleOptionError, SqlCleanError +from sqlclean.rules.models import Finding, Repository, RuleDefinition, SqlFile +from sqlclean.rules.types import RuleOptionValue, RuleSubjectKind + + +def evaluate( + *, + definition: RuleDefinition, + files: tuple[SqlFile, ...], + options: Mapping[str, object], +) -> tuple[Finding, ...]: + """Return the Rule's findings over each of its subjects in files.""" + + values: dict[str, RuleOptionValue] = parse_options(definition=definition, configured=options) + sources: dict[str, str] = {file.path: file.source for file in files} + findings: list[Finding] = [] + for subject, file in _subjects(definition=definition, files=files): + context: RuleContext = RuleContext(definition=definition, options=values, file=file) + returned: object = _call(definition=definition, subject=subject, context=context) + findings.extend(_checked(definition=definition, returned=returned, sources=sources)) + return tuple(findings) + + +def parse_options( + *, definition: RuleDefinition, configured: Mapping[str, object] +) -> dict[str, RuleOptionValue]: + """Return the configured option values, validated against the Rule's declarations.""" + + declared: dict[str, RuleOption[object]] = {option.name: option for option in definition.options} + unknown: list[str] = sorted(set(configured) - set(declared)) + if unknown: + raise RuleOptionError(f"Rule {definition.code} has no option {', '.join(unknown)}") + values: dict[str, RuleOptionValue] = {} + for option in definition.options: + if option.name in configured: + try: + values[option.name] = cast(RuleOptionValue, option.parse(configured[option.name])) + except RuleOptionError as error: + raise RuleOptionError(f"[rules.rule_options.{definition.code}] {error}") from error + return values + + +def _subjects( + *, definition: RuleDefinition, files: tuple[SqlFile, ...] +) -> Iterator[tuple[object, SqlFile | None]]: + if definition.subject is RuleSubjectKind.REPOSITORY: + yield Repository(files=files), None + return + for file in files: + if definition.subject is RuleSubjectKind.FILE: + yield file, file + else: + for statement in file.statements: + yield statement, file + + +def _call(*, definition: RuleDefinition, subject: object, context: RuleContext) -> object: + arguments: dict[str, object] = { + definition.subject_parameter: subject, + definition.context_parameter: context, + } + try: + return definition.check(**arguments) + except SqlCleanError: + raise + except Exception as error: + location: str = getattr(subject, "path", "the repository") + raise RuleEvaluationError( + f"custom Rule {definition.code} failed on {location}: " + f"{type(error).__name__}: {error}\n{traceback.format_exc()}" + ) from error + + +def _checked( + *, definition: RuleDefinition, returned: object, sources: Mapping[str, str] +) -> list[Finding]: + if not isinstance(returned, list | tuple) or not all( + isinstance(item, Finding) for item in returned + ): + raise RuleEvaluationError( + f"custom Rule {definition.code} must return a list of findings from ctx.finding(...)" + ) + for finding in returned: + _check_finding(definition=definition, finding=finding, sources=sources) + return list(returned) + + +def _check_finding( + *, definition: RuleDefinition, finding: Finding, sources: Mapping[str, str] +) -> None: + source: str | None = sources.get(finding.path) + if finding.code != definition.code or source is None: + raise RuleEvaluationError( + f"custom Rule {definition.code} returned a finding for another Rule or file" + ) + for edit in finding.edits: + if not 0 <= edit.start <= edit.end <= len(source): + raise RuleEvaluationError( + f"custom Rule {definition.code} returned an edit outside {finding.path}" + ) diff --git a/src/sqlclean/hosting/_helpers/loading.py b/src/sqlclean/hosting/_helpers/loading.py new file mode 100644 index 0000000..425808b --- /dev/null +++ b/src/sqlclean/hosting/_helpers/loading.py @@ -0,0 +1,64 @@ +"""Import the configured custom Rule modules and collect their @rule functions.""" + +import sys +from importlib import import_module +from pathlib import Path +from types import ModuleType + +from sqlclean.hosting.constants import ( + PYTHON_SUFFIX, + SKIPPED_DIRECTORIES, + SKIPPED_MODULE_NAMES, + SKIPPED_MODULE_PREFIX, +) +from sqlclean.hosting.exceptions import RuleLoadingError +from sqlclean.rules.constants import RULE_ATTRIBUTE +from sqlclean.rules.models import RuleDefinition + + +def load_definitions(*, root: Path, custom: list[str]) -> dict[str, RuleDefinition]: + """Return every custom Rule defined in the modules under the configured paths, by code.""" + + root_text: str = str(root) + if root_text not in sys.path: + sys.path.insert(0, root_text) + definitions: dict[str, RuleDefinition] = {} + for module_name in _module_names(root=root, custom=custom): + module: ModuleType = _import(module_name) + for value in vars(module).values(): + definition: object = getattr(value, RULE_ATTRIBUTE, None) + if not isinstance(definition, RuleDefinition) or definition.module != module_name: + continue + if definition.code in definitions: + raise RuleLoadingError(f"custom Rule code {definition.code} is defined twice") + definitions[definition.code] = definition + return definitions + + +def _module_names(*, root: Path, custom: list[str]) -> list[str]: + files: set[Path] = set() + for entry in custom: + path: Path = root / entry + candidates: list[Path] = ( + [path] if path.is_file() else sorted(path.rglob(f"*{PYTHON_SUFFIX}")) + ) + files.update(candidate for candidate in candidates if _is_rule_module(candidate)) + return sorted(".".join(file.relative_to(root).with_suffix("").parts) for file in files) + + +def _is_rule_module(path: Path) -> bool: + return ( + path.suffix == PYTHON_SUFFIX + and path.name not in SKIPPED_MODULE_NAMES + and not path.name.startswith(SKIPPED_MODULE_PREFIX) + and not SKIPPED_DIRECTORIES.intersection(path.parts) + ) + + +def _import(module_name: str) -> ModuleType: + try: + return import_module(module_name) + except Exception as error: + raise RuleLoadingError( + f"could not import custom Rule module {module_name}: {type(error).__name__}: {error}" + ) from error diff --git a/src/sqlclean/hosting/_helpers/requests.py b/src/sqlclean/hosting/_helpers/requests.py new file mode 100644 index 0000000..c3bb10e --- /dev/null +++ b/src/sqlclean/hosting/_helpers/requests.py @@ -0,0 +1,116 @@ +"""One host request: list the custom Rules, or evaluate them over files.""" + +from collections.abc import Mapping, Sequence +from pathlib import Path + +from sqlclean.hosting._helpers.evaluation import evaluate +from sqlclean.hosting._helpers.loading import load_definitions +from sqlclean.hosting._helpers.sql_files import sql_file_from_facts +from sqlclean.hosting.constants import CATALOGUE_MODE +from sqlclean.hosting.exceptions import RuleEvaluationError +from sqlclean.rules.exceptions import SqlCleanError +from sqlclean.rules.models import Edit, Finding, RuleDefinition, SqlFile + +type _Json = dict[str, object] + + +def handle_request(request: Mapping[str, object]) -> _Json: + """Return the response to one request, or an error the command reports.""" + + try: + custom: list[str] = [str(path) for path in _list(container=request, key="custom")] + definitions: dict[str, RuleDefinition] = load_definitions( + root=Path(str(request["root"])), custom=custom + ) + if request["mode"] == CATALOGUE_MODE: + return {"rules": [_rule_info(definition) for definition in definitions.values()]} + return {"results": _evaluate(request=request, definitions=definitions)} + except SqlCleanError as error: + return {"error": str(error)} + + +def _evaluate( + *, request: Mapping[str, object], definitions: Mapping[str, RuleDefinition] +) -> list[_Json]: + files: list[tuple[SqlFile, list[str]]] = [ + _requested_file(item) for item in _list(container=request, key="files") + ] + options: Mapping[str, object] = _mapping(container=request, key="options") + results: list[_Json] = [] + for file, codes in files: + for code in codes: + findings: tuple[Finding, ...] = evaluate( + definition=_definition(definitions=definitions, code=code), + files=(file,), + options=_mapping(container=options, key=code), + ) + results.append(_result(code=code, path=file.path, findings=findings)) + every_file: tuple[SqlFile, ...] = tuple(file for file, _ in files) + for code in _list(container=request, key="repository_rules"): + findings = evaluate( + definition=_definition(definitions=definitions, code=str(code)), + files=every_file, + options=_mapping(container=options, key=str(code)), + ) + results.append(_result(code=str(code), path=None, findings=findings)) + return results + + +def _requested_file(item: object) -> tuple[SqlFile, list[str]]: + fields: Mapping[str, object] = item if isinstance(item, Mapping) else {} + file: SqlFile = sql_file_from_facts( + path=str(fields["path"]), + source=str(fields["source"]), + dialect=str(fields["dialect"]), + facts=_mapping(container=fields, key="facts"), + ) + return file, [str(code) for code in _list(container=fields, key="rules")] + + +def _definition(*, definitions: Mapping[str, RuleDefinition], code: str) -> RuleDefinition: + definition: RuleDefinition | None = definitions.get(code) + if definition is None: + raise RuleEvaluationError(f"custom Rule {code} is no longer defined") + return definition + + +def _rule_info(definition: RuleDefinition) -> _Json: + return { + "code": definition.code, + "message": definition.message, + "remediation": definition.remediation, + "enabled_by_default": definition.enabled_by_default, + "custom": True, + "subject": definition.subject.value, + "fixable": None if definition.fixable is None else definition.fixable.value, + "module": definition.module, + } + + +def _result(*, code: str, path: str | None, findings: tuple[Finding, ...]) -> _Json: + return {"code": code, "path": path, "findings": [_finding(finding) for finding in findings]} + + +def _finding(finding: Finding) -> _Json: + return { + "path": finding.path, + "start": finding.start, + "end": finding.end, + "message": finding.message, + "remediation": finding.remediation, + "edits": [_edit(edit) for edit in finding.edits], + } + + +def _edit(edit: Edit) -> _Json: + return {"start": edit.start, "end": edit.end, "text": edit.text} + + +def _list(*, container: Mapping[str, object], key: str) -> Sequence[object]: + value: object = container.get(key, ()) + return value if isinstance(value, list) else () + + +def _mapping(*, container: Mapping[str, object], key: str) -> Mapping[str, object]: + value: object = container.get(key, {}) + return value if isinstance(value, Mapping) else {} diff --git a/src/sqlclean/hosting/_helpers/sql_files.py b/src/sqlclean/hosting/_helpers/sql_files.py new file mode 100644 index 0000000..ca7b14f --- /dev/null +++ b/src/sqlclean/hosting/_helpers/sql_files.py @@ -0,0 +1,126 @@ +"""Statement facts, with character offsets, turned into located Rule subjects.""" + +from bisect import bisect_right +from collections.abc import Mapping, Sequence +from typing import TypedDict + +from sqlclean.hosting.constants import NEWLINE +from sqlclean.rules.models import ( + ColumnReference, + Cte, + FunctionCall, + Join, + Literal, + SqlFile, + Star, + Statement, + Table, +) + +type _Facts = Mapping[str, object] + + +class _Location(TypedDict): + text: str + start: int + end: int + line: int + column: int + + +class _Locator: + def __init__(self, source: str) -> None: + self.source: str = source + self._starts: list[int] = [0] + [ + index + 1 for index, character in enumerate(source) if character == NEWLINE + ] + + def located(self, facts: _Facts) -> _Location: + start: int = _integer(facts=facts, key="start") + end: int = _integer(facts=facts, key="end") + line: int = bisect_right(self._starts, start) + return _Location( + text=self.source[start:end], + start=start, + end=end, + line=line, + column=start - self._starts[line - 1] + 1, + ) + + def cte(self, item: _Facts) -> Cte: + return Cte(name=str(item["name"]), **self.located(item)) + + def table(self, item: _Facts) -> Table: + return Table( + name=str(item["name"]), + qualified_name=str(item["qualified_name"]), + alias=_optional(facts=item, key="alias"), + **self.located(item), + ) + + def join(self, item: _Facts) -> Join: + return Join( + kind=str(item["kind"]), + relation=_optional(facts=item, key="relation"), + has_condition=bool(item["has_condition"]), + **self.located(item), + ) + + def star(self, item: _Facts) -> Star: + return Star(qualifier=_optional(facts=item, key="qualifier"), **self.located(item)) + + def function(self, item: _Facts) -> FunctionCall: + return FunctionCall(name=str(item["name"]), **self.located(item)) + + def column(self, item: _Facts) -> ColumnReference: + return ColumnReference( + name=str(item["name"]), + qualifier=_optional(facts=item, key="qualifier"), + **self.located(item), + ) + + def literal(self, item: _Facts) -> Literal: + return Literal(kind=str(item["kind"]), **self.located(item)) + + +def sql_file_from_facts(*, path: str, source: str, dialect: str, facts: _Facts) -> SqlFile: + """Return the SqlFile for one file's native facts.""" + + locator: _Locator = _Locator(source) + statements: tuple[Statement, ...] = tuple( + _statement(path=path, dialect=dialect, facts=statement, locator=locator) + for statement in _items(facts=facts, key="statements") + ) + return SqlFile(path=path, source=source, dialect=dialect, statements=statements) + + +def _statement(*, path: str, dialect: str, facts: _Facts, locator: _Locator) -> Statement: + return Statement( + path=path, + index=_integer(facts=facts, key="index"), + kind=str(facts["kind"]), + dialect=dialect, + ctes=tuple(locator.cte(item) for item in _items(facts=facts, key="ctes")), + tables=tuple(locator.table(item) for item in _items(facts=facts, key="tables")), + joins=tuple(locator.join(item) for item in _items(facts=facts, key="joins")), + stars=tuple(locator.star(item) for item in _items(facts=facts, key="stars")), + functions=tuple(locator.function(item) for item in _items(facts=facts, key="functions")), + columns=tuple(locator.column(item) for item in _items(facts=facts, key="columns")), + literals=tuple(locator.literal(item) for item in _items(facts=facts, key="literals")), + **locator.located(facts), + ) + + +def _items(*, facts: _Facts, key: str) -> Sequence[_Facts]: + value: object = facts.get(key, ()) + return value if isinstance(value, list) else () + + +def _integer(*, facts: _Facts, key: str) -> int: + value: object = facts[key] + return value if isinstance(value, int) else 0 + + +def _optional(*, facts: _Facts, key: str) -> str | None: + value: object = facts.get(key) + return None if value is None else str(value) diff --git a/src/sqlclean/hosting/constants.py b/src/sqlclean/hosting/constants.py new file mode 100644 index 0000000..d4ce985 --- /dev/null +++ b/src/sqlclean/hosting/constants.py @@ -0,0 +1,9 @@ +"""Names in the JSON the sqlclean command exchanges with this host.""" + +CATALOGUE_MODE: str = "catalogue" +EVALUATE_MODE: str = "evaluate" +NEWLINE: str = "\n" +PYTHON_SUFFIX: str = ".py" +SKIPPED_MODULE_PREFIX: str = "test_" +SKIPPED_MODULE_NAMES: frozenset[str] = frozenset({"conftest.py", "__init__.py"}) +SKIPPED_DIRECTORIES: frozenset[str] = frozenset({"__pycache__"}) diff --git a/src/sqlclean/hosting/exceptions.py b/src/sqlclean/hosting/exceptions.py new file mode 100644 index 0000000..520908b --- /dev/null +++ b/src/sqlclean/hosting/exceptions.py @@ -0,0 +1,11 @@ +"""Errors the custom Rule host reports to the sqlclean command.""" + +from sqlclean.rules.exceptions import SqlCleanError + + +class RuleLoadingError(SqlCleanError): + """A custom Rule module could not be imported, or two Rules share a code.""" + + +class RuleEvaluationError(SqlCleanError): + """A custom Rule raised an exception or returned something other than findings.""" diff --git a/src/sqlclean/hosting/main/__init__.py b/src/sqlclean/hosting/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sqlclean/hosting/main/build_sql_file.py b/src/sqlclean/hosting/main/build_sql_file.py new file mode 100644 index 0000000..c9613a7 --- /dev/null +++ b/src/sqlclean/hosting/main/build_sql_file.py @@ -0,0 +1,12 @@ +"""Build the SqlFile a Rule receives from the native statement facts.""" + +from collections.abc import Mapping + +from sqlclean.hosting._helpers.sql_files import sql_file_from_facts +from sqlclean.rules.models import SqlFile + + +def build_sql_file(*, path: str, source: str, dialect: str, facts: Mapping[str, object]) -> SqlFile: + """Return the file with its statements and their located structures.""" + + return sql_file_from_facts(path=path, source=source, dialect=dialect, facts=facts) diff --git a/src/sqlclean/hosting/main/evaluate_definition.py b/src/sqlclean/hosting/main/evaluate_definition.py new file mode 100644 index 0000000..418a32c --- /dev/null +++ b/src/sqlclean/hosting/main/evaluate_definition.py @@ -0,0 +1,17 @@ +"""Evaluate one custom Rule over files or a repository.""" + +from collections.abc import Mapping + +from sqlclean.hosting._helpers.evaluation import evaluate +from sqlclean.rules.models import Finding, RuleDefinition, SqlFile + + +def evaluate_definition( + *, + definition: RuleDefinition, + files: tuple[SqlFile, ...], + options: Mapping[str, object], +) -> tuple[Finding, ...]: + """Return the Rule's findings: per statement or file, or once over every file.""" + + return evaluate(definition=definition, files=files, options=options) diff --git a/src/sqlclean/hosting/main/serve.py b/src/sqlclean/hosting/main/serve.py new file mode 100644 index 0000000..34c7c52 --- /dev/null +++ b/src/sqlclean/hosting/main/serve.py @@ -0,0 +1,15 @@ +"""Answer one request from the sqlclean command on standard input and output.""" + +import json +import sys + +from sqlclean.hosting._helpers.requests import handle_request + + +def serve() -> int: + """Read one JSON request, write one JSON response and return the exit status.""" + + request: dict[str, object] = json.loads(sys.stdin.read()) + response: dict[str, object] = handle_request(request) + sys.stdout.write(json.dumps(response)) + return 0 diff --git a/src/sqlclean/py.typed b/src/sqlclean/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/sqlclean/rule_testing/__init__.py b/src/sqlclean/rule_testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sqlclean/rule_testing/_helpers/__init__.py b/src/sqlclean/rule_testing/_helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sqlclean/rule_testing/_helpers/harness.py b/src/sqlclean/rule_testing/_helpers/harness.py new file mode 100644 index 0000000..9c5be80 --- /dev/null +++ b/src/sqlclean/rule_testing/_helpers/harness.py @@ -0,0 +1,66 @@ +"""Evaluate a Rule over a case twice, in opposite file orders, and compare.""" + +from sqlclean.hosting.main.build_sql_file import build_sql_file +from sqlclean.hosting.main.evaluate_definition import evaluate_definition +from sqlclean.rule_testing._helpers.native_facts import native_facts +from sqlclean.rule_testing.exceptions import RuleCaseError +from sqlclean.rule_testing.models import RuleCase, RuleFile, RuleResult +from sqlclean.rules.constants import RULE_ATTRIBUTE +from sqlclean.rules.models import Edit, Finding, RuleDefinition, SqlFile +from sqlclean.rules.types import RuleCheck + + +def run_case(*, rule: RuleCheck, test_case: RuleCase) -> RuleResult: + """Return the Rule's result on the case after checking count and determinism.""" + + definition: object = getattr(rule, RULE_ATTRIBUTE, None) + if not isinstance(definition, RuleDefinition): + raise RuleCaseError("evaluate_rule needs a function decorated with @rule") + files: tuple[SqlFile, ...] = tuple( + build_sql_file( + path=file.path, + source=file.source, + dialect=test_case.dialect, + facts=native_facts(source=file.source, dialect=test_case.dialect), + ) + for file in (RuleFile(path=test_case.path, source=test_case.source), *test_case.files) + ) + first: tuple[Finding, ...] = evaluate_definition( + definition=definition, files=files, options=test_case.options + ) + second: tuple[Finding, ...] = evaluate_definition( + definition=definition, files=tuple(reversed(files)), options=test_case.options + ) + if sorted(first, key=_order) != sorted(second, key=_order): + raise RuleCaseError(f"custom Rule {definition.code} is not deterministic") + findings: tuple[Finding, ...] = tuple(sorted(first, key=_order)) + if len(findings) != test_case.expected_finding_count: + raise RuleCaseError( + f"{test_case.description}: expected {test_case.expected_finding_count} findings, " + f"got {len(findings)}" + ) + return RuleResult( + findings=findings, fixed_source=_fixed(test_case=test_case, findings=findings) + ) + + +def _order(finding: Finding) -> tuple[str, int, int, str]: + return finding.path, finding.start, finding.end, finding.message + + +def _fixed(*, test_case: RuleCase, findings: tuple[Finding, ...]) -> str: + edits: list[Edit] = [] + for finding in findings: + if finding.path == test_case.path: + edits.extend(finding.edits) + edits.sort(key=lambda edit: (edit.start, edit.end)) + pieces: list[str] = [] + cursor: int = 0 + for edit in edits: + if edit.start < cursor: + continue + pieces.append(test_case.source[cursor : edit.start]) + pieces.append(edit.text) + cursor = edit.end + pieces.append(test_case.source[cursor:]) + return "".join(pieces) diff --git a/src/sqlclean/rule_testing/_helpers/native_facts.py b/src/sqlclean/rule_testing/_helpers/native_facts.py new file mode 100644 index 0000000..510aedd --- /dev/null +++ b/src/sqlclean/rule_testing/_helpers/native_facts.py @@ -0,0 +1,39 @@ +"""Statement facts from the installed sqlclean executable, the same the command hosts use.""" + +import json +import shutil +import subprocess +import sysconfig +from pathlib import Path + +from sqlclean.rule_testing.constants import EXECUTABLE_NAME, FACTS_COMMAND +from sqlclean.rule_testing.exceptions import RuleCaseError + + +def native_facts(*, source: str, dialect: str) -> dict[str, object]: + """Return the native statement facts of source, or raise RuleCaseError if it fails.""" + + completed: subprocess.CompletedProcess[str] = subprocess.run( + [_executable(), FACTS_COMMAND, "--dialect", dialect], + input=source, + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + if completed.returncode: + raise RuleCaseError(f"sqlclean could not parse the case SQL: {completed.stderr.strip()}") + facts: dict[str, object] = json.loads(completed.stdout) + return facts + + +def _executable() -> str: + scripts: Path = Path(sysconfig.get_path("scripts")) + for name in (EXECUTABLE_NAME, f"{EXECUTABLE_NAME}.exe"): + candidate: Path = scripts / name + if candidate.is_file(): + return str(candidate) + found: str | None = shutil.which(EXECUTABLE_NAME) + if found is None: + raise RuleCaseError("the sqlclean executable is not installed; install sqlclean") + return found diff --git a/src/sqlclean/rule_testing/constants.py b/src/sqlclean/rule_testing/constants.py new file mode 100644 index 0000000..f700b4c --- /dev/null +++ b/src/sqlclean/rule_testing/constants.py @@ -0,0 +1,6 @@ +"""Defaults of the custom Rule test harness.""" + +DEFAULT_CASE_PATH: str = "query.sql" +DEFAULT_CASE_DIALECT: str = "generic" +EXECUTABLE_NAME: str = "sqlclean" +FACTS_COMMAND: str = "facts" diff --git a/src/sqlclean/rule_testing/exceptions.py b/src/sqlclean/rule_testing/exceptions.py new file mode 100644 index 0000000..ef0bae5 --- /dev/null +++ b/src/sqlclean/rule_testing/exceptions.py @@ -0,0 +1,7 @@ +"""Failures the custom Rule test harness reports.""" + +from sqlclean.rules.exceptions import SqlCleanError + + +class RuleCaseError(SqlCleanError): + """A Rule case did not produce what it expects, or the Rule is not deterministic.""" diff --git a/src/sqlclean/rule_testing/main/__init__.py b/src/sqlclean/rule_testing/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sqlclean/rule_testing/main/evaluate_rule.py b/src/sqlclean/rule_testing/main/evaluate_rule.py new file mode 100644 index 0000000..33dce75 --- /dev/null +++ b/src/sqlclean/rule_testing/main/evaluate_rule.py @@ -0,0 +1,11 @@ +"""Run one custom Rule over a test case.""" + +from sqlclean.rule_testing._helpers.harness import run_case +from sqlclean.rule_testing.models import RuleCase, RuleResult +from sqlclean.rules.types import RuleCheck + + +def evaluate_rule(*, rule: RuleCheck, test_case: RuleCase) -> RuleResult: + """Return the Rule's findings on the case, failing when the count or determinism differs.""" + + return run_case(rule=rule, test_case=test_case) diff --git a/src/sqlclean/rule_testing/models.py b/src/sqlclean/rule_testing/models.py new file mode 100644 index 0000000..9ee5aaf --- /dev/null +++ b/src/sqlclean/rule_testing/models.py @@ -0,0 +1,42 @@ +"""One custom Rule test case and its result.""" + +from collections.abc import Mapping +from dataclasses import dataclass, field + +from sqlclean.rule_testing.constants import DEFAULT_CASE_DIALECT, DEFAULT_CASE_PATH +from sqlclean.rules.models import Finding + + +@dataclass(frozen=True, slots=True) +class RuleFile: + """A supporting SQL file for Rules that read more than the case's own file.""" + + path: str + source: str + + +@dataclass(frozen=True, slots=True) +class RuleCase: + """SQL to check and the number of findings the Rule must report on it.""" + + description: str + source: str + expected_finding_count: int + path: str = DEFAULT_CASE_PATH + dialect: str = DEFAULT_CASE_DIALECT + files: tuple[RuleFile, ...] = () + options: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class RuleResult: + """The Rule's findings, and the case source with every fix applied.""" + + findings: tuple[Finding, ...] + fixed_source: str + + @property + def finding_count(self) -> int: + """Return how many findings the Rule reported.""" + + return len(self.findings) diff --git a/src/sqlclean/rules/__init__.py b/src/sqlclean/rules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sqlclean/rules/_helpers/__init__.py b/src/sqlclean/rules/_helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sqlclean/rules/_helpers/definition.py b/src/sqlclean/rules/_helpers/definition.py new file mode 100644 index 0000000..22774d2 --- /dev/null +++ b/src/sqlclean/rules/_helpers/definition.py @@ -0,0 +1,107 @@ +"""Validate a @rule declaration and attach its definition to the function.""" + +import inspect +from collections.abc import Callable +from typing import get_type_hints + +from sqlclean.rules.classes.rule_context import RuleContext +from sqlclean.rules.classes.rule_option import RuleOption +from sqlclean.rules.constants import ( + CUSTOM_CODE_PREFIX, + RULE_ATTRIBUTE, + RULE_CODE_DIGIT_COUNT, + RULE_PARAMETER_COUNT, + SIGNATURE_REQUIREMENT, +) +from sqlclean.rules.exceptions import RuleDefinitionError +from sqlclean.rules.models import Repository, RuleDefinition, SqlFile, Statement +from sqlclean.rules.types import FixSafety, RuleCheck, RuleSubjectKind + +_SUBJECTS: dict[type, RuleSubjectKind] = { + Statement: RuleSubjectKind.STATEMENT, + SqlFile: RuleSubjectKind.FILE, + Repository: RuleSubjectKind.REPOSITORY, +} + + +def define_rule( + *, + code: str, + slug: str, + message: str, + remediation: str, + options: tuple[RuleOption[object], ...], + enabled_by_default: bool, + fixable: FixSafety | None, +) -> Callable[[RuleCheck], RuleCheck]: + """Return the decorator that validates and attaches one Rule definition.""" + + if not all(value.strip() for value in (code, slug, message, remediation)): + raise RuleDefinitionError(f"custom Rule {code} needs a code, slug, message and remediation") + if not is_custom_code(code): + raise RuleDefinitionError( + f"custom Rule code must be {CUSTOM_CODE_PREFIX}, optional capital letters and " + f"{RULE_CODE_DIGIT_COUNT} digits, such as XSQCNAME001: {code}" + ) + names: list[str] = [option.name for option in options] + if len(names) != len(set(names)): + raise RuleDefinitionError(f"custom Rule {code} declares an option name twice") + + def decorate(check: RuleCheck) -> RuleCheck: + subject, subject_parameter, context_parameter = _signature(code=code, check=check) + definition: RuleDefinition = RuleDefinition( + code=code, + slug=slug, + message=message, + remediation=remediation, + check=check, + subject=subject, + subject_parameter=subject_parameter, + context_parameter=context_parameter, + options=options, + enabled_by_default=enabled_by_default, + fixable=fixable, + module=check.__module__, + ) + setattr(check, RULE_ATTRIBUTE, definition) + return check + + return decorate + + +def is_custom_code(code: str) -> bool: + """Return whether code is XSQC, optional capital letters and three digits.""" + + family: str = code[:-RULE_CODE_DIGIT_COUNT] + number: str = code[-RULE_CODE_DIGIT_COUNT:] + letters: str = family.removeprefix(CUSTOM_CODE_PREFIX) + return ( + family.startswith(CUSTOM_CODE_PREFIX) + and (not letters or (letters.isascii() and letters.isalpha() and letters.isupper())) + and number.isascii() + and number.isdecimal() + and len(number) == RULE_CODE_DIGIT_COUNT + ) + + +def _signature(*, code: str, check: RuleCheck) -> tuple[RuleSubjectKind, str, str]: + parameters: tuple[inspect.Parameter, ...] = tuple(inspect.signature(check).parameters.values()) + if len(parameters) != RULE_PARAMETER_COUNT or any( + parameter.kind is not inspect.Parameter.KEYWORD_ONLY for parameter in parameters + ): + raise RuleDefinitionError(f"custom Rule {code} {SIGNATURE_REQUIREMENT}") + try: + hints: dict[str, object] = get_type_hints(check) + except (NameError, TypeError) as error: + raise RuleDefinitionError( + f"could not resolve custom Rule {code} annotations: {error}" + ) from error + subjects: list[tuple[RuleSubjectKind, str]] = [ + (_SUBJECTS[hint], name) + for name, hint in hints.items() + if isinstance(hint, type) and hint in _SUBJECTS + ] + contexts: list[str] = [name for name, hint in hints.items() if hint is RuleContext] + if len(subjects) != 1 or len(contexts) != 1: + raise RuleDefinitionError(f"custom Rule {code} {SIGNATURE_REQUIREMENT}") + return subjects[0][0], subjects[0][1], contexts[0] diff --git a/src/sqlclean/rules/classes/__init__.py b/src/sqlclean/rules/classes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sqlclean/rules/classes/rule_context.py b/src/sqlclean/rules/classes/rule_context.py new file mode 100644 index 0000000..eab7a28 --- /dev/null +++ b/src/sqlclean/rules/classes/rule_context.py @@ -0,0 +1,99 @@ +"""What a Rule receives besides its subject: options, findings and fix edits.""" + +from collections.abc import Mapping, Sequence +from typing import cast + +from sqlclean.rules.classes.rule_option import RuleOption +from sqlclean.rules.exceptions import RuleUsageError +from sqlclean.rules.models import Edit, Finding, RuleDefinition, SqlFile, Statement +from sqlclean.rules.types import Located, RuleOptionValue + + +class RuleContext: + """The per-evaluation API of one Rule: typed options, findings and edits.""" + + def __init__( + self, + *, + definition: RuleDefinition, + options: Mapping[str, RuleOptionValue], + file: SqlFile | None, + ) -> None: + self._definition: RuleDefinition = definition + self._options: Mapping[str, RuleOptionValue] = options + self._file: SqlFile | None = file + + @property + def file(self) -> SqlFile | None: + """Return the file being checked, or None for a Repository subject.""" + + return self._file + + @property + def dialect(self) -> str | None: + """Return the dialect of the file being checked, or None for a Repository subject.""" + + return None if self._file is None else self._file.dialect + + def option[T](self, option: RuleOption[T]) -> T: + """Return the configured value of one declared option, or its default.""" + + if all(declared is not option for declared in self._definition.options): + raise RuleUsageError( + f"Rule {self._definition.code} reads undeclared option {option.name}" + ) + if option.name not in self._options: + return option.default + return cast(T, self._options[option.name]) + + def finding( + self, + *, + subject: Statement | SqlFile, + node: Located | None = None, + message: str | None = None, + remediation: str | None = None, + fix: Sequence[Edit] = (), + ) -> Finding: + """Return a finding at node, or at the subject, with an optional fix.""" + + if fix and self._definition.fixable is None: + raise RuleUsageError( + f"Rule {self._definition.code} returned a fix but does not declare fixable" + ) + located: Located = node if node is not None else _subject_location(subject) + return Finding( + code=self._definition.code, + path=subject.path, + start=located.start, + end=located.end, + message=message or self._definition.message, + remediation=remediation or self._definition.remediation, + edits=tuple(fix), + ) + + def replace(self, *, node: Located, text: str) -> Edit: + """Return an edit that replaces node's text.""" + + return Edit(start=node.start, end=node.end, text=text) + + def insert_before(self, *, node: Located, text: str) -> Edit: + """Return an edit that inserts text immediately before node.""" + + return Edit(start=node.start, end=node.start, text=text) + + def insert_after(self, *, node: Located, text: str) -> Edit: + """Return an edit that inserts text immediately after node.""" + + return Edit(start=node.end, end=node.end, text=text) + + def delete(self, *, node: Located) -> Edit: + """Return an edit that removes node's text.""" + + return Edit(start=node.start, end=node.end, text="") + + +def _subject_location(subject: Statement | SqlFile) -> Located: + if isinstance(subject, SqlFile): + return Edit(start=0, end=0, text="") + return subject diff --git a/src/sqlclean/rules/classes/rule_option.py b/src/sqlclean/rules/classes/rule_option.py new file mode 100644 index 0000000..a259fde --- /dev/null +++ b/src/sqlclean/rules/classes/rule_option.py @@ -0,0 +1,128 @@ +"""One typed configuration option a Rule declares.""" + +from typing import cast + +from sqlclean.rules.exceptions import RuleDefinitionError, RuleOptionError + + +class RuleOption[T]: + """A named, typed option configured under [rules.rule_options.].""" + + def __init__( + self, + *, + name: str, + value_type: type, + default: T, + description: str, + choices: tuple[T, ...] = (), + minimum: int | None = None, + maximum: int | None = None, + item_type: type | None = None, + ) -> None: + if not name.isidentifier(): + raise RuleDefinitionError(f"Rule option name must be an identifier: {name!r}") + self.name: str = name + self.value_type: type = value_type + self.default: T = default + self.description: str = description + self.choices: tuple[T, ...] = choices + self.minimum: int | None = minimum + self.maximum: int | None = maximum + self.item_type: type | None = item_type + _ = self.parse(default) + + @classmethod + def boolean(cls, *, name: str, default: bool, description: str) -> "RuleOption[bool]": + """Declare a true or false option.""" + + return RuleOption[bool]( + name=name, value_type=bool, default=default, description=description + ) + + @classmethod + def integer( + cls, + *, + name: str, + default: int, + description: str, + minimum: int | None = None, + maximum: int | None = None, + ) -> "RuleOption[int]": + """Declare a whole-number option with optional bounds.""" + + return RuleOption[int]( + name=name, + value_type=int, + default=default, + description=description, + minimum=minimum, + maximum=maximum, + ) + + @classmethod + def string( + cls, *, name: str, default: str, description: str, choices: tuple[str, ...] = () + ) -> "RuleOption[str]": + """Declare a text option, optionally limited to choices.""" + + return RuleOption[str]( + name=name, value_type=str, default=default, description=description, choices=choices + ) + + @classmethod + def string_list( + cls, *, name: str, default: tuple[str, ...], description: str + ) -> "RuleOption[tuple[str, ...]]": + """Declare a list-of-text option.""" + + return RuleOption[tuple[str, ...]]( + name=name, value_type=tuple, default=default, description=description, item_type=str + ) + + @classmethod + def integer_list( + cls, *, name: str, default: tuple[int, ...], description: str + ) -> "RuleOption[tuple[int, ...]]": + """Declare a list-of-whole-numbers option.""" + + return RuleOption[tuple[int, ...]]( + name=name, value_type=tuple, default=default, description=description, item_type=int + ) + + def parse(self, value: object) -> T: + """Return a configured value as this option's type, or raise RuleOptionError.""" + + if self.item_type is not None: + return self._parse_list(value) + if not self._is_scalar(value=value, expected=self.value_type): + raise RuleOptionError( + f"option {self.name} expects {self.value_type.__name__}, got {value!r}" + ) + if self.choices and value not in self.choices: + raise RuleOptionError(f"option {self.name} must be one of {list(self.choices)!r}") + if isinstance(value, int) and not isinstance(value, bool): + self._check_bounds(value) + return cast(T, value) + + def _parse_list(self, value: object) -> T: + if not isinstance(value, list | tuple): + raise RuleOptionError(f"option {self.name} expects a list, got {value!r}") + items: tuple[object, ...] = tuple(value) + item_type: type = self.item_type or str + if not all(self._is_scalar(value=item, expected=item_type) for item in items): + raise RuleOptionError(f"option {self.name} expects a list of {item_type.__name__}") + return cast(T, items) + + def _check_bounds(self, value: int) -> None: + if self.minimum is not None and value < self.minimum: + raise RuleOptionError(f"option {self.name} must be at least {self.minimum}") + if self.maximum is not None and value > self.maximum: + raise RuleOptionError(f"option {self.name} must be at most {self.maximum}") + + @staticmethod + def _is_scalar(*, value: object, expected: type) -> bool: + if expected is int and isinstance(value, bool): + return False + return isinstance(value, expected) diff --git a/src/sqlclean/rules/constants.py b/src/sqlclean/rules/constants.py new file mode 100644 index 0000000..878101b --- /dev/null +++ b/src/sqlclean/rules/constants.py @@ -0,0 +1,12 @@ +"""Constants of the custom Rule authoring API.""" + +RULE_ATTRIBUTE: str = "__sqlclean_rule__" +CUSTOM_CODE_PREFIX: str = "XSQC" +RULE_CODE_DIGIT_COUNT: int = 3 +RULE_PARAMETER_COUNT: int = 2 +SIGNATURE_REQUIREMENT: str = ( + "must accept exactly one typed Statement, SqlFile or Repository subject and one typed " + "RuleContext, both keyword-only" +) +POLYGLOT_MODULE: str = "polyglot_sql" +POLYGLOT_INSTALL_HINT: str = "install the ast extra: pip install 'sqlclean[ast]'" diff --git a/src/sqlclean/rules/exceptions.py b/src/sqlclean/rules/exceptions.py new file mode 100644 index 0000000..ff3d556 --- /dev/null +++ b/src/sqlclean/rules/exceptions.py @@ -0,0 +1,17 @@ +"""Errors of the custom Rule authoring API.""" + + +class SqlCleanError(Exception): + """Base class of every error SQLClean raises.""" + + +class RuleDefinitionError(SqlCleanError): + """A @rule declaration is invalid.""" + + +class RuleOptionError(SqlCleanError): + """A configured Rule option value is invalid.""" + + +class RuleUsageError(SqlCleanError): + """A Rule used the RuleContext API incorrectly.""" diff --git a/src/sqlclean/rules/main/__init__.py b/src/sqlclean/rules/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sqlclean/rules/main/define.py b/src/sqlclean/rules/main/define.py new file mode 100644 index 0000000..f3a5436 --- /dev/null +++ b/src/sqlclean/rules/main/define.py @@ -0,0 +1,30 @@ +"""The @rule decorator that declares a custom Rule.""" + +from collections.abc import Callable + +from sqlclean.rules._helpers.definition import define_rule +from sqlclean.rules.classes.rule_option import RuleOption +from sqlclean.rules.types import FixSafety, RuleCheck + + +def rule( + *, + code: str, + message: str, + remediation: str, + slug: str | None = None, + options: tuple[RuleOption[object], ...] = (), + enabled_by_default: bool = False, + fixable: FixSafety | str | None = None, +) -> Callable[[RuleCheck], RuleCheck]: + """Declare one custom Rule while returning the function unchanged.""" + + return define_rule( + code=code, + slug=slug if slug is not None else code.lower(), + message=message, + remediation=remediation, + options=options, + enabled_by_default=enabled_by_default, + fixable=None if fixable is None else FixSafety(fixable), + ) diff --git a/src/sqlclean/rules/models.py b/src/sqlclean/rules/models.py new file mode 100644 index 0000000..ab8c093 --- /dev/null +++ b/src/sqlclean/rules/models.py @@ -0,0 +1,194 @@ +"""Structured values Rules receive and return.""" + +from dataclasses import dataclass, field +from functools import cached_property +from importlib import import_module +from types import ModuleType + +from sqlclean.rules.classes.rule_option import RuleOption +from sqlclean.rules.constants import POLYGLOT_INSTALL_HINT, POLYGLOT_MODULE +from sqlclean.rules.exceptions import RuleUsageError +from sqlclean.rules.types import FixSafety, RuleCheck, RuleSubjectKind + + +@dataclass(frozen=True, slots=True) +class Edit: + """Replace the characters start..end of a file with text.""" + + start: int + end: int + text: str + + +@dataclass(frozen=True, slots=True) +class Finding: + """One Rule violation located by character offsets in its file.""" + + code: str + path: str + start: int + end: int + message: str + remediation: str + edits: tuple[Edit, ...] = () + + +@dataclass(frozen=True, slots=True) +class Cte: + """A common table expression, located at its name.""" + + name: str + text: str + start: int + end: int + line: int + column: int + + +@dataclass(frozen=True, slots=True) +class Table: + """A relation read in FROM or JOIN, located at its name.""" + + name: str + qualified_name: str + alias: str | None + text: str + start: int + end: int + line: int + column: int + + +@dataclass(frozen=True, slots=True) +class Join: + """One join, located at the relation it joins.""" + + kind: str + relation: str | None + has_condition: bool + text: str + start: int + end: int + line: int + column: int + + +@dataclass(frozen=True, slots=True) +class Star: + """A * or qualifier.* projection.""" + + qualifier: str | None + text: str + start: int + end: int + line: int + column: int + + +@dataclass(frozen=True, slots=True) +class FunctionCall: + """A call of a named function, located at its name.""" + + name: str + text: str + start: int + end: int + line: int + column: int + + +@dataclass(frozen=True, slots=True) +class ColumnReference: + """A column reference with its qualifier when written.""" + + name: str + qualifier: str | None + text: str + start: int + end: int + line: int + column: int + + +@dataclass(frozen=True, slots=True) +class Literal: + """A string or numeric literal as written.""" + + kind: str + text: str + start: int + end: int + line: int + column: int + + +@dataclass(frozen=True) +class Statement: + """One statement of a SQL file, located by character offsets in that file.""" + + path: str + index: int + kind: str + text: str + start: int + end: int + line: int + column: int + dialect: str + ctes: tuple[Cte, ...] + tables: tuple[Table, ...] + joins: tuple[Join, ...] + stars: tuple[Star, ...] + functions: tuple[FunctionCall, ...] + columns: tuple[ColumnReference, ...] + literals: tuple[Literal, ...] + + def polyglot_ast(self) -> object: + """Return Polyglot's syntax tree of this statement, parsed on first use.""" + + return self._polyglot_ast + + @cached_property + def _polyglot_ast(self) -> object: + try: + polyglot: ModuleType = import_module(POLYGLOT_MODULE) + except ImportError as error: + raise RuleUsageError( + f"polyglot_ast() needs the Polyglot SQL package: {POLYGLOT_INSTALL_HINT}" + ) from error + return polyglot.parse_one(self.text, dialect=self.dialect) + + +@dataclass(frozen=True) +class SqlFile: + """One SQL file and its statements.""" + + path: str + source: str + dialect: str + statements: tuple[Statement, ...] + + +@dataclass(frozen=True) +class Repository: + """Every checked SQL file, for Rules about conventions across files.""" + + files: tuple[SqlFile, ...] + + +@dataclass(frozen=True, slots=True) +class RuleDefinition: + """The metadata @rule attaches to a Rule function.""" + + code: str + slug: str + message: str + remediation: str + check: RuleCheck + subject: RuleSubjectKind + subject_parameter: str + context_parameter: str + options: tuple[RuleOption[object], ...] = () + enabled_by_default: bool = False + fixable: FixSafety | None = None + module: str = field(default="") diff --git a/src/sqlclean/rules/types.py b/src/sqlclean/rules/types.py new file mode 100644 index 0000000..82d25fb --- /dev/null +++ b/src/sqlclean/rules/types.py @@ -0,0 +1,33 @@ +"""Type declarations of the custom Rule authoring API.""" + +from collections.abc import Callable, Sequence +from enum import StrEnum +from typing import Protocol + +type RuleOptionValue = bool | int | str | tuple[str, ...] | tuple[int, ...] +type RuleCheck = Callable[..., Sequence[object]] + + +class FixSafety(StrEnum): + """Whether a Rule's fix keeps query results (safe) or may change them (unsafe).""" + + SAFE = "safe" + UNSAFE = "unsafe" + + +class RuleSubjectKind(StrEnum): + """The subject a Rule evaluates.""" + + STATEMENT = "statement" + FILE = "file" + REPOSITORY = "repository" + + +class Located(Protocol): + """Anything with a character range in its file, such as a statement or a SQL node.""" + + @property + def start(self) -> int: ... + + @property + def end(self) -> int: ... diff --git a/src/sqlclean/testing.py b/src/sqlclean/testing.py new file mode 100644 index 0000000..2be0bbc --- /dev/null +++ b/src/sqlclean/testing.py @@ -0,0 +1,6 @@ +"""Test harness for custom Rules.""" + +from sqlclean.rule_testing.main.evaluate_rule import evaluate_rule +from sqlclean.rule_testing.models import RuleCase, RuleFile, RuleResult + +__all__ = ["RuleCase", "RuleFile", "RuleResult", "evaluate_rule"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/src/__init__.py b/tests/e2e/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/src/sqlclean/__init__.py b/tests/e2e/src/sqlclean/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/src/sqlclean/hosting/__init__.py b/tests/e2e/src/sqlclean/hosting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/src/sqlclean/hosting/_test_types.py b/tests/e2e/src/sqlclean/hosting/_test_types.py new file mode 100644 index 0000000..e47c068 --- /dev/null +++ b/tests/e2e/src/sqlclean/hosting/_test_types.py @@ -0,0 +1,93 @@ +"""Test case types for the sqlclean command.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class CheckTestCase: + """A project, a check invocation and the findings and exit status it must give.""" + + description: str + config: str + files: dict[str, str] + arguments: tuple[str, ...] + expected_exit: int + expected_codes: tuple[str, ...] + expected_stale: tuple[str, ...] = () + command: str = "check" + + +@dataclass(frozen=True) +class RewriteTestCase: + """A project, a fix or format invocation and the file contents it must leave.""" + + description: str + config: str + files: dict[str, str] + arguments: tuple[str, ...] + expected_exit: int + expected_files: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class IdempotencyTestCase: + """Unformatted SQL that a second format run must leave unchanged.""" + + description: str + source: str + expected_exits: tuple[int, int] + + +@dataclass(frozen=True) +class CacheTestCase: + """A project checked, rechecked without Python, then rechecked after an edit.""" + + description: str + files: dict[str, str] + edited: dict[str, str] + expected_exits: tuple[int, int, int] + expected_cached_code: str + expected_error: str + + +@dataclass(frozen=True) +class SummaryTestCase: + """SQL whose text report must end with one summary line.""" + + description: str + config: str + source: str + expected_summary: str + + +@dataclass(frozen=True) +class SarifTestCase: + """A project whose SARIF report must carry one result message.""" + + description: str + config: str + source: str + expected_message: str + + +@dataclass(frozen=True) +class ManyFilesTestCase: + """Enough files for custom Rules to run on several hosts, every flagged_every-th one flagged.""" + + description: str + file_count: int + flagged_every: int + expected_finding_count: int + + +@dataclass(frozen=True) +class SkillsTestCase: + """Files already in a project, a skills invocation and the skill entries it must leave.""" + + description: str + existing: dict[str, str] + arguments: tuple[str, ...] + expected_exit: int + expected_entries: dict[str, bool] diff --git a/tests/e2e/src/sqlclean/hosting/helpers.py b/tests/e2e/src/sqlclean/hosting/helpers.py new file mode 100644 index 0000000..a4c972f --- /dev/null +++ b/tests/e2e/src/sqlclean/hosting/helpers.py @@ -0,0 +1,45 @@ +"""Run the installed sqlclean executable on a temporary project.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sysconfig +from pathlib import Path + + +def executable() -> str: + """Return the sqlclean executable installed beside this interpreter, else the one on PATH.""" + + found: str | None = shutil.which( + "sqlclean", path=sysconfig.get_path("scripts") + ) or shutil.which("sqlclean") + assert found is not None, "install sqlclean before running the command tests" + return found + + +def write_project(root: Path, *, config: str, files: dict[str, str]) -> None: + """Write sqlclean.toml and every file under root with LF line endings on every platform.""" + + (root / "sqlclean.toml").write_text(config, encoding="utf-8", newline="\n") + for path, text in files.items(): + target: Path = root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8", newline="\n") + + +def run( + root: Path, *arguments: str, environment: dict[str, str] +) -> subprocess.CompletedProcess[str]: + """Run sqlclean with arguments in root, with environment added, and return the process.""" + + return subprocess.run( + [executable(), *arguments], + cwd=root, + capture_output=True, + text=True, + encoding="utf-8", + check=False, + env={**os.environ, **environment}, + ) diff --git a/tests/e2e/src/sqlclean/hosting/test_sqlclean_command.py b/tests/e2e/src/sqlclean/hosting/test_sqlclean_command.py new file mode 100644 index 0000000..279a38a --- /dev/null +++ b/tests/e2e/src/sqlclean/hosting/test_sqlclean_command.py @@ -0,0 +1,451 @@ +"""End-to-end tests for the sqlclean command on temporary projects.""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from tests.e2e.src.sqlclean.hosting._test_types import ( + CacheTestCase, + CheckTestCase, + IdempotencyTestCase, + ManyFilesTestCase, + RewriteTestCase, + SarifTestCase, + SkillsTestCase, + SummaryTestCase, +) +from tests.e2e.src.sqlclean.hosting.helpers import run, write_project +from tests.integration.src.sqlclean.rule_testing.helpers import SAMPLE_RULES + +DUCKDB: str = 'dialect = "duckdb"\n' +CROSS_JOIN_ONLY: str = DUCKDB + '[rules]\nselect = ["SQC038"]\n' +CUSTOM: str = DUCKDB + '[rules]\nselect = ["XSQCNAME", "XSQCSTYLE"]\ncustom = ["sql_rules"]\n' +CROSS_JOIN_EXCEPTIONS: str = ( + CROSS_JOIN_ONLY + + '[[rules.rule_exceptions]]\nrule = "SQC038"\npath = "models/q.sql"\nrelation = "b"\n' + + 'reason = "Every pair is needed."\n' + + '[[rules.rule_exceptions]]\nrule = "SQC038"\npath = "models/q.sql"\nrelation = "z"\n' + + 'reason = "Removed join."\n' +) +NULL_AND_COMMA: str = "select a.id from a, b where a.x = null\n" + + +@pytest.mark.parametrize( + "test_case", + [ + CheckTestCase( + description="a clean file passes", + config=CROSS_JOIN_ONLY, + files={"models/q.sql": "SELECT a.id FROM a\n"}, + arguments=(), + expected_exit=0, + expected_codes=(), + ), + CheckTestCase( + description="selected Rules report in file order", + config=DUCKDB, + files={"models/q.sql": NULL_AND_COMMA}, + arguments=("--select", "SQC001,SQC002"), + expected_exit=1, + expected_codes=("SQC002", "SQC001"), + ), + CheckTestCase( + description="an ignored Rule is skipped", + config=DUCKDB, + files={"models/q.sql": NULL_AND_COMMA}, + arguments=("--select", "SQC001,SQC002", "--ignore", "SQC002"), + expected_exit=1, + expected_codes=("SQC001",), + ), + CheckTestCase( + description="an exception hides its finding and an unmatched one is stale", + config=CROSS_JOIN_EXCEPTIONS, + files={"models/q.sql": "SELECT a.id FROM a CROSS JOIN b\n"}, + arguments=(), + expected_exit=1, + expected_codes=(), + expected_stale=("z",), + ), + CheckTestCase( + description="a bare --config file name is read from the working directory", + config=DUCKDB, + files={ + "strict.toml": CROSS_JOIN_ONLY, + "models/q.sql": "SELECT a.id FROM a CROSS JOIN b\n", + }, + arguments=("--config", "strict.toml"), + expected_exit=1, + expected_codes=("SQC038",), + ), + CheckTestCase( + description="lint is check by another name", + config=CROSS_JOIN_ONLY, + files={"models/q.sql": "SELECT a.id FROM a CROSS JOIN b\n"}, + arguments=(), + command="lint", + expected_exit=1, + expected_codes=("SQC038",), + ), + CheckTestCase( + description="unparseable SQL is reported as a finding", + config=CROSS_JOIN_ONLY, + files={"models/q.sql": "SELECT FROM WHERE (\n"}, + arguments=(), + expected_exit=1, + expected_codes=("SQC999",), + ), + CheckTestCase( + description="custom Rules run with their configured options", + config=CUSTOM + '[rules.rule_options.XSQCNAME001]\nprefixes = ["stg_"]\n', + files={ + "sql_rules/naming.py": SAMPLE_RULES, + "models/q.sql": "SELECT COUNT(1) AS n FROM stg_orders JOIN tmp_x ON TRUE\n", + }, + arguments=(), + expected_exit=1, + expected_codes=("XSQCSTYLE001", "XSQCNAME001"), + ), + ], + ids=lambda case: case.description, +) +def test_given_project_when_checking_then_reports_findings_and_exit_status( + tmp_path: Path, test_case: CheckTestCase +) -> None: + write_project(tmp_path, config=test_case.config, files=test_case.files) + + completed: subprocess.CompletedProcess[str] = run( + tmp_path, test_case.command, "--output-format", "json", *test_case.arguments, environment={} + ) + report: dict[str, list[dict[str, object]]] = json.loads(completed.stdout) + + assert completed.returncode == test_case.expected_exit, completed.stderr + assert tuple(finding["code"] for finding in report["findings"]) == test_case.expected_codes + assert ( + tuple(stale["relation"] for stale in report["stale_exceptions"]) == test_case.expected_stale + ) + + +@pytest.mark.parametrize( + "test_case", + [ + RewriteTestCase( + description="fix applies safe fixes, leaves unsafe ones and formats", + config=DUCKDB + '[rules]\nselect = ["SQC001", "SQC002"]\n', + files={"models/q.sql": NULL_AND_COMMA}, + arguments=("fix",), + expected_exit=1, + expected_files={ + "models/q.sql": "SELECT a.id\nFROM a\nCROSS JOIN b\nWHERE a.x = NULL\n" + }, + ), + RewriteTestCase( + description="fix with unsafe fixes applies them too and then passes", + config=DUCKDB + '[rules]\nselect = ["SQC001", "SQC002"]\n', + files={"models/q.sql": NULL_AND_COMMA}, + arguments=("fix", "--unsafe-fixes"), + expected_exit=0, + expected_files={ + "models/q.sql": "SELECT a.id\nFROM a\nCROSS JOIN b\nWHERE a.x IS NULL\n" + }, + ), + RewriteTestCase( + description="fix applies custom Rule fixes", + config=CUSTOM, + files={ + "sql_rules/naming.py": SAMPLE_RULES, + "models/q.sql": "select count(1) as n from orders\n", + }, + arguments=("fix",), + expected_exit=0, + expected_files={"models/q.sql": "SELECT COUNT(*) AS n\nFROM orders\n"}, + ), + RewriteTestCase( + description="fix --diff writes nothing", + config=DUCKDB + '[rules]\nselect = ["SQC002"]\n', + files={"models/q.sql": "select a.id from a, b\n"}, + arguments=("fix", "--diff"), + expected_exit=1, + expected_files={"models/q.sql": "select a.id from a, b\n"}, + ), + RewriteTestCase( + description="format --check fails without writing", + config=DUCKDB, + files={"models/q.sql": "select 1;select 2\n"}, + arguments=("format", "--check"), + expected_exit=1, + expected_files={"models/q.sql": "select 1;select 2\n"}, + ), + RewriteTestCase( + description="format writes each statement apart with one final newline", + config=DUCKDB, + files={"models/q.sql": "select 1;select 2"}, + arguments=("format",), + expected_exit=0, + expected_files={"models/q.sql": "SELECT 1;\n\nSELECT 2\n"}, + ), + RewriteTestCase( + description="format indents four spaces by default", + config=DUCKDB, + files={"models/q.sql": "with a as (select 1 as x) select x from a\n"}, + arguments=("format",), + expected_exit=0, + expected_files={ + "models/q.sql": "WITH a AS (\n SELECT 1 AS x\n)\n\nSELECT x\nFROM a\n" + }, + ), + RewriteTestCase( + description="format indents by the configured width", + config=DUCKDB + "[format]\nindent = 2\n", + files={"models/q.sql": "with a as (select 1 as x) select x from a\n"}, + arguments=("format",), + expected_exit=0, + expected_files={ + "models/q.sql": "WITH a AS (\n SELECT 1 AS x\n)\n\nSELECT x\nFROM a\n" + }, + ), + RewriteTestCase( + description="a file of only comments keeps its final newline", + config=DUCKDB, + files={"models/q.sql": "-- only a comment\n"}, + arguments=("format", "--check"), + expected_exit=0, + expected_files={"models/q.sql": "-- only a comment\n"}, + ), + ], + ids=lambda case: case.description, +) +def test_given_project_when_rewriting_then_files_and_exit_status_match( + tmp_path: Path, test_case: RewriteTestCase +) -> None: + write_project(tmp_path, config=test_case.config, files=test_case.files) + + completed: subprocess.CompletedProcess[str] = run( + tmp_path, *test_case.arguments, environment={} + ) + files: dict[str, str] = { + path: (tmp_path / path).read_text(encoding="utf-8") for path in test_case.expected_files + } + + assert completed.returncode == test_case.expected_exit, completed.stdout + completed.stderr + assert files == test_case.expected_files + + +@pytest.mark.parametrize( + "test_case", + [ + SummaryTestCase( + description="a safe fix is fixable with plain fix", + config=DUCKDB + '[rules]\nselect = ["SQC002"]\n', + source="select a.id from a, b\n", + expected_summary="Found 1 problem (1 fixable with `sqlclean fix`).", + ), + SummaryTestCase( + description="an unsafe fix needs --unsafe-fixes", + config=DUCKDB + '[rules]\nselect = ["SQC001"]\n', + source="select a.id from a where a.x = null\n", + expected_summary="Found 1 problem (1 fixable with `sqlclean fix --unsafe-fixes`).", + ), + SummaryTestCase( + description="safe and unsafe fixes are counted apart", + config=DUCKDB + '[rules]\nselect = ["SQC001", "SQC002"]\n', + source=NULL_AND_COMMA, + expected_summary=( + "Found 2 problems (1 fixable with `sqlclean fix`, 1 more with `--unsafe-fixes`)." + ), + ), + ], + ids=lambda case: case.description, +) +def test_given_fixable_findings_when_checking_then_summary_counts_safe_and_unsafe_fixes( + tmp_path: Path, test_case: SummaryTestCase +) -> None: + write_project(tmp_path, config=test_case.config, files={"q.sql": test_case.source}) + + completed: subprocess.CompletedProcess[str] = run(tmp_path, "check", environment={}) + + assert completed.stdout.rstrip().splitlines()[-1] == test_case.expected_summary + + +@pytest.mark.parametrize( + "test_case", + [ + SarifTestCase( + description="the message and remediation read as two sentences", + config=DUCKDB + '[rules]\nselect = ["SQC025"]\n', + source="select a.id from a join b on a.id = b.id\n", + expected_message="JOIN type is implicit. Write INNER JOIN explicitly.", + ), + ], + ids=lambda case: case.description, +) +def test_given_findings_when_reporting_sarif_then_each_result_has_a_readable_message( + tmp_path: Path, test_case: SarifTestCase +) -> None: + write_project(tmp_path, config=test_case.config, files={"q.sql": test_case.source}) + + completed: subprocess.CompletedProcess[str] = run( + tmp_path, "check", "--output-format", "sarif", environment={} + ) + results: list[dict[str, dict[str, str]]] = json.loads(completed.stdout)["runs"][0]["results"] + + assert [result["message"]["text"] for result in results] == [test_case.expected_message] + + +@pytest.mark.parametrize( + "test_case", + [ + IdempotencyTestCase( + description="a formatted query is a fixed point of the formatter", + source="select a, b from t where a > 1 and b in (select b from u) order by a", + expected_exits=(0, 0), + ), + ], + ids=lambda case: case.description, +) +def test_given_formatted_file_when_formatting_again_then_nothing_changes( + tmp_path: Path, test_case: IdempotencyTestCase +) -> None: + write_project(tmp_path, config=DUCKDB, files={"q.sql": test_case.source}) + + first: subprocess.CompletedProcess[str] = run(tmp_path, "format", environment={}) + formatted: str = (tmp_path / "q.sql").read_text(encoding="utf-8") + second: subprocess.CompletedProcess[str] = run(tmp_path, "format", "--check", environment={}) + + assert (first.returncode, second.returncode) == test_case.expected_exits, second.stdout + assert (tmp_path / "q.sql").read_text(encoding="utf-8") == formatted + + +@pytest.mark.parametrize( + "test_case", + [ + CacheTestCase( + description="unchanged files reuse custom Rule results and edited files rerun them", + files={ + "sql_rules/naming.py": SAMPLE_RULES, + "models/q.sql": "SELECT * FROM tmp_orders\n", + }, + edited={"models/q.sql": "SELECT * FROM tmp_customers\n"}, + expected_exits=(1, 1, 2), + expected_cached_code="XSQCNAME001", + expected_error="missing-python", + ), + ], + ids=lambda case: case.description, +) +def test_given_unchanged_files_when_checking_again_then_custom_rules_come_from_the_cache( + tmp_path: Path, test_case: CacheTestCase +) -> None: + write_project(tmp_path, config=CUSTOM, files=test_case.files) + no_python: dict[str, str] = {"SQLCLEAN_PYTHON": str(tmp_path / "missing-python")} + + first: subprocess.CompletedProcess[str] = run(tmp_path, "check", environment={}) + cached: subprocess.CompletedProcess[str] = run(tmp_path, "check", environment=no_python) + write_project(tmp_path, config=CUSTOM, files=test_case.edited) + changed: subprocess.CompletedProcess[str] = run(tmp_path, "check", environment=no_python) + + assert (first.returncode, cached.returncode, changed.returncode) == test_case.expected_exits + assert test_case.expected_cached_code in cached.stdout + assert test_case.expected_error in changed.stderr + + +@pytest.mark.parametrize( + "test_case", + [ + ManyFilesTestCase( + description="1,200 files split across hosts report every finding in file order", + file_count=1200, + flagged_every=3, + expected_finding_count=400, + ), + ], + ids=lambda case: case.description, +) +def test_given_many_files_when_checking_custom_rules_then_every_host_share_reports( + tmp_path: Path, test_case: ManyFilesTestCase +) -> None: + flagged: range = range(0, test_case.file_count, test_case.flagged_every) + files: dict[str, str] = { + **{f"models/m{index:04}.sql": "SELECT a FROM x\n" for index in range(test_case.file_count)}, + **{f"models/m{index:04}.sql": "SELECT a FROM tmp_x\n" for index in flagged}, + } + write_project(tmp_path, config=CUSTOM, files={"sql_rules/naming.py": SAMPLE_RULES, **files}) + + completed: subprocess.CompletedProcess[str] = run( + tmp_path, "check", "--output-format", "json", "--select", "XSQCNAME001", environment={} + ) + report: dict[str, list[dict[str, object]]] = json.loads(completed.stdout) + + assert completed.returncode == 1, completed.stderr + assert len(report["findings"]) == test_case.expected_finding_count + assert [finding["path"] for finding in report["findings"]] == [ + f"models/m{index:04}.sql" for index in flagged + ] + + +GENERATED: str = "" +STALE_PAGE: str = ".claude/skills/sqlclean/references/docs/removed.md" + + +@pytest.mark.parametrize( + "test_case", + [ + SkillsTestCase( + description="default targets get the skill and its bundled docs", + existing={}, + arguments=(), + expected_exit=0, + expected_entries={ + ".agents/skills/sqlclean/SKILL.md": True, + ".agents/skills/sqlclean/references/docs/CONTENTS.md": True, + ".claude/skills/sqlclean/references/docs/configuration.md": True, + ".opencode/skills/sqlclean/SKILL.md": False, + }, + ), + SkillsTestCase( + description="a generated page the package no longer ships is removed", + existing={STALE_PAGE: GENERATED + "\n"}, + arguments=("--target", "claude"), + expected_exit=0, + expected_entries={STALE_PAGE: False, ".claude/skills/sqlclean/SKILL.md": True}, + ), + SkillsTestCase( + description="a hand-written skill file is not overwritten", + existing={".claude/skills/sqlclean/SKILL.md": "my notes\n"}, + arguments=(), + expected_exit=2, + expected_entries={ + ".claude/skills/sqlclean/SKILL.md": False, + ".agents/skills/sqlclean/SKILL.md": False, + }, + ), + SkillsTestCase( + description="--force replaces a hand-written skill file", + existing={".claude/skills/sqlclean/SKILL.md": "my notes\n"}, + arguments=("--force", "--target", "claude"), + expected_exit=0, + expected_entries={".claude/skills/sqlclean/SKILL.md": True}, + ), + ], + ids=lambda case: case.description, +) +def test_given_project_when_installing_skills_then_only_generated_files_are_written( + tmp_path: Path, test_case: SkillsTestCase +) -> None: + write_project(tmp_path, config=DUCKDB, files=test_case.existing) + + completed: subprocess.CompletedProcess[str] = run( + tmp_path, "skills", *test_case.arguments, environment={} + ) + + assert completed.returncode == test_case.expected_exit, completed.stderr + generated: dict[str, bool] = { + path.relative_to(tmp_path).as_posix(): GENERATED in path.read_text(encoding="utf-8") + for path in tmp_path.rglob("*.md") + } + assert { + path: generated.get(path, False) for path in test_case.expected_entries + } == test_case.expected_entries diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/src/__init__.py b/tests/integration/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/src/sqlclean/__init__.py b/tests/integration/src/sqlclean/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/src/sqlclean/rule_testing/__init__.py b/tests/integration/src/sqlclean/rule_testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/src/sqlclean/rule_testing/_test_types.py b/tests/integration/src/sqlclean/rule_testing/_test_types.py new file mode 100644 index 0000000..bb99f7e --- /dev/null +++ b/tests/integration/src/sqlclean/rule_testing/_test_types.py @@ -0,0 +1,28 @@ +"""Test case types for the custom Rule harness.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlclean.rule_testing.models import RuleCase + + +@dataclass(frozen=True) +class HarnessTestCase: + """A Rule, its case and the findings and fixed source the harness must return.""" + + description: str + rule_name: str + rule_case: RuleCase + expected_texts: tuple[str, ...] + expected_fixed_source: str + + +@dataclass(frozen=True) +class HarnessFailureTestCase: + """A Rule case the harness must reject, and the reason it gives.""" + + description: str + rule_name: str + rule_case: RuleCase + expected_error: str diff --git a/tests/integration/src/sqlclean/rule_testing/helpers.py b/tests/integration/src/sqlclean/rule_testing/helpers.py new file mode 100644 index 0000000..7a479cd --- /dev/null +++ b/tests/integration/src/sqlclean/rule_testing/helpers.py @@ -0,0 +1,105 @@ +"""The sample custom Rules, as users would write them, and their loader.""" + +from __future__ import annotations + +import importlib.util +from importlib.machinery import ModuleSpec +from pathlib import Path +from types import ModuleType + +from sqlclean.rules.types import RuleCheck + +SAMPLE_RULES: str = """ +from sqlclean import FixSafety, Repository, RuleContext, RuleOption, SqlFile, Statement, rule + +MAX_JOINS = RuleOption.integer( + name="max_joins", default=1, description="Most joins one statement may use.", minimum=0 +) +PREFIXES = RuleOption.string_list( + name="prefixes", default=("tmp_",), description="Forbidden table prefixes." +) + + +@rule( + code="XSQCSHAPE001", + message="Statement joins too many relations", + remediation="Split the statement into CTEs.", + options=(MAX_JOINS,), +) +def too_many_joins(*, statement: Statement, ctx: RuleContext) -> list: + over_limit = statement.joins[ctx.option(MAX_JOINS):] + return [ctx.finding(subject=statement, node=join) for join in over_limit[:1]] + + +@rule( + code="XSQCNAME001", + message="Table name uses a forbidden prefix", + remediation="Read from the published table.", + options=(PREFIXES,), + enabled_by_default=True, +) +def forbidden_prefix(*, statement: Statement, ctx: RuleContext) -> list: + prefixes = ctx.option(PREFIXES) + return [ + ctx.finding(subject=statement, node=table) + for table in statement.tables + if table.name.startswith(prefixes) + ] + + +@rule( + code="XSQCSTYLE001", + message="COUNT(1) should be COUNT(*)", + remediation="Write COUNT(*).", + fixable=FixSafety.SAFE, + enabled_by_default=True, +) +def count_one(*, statement: Statement, ctx: RuleContext) -> list: + counts = [function for function in statement.functions if function.name.upper() == "COUNT"] + called = {function.end + 1 for function in counts} + return [ + ctx.finding(subject=statement, node=literal, fix=[ctx.replace(node=literal, text="*")]) + for literal in statement.literals + if literal.text == "1" and literal.start in called + ] + + +@rule( + code="XSQCFILE001", + message="File holds more than one statement", + remediation="Keep one statement per file.", +) +def one_statement(*, file: SqlFile, ctx: RuleContext) -> list: + return [ctx.finding(subject=file)] if len(file.statements) > 1 else [] + + +@rule( + code="XSQCREPO001", + message="Table is read by only one file", + remediation="Inline the table or share it.", +) +def single_reader(*, repository: Repository, ctx: RuleContext) -> list: + readers = {} + for file in repository.files: + for statement in file.statements: + for table in statement.tables: + readers.setdefault(table.name, []).append((statement, table)) + return [ + ctx.finding(subject=uses[0][0], node=uses[0][1]) + for uses in readers.values() + if len({statement.path for statement, _ in uses}) == 1 + ] +""" + + +def sample_rule(directory: Path, name: str) -> RuleCheck: + """Return the sample Rule function called name, imported from a module under directory.""" + + path: Path = directory / "sample_rules.py" + path.write_text(SAMPLE_RULES, encoding="utf-8") + spec: ModuleSpec | None = importlib.util.spec_from_file_location("sample_rules", path) + assert spec is not None and spec.loader is not None + module: ModuleType = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + check: RuleCheck = getattr(module, name) + return check diff --git a/tests/integration/src/sqlclean/rule_testing/test_evaluate_rule.py b/tests/integration/src/sqlclean/rule_testing/test_evaluate_rule.py new file mode 100644 index 0000000..d04a50c --- /dev/null +++ b/tests/integration/src/sqlclean/rule_testing/test_evaluate_rule.py @@ -0,0 +1,149 @@ +"""Tests for evaluate_rule over SQL parsed by the native sqlclean executable.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from sqlclean.rules.exceptions import SqlCleanError +from sqlclean.rules.types import RuleCheck +from sqlclean.testing import RuleCase, RuleFile, RuleResult, evaluate_rule +from tests.integration.src.sqlclean.rule_testing._test_types import ( + HarnessFailureTestCase, + HarnessTestCase, +) +from tests.integration.src.sqlclean.rule_testing.helpers import sample_rule + +JOINED: str = "SELECT a.id FROM a JOIN b ON a.id = b.id JOIN c ON b.id = c.id" + + +@pytest.mark.parametrize( + "test_case", + [ + HarnessTestCase( + description="the default limit reports the first join over it", + rule_name="too_many_joins", + rule_case=RuleCase(description="two joins", source=JOINED, expected_finding_count=1), + expected_texts=("JOIN c ON b.id = c.id",), + expected_fixed_source=JOINED, + ), + HarnessTestCase( + description="a configured limit replaces the default", + rule_name="too_many_joins", + rule_case=RuleCase( + description="limit two", + source=JOINED, + expected_finding_count=0, + options={"max_joins": 2}, + ), + expected_texts=(), + expected_fixed_source=JOINED, + ), + HarnessTestCase( + description="a fix after a multibyte literal edits by character offset", + rule_name="count_one", + rule_case=RuleCase( + description="count one", + source="SELECT 'é' AS mark, COUNT(1) AS n FROM t", + expected_finding_count=1, + ), + expected_texts=("1",), + expected_fixed_source="SELECT 'é' AS mark, COUNT(*) AS n FROM t", + ), + HarnessTestCase( + description="a file Rule sees every statement of the file", + rule_name="one_statement", + rule_case=RuleCase( + description="two statements", + source="SELECT 1;\nSELECT 2;\n", + expected_finding_count=1, + ), + expected_texts=("",), + expected_fixed_source="SELECT 1;\nSELECT 2;\n", + ), + HarnessTestCase( + description="a repository Rule reads the supporting files", + rule_name="single_reader", + rule_case=RuleCase( + description="orders read twice, customers once", + source="SELECT * FROM orders", + path="models/a.sql", + files=(RuleFile(path="models/b.sql", source="SELECT * FROM orders, customers"),), + expected_finding_count=1, + ), + expected_texts=("customers",), + expected_fixed_source="SELECT * FROM orders", + ), + ], + ids=lambda case: case.description, +) +def test_given_rule_case_when_evaluating_then_findings_and_fixed_source_match( + tmp_path: Path, test_case: HarnessTestCase +) -> None: + rule: RuleCheck = sample_rule(tmp_path, test_case.rule_name) + + result: RuleResult = evaluate_rule(rule=rule, test_case=test_case.rule_case) + sources: dict[str, str] = { + test_case.rule_case.path: test_case.rule_case.source, + **{file.path: file.source for file in test_case.rule_case.files}, + } + texts: tuple[str, ...] = tuple( + sources[finding.path][finding.start : finding.end] for finding in result.findings + ) + + assert texts == test_case.expected_texts + assert result.fixed_source == test_case.expected_fixed_source + + +@pytest.mark.parametrize( + "test_case", + [ + HarnessFailureTestCase( + description="a wrong expected count fails with both counts", + rule_name="too_many_joins", + rule_case=RuleCase(description="two joins", source=JOINED, expected_finding_count=0), + expected_error="expected 0 findings, got 1", + ), + HarnessFailureTestCase( + description="an option below its minimum is rejected", + rule_name="too_many_joins", + rule_case=RuleCase( + description="negative limit", + source=JOINED, + expected_finding_count=0, + options={"max_joins": -1}, + ), + expected_error="max_joins must be at least 0", + ), + HarnessFailureTestCase( + description="an undeclared option is rejected", + rule_name="too_many_joins", + rule_case=RuleCase( + description="unknown option", + source=JOINED, + expected_finding_count=0, + options={"limit": 2}, + ), + expected_error="has no option limit", + ), + HarnessFailureTestCase( + description="SQL the parser rejects fails the case", + rule_name="too_many_joins", + rule_case=RuleCase( + description="broken", source="SELECT FROM WHERE (", expected_finding_count=0 + ), + expected_error="could not parse the case SQL", + ), + ], + ids=lambda case: case.description, +) +def test_given_invalid_rule_case_when_evaluating_then_reports_the_reason( + tmp_path: Path, test_case: HarnessFailureTestCase +) -> None: + rule: RuleCheck = sample_rule(tmp_path, test_case.rule_name) + + with pytest.raises(SqlCleanError) as raised: + evaluate_rule(rule=rule, test_case=test_case.rule_case) + + assert test_case.expected_error in str(raised.value) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/src/__init__.py b/tests/unit/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/src/sqlclean/__init__.py b/tests/unit/src/sqlclean/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/src/sqlclean/rules/__init__.py b/tests/unit/src/sqlclean/rules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/src/sqlclean/rules/_test_types.py b/tests/unit/src/sqlclean/rules/_test_types.py new file mode 100644 index 0000000..a4bc55b --- /dev/null +++ b/tests/unit/src/sqlclean/rules/_test_types.py @@ -0,0 +1,14 @@ +"""Test case types for the custom Rule authoring API.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RuleCodeTestCase: + """A custom Rule code @rule must accept or reject.""" + + description: str + code: str + expected_error: str | None diff --git a/tests/unit/src/sqlclean/rules/helpers.py b/tests/unit/src/sqlclean/rules/helpers.py new file mode 100644 index 0000000..5fcd58e --- /dev/null +++ b/tests/unit/src/sqlclean/rules/helpers.py @@ -0,0 +1,12 @@ +"""A Rule body for declaration tests.""" + +from __future__ import annotations + +from sqlclean import RuleContext, Statement +from sqlclean.rules.models import Finding + + +def empty_rule(*, statement: Statement, ctx: RuleContext) -> list[Finding]: + """Report nothing.""" + + return [] diff --git a/tests/unit/src/sqlclean/rules/test_rule_definition.py b/tests/unit/src/sqlclean/rules/test_rule_definition.py new file mode 100644 index 0000000..c10173f --- /dev/null +++ b/tests/unit/src/sqlclean/rules/test_rule_definition.py @@ -0,0 +1,69 @@ +"""Tests for @rule declarations.""" + +from __future__ import annotations + +import pytest + +from sqlclean import rule +from sqlclean.rules.constants import RULE_ATTRIBUTE +from sqlclean.rules.exceptions import RuleDefinitionError +from sqlclean.rules.types import RuleCheck +from tests.unit.src.sqlclean.rules._test_types import RuleCodeTestCase +from tests.unit.src.sqlclean.rules.helpers import empty_rule + + +@pytest.mark.parametrize( + "test_case", + [ + RuleCodeTestCase( + description="a bare custom prefix with three digits", + code="XSQC001", + expected_error=None, + ), + RuleCodeTestCase( + description="a family of capital letters", + code="XSQCNAME001", + expected_error=None, + ), + ], + ids=lambda case: case.description, +) +def test_given_custom_code_when_declaring_then_the_definition_is_attached( + test_case: RuleCodeTestCase, +) -> None: + declared: RuleCheck = rule(code=test_case.code, message="Message", remediation="Fix it.")( + empty_rule + ) + + assert getattr(declared, RULE_ATTRIBUTE).code == test_case.code + assert test_case.expected_error is None + + +@pytest.mark.parametrize( + "test_case", + [ + RuleCodeTestCase( + description="a built-in code is reserved", + code="SQC001", + expected_error="custom Rule code must be XSQC", + ), + RuleCodeTestCase( + description="a lower-case family", + code="XSQCname001", + expected_error="custom Rule code must be XSQC", + ), + RuleCodeTestCase( + description="two digits", + code="XSQCNAME01", + expected_error="custom Rule code must be XSQC", + ), + ], + ids=lambda case: case.description, +) +def test_given_invalid_code_when_declaring_then_it_is_rejected( + test_case: RuleCodeTestCase, +) -> None: + with pytest.raises(RuleDefinitionError) as raised: + rule(code=test_case.code, message="Message", remediation="Fix it.")(empty_rule) + + assert str(test_case.expected_error) in str(raised.value) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b84f833 --- /dev/null +++ b/uv.lock @@ -0,0 +1,831 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "argcomplete" +version = "3.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz", hash = "sha256:aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9", size = 74473, upload-time = "2026-08-06T04:53:21.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl", hash = "sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19", size = 43294, upload-time = "2026-08-06T04:53:20.246Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "commitizen" +version = "4.19.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "charset-normalizer" }, + { name = "colorama" }, + { name = "decli" }, + { name = "deprecated" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "prompt-toolkit" }, + { name = "pyyaml" }, + { name = "questionary" }, + { name = "termcolor" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/bc/294ceb7ba99f83dca2bf62942dd325ffca86e504aa41cc42c2982350bd2b/commitizen-4.19.3.tar.gz", hash = "sha256:db77b1d521b5b9a16231a4fd5093d09ca519c5f170938806a784309b81938419", size = 73784, upload-time = "2026-10-24T07:24:08.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ae/4f63a84dd2db955a3b222b2df002ebff61387fc80efdf46f611f9f4dd308/commitizen-4.19.3-py3-none-any.whl", hash = "sha256:d625e038cce66d92da20d24c2c366d410caca43966eba8ec036debf48fda2a68", size = 92037, upload-time = "2026-10-24T07:24:06.801Z" }, +] + +[[package]] +name = "decli" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/59/d4ffff1dee2c8f6f2dd8f87010962e60f7b7847504d765c91ede5a466730/decli-0.6.3.tar.gz", hash = "sha256:87f9d39361adf7f16b9ca6e3b614badf7519da13092f2db3c80ca223c53c7656", size = 7564, upload-time = "2025-06-01T15:23:41.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/fa/ec878c28bc7f65b77e7e17af3522c9948a9711b9fa7fc4c5e3140a7e3578/decli-0.6.3-py3-none-any.whl", hash = "sha256:5152347c7bb8e3114ad65db719e5709b28d7f7f45bdb709f70167925e55640f3", size = 7989, upload-time = "2025-06-01T15:23:40.228Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/d36966de7304047a1001a7559fbf266c464bb5d8cc85464536ef8c9bcc32/distlib-0.4.4.tar.gz", hash = "sha256:ca4175b4ac7f71223cc30ed7248c45d2b38c2439d8ee6e498ab67ed63cf3508d", size = 621304, upload-time = "2026-10-16T13:56:34.203Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/2b/238f02a4927fa2e27aa995371d240a468b52e42eae44a678434aff736b38/distlib-0.4.4-py2.py3-none-any.whl", hash = "sha256:cf09d55b1e85a89576b036aeb71550bfd68320f3f21761ca329eadadad918638", size = 472099, upload-time = "2026-10-16T13:56:32.053Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "fensu" +version = "0.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fensu-cli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/69/a132ffc407be97a0fdcfa052cb8b49408091db748e6afe0f7a6c77e6fa7d/fensu-0.28.0.tar.gz", hash = "sha256:11bcea3207f7fd92babc200f40d074e3da789ee11e7fc7024575a415de7c1176", size = 538990, upload-time = "2026-10-27T00:39:16.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/e9/7434bd0565d54ff58796105728b3ccc8b838c7200997184978e6ebfdec4d/fensu-0.28.0-cp312-abi3-macosx_10_12_x86_64.whl", hash = "sha256:29df31aff900d20b0c845940af0cd91eead55e158a2511e682e3e887a22dada8", size = 4154207, upload-time = "2026-10-27T00:38:52.311Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d8/e0875b269515d9a75643d5488bdc6f70a4b6c346995f12ce07304a3610f7/fensu-0.28.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:33a6d8b7d2f734c48f48087462c0e5a1ff94535c0e27e6a4820bc10303ff3653", size = 4004243, upload-time = "2026-10-27T00:38:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/e8/05/b87c0ca5cf3f724517d6958f1ebd68948f9399306bd6d6814783635bad9a/fensu-0.28.0-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de07b3fee5d908d2a766ca136753c8040d6fe25a1346d9b3b5a98483fc23d874", size = 4230192, upload-time = "2026-10-27T00:38:57.321Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d2/fdfc7fd322c3497c53c22754a33433e33678a6de84832fd41add09481a21/fensu-0.28.0-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f44293a2da6bf4558d00eb4f859b28f3fb69d396dad1c25ac466a92a7d1dd734", size = 4307267, upload-time = "2026-10-27T00:38:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/56/c0/2ab2c2c1af4ddd1b45c750679bd9f0b777d7eca3c0e00a233c991aaa13e7/fensu-0.28.0-cp312-abi3-win_amd64.whl", hash = "sha256:ffa88f1d259a1830d513ae930ff856d5b5e4941ba433889dea04d3db5753f281", size = 3977828, upload-time = "2026-10-27T00:39:01.761Z" }, +] + +[[package]] +name = "fensu-cli" +version = "0.28.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/d9/f347f514fb224f6a1f4ca6e616ac49b573f9dceba4ff8d9efcd334f48bea/fensu_cli-0.28.0.tar.gz", hash = "sha256:1feb379ed92e193cbd4d28ab3d7729798b88120aaee2f59fd7f341519b6ee945", size = 765638, upload-time = "2026-10-27T00:39:17.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/b3/57f1de89e1cb7534b0ef6415822c8982a247b9d2efe8d2fddd85e35389c8/fensu_cli-0.28.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:13ecc7a224a4b32eef19456f21659e682e636ef53f305919aa57cae27c8e3fec", size = 7978247, upload-time = "2026-10-27T00:39:04.644Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ff/2e887d727f82c61ea7cb6773429b61d4711b93f7f94814eb9a7e891384dc/fensu_cli-0.28.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:149e5e22c8895c0a3ed3956b30e693e78df7dfb67ce8e0c5682b84c77e811e7e", size = 7490546, upload-time = "2026-10-27T00:39:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/daa4c2d70e5b0398382f84c846aacc4a937c4d00b3938d00a67351b0f212/fensu_cli-0.28.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:99932d17956bb10da1ac677867567b504a961d0565fcda730c1e682434244113", size = 7570220, upload-time = "2026-10-27T00:39:09.852Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f9/c9e1095c507da3a53b3e7bf3f7c95f75824429ea45a72cb2c7af67a605f7/fensu_cli-0.28.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee51c5d6d0d5fa0b36c0f70d3d6fde429793b643f0075cdfdf3fc8b8d73e3ccb", size = 8086364, upload-time = "2026-10-27T00:39:12.052Z" }, + { url = "https://files.pythonhosted.org/packages/9c/89/ece32e452d2b4ade243dd66a2612619be7400f6acf59f854ce674bb78e30/fensu_cli-0.28.0-py3-none-win_amd64.whl", hash = "sha256:acca6ac39cbe0614d3a8e4ce7c2c12cc5fd3c800fc4c66b5484c54dc19ef4a66", size = 7700477, upload-time = "2026-10-27T00:39:14.15Z" }, +] + +[[package]] +name = "filelock" +version = "4.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/28/3893324bcadf8f89b1616637468d766af3f625008c0735bf704fdfa86617/filelock-4.2.4.tar.gz", hash = "sha256:c6d8663535e1650ee430507b4ffbac7b464dd64b7647ea0112a9f406499d163d", size = 652444, upload-time = "2026-10-19T01:57:51.001Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/1b/5e74289a1878e666bb4b65fe5805a04cafcac2aa5f519ef1ead67ef91c56/filelock-4.2.4-py3-none-any.whl", hash = "sha256:301241438c57936ef93f4243a94f2559022268bfcf9b375a9a72eb0f8db8358e", size = 162475, upload-time = "2026-10-19T01:57:49.419Z" }, +] + +[[package]] +name = "identify" +version = "2.6.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/35/d70c0006c7cee65999ea94a6273e60b2094f600a3d8b71b04318253fc643/identify-2.6.20.tar.gz", hash = "sha256:ad729860a923858d26917c2f4fb0a1d83d27a75b1e090c06440c573f048f3285", size = 99681, upload-time = "2026-09-26T20:29:07.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl", hash = "sha256:6a16b69b93187244e0548cbfd25b3e4a6f9a7a2ad784625c3bec2b8d27b81aaa", size = 99506, upload-time = "2026-09-26T20:29:06.054Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/27/79acfd3d6f526a4a16f3d209dedcf7b5bc1ea15c5bf2bbb82f2c2285057e/nodeenv-1.11.1.tar.gz", hash = "sha256:d7fa027c1df437bdd974b0502a8dc76fa1b3183e369ac9539602bc6bab9100ba", size = 99853, upload-time = "2026-10-29T20:54:04.261Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/35/a09e0b1dac4027e35b872e4f123a9f9e7bbc39e6424bdc9776920d3a6491/nodeenv-1.11.1-py2.py3-none-any.whl", hash = "sha256:bc0fb085d8ed0d67a6471892535f3d9b795eb911b7cd129d3a24967aab7a538f", size = 34473, upload-time = "2026-10-29T20:54:02.752Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/28/1c058a9143d2304b3078f8dadcbb9dc27f7d3581b5d05157c84869c4cc0d/platformdirs-4.14.0.tar.gz", hash = "sha256:83f009c1e8c5fc36869b5171755b291887bd2cade7965233cdfabd23d754720b", size = 70387, upload-time = "2026-10-28T18:27:30.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/2b/887969ef4bb53e0cb271dc7c3f4a73d50c5d58bb88aeaae311e3340cd57e/platformdirs-4.14.0-py3-none-any.whl", hash = "sha256:1dadfc59a8a5d7565b3d9270ae8fa71c065595352c4277b41c11ee6c222c4b91", size = 35259, upload-time = "2026-10-28T18:27:29.659Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "polyglot-sql-chio" +version = "0.13.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/8c/b48d0fcca36c2e6df0a2ab8aa24e029373264b6dfa9db683eb533f6b6249/polyglot_sql_chio-0.13.10.tar.gz", hash = "sha256:4365617889c3779fd1a3ebdd15ab03c92c517cb1be36dedf82e5853b31d39781", size = 2279296, upload-time = "2026-10-28T23:07:03.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/7a/c66b36a40c0931d9647215f315f90e2e0d83328165dce7275d624f694381/polyglot_sql_chio-0.13.10-cp312-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a1abe78cde4b54d145f42356ca4c3a7585775aa4efee954209084ea993633f90", size = 13701198, upload-time = "2026-10-28T23:06:50.006Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e2/b2ba58e445163b9e29a715d9e3eab5fd9e276e8bbe58f5710bd77aae70d7/polyglot_sql_chio-0.13.10-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:a7d01559916bff555fd0c258f4a52a98e3f2fa25b1f4c482ef35cb868704c3b6", size = 12941723, upload-time = "2026-10-28T23:06:52.738Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/a9e5cb57c9938551edeb0884cc1b57ef54bbb38eb5f371bb146b663881ab/polyglot_sql_chio-0.13.10-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e5ee843dccffe87501113238ba333c17dce55b31867fcdc1b81e245b9c3eb4", size = 12895624, upload-time = "2026-10-28T23:06:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9e/0879b3a267899906c231270d0167d8c32c14dcc2ae346b86834c4f7bb8b2/polyglot_sql_chio-0.13.10-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd2ca982a19c7fbeb460914eceae60d9c5f7e6e631473f0141585b778772c1ce", size = 12661001, upload-time = "2026-10-28T23:06:58.287Z" }, + { url = "https://files.pythonhosted.org/packages/e7/13/dbdbce022153042edb59abe07e64c51a0e0a9a8f4c86cf2043f1c42a1da7/polyglot_sql_chio-0.13.10-cp312-abi3-win_amd64.whl", hash = "sha256:f366391d63fbf455f71c07fb03e4ac1cf860609de7fd875759647ffe98659320", size = 12301030, upload-time = "2026-10-28T23:07:00.945Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/c3/6965c2690ddce885ec75121572d065a5b94c5a5d8d3e657927ecda9fd715/pre_commit-4.7.0.tar.gz", hash = "sha256:1f3deee12b914c8fa184bd2b5953391c2e4f36763e403d4a23dc9983fa7c6e86", size = 198120, upload-time = "2026-10-12T20:45:40.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/83/ce0a196e4f7b20b11ce3aa1fc72fb973f64b7628d51c1b8aebad37fb7f81/pre_commit-4.7.0-py2.py3-none-any.whl", hash = "sha256:2e229038ad3656081b70c27913157e4b636ef306f643f024a8a8e3f0f7b2f877", size = 226261, upload-time = "2026-10-12T20:45:38.858Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/0b/908da9ef8a14b600fc188eee8d63ede071996114613721153f6fc9ea3cd5/python_discovery-1.6.7.tar.gz", hash = "sha256:7f7c28568c8ec3d988d8bddbdbec8e65f08e8767e52f734e18b8b498549b9b70", size = 117807, upload-time = "2026-10-28T06:24:21.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/e4/1c7c388e874222c28afb92fe3ebfeebe55555276523ba7aab0a82a10b2b5/python_discovery-1.6.7-py3-none-any.whl", hash = "sha256:5422f67e5f533bb02d7b73de1c422ed74037536fd869e05156c07565926ec68e", size = 39156, upload-time = "2026-10-28T06:24:20.462Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" }, + { url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" }, + { url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" }, + { url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" }, + { url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" }, + { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" }, +] + +[[package]] +name = "sqlclean" +version = "0.0.1" +source = { editable = "." } +dependencies = [ + { name = "sqlclean-cli" }, +] + +[package.optional-dependencies] +ast = [ + { name = "polyglot-sql-chio" }, +] + +[package.dev-dependencies] +dev = [ + { name = "commitizen" }, + { name = "fensu" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "polyglot-sql-chio", marker = "extra == 'ast'", specifier = "==0.13.10" }, + { name = "sqlclean-cli", directory = "packages/sqlclean-cli" }, +] +provides-extras = ["ast"] + +[package.metadata.requires-dev] +dev = [ + { name = "commitizen", specifier = ">=4.9.1" }, + { name = "fensu", specifier = ">=0.28.0" }, + { name = "pre-commit", specifier = ">=4.3.0" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "ruff", specifier = ">=0.12.0" }, + { name = "ty", specifier = ">=0.0.1a19" }, +] + +[[package]] +name = "sqlclean-cli" +version = "0.0.1" +source = { directory = "packages/sqlclean-cli" } + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/72/fc01563df95c189a9f65850096bdc205bc2fe37da6c740cd64afaf18d07e/tomlkit-0.15.2.tar.gz", hash = "sha256:83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6", size = 190522, upload-time = "2026-10-16T04:07:08.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/46/7d84138a60a8bd796825b2d38ebe92d0d16501ca57d501b7c5d677401bbc/tomlkit-0.15.2-py3-none-any.whl", hash = "sha256:05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437", size = 52225, upload-time = "2026-10-16T04:07:09.216Z" }, +] + +[[package]] +name = "ty" +version = "0.0.95" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/08/d63954788578b9189dbf85081d8cbdba9a32a9a6a29729129a0da09cd3a0/ty-0.0.95.tar.gz", hash = "sha256:a480853a74d43074b2d4b28025d406b9d7223f7fe9f3a6c2adec3eacdbf14ca0", size = 8115525, upload-time = "2026-10-29T19:21:51.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/c8/8f912c9d91503aa26b704838e2ecba448fae5713a799e2022e03e20d5186/ty-0.0.95-py3-none-linux_armv6l.whl", hash = "sha256:4e0745137dbd472027e71827ef9dec803439395319a182835fea2bb7ccc264cb", size = 14526217, upload-time = "2026-10-29T19:21:11.573Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/25feb9b4c04da26d2aa91527b0cc3de281aa45c9857abb900f1c4275e1aa/ty-0.0.95-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b9587b262312f94bce6b8c139db0542c44a1dce8c2224b4d33ab649b287b119", size = 14099685, upload-time = "2026-10-29T19:21:14.433Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6b/2a084a72c8069f8496fcc0f576ec053f648b1baeed07532b3eb84c4982e4/ty-0.0.95-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3e7902477410e897a60d2cf29ca89c28ef2a2abdb81698d8ae4be2e33b934d4b", size = 13144133, upload-time = "2026-10-29T19:21:16.734Z" }, + { url = "https://files.pythonhosted.org/packages/84/ad/e4088e627b3e3cd8cbb6ad08668cde33fc7093887606de6f23e4e3e639b9/ty-0.0.95-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cef0d06e2f286db6596e004f5820d4dd3b3a1a698465840e89a5e8a5934aca7c", size = 13996368, upload-time = "2026-10-29T19:21:19.061Z" }, + { url = "https://files.pythonhosted.org/packages/26/76/67b0ee993698496e0c93b5afbec7838cc26003ac74486fc11d89c15b52fb/ty-0.0.95-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4e032018e2aaeba51a48b639acacfd949be335cd2395faa205a305a9ff524b9f", size = 14262316, upload-time = "2026-10-29T19:21:21.303Z" }, + { url = "https://files.pythonhosted.org/packages/14/23/7214c42a9d8533c5f8a6384ba6d82adda9adab2347d5fe1c395c32c4e4e9/ty-0.0.95-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c98a2ea2d1a731d0db27a440bceea055ba3555e3e80d694e567110ba2a477a", size = 15217155, upload-time = "2026-10-29T19:21:23.597Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/4f51df84ba70438fbf2d481baa59cc8801d86e26a472a1c0b885c01c9c99/ty-0.0.95-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c26f09e9d6039f5ffc1b35e24830da67ff6202fef8583991e2ed169c0b188a54", size = 15607522, upload-time = "2026-10-29T19:21:26.245Z" }, + { url = "https://files.pythonhosted.org/packages/bb/7d/958cfc1e9cd56390f916f36d97a00b7aa15db88b2cf1a39c22f524c6078d/ty-0.0.95-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e01e493e87aede663405e6f2cf38c65a95456c8181ab04e9e2e9662538ea717", size = 15562445, upload-time = "2026-10-29T19:21:28.642Z" }, + { url = "https://files.pythonhosted.org/packages/90/64/636308717989afa2fe765604ae2a76189f470d566e5e58da4482b625d64e/ty-0.0.95-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d47be2db5db80cd70c582b240a71dd8e9be215ce9288298c863c7c3611ef02b", size = 16312046, upload-time = "2026-10-29T19:21:30.801Z" }, + { url = "https://files.pythonhosted.org/packages/a9/75/e57a2c0bdce3dcf3d43ac15f3c9adc34212330e056db8a70208ab7f3730f/ty-0.0.95-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3843233ed7ddca6d1d2e38034bf28d2618ff2fc8b16190d2efbbe46b066ea524", size = 15070529, upload-time = "2026-10-29T19:21:33.7Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/2efd45b625fef61491986feae6f348449051ee474de4d79a77819c678aa9/ty-0.0.95-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0b24691b55fc64d480d612bbf555ff369c80d56e26c33af94acb2cbadf1a37f6", size = 13962242, upload-time = "2026-10-29T19:21:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a5/d5082fbb65305af5689f1e50f3b6b8897287f1ded5d06ef713faaed17e97/ty-0.0.95-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:bf72de3cc1d528b1b19020209f4692997f402bba40d8b3e5654906edf61d1c84", size = 14265678, upload-time = "2026-10-29T19:21:38.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/e6/2347c9519c35179d7e2ca74b0ea0b9d78a503d6673905ae911dfcc13f2ed/ty-0.0.95-py3-none-musllinux_1_2_i686.whl", hash = "sha256:721260b0dc05f9bb3fe79db642a2eed94aec99de2d995c7c92e56ffd8637b1a1", size = 14636684, upload-time = "2026-10-29T19:21:40.466Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b4/bac1a90c887c0251955ed17e5da8139b4ac87352d67fcb3d1ffdb1b8723e/ty-0.0.95-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:156abe019cea9aa54e581d89ca34a48f81b8e4e9c49c203943feb60a06b822af", size = 14985737, upload-time = "2026-10-29T19:21:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/68/ef/8840c75892f50107a541e7ca3fc0912d9dce133e4e13728838981901e1c1/ty-0.0.95-py3-none-win32.whl", hash = "sha256:b1903d8c17645c29ee566e708bee569d971c461f3429872305a574ccdae4c732", size = 13503067, upload-time = "2026-10-29T19:21:45.085Z" }, + { url = "https://files.pythonhosted.org/packages/b2/df/46fdcc84de81159e1d40ca103d24ac08b2450a2a51a1572333e8e796b8fc/ty-0.0.95-py3-none-win_amd64.whl", hash = "sha256:d07bd09f8a525759a595e04051e923aa54f8dc483f90e6a0b003d77a3fae0f04", size = 14097073, upload-time = "2026-10-29T19:21:47.462Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e4/84cf4f3c313448d97550fdce50e7c70b934b38765772dd4f7fdaac064cca/ty-0.0.95-py3-none-win_arm64.whl", hash = "sha256:74ac66df3cb1851e3f157c4e664aec1f28857b467c7e13f62ccf53efc6884c82", size = 13575281, upload-time = "2026-10-29T19:21:49.773Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/24/20282bc8e69ff8dee680dc2b95e9dc81fa7e7894b14c1a8d06330e97fef7/virtualenv-21.15.1.tar.gz", hash = "sha256:e79c90b8f601701599c25905e02839924fede2c798d52325670d24fbdaab74ee", size = 5517048, upload-time = "2026-10-28T01:12:50.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/23/cdab19d3a9da42ca0938081e163e25f81e610141800ead743ba1ee881f0e/virtualenv-21.15.1-py3-none-any.whl", hash = "sha256:0d14a991689c67600316880eadc0a37b7690cb632e3b4ac67e2bc05b2b2e24a4", size = 5558848, upload-time = "2026-10-28T01:12:48.605Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/01/e5ac23733628a6bbb91913a765fcf55ee8070d8d991bc88fdaaf805052c7/wcwidth-0.9.4.tar.gz", hash = "sha256:fd8d57d3cdfe570e3265c8fab1885c24e34ef40889c0759c0c304a19e9835954", size = 970051, upload-time = "2026-10-24T18:11:44.954Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/b8/d251589a100250b71a7b8e83fedfcabfd8bd60e78cd1e3329372f63bcc93/wcwidth-0.9.4-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b7969c9aa258d536df54c8ac8af5cad0c74a6268c9972e1fb60a5f29feffb66d", size = 612952, upload-time = "2026-10-24T18:10:55.925Z" }, + { url = "https://files.pythonhosted.org/packages/04/e9/dfdca30ca36c472de3e260406dee29fc108499ec590c63b3fe279887ee9d/wcwidth-0.9.4-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f279f32c8e206a9092fa350e53ca0c2bd8072e45b1c9fd31bb6d476b59b591c4", size = 618904, upload-time = "2026-10-24T18:11:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/d7416f38054a107d2fb17c2b73b68723c6c334f69c4e29545fb9e6df81eb/wcwidth-0.9.4-cp310-abi3-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a1526cd25c6420e5153bb7d764c7509e692215ee3ad9b725db1ad665d47eae09", size = 778780, upload-time = "2026-10-24T18:11:17.299Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5b/42bdf9a4b0e4f9126584af2ec73642de495f3bd8add907430f13c3bb98d2/wcwidth-0.9.4-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:093469e01d881bff3db93ff3f56c364dc87c441ea01f9f41c22de0c0bdf5c6ef", size = 789012, upload-time = "2026-10-24T18:11:07.829Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e8/0485850748dfdba9443f4151c7b2442cfa4987ba6cd871038eb5d6b8eca9/wcwidth-0.9.4-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b185c8b927b23af2721be1783499d1cfa5c028b098b0858951b4ae224477f1c2", size = 785946, upload-time = "2026-10-24T18:11:18.748Z" }, + { url = "https://files.pythonhosted.org/packages/e9/11/dd3d0379c6094524df6585b76ae3d9727161ff29c003177e3661c36437c1/wcwidth-0.9.4-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:17619066f95c10f44d7ae08ee456602456bc66cc9d5e91966ec73d799acebc5f", size = 795348, upload-time = "2026-10-24T18:11:09.931Z" }, + { url = "https://files.pythonhosted.org/packages/3c/48/1b5299983d9ec084be897162b7cc6014622dfe62847224fa3272b6d79ea5/wcwidth-0.9.4-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:b52f265a694dda6b3ad9050c8bd89158ad519109ef7c5eb110b906cc49f25763", size = 793420, upload-time = "2026-10-24T18:11:20.551Z" }, + { url = "https://files.pythonhosted.org/packages/7a/be/e3097d859b075344a7be57774a58d468eafb0ca88e02aad93eb31d69f6ba/wcwidth-0.9.4-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d8905f1390d828d745fdd1904374c90386d902d3cfa9ee88e9875099f75a4e17", size = 796192, upload-time = "2026-10-24T18:11:22.552Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cd/739710c8cf0de461239db5bb6ee678c9a33d30aaa22beff55cc356781818/wcwidth-0.9.4-cp310-abi3-win32.whl", hash = "sha256:4831662c396dd4c59a016ec53c0ac96738006d653a00482777d9a2e2ea323d62", size = 593229, upload-time = "2026-10-24T18:11:35.609Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f1/c123f902bc2ff9e9c2ab66241b8872204df87cbb18ecdf035e120860fedc/wcwidth-0.9.4-cp310-abi3-win_amd64.whl", hash = "sha256:f28d77153303450ee13ebbe888297e75cf532c754555b6cabafc9bdda16c421c", size = 597780, upload-time = "2026-10-24T18:11:36.952Z" }, + { url = "https://files.pythonhosted.org/packages/4b/88/1cb5ddaf5510b918e01aa95fc4b68df91dead784b63e4931037c5673e85f/wcwidth-0.9.4-cp310-abi3-win_arm64.whl", hash = "sha256:2b58fc8cf2de1bf9cdb68b8beaa5be198742414a7c8cd97aa51ec039783ffa49", size = 598786, upload-time = "2026-10-24T18:11:30.886Z" }, + { url = "https://files.pythonhosted.org/packages/b0/cc/46a31c12b4cdad04f546a3c0aca5d000d77aa5069ae88b4db24525cc29c4/wcwidth-0.9.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:87e4101789415a6cf55ec4c53eceeb4782c2654c88692941cb588f19dca1959f", size = 613805, upload-time = "2026-10-24T18:10:58.887Z" }, + { url = "https://files.pythonhosted.org/packages/18/f6/5a8327af9a65153ea9823d6a12fc182f325264050aa25c05a5deadc189c9/wcwidth-0.9.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ab1e5190fb82476e75fbf963e9d59b4fb1c1e1f4ced82069a0c989016aa959f7", size = 619936, upload-time = "2026-10-24T18:11:04.073Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/bb8a7f4aa9b915e56e09841b07636ba49c82ec987ad3d2022e1696c038d9/wcwidth-0.9.4-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:8f892713ab5795d3abae181e9c02db0eb24de4084f9ee080535369d3d98d7bb5", size = 794563, upload-time = "2026-10-24T18:11:24.4Z" }, + { url = "https://files.pythonhosted.org/packages/12/0c/986acadef534688a57502d484a40e0f30bdca2d39514fe0a311f7d575f70/wcwidth-0.9.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:befbf46467100e43fd8c8cc0c031a0105f13f5418c3be95fdcf0c47214487967", size = 806565, upload-time = "2026-10-24T18:11:14.297Z" }, + { url = "https://files.pythonhosted.org/packages/9b/1d/66a94b9ac07037d7cf2c1f1288867c9932215d467893eaa5ec8d548c7f2f/wcwidth-0.9.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9c4b0dd04cca520792100f6110d10be262c94b285a281d1759c551f159214633", size = 802372, upload-time = "2026-10-24T18:11:26.275Z" }, + { url = "https://files.pythonhosted.org/packages/d1/00/1d50b8e8b45a8330b582695bf8196081a609317928a3718cf21b3d14a899/wcwidth-0.9.4-cp314-cp314t-win32.whl", hash = "sha256:14410eb73178cc492aa9bf22f7c12e3bf930e5470b7220cf6f530efbfa2e03f3", size = 598917, upload-time = "2026-10-24T18:11:38.369Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/3e39a33fc8f5c4e07611578710275039c9d4a6655e2be7cf3459401a30cc/wcwidth-0.9.4-cp314-cp314t-win_amd64.whl", hash = "sha256:c36954849aafae1092d6f9de9548c5280c1d6841637a27ca288b2241c802c4ab", size = 603536, upload-time = "2026-10-24T18:11:39.922Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8c/b1f399fd2f8449e42978192967ab6df441d7cf894b10117983c36c062b18/wcwidth-0.9.4-cp314-cp314t-win_arm64.whl", hash = "sha256:79e09e9824c88f5e275ca99e921685567a5c5c1bb6165c16e27ddd1adab33a95", size = 605127, upload-time = "2026-10-24T18:11:32.367Z" }, + { url = "https://files.pythonhosted.org/packages/04/46/839780e772e9068580619d4d691348b7a5f9c4fa5ffe501e522eefeb27cc/wcwidth-0.9.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:a8c9ce14c6b4a499e61de6de7e9e456de11be8bc991554e2ab2ed4feb40b9664", size = 613850, upload-time = "2026-10-24T18:11:00.725Z" }, + { url = "https://files.pythonhosted.org/packages/d2/66/b2559c7012bbd4268f5b5c0288cb793c78235d354e306871a55893aa9d1a/wcwidth-0.9.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d68f09093c9bec9226a52edc47487042ed4f64c5df91d06f00419495071c0279", size = 620207, upload-time = "2026-10-24T18:11:05.975Z" }, + { url = "https://files.pythonhosted.org/packages/d1/fe/7d2c39e28924b2430b20099e36276f14e00e75c7bbe4ba5b1ed44d1d465a/wcwidth-0.9.4-cp315-cp315t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d76b7c715c5ca0103b28915f699f1020ae21f06f1d22cdd85a7a97f7b1536eed", size = 797056, upload-time = "2026-10-24T18:11:27.911Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/f4f9967a411e02b89b74df2f49f44977c4badccf29738f3ae6941619e27c/wcwidth-0.9.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:29c0685f5f5f8815b88308fdcbe2cd12a92996a21a5b1e18352d165174ef7a6d", size = 806169, upload-time = "2026-10-24T18:11:15.724Z" }, + { url = "https://files.pythonhosted.org/packages/ac/bb/e2b40c6522f3aa1222558487dda3551d297e07e245761b4816f6caeebc29/wcwidth-0.9.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e05458491759aa42b7b85f36345b3e6df082d6d2d9bf616299668fd598e0209d", size = 803462, upload-time = "2026-10-24T18:11:29.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f2/ab69009f2dd0d3772d3e07f025fa046b33c60c195e4f2e5f000297d23547/wcwidth-0.9.4-cp315-cp315t-win32.whl", hash = "sha256:9e5090d253f5df99659cddfd3ed90dd21f6ff51989b0421331a63fb2681cbe27", size = 598931, upload-time = "2026-10-24T18:11:41.659Z" }, + { url = "https://files.pythonhosted.org/packages/63/6f/b321f10c2298e7d16cffc1a5f05f3d7e5231b45f9e68af5ee378e23bb2e5/wcwidth-0.9.4-cp315-cp315t-win_amd64.whl", hash = "sha256:12786620375dc83fabf4b2b2aa52416fef2bc6094522ea29c8a53c75b0dfe9b6", size = 603485, upload-time = "2026-10-24T18:11:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/4e/cc/7eeb28059186cae72a770df79e744f232ef068192a5a588b718473e35bb4/wcwidth-0.9.4-cp315-cp315t-win_arm64.whl", hash = "sha256:2cca7b5d0b9d0ae49068d3ce806e965326a987f555b62eaa322223396e10d01b", size = 605198, upload-time = "2026-10-24T18:11:33.75Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0c/309b1813b230d1dbbd6f2c2ab2bb21ef68992358977605d9ef289bbfedcc/wcwidth-0.9.4-py3-none-any.whl", hash = "sha256:901eace6d2dd25ebb917d0ce5db18e5c537e1504c905cf32f0d006c12098b05d", size = 301806, upload-time = "2026-10-24T18:10:52.651Z" }, +] + +[[package]] +name = "wrapt" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/b0/f752d19e1e83ab8bd64aeccfde32ff086aee3ef08520f7340b69a8d0a4d2/wrapt-2.5.2.tar.gz", hash = "sha256:551cb49e88b3722faad780303833bc4fa70685fb1773270fb98b40d39d9fddbf", size = 186066, upload-time = "2026-10-29T23:06:39.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/fe/9ccae0a147d0732397f54ff232b1a0da148a4099f17c1b156b9d3e79127e/wrapt-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bec3b6b1ae2f7ed763fb0ba1776b9b5f11283e6ff40c9cbe3a855ea652c326cf", size = 106628, upload-time = "2026-10-29T23:04:44.962Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ca/a61d994df49fa422abd859e525e04b95ef2888e3818a0f451aa7d4d3527f/wrapt-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e57d79e3338b3cecee1dd80d6ef8f945f8ea7b92f0d8d5591f840ffa0afc14dc", size = 106264, upload-time = "2026-10-29T23:04:46.307Z" }, + { url = "https://files.pythonhosted.org/packages/dd/26/a6caa9a84ef5db4a273787949cea7ab51f6001d2548768af4c8e3c49debd/wrapt-2.5.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:092472b7b1494deef40dac5e9f6295013a9d51b112b5fbc4350f64dc2574e46a", size = 250614, upload-time = "2026-10-29T23:04:47.735Z" }, + { url = "https://files.pythonhosted.org/packages/c7/28/2189571e711ab31cb7f682c739d05f033ae0e83e12eb4d5d21473ca2cd1a/wrapt-2.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d108bd98ab2c5ccbf55cf2154e0a73341df930030df2e01ee35d71a44a99d3ad", size = 250770, upload-time = "2026-10-29T23:04:49.183Z" }, + { url = "https://files.pythonhosted.org/packages/80/8e/46a6cc2e8215df7fae292178a355df729547ff9b912686823201d38be269/wrapt-2.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:858511731d6ede4217960c5fecdb5ecbf90e306dfd93b837395658d61374a498", size = 230031, upload-time = "2026-10-29T23:04:50.641Z" }, + { url = "https://files.pythonhosted.org/packages/97/38/dff531d0125fe880e05fe5fcb89f144d25ffbe95aa9a2c50539eabde7567/wrapt-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b6885250b809532a634acd56e360d57c201c1cdc75cbf2b5e106241ece6cbc3", size = 247325, upload-time = "2026-10-29T23:04:52.16Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5b/f4a78df4c52175d53ff095f2f5a54206a2d16e542b1dd2cdcaed901fe432/wrapt-2.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c60a5cd8b7b510056f2643d94f1c144533a568ade2891a9dd35733f5e0055777", size = 226900, upload-time = "2026-10-29T23:04:53.559Z" }, + { url = "https://files.pythonhosted.org/packages/20/6f/8eab144c717300f76f1337ba58091f4de5da0f4d772727c9f41135b325c1/wrapt-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:40e03506f09dbf4854fd69848e843dadd9b7fbbdd217837cb27b37fb8f0b5bc3", size = 239017, upload-time = "2026-10-29T23:04:55.293Z" }, + { url = "https://files.pythonhosted.org/packages/59/1a/98e278ef2f8952a34f847a91f75899f6385762b07577c51a5ef82c768dfb/wrapt-2.5.2-cp312-cp312-win32.whl", hash = "sha256:ac00ba7d22d55d3c8d085005ce4942ff3bb118d480f889b5984f1d3454052917", size = 100660, upload-time = "2026-10-29T23:04:56.703Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0a/ab645f8b4c594cac2db49bbc805bff97a9d3f2944061b865f67e0351b6c8/wrapt-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:36021ef761856bdbc10d5f3da9a95af93ef60d6acf9c72c8f849937ba2b902aa", size = 106220, upload-time = "2026-10-29T23:04:58.081Z" }, + { url = "https://files.pythonhosted.org/packages/37/1e/232868b68b925ab29ca5acbec4ad54ee42a853fcd8f596bf7e0e0e3850f1/wrapt-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:81bdf0bd141d9b1706a0751b2ee068c7afe6be4042712d427b32b3072d5c40b9", size = 103377, upload-time = "2026-10-29T23:04:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7a/38f08a89ca8faa9b788a857f109b5e4cd1f25671a5157e2218072f64aa76/wrapt-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:88f982c2bb306732271aa15cded4002ce28b5d9023de9d11e927da4dcf65e441", size = 106242, upload-time = "2026-10-29T23:05:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8b/2fb9b2522b7e1e7534a72fec0040bbf3d273129ec0a98316e7d37b09aa35/wrapt-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:269321b17467a10997cc6e14793bf1816e6ce488f9711c74b72552466d206739", size = 106366, upload-time = "2026-10-29T23:05:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/c2/1e/770d73e0cc82a41602cb2cb184769c28bdbaa9b998944d577a1097c18da3/wrapt-2.5.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8b039b3a296762db1908d6108365225393c4fb33c1dbc5f00aa36f8302eaeaa0", size = 248696, upload-time = "2026-10-29T23:05:03.593Z" }, + { url = "https://files.pythonhosted.org/packages/ac/2b/b4362e803d870451b5e701df5e30ded2d6fb1d9e0c01eec4dc7fa8332b57/wrapt-2.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1af6136ac5bcfab17190a70b25d8437eb8d8f9ca2b821a0655d39275a8cb7e7", size = 248663, upload-time = "2026-10-29T23:05:05.084Z" }, + { url = "https://files.pythonhosted.org/packages/87/15/244dbd2d86285857898be5f7632e8375161953e645f250ac9778c058ab9b/wrapt-2.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d4e1cdf2efdc7181c8177f6090886e22278e70f7257df12863d2f52a9dcbbd7", size = 227568, upload-time = "2026-10-29T23:05:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/62/0e/15ae5812d510fbfb65acb3a63aecbd8ffb4b944b2fd0131d968e31df89d6/wrapt-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e33578755e07edf3a70df3241f4cf376dad507e2cc73e737311bb00b5067f4d7", size = 245147, upload-time = "2026-10-29T23:05:08.322Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c2/e611b881c05f7f2a5c88f9d89236afb091b8adba3ac89ff9040b6a837be4/wrapt-2.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2bf23cde96e0f771a6a2c68973c1ded45c9707f8bd519ede30ecf075f3c720f6", size = 225491, upload-time = "2026-10-29T23:05:10.102Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f4/bdb253208ec907097809e34c25d199cd66686a7974257e08b57d96e3176c/wrapt-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eff91af619ad5d587f8855f6c02164e12d33d205c702eb73d487deb499520265", size = 237319, upload-time = "2026-10-29T23:05:11.625Z" }, + { url = "https://files.pythonhosted.org/packages/67/86/74205217834201f27bc73a76b4db67f488fd07787e12a045fb2b39f09c29/wrapt-2.5.2-cp313-cp313-win32.whl", hash = "sha256:11655df8e17658a67628cdc97fe95d6b263e023213cf18067f21e5ec0b0285f3", size = 100634, upload-time = "2026-10-29T23:05:13.073Z" }, + { url = "https://files.pythonhosted.org/packages/e0/56/7c732aed39116853d74485b4431a0ea37b05bd2bf250bdddc55ceb64f8c0/wrapt-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:5726faa7342ba473041bc1a276486d232b42e3927d35427729b266b1b5ecaa42", size = 106044, upload-time = "2026-10-29T23:05:14.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a7/c4f7ae891bbfef566aa63effe846f6804dac4c0461601993f4c59deaf955/wrapt-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:e1fb4af8aac3573770ed3e64efc39d953342b12bcb6e6479512a945eb5557523", size = 103077, upload-time = "2026-10-29T23:05:15.718Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/a259acf2b59eefe400b2a1c6b45bc19b0d0b583722426c9dabb276cc0d19/wrapt-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:229859aebfc909626ea6b5a3ae9523aba43c63fd828f876f2a813f4a5767726f", size = 106620, upload-time = "2026-10-29T23:05:17.256Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c5/b34ca079b7906eb043c53c06896b4e42f3ca9efa97ac10e4e254c88e2ca6/wrapt-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c1ea16d7dfe7d235d29435307b1539495b4925e88b135d532892973a04569245", size = 106555, upload-time = "2026-10-29T23:05:18.656Z" }, + { url = "https://files.pythonhosted.org/packages/e2/46/f66042c9ed3626cdcdf641105f091964f6d38cd406cec96479c4a3f70640/wrapt-2.5.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59b7e6d1dea7c3f38bab21954f587499c27c16ea537058eee4add6e4da1780ea", size = 251681, upload-time = "2026-10-29T23:05:20.035Z" }, + { url = "https://files.pythonhosted.org/packages/70/95/1ebe33ff8053a94b3e1a0bafe31ea94b5e9296f9c5098cbcafc4ee2bcdc9/wrapt-2.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfd508866c421687a99bed9ce5b7b4f26fd9b93d3753685d81947ab37d54c496", size = 250919, upload-time = "2026-10-29T23:05:21.501Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bf/ee90a8674c8f2179ad7aeaf8c5b875fa33a12fec7bf1ccf6cbfcc31fbbf0/wrapt-2.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:459bb66cd4bc43e35cba1f1ab2bdfb9b507e0d8dc0ec40c93a060c9cb0c5415e", size = 231773, upload-time = "2026-10-29T23:05:22.996Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/610ef6bf4f35e4719a5edea6bd0c4d948921d028950bc9669d1fa9234af9/wrapt-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a8626135ecbb0b892ca79c388581ab0ef57e087f09dc0e51a2f34185d3682055", size = 248171, upload-time = "2026-10-29T23:05:24.481Z" }, + { url = "https://files.pythonhosted.org/packages/c6/10/6f12cf42bd9a34829c902c8a617c8151a4e16e7d7cb015289eede5813f39/wrapt-2.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7ffa0be31f651da63ee51e5b74701339feac1860b561a985c7b4869d4600e800", size = 228970, upload-time = "2026-10-29T23:05:25.904Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/79417514032b9015b772bf5bf006ca542937b5f6c483b9ab092f9ceff8e9/wrapt-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ab31d929ca8b2b7bf9320ad2a927edfb1841609951a869a9b98723b285ffbc6", size = 239267, upload-time = "2026-10-29T23:05:27.502Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ce/092f2723758586973c94afa8c38a206ce3720ec5cd3edb64ee9c8b27a1c4/wrapt-2.5.2-cp314-cp314-win32.whl", hash = "sha256:afd99530994449aaac9a58e1e268eb080bb1ffd758be43ab2f8a249ce8970c9c", size = 101091, upload-time = "2026-10-29T23:05:28.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/86/201fb9180290373b7fdff18e8d600151694fdb0b10892cdee77c13eb7e6b/wrapt-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:fabc60720d1e4a36bbe8aaf1c32024f04c6674991313f5acaca5eeadb564c600", size = 106417, upload-time = "2026-10-29T23:05:30.395Z" }, + { url = "https://files.pythonhosted.org/packages/73/a8/cc313dc9230f4b5029e24867a1fc1dbcb929b792b02ac5fb2e0d79df7401/wrapt-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c17ca5ce83c7145eddaa20073398ec2192dad65c8d8ff4d9b78fd2917c46a75", size = 104145, upload-time = "2026-10-29T23:05:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/62/04/15e0c465088fbfdab321a672c88739f68429a18aeb81ad7995a0a5cf2065/wrapt-2.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a60863d874cc6ac917bbf44324e3f195564064dee15c84e83b19e1fbbccd3169", size = 109731, upload-time = "2026-10-29T23:05:33.131Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b6/91d6f77257afabafe19bca698b41d60dde7cf49b59b0126eb25ed6a3a90e/wrapt-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d41c45517912610de55546188695e2bd0fbcd70b585087cd759f85a96f47be7e", size = 110032, upload-time = "2026-10-29T23:05:34.589Z" }, + { url = "https://files.pythonhosted.org/packages/9c/84/40901e8ba08f6f18f213fea51daa41a8107ccaae4e0b6e13b842f02f5feb/wrapt-2.5.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:48fd5026977adb3e2fda292a6a796967b511935d23f97dc2ae1f127a3a8639d5", size = 284278, upload-time = "2026-10-29T23:05:36.033Z" }, + { url = "https://files.pythonhosted.org/packages/89/af/6bfdffc2688a78019762f0b14171ca47c73fae1421c3324c0850135b212c/wrapt-2.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d7639c3287d4efcdc2c8837f1854d1ecf27aae761714ba3d95e61fe8eb48db3", size = 294060, upload-time = "2026-10-29T23:05:37.515Z" }, + { url = "https://files.pythonhosted.org/packages/56/d1/9a4e3aed00d6d877d2646072d9b6b4c9a558a8ad79a18d3abafc1079cd07/wrapt-2.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeaa40251c40e18c5c97ef11d5dbbad28a7b7b99fdd7a7469662799100d74dc7", size = 263272, upload-time = "2026-10-29T23:05:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/0086e02aae9144a0fa029fb0351a8b2f91fbd28eb191c8f2ced3831cb1ba/wrapt-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b9ce67ec8803e6c56e78c9ebf726f61116516212c28dfa3683d0962e15cf9a62", size = 289219, upload-time = "2026-10-29T23:05:40.459Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ac/e342f997166b7d433abc12720b6e30793fcf3a36c5457d22261558b61411/wrapt-2.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4d5faf5c3e435207b3a51294ed5a00e5e17e3842442ccbbd864ee9bc0efc63fb", size = 260554, upload-time = "2026-10-29T23:05:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/65/64/48ddff54611f6e08ba70adf1b3dbf36c7b08b7aa5dbf7c65a03672b0eed1/wrapt-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2861d50a4f330fa140ddc2a9ddee33f7e4f9bdb649a23dc9be8ed6310ffa47fc", size = 278921, upload-time = "2026-10-29T23:05:43.489Z" }, + { url = "https://files.pythonhosted.org/packages/c0/bf/0ca422495491f22656b401832184a87b1e67d1dfb7f31ac8d60525c572ce/wrapt-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:46cee4400437c9f33e755522be9e7ce6ebd097273f464eb6f57bee09787f0c59", size = 103586, upload-time = "2026-10-29T23:05:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/69/17/922d99009a228907cd0a5f5d590bbc176dd70527d3eb06f8b4d74889edf8/wrapt-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:1ad706a42b139ad7f03f32aed57da5e10fe8ed707e29849cfa94f1d49cba1405", size = 110111, upload-time = "2026-10-29T23:05:46.458Z" }, + { url = "https://files.pythonhosted.org/packages/68/45/d168c09643e3207c5618b33a5636137f298b9ded38b3a33fad67dc343403/wrapt-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:450cc93f5a2d58d4a74d384d52db4b41d5728065c0057c9bb4666c6115f2a2c9", size = 106384, upload-time = "2026-10-29T23:05:48.051Z" }, + { url = "https://files.pythonhosted.org/packages/67/8c/3b2b95cd60b78f9450d748854e421fcf48bc350d76ca9e0fe048b87ad796/wrapt-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c17ec4310cc06f864799a4c37c1c716d5dcf05ceead2e63ca992a9466903b73", size = 106621, upload-time = "2026-10-29T23:05:49.433Z" }, + { url = "https://files.pythonhosted.org/packages/16/12/c430cd1289711cdf2b6b1c469d5efa527f4815fabab32bff3249545344da/wrapt-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:430070776fb30d925a7a1e13d68086e4731b63eedf4c43ee62fc4c8108920cce", size = 106590, upload-time = "2026-10-29T23:05:50.967Z" }, + { url = "https://files.pythonhosted.org/packages/69/4b/d7230c57ca30023a5630bf5cc0e092ad1cfce4d999a309b8377a36b3c249/wrapt-2.5.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99978d196df45c115273c6119e4d0dacbc67bc267a196506625bf7084b741f0b", size = 252192, upload-time = "2026-10-29T23:05:52.344Z" }, + { url = "https://files.pythonhosted.org/packages/fa/74/6dc841c96b9906e0c466aaeed7f51162129cc8745a91bb28168aa3b45194/wrapt-2.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:217798fb0cfb31479a264f841b0242bc1bed1ffc70d233e8444a876cec57928c", size = 251207, upload-time = "2026-10-29T23:05:54.011Z" }, + { url = "https://files.pythonhosted.org/packages/52/bb/25e710cc9d3bd25d81d46791dd25029d6f9b9d44b990e3d81755b549ff71/wrapt-2.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62b339565cfdfcc0ba71c527b4ad13ac9de25504d474fa4ae5db95d1c7e4c6a2", size = 233048, upload-time = "2026-10-29T23:05:55.796Z" }, + { url = "https://files.pythonhosted.org/packages/4e/80/672640d799862a36abae740e778146dbe155ec8ffd6211b3a99240f599ca/wrapt-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:84619ca6b6f67a7dd53754ad402cafceb4ddc9d8981f144f62718b783f05786e", size = 248479, upload-time = "2026-10-29T23:05:57.346Z" }, + { url = "https://files.pythonhosted.org/packages/f6/34/91eb6724eb7cf64b77637d720ef71cad1ec6e4ce2d15eb548670c47078fa/wrapt-2.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:08d1d7bcb7dd277b7b976fd914455c3c1088f5c101ac7469ae5a5b8c03be0a0a", size = 230013, upload-time = "2026-10-29T23:05:58.745Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/f1fc0c3394819c9a6a3c0d0055b06614a3bef7ad133cf18f9c09a2b2141f/wrapt-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4e86f3543c67d72b4d4c08c3e351523c2d69cd05932df65937d79bc472e9d62f", size = 239808, upload-time = "2026-10-29T23:06:00.266Z" }, + { url = "https://files.pythonhosted.org/packages/56/d1/70f9e71674d7b6c21c8e8cafc3016f773dc3acb31a131f23ab4af8c1effb/wrapt-2.5.2-cp315-cp315-win32.whl", hash = "sha256:035cd71e2aadfc0ad366ab4785b49e62093370885ea4b527b61b308b7a92ff14", size = 101099, upload-time = "2026-10-29T23:06:01.777Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e6/fdea9e35605a6e7fc71556068ea184fb3e9cc09d1e3be8ca8f26d16339c9/wrapt-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:b9f41029f9bc6ba80755b9c07f5e77e9e7aafb71c625b9f67614e4ebfb24302a", size = 106423, upload-time = "2026-10-29T23:06:03.076Z" }, + { url = "https://files.pythonhosted.org/packages/d5/50/8c41d8ba868cde9afd5b68e18f19b94300d091c409ef081b80c2aecda653/wrapt-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:ae84013e89ec1874415549a46cfea373e28ddecbb72e1f5456cddfa2b76a28e6", size = 104152, upload-time = "2026-10-29T23:06:04.356Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e7/b43d341b036e2862550eb7fffe2b4130015c39c861f30eb34014a2f032d2/wrapt-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:b680bef5200899524667f813fa34dcfde9313fef9f9fa432985d4bc7b4195ba3", size = 109711, upload-time = "2026-10-29T23:06:05.768Z" }, + { url = "https://files.pythonhosted.org/packages/be/52/8012aaf046264e27969f79bc94cd2afb4729338f5434b90edd3d4bb60b8f/wrapt-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:da51f5ac290f2e024ee842799c6592f68cd880c20f6b3a54305d1b6aeab459a4", size = 110047, upload-time = "2026-10-29T23:06:07.309Z" }, + { url = "https://files.pythonhosted.org/packages/54/a7/628c315d19c6027e8b0e700c0dd7d5c3fd662329c7aa1294315064b87cdd/wrapt-2.5.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ebc07822649eda442382c1d20983bc6ec5aa2bf79a600da11b29ecb62a0fb2", size = 285041, upload-time = "2026-10-29T23:06:08.777Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fd/4b418c9655bacb5ce8b0aed894f8355e12940bde74237ec45d9396e1f87f/wrapt-2.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb6e959c1e2c32bbad291f83af6320c8c46bb64e256801e85f55d88a09522d5", size = 295329, upload-time = "2026-10-29T23:06:10.489Z" }, + { url = "https://files.pythonhosted.org/packages/92/24/dbe9776fded6dd5f2bfa1446ec04a33afe5afac1d5fb89cd7f5e96fbc61d/wrapt-2.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0541e62f7b9c0d42130e3469d6ac3b1767f019314ae795646a2850a75c64412", size = 265088, upload-time = "2026-10-29T23:06:11.963Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ee/4103576d31bed902224fed8b1d06b94313491504d1d644e42bceba4de880/wrapt-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:18819c05409a161999be68da8e451636470c35f77e25c8201825a33a7d88c89c", size = 290413, upload-time = "2026-10-29T23:06:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7c/944e95c25232a5805ea9f25cf2ce90386262db025ba04fbb61f451d9523f/wrapt-2.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:2594e0be4ee09b2f19828265a39df90e591def44215fe8c5b8d6b83aa4cc18b5", size = 262383, upload-time = "2026-10-29T23:06:14.887Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b3/a4b66497e3bb63d2e94ea1605f4beaf5b6b5dafbb6ce41abd727a6fcb7b5/wrapt-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:32c0387872e67b9dfdd2b599bf31fe127e597ed8526c5f192cdaca57a491e9dc", size = 280180, upload-time = "2026-10-29T23:06:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/91/bd/28d046dccfcade6ad62ad19f2f805e5b2e3f3463431307c3b8bd54e24e7f/wrapt-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:9cb33f293e3aa04b36f7d165dc05b42db85e0a88e20a8fd81f60426d1f17d535", size = 103551, upload-time = "2026-10-29T23:06:17.732Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d9/aef3035a3e3d918eb51e7db454af9e06919f0c5208fa0a9315548fef7734/wrapt-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:7f89423965b23aa62a90bf21c20353056a829ca9a60c01fde9aec9876c99392e", size = 110099, upload-time = "2026-10-29T23:06:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/56eddba6249fae5361b53eb62e2e25ad86cabec4632d943d356aba8dea1b/wrapt-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:fa642d3781c061579cb138773add4a55b16f99de9ebc41603e89a756cf9e62c6", size = 106397, upload-time = "2026-10-29T23:06:20.826Z" }, + { url = "https://files.pythonhosted.org/packages/77/8b/1ea57b936b70f98e8e7620a352578096d778dbee95b262f73c066d210ffb/wrapt-2.5.2-py3-none-any.whl", hash = "sha256:61eb54e9540ea522c2c4752a57549a83d3c67e6704ed708403d28e067aaf90f0", size = 82025, upload-time = "2026-10-29T23:06:38.56Z" }, +] diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..ddce69b --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.astro/ diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000..c0a2f6a --- /dev/null +++ b/website/README.md @@ -0,0 +1,33 @@ +# Website + +The SQLClean website: the homepage at `/` and the documentation at `/docs/`, built as one +[Astro Starlight](https://starlight.astro.build) site. + +- `public/index.html` and `public/styles.css`: the homepage, plain static HTML and CSS. Its terminal + examples are real `sqlclean` output from small example projects, trimmed to the relevant lines. +- `src/content/docs/docs/`: the documentation pages (MDX). `src/sidebar.json` holds the sidebar + and `src/navigation.mjs` the redirects. `STYLE.md` is the style guide for these pages. +- `src/components/`: the page layout, header and page title, replacing Starlight's defaults. + `src/components/docs/` has the `Card`, `CardGroup`, `Frame`, `Note` and `Warning` components the + pages use. +- `public/fonts/`: self-hosted fonts, each with its licence. +- `recordings/`: the VHS tapes behind the README demo; see `recordings/README.md`. + +Search uses Pagefind, and `llms.txt`, `llms-small.txt` and `llms-full.txt` are generated at build +time. The Website workflow builds the site and checks its links on every change; it does not deploy. +Cloudflare Pages deploys it: every push to `main` publishes https://sqlclean.io, and every other +branch gets a preview at `.sqlclean.pages.dev`. See [Releasing](../.github/RELEASING.md). + +The pages under `src/content/docs/docs/` are also bundled into the `sqlclean skills` agent skill. +After editing them, run `make skills` from the repository root and commit the regenerated +`crates/sqlclean-cli/skill/references/docs/`; CI fails while the two differ. + +Requires Node 22.12 or newer. + +```bash +npm install +npm run dev # live preview +npm run build # static site in dist/, including the search index +npm run preview # serve dist/ +python3 scripts/check_links.py dist +``` diff --git a/website/STYLE.md b/website/STYLE.md new file mode 100644 index 0000000..78709f7 --- /dev/null +++ b/website/STYLE.md @@ -0,0 +1,54 @@ +# Docs style guide + +These rules apply to every page under `website/src/content/docs/`. Pages that follow them well: +`rules/overview.mdx`, `cli/check.mdx`, `formatting.mdx` and `custom-rules/overview.mdx`. + +## Write for users + +- Document what a user does, sees and must decide: commands, flags, config keys, defaults, limits, + Rule codes and messages, output, exit codes, and the fix for each problem. +- Leave out how SQLClean works inside: cache files and their formats, hashing, parallelism, the + custom Rule host protocol and internal structures. Include a mechanism only when the user must act + on it, and then say what to do (for example, which flag forces a fresh check). +- Never document behaviour you haven't checked in the source or with the real `sqlclean` command. + Paste real output, trimmed. + +## Structure + +- Lead with what the feature does and a short example: a command, a config block or the output a + user sees. +- Organise sections around the user's tasks ("Recording an exception", "Formatting in CI"), not + around the implementation. +- One topic per section. Put flags, config keys and codes in reference tables near the top or + bottom, and details that rarely matter in a short "Details" or "Limitations" list at the end. +- Keep headings that other pages link to. Before renaming or removing one, run + `grep -rn "#" website/src` and update the links. + +## Tables + +- Use a table for anything that varies by dialect or Rule: support, defaults, fixes and caveats. + Don't write a paragraph per dialect or per Rule. +- Use tables for flags, config keys with defaults, exit codes, Rule codes and fields. +- Keep cells short. Put a long explanation in the text below the table. + +## Sentences + +- Write short, plain sentences in the present tense. One idea per sentence. +- Give every Rule an example where it matters, such as the SQL that triggers a finding and the SQL + that fixes it. +- Use neutral orders-style examples (orders, customers, products, payments). + +## No history + +- Describe the product as it is. Don't write "now", "no longer", "used to", "earlier releases", + "as before", "upgrade note" or "previously". History belongs in `CHANGELOG.md`. +- Drop notes about legacy syntax and migrations from old versions once the error message itself + tells the user what to do. + +## Changing a page + +- When a feature changes a page, edit the page into shape: put the new fact in the section it + belongs to, update tables and examples, and remove what is no longer true. Don't append a + paragraph to the end of a section or page. +- Don't repeat a rule that another page owns. Link to it instead, for example to + `rules/overview#exceptions` for how exceptions match findings. diff --git a/website/astro.config.mjs b/website/astro.config.mjs new file mode 100644 index 0000000..a4f4b43 --- /dev/null +++ b/website/astro.config.mjs @@ -0,0 +1,81 @@ +import { defineConfig } from 'astro/config'; +import starlight from '@astrojs/starlight'; +import starlightLlmsTxt from 'starlight-llms-txt'; +import { sqlcleanDark, sqlcleanLight } from './src/code-themes.mjs'; +import { redirects, sidebar } from './src/navigation.mjs'; + +export default defineConfig({ + site: 'https://sqlclean.io', + redirects, + integrations: [ + starlight({ + title: 'SQLClean', + description: 'Fast SQL linting, safe fixes and one canonical format.', + logo: { + light: './src/assets/logo-light.png', + dark: './src/assets/logo-dark.png', + replacesTitle: true, + }, + favicon: '/favicon.png', + head: [ + { tag: 'meta', attrs: { property: 'og:image', content: 'https://sqlclean.io/assets/og-sqlclean-docs.png' } }, + { tag: 'meta', attrs: { property: 'og:image:width', content: '1200' } }, + { tag: 'meta', attrs: { property: 'og:image:height', content: '630' } }, + { tag: 'meta', attrs: { name: 'twitter:image', content: 'https://sqlclean.io/assets/og-sqlclean-docs.png' } }, + ], + social: [ + { icon: 'github', label: 'GitHub', href: 'https://github.com/sqlstack-io/sqlclean' }, + ], + components: { + PageFrame: './src/components/PageFrame.astro', + TwoColumnContent: './src/components/TwoColumnContent.astro', + Header: './src/components/Header.astro', + PageTitle: './src/components/PageTitle.astro', + SiteTitle: './src/components/SiteTitle.astro', + Pagination: './src/components/Pagination.astro', + ThemeSelect: './src/components/ThemeSelect.astro', + }, + customCss: ['./src/styles/fonts.css', './src/styles/sqlclean.css'], + expressiveCode: { + themes: [sqlcleanDark, sqlcleanLight], + defaultProps: { + // Plain boxes for shell snippets, without the fake terminal title bar. + overridesByLang: { 'bash,sh,shell,zsh,console,powershell': { frame: 'none' } }, + }, + styleOverrides: { + borderRadius: '2px', + borderWidth: '1px', + codeFontFamily: 'var(--sl-font-mono)', + codeFontSize: '13px', + codeLineHeight: '1.65', + uiFontFamily: 'var(--sl-font)', + frames: { + shadowColor: 'transparent', + inlineButtonBorder: 'transparent', + inlineButtonBackgroundIdleOpacity: '0', + inlineButtonBackgroundHoverOrFocusOpacity: '0.08', + inlineButtonForeground: 'var(--sqc-muted)', + frameBoxShadowCssValue: 'none', + editorTabBarBackground: 'var(--sqc-code-bg)', + editorActiveTabBackground: 'var(--sqc-code-bg)', + editorActiveTabIndicatorTopColor: 'var(--sqc-primary)', + editorTabBarBorderBottomColor: 'var(--sqc-line)', + terminalTitlebarBackground: 'var(--sqc-code-bg)', + terminalTitlebarBorderBottomColor: 'var(--sqc-line)', + terminalTitlebarDotsOpacity: '0.35', + terminalBackground: 'var(--sqc-code-bg)', + }, + borderColor: 'var(--sqc-line)', + }, + }, + sidebar, + plugins: [ + starlightLlmsTxt({ + projectName: 'SQLClean', + description: + 'SQLClean is a free, open-source SQL linter and formatter with safe fixes and custom Rules in Python.', + }), + ], + }), + ], +}); diff --git a/website/package-lock.json b/website/package-lock.json new file mode 100644 index 0000000..30037c6 --- /dev/null +++ b/website/package-lock.json @@ -0,0 +1,6078 @@ +{ + "name": "sqlclean-website", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sqlclean-website", + "dependencies": { + "@astrojs/starlight": "^0.42.4", + "@lucide/astro": "^1.48.0", + "astro": "^7.3.5", + "sharp": "^0.35.4", + "starlight-llms-txt": "^0.12.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.5.0.tgz", + "integrity": "sha512-PxFTx3WoxQnbgUvvwmF6LRQwhHO77a4pewOtJvzxlrYtQ90tLgomLKvks/akrd2iix2ngGlOhxQonKNd8YoBHg==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@astrojs/compiler-binding-android-arm64": "0.5.0", + "@astrojs/compiler-binding-darwin-arm64": "0.5.0", + "@astrojs/compiler-binding-darwin-x64": "0.5.0", + "@astrojs/compiler-binding-linux-arm64-gnu": "0.5.0", + "@astrojs/compiler-binding-linux-arm64-musl": "0.5.0", + "@astrojs/compiler-binding-linux-x64-gnu": "0.5.0", + "@astrojs/compiler-binding-linux-x64-musl": "0.5.0", + "@astrojs/compiler-binding-wasm32-wasi": "0.5.0", + "@astrojs/compiler-binding-win32-arm64-msvc": "0.5.0", + "@astrojs/compiler-binding-win32-x64-msvc": "0.5.0" + } + }, + "node_modules/@astrojs/compiler-binding-android-arm64": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-android-arm64/-/compiler-binding-android-arm64-0.5.0.tgz", + "integrity": "sha512-wVviC/1KaBqGVF0DbTZ8UtAE3c8TN84CCe7w4ju3SGzo/ByWKn8HCqSU/+1wdltGCCSa30pjVk3H8DQUgnfhRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-arm64": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.5.0.tgz", + "integrity": "sha512-5NNIkaGeiztD9QQfhOy+nNrBCZVeHqVZPlfJVjTKq4yJiiSsxAhuBzmVlmK3BDhbSgUT8dai4JV5m6t4/BIQaw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-x64": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.5.0.tgz", + "integrity": "sha512-HU/waaccOQDXpvjoWA/9xzadNZIcWKblJoaw19lCxfVs6AHQYdZZOzZUQjY8HaLYGerTXjdADbra2XU+kXk9Og==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-gnu": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.5.0.tgz", + "integrity": "sha512-a21petBJRi0mNBf+gkRZFoWdGH+VOo0zRQSzM4Nuez5RL5rFFkmbondMejkaRZzvE3LVYU2NVD5MGRgAs4V+nQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-musl": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.5.0.tgz", + "integrity": "sha512-t5rmu23b/6e6v0bhPibCtwPdABXMRL+bUaJXdkJwTpkINp4zCgH6fNa5Z6+RgkgFSCpmg9uf1MtRIqx1gdmBaA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-gnu": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.5.0.tgz", + "integrity": "sha512-JWtouuxSiDb2OKKFsxOwfqlpJuZHo1ExirlBGfDgoJVG5zeJWwcd2tU2w4UaJA4suT/tAFSLk/vFhM5fLjWV4A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-musl": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.5.0.tgz", + "integrity": "sha512-LlaSO5v3UC6IxuTKXDViWV/fFHYhLKSO9Of118lNBXv/sjIWwziU+jjENxPgxfq/4oUeOu+613y6H93LBPNTOA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-wasm32-wasi": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.5.0.tgz", + "integrity": "sha512-IP+Cp0m9yKg/AUT47077vHEaAOh/oT/iUZx1PDnV2GJzUxLSEotdQy5Hhv+ycvEadO/DbETSJfAWeV7kte4etQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.3", + "@emnapi/runtime": "1.11.3", + "@napi-rs/wasm-runtime": "^1.2.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.5.0.tgz", + "integrity": "sha512-v6plwy5GQBGvfv4QgiGhy/qkkQvYKW7Zh2vBArOnsGEDNHyUS43CWYwIQdw4ZdVVmLsFyhwTiueDZEP6zejddQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-x64-msvc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.5.0.tgz", + "integrity": "sha512-SgM158cSCJ248UIXaCCW2WqSTjOoFlDv93ed/ATguJqYpZNNVMlOdcPlgNH7WYj/OYeD0rIQr7XyRAYhB7+G1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-rs": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.5.0.tgz", + "integrity": "sha512-2xIypn5R33FRWCXXXv0sfbDj8xcOvc2VmIbteytxMjOzP9LiGfvqaAFJjhdAjzhs6+ZKzYzbSE79rrPObYW5mg==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler-binding": "0.5.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.11.0.tgz", + "integrity": "sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.3.0", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } + }, + "node_modules/@astrojs/markdown-satteri": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.2.tgz", + "integrity": "sha512-g4QehXnB/MzxQWnSf17NciFInMqEJy9948p8cc7zGg/r7995m0Ln5PMr3Xlnj24iey1LchaoZ3SzTOa3D4P/3w==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.11.0", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "satteri": "^0.10.3" + } + }, + "node_modules/@astrojs/mdx": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-8.0.2.tgz", + "integrity": "sha512-WedIVP2Bu8fXSzxb3ZYZ8xDrWWjf2SRKCneh52Llhoa+O3lcpHPhoZVP3FerlivUaH/0LJz2yVaCNvMdg0u8Bw==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.11.0", + "@astrojs/markdown-satteri": "0.4.2", + "es-module-lexer": "^2.0.0" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "^7.3.0", + "@astrojs/markdown-satteri": "^0.4.0", + "astro": "^7.2.10" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + } + }, + "node_modules/@astrojs/prism": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", + "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/sitemap": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.4.tgz", + "integrity": "sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA==", + "license": "MIT", + "dependencies": { + "sitemap": "^9.0.0", + "zod": "^4.3.6" + } + }, + "node_modules/@astrojs/starlight": { + "version": "0.42.4", + "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.42.4.tgz", + "integrity": "sha512-EsQsqGJ1uTBQcIMojvLqqg4Ku9idwi6PSNuppIOoYmvVP18xjoUkijMdKoDkhmmwKdhbB4ctLhGjPRJzxaq+Jw==", + "license": "MIT", + "dependencies": { + "@astrojs/markdown-satteri": "^0.4.0", + "@astrojs/mdx": "^8.0.0", + "@astrojs/sitemap": "^3.7.3", + "@pagefind/default-ui": "^1.3.0", + "@types/hast": "^3.0.5", + "@types/js-yaml": "^4.0.9", + "@types/mdast": "^4.0.4", + "astro-expressive-code": "^0.44.0", + "bcp-47": "^2.1.0", + "hast-util-format": "^1.1.0", + "hast-util-select": "^6.0.4", + "hast-util-to-html": "^9.0.5", + "hast-util-to-string": "^3.0.1", + "hastscript": "^9.0.1", + "i18next": "^26.0.7", + "js-yaml": "^4.1.1", + "klona": "^2.0.6", + "magic-string": "^1.2.3", + "mdast-util-directive": "^3.1.0", + "mdast-util-to-markdown": "^2.1.2", + "mdast-util-to-string": "^4.0.0", + "pagefind": "^1.5.2", + "remark-directive": "^4.0.0", + "satteri": "^0.10.3", + "ultrahtml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0", + "vfile": "^6.0.3" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "^7.3.0", + "astro": "^7.2.10" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz", + "integrity": "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.4.0", + "dset": "^3.1.4", + "is-docker": "^4.0.0", + "package-manager-detector": "^1.6.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bruits/satteri-darwin-arm64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", + "integrity": "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-darwin-x64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.10.5.tgz", + "integrity": "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.10.5.tgz", + "integrity": "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.10.5.tgz", + "integrity": "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.10.5.tgz", + "integrity": "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.10.5.tgz", + "integrity": "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-wasm32-wasi": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.10.5.tgz", + "integrity": "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.2.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-win32-arm64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.10.5.tgz", + "integrity": "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@bruits/satteri-win32-x64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.10.5.tgz", + "integrity": "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.1.tgz", + "integrity": "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@clack/core": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", + "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", + "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.5.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.1.tgz", + "integrity": "sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@expressive-code/core": { + "version": "0.44.2", + "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.44.2.tgz", + "integrity": "sha512-Yw5KonQCD1HoOO2P89y4wifqS9qrJcGVY0LkVMUsVZjhi++bhz4WwQ37inenKFNOEVo50P7cjoVsCdaVnUbq8w==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.0.4", + "hast-util-select": "^6.0.2", + "hast-util-to-html": "^9.0.1", + "hast-util-to-text": "^4.0.1", + "hastscript": "^9.0.0", + "postcss": "^8.4.38", + "postcss-nested": "^6.0.1", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.1" + } + }, + "node_modules/@expressive-code/plugin-frames": { + "version": "0.44.2", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.44.2.tgz", + "integrity": "sha512-gVLm6TQMNIRWIaM7o+mCtxkGNjLZY5/2faSTLoXeo9wv/Pkj/0QAtZyXPVPrRsPARqiipATNFmfvQFLJudxPHQ==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.2" + } + }, + "node_modules/@expressive-code/plugin-shiki": { + "version": "0.44.2", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.44.2.tgz", + "integrity": "sha512-ebWIvCDzXvjyD8rH6T6DfytYJw1GoArLR4nusIAE4VxXrCQf3d+owytiv010KU/dUg+SRLZskDn0N+HaFXeuSg==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.2", + "shiki": "^4.0.2" + } + }, + "node_modules/@expressive-code/plugin-text-markers": { + "version": "0.44.2", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.44.2.tgz", + "integrity": "sha512-SNI7EgUSiADqbqUzzMbj1ZeC/r2pa5eg3KyFOGAd3a7EsIA4qzjXQLp+ODWQtHVDKFCdGs4J3ovSJUqvKIK96g==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.2" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@lucide/astro": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/@lucide/astro/-/astro-1.48.0.tgz", + "integrity": "sha512-BL+7lLGjjSTfdYEBd9pAB0ixhJ7M84OVzxHoY3oG3M92kX/q1UG2+znp+pqH3waxpIHF1l8Hd1QZxytqv5WH8A==", + "license": "ISC", + "peerDependencies": { + "astro": "^4 || ^5 || ^6 || ^7" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@pagefind/darwin-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.5.2.tgz", + "integrity": "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/darwin-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.5.2.tgz", + "integrity": "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/default-ui": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/default-ui/-/default-ui-1.5.2.tgz", + "integrity": "sha512-pm1LMnQg8N2B3n2TnjKlhaFihpz6zTiA4HiGQ6/slKO/+8K9CAU5kcjdSSPgpuk1PMuuN4hxLipUIifnrkl3Sg==", + "license": "MIT" + }, + "node_modules/@pagefind/freebsd-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.5.2.tgz", + "integrity": "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@pagefind/linux-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.5.2.tgz", + "integrity": "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/linux-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.5.2.tgz", + "integrity": "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/windows-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/windows-arm64/-/windows-arm64-1.5.2.tgz", + "integrity": "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@pagefind/windows-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.5.2.tgz", + "integrity": "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@shikijs/core": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", + "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", + "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", + "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", + "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", + "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", + "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/braces": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.5.tgz", + "integrity": "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/micromatch": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.10.tgz", + "integrity": "sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==", + "license": "MIT", + "dependencies": { + "@types/braces": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "24.13.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", + "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, + "node_modules/am-i-vibing": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz", + "integrity": "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==", + "license": "MIT", + "dependencies": { + "process-ancestry": "^0.1.0" + }, + "bin": { + "am-i-vibing": "dist/cli.mjs" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/astro": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.3.5.tgz", + "integrity": "sha512-1p/ernaq/RuESbM5NMjWzbO6SiebMGrGcYlGjMfBhzzgM+n/X7AUmVyw8BqW2QYCdivpsHwJBcD1fqmlTZjZHg==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler-rs": "^0.5.0", + "@astrojs/internal-helpers": "0.11.0", + "@astrojs/markdown-satteri": "0.4.2", + "@astrojs/telemetry": "3.3.3", + "@capsizecss/unpack": "^4.0.0", + "@clack/prompts": "^1.1.0", + "@oslojs/encoding": "^1.1.0", + "am-i-vibing": "^0.4.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "ci-info": "^4.4.0", + "clsx": "^2.1.1", + "common-ancestor-path": "^2.0.0", + "cookie": "^2.0.1", + "devalue": "^5.8.1", + "diff": "^9.0.0", + "dset": "^3.1.4", + "es-module-lexer": "^2.0.0", + "esbuild": "^0.28.0", + "find-proc": "0.2.0", + "flattie": "^1.1.1", + "fontace": "~0.4.1", + "get-tsconfig": "5.0.0-beta.4", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "js-yaml": "^4.3.2", + "jsonc-parser": "^3.3.1", + "magic-string": "^1.0.0", + "magicast": "^0.5.2", + "mrmime": "^2.0.1", + "neotraverse": "^1.0.1", + "obug": "^3.0.0", + "p-limit": "^7.3.0", + "p-queue": "^9.1.0", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.4", + "shiki": "^4.0.2", + "smol-toml": "^1.8.0", + "svgo": "^4.1.0", + "tinyclip": "^1.0.1", + "tinyexec": "^1.0.4", + "tinyglobby": "^0.2.15", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.5", + "unstorage": "^1.17.5", + "verkit": "^0.4.0", + "vite": "^8.0.13", + "vitefu": "^1.1.2", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^22.0.0", + "zod": "^4.5.4" + }, + "bin": { + "astro": "bin/astro.mjs" + }, + "engines": { + "node": ">=22.12.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.35.4" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "^7.3.0" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + } + }, + "node_modules/astro-expressive-code": { + "version": "0.44.2", + "resolved": "https://registry.npmjs.org/astro-expressive-code/-/astro-expressive-code-0.44.2.tgz", + "integrity": "sha512-avop7nZcRwC7quvIxVguhwEZ9Tp6IoTzKUCLwP30Fb7+er+pWQgcysP01dcuzRIJo81kLGg30q/YAI2Yb1Sdzg==", + "license": "MIT", + "dependencies": { + "rehype-expressive-code": "^0.44.2", + "url-extras": "^0.1.0" + }, + "peerDependencies": { + "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.1.tgz", + "integrity": "sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47-match": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", + "integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", + "license": "MIT" + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-selector-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz", + "integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/direction": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/direction/-/direction-2.0.1.tgz", + "integrity": "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==", + "license": "MIT", + "bin": { + "direction": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expressive-code": { + "version": "0.44.2", + "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.44.2.tgz", + "integrity": "sha512-MLNghkvhyFgVW4oCD5DxBuYnnTXIf0PygzGmfT6OZeOOYWh/D4sH1j931biElkA5RG/J9wY/iuSMGAPF5vzHOg==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.2", + "@expressive-code/plugin-frames": "^0.44.2", + "@expressive-code/plugin-shiki": "^0.44.2", + "@expressive-code/plugin-text-markers": "^0.44.2" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-proc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/find-proc/-/find-proc-0.2.0.tgz", + "integrity": "sha512-a6h2nTrgB3g5Wrn4au9Ux4kMk7IJtyF6oA8uGn+mk3weLZssUtc1cp6meHIzzuW2o1srrBAxzkmXLbqjRkgB6w==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", + "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "5.0.0-beta.4", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", + "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "engines": { + "node": ">=20.20.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/h3": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.3", + "crossws": "^0.3.5", + "defu": "^6.1.6", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/hast-util-embedded": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", + "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-format": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hast-util-format/-/hast-util-format-1.1.0.tgz", + "integrity": "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-minify-whitespace": "^1.0.0", + "hast-util-phrasing": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "html-whitespace-sensitive-tag-names": "^3.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-has-property": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", + "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-body-ok-link": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz", + "integrity": "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-minify-whitespace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz", + "integrity": "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-is-body-ok-link": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-select": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/hast-util-select/-/hast-util-select-6.0.4.tgz", + "integrity": "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "bcp-47-match": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "css-selector-parser": "^3.0.0", + "devlop": "^1.0.0", + "direction": "^2.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-to-string": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "nth-check": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-mdast": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/hast-util-to-mdast/-/hast-util-to-mdast-10.1.2.tgz", + "integrity": "sha512-FiCRI7NmOvM4y+f5w32jPRzcxDIz+PUqDwEqn1A+1q2cdp3B8Gx7aVrXORdOKjMNDQsD1ogOr896+0jJHW1EFQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-phrasing": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "hast-util-to-text": "^4.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "mdast-util-to-string": "^4.0.0", + "rehype-minify-whitespace": "^6.0.0", + "trim-trailing-lines": "^2.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/html-whitespace-sensitive-tag-names": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.1.tgz", + "integrity": "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/i18next": { + "version": "26.4.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", + "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-4.0.0.tgz", + "integrity": "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-directive": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", + "integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz", + "integrity": "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.5.tgz", + "integrity": "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.3.tgz", + "integrity": "sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz", + "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.2.tgz", + "integrity": "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, + "node_modules/pagefind": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.5.2.tgz", + "integrity": "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==", + "license": "MIT", + "bin": { + "pagefind": "lib/runner/bin.cjs" + }, + "optionalDependencies": { + "@pagefind/darwin-arm64": "1.5.2", + "@pagefind/darwin-x64": "1.5.2", + "@pagefind/freebsd-x64": "1.5.2", + "@pagefind/linux-arm64": "1.5.2", + "@pagefind/linux-x64": "1.5.2", + "@pagefind/windows-arm64": "1.5.2", + "@pagefind/windows-x64": "1.5.2" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/process-ancestry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/process-ancestry/-/process-ancestry-0.1.0.tgz", + "integrity": "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype-expressive-code": { + "version": "0.44.2", + "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.44.2.tgz", + "integrity": "sha512-jvjQpsfWKBquDrEl5YQA3CJybRRIuDOYsui1BJTs5oKDUK8pPkJSSxY8FjxNlIFIdtgrTjC1zuahuY4Cc1j9SQ==", + "license": "MIT", + "dependencies": { + "expressive-code": "^0.44.2" + } + }, + "node_modules/rehype-minify-whitespace": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/rehype-minify-whitespace/-/rehype-minify-whitespace-6.0.2.tgz", + "integrity": "sha512-Zk0pyQ06A3Lyxhe9vGtOtzz3Z0+qZ5+7icZ/PL/2x1SHPbKao5oB/g/rlc6BCTajqBb33JcOe71Ye1oFsuYbnw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-minify-whitespace": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-remark": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-remark/-/rehype-remark-10.0.1.tgz", + "integrity": "sha512-EmDndlb5NVwXGfUa4c9GPK+lXeItTilLhE6ADSaQuHr4JUlKw9MidzGzx4HpqZrNCt6vnHmEifXQiiA+CEnjYQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "hast-util-to-mdast": "^10.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-4.0.0.tgz", + "integrity": "sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-directive": "^3.0.0", + "micromark-extension-directive": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/satteri": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", + "integrity": "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.5", + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "@types/unist": "^3.0.3" + }, + "optionalDependencies": { + "@bruits/satteri-darwin-arm64": "0.10.5", + "@bruits/satteri-darwin-x64": "0.10.5", + "@bruits/satteri-linux-arm64-gnu": "0.10.5", + "@bruits/satteri-linux-arm64-musl": "0.10.5", + "@bruits/satteri-linux-x64-gnu": "0.10.5", + "@bruits/satteri-linux-x64-musl": "0.10.5", + "@bruits/satteri-wasm32-wasi": "0.10.5", + "@bruits/satteri-win32-arm64-msvc": "0.10.5", + "@bruits/satteri-win32-x64-msvc": "0.10.5" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/shiki": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", + "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.3", + "@shikijs/engine-javascript": "4.4.3", + "@shikijs/engine-oniguruma": "4.4.3", + "@shikijs/langs": "4.4.3", + "@shikijs/themes": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/sitemap": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz", + "integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^24.9.2", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.4.1" + }, + "bin": { + "sitemap": "dist/esm/cli.js" + }, + "engines": { + "node": ">=20.19.5", + "npm": ">=10.8.2" + } + }, + "node_modules/smol-toml": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", + "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/starlight-llms-txt": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/starlight-llms-txt/-/starlight-llms-txt-0.12.0.tgz", + "integrity": "sha512-ya4c0ZoiPUXuHDzd3AYCKh78Y3II3EToSE9Yp6uVN0c2UUKoNpqTjijhW86W/4toZzSUhWSnxeYs7QBcXNIPGg==", + "license": "MIT", + "dependencies": { + "@astrojs/mdx": "^8.0.1", + "@types/hast": "^3.0.5", + "@types/micromatch": "^4.0.10", + "github-slugger": "^2.0.0", + "hast-util-select": "^6.0.4", + "micromatch": "^4.0.8", + "rehype-parse": "^9.0.1", + "rehype-remark": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-stringify": "^11.0.0", + "unified": "^11.0.5", + "unist-util-remove": "^4.0.0" + }, + "peerDependencies": { + "@astrojs/starlight": ">=0.41.0", + "astro": "^7.0.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/svgo": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz", + "integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^6.0.0", + "css-tree": "^3.0.1", + "css-what": "^7.0.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "1.6.1" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyclip": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-1.0.3.tgz", + "integrity": "sha512-nTv2raKFMzGG6JJp0lpNDsRPdfFE6UHlu2rVEjXo8vkx1ZS3LW2CVXqIaLu8xZlu+6tbFs9V58Qc/Jmk/Ii7wA==", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >= 17.3.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trim-trailing-lines": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/trim-trailing-lines/-/trim-trailing-lines-2.1.0.tgz", + "integrity": "sha512-5UR5Biq4VlVOtzqkm2AZlgvSlDJtME46uV0br0gENbwN4l5+mMKT4b9gJKqWtuL2zAIqajGJGuvbCbcAJUZqBg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz", + "integrity": "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.5.tgz", + "integrity": "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ohash": "^2.0.11", + "undici": "^8.0.0" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove/-/unist-util-remove-4.0.0.tgz", + "integrity": "sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/url-extras": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/url-extras/-/url-extras-0.1.0.tgz", + "integrity": "sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/verkit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.4.1.tgz", + "integrity": "sha512-mMVzj0TXExtVdlDEq+Mzp0eyOuyznNpFobNM3uAqe3HVm/Ps2c+u17BligMkZjZCA6EiXpoon8iRTer3iu40SQ==", + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.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 + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..6516925 --- /dev/null +++ b/website/package.json @@ -0,0 +1,20 @@ +{ + "name": "sqlclean-website", + "private": true, + "type": "module", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@astrojs/starlight": "^0.42.4", + "@lucide/astro": "^1.48.0", + "astro": "^7.3.5", + "sharp": "^0.35.4", + "starlight-llms-txt": "^0.12.0" + } +} diff --git a/website/public/assets/icon.png b/website/public/assets/icon.png new file mode 100644 index 0000000..48c19ba Binary files /dev/null and b/website/public/assets/icon.png differ diff --git a/website/public/assets/logo-dark.png b/website/public/assets/logo-dark.png new file mode 100644 index 0000000..9fecb46 Binary files /dev/null and b/website/public/assets/logo-dark.png differ diff --git a/website/public/assets/logo-light.png b/website/public/assets/logo-light.png new file mode 100644 index 0000000..d488ef1 Binary files /dev/null and b/website/public/assets/logo-light.png differ diff --git a/website/public/assets/og-icon.png b/website/public/assets/og-icon.png new file mode 100644 index 0000000..cce1d98 Binary files /dev/null and b/website/public/assets/og-icon.png differ diff --git a/website/public/assets/og-sqlclean-docs.png b/website/public/assets/og-sqlclean-docs.png new file mode 100644 index 0000000..61a85f3 Binary files /dev/null and b/website/public/assets/og-sqlclean-docs.png differ diff --git a/website/public/assets/og-sqlclean.png b/website/public/assets/og-sqlclean.png new file mode 100644 index 0000000..33be701 Binary files /dev/null and b/website/public/assets/og-sqlclean.png differ diff --git a/website/public/favicon.png b/website/public/favicon.png new file mode 100644 index 0000000..48c19ba Binary files /dev/null and b/website/public/favicon.png differ diff --git a/website/public/fonts/LICENSE-DejaVu.txt b/website/public/fonts/LICENSE-DejaVu.txt new file mode 100644 index 0000000..b3d93a1 --- /dev/null +++ b/website/public/fonts/LICENSE-DejaVu.txt @@ -0,0 +1,78 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: DejaVu fonts +Upstream-Author: Stepan Roh (original author), + see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list +Source: https://dejavu-fonts.github.io/ + +Files: * +Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. + Bitstream Vera is a trademark of Bitstream, Inc. + DejaVu changes are in public domain. +License: bitstream-vera + Permission is hereby granted, free of charge, to any person obtaining a copy + of the fonts accompanying this license ("Fonts") and associated + documentation files (the "Font Software"), to reproduce and distribute the + Font Software, including without limitation the rights to use, copy, merge, + publish, distribute, and/or sell copies of the Font Software, and to permit + persons to whom the Font Software is furnished to do so, subject to the + following conditions: + . + The above copyright and trademark notices and this permission notice shall + be included in all copies of one or more of the Font Software typefaces. + . + The Font Software may be modified, altered, or added to, and in particular + the designs of glyphs or characters in the Fonts may be modified and + additional glyphs or characters may be added to the Fonts, only if the fonts + are renamed to names not containing either the words "Bitstream" or the word + "Vera". + . + This License becomes null and void to the extent applicable to Fonts or Font + Software that has been modified and is distributed under the "Bitstream + Vera" names. + . + The Font Software may be sold as part of a larger software package but no + copy of one or more of the Font Software typefaces may be sold by itself. + . + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, + TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME + FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING + ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF + THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE + FONT SOFTWARE. + . + Except as contained in this notice, the names of Gnome, the Gnome + Foundation, and Bitstream Inc., shall not be used in advertising or + otherwise to promote the sale, use or other dealings in this Font Software + without prior written authorization from the Gnome Foundation or Bitstream + Inc., respectively. For further information, contact: fonts at gnome dot + org. + +Files: debian/* +Copyright: (C) 2005-2006 Peter Cernak + (C) 2006-2011 Davide Viti + (C) 2011-2013 Christian Perrier + (C) 2013 Fabian Greffrath +License: GPL-2+ + This program is free software; you can redistribute it + and/or modify it under the terms of the GNU General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later + version. + . + This program is distributed in the hope that it will be + useful, but WITHOUT ANY WARRANTY; without even the implied + warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + PURPOSE. See the GNU General Public License for more + details. + . + You should have received a copy of the GNU General Public + License along with this package; if not, write to the Free + Software Foundation, Inc., 51 Franklin St, Fifth Floor, + Boston, MA 02110-1301 USA + . + On Debian systems, the full text of the GNU General Public + License version 2 can be found in the file + /usr/share/common-licenses/GPL-2'. diff --git a/website/public/fonts/LICENSE-IBM-Plex-Mono.txt b/website/public/fonts/LICENSE-IBM-Plex-Mono.txt new file mode 100644 index 0000000..83eac82 --- /dev/null +++ b/website/public/fonts/LICENSE-IBM-Plex-Mono.txt @@ -0,0 +1,93 @@ +Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ThinItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLight.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Light.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-LightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Regular.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Italic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Medium.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-MediumItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Bold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-BoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/website/public/fonts/LICENSE-Inter.txt b/website/public/fonts/LICENSE-Inter.txt new file mode 100644 index 0000000..40589da --- /dev/null +++ b/website/public/fonts/LICENSE-Inter.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/website/public/fonts/dejavu-sans-mono-diagram.woff2 b/website/public/fonts/dejavu-sans-mono-diagram.woff2 new file mode 100644 index 0000000..de6e29f Binary files /dev/null and b/website/public/fonts/dejavu-sans-mono-diagram.woff2 differ diff --git a/website/public/fonts/ibm-plex-mono-latin-400-normal.woff2 b/website/public/fonts/ibm-plex-mono-latin-400-normal.woff2 new file mode 100644 index 0000000..0804aaf Binary files /dev/null and b/website/public/fonts/ibm-plex-mono-latin-400-normal.woff2 differ diff --git a/website/public/fonts/ibm-plex-mono-latin-500-normal.woff2 b/website/public/fonts/ibm-plex-mono-latin-500-normal.woff2 new file mode 100644 index 0000000..090f82f Binary files /dev/null and b/website/public/fonts/ibm-plex-mono-latin-500-normal.woff2 differ diff --git a/website/public/fonts/inter-latin-wght-normal.woff2 b/website/public/fonts/inter-latin-wght-normal.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/website/public/fonts/inter-latin-wght-normal.woff2 differ diff --git a/website/public/index.html b/website/public/index.html new file mode 100644 index 0000000..6e5c495 --- /dev/null +++ b/website/public/index.html @@ -0,0 +1,493 @@ + + + + + + SQLClean · Clean SQL, every time + + + + + + + + + + + + + + + + + + + +
+
+
+

Rust-powered · Free and open source · Apache-2.0

+

Clean SQL, every time

+

A SQL linter and formatter, 50× faster than sqruff.

+

+ SQLClean flags SQL that runs but rarely does what you meant, applies safe fixes, and formats every file + one way. Custom Rules are plain Python functions, backed by a Rust API that keeps them fast. +

+
+
+ $ pip install sqlclean + +
+ Read the docs + View on GitHub +
+
+ +
+ +
+
+
+

Why SQL stays messy

+
+
+
+

Valid isn't the same as correct

+

WHERE x = NULL runs without an error and matches no rows.

+
+
+

Style eats review time

+

Reviewers argue about commas and casing instead of the logic.

+
+
+

Team rules live in people's heads

+

"Marts read staging, not raw" holds until someone new misses it.

+
+
+

Autofixes you can't trust

+

A fix that changes which rows match is a bug, not a cleanup.

+
+
+
+
+ +
+
+
+

From a messy query to a clean one

+

Check, fix and format, then keep it that way with your own Rules, in CI and before every commit.

+
+ +
+

Find the problems

+
+
+ 01 · Catch common mistakes +

Flag SQL that runs but is easy to get wrong

+

+ Default Rules flag comparisons with NULL, NOT IN lists that contain + NULL, and ROW_NUMBER with no deterministic order. Each finding points at the + exact characters and says what to write instead. +

+

How Rules work →

+
+
sqlclean check
+error[SQC018]: ROW_NUMBER has no deterministic ordering
+  --> models/open_orders.sql:2:3
+   |
+ 2 |   ROW_NUMBER() OVER (PARTITION BY o.customer_id) AS order_rank
+   |   ^^^^^^^^^^
+  = help: Add ORDER BY with a stable tie-breaker inside the window specification.
+
+error[SQC001]: Comparison with NULL is never true
+  --> models/open_orders.sql:4:22
+   |
+ 4 | WHERE o.cancelled_at = NULL
+   |                      ^
+  = help: Use IS NULL or IS NOT NULL when testing for NULL.
+  = fix: available with `sqlclean fix --unsafe-fixes`
+
+error[SQC019]: NOT IN list contains NULL and can reject every row
+  --> models/open_orders.sql:5:16
+   |
+ 5 |   AND o.status NOT IN ('refunded', NULL)
+   |                ^^^
+  = help: Remove NULL from the list or express the anti-join with a NULL-safe NOT EXISTS.
+
+Found 3 problems (1 fixable with `sqlclean fix --unsafe-fixes`).
+
+ +

Fix them

+
+
+ 02 · Fix safely +

Apply safe fixes, and hold back the rest

+

+ sqlclean fix applies safe fixes until the file is stable, then formats it. A fix that can + change which rows match, like = NULL to IS NULL, waits for + --unsafe-fixes. A pass that would break parsing is thrown away. +

+

How fixing works →

+
+
sqlclean fix --unsafe-fixes --diff
+--- a/models/open_orders.sql
++++ b/models/open_orders.sql
+@@ -1,5 +1,8 @@
+-SELECT o.order_id, o.customer_id, o.total_cents,
+-  ROW_NUMBER() OVER (PARTITION BY o.customer_id) AS order_rank
++SELECT
++    o.order_id,
++    o.customer_id,
++    o.total_cents,
++    ROW_NUMBER() OVER (PARTITION BY o.customer_id) AS order_rank
+ FROM orders o
+-WHERE o.cancelled_at = NULL
+-  AND o.status NOT IN ('refunded', NULL)
++WHERE
++    o.cancelled_at IS NULL AND o.status NOT IN ('refunded', NULL)
+Would fix 1 problem(s) and change 1 file(s).
+
+ +
+
+ 03 · One canonical format +

Stop arguing about layout

+

+ Every statement gets the same layout: upper-case keywords, one column per line and each clause on its + own line. Formatting a formatted file changes nothing, and SQL the formatter can't lay out safely is reported and left as it is. +

+

How formatting works →

+
+
sqlclean format --diff models/revenue.sql
+--- a/models/revenue.sql
++++ b/models/revenue.sql
+@@ -1,3 +1,17 @@
+-with paid as (select order_id, sum(amount_cents) as paid_cents from payments where status = 'paid' group by order_id)
+-select o.order_id, o.ordered_at::date as order_date, coalesce(p.paid_cents, 0) as paid_cents
+-from orders o left join paid p on p.order_id = o.order_id
++WITH paid AS (
++    SELECT
++        order_id,
++        SUM(amount_cents) AS paid_cents
++    FROM payments
++    WHERE status = 'paid'
++    GROUP BY
++        order_id
++)
++
++SELECT
++    o.order_id,
++    o.ordered_at::date AS order_date,
++    COALESCE(p.paid_cents, 0) AS paid_cents
++FROM orders o
++LEFT JOIN paid p
++    ON p.order_id = o.order_id
+1 file(s) would be reformatted, 0 file(s) already formatted.
+
+ +

Keep them fixed

+
+
+ 04 · Make lessons permanent +

Turn review comments into Rules

+

+ A custom Rule is a Python function over the tables, joins, columns and literals of each statement, + already parsed and located for you. No regular expressions, no parser to learn. Here marts must read + staging models, never raw tables. +

+

Write a custom Rule →

+
+
# sql_rules/layers.py
+@rule(
+    code="XSQCLAYER001",
+    message="Marts must not read raw tables",
+    remediation="Read the staging model instead.",
+    enabled_by_default=True,
+)
+def marts_read_staging(*, statement: Statement, ctx: RuleContext) -> list:
+    if not statement.path.startswith("models/marts/"):
+        return []
+    return [
+        ctx.finding(subject=statement, node=table)
+        for table in statement.tables
+        if table.qualified_name.startswith("raw.")
+    ]
+
+sqlclean check
+error[XSQCLAYER001]: Marts must not read raw tables
+  --> models/marts/revenue.sql:5:12
+   |
+ 5 | INNER JOIN raw.payments p
+   |            ^^^^^^^^^^^^
+  = help: Read the staging model instead.
+
+ +
+
+ 05 · Test your Rules +

Rules with tests, like any other code

+

+ sqlclean.testing parses each case with the same engine as sqlclean check and + fails when the number of findings is wrong. It runs every case twice, in opposite file orders, to catch + Rules that aren't deterministic. +

+

Test a custom Rule →

+
+
# tests/test_layers.py
+from sqlclean.testing import RuleCase, evaluate_rule
+
+def test_marts_reading_raw_tables_are_reported() -> None:
+    evaluate_rule(
+        rule=marts_read_staging,
+        test_case=RuleCase(
+            description="a mart joins raw.payments",
+            path="models/marts/revenue.sql",
+            source="SELECT o.order_id FROM stg_orders o JOIN raw.payments p ON …",
+            dialect="duckdb",
+            expected_finding_count=1,
+        ),
+    )
+
+pytest tests
+..                                                    [100%]
+2 passed in 0.04s
+
+ +
+
+ 06 · Review every exception +

Allow a finding once, with a reason

+

+ An exception allows one Rule in one file, and for a cross join, one relation, with a reason a reviewer + can read. When the SQL changes and the exception no longer matches, sqlclean check fails + until it is removed, so allowances never outlive the code they covered. +

+

How exceptions work →

+
+
# sqlclean.toml
+[[rules.rule_exceptions]]
+rule = "SQC038"
+path = "models/marts/calendar.sql"
+relation = "regions"
+reason = "Every date is reported for every region."
+
+sqlclean check
+error[SQC038]: CROSS JOIN with `markets` creates an explicit cartesian product
+  --> models/marts/calendar.sql:5:1
+   |
+ 5 | CROSS JOIN markets r
+   | ^^^^^
+  = help: Use a keyed JOIN, or make one side provably at most one row …
+
+error: stale rule exception: SQC038 in models/marts/calendar.sql for relation regions
+  matches no finding; remove it from sqlclean.toml
+
+ +
+
+ 07 · Run it everywhere +

Before every commit and in CI

+

+ Ready-made pre-commit hooks check, fix or format staged SQL. In CI, sqlclean check exits + non-zero on findings and writes SARIF for code-scanning annotations, or JSON for anything else. +

+

Set up CI and pre-commit →

+
+
# .pre-commit-config.yaml
+repos:
+  - repo: https://github.com/sqlstack-io/sqlclean
+    rev: v0.0.1
+    hooks:
+      - id: sqlclean-fix
+      - id: sqlclean-format
+
+sqlclean check --output-format sarif > sqlclean.sarif
+{
+  "version": "2.1.0",
+  "runs": [{
+    "tool": { "driver": { "name": "sqlclean", … } },
+    "results": [{
+      "ruleId": "XSQCLAYER001",
+      "message": { "text": "Marts must not read raw tables. Read the staging model instead." },
+      …
+
+
+

Real output from sqlclean on small example projects, trimmed to the relevant lines.

+
+
+ +
+
+
+

Fast, and still yours to extend

+

Measured on the same 10,282 SQL files. See the benchmark →

+
+
+
+

50× faster than sqruff

+

sqlclean check takes 0.8 s, sqruff 41 s and SQLFluff 15 minutes. Unchanged files come from the cache, so a rerun takes 0.2 s.

+
+
+

Custom Rules in plain Python

+

A Rule is a Python function over the tables, joins and columns of each query. The Rust engine parses the SQL and builds those facts, so Python only runs your logic: three custom Rules check the same files in 2.3 s.

+
+
+

Safe fixes by default

+

A fix that could change which rows a query returns waits for --unsafe-fixes. A fix that breaks parsing is thrown away.

+
+
+
+
+ +
+
+
+

Start in a minute

+

+ Install it, point it at your SQL, and add a sqlclean.toml when you want to choose a dialect + or Rules. +

+
    +
  • No account, server or plugins to set up.
  • +
  • Custom Rules run in the same Python environment as the command.
  • +
  • Free and open source, with no paid tier.
  • +
+
+ DuckDB + Snowflake + BigQuery + PostgreSQL + Databricks + Redshift + ClickHouse + SQL Server + MySQL + Trino +
+
+
pip install sqlclean
+sqlclean check
+…
+Found 1 problem (1 fixable with `sqlclean fix --unsafe-fixes`).
+sqlclean fix --unsafe-fixes
+Fixed 1 problem(s) and changed 1 file(s).
+All checks passed.
+
+
+ +
+
+

Make clean SQL the default

+

The docs cover every command, every Rule, configuration and custom Rules.

+ +
+
+
+ + + + + + diff --git a/website/public/styles.css b/website/public/styles.css new file mode 100644 index 0000000..28cd94a --- /dev/null +++ b/website/public/styles.css @@ -0,0 +1,1011 @@ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('/fonts/inter-latin-wght-normal.woff2') format('woff2'); +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/ibm-plex-mono-latin-400-normal.woff2') format('woff2'); +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/ibm-plex-mono-latin-500-normal.woff2') format('woff2'); +} + +/* IBM Plex Mono has no box-drawing glyphs. DejaVu Sans Mono fills them in, and draws whole + diagrams so every cell has the same advance width. */ +@font-face { + font-family: 'SQLClean Box Drawing'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/dejavu-sans-mono-diagram.woff2') format('woff2'); + unicode-range: U+2500-259F, U+2260; +} + +@font-face { + font-family: 'SQLClean Diagram'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/dejavu-sans-mono-diagram.woff2') format('woff2'); +} + +/* Tokens shared with the docs theme in src/styles/sqlclean.css. */ +:root { + color-scheme: light; + --radius: 2px; + --background: #ffffff; + --foreground: #171a1f; + --muted-foreground: #626a76; + --faint: #98a0ad; + --primary: #0b8a74; + --primary-hover: #097562; + --primary-foreground: #ffffff; + --panel: #ffffff; + --panel-border: rgba(28, 32, 38, 0.2); + --inner-line: rgba(28, 32, 38, 0.08); + --code-bg: #fbfcfe; + --code-fg: #2b3038; + --success: #16825c; + --warning: #aa6c16; + --error: #c74757; + --stale: #7756b9; + --accent-soft: rgba(11, 138, 116, 0.08); + --font-sans: 'Inter', system-ui, sans-serif; + --font-mono: 'IBM Plex Mono', 'SQLClean Box Drawing', ui-monospace, monospace; +} + +:root[data-theme='dark'] { + color-scheme: dark; + --background: #0d0f12; + --foreground: #e9ebef; + --muted-foreground: #9ba1ac; + --faint: #626975; + --primary-foreground: #0d0f12; + --primary: #1fd8b8; + --primary-hover: #4fe3ca; + --panel: #171a1f; + --panel-border: transparent; + --inner-line: rgba(176, 184, 198, 0.1); + --code-bg: #111418; + --code-fg: #c9cdd4; + --success: #54cf8e; + --warning: #f0c267; + --error: #f47089; + --stale: #a78bfa; + --accent-soft: rgba(31, 216, 184, 0.12); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; +} + +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--primary); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +code, +pre { + font-family: var(--font-mono); +} + +.wrap { + width: min(1240px, 100% - 48px); + margin: 0 auto; +} + +/* Header */ +.site-header { + position: sticky; + top: 0; + z-index: 10; + background: var(--background); + border-bottom: 1px solid var(--inner-line); +} + +.site-header .wrap { + display: flex; + align-items: center; + justify-content: space-between; + height: 60px; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--foreground); + font-weight: 650; + font-size: 18px; + letter-spacing: -0.01em; +} + +.brand:hover { + text-decoration: none; +} + +.brand img { + width: auto; + height: 30px; +} + +.brand .logo-for-dark, +:root[data-theme='dark'] .brand .logo-for-light { + display: none; +} + +:root[data-theme='dark'] .brand .logo-for-dark { + display: block; +} + +.nav { + display: flex; + align-items: center; + gap: 22px; + font-size: 14px; +} + +.nav a { + color: var(--foreground); + font-weight: 500; + opacity: 0.8; + text-decoration: underline; + text-decoration-color: transparent; + text-decoration-thickness: 2px; + text-underline-offset: 6px; + transition: + opacity 120ms, + text-decoration-color 120ms; +} + +.nav a:hover { + opacity: 1; + text-decoration-color: var(--primary); +} + +.nav .nav-icon { + display: inline-grid; + place-items: center; + color: var(--muted-foreground); +} + +.nav .nav-icon:hover { + color: var(--foreground); + opacity: 1; +} + +.nav-icon svg { + width: 18px; + height: 18px; +} + +.theme-toggle { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--inner-line); + border-radius: var(--radius); + background: transparent; + color: var(--muted-foreground); + cursor: pointer; +} + +.theme-toggle:hover { + color: var(--foreground); +} + +.theme-toggle svg { + width: 16px; + height: 16px; +} + +.icon-sun { + display: none; +} + +:root[data-theme='dark'] .icon-sun { + display: block; +} + +:root[data-theme='dark'] .icon-moon { + display: none; +} + +/* Hero */ +.hero { + padding: 96px 0 72px; +} + +.eyebrow { + margin: 0 0 20px; + color: var(--muted-foreground); + font-size: 14px; +} + +.hero h1 { + margin: 0; + max-width: 14ch; + font-size: clamp(44px, 7vw, 76px); + font-weight: 700; + line-height: 1.02; + letter-spacing: -0.035em; +} + +.tagline { + margin: 24px 0 0; + font-size: clamp(20px, 2.4vw, 26px); + font-weight: 550; + letter-spacing: -0.015em; +} + +.lede { + max-width: 62ch; + margin: 20px 0 0; + color: var(--muted-foreground); + font-size: 18px; +} + +.hero .lede { + max-width: 68ch; +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 36px; +} + +.button { + display: inline-flex; + align-items: center; + height: 44px; + padding: 0 18px; + border: 1px solid var(--panel-border); + border-radius: var(--radius); + background: var(--panel); + color: var(--foreground); + font-size: 15px; + font-weight: 550; +} + +.button:hover { + text-decoration: none; + border-color: var(--faint); +} + +.button.primary { + border-color: var(--primary); + background: var(--primary); + color: var(--primary-foreground); +} + +.button.primary:hover { + border-color: var(--primary-hover); + background: var(--primary-hover); +} + +.install { + display: inline-flex; + align-items: center; + gap: 12px; + height: 44px; + padding: 0 8px 0 16px; + border: 1px solid var(--panel-border); + border-radius: var(--radius); + background: var(--code-bg); + font-family: var(--font-mono); + font-size: 14px; +} + +.install .prompt { + color: var(--faint); +} + +.copy { + height: 28px; + padding: 0 10px; + border: 1px solid var(--inner-line); + border-radius: var(--radius); + background: transparent; + color: var(--muted-foreground); + font-family: var(--font-sans); + font-size: 12px; + cursor: pointer; +} + +.copy:hover { + color: var(--foreground); +} + +/* Sections */ +section { + padding: 72px 0; + border-top: 1px solid var(--inner-line); +} + +.section-head { + max-width: 68ch; + margin-bottom: 40px; +} + +h2 { + margin: 0; + font-size: clamp(28px, 3.4vw, 38px); + font-weight: 680; + line-height: 1.15; + letter-spacing: -0.025em; +} + +.section-head p { + margin: 14px 0 0; + color: var(--muted-foreground); + font-size: 17px; +} + +h3 { + margin: 0; + font-size: 18px; + font-weight: 650; + letter-spacing: -0.01em; +} + +/* Problem */ +.problem { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} + +.card { + padding: 24px; + border: 1px solid var(--panel-border); + border-radius: var(--radius); + background: var(--panel); +} + +.card p { + margin: 10px 0 0; + color: var(--muted-foreground); + font-size: 15px; +} + +/* Walkthrough */ +.steps { + display: grid; + gap: 16px; + counter-reset: step; +} + +.step { + display: grid; + grid-template-columns: minmax(0, 4fr) minmax(0, 8fr); + border: 1px solid var(--panel-border); + border-radius: var(--radius); + background: var(--panel); + overflow: hidden; +} + +.step-text { + padding: 28px; +} + +.step-text .num { + display: block; + margin-bottom: 12px; + color: var(--primary); + font-family: var(--font-mono); + font-size: 13px; + font-weight: 500; +} + +.step-text p { + margin: 10px 0 0; + color: var(--muted-foreground); + font-size: 15px; +} + +.step-text .more { + margin-top: 14px; + font-size: 14px; +} + +.terminal { + margin: 0; + padding: 22px 24px; + border-left: 1px solid var(--inner-line); + background: var(--code-bg); + color: var(--code-fg); + font-size: 12px; + line-height: 1.65; + overflow-x: auto; + white-space: pre; +} + +.terminal .cmd { + color: var(--foreground); + font-weight: 500; +} + +.terminal .cmd::before { + content: '$ '; + color: var(--faint); + font-weight: 400; +} + +.terminal .boxes { + display: block; + font-family: 'SQLClean Diagram', var(--font-mono); + line-height: 1.15; +} + +.terminal .tree { + display: block; + font-family: 'SQLClean Diagram', var(--font-mono); +} + +.terminal .dim { + color: var(--faint); +} + +.terminal .ok { + color: var(--success); +} + +.terminal .bad { + color: var(--error); +} + +.terminal .warn { + color: var(--warning); +} + +.terminal .note { + color: var(--stale); +} + +.terminal .info { + color: var(--primary); +} + +.terminal .strong { + font-weight: 500; +} + +.step-text code, +.grid code, +.caption code { + padding: 1px 4px; + border-radius: var(--radius); + background: var(--accent-soft); + color: var(--foreground); + font-size: 0.86em; + word-spacing: -0.2em; +} + +.caption { + margin: 16px 0 0; + color: var(--faint); + font-size: 13px; +} + +/* Feature grid */ +.grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + border: 1px solid var(--panel-border); + border-radius: var(--radius); + background: var(--panel); +} + +.grid .item { + padding: 24px; + border-right: 1px solid var(--inner-line); + border-bottom: 1px solid var(--inner-line); +} + +.grid .item:nth-child(3n) { + border-right: none; +} + +.grid .item:nth-last-child(-n + 3) { + border-bottom: none; +} + +.grid .item p { + margin: 8px 0 0; + color: var(--muted-foreground); + font-size: 15px; +} + +/* Start */ +.start { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 40px; + align-items: start; +} + +.start ul { + margin: 20px 0 0; + padding: 0; + list-style: none; +} + +.start li { + position: relative; + padding: 10px 0 10px 22px; + border-top: 1px solid var(--inner-line); + color: var(--muted-foreground); + font-size: 15px; +} + +.start li::before { + content: ''; + position: absolute; + top: 20px; + left: 2px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--success); +} + +.start .terminal { + border: 1px solid var(--panel-border); + border-radius: var(--radius); + white-space: pre-wrap; +} + +.adapters { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 28px; +} + +.adapters span { + padding: 4px 10px; + border: 1px solid var(--panel-border); + border-radius: var(--radius); + background: var(--panel); + font-size: 13px; +} + +.adapters small { + margin-left: 4px; + color: var(--muted-foreground); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +/* Closing call to action */ +.closing { + padding: 88px 0 96px; + text-align: center; +} + +.closing h2 { + max-width: 22ch; + margin: 0 auto; +} + +.closing p { + max-width: 56ch; + margin: 16px auto 0; + color: var(--muted-foreground); + font-size: 17px; +} + +.closing .actions { + justify-content: center; +} + +footer { + padding: 28px 0; + border-top: 1px solid var(--inner-line); + color: var(--faint); + font-size: 13px; +} + +footer .wrap { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 12px; +} + +footer a { + color: var(--muted-foreground); +} + +@media (max-width: 880px) { + .problem, + .grid, + .start, + .step { + grid-template-columns: 1fr; + } + + .terminal { + border-left: none; + border-top: 1px solid var(--inner-line); + } + + .grid .item, + .grid .item:nth-child(3n), + .grid .item:nth-last-child(-n + 3) { + border-right: none; + border-bottom: 1px solid var(--inner-line); + } + + .grid .item:last-child { + border-bottom: none; + } + + .hero { + padding: 64px 0 48px; + } + + .nav .hide-sm { + display: none; + } +} + +/* Syntax colours for the SQL and Python examples. */ +:root { + --syn-keyword: #1a56c4; + --syn-string: #0a7a3f; + --syn-number: #9a6700; + --syn-comment: #8a909c; + --syn-function: #7c3aed; + --syn-type: #0f766e; +} + +:root[data-theme='dark'] { + --syn-keyword: #5aa9e6; + --syn-string: #79c98a; + --syn-number: #e3b341; + --syn-comment: #5c606b; + --syn-function: #c79bf0; + --syn-type: #4dd6c4; +} + +.terminal .kw { + color: var(--syn-keyword); +} + +.terminal .str { + color: var(--syn-string); +} + +.terminal .num { + color: var(--syn-number); +} + +.terminal .cm { + color: var(--syn-comment); +} + +.terminal .fn { + color: var(--syn-function); +} + +.terminal .ty { + color: var(--syn-type); +} + +.terminal .arg { + color: var(--muted-foreground); +} + +.step-group { + margin: 24px 0 0; + color: var(--muted-foreground); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.01em; +} + +.step-group:first-child { + margin-top: 0; +} + +/* Hero visual: sqlclean fix turns a messy query into a clean one. */ +.hero { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 48px; + align-items: center; +} + +.hero-visual.terminal { + border: 1px solid var(--panel-border); + border-left: 1px solid var(--inner-line); + border-radius: var(--radius); + min-height: 300px; +} + +.hero-visual { + font-family: var(--font-mono); +} + +.hero-visual .tree { + display: inline; +} + +.hero-visual .hv-stack { + display: grid; +} + +.hero-visual .hv-stack > * { + grid-area: 1 / 1; +} + +.hero-visual .hv-l { + display: block; +} + +.hero-visual .hv-a, +.hero-visual .hv-b, +.hero-visual .hv-l { + animation-duration: 10s; + animation-iteration-count: infinite; + animation-fill-mode: both; + animation-timing-function: ease-out; +} + +.hero-visual .hv-a { + animation-name: hv-a; +} + +.hero-visual .hv-b { + animation-name: hv-b; +} + +@keyframes hv-a { + 0%, + 15% { + opacity: 1; + } + 18%, + 96% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes hv-b { + 0%, + 15% { + opacity: 0; + } + 18%, + 92% { + opacity: 1; + } + 96%, + 100% { + opacity: 0; + } +} + +@keyframes hv-l0 { + 0%, + 6% { + opacity: 0; + transform: translateY(3px); + } + 9%, + 92% { + opacity: 1; + transform: none; + } + 96%, + 100% { + opacity: 0; + } +} + +.hv-l0 { + animation-name: hv-l0; +} + +@keyframes hv-l1 { + 0%, + 24% { + opacity: 0; + transform: translateY(3px); + } + 27%, + 92% { + opacity: 1; + transform: none; + } + 96%, + 100% { + opacity: 0; + } +} + +.hv-l1 { + animation-name: hv-l1; +} + +@keyframes hv-l2 { + 0%, + 30% { + opacity: 0; + transform: translateY(3px); + } + 33%, + 92% { + opacity: 1; + transform: none; + } + 96%, + 100% { + opacity: 0; + } +} + +.hv-l2 { + animation-name: hv-l2; +} + +/* Benchmark race: real elapsed time; the script drives it, the markup shows final results. */ +.race { + display: grid; + align-content: center; + gap: 14px; + box-sizing: border-box; + width: 520px; + max-width: 100%; + font-size: 13px; + white-space: normal; +} + + +.race-row { + display: grid; + grid-template-columns: 9.5em minmax(0, 1fr) 6.5em; + align-items: center; + gap: 12px; +} + +.race-row .cmd { + white-space: nowrap; +} + +.race-bar { + height: 10px; + border-radius: var(--radius); + background: var(--inner-line); + overflow: hidden; +} + +.race-fill { + display: block; + height: 100%; + background: var(--faint); + transform-origin: left; +} + +.race .is-sqlclean .race-fill { + background: var(--primary); +} + +.race-time { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.race .is-sqlclean .race-time { + color: var(--primary); + font-weight: 500; +} + +.race .race-row .race-time.dim { + color: var(--faint); +} + +.race-foot { + font-variant-numeric: tabular-nums; +} + +.race-foot a { + color: inherit; +} + +.race:not(.is-live) .race-clock { + display: none; +} + +@media (max-width: 620px) { + .race { + width: 100%; + min-height: 0; + font-size: 11px; + } + + .race-row { + grid-template-columns: 10em minmax(0, 1fr) 5em; + gap: 8px; + } +} + +@media (prefers-reduced-motion: reduce) { + .hero-visual .hv-a, + .hero-visual .hv-b, + .hero-visual .hv-l { + animation: none; + } + + .hero-visual .hv-a { + opacity: 0; + } +} + +@media (max-width: 1060px) { + .hero { + grid-template-columns: minmax(0, 1fr); + } + + .hero-visual { + justify-self: start; + } +} + +@media (max-width: 620px) { + .hero-visual { + justify-self: stretch; + } + + .hero-visual .hv-wide { + display: none; + } + + .hero-visual .hv-break::after { + content: '\\\A '; + } +} diff --git a/website/recordings/.gitignore b/website/recordings/.gitignore new file mode 100644 index 0000000..2082670 --- /dev/null +++ b/website/recordings/.gitignore @@ -0,0 +1,2 @@ +scratch/ +out/ diff --git a/website/recordings/README.md b/website/recordings/README.md new file mode 100644 index 0000000..0076f23 --- /dev/null +++ b/website/recordings/README.md @@ -0,0 +1,31 @@ +# CLI recordings + +Terminal recordings of `sqlclean` for the README and the website, rendered with +[VHS](https://github.com/charmbracelet/vhs) from the `.tape` scripts in `tapes/`. The tapes and +scripts are the source; the rendered GIF, MP4 and PNG files go to `out/`, which is not committed. + +| Demo | Shows | +|------|-------| +| `check-fix` | `sqlclean check` reports built-in and custom findings, and `sqlclean fix` fixes and formats. | + +The README uses `check-fix`, copied into `.github/demos/`. + +## Requirements + +- `vhs`, `ttyd` and `ffmpeg` on `PATH`, plus a Chromium or Chrome that VHS can find. +- DejaVu Sans Mono, the font the tapes use. +- `sqlclean` on `PATH` with its Python package in the same environment, or `SQLCLEAN_BIN` pointing at + a directory that contains the `sqlclean` to record. + +## Record + +```bash +cd website/recordings +./record.sh check-fix # out/check-fix.gif, out/check-fix.mp4 and out/check-fix.png +cp out/check-fix.gif ../../.github/demos/ +``` + +`record.sh` runs `prepare.sh ` to build a clean project under `scratch//`, then renders +`tapes/.tape`. Shared terminal settings and the colour theme are in `tapes/common.tape`. + +Look at every recording before publishing it. diff --git a/website/recordings/prepare.sh b/website/recordings/prepare.sh new file mode 100755 index 0000000..25eb94b --- /dev/null +++ b/website/recordings/prepare.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Build a clean scratch project for one demo. Usage: prepare.sh +set -euo pipefail +demo="$1" +here="$(cd "$(dirname "$0")" && pwd)" +dir="$here/scratch/$demo" +rm -rf "$dir" +mkdir -p "$dir" +cd "$dir" +case "$demo" in + check-fix) + # One messy query: a bare JOIN, COUNT(1) and a temporary table that a custom Rule forbids. + mkdir -p models sql_rules + cat > sqlclean.toml <<'TOML' +dialect = "duckdb" + +[rules] +select = ["SQC025", "XSQC"] +custom = ["sql_rules"] +TOML + cat > models/orders.sql <<'SQL' +select o.order_id, c.name, count(1) as items +from orders o join tmp_customers c on o.customer_id = c.customer_id +group by o.order_id, c.name +SQL + cat > sql_rules/style.py <<'PY' +from sqlclean import FixSafety, RuleContext, Statement, rule + + +@rule( + code="XSQCNAME001", + message="Query reads a temporary table", + remediation="Read from the published table instead.", + enabled_by_default=True, +) +def temporary_table(*, statement: Statement, ctx: RuleContext) -> list: + return [ + ctx.finding(subject=statement, node=table) + for table in statement.tables + if table.name.startswith("tmp_") + ] + + +@rule( + code="XSQCSTYLE001", + message="COUNT(1) should be COUNT(*)", + remediation="Write COUNT(*).", + fixable=FixSafety.SAFE, + enabled_by_default=True, +) +def count_one(*, statement: Statement, ctx: RuleContext) -> list: + counts = {f.end + 1 for f in statement.functions if f.name.upper() == "COUNT"} + return [ + ctx.finding(subject=statement, node=literal, fix=[ctx.replace(node=literal, text="*")]) + for literal in statement.literals + if literal.text == "1" and literal.start in counts + ] +PY + ;; + *) + echo "unknown demo: $demo" >&2 + exit 2 ;; +esac diff --git a/website/recordings/record.sh b/website/recordings/record.sh new file mode 100755 index 0000000..38c4c9c --- /dev/null +++ b/website/recordings/record.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Record one demo to out/.gif and out/.mp4. Usage: record.sh +# Uses the sqlclean on PATH; set SQLCLEAN_BIN to a directory containing another sqlclean to use that. +set -euo pipefail +demo="$1" +here="$(cd "$(dirname "$0")" && pwd)" +if [[ -n "${SQLCLEAN_BIN:-}" ]]; then + export PATH="$SQLCLEAN_BIN:$PATH" +fi +"$here/prepare.sh" "$demo" +mkdir -p "$here/out" +cd "$here" +# Headless Chromium's sandbox is unavailable in many containers and CI runners. +VHS_NO_SANDBOX="${VHS_NO_SANDBOX:-true}" vhs "tapes/$demo.tape" diff --git a/website/recordings/tapes/check-fix.tape b/website/recordings/tapes/check-fix.tape new file mode 100644 index 0000000..20610b8 --- /dev/null +++ b/website/recordings/tapes/check-fix.tape @@ -0,0 +1,27 @@ +Source tapes/common.tape +Set Height 820 +Output out/check-fix.gif +Output out/check-fix.mp4 +Hide +Type "cd scratch/check-fix && export PS1='$ ' && clear" +Enter +Sleep 500ms +Show +Type "cat models/orders.sql" +Enter +Sleep 1500ms +Type "sqlclean check" +Sleep 300ms +Enter +Wait@60s /\$\s*$/ +Sleep 3s +Type "clear && sqlclean fix" +Sleep 300ms +Enter +Wait@60s /\$\s*$/ +Sleep 1500ms +Type "cat models/orders.sql" +Enter +Sleep 1s +Screenshot out/check-fix.png +Sleep 8s diff --git a/website/recordings/tapes/common.tape b/website/recordings/tapes/common.tape new file mode 100644 index 0000000..05d492f --- /dev/null +++ b/website/recordings/tapes/common.tape @@ -0,0 +1,13 @@ +Set Shell bash +Set FontFamily "DejaVu Sans Mono" +Set FontSize 18 +Set LineHeight 1.0 +Set Width 1200 +Set Height 680 +Set Padding 28 +Set Margin 24 +Set MarginFill "#0d0f12" +Set BorderRadius 10 +Set WindowBar Colorful +Set TypingSpeed 45ms +Set Theme { "name": "SQLClean", "black": "#626975", "red": "#f47089", "green": "#54cf8e", "yellow": "#f0c267", "blue": "#3788e8", "magenta": "#a78bfa", "cyan": "#1fe0c0", "white": "#c9cdd4", "brightBlack": "#9ba1ac", "brightRed": "#f47089", "brightGreen": "#54cf8e", "brightYellow": "#f0c267", "brightBlue": "#4f98ee", "brightMagenta": "#c79bf0", "brightCyan": "#1fe0c0", "brightWhite": "#e9ebef", "background": "#111418", "foreground": "#c9cdd4", "selection": "#2a2f37", "cursor": "#e9ebef" } diff --git a/website/scripts/check_links.py b/website/scripts/check_links.py new file mode 100644 index 0000000..1cb1e88 --- /dev/null +++ b/website/scripts/check_links.py @@ -0,0 +1,65 @@ +"""Fail when a built page links to a missing page or anchor on the same site.""" + +import sys +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote, urlsplit + + +class PageParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.hrefs: list[str] = [] + self.ids: set[str] = set() + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + values = dict(attrs) + if values.get("id"): + self.ids.add(values["id"] or "") + if tag == "a" and values.get("href"): + self.hrefs.append(values["href"] or "") + + +def route_for(dist: Path, file: Path) -> str: + relative = "/" + file.relative_to(dist).as_posix() + return relative.removesuffix("index.html") + + +def main() -> int: + dist = Path(sys.argv[1] if len(sys.argv) > 1 else "dist") + pages: dict[str, PageParser] = {} + for file in dist.rglob("*.html"): + parser = PageParser() + parser.feed(file.read_text()) + pages[route_for(dist, file)] = parser + + broken: list[str] = [] + for route, page in sorted(pages.items()): + if route.startswith("/404"): + continue + for href in page.hrefs: + url = urlsplit(href) + if url.scheme or url.netloc: + continue + path = unquote(url.path) or route + if not path.startswith("/"): + path = route + path + if not path.endswith("/") and "." not in path.rsplit("/", 1)[-1]: + path += "/" + target = pages.get(path) + if target is None: + if not (dist / path.lstrip("/")).exists(): + broken.append(f"{route}: missing page {href}") + continue + fragment = unquote(url.fragment) + if fragment and fragment != "_top" and fragment not in target.ids: + broken.append(f"{route}: missing anchor {href}") + + for line in broken: + print(line) + print(f"{len(pages)} pages, {len(broken)} broken links") + return 1 if broken else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/website/src/assets/logo-dark.png b/website/src/assets/logo-dark.png new file mode 100644 index 0000000..9fecb46 Binary files /dev/null and b/website/src/assets/logo-dark.png differ diff --git a/website/src/assets/logo-light.png b/website/src/assets/logo-light.png new file mode 100644 index 0000000..d488ef1 Binary files /dev/null and b/website/src/assets/logo-light.png differ diff --git a/website/src/code-themes.mjs b/website/src/code-themes.mjs new file mode 100644 index 0000000..70304e7 --- /dev/null +++ b/website/src/code-themes.mjs @@ -0,0 +1,69 @@ +// Code colours mirror the homepage's --syn-* tokens. +function theme(name, type, c) { + return { + name, + type, + colors: { + 'editor.background': c.bg, + 'editor.foreground': c.fg, + 'editorGroupHeader.tabsBackground': c.bg, + 'tab.activeBackground': c.bg, + 'tab.activeForeground': c.fg, + 'tab.border': c.line, + 'tab.activeBorderTop': c.primary, + 'editorGroupHeader.tabsBorder': c.line, + 'panel.border': c.line, + 'titleBar.border': c.line, + 'terminal.background': c.bg, + 'terminal.foreground': c.fg, + }, + tokenColors: [ + { scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: c.comment } }, + { + scope: ['keyword', 'storage', 'storage.type', 'keyword.control', 'keyword.other'], + settings: { foreground: c.keyword }, + }, + { scope: ['keyword.operator'], settings: { foreground: c.fg } }, + { scope: ['string', 'string.quoted', 'markup.inline.raw'], settings: { foreground: c.string } }, + { scope: ['constant.numeric', 'constant.language', 'constant'], settings: { foreground: c.number } }, + { + scope: ['entity.name.function', 'support.function', 'meta.function-call.generic'], + settings: { foreground: c.function }, + }, + { + scope: ['entity.name.type', 'support.type', 'support.class', 'entity.name.class', 'entity.name.tag'], + settings: { foreground: c.type }, + }, + { scope: ['variable.parameter'], settings: { foreground: c.fg } }, + { scope: ['variable.other.key', 'support.type.property-name'], settings: { foreground: c.keyword } }, + { scope: ['entity.name.section', 'entity.name.tag'], settings: { foreground: c.type } }, + { scope: ['entity.other.attribute-name', 'support.type.property-name'], settings: { foreground: c.type } }, + ], + }; +} + +export const sqlcleanDark = theme('sqlclean-dark', 'dark', { + bg: '#111418', + fg: '#c9cdd4', + line: '#1f232a', + primary: '#1fd8b8', + keyword: '#5aa9e6', + string: '#79c98a', + number: '#e3b341', + comment: '#5c606b', + function: '#c79bf0', + type: '#4dd6c4', +}); + +export const sqlcleanLight = theme('sqlclean-light', 'light', { + bg: '#fbfcfe', + fg: '#2b3038', + line: '#e6e8eb', + primary: '#0b8a74', + keyword: '#1a56c4', + string: '#0a7a3f', + number: '#9a6700', + comment: '#8a909c', + function: '#7c3aed', + type: '#0f766e', +}); diff --git a/website/src/components/Header.astro b/website/src/components/Header.astro new file mode 100644 index 0000000..bd88fed --- /dev/null +++ b/website/src/components/Header.astro @@ -0,0 +1,63 @@ +--- +import Search from '@astrojs/starlight/components/Search.astro'; +import SiteTitle from './SiteTitle.astro'; +import SocialIcons from '@astrojs/starlight/components/SocialIcons.astro'; +import ThemeSelect from './ThemeSelect.astro'; +--- + +
+ + +
+ + +
+
+ + diff --git a/website/src/components/PageFrame.astro b/website/src/components/PageFrame.astro new file mode 100644 index 0000000..ceb5393 --- /dev/null +++ b/website/src/components/PageFrame.astro @@ -0,0 +1,135 @@ +--- +import MobileMenuToggle from '@astrojs/starlight/components/MobileMenuToggle.astro'; + +const { hasSidebar } = Astro.locals.starlightRoute; +--- + +
+ +
+ { + hasSidebar && ( + + ) + } +
+
+
+ + diff --git a/website/src/components/PageTitle.astro b/website/src/components/PageTitle.astro new file mode 100644 index 0000000..7d5f9c4 --- /dev/null +++ b/website/src/components/PageTitle.astro @@ -0,0 +1,54 @@ +--- +const { entry, sidebar } = Astro.locals.starlightRoute; + +type Entry = (typeof sidebar)[number]; + +function currentGroup(entries: Entry[], parent?: string): string | undefined { + for (const item of entries) { + if (item.type === 'link' && item.isCurrent) return parent; + if (item.type === 'group') { + const found = currentGroup(item.entries, item.label); + if (found) return found; + } + } + return undefined; +} + +const group = currentGroup(sidebar); +const { title, description } = entry.data; +--- + +
+ {group &&

{group}

} +

{title}

+ {description &&

{description}

} +
+ + diff --git a/website/src/components/Pagination.astro b/website/src/components/Pagination.astro new file mode 100644 index 0000000..ddf633b --- /dev/null +++ b/website/src/components/Pagination.astro @@ -0,0 +1,61 @@ +--- +const { prev, next } = Astro.locals.starlightRoute.pagination; +--- + +{ + (prev || next) && ( + + ) +} + + diff --git a/website/src/components/SiteTitle.astro b/website/src/components/SiteTitle.astro new file mode 100644 index 0000000..35fc74b --- /dev/null +++ b/website/src/components/SiteTitle.astro @@ -0,0 +1,58 @@ +--- +import logoDark from '../assets/logo-dark.png'; +import logoLight from '../assets/logo-light.png'; +--- + + + + diff --git a/website/src/components/ThemeSelect.astro b/website/src/components/ThemeSelect.astro new file mode 100644 index 0000000..9618911 --- /dev/null +++ b/website/src/components/ThemeSelect.astro @@ -0,0 +1,53 @@ + + + + + + + diff --git a/website/src/components/TwoColumnContent.astro b/website/src/components/TwoColumnContent.astro new file mode 100644 index 0000000..c1d70e1 --- /dev/null +++ b/website/src/components/TwoColumnContent.astro @@ -0,0 +1,43 @@ +--- +const { toc } = Astro.locals.starlightRoute; +--- + +
+
+ { + toc && ( + + ) + } +
+ + diff --git a/website/src/components/docs/Card.astro b/website/src/components/docs/Card.astro new file mode 100644 index 0000000..3145537 --- /dev/null +++ b/website/src/components/docs/Card.astro @@ -0,0 +1,65 @@ +--- +import ArrowRight from '@lucide/astro/icons/arrow-right'; +import BookOpen from '@lucide/astro/icons/book-open'; +import Code from '@lucide/astro/icons/code'; +import Compass from '@lucide/astro/icons/compass'; +import Eye from '@lucide/astro/icons/eye'; +import FlaskConical from '@lucide/astro/icons/flask-conical'; +import FolderTree from '@lucide/astro/icons/folder-tree'; +import Gauge from '@lucide/astro/icons/gauge'; +import GitBranch from '@lucide/astro/icons/git-branch'; +import Hash from '@lucide/astro/icons/hash'; +import Layers from '@lucide/astro/icons/layers'; +import List from '@lucide/astro/icons/list'; +import Lock from '@lucide/astro/icons/lock'; +import MapPin from '@lucide/astro/icons/map-pin'; +import Network from '@lucide/astro/icons/network'; +import Rocket from '@lucide/astro/icons/rocket'; +import ShieldCheck from '@lucide/astro/icons/shield-check'; +import SlidersHorizontal from '@lucide/astro/icons/sliders-horizontal'; +import Terminal from '@lucide/astro/icons/terminal'; +import TestTube from '@lucide/astro/icons/test-tube'; +import TriangleAlert from '@lucide/astro/icons/triangle-alert'; + +interface Props { + title: string; + href?: string; + icon?: string; +} + +// Font Awesome card icon names mapped to Lucide icons. +const icons = { + 'arrow-right': ArrowRight, + book: BookOpen, + code: Code, + 'code-branch': GitBranch, + compass: Compass, + 'diagram-project': Network, + eye: Eye, + flask: FlaskConical, + 'flask-vial': TestTube, + 'folder-tree': FolderTree, + 'gauge-high': Gauge, + hashtag: Hash, + 'layer-group': Layers, + list: List, + 'location-dot': MapPin, + lock: Lock, + rocket: Rocket, + 'shield-check': ShieldCheck, + sliders: SlidersHorizontal, + terminal: Terminal, + 'triangle-exclamation': TriangleAlert, +} as const; + +const { title, href, icon } = Astro.props; +const Icon = icon ? icons[icon as keyof typeof icons] : undefined; +if (icon && !Icon) throw new Error(`Unknown card icon: ${icon}`); +const Tag = href ? 'a' : 'div'; +--- + + + {Icon && diff --git a/website/src/components/docs/CardGroup.astro b/website/src/components/docs/CardGroup.astro new file mode 100644 index 0000000..8db4c82 --- /dev/null +++ b/website/src/components/docs/CardGroup.astro @@ -0,0 +1,8 @@ +--- +interface Props { + cols?: number; +} +const { cols = 2 } = Astro.props; +--- + +
diff --git a/website/src/components/docs/Frame.astro b/website/src/components/docs/Frame.astro new file mode 100644 index 0000000..796a178 --- /dev/null +++ b/website/src/components/docs/Frame.astro @@ -0,0 +1 @@ +
diff --git a/website/src/components/docs/Note.astro b/website/src/components/docs/Note.astro new file mode 100644 index 0000000..d721ca8 --- /dev/null +++ b/website/src/components/docs/Note.astro @@ -0,0 +1,8 @@ +--- +import Icon from '@lucide/astro/icons/info'; +--- + + diff --git a/website/src/components/docs/Warning.astro b/website/src/components/docs/Warning.astro new file mode 100644 index 0000000..8a06507 --- /dev/null +++ b/website/src/components/docs/Warning.astro @@ -0,0 +1,8 @@ +--- +import Icon from '@lucide/astro/icons/triangle-alert'; +--- + + diff --git a/website/src/content.config.ts b/website/src/content.config.ts new file mode 100644 index 0000000..d9ee8c9 --- /dev/null +++ b/website/src/content.config.ts @@ -0,0 +1,7 @@ +import { defineCollection } from 'astro:content'; +import { docsLoader } from '@astrojs/starlight/loaders'; +import { docsSchema } from '@astrojs/starlight/schema'; + +export const collections = { + docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), +}; diff --git a/website/src/content/docs/docs/benchmarks.mdx b/website/src/content/docs/docs/benchmarks.mdx new file mode 100644 index 0000000..d75f38e --- /dev/null +++ b/website/src/content/docs/docs/benchmarks.mdx @@ -0,0 +1,77 @@ +--- +title: "Benchmarks" +description: "SQLClean, sqruff and SQLFluff linting the same 10,282 Snowflake SQL files." +--- + +SQLClean 0.0.1, sqruff 0.44.0 and SQLFluff 4.4.0 linting the same [10,282 SQL files](#the-corpus) on +a Linux machine with 4 CPUs. Each tool runs its default rules on the Snowflake dialect, writes JSON +findings, and starts with no cache. SQLClean and sqruff cells are the median of three rounds; +SQLFluff ran once. + +| Command | Wall time | CPU time | Peak memory | vs SQLClean defaults | +|---|---:|---:|---:|---:| +| `sqlclean check` (13 default Rules) | **0.81 s** | 2.5 s | 48 MiB | 1× | +| `sqlclean check --select SQC` (all 45 Rules) | **1.33 s** | 3.8 s | 140 MiB | 1.6× | +| `sqlclean check` with three [custom Rules](#custom-rules) | **2.27 s** | 6.9 s | 182 MiB | 2.8× | +| `sqlclean format --check` | **3.03 s** | 11.5 s | 49 MiB | 3.7× | +| `sqruff lint` | 41.0 s | 114 s | 235 MiB | 51× | +| `sqlfluff lint --processes 4` | 919 s | 2,796 s | 992 MiB | 1,135× | + +Run again with nothing changed, `sqlclean check` reads its result cache and finishes in 0.23 s. +sqruff and SQLFluff have no result cache, so a second run costs the same as the first. + +## Custom Rules + +The custom variant adds three Python Rules from `benchmarks/linters/rules/` to the default Rules: +marts must not read raw sources, staging models read only sources and seeds, and no model calls a +nondeterministic function such as `CURRENT_TIMESTAMP`. They report 3,355 findings on top of the +default Rules' 3,872. + +SQLClean builds each statement's facts in Rust and evaluates statement and file Rules in one Python +process per CPU, so custom Rules use every core. A Rule over the whole repository runs in one +process, because it reads every file. Run again with nothing changed, the custom variant reads its +result cache and finishes in 0.46 s. + +## Comparing like with like + +The tools don't check the same things. Almost every sqruff and SQLFluff finding on this corpus +comes from a layout rule, such as indentation (`LT02`) or line length (`LT05`). SQLClean leaves +layout to its [formatter](/docs/formatting/), so the fair comparison is `sqlclean check` plus +`sqlclean format --check`: 3.8 s together, against 41 s for sqruff and 919 s for SQLFluff. + +The finding counts show that each tool read every file, not how many problems each one finds: + +| Tool | Findings | Parse errors | +|---|---:|---:| +| SQLClean, default Rules | 3,872 | 0 | +| SQLClean, all Rules | 34,322 | 0 | +| sqruff | 258,378 | 0 | +| SQLFluff | 266,367 | 0 | + +## The corpus + +The files come from DuckShop 10K, the generated project SQLBuild benchmarks its compiler with. It +describes a fictional home goods business: storefronts, marketplaces, carriers, ledgers, ad networks +and support tools, with every name invented. Compiling the project expands its macros, and +`benchmarks/linters/convert_duckshop.py` then replaces SQLBuild's reference functions with plain +table names, so every linter reads ordinary Snowflake SQL. + +| What | Count | +|---|---:| +| SQL files | 10,282 | +| Staging, intermediate and mart models | 6,149, 1,961 and 2,162 | +| Size | 44 MB | + +## Run it yourself + +The harness lives in `benchmarks/linters/`. Its README has the steps to build the corpus; +then: + +```bash +pip install sqruff sqlfluff +benchmarks/linters/bench.sh /tmp/corpus/sql /tmp/linter-bench 3 +python benchmarks/linters/summarize.py /tmp/linter-bench +``` + +Each round copies the corpus into a fresh directory and runs every tool in alternating order. +`/usr/bin/time` records wall time, CPU time and peak memory. diff --git a/website/src/content/docs/docs/cli/check.mdx b/website/src/content/docs/docs/cli/check.mdx new file mode 100644 index 0000000..a288612 --- /dev/null +++ b/website/src/content/docs/docs/cli/check.mdx @@ -0,0 +1,160 @@ +--- +title: "sqlclean check" +description: "Report Rule findings without changing any file." +sidebar: + label: "check" +--- + +`sqlclean check` runs the selected Rules over SQL files and reports every finding. It never changes +a file. + +`sqlclean lint` is the same command, for anyone used to SQLFluff or sqruff. + +## Usage + +```bash +sqlclean check [OPTIONS] [PATHS]... +``` + +```bash +sqlclean check # every matching file under the configuration root +sqlclean check models/orders.sql reports/ # only these files and directories +sqlclean check --select SQC001,SQC004 # only these Rules +sqlclean check --output-format sarif > sqlclean.sarif +``` + +With no paths, `check` reads every file under the configuration root that matches `include` and +not `exclude`. A directory is walked the same way. A file named on the command line is read even +when `include` does not match it, unless `exclude` does. See +[Choosing files](/docs/configuration/#choosing-files). + +## Flags + +| Flag | Description | +|------|-------------| +| `PATHS...` | Files or directories to read; the configuration root when omitted | +| `--select ` | Rule codes or prefixes to run instead of `[rules] select` | +| `--ignore ` | Rule codes or prefixes to skip, in addition to `[rules] ignore` | +| `--no-cache` | Evaluate every file instead of reusing results for unchanged inputs | +| `--output-format ` | `text` (default), `json` or `sarif` | +| `--config ` | Use this configuration file instead of the nearest `sqlclean.toml` | + +`--select` and `--ignore` take a comma-separated list, or the flag repeated. A value is an exact +code such as `SQC001` or a prefix such as `SQC` or `XSQCNAME`. A value that matches no Rule is an +error. See [Selecting Rules](/docs/rules/overview/#selecting-rules). + +## Text output + +```text +error[SQC004]: Row selection is nondeterministic + --> models/customers.sql:6:1 + | + 6 | LIMIT 10; + | ^^^^^ + = help: Add ORDER BY with a deterministic tie-breaker before LIMIT or OFFSET. + +error[SQC001]: Comparison with NULL is never true + --> models/orders.sql:8:22 + | + 8 | WHERE o.cancelled_at = NULL + | ^ + = help: Use IS NULL or IS NOT NULL when testing for NULL. + = fix: available with `sqlclean fix --unsafe-fixes` + +error: stale rule exception: SQC004 in models/orders.sql matches no finding; remove it from sqlclean.toml + +Found 3 problems (1 fixable with `sqlclean fix --unsafe-fixes`). +``` + +Findings are sorted by path, then position. Each one shows the Rule code and message, the +location as `path:line:column`, the source line, the remediation, and the command that applies its +fix when it has one. [Stale exceptions](/docs/rules/overview/#stale-exceptions) follow the +findings. A clean run prints `All checks passed.` + +Paths are relative to the configuration root, with `/` separators. Lines and columns start at 1 +and count characters, not bytes. + +## JSON output + +`--output-format json` prints one document: + +```json +{ + "findings": [ + { + "code": "SQC004", + "path": "models/customers.sql", + "line": 6, + "column": 1, + "end_line": 6, + "end_column": 6, + "message": "Row selection is nondeterministic", + "remediation": "Add ORDER BY with a deterministic tie-breaker before LIMIT or OFFSET.", + "fix": null + }, + { + "code": "SQC001", + "path": "models/orders.sql", + "line": 8, + "column": 22, + "end_line": 8, + "end_column": 23, + "message": "Comparison with NULL is never true", + "remediation": "Use IS NULL or IS NOT NULL when testing for NULL.", + "fix": "unsafe" + } + ], + "stale_exceptions": [ + { + "rule": "SQC004", + "path": "models/orders.sql", + "relation": null + } + ], + "summary": { + "findings": 2, + "fixable": 1, + "stale_exceptions": 1 + } +} +``` + +`fix` is `"safe"`, `"unsafe"` or `null` when the finding has no fix. `summary.fixable` counts +findings with a fix of either kind. + +## SARIF output + +`--output-format sarif` prints a SARIF 2.1.0 log for code-scanning tools. Every finding is a result +with `level` `error`, its Rule code as `ruleId`, and its message and remediation as the result +text: + +```json +{ + "ruleId": "SQC001", + "level": "error", + "message": { + "text": "Comparison with NULL is never true. Use IS NULL or IS NOT NULL when testing for NULL." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { "uri": "models/orders.sql" }, + "region": { "startLine": 8, "startColumn": 22, "endLine": 8, "endColumn": 23 } + } + } + ] +} +``` + +The SARIF log lists findings only. Stale exceptions still make the command exit `1`; read them +from the text or JSON output. See [CI](/docs/integrations/#ci) for an upload example. + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | No findings and no stale exceptions | +| `1` | At least one finding or stale exception | +| `2` | The command could not run: invalid arguments, configuration or input, or a custom Rule that failed | + +Errors are printed on stderr as `sqlclean: error: `. diff --git a/website/src/content/docs/docs/cli/facts.mdx b/website/src/content/docs/docs/cli/facts.mdx new file mode 100644 index 0000000..8e29b53 --- /dev/null +++ b/website/src/content/docs/docs/cli/facts.mdx @@ -0,0 +1,77 @@ +--- +title: "sqlclean facts" +description: "Print the statement facts custom Rules read for SQL on standard input." +sidebar: + label: "facts" +--- + +`sqlclean facts` parses SQL from standard input and prints the structural facts of each statement +as one line of JSON. These are the facts a [custom Rule](/docs/custom-rules/overview/) receives, +so use it to see what a Rule will read before writing one. + +## Usage + +```bash +sqlclean facts [--dialect ] < query.sql +``` + +```bash +echo "SELECT o.order_id, COUNT(*) AS items FROM orders AS o JOIN order_items AS i ON i.order_id = o.order_id WHERE o.status = 'paid' GROUP BY o.order_id;" \ + | sqlclean facts --dialect duckdb +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--dialect ` | The dialect to parse with (default: `generic`); see [Dialects](/docs/configuration/#dialects) | + +`facts` reads no configuration file and no paths. + +## Output + +The output, pretty-printed here and trimmed to some of the columns: + +```json +{ + "statements": [ + { + "index": 0, + "kind": "select", + "start": 0, + "end": 146, + "terminated_end": 147, + "ctes": [], + "tables": [ + { "start": 42, "end": 48, "name": "orders", "qualified_name": "orders", "alias": "o" }, + { "start": 59, "end": 70, "name": "order_items", "qualified_name": "order_items", "alias": "i" } + ], + "joins": [ + { "start": 54, "end": 102, "kind": "INNER", "relation": "order_items", "has_condition": true } + ], + "stars": [], + "functions": [ + { "start": 19, "end": 24, "name": "COUNT" } + ], + "columns": [ + { "start": 7, "end": 17, "name": "order_id", "qualifier": "o" }, + { "start": 109, "end": 117, "name": "status", "qualifier": "o" } + ], + "literals": [ + { "start": 120, "end": 126, "kind": "string", "text": "'paid'" } + ] + } + ] +} +``` + +Offsets count characters from the start of the input, not bytes. Custom Rules receive the same +facts with `line`, `column` and `text` added to every node. See +[Statement facts](/docs/custom-rules/statement-facts/) for every field. + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | The facts were printed | +| `2` | The SQL does not parse under the dialect, or the dialect is unknown | diff --git a/website/src/content/docs/docs/cli/fix.mdx b/website/src/content/docs/docs/cli/fix.mdx new file mode 100644 index 0000000..d054888 --- /dev/null +++ b/website/src/content/docs/docs/cli/fix.mdx @@ -0,0 +1,98 @@ +--- +title: "sqlclean fix" +description: "Apply Rule fixes until the files stop changing, then format them." +sidebar: + label: "fix" +--- + +`sqlclean fix` applies the fixes of the selected Rules, checks again, and repeats until no fix +applies. It then formats every file it read and reports the findings that remain. + +## Usage + +```bash +sqlclean fix [OPTIONS] [PATHS]... +``` + +```bash +sqlclean fix # apply safe fixes, then format +sqlclean fix --diff # print the changes without writing them +sqlclean fix --unsafe-fixes # also apply fixes that can change query results +sqlclean fix models/orders.sql # only this file +``` + +Files are chosen as for [`sqlclean check`](/docs/cli/check/). + +## Flags + +| Flag | Description | +|------|-------------| +| `PATHS...` | Files or directories to read; the configuration root when omitted | +| `--unsafe-fixes` | Also apply fixes their Rules mark unsafe because they can change results | +| `--diff` | Print the changes as a unified diff instead of writing them | +| `--select ` | Rule codes or prefixes to run instead of `[rules] select` | +| `--ignore ` | Rule codes or prefixes to skip, in addition to `[rules] ignore` | +| `--no-cache` | Evaluate every file instead of reusing results for unchanged inputs | +| `--config ` | Use this configuration file instead of the nearest `sqlclean.toml` | + +Only fixes of selected Rules apply. `sqlclean fix --select SQC005` removes unused CTEs and +formats the files, and leaves every other finding alone. + +## Output + +```text +Fixed 1 problem(s) and changed 1 file(s). +error[SQC001]: Comparison with NULL is never true + --> models/open_orders.sql:7:24 + | + 7 | WHERE cancelled_at = NULL + | ^ + = help: Use IS NULL or IS NOT NULL when testing for NULL. + = fix: available with `sqlclean fix --unsafe-fixes` + +Found 1 problem (1 fixable with `sqlclean fix --unsafe-fixes`). +``` + +The summary line goes to stderr. The remaining findings go to stdout in the same format as +[`sqlclean check`](/docs/cli/check/#text-output), with positions in the fixed file. + +With `--diff`, nothing is written. `fix` prints a unified diff of every file it would change, +followed by `Would fix N problem(s) and change N file(s).`, and does not list the remaining +findings: + +```diff +--- a/models/orders.sql ++++ b/models/orders.sql +@@ -5,7 +5,7 @@ + FROM orders o + JOIN customers c + ON o.customer_id = c.customer_id +-WHERE o.cancelled_at = NULL ++WHERE o.cancelled_at IS NULL + GROUP BY + o.order_id, + c.customer_name +``` + +## How fixes are applied + +- Each pass checks the files, then applies every eligible fix whose edits do not overlap an earlier + fix in the same file. A skipped fix is reconsidered on the next pass. +- A pass that would turn a file that parses into one that does not is not applied to that file. +- `fix` stops when a pass applies nothing, and after at most 10 passes. +- Safe fixes always apply. Unsafe fixes apply only with `--unsafe-fixes`. See + [Safe and unsafe fixes](/docs/rules/overview/#safe-and-unsafe-fixes). +- Fixes from [custom Rules](/docs/custom-rules/fixes-and-options/) apply the same way. +- Every file is formatted after the last pass, whether or not a fix changed it. A file the + formatter cannot format is reported as `: could not format: ` and keeps its fixes. + +A file that does not parse gets no fixes and is reported as +[`SQC999`](/docs/rules/overview/#parse-errors-sqc999). + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Every file is fixed and formatted, and no finding or stale exception remains | +| `1` | Findings or stale exceptions remain, a file could not be formatted, or `--diff` found changes to make | +| `2` | The command could not run: invalid arguments, configuration or input, or a custom Rule that failed | diff --git a/website/src/content/docs/docs/cli/format.mdx b/website/src/content/docs/docs/cli/format.mdx new file mode 100644 index 0000000..8582313 --- /dev/null +++ b/website/src/content/docs/docs/cli/format.mdx @@ -0,0 +1,76 @@ +--- +title: "sqlclean format" +description: "Rewrite SQL files in the canonical layout, or check that they already are." +sidebar: + label: "format" +--- + +`sqlclean format` rewrites SQL files in SQLClean's canonical layout. It runs no Rules and applies +no fixes; use [`sqlclean fix`](/docs/cli/fix/) for both. + +## Usage + +```bash +sqlclean format [OPTIONS] [PATHS]... +``` + +```bash +sqlclean format # rewrite every matching file +sqlclean format --check # fail when a file needs formatting +sqlclean format --diff reports/ # print the changes for one directory +``` + +Files are chosen as for [`sqlclean check`](/docs/cli/check/). Each file is formatted with its own +[dialect](/docs/configuration/#dialects) and the configured +[`line_width`](/docs/formatting/#line-width) and [`indent`](/docs/formatting/#indent). + +## Flags + +| Flag | Description | +|------|-------------| +| `PATHS...` | Files or directories to format; the configuration root when omitted | +| `--check` | Fail when a file needs formatting, without writing it | +| `--diff` | Print the changes as a unified diff instead of writing them | +| `--config ` | Use this configuration file instead of the nearest `sqlclean.toml` | + +## Output + +```bash +sqlclean format --check +``` + +```text +j.sql: could not format: Parse error at line 1, column 16: Expected Colon, got RBrace ('}') near [{{ col }} from orders] +Would reformat: models_a.sql +1 file(s) would be reformatted, 2 file(s) already formatted. +``` + +`--check` names each file that would change. `--diff` prints a unified diff instead: + +```diff +--- a/models_a.sql ++++ b/models_a.sql +@@ -1 +1,5 @@ +-select order_id, amount from orders where amount > 0 ++SELECT ++ order_id, ++ amount ++FROM orders ++WHERE amount > 0 +``` + +Without either flag, files are rewritten in place and the summary reads +`N file(s) reformatted, N file(s) already formatted.` The summary and the `could not format` +lines go to stderr; the `Would reformat` lines and diffs go to stdout. + +A file the formatter cannot format, usually because it does not parse under its dialect, is left +unchanged and reported with the reason. See +[When a file can't be formatted](/docs/formatting/#when-a-file-cant-be-formatted). + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Every file is formatted, or was rewritten | +| `1` | A file could not be formatted, or `--check` or `--diff` found a file to change | +| `2` | The command could not run: invalid arguments, configuration or input | diff --git a/website/src/content/docs/docs/cli/rules.mdx b/website/src/content/docs/docs/cli/rules.mdx new file mode 100644 index 0000000..a9fd64b --- /dev/null +++ b/website/src/content/docs/docs/cli/rules.mdx @@ -0,0 +1,112 @@ +--- +title: "sqlclean rules" +description: "List the built-in and custom Rules, or show one Rule's details." +sidebar: + label: "rules" +--- + +`sqlclean rules` lists every Rule the configuration can select: the built-in Rules, then the custom +Rules under `[rules] custom`. With a code, it shows that Rule. + +## Usage + +```bash +sqlclean rules [OPTIONS] [CODE] +``` + +```bash +sqlclean rules # list every Rule +sqlclean rules SQC001 # show one Rule +sqlclean rules --json # every Rule as JSON +sqlclean rules --json SQC001 # one Rule as JSON +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `CODE` | Show this Rule's details; an exact code, not a prefix | +| `--json` | Print Rules as a JSON array | +| `--config ` | Use this configuration file instead of the nearest `sqlclean.toml` | + +## Listing Rules + +```text +SQC001 default Comparison with NULL is never true +SQC002 default Comma-separated sources create an implicit cartesian join +SQC003 default Non-cross join has no meaningful condition +SQC004 default Row selection is nondeterministic +... +SQC045 JSON payload is parsed more than once in one SELECT +XSQCNAME001 default Query reads a temporary table +XSQCSTYLE001 default COUNT(1) should be COUNT(*) +``` + +`default` marks the Rules that run when `[rules] select` is not set. See +[Default Rules](/docs/rules/overview/#default-rules) and the +[Rule reference](/docs/rules/reference/). + +## Showing one Rule + +```bash +sqlclean rules SQC001 +``` + +```text +SQC001: Comparison with NULL is never true +Subject: statement +Enabled by default: yes +Fixable: unsafe +Remediation: Use IS NULL or IS NOT NULL when testing for NULL. +``` + +A custom Rule also shows the Python module that defines it: + +```text +XSQCSTYLE001: COUNT(1) should be COUNT(*) +Subject: statement +Enabled by default: yes +Defined in: sql_rules.naming +Fixable: safe +Remediation: Write COUNT(*). +``` + +`Fixable` is left out when the Rule has no fix. + +## JSON output + +```bash +sqlclean rules --json XSQCNAME001 +``` + +```json +[ + { + "code": "XSQCNAME001", + "message": "Query reads a temporary table", + "remediation": "Read from the published table instead.", + "enabled_by_default": true, + "custom": true, + "subject": "statement", + "module": "sql_rules.naming" + } +] +``` + +| Field | Description | +|-------|-------------| +| `code` | The Rule code | +| `message` | The finding message | +| `remediation` | What to do about a finding | +| `enabled_by_default` | Whether the Rule runs when `[rules] select` is not set | +| `custom` | `true` for custom Rules | +| `subject` | `statement`, `file` or `repository` | +| `fixable` | `safe` or `unsafe`; absent when the Rule has no fix | +| `module` | The Python module of a custom Rule; absent for built-in Rules | + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | The Rules were printed | +| `2` | Unknown Rule code, or the configuration or a custom Rule module could not be loaded | diff --git a/website/src/content/docs/docs/cli/skills.mdx b/website/src/content/docs/docs/cli/skills.mdx new file mode 100644 index 0000000..f55a5c4 --- /dev/null +++ b/website/src/content/docs/docs/cli/skills.mdx @@ -0,0 +1,60 @@ +--- +title: "sqlclean skills" +description: "Install the SQLClean skill so AI coding agents know its commands, Rules and configuration." +sidebar: + label: "skills" +--- + +`sqlclean skills` writes the SQLClean skill into your project, so AI coding agents such as Claude +Code, OpenCode and Cursor know how to check, fix and format SQL, configure `sqlclean.toml` and +write custom Rules. The skill ships inside the `sqlclean` package with a copy of every page of +these docs, so it always matches the installed version. + +## Usage + +```bash +sqlclean skills [--target ]... [--global] [--force] +``` + +```bash +# Install the agents and claude targets (default) +sqlclean skills + +# Install for Claude Code only +sqlclean skills --target claude + +# Install under your home directory for every project +sqlclean skills --global +``` + +## Flags + +| Flag | Description | +|------|-------------| +| `--target ` | The agent to install for: `agents`, `claude` or `opencode`. Repeat it for several. | +| `--global` | Install under the home directory instead of the configuration root | +| `--force` | Overwrite skill files that SQLClean did not generate | + +## Targets + +| Target | Project path | Global path | +|--------|--------------|-------------| +| `agents` | `.agents/skills/sqlclean/` | `~/.agents/skills/sqlclean/` | +| `claude` | `.claude/skills/sqlclean/` | `~/.claude/skills/sqlclean/` | +| `opencode` | `.opencode/skills/sqlclean/` | `~/.config/opencode/skills/sqlclean/` | + +Without `--target`, SQLClean installs `agents` and `claude`. OpenCode and several other agents +read the shared `.agents` directory; Claude Code needs its own `.claude` copy. + +The project path is relative to the directory of the nearest `sqlclean.toml`, or the working +directory when there is none. + +## Updating + +Every file SQLClean writes carries a `generated-by: sqlclean skills` comment. Rerun +`sqlclean skills` after upgrading SQLClean: + +- Generated files are rewritten with the new version's content. +- Generated pages the new version no longer ships are removed. +- A file without the comment, such as one you wrote yourself, is never overwritten. The command + stops with exit status 2 before writing anything, unless you pass `--force`. diff --git a/website/src/content/docs/docs/configuration.mdx b/website/src/content/docs/docs/configuration.mdx new file mode 100644 index 0000000..cc0a41f --- /dev/null +++ b/website/src/content/docs/docs/configuration.mdx @@ -0,0 +1,177 @@ +--- +title: "Configuration" +description: "Every sqlclean.toml key, its default, per-path dialects and how the file is found." +--- + +SQLClean reads one `sqlclean.toml`. Every key is optional, and without a file every default +applies. + +```toml +dialect = "duckdb" # default "generic" +include = ["**/*.sql"] +exclude = ["vendor/**"] + +[[dialects]] # a different dialect for some paths +paths = ["warehouse/**"] +dialect = "snowflake" + +[format] +line_width = 100 +indent = 4 + +[rules] +select = ["SQC"] # omit to run the Rules enabled by default +ignore = ["SQC027"] +custom = ["sql_rules"] # Python files or directories that define custom Rules + +[rules.thresholds] +max_literal_length = 100 # SQC044 +max_ranking_order_by = 5 # SQC043 + +[rules.rule_options.XSQCNAME001] +prefixes = ["tmp_", "scratch_"] + +[[rules.rule_exceptions]] # one reviewed finding that is allowed +rule = "SQC038" +path = "models/calendar.sql" +relation = "dates" +reason = "Every pair of dates is needed." + +[[rules.rule_ignores]] # Rules that do not apply to some paths +rules = ["SQC034"] +paths = ["scratch/**"] +reason = "Exploratory queries." +``` + +## Finding the file + +Every command uses the nearest `sqlclean.toml` in the working directory or one of its parents. +`--config ` uses that file instead. + +The directory that holds the file is the **configuration root**. Every path in the file, every path +in findings, and the default set of files to read are relative to it. Without a file, the working +directory is the root. + +## Keys + +| Key | Default | Description | +|-----|---------|-------------| +| `dialect` | `"generic"` | The SQL dialect of every file without a `[[dialects]]` match | +| `include` | `["**/*.sql"]` | Glob patterns of the files to read | +| `exclude` | `[]` | Glob patterns of files never to read | +| `[[dialects]]` | none | `paths` and `dialect`: a different dialect for matching files | +| `[format] line_width` | `100` | The line width the formatter fills; see [Line width](/docs/formatting/#line-width) | +| `[format] indent` | `4` | Spaces per indent step, from `1` to `8`; see [Indent](/docs/formatting/#indent) | +| `[rules] select` | Rules enabled by default | Rule codes or prefixes to run | +| `[rules] ignore` | `[]` | Rule codes or prefixes to skip | +| `[rules] custom` | `[]` | Python files or directories that define [custom Rules](/docs/custom-rules/overview/) | +| `[rules.thresholds] max_literal_length` | `100` | The longest string literal `SQC044` allows | +| `[rules.thresholds] max_ranking_order_by` | `5` | The most `ORDER BY` expressions `SQC043` allows in a ranking window | +| `[rules.rule_options.]` | none | Option values of one custom Rule; see [Options](/docs/custom-rules/fixes-and-options/#options) | +| `[[rules.rule_exceptions]]` | none | One allowed finding; see [Exceptions](/docs/rules/overview/#exceptions) | +| `[[rules.rule_ignores]]` | none | Rules that do not apply to some paths; see [Path ignores](/docs/rules/overview/#path-ignores) | + +Selection, exceptions and ignores are described in [Rules](/docs/rules/overview/). + +## Choosing files + +With no paths on the command line, a command reads every file under the configuration root whose +root-relative path matches `include` and no `exclude` pattern. + +- In patterns, `*` matches within one directory and `**` matches across directories: + `models/*.sql` reads `models/orders.sql` but not `models/marts/orders.sql`. +- In a Git repository, files that `.gitignore` ignores are skipped. +- Hidden files and directories, whose names start with `.`, are skipped. +- A directory on the command line is walked the same way. +- A file named on the command line is read even when `include` does not match it. `exclude` still + applies. + +## Dialects + +`dialect` sets how every file is parsed and formatted. `[[dialects]]` entries set a different +dialect for the files their `paths` match. The first matching entry wins; files that match none use +`dialect`. + +```toml +dialect = "duckdb" + +[[dialects]] +paths = ["reports/**"] +dialect = "tsql" +``` + +A file that does not parse under its dialect is reported as +[`SQC999`](/docs/rules/overview/#parse-errors-sqc999), with the dialect in the message. Without +the `[[dialects]]` entry above, this T-SQL file under `reports/` is parsed as `duckdb`: + +```sql +SELECT [order id] FROM orders +``` + +```text +error[SQC999]: SQL could not be parsed as duckdb: Parse error at line 1, column 17: Expected RBracket, got Var ('id') near [SELECT [order id] FROM orders] +``` + +Supported dialects, case-insensitive: + +| Dialect | Also accepted | +|---------|---------------| +| `generic` | | +| `athena` | | +| `bigquery` | | +| `clickhouse` | | +| `cockroachdb` | `cockroach` | +| `databricks` | | +| `datafusion` | `arrow-datafusion`, `arrow_datafusion` | +| `doris` | | +| `dremio` | | +| `drill` | | +| `druid` | | +| `duckdb` | | +| `dune` | | +| `exasol` | | +| `fabric` | | +| `hana` | `saphana`, `sap_hana` | +| `hive` | | +| `materialize` | | +| `mysql` | | +| `oracle` | | +| `postgres` | `postgresql` | +| `presto` | | +| `redshift` | | +| `risingwave` | | +| `singlestore` | `memsql` | +| `snowflake` | | +| `solr` | | +| `spark` | `spark2` | +| `sqlite` | | +| `starrocks` | | +| `tableau` | | +| `teradata` | | +| `tidb` | | +| `trino` | | +| `tsql` | `mssql`, `sqlserver` | +| `vertica` | | + +## Validation + +SQLClean reads and validates the file when a command starts, and exits with `2` on the first +problem it finds. `check` and `fix` also validate Rule selectors and `rule_options` before they +report any finding. + +| Problem | Error | +|---------|-------| +| A key SQLClean does not know | ``unknown field `colour`, expected one of `dialect`, `include`, ...`` | +| An unknown dialect | `sqlclean.toml: unknown SQL dialect 'nosuch': ...` | +| An invalid glob pattern | `sqlclean.toml: invalid path pattern '[bad': ...` | +| `line_width` of `0` | `sqlclean.toml: [format] line_width must be positive` | +| `indent` of `0` or more than `8` | `sqlclean.toml: [format] indent must be between 1 and 8` | +| A threshold of `0` | `sqlclean.toml: [rules.thresholds] max_literal_length and max_ranking_order_by must be positive` | +| An exception or ignore without a reason | `sqlclean.toml: rule exception or ignore for SQC001 needs a reason` | +| An exception with `cte` but no `relation` | `sqlclean.toml: rule exception for SQC038 at models/calendar.sql sets cte without relation` | +| A `custom` path that does not exist | `[rules] custom path does not exist: sql_rules` | +| `rule_options` for a built-in Rule | `[rules.rule_options.SQC001]: built-in Rules take no options; set limits in [rules.thresholds]` | +| `rule_options` for a code no custom Rule has | `[rules.rule_options.XSQCNOPE001]: no custom Rule has this code` | +| A selector that matches no Rule | `Rule selector 'SQC9' matches no Rule` | + +Errors are printed as `sqlclean: error: `. diff --git a/website/src/content/docs/docs/custom-rules/fixes-and-options.mdx b/website/src/content/docs/docs/custom-rules/fixes-and-options.mdx new file mode 100644 index 0000000..118f69d --- /dev/null +++ b/website/src/content/docs/docs/custom-rules/fixes-and-options.mdx @@ -0,0 +1,154 @@ +--- +title: "Fixes and options" +description: "Give custom Rules safe or unsafe fixes, and typed options configured in sqlclean.toml." +--- + +A custom Rule can attach a fix to each finding, which [`sqlclean fix`](/docs/cli/fix/) applies, +and can declare typed options that a project sets in `sqlclean.toml`. + +## Fixes + +A fix is a list of edits to the file. Declare `fixable` on the Rule, then pass the edits to +`ctx.finding`: + +```python +from sqlclean import FixSafety, Finding, RuleContext, Statement, rule + + +@rule( + code="XSQCSTYLE001", + message="COUNT(1) should be COUNT(*)", + remediation="Write COUNT(*).", + fixable=FixSafety.SAFE, + enabled_by_default=True, +) +def count_one(*, statement: Statement, ctx: RuleContext) -> list[Finding]: + counts = {f.end + 1 for f in statement.functions if f.name.upper() == "COUNT"} + return [ + ctx.finding(subject=statement, node=literal, fix=[ctx.replace(node=literal, text="*")]) + for literal in statement.literals + if literal.text == "1" and literal.start in counts + ] +``` + +A function call is located at its name, so `f.end + 1` is the first character inside its +parentheses. + +```bash +sqlclean fix --diff --select XSQCSTYLE +``` + +```diff +--- a/models/orders.sql ++++ b/models/orders.sql +@@ -1,3 +1,6 @@ +-SELECT customer_id, COUNT(1) AS orders ++SELECT ++ customer_id, ++ COUNT(*) AS orders + FROM customer_orders +-GROUP BY customer_id ++GROUP BY ++ customer_id +Would fix 1 problem(s) and change 1 file(s). +``` + +### Making edits + +| Method | Edit | +|--------|------| +| `ctx.replace(node=..., text=...)` | Replace the node's text | +| `ctx.insert_before(node=..., text=...)` | Insert text just before the node | +| `ctx.insert_after(node=..., text=...)` | Insert text just after the node | +| `ctx.delete(node=...)` | Remove the node's text | + +`node` is any node or statement, or anything with `start` and `end` character offsets in the file. +An `Edit(start=..., end=..., text=...)` from `sqlclean` builds an edit from offsets directly. Every +edit must lie inside the finding's file. + +### Safe and unsafe + +`fixable=FixSafety.SAFE` declares fixes that keep the query's results; `sqlclean fix` applies them. +`fixable=FixSafety.UNSAFE` declares fixes that can change results; they apply only with +`--unsafe-fixes`. The safety belongs to the Rule, so every fix of one Rule has the same safety. +See [Safe and unsafe fixes](/docs/rules/overview/#safe-and-unsafe-fixes). + +A finding without `fix` has no fix, even in a fixable Rule. A Rule that returns a fix without +declaring `fixable` fails with `Rule XSQCSTYLE001 returned a fix but does not declare fixable`. + +### How fixes combine + +`sqlclean fix` applies custom fixes exactly as it applies built-in ones: + +- A fix whose edits overlap another fix's edits waits for the next pass, where the Rule runs again + on the changed file. +- A pass that would leave a file that no longer parses is not applied to that file. +- After at most 10 passes, the files are formatted, so a fix need not produce formatted SQL. + +Keep each edit as small as the change: replace the literal, not the statement. + +## Options + +An option is a typed value a project sets per Rule. Declare it with `RuleOption`, list it in +`options`, and read it with `ctx.option`: + +```python +from sqlclean import Finding, RuleContext, RuleOption, Statement, rule + +PREFIXES = RuleOption.string_list( + name="prefixes", default=("tmp_",), description="Table prefixes that must not be read." +) + + +@rule( + code="XSQCNAME001", + message="Query reads a temporary table", + remediation="Read from the published table instead.", + options=(PREFIXES,), + enabled_by_default=True, +) +def temporary_table(*, statement: Statement, ctx: RuleContext) -> list[Finding]: + prefixes = ctx.option(PREFIXES) + return [ + ctx.finding(subject=statement, node=table) + for table in statement.tables + if table.name.startswith(prefixes) + ] +``` + +```toml +[rules.rule_options.XSQCNAME001] +prefixes = ["tmp_", "scratch_"] +``` + +`ctx.option` returns the configured value, or the default when `sqlclean.toml` does not set it. +List options arrive as tuples. + +| Constructor | TOML value | Extra arguments | +|-------------|------------|-----------------| +| `RuleOption.boolean(name=, default=, description=)` | `true` or `false` | | +| `RuleOption.integer(name=, default=, description=)` | An integer | `minimum`, `maximum` | +| `RuleOption.string(name=, default=, description=)` | A string | `choices` | +| `RuleOption.string_list(name=, default=, description=)` | A list of strings | | +| `RuleOption.integer_list(name=, default=, description=)` | A list of integers | | + +`name` must be a Python identifier, and the default must be valid for the option. + +### Validation + +Configured values are checked against the declarations, and a bad value stops the command with +exit code `2`: + +| Problem | Error | +|---------|-------| +| A value of the wrong type | `[rules.rule_options.XSQCNAME001] option prefixes expects a list, got 'tmp_'` | +| An integer out of bounds | `[rules.rule_options.XSQCFILE002] option max_statements must be at least 1` | +| A string outside `choices` | `[rules.rule_options.XSQCFILE002] option style must be one of ['strict', 'loose']` | +| A name the Rule does not declare | `Rule XSQCNAME001 has no option prefix` | +| A code no custom Rule has | `[rules.rule_options.XSQCNOPE001]: no custom Rule has this code` | +| A Rule that reads an option it did not declare | `Rule XSQCFILE002 reads undeclared option verbose` | + +Built-in Rules take no options. Their two limits are set in +[`[rules.thresholds]`](/docs/rules/overview/#thresholds). + +Changing an option evaluates that Rule again on every file; cached results for other Rules stay. diff --git a/website/src/content/docs/docs/custom-rules/overview.mdx b/website/src/content/docs/docs/custom-rules/overview.mdx new file mode 100644 index 0000000..bc04aef --- /dev/null +++ b/website/src/content/docs/docs/custom-rules/overview.mdx @@ -0,0 +1,196 @@ +--- +title: "Overview" +description: "Write your own Rules as Python functions over the facts of each SQL statement." +--- + +import Card from '../../../../components/docs/Card.astro'; +import CardGroup from '../../../../components/docs/CardGroup.astro'; + +A custom Rule is a Python function decorated with `@rule`. It receives a statement, a file or the +whole repository, and returns findings. Custom Rules use the same selection, exceptions, fixes, +output and cache as the built-in Rules. + +```python +# sql_rules/naming.py +from sqlclean import Finding, RuleContext, Statement, rule + + +@rule( + code="XSQCNAME001", + message="Query reads a temporary table", + remediation="Read from the published table instead.", + enabled_by_default=True, +) +def temporary_table(*, statement: Statement, ctx: RuleContext) -> list[Finding]: + return [ + ctx.finding(subject=statement, node=table) + for table in statement.tables + if table.name.startswith("tmp_") + ] +``` + +Point `[rules] custom` at the file or its directory: + +```toml +[rules] +custom = ["sql_rules"] +``` + +```text +error[XSQCNAME001]: Query reads a temporary table + --> models/orders.sql:2:6 + | + 2 | FROM tmp_orders + | ^^^^^^^^^^ + = help: Read from the published table instead. +``` + +## Where Rules live + +`[rules] custom` lists Python files or directories, relative to the configuration root. SQLClean +imports every `.py` file under them and registers each function decorated with `@rule`. Other +functions, constants and classes stay ordinary Python. + +- Files named `test_*.py`, `conftest.py` and `__init__.py` are not loaded as Rules, so tests can + live next to the Rules they test. +- Modules are imported by their path from the configuration root: `sql_rules/naming.py` is + `sql_rules.naming`. A Rule module can import a helper the same way, for example + `from sql_rules.helpers import table_prefixes`. +- Two Rules with the same code are an error: `custom Rule code XSQCNAME001 is defined twice`. + +## Declaring a Rule + +| `@rule` argument | Default | Description | +|------------------|---------|-------------| +| `code` | required | `XSQC`, optional capital letters, and three digits, such as `XSQCNAME001` | +| `message` | required | The finding message | +| `remediation` | required | What to do about a finding | +| `enabled_by_default` | `False` | Run the Rule when `[rules] select` is not set | +| `fixable` | `None` | `FixSafety.SAFE` or `FixSafety.UNSAFE` when findings carry fixes | +| `options` | `()` | Typed `RuleOption`s configured in `sqlclean.toml` | +| `slug` | the code in lower case | A short name for the Rule | + +A Rule that is not enabled by default runs when `select` names it, by code or by a prefix such as +`XSQC` or `XSQCNAME`. See [Selecting Rules](/docs/rules/overview/#selecting-rules). Fixes and +options are covered in [Fixes and options](/docs/custom-rules/fixes-and-options/). + +## Choosing a subject + +The function takes exactly two keyword-only parameters: one subject and one `RuleContext`. Their +type annotations decide how the Rule is called; their names and order do not matter. + +| Subject | Called | Use it for | +|---------|--------|------------| +| `statement: Statement` | Once per statement | Checks of one query, such as the tables it reads | +| `file: SqlFile` | Once per file | Checks of a file as a whole, such as how many statements it holds | +| `repository: Repository` | Once over every checked file | Conventions across files | + +```python +from collections import Counter + +from sqlclean import Finding, Repository, RuleContext, SqlFile, rule + + +@rule( + code="XSQCFILE001", + message="File holds more than one statement", + remediation="Split the file so each holds one statement.", +) +def one_statement(*, file: SqlFile, ctx: RuleContext) -> list[Finding]: + return [ctx.finding(subject=file)] if len(file.statements) > 1 else [] + + +@rule( + code="XSQCREPO001", + message="Two files define a CTE with the same name", + remediation="Give each CTE a name that is unique across files.", +) +def duplicate_cte_names(*, repository: Repository, ctx: RuleContext) -> list[Finding]: + statements = [s for file in repository.files for s in file.statements] + counts = Counter(cte.name for s in statements for cte in s.ctes) + return [ + ctx.finding(subject=s, node=cte) + for s in statements + for cte in s.ctes + if counts[cte.name] > 1 + ] +``` + +A Repository Rule is evaluated again whenever any checked file changes, so prefer a statement or +file subject when the check needs only one file. + +[Statement facts](/docs/custom-rules/statement-facts/) lists everything a subject exposes. + +## Reporting findings + +A Rule returns a list of findings made with `ctx.finding`: + +| Argument | Description | +|----------|-------------| +| `subject` | The `Statement` or `SqlFile` the finding is in | +| `node` | Where to report it: any node or statement; the start of `subject` when omitted | +| `message` | Replaces the Rule's message for this finding | +| `remediation` | Replaces the Rule's remediation for this finding | +| `fix` | A list of edits; see [Fixes](/docs/custom-rules/fixes-and-options/#fixes) | + +A Repository Rule passes the statement or file each finding belongs to as `subject`. + +## How Rules run + +- Custom Rules run after the built-in Rules, on files that parse. A file with a + [parse error](/docs/rules/overview/#parse-errors-sqc999) gets no custom Rule findings. +- From 500 files, Statement and File Rules split the files across one Python process per CPU. A + Rule can't share state between files, so anything that spans files belongs in a Repository Rule, + which always runs in one process with every file. +- The working directory is the configuration root, and `PYTHONHASHSEED` is `0`, so iterating a set + of strings gives the same order on every run. +- Results are cached with the built-in results. Editing any Python file under `[rules] custom` + evaluates the custom Rules again. See [Caching](/docs/rules/overview/#caching). +- Don't print to standard output from a Rule. The command reads Rule results from it and fails + with `custom Rules host returned no result`. + +## Python environment + +Custom Rules run in a Python interpreter that has the `sqlclean` package installed. SQLClean uses, +in order: + +1. the interpreter named by the `SQLCLEAN_PYTHON` environment variable; +2. the Python installed next to the `sqlclean` executable, such as the one in the same virtual + environment; +3. `python3` on the `PATH`. + +When none has the package, the command fails with: + +```text +Custom Rules need the sqlclean Python package in the environment that runs sqlclean: install it with `pip install sqlclean`, or set SQLCLEAN_PYTHON to an interpreter that has it. +``` + +## Errors + +A Rule that cannot load or run stops the command with exit code `2`: + +| Problem | Error | +|---------|-------| +| An invalid code | `custom Rule code must be XSQC, optional capital letters and 3 digits, such as XSQCNAME001: XSQC01` | +| A wrong signature | `custom Rule XSQCBAD001 must accept exactly one typed Statement, SqlFile or Repository subject and one typed RuleContext, both keyword-only` | +| A module that fails to import | `could not import custom Rule module sql_rules.bad: ` | +| An exception in the Rule | `custom Rule XSQCBAD001 failed on models/a.sql: IndexError: tuple index out of range`, then the traceback | +| A return value that is not a list of findings | `custom Rule XSQCRET001 must return a list of findings from ctx.finding(...)` | +| A fix from a Rule without `fixable` | `Rule XSQCBAD001 returned a fix but does not declare fixable` | + +## Continue + + + + Every node type and field a Rule can read. + + + Add safe or unsafe fixes, and typed options set in `sqlclean.toml`. + + + Test Rules with the `sqlclean.testing` harness. + + + See the facts of any SQL from the command line. + + diff --git a/website/src/content/docs/docs/custom-rules/statement-facts.mdx b/website/src/content/docs/docs/custom-rules/statement-facts.mdx new file mode 100644 index 0000000..3284593 --- /dev/null +++ b/website/src/content/docs/docs/custom-rules/statement-facts.mdx @@ -0,0 +1,169 @@ +--- +title: "Statement facts" +description: "Every node type and field a custom Rule can read from a statement, a file and the repository." +--- + +A custom Rule reads structural facts that the native engine extracts when it parses each file: the +CTEs, tables, joins, stars, function calls, columns and literals of every statement. Every node +carries its position, so a finding can point at it and a fix can replace it. + +```python +from sqlclean import Finding, RuleContext, Statement, rule + + +@rule( + code="XSQCJOIN001", + message="Join to customers has no condition", + remediation="Join customers on customer_id.", +) +def unkeyed_customer_join(*, statement: Statement, ctx: RuleContext) -> list[Finding]: + return [ + ctx.finding(subject=statement, node=join) + for join in statement.joins + if join.relation == "customers" and not join.has_condition + ] +``` + +To see the facts of any SQL, pipe it to [`sqlclean facts`](/docs/cli/facts/). + +## Positions + +Every node, and every statement, has these fields: + +| Field | Type | Description | +|-------|------|-------------| +| `start` | `int` | Offset of the first character in the file | +| `end` | `int` | Offset just past the last character | +| `line` | `int` | Line of `start`, from 1 | +| `column` | `int` | Column of `start`, from 1 | +| `text` | `str` | The source text from `start` to `end` | + +Offsets count Unicode characters from the start of the file, not bytes, so +`file.source[node.start:node.end] == node.text` in Python. Offsets are in the file, not the +statement: the second statement of a file starts after the first. + +## Statement + +One statement of a file, without its terminating semicolon. + +| Field | Type | Description | +|-------|------|-------------| +| `path` | `str` | The file's path relative to the configuration root | +| `index` | `int` | The statement's position in the file, from 0 | +| `kind` | `str` | The statement kind in lower case, such as `select`, `union`, `insert`, `update`, `delete` or `create_table` | +| `dialect` | `str` | The dialect the file was parsed with | +| `ctes` | `tuple[Cte, ...]` | Common table expressions | +| `tables` | `tuple[Table, ...]` | Relations read in `FROM` and `JOIN` | +| `joins` | `tuple[Join, ...]` | Joins | +| `stars` | `tuple[Star, ...]` | `*` and `qualifier.*` projections | +| `functions` | `tuple[FunctionCall, ...]` | Calls of named functions | +| `columns` | `tuple[ColumnReference, ...]` | Column references | +| `literals` | `tuple[Literal, ...]` | String and numeric literals | + +Each tuple is in source order and includes nodes inside CTEs and subqueries. + +`statement.polyglot_ast()` returns the full Polyglot syntax tree of the statement, parsed on first +use. It needs the `ast` extra: `pip install "sqlclean[ast]"`. Without it, the call fails with +`polyglot_ast() needs the Polyglot SQL package: install the ast extra: pip install 'sqlclean[ast]'`. + +## Cte + +A common table expression, located at its name. + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `str` | The CTE name | + +## Table + +A relation read in `FROM` or `JOIN`, located at its name. A CTE that the query reads appears here +too. The target of `INSERT INTO`, `UPDATE` and `DELETE FROM` does not. + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `str` | The relation name without its schema, such as `payments` | +| `qualified_name` | `str` | The name as qualified in the SQL, such as `shop.payments` | +| `alias` | `str \| None` | The alias, or `None` | + +## Join + +One join, from its join keyword to the end of its condition, such as +`LEFT JOIN paid ON o.order_id = paid.order_id`. + +| Field | Type | Description | +|-------|------|-------------| +| `kind` | `str` | The join kind in upper case: `INNER` for a bare `JOIN`, `LEFT`, `FULL`, `CROSS`, `NATURAL` and so on | +| `relation` | `str \| None` | The joined relation's name, or `None` when the join reads a subquery | +| `has_condition` | `bool` | Whether the join has `ON` or `USING` | + +A join that holds no table or column reference, such as `CROSS JOIN UNNEST([1, 2])`, is not +listed. + +## Star + +A `*` or `qualifier.*` projection. + +| Field | Type | Description | +|-------|------|-------------| +| `qualifier` | `str \| None` | The qualifier of `o.*`, or `None` for a bare `*` | + +## FunctionCall + +A call of a named function, located at its name. The arguments follow at `end + 1`, after the +opening parenthesis. + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `str` | The name as written: `count` and `COUNT` differ | + +## ColumnReference + +A column reference, including its qualifier: the text of `o.order_id` is `o.order_id`. + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `str` | The column name, without quotes | +| `qualifier` | `str \| None` | The relation or alias that qualifies it, or `None` | + +## Literal + +A string or numeric literal as written. `TRUE`, `FALSE` and `NULL` are not literals here. + +| Field | Type | Description | +|-------|------|-------------| +| `kind` | `str` | `string` or `number` | + +`text` holds the literal with its quotes: `'paid'`, `1.5`. + +## SqlFile + +The subject of a file Rule, and the parent of every statement. + +| Field | Type | Description | +|-------|------|-------------| +| `path` | `str` | The path relative to the configuration root | +| `source` | `str` | The file's full text | +| `dialect` | `str` | The dialect the file was parsed with | +| `statements` | `tuple[Statement, ...]` | The statements, in order | + +## Repository + +The subject of a repository Rule. + +| Field | Type | Description | +|-------|------|-------------| +| `files` | `tuple[SqlFile, ...]` | Every checked file that parses, sorted by path | + +## RuleContext + +The second parameter of every Rule. + +| Member | Description | +|--------|-------------| +| `ctx.file` | The `SqlFile` being checked, or `None` in a repository Rule | +| `ctx.dialect` | The dialect of that file, or `None` in a repository Rule | +| `ctx.option(OPTION)` | The configured value of a declared option; see [Options](/docs/custom-rules/fixes-and-options/#options) | +| `ctx.finding(...)` | A finding; see [Reporting findings](/docs/custom-rules/overview/#reporting-findings) | +| `ctx.replace`, `ctx.insert_before`, `ctx.insert_after`, `ctx.delete` | Fix edits; see [Fixes](/docs/custom-rules/fixes-and-options/#fixes) | + +All facts are read-only. A Rule cannot change the SQL except through the edits of a fix. diff --git a/website/src/content/docs/docs/custom-rules/testing.mdx b/website/src/content/docs/docs/custom-rules/testing.mdx new file mode 100644 index 0000000..f1c30ee --- /dev/null +++ b/website/src/content/docs/docs/custom-rules/testing.mdx @@ -0,0 +1,177 @@ +--- +title: "Testing" +description: "Test custom Rules with the sqlclean.testing harness, and keep them deterministic." +--- + +`sqlclean.testing` runs one Rule over SQL you give it, with the same native engine as +`sqlclean check`. It fails when the Rule reports a different number of findings than you expect, +or when its findings depend on the order of the files. + +```python +# sql_rules/test_naming.py +from sqlclean.testing import RuleCase, evaluate_rule + +from sql_rules.naming import count_one, temporary_table + + +def test_scratch_tables_are_reported() -> None: + result = evaluate_rule( + rule=temporary_table, + test_case=RuleCase( + description="reads scratch_orders", + source="SELECT order_id FROM scratch_orders", + dialect="duckdb", + options={"prefixes": ["tmp_", "scratch_"]}, + expected_finding_count=1, + ), + ) + finding = result.findings[0] + assert (finding.start, finding.end) == (21, 35) + + +def test_published_tables_pass() -> None: + evaluate_rule( + rule=temporary_table, + test_case=RuleCase( + description="reads orders", + source="SELECT order_id FROM orders", + expected_finding_count=0, + ), + ) + + +def test_count_one_is_fixed() -> None: + result = evaluate_rule( + rule=count_one, + test_case=RuleCase( + description="COUNT(1)", + source="SELECT COUNT(1) FROM orders", + expected_finding_count=1, + ), + ) + assert result.fixed_source == "SELECT COUNT(*) FROM orders" +``` + +Test failing cases, passing cases and near misses, and assert exact positions where they matter. + +## Running the tests + +Files named `test_*.py` and `conftest.py` under `[rules] custom` are not loaded as Rules, so tests +can live next to the Rules they test. They import Rules by module path from the configuration +root, so put the root on the import path: run `python -m pytest` from the root, add a +`conftest.py` there, or set `pythonpath`: + +```toml +# pyproject.toml +[tool.pytest.ini_options] +pythonpath = ["."] +``` + +The harness runs the `sqlclean` executable installed in the same environment, or the one on the +`PATH`, to parse the case SQL. + +## RuleCase + +| Field | Default | Description | +|-------|---------|-------------| +| `description` | required | Names the case in failure messages | +| `source` | required | The SQL of the file under test | +| `expected_finding_count` | required | How many findings the Rule must report over every file of the case | +| `path` | `"query.sql"` | The path of `source` | +| `dialect` | `"generic"` | The dialect every file of the case is parsed with | +| `files` | `()` | More files, as `RuleFile(path=..., source=...)` | +| `options` | `{}` | Option values, as in `[rules.rule_options.]` | + +`options` are validated as in `sqlclean.toml`, so a test with a bad value fails with the same +error as `sqlclean check`. See [Validation](/docs/custom-rules/fixes-and-options/#validation). + +`files` matter for file and repository Rules. A repository Rule sees every file of the case: + +```python +from sqlclean.testing import RuleCase, RuleFile, evaluate_rule + +from sql_rules.layout import duplicate_cte_names + + +def test_cte_names_repeated_across_files() -> None: + result = evaluate_rule( + rule=duplicate_cte_names, + test_case=RuleCase( + description="paid is defined in two files", + source="WITH paid AS (SELECT order_id FROM payments) SELECT order_id FROM paid", + path="models/paid_orders.sql", + files=( + RuleFile( + path="models/refunds.sql", + source="WITH paid AS (SELECT order_id FROM refunds) SELECT order_id FROM paid", + ), + ), + expected_finding_count=2, + ), + ) + assert [finding.path for finding in result.findings] == [ + "models/paid_orders.sql", + "models/refunds.sql", + ] +``` + +## RuleResult + +| Member | Description | +|--------|-------------| +| `findings` | The findings, sorted by path and position | +| `finding_count` | `len(findings)` | +| `fixed_source` | `source` with the fixes of its findings applied; `source` itself when there are none | + +Each `Finding` has `code`, `path`, `start`, `end`, `message`, `remediation` and `edits`. Offsets +count characters in the file, as in [Statement facts](/docs/custom-rules/statement-facts/#positions). + +`fixed_source` applies every finding's edits once, skipping an edit that overlaps an earlier one, +and does not format the result. It shows exactly what the Rule's fix changes. + +## Failures + +`evaluate_rule` raises `RuleCaseError`, which fails the test: + +| Problem | Message | +|---------|---------| +| A different number of findings | `reads orders: expected 1 findings, got 0` | +| Findings that depend on file order | `custom Rule XSQCND001 is not deterministic` | +| Case SQL that does not parse | `sqlclean could not parse the case SQL: sqlclean: error: Parse error at line 1, column 18: ...` | +| A function without `@rule` | `evaluate_rule needs a function decorated with @rule` | +| No `sqlclean` executable | `the sqlclean executable is not installed; install sqlclean` | + +Errors in the Rule itself, such as an exception or a fix without `fixable`, raise the same errors +as in [`sqlclean check`](/docs/custom-rules/overview/#errors). + +## Determinism + +`sqlclean check` caches each Rule's findings per file and reuses them while the inputs are +unchanged, so a Rule must report the same findings for the same input every time. The harness +checks this: it evaluates the Rule over the case's files, then again in reverse file order, and +fails when the two sets of findings differ. + +The usual cause is state kept between calls, such as a module-level set of names already seen: + +```python +SEEN: set[str] = set() + + +@rule(code="XSQCND001", message="Table read in an earlier file", remediation="...") +def seen_before(*, statement: Statement, ctx: RuleContext) -> list[Finding]: + found = [ctx.finding(subject=statement, node=t) for t in statement.tables if t.name in SEEN] + SEEN.update(t.name for t in statement.tables) + return found +``` + +```text +RuleCaseError: custom Rule XSQCND001 is not deterministic +``` + +Compute findings from the subject and `ctx` alone. When a Rule needs to compare files, give it a +[repository subject](/docs/custom-rules/overview/#choosing-a-subject), which sees every file at +once. + +Avoid other inputs SQLClean does not track, such as the clock, environment variables, network +calls and files read directly from disk. A changed input of that kind does not invalidate cached +results; run with `--no-cache` when it changes. diff --git a/website/src/content/docs/docs/embedding.mdx b/website/src/content/docs/docs/embedding.mdx new file mode 100644 index 0000000..3675c8c --- /dev/null +++ b/website/src/content/docs/docs/embedding.mdx @@ -0,0 +1,147 @@ +--- +title: "Embedding the engine" +description: "Lint, format and read statement facts from Rust, in a tool that owns its own configuration." +--- + +The `sqlclean` crate is the engine without the command: the built-in Rules, the formatter and the +statement facts. Tools that template, compile or generate SQL can call it in process and keep their +own configuration, file discovery and Rule codes. + +```rust +use sqlclean::sql_lint::constants::LINT_API_VERSION; +use sqlclean::sql_lint::main::lint::lint; +use sqlclean::sql_lint::models::{LintRequest, LintResponse}; + +fn main() -> Result<(), String> { + let request = LintRequest { + version: LINT_API_VERSION, + sql: "SELECT order_id FROM orders WHERE cancelled_at = NULL LIMIT 10".to_owned(), + dialect: "duckdb".to_owned(), + enabled_rules: Some(vec!["SQC001".to_owned(), "SQC004".to_owned()]), + ..LintRequest::default() + }; + let response: LintResponse = lint(request)?; + for diagnostic in &response.diagnostics { + println!("{} {}..{} {}", diagnostic.code, diagnostic.start, diagnostic.end, diagnostic.message); + if let Some(fix) = &diagnostic.fix { + println!(" fix: replace {}..{} with {:?}", fix.start, fix.end, fix.replacement); + } + } + Ok(()) +} +``` + +```text +SQC001 47..48 Comparison with NULL is never true + fix: replace 47..48 with "IS" +SQC004 54..59 Row selection is nondeterministic +``` + +## Adding the dependency + +The crate is not published to crates.io. Depend on it from the +[GitHub repository](https://github.com/sqlstack-io/sqlclean), pinned to a release tag or commit: + +```toml +[dependencies] +sqlclean = { git = "https://github.com/sqlstack-io/sqlclean", tag = "" } +``` + +## Entry points + +| Function | What it does | +|----------|--------------| +| `sql_lint::main::lint::lint` | Lints one SQL text with a typed `LintRequest` | +| `sql_lint::main::engine::lint_json` | The same, with the request and response as JSON strings | +| `sql_lint::main::batch_engine::lint_batch_json` | Lints a JSON array of requests in parallel; returns an array of `{"Ok": response}` or `{"Err": message}` | +| `sql_lint::main::formatter::format_json` | Formats SQL from a JSON request | +| `sql_lint::main::catalogue::catalogue` | Every built-in Rule's code, message and remediation | +| `sql_lint::main::default_rules::default_rules` | The codes of the Rules enabled by default | +| `sql_facts::main::file_facts::file_facts` | The statement facts of a SQL text, as custom Rules read them | + +`lint`, `lint_json` and `file_facts` return `Err` with a message when the SQL does not parse under +the dialect. Offsets in requests, findings, edits and facts count Unicode characters, not bytes. + +## Lint requests + +`version` must be `LINT_API_VERSION`; another value is rejected with +`unsupported native lint request version 2; expected 1`. + +| Field | Default | Description | +|-------|---------|-------------| +| `version` | required | `LINT_API_VERSION` | +| `sql` | required | The SQL text | +| `dialect` | required | A [dialect name](/docs/configuration/#dialects) | +| `enabled_rules` | the default Rules | Rule codes or prefixes to run | +| `ignored_rules` | `[]` | Rule codes or prefixes to skip | +| `max_literal_length` | `100` | The `SQC044` limit | +| `max_ranking_order_by` | `5` | The `SQC043` limit | +| `config_file` | `sqlclean.toml` | The configuration file name that remediation text refers to | +| `externally_referenced_ctes` | `[]` | CTE names that something outside the SQL reads, so `SQC005` does not report them | +| `relation_keys` | `{}` | Declared unique keys by relation name; an empty key means the relation holds at most one row, so `SQC038` does not report a cross join with it | + +Other fields describe template code the host has replaced before linting. See `LintRequest` in +`sql_lint::models`. + +A selector that matches no Rule is an error: `unknown native lint rule 'SQC9'`. + +## Fixes + +`LintDiagnostic.fix` holds at most one edit: `start`, `end` and `replacement`. The engine does not +say whether a fix is safe. The `sqlclean` command treats the `SQC001` fix as unsafe, because +turning `= NULL` into `IS NULL` changes which rows match, and every other built-in fix as safe. A +host that applies fixes makes the same decision. See +[Safe and unsafe fixes](/docs/rules/overview/#safe-and-unsafe-fixes). + +## Formatting + +```rust +let request = r#"{"version": 1, "sql": "select order_id, amount from orders where amount > 0", "dialect": "duckdb", "line_width": 100}"#; +println!("{}", format_json(request)?); +``` + +```json +{"version":1,"sql":"SELECT\n order_id,\n amount\nFROM orders\nWHERE amount > 0","changed":true,"formatted":true} +``` + +`indent` sets the spaces per indent step. Without it the engine indents two spaces, its layout +before the `sqlclean` command chose four; pass `"indent": 4` to match `sqlclean format`. + +When the formatter cannot format the SQL, `formatted` is `false`, `sql` is the input unchanged, +and `reason` says why. + +`format_json` formats the text it is given as one unit. `sqlclean format` formats each statement on +its own, joins them with one blank line and ends the file with a newline. To produce the same +layout, split the file with `file_facts`, whose statements carry `start` and `terminated_end`, and +format each statement. See [Formatting](/docs/formatting/#statements). + +## Statement facts + +```rust +let facts = file_facts("SELECT o.order_id FROM orders AS o", "duckdb")?; +for statement in &facts.statements { + for table in &statement.tables { + println!("{} {} {}..{}", statement.kind, table.qualified_name, table.start, table.end); + } +} +``` + +```text +select orders 23..29 +``` + +The fields are those of [Statement facts](/docs/custom-rules/statement-facts/), without `line`, +`column` and `text`. Functions are `NamedNode` values with a `name`. + +## What stays in the host + +The crate reads no files, no configuration and no cache, and does not run Python. A host owns: + +- which files to lint and in which dialect; +- Rule selection, which it passes as `enabled_rules` and `ignored_rules`; +- exceptions, ignores and their reasons; +- the codes and configuration it shows its users; +- caching, and applying fixes. + +The [`sqlclean` command](/docs/cli/check/) is one such host. Its behaviour, described in the rest of +these docs, is a reference for what each of these pieces does. diff --git a/website/src/content/docs/docs/formatting.mdx b/website/src/content/docs/docs/formatting.mdx new file mode 100644 index 0000000..d45b31b --- /dev/null +++ b/website/src/content/docs/docs/formatting.mdx @@ -0,0 +1,186 @@ +--- +title: "Formatting" +description: "One canonical layout for every SQL file, the line width and indent, and files that can't be formatted." +--- + +`sqlclean format` prints every statement in one canonical layout. The only style options are the +line width and the indent, so every file in a project looks the same whoever wrote it. +[`sqlclean fix`](/docs/cli/fix/) formats the files it reads too. + +## Before and after + +```sql +-- Revenue per customer +with paid as (select order_id, customer_id, amount from payments where status = 'paid' and amount > 0), +totals as (select customer_id, sum(amount) as revenue, count(*) as orders from paid group by customer_id) +select c.customer_id, c.customer_name, t.revenue, t.orders, case when t.revenue > 1000 then 'gold' when t.revenue > 100 then 'silver' else 'bronze' end as tier +from customers c left join totals t on c.customer_id = t.customer_id +order by t.revenue desc; +delete from orders where status='cancelled';select 1 +``` + +```bash +sqlclean format +``` + +```sql +-- Revenue per customer +WITH paid AS ( + SELECT + order_id, + customer_id, + amount + FROM payments + WHERE + status = 'paid' AND amount > 0 +), + +totals AS ( + SELECT + customer_id, + SUM(amount) AS revenue, + COUNT(*) AS orders + FROM paid + GROUP BY + customer_id +) + +SELECT + c.customer_id, + c.customer_name, + t.revenue, + t.orders, + CASE + WHEN t.revenue > 1000 + THEN 'gold' + WHEN t.revenue > 100 + THEN 'silver' + ELSE 'bronze' + END AS tier +FROM customers c +LEFT JOIN totals t + ON c.customer_id = t.customer_id +ORDER BY + t.revenue DESC; + +DELETE FROM orders WHERE status = 'cancelled'; + +SELECT 1 +``` + +## The layout + +- Clause keywords and operators such as `SELECT`, `LEFT JOIN`, `AND` and `DESC`, and function + names such as `SUM` and `COUNT`, are upper case. Identifiers, aliases and literals keep the case + they were written in. +- Each clause starts a line. Each indent step is four spaces; see [Indent](#indent). +- A `SELECT` list with two or more items puts one item per line. A single item stays on the + `SELECT` line: `SELECT order_id`, `SELECT *`. +- `GROUP BY` and `ORDER BY` items go on the lines below the clause. +- A `WHERE` with one condition stays on the clause line: `WHERE amount > 0`. Several conditions + go on an indented line below it. +- A join's `ON` condition goes on the next line, indented. +- Each CTE closes with `),` and one blank line. One blank line separates the last CTE from the + query that reads it. +- `CASE` puts each `WHEN`, `THEN` and `ELSE` on its own line. +- Comments are kept. + +## Statements + +Each statement in a file is formatted on its own. Statements are separated by one blank line, and +every file ends with a newline. Semicolons are kept where they are written; the formatter does not +add one after the last statement. + +## Line width + +`line_width` sets the width the formatter fills before it breaks a line. The default is `100`: + +```toml +[format] +line_width = 100 +``` + +A line that does not fit is broken at its outermost structure. Inner parentheses that fit on +their line stay on one line. At the default width this `IN` list fits; at `line_width = 60` it is +broken: + +```sql +SELECT order_id +FROM orders +WHERE status IN ('placed', 'paid', 'packed', 'shipped', 'delivered', 'returned') +``` + +```sql +SELECT order_id +FROM orders +WHERE + status IN ( + 'placed', + 'paid', + 'packed', + 'shipped', + 'delivered', + 'returned' + ) +``` + +`line_width` must be positive. + +## Indent + +`indent` sets the number of spaces per indent step. The default is `4`: + +```toml +[format] +indent = 4 +``` + +With `indent = 2`, `select order_id, amount from orders where amount > 0` formats as: + +```sql +SELECT + order_id, + amount +FROM orders +WHERE amount > 0 +``` + +`indent` must be between `1` and `8`. + +## Idempotency + +Formatting is idempotent: a formatted file formats to itself. Running `sqlclean format` twice +changes nothing the second time, so `sqlclean format --check` passes on any file +`sqlclean format` wrote: + +```text +0 file(s) reformatted, 1 file(s) already formatted. +``` + +## Formatting in CI + +`sqlclean format --check` exits with `1` and names every file that would change, without writing: + +```text +Would reformat: models/open_orders.sql +1 file(s) would be reformatted, 0 file(s) already formatted. +``` + +`--diff` prints the changes instead. See [`sqlclean format`](/docs/cli/format/) and +[Integrations](/docs/integrations/). + +## When a file can't be formatted + +The formatter leaves a file unchanged when it cannot format it, usually because the file does not +parse under its [dialect](/docs/configuration/#dialects). Template code such as `{{ col }}` does +not parse: + +```text +j.sql: could not format: Parse error at line 1, column 16: Expected Colon, got RBrace ('}') near [{{ col }} from orders] +``` + +`sqlclean format` and `sqlclean fix` report the file on stderr and exit with `1`, and format every +other file as usual. Fix the SQL, set the file's dialect with +[`[[dialects]]`](/docs/configuration/#dialects), or +[`exclude`](/docs/configuration/#choosing-files) the file. `sqlclean check` reports the same file as +[`SQC999`](/docs/rules/overview/#parse-errors-sqc999). diff --git a/website/src/content/docs/docs/index.mdx b/website/src/content/docs/docs/index.mdx new file mode 100644 index 0000000..c824b63 --- /dev/null +++ b/website/src/content/docs/docs/index.mdx @@ -0,0 +1,93 @@ +--- +title: "Introduction" +description: "Clean SQL, every time. Fast linting, safe fixes and one canonical format." +--- + +import Card from '../../../components/docs/Card.astro'; +import CardGroup from '../../../components/docs/CardGroup.astro'; + +SQLClean is a free, open-source SQL linter and formatter. It checks `.sql` files against built-in +Rules, applies their fixes, and formats every file in one canonical layout. The engine and the +`sqlclean` command are written in Rust. Custom Rules are plain Python functions that read +structural facts about each statement. + +```bash +sqlclean check +``` + +```text +error[SQC001]: Comparison with NULL is never true + --> models/open_orders.sql:1:77 + | + 1 | with open_orders as (select order_id, amount from orders where cancelled_at = null), + | ^ + = help: Use IS NULL or IS NOT NULL when testing for NULL. + = fix: available with `sqlclean fix --unsafe-fixes` + +error[SQC005]: CTE is unreachable from the final query + --> models/open_orders.sql:2:1 + | + 2 | refunds as (select order_id from payments where amount < 0) + | ^^^^^^^ + = help: Reference the CTE from the final query or another reachable CTE, or remove it. + = fix: available with `sqlclean fix` + +Found 2 problems (1 fixable with `sqlclean fix`, 1 more with `--unsafe-fixes`). +``` + +`sqlclean fix` removes the unused CTE, then formats the file. The `= NULL` comparison has a fix +too, but it changes which rows match, so it waits for `--unsafe-fixes`. + +## Check + +[`sqlclean check`](/docs/cli/check/) reports findings with the source line, the problem and what to +do about it. Output is text for people, or [JSON and SARIF](/docs/integrations/) for CI and code +scanning. The [built-in Rules](/docs/rules/reference/) cover SQL that runs but returns the wrong +rows: `NULL` comparisons, implicit cartesian joins, `LIMIT` without `ORDER BY`, unused CTEs and +mismatched set operations. Opt-in Rules enforce a consistent query structure. + +## Fix + +[`sqlclean fix`](/docs/cli/fix/) applies every safe fix until the files stop changing, then formats +them. A fix that can change query results is marked unsafe and runs only with `--unsafe-fixes`. +See [Safe and unsafe fixes](/docs/rules/overview/#safe-and-unsafe-fixes). + +## Format + +[`sqlclean format`](/docs/cli/format/) rewrites every file in one canonical layout. Keywords are +upper case, each clause starts a line, and statements are separated by one blank line. Formatting +is idempotent: a formatted file formats to itself. See [Formatting](/docs/formatting/). + +## Extend + +[Custom Rules](/docs/custom-rules/overview/) are Python functions decorated with `@rule`. They read +the tables, joins, CTEs, columns and literals of each statement, can carry fixes and typed options, +and run with the same selection, exceptions and cache as the built-in Rules. Tools that compile or +template SQL can [embed the engine](/docs/embedding/) from Rust. + +## Commands + +| Command | What it does | +|---------|--------------| +| [`sqlclean check`](/docs/cli/check/) | Reports Rule findings without changing any file | +| [`sqlclean fix`](/docs/cli/fix/) | Applies Rule fixes until stable, then formats | +| [`sqlclean format`](/docs/cli/format/) | Rewrites SQL files in the canonical layout | +| [`sqlclean rules`](/docs/cli/rules/) | Lists the available Rules, or shows one | +| [`sqlclean facts`](/docs/cli/facts/) | Prints the statement facts custom Rules read, as JSON | + +## Where to start + + + + Install SQLClean, then check, fix and format your first file. + + + Selection, defaults, exceptions and fixes. + + + Encode your team's SQL conventions in Python. + + + Every command and flag. + + diff --git a/website/src/content/docs/docs/integrations.mdx b/website/src/content/docs/docs/integrations.mdx new file mode 100644 index 0000000..bb6ce92 --- /dev/null +++ b/website/src/content/docs/docs/integrations.mdx @@ -0,0 +1,107 @@ +--- +title: "Integrations" +description: "Run SQLClean in CI with JSON or SARIF output, in pre-commit, and from editors." +--- + +`sqlclean check`, `fix` and `format` exit with `0` when there is nothing to report, `1` when there +are findings or files to change, and `2` when they cannot run. Any CI system, Git hook or editor +can gate on that. + +## CI + +Run `sqlclean check` and `sqlclean format --check` on every change: + +```bash +sqlclean check +sqlclean format --check +``` + +Both read every matching file under the configuration root. `check` fails on findings and +[stale exceptions](/docs/rules/overview/#stale-exceptions); `format --check` fails on files that +need formatting or can't be formatted. + +### Code scanning with SARIF + +`--output-format sarif` writes a SARIF 2.1.0 log that code-scanning tools show as annotations on +the changed lines. On GitHub Actions: + +```yaml +name: sqlclean + +on: [push, pull_request] + +jobs: + sqlclean: + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install sqlclean + - run: sqlclean format --check + - run: sqlclean check --output-format sarif > sqlclean.sarif + - uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: sqlclean.sarif +``` + +`sqlclean check` exits with `1` when it reports findings, so the upload step runs with +`if: always()`. The SARIF log lists findings only; stale exceptions still fail the step. See +[SARIF output](/docs/cli/check/#sarif-output). + +### JSON for other tools + +`--output-format json` prints one document with every finding, its position, message, remediation +and fix safety, the stale exceptions, and a summary. See +[JSON output](/docs/cli/check/#json-output). `sqlclean rules --json` describes every Rule a finding +can come from. + +### Caching in CI + +`check` stores results in `.sqlclean/` under the configuration root. Keep that directory between +runs to skip files that have not changed, or pass `--no-cache` to evaluate everything. See +[Caching](/docs/rules/overview/#caching). + +## pre-commit + +SQLClean ships three [pre-commit](https://pre-commit.com/) hooks: + +| Hook | Runs | Changes files | +|------|------|---------------| +| `sqlclean-check` | `sqlclean check` | No | +| `sqlclean-fix` | `sqlclean fix` | Yes: safe fixes, then formatting | +| `sqlclean-format` | `sqlclean format` | Yes: formatting | + +```yaml +# .pre-commit-config.yaml +repos: + - repo: https://github.com/sqlstack-io/sqlclean + rev: v0.0.1 + hooks: + - id: sqlclean-fix + - id: sqlclean-format +``` + +Use `sqlclean-check` instead of `sqlclean-fix` where a hook must not change files. + +The hooks run on staged `.sql` files and pass them to the command by name, so `exclude` in +`sqlclean.toml` still applies but `include` does not. They use the nearest `sqlclean.toml` at +or above the repository root. A hook that changes a file, or leaves a finding, fails the commit; +stage the changes and commit again. + +## Editors + +Editors can run `sqlclean format` on save, passing the saved file: + +```bash +sqlclean format path/to/file.sql +``` + +The file is formatted with the dialect, `line_width` and `indent` of the nearest `sqlclean.toml` at or above +the editor's working directory. Use `sqlclean check --output-format json path/to/file.sql` to show +findings with their lines and columns. Lines and columns start at 1 and count characters. diff --git a/website/src/content/docs/docs/quickstart.mdx b/website/src/content/docs/docs/quickstart.mdx new file mode 100644 index 0000000..1702e59 --- /dev/null +++ b/website/src/content/docs/docs/quickstart.mdx @@ -0,0 +1,211 @@ +--- +title: "Quickstart" +description: "Install SQLClean, then check, fix and format your first SQL file." +--- + +This guide installs SQLClean, checks one SQL file, fixes what can be fixed, and adds a +`sqlclean.toml` that selects more Rules. + +## Prerequisites + +- Python 3.12 or newer +- SQLClean installed from [PyPI](https://pypi.org/project/sqlclean/): + +```bash +uv tool install sqlclean # or: pip install sqlclean +sqlclean --version +``` + +The `sqlclean` package installs the native `sqlclean` executable. Custom Rules run in the same +Python environment as that executable. + +## 1. Check a file + +Save this query as `models/open_orders.sql`: + +```sql +with open_orders as (select order_id, customer_id, amount from orders where cancelled_at = null), +refunds as (select order_id from payments where amount < 0) +select order_id, customer_id, amount from open_orders limit 100 +``` + +Run `sqlclean check` in the project directory. With no paths, it reads every `.sql` file under the +directory: + +```bash +sqlclean check +``` + +```text +error[SQC001]: Comparison with NULL is never true + --> models/open_orders.sql:1:90 + | + 1 | with open_orders as (select order_id, customer_id, amount from orders where cancelled_at = null), + | ^ + = help: Use IS NULL or IS NOT NULL when testing for NULL. + = fix: available with `sqlclean fix --unsafe-fixes` + +error[SQC005]: CTE is unreachable from the final query + --> models/open_orders.sql:2:1 + | + 2 | refunds as (select order_id from payments where amount < 0) + | ^^^^^^^ + = help: Reference the CTE from the final query or another reachable CTE, or remove it. + = fix: available with `sqlclean fix` + +error[SQC004]: Row selection is nondeterministic + --> models/open_orders.sql:3:55 + | + 3 | select order_id, customer_id, amount from open_orders limit 100 + | ^^^^^ + = help: Add ORDER BY with a deterministic tie-breaker before LIMIT or OFFSET. + +Found 3 problems (1 fixable with `sqlclean fix`, 1 more with `--unsafe-fixes`). +``` + +Each finding names its Rule code, the file, line and column, and what to do. The command exits +with `1` because there are findings. + +## 2. Fix + +Preview the safe fixes as a diff, then apply them: + +```bash +sqlclean fix --diff +sqlclean fix +``` + +```text +Fixed 1 problem(s) and changed 1 file(s). +error[SQC001]: Comparison with NULL is never true + --> models/open_orders.sql:7:24 + | + 7 | WHERE cancelled_at = NULL + | ^ + = help: Use IS NULL or IS NOT NULL when testing for NULL. + = fix: available with `sqlclean fix --unsafe-fixes` + +error[SQC004]: Row selection is nondeterministic + --> models/open_orders.sql:15:1 + | + 15 | LIMIT 100 + | ^^^^^ + = help: Add ORDER BY with a deterministic tie-breaker before LIMIT or OFFSET. + +Found 2 problems (1 fixable with `sqlclean fix --unsafe-fixes`). +``` + +`fix` removed the unused `refunds` CTE and formatted the file. The `= NULL` fix turns the +comparison into `IS NULL`, which changes which rows match, so it is unsafe. Review it, then apply +it: + +```bash +sqlclean fix --unsafe-fixes +``` + +`SQC004` has no fix: only you know which column makes the order deterministic. Add +`ORDER BY order_id` before `LIMIT 100`, then run `sqlclean fix` once more to format the new +clause. The file is now: + +```sql +WITH open_orders AS ( + SELECT + order_id, + customer_id, + amount + FROM orders + WHERE cancelled_at IS NULL +) + +SELECT + order_id, + customer_id, + amount +FROM open_orders +ORDER BY + order_id +LIMIT 100 +``` + +```bash +sqlclean check +``` + +```text +All checks passed. +``` + +## 3. Format + +`sqlclean fix` formats the files it reads. To format without applying fixes, or to check +formatting in CI, use `sqlclean format`: + +```bash +sqlclean format # rewrite files +sqlclean format --check # exit 1 when a file needs formatting +``` + +See [Formatting](/docs/formatting/) for the layout. + +## 4. Add a sqlclean.toml + +Without a configuration file, SQLClean parses files as `generic` SQL and runs the 13 Rules that +are enabled by default. Create `sqlclean.toml` in the project root to set the dialect and select +every built-in Rule: + +```toml +dialect = "duckdb" + +[rules] +select = ["SQC"] +``` + +`SQC` is a prefix, so it selects all 45 built-in Rules. Three of the opt-in structure Rules now +report the file: + +```text +error[SQC041]: Only the last top-level CTE may be named final, and it must use that name + --> models/open_orders.sql:1:6 + | + 1 | WITH open_orders AS ( + | ^^^^^^^^^^^ + = help: Name the last top-level CTE final and rename any other final CTE; SQC035 checks the terminal SELECT. + +error[SQC034]: Query keeps transformation logic outside top-level CTEs + --> models/open_orders.sql:10:1 + | + 10 | SELECT + | ^^^^^^ + = help: Move transformation logic into named top-level CTEs before the terminal SELECT. + +error[SQC035]: Terminal SELECT does not read plainly from the final top-level CTE + --> models/open_orders.sql:10:1 + | + 10 | SELECT + | ^^^^^^ + = help: Name the final logical CTE and leave one terminal SELECT from that CTE. + +Found 3 problems. +``` + +Follow them, or skip the ones your team does not want: + +```toml +dialect = "duckdb" + +[rules] +select = ["SQC"] +ignore = ["SQC034", "SQC035", "SQC041"] +``` + +```text +All checks passed. +``` + +## Next steps + +- [Configuration](/docs/configuration/) lists every key, including per-path dialects. +- [Rules](/docs/rules/overview/) covers selection, exceptions and fixes, and the + [Rule reference](/docs/rules/reference/) lists every built-in Rule. +- [Integrations](/docs/integrations/) runs SQLClean in CI and pre-commit. +- [Custom Rules](/docs/custom-rules/overview/) checks your own conventions. diff --git a/website/src/content/docs/docs/rules/overview.mdx b/website/src/content/docs/docs/rules/overview.mdx new file mode 100644 index 0000000..c5b3905 --- /dev/null +++ b/website/src/content/docs/docs/rules/overview.mdx @@ -0,0 +1,271 @@ +--- +title: "Overview" +description: "Select Rules, record reviewed exceptions, and apply safe and unsafe fixes." +--- + +import Card from '../../../../components/docs/Card.astro'; +import CardGroup from '../../../../components/docs/CardGroup.astro'; + +A Rule is one check over SQL. It reports a finding at an exact position, with a message, a +remediation, and sometimes a fix. SQLClean has 45 built-in Rules with `SQC###` codes, and runs +[custom Rules](/docs/custom-rules/overview/) written in Python with `XSQC` codes. + +Consider a query that returns an arbitrary hundred rows: + +```sql +SELECT order_id +FROM orders +LIMIT 100 +``` + +```bash +sqlclean check +``` + +```text +error[SQC004]: Row selection is nondeterministic + --> models/recent_orders.sql:3:1 + | + 3 | LIMIT 100 + | ^^^^^ + = help: Add ORDER BY with a deterministic tie-breaker before LIMIT or OFFSET. + +Found 1 problem. +``` + +Add an ordering and check again: + +```sql +SELECT order_id +FROM orders +ORDER BY order_id +LIMIT 100 +``` + +Run `sqlclean rules` for every Rule, or see the [Rule reference](/docs/rules/reference/). + +## Selecting Rules + +Select Rules in `sqlclean.toml` by exact code or by prefix: + +```toml +[rules] +select = ["SQC"] # every built-in Rule +ignore = ["SQC027", "SQC03"] # except SQC027 and SQC030 to SQC039 +``` + +- An exact code such as `SQC004` selects one Rule. +- A prefix selects every Rule whose code starts with it: `SQC` selects all built-in Rules, + `SQC00` selects `SQC001` to `SQC009`, and `XSQCNAME` selects every custom Rule in the `NAME` + family. +- `ignore` removes Rules from the selection, by code or prefix. +- A selector that matches no Rule is an error: `Rule selector 'SQC9' matches no Rule`. + +On the command line, `--select` replaces `[rules] select` for one run, and `--ignore` adds to +`[rules] ignore`: + +```bash +sqlclean check --select SQC001,SQC004 +sqlclean check --ignore SQC039 +``` + +`select = []` runs no Rules. Files are still parsed, so [parse errors](#parse-errors-sqc999) are +still reported. + +## Default Rules + +Without `[rules] select`, SQLClean runs the 13 Rules that are enabled by default. They cover +mistakes that run without an error and are easy to miss in review: NULL handling, joins, row order, +duplicate names and unused CTEs. + +| Code | Finding | +|------|---------| +| `SQC001` | Comparison with NULL is never true | +| `SQC002` | Comma-separated sources create an implicit cartesian join | +| `SQC003` | Non-cross join has no meaningful condition | +| `SQC004` | Row selection is nondeterministic | +| `SQC005` | CTE is unreachable from the final query | +| `SQC006` | DISTINCT is redundant with the grouped output | +| `SQC007` | Positional set operation is vulnerable to column-order drift | +| `SQC009` | Table alias is duplicated in the same query scope | +| `SQC010` | Output alias is duplicated in the same select list | +| `SQC018` | ROW_NUMBER has no deterministic ordering | +| `SQC019` | NOT IN list contains NULL and can reject every row | +| `SQC020` | Set-operation branches project different column counts | +| `SQC039` | Inline query relation obscures data flow | + +The other 32 Rules run only when `select` names them, by code or prefix. A prefix selects every +matching Rule, whether it is enabled by default or not. A custom Rule is enabled by default when its +`@rule` sets `enabled_by_default=True`. + +## Thresholds + +Two built-in Rules compare against a limit, set under `[rules.thresholds]`: + +```toml +[rules.thresholds] +max_literal_length = 100 # SQC044: the longest string literal allowed +max_ranking_order_by = 5 # SQC043: the most ORDER BY expressions in a ranking window +``` + +Both default to the values shown and must be positive. Built-in Rules take no other options. + +## Exceptions + +An exception allows one reviewed finding: one Rule code in one file. It lives in `sqlclean.toml`, +so a reviewer sees it in the diff, and it needs a reason: + +```toml +[[rules.rule_exceptions]] +rule = "SQC004" +path = "models/order_preview.sql" +reason = "A preview of any hundred orders is enough." +``` + +`rule` is an exact code, not a prefix. `path` is the file's path relative to the configuration +root, as findings print it. The exception covers every finding of that Rule in that file. + +SQLClean has no inline suppression comments. A comment such as `-- noqa: SQC001` changes nothing; +record the exception in `sqlclean.toml` instead. + +### Cross join exceptions + +A file-wide exception for a deliberate `CROSS JOIN` would also hide any accidental cross join added +to the file later. `SQC038` findings name the joined relation and, inside a CTE, that CTE, so an +exception can cover exactly one join: + +```text +error[SQC038]: CROSS JOIN with `regions` in CTE `pairs` creates an explicit cartesian product + --> models/calendar.sql:11:3 +``` + +```toml +[[rules.rule_exceptions]] +rule = "SQC038" +path = "models/calendar.sql" +relation = "regions" +cte = "pairs" # optional: only the join inside this CTE +reason = "Every order date is reported for every region." +``` + +`relation` and `cte` compare without case. Without `cte`, the exception covers joins with that +relation anywhere in the file. `cte` needs `relation`, and `relation` matches only `SQC038` +findings. + +`SQC038` does not report a cross join when one side is provably at most one row, such as a CTE +that aggregates without `GROUP BY`, `LIMIT 1` or a one-row `VALUES`, so many intentional cross +joins need no exception: + +```sql +WITH latest AS ( + SELECT MAX(order_date) AS latest_date + FROM orders +) + +SELECT + o.order_id, + l.latest_date +FROM orders AS o +CROSS JOIN latest AS l +``` + +### Stale exceptions + +An exception that matches no finding is stale. `check` and `fix` report it and exit with `1`: + +```text +error: stale rule exception: SQC004 in models/orders.sql matches no finding; remove it from sqlclean.toml +``` + +Delete the entry. Exceptions are only judged stale when their file was checked and their Rule was +selected, so `sqlclean check models/customers.sql` or `--select SQC001` never reports an exception +for another file or Rule. + +## Path ignores + +A path ignore turns Rules off for files that follow a different convention. It takes codes or +prefixes, glob patterns, and a reason: + +```toml +[[rules.rule_ignores]] +rules = ["SQC034", "SQC035"] +paths = ["scratch/**"] +reason = "Exploratory queries." +``` + +Patterns are relative to the configuration root, as in [`include`](/docs/configuration/#choosing-files). +To turn a Rule off everywhere, add it to `[rules] ignore` instead. + +## Parse errors (SQC999) + +A file that does not parse under its [dialect](/docs/configuration/#dialects) gets one `SQC999` +finding and no other: + +```text +error[SQC999]: SQL could not be parsed as duckdb: Parse error at line 1, column 18: Expected table name or subquery, got Where + --> scratch/broken.sql:1:1 + | + 1 | SELECT FROM WHERE orders + | ^ + = help: Fix the syntax error, or set this file's dialect in sqlclean.toml. +``` + +The finding points at the start of the file; the message gives the line and column of the error. +`SQC999` is not a selectable Rule: selection, `ignore`, path ignores and exceptions never hide it. +Fix the SQL, set the right dialect for the file, or [`exclude`](/docs/configuration/#choosing-files) +the file. + +## Safe and unsafe fixes + +Some Rules carry a fix. [`sqlclean fix`](/docs/cli/fix/) applies it. + +- A **safe** fix keeps the query's results. `SQC005` removes an unused CTE; `SQC002` turns + `FROM orders, customers` into `FROM orders CROSS JOIN customers`. +- An **unsafe** fix can change results. `SQC001` turns `cancelled_at = NULL`, which matches no row, + into `cancelled_at IS NULL`, which matches rows. That is almost always the intent, but it is a + different query. Unsafe fixes apply only with `--unsafe-fixes`. + +`sqlclean check` says which command applies each fix: + +```text + = fix: available with `sqlclean fix --unsafe-fixes` +``` + +The [Rule reference](/docs/rules/reference/) lists which Rules can fix and how safely. + +## Caching + +`check` and `fix` reuse the results of earlier runs for inputs that have not changed, so a second +run over an unchanged project costs little more than reading the files. Results are stored in +`.sqlclean/` under the configuration root, which has its own `.gitignore`. + +A file's results are evaluated again when any of these change: + +- the file's text, path or dialect; +- the selected Rules or `[rules.thresholds]`; +- a custom Rule's options, or any Python file under `[rules] custom`; +- for a custom Rule over the whole repository, any checked file; +- the SQLClean version. + +Exceptions and ignores apply after the cache, so editing them takes effect at once. + +Pass `--no-cache` to evaluate every file without reading or writing the cache. Use it when a custom +Rule imports Python from outside its `[rules] custom` paths, since changes there are not tracked. +`sqlclean format` does not use the cache. + +## Continue + + + + Every built-in Rule, its default and its fix. + + + Define your own Rules in Python. + + + Every `sqlclean.toml` key. + + + Flags, output formats and exit codes. + + diff --git a/website/src/content/docs/docs/rules/reference.mdx b/website/src/content/docs/docs/rules/reference.mdx new file mode 100644 index 0000000..6c7eae4 --- /dev/null +++ b/website/src/content/docs/docs/rules/reference.mdx @@ -0,0 +1,262 @@ +--- +title: "Rule reference" +description: "Every built-in Rule, whether it runs by default, and whether it has a fix." +--- + +SQLClean has 45 built-in Rules. `Default` Rules run when `[rules] select` is not set; the others +run when `select` names them by code or prefix. A Rule with a `Safe` fix is fixed by +`sqlclean fix`; an `Unsafe` fix also needs `--unsafe-fixes`. See +[Selecting Rules](/docs/rules/overview/#selecting-rules) and +[Safe and unsafe fixes](/docs/rules/overview/#safe-and-unsafe-fixes). + +Run `sqlclean rules ` for a Rule's remediation. + +| Code | Finding | Default | Fix | +|------|---------|---------|-----| +| `SQC001` | Comparison with NULL is never true | Yes | Unsafe | +| `SQC002` | Comma-separated sources create an implicit cartesian join | Yes | Safe | +| `SQC003` | Non-cross join has no meaningful condition | Yes | Safe | +| `SQC004` | Row selection is nondeterministic | Yes | — | +| `SQC005` | CTE is unreachable from the final query | Yes | Safe | +| `SQC006` | DISTINCT is redundant with the grouped output | Yes | Safe | +| `SQC007` | Positional set operation is vulnerable to column-order drift | Yes | — | +| `SQC008` | Bare UNION leaves duplicate handling implicit | No | Safe | +| `SQC009` | Table alias is duplicated in the same query scope | Yes | — | +| `SQC010` | Output alias is duplicated in the same select list | Yes | — | +| `SQC011` | Clause mixes positional and named references | No | — | +| `SQC012` | ELSE NULL is redundant | No | — | +| `SQC013` | DISTINCT is written as though it were a function | No | Safe | +| `SQC014` | Predicate compares identical constant values | No | — | +| `SQC015` | Empty statement follows another terminator | No | — | +| `SQC016` | Expression is aliased to its existing name | No | — | +| `SQC017` | Row count uses a constant expression | No | Safe | +| `SQC018` | ROW_NUMBER has no deterministic ordering | Yes | — | +| `SQC019` | NOT IN list contains NULL and can reject every row | Yes | — | +| `SQC020` | Set-operation branches project different column counts | Yes | — | +| `SQC021` | Query output has an uncontrolled column shape | No | — | +| `SQC022` | Calculated projection has no stable output name | No | — | +| `SQC023` | Table alias is never referenced | No | — | +| `SQC024` | WHERE predicate rejects NULL rows from a LEFT JOIN | No | — | +| `SQC025` | JOIN type is implicit | No | Safe | +| `SQC026` | ORDER BY mixes explicit and implicit directions | No | — | +| `SQC027` | Column reference is unqualified in a multi-relation query | No | — | +| `SQC028` | Single-relation query mixes qualified and unqualified columns | No | — | +| `SQC029` | Qualified reference uses a relation absent from this query scope | No | — | +| `SQC030` | CASE expression only converts a condition to TRUE or FALSE | No | Safe | +| `SQC031` | CASE expression is nested directly inside ELSE | No | — | +| `SQC032` | Joined relation contributes no referenced values | No | — | +| `SQC033` | Comment is not attached to the syntax node it explains | No | — | +| `SQC034` | Query keeps transformation logic outside top-level CTEs | No | — | +| `SQC035` | Terminal SELECT does not read plainly from the final top-level CTE | No | — | +| `SQC036` | CTE is nested inside another query | No | — | +| `SQC037` | Recursive CTE is not permitted | No | — | +| `SQC038` | CROSS JOIN creates an explicit cartesian product | No | — | +| `SQC039` | Inline query relation obscures data flow | Yes | — | +| `SQC040` | JOIN ON contains a computed expression | No | — | +| `SQC041` | Only the last top-level CTE may be named final, and it must use that name | No | — | +| `SQC042` | CTE output column is never read later in the query | No | — | +| `SQC043` | Ranking window sorts by more expressions than max_ranking_order_by | No | — | +| `SQC044` | String literal is longer than max_literal_length | No | — | +| `SQC045` | JSON payload is parsed more than once in one SELECT | No | — | + +`SQC999` is the [parse error](/docs/rules/overview/#parse-errors-sqc999) finding. It is not a +selectable Rule. + +`SQC043` and `SQC044` read their limits from +[`[rules.thresholds]`](/docs/rules/overview/#thresholds). + +## SQC001: NULL comparisons + +`= NULL` and `<> NULL` are never true, so the filter matches no row: + +```sql +SELECT order_id +FROM orders +WHERE cancelled_at = NULL +``` + +```text +error[SQC001]: Comparison with NULL is never true + --> models/open_orders.sql:3:20 + | + 3 | WHERE cancelled_at = NULL + | ^ + = help: Use IS NULL or IS NOT NULL when testing for NULL. + = fix: available with `sqlclean fix --unsafe-fixes` +``` + +The fix writes `WHERE cancelled_at IS NULL`. It is unsafe because the query then returns rows it +did not return before. + +## SQC002 and SQC003: joins without keys + +A comma between sources joins every row with every row unless a `WHERE` predicate filters them: + +```sql +SELECT + o.order_id, + c.customer_name +FROM orders AS o, customers AS c +WHERE o.customer_id = c.customer_id +``` + +The `SQC002` fix keeps the meaning and makes the cartesian product visible: + +```diff +-FROM orders AS o, customers AS c ++FROM orders AS o ++CROSS JOIN customers AS c + WHERE o.customer_id = c.customer_id +``` + +Move the predicate into `JOIN customers AS c ON o.customer_id = c.customer_id` by hand to make it a +keyed join. `SQC003` reports a `JOIN` whose condition is missing or constant, such as +`ON 1 = 1`; its fix turns a `JOIN` without a condition into `CROSS JOIN`. + +## SQC005: unused CTEs + +A CTE that no reachable query reads is dead code: + +```sql +WITH paid AS ( + SELECT order_id FROM payments +), + +refunded AS ( + SELECT order_id FROM refunds +) + +SELECT order_id FROM paid +``` + +```text +error[SQC005]: CTE is unreachable from the final query + --> models/paid_orders.sql:5:1 + | + 5 | refunded AS ( + | ^^^^^^^^ + = help: Reference the CTE from the final query or another reachable CTE, or remove it. + = fix: available with `sqlclean fix` +``` + +The fix removes `refunded`. + +## SQC018: unstable row numbers + +Without `ORDER BY` in the window, the row numbered `1` can change between runs: + +```sql +SELECT + order_id, + ROW_NUMBER() OVER (PARTITION BY customer_id) AS order_rank +FROM orders +``` + +```text +error[SQC018]: ROW_NUMBER has no deterministic ordering +``` + +An `ORDER BY` is not always enough. Ties still rank in any order unless `PARTITION BY` and +`ORDER BY` together cover a unique key of the ranked rows. SQLClean only knows a key it can prove +from the query, such as the `GROUP BY` columns of a CTE. Ranking straight over a table is reported +with a second message: + +```text +error[SQC018]: Ranking window order is not proven unique within its partition + --> models/ranked.sql:3:3 + | + 3 | ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ordered_at, order_id) AS order_rank + | ^^^^^^^^^^ + = help: PARTITION BY and ORDER BY must together cover a unique key of the ranked rows. End the ORDER BY with a unique, non-null tie-breaker column. If the rows have no unique column, build one upstream from the columns that identify a row (for example `HASH(source_partition, source_offset, line_index) AS row_key`) and order by it last. +``` + +Here the CTE's `GROUP BY` makes `customer_id, order_id` a key, so the window passes: + +```sql +WITH customer_orders AS ( + SELECT + customer_id, + order_id, + MAX(ordered_at) AS ordered_at + FROM orders + GROUP BY + customer_id, + order_id +) + +SELECT + order_id, + ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ordered_at, order_id) AS order_rank +FROM customer_orders +``` + +`SQC004` reports the same problem for `LIMIT` and `OFFSET`; see the example in +[Rules](/docs/rules/overview/). + +## SQC019: NULL in NOT IN + +`x NOT IN (..., NULL)` is never true, so the filter rejects every row: + +```sql +SELECT order_id +FROM orders +WHERE status NOT IN ('cancelled', NULL) +``` + +```text +error[SQC019]: NOT IN list contains NULL and can reject every row + --> models/active_orders.sql:3:14 + | + 3 | WHERE status NOT IN ('cancelled', NULL) + | ^^^ + = help: Remove NULL from the list or express the anti-join with a NULL-safe NOT EXISTS. +``` + +## SQC039: inline subqueries + +A subquery in `FROM` hides a step of the query. Name it as a CTE instead: + +```sql +-- Reported +SELECT order_id +FROM (SELECT order_id FROM orders WHERE amount > 0) AS paid + +-- Passes +WITH paid AS ( + SELECT order_id + FROM orders + WHERE amount > 0 +) + +SELECT order_id +FROM paid +``` + +## SQC034, SQC035 and SQC041: query structure + +These opt-in Rules ask every query to do its work in top-level CTEs, name the last one `final`, +and end with a plain `SELECT` of named columns from it. This query passes every built-in Rule: + +```sql +WITH open_orders AS ( + SELECT + order_id, + amount + FROM orders + WHERE cancelled_at IS NULL +), + +final AS ( + SELECT + order_id, + amount + FROM open_orders +) + +SELECT + order_id, + amount +FROM final +``` + +Ending with `SELECT * FROM final` is reported by `SQC021`, which asks for named output columns. diff --git a/website/src/navigation.mjs b/website/src/navigation.mjs new file mode 100644 index 0000000..90f67f0 --- /dev/null +++ b/website/src/navigation.mjs @@ -0,0 +1,5 @@ +import sidebarData from './sidebar.json'; + +export const sidebar = sidebarData; + +export const redirects = {}; diff --git a/website/src/sidebar.json b/website/src/sidebar.json new file mode 100644 index 0000000..fde9167 --- /dev/null +++ b/website/src/sidebar.json @@ -0,0 +1,55 @@ +[ + { + "label": "Getting Started", + "items": [ + "docs", + "docs/quickstart" + ] + }, + { + "label": "Concepts", + "items": [ + "docs/configuration", + { + "label": "Rules", + "collapsed": false, + "items": [ + { + "label": "Overview", + "slug": "docs/rules/overview" + }, + "docs/rules/reference" + ] + }, + "docs/formatting" + ] + }, + { + "label": "Custom Rules", + "items": [ + "docs/custom-rules/overview", + "docs/custom-rules/statement-facts", + "docs/custom-rules/fixes-and-options", + "docs/custom-rules/testing" + ] + }, + { + "label": "Integrations", + "items": [ + "docs/integrations", + "docs/embedding", + "docs/benchmarks" + ] + }, + { + "label": "CLI Reference", + "items": [ + "docs/cli/check", + "docs/cli/fix", + "docs/cli/format", + "docs/cli/rules", + "docs/cli/facts", + "docs/cli/skills" + ] + } +] diff --git a/website/src/styles/fonts.css b/website/src/styles/fonts.css new file mode 100644 index 0000000..566aaec --- /dev/null +++ b/website/src/styles/fonts.css @@ -0,0 +1,42 @@ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('/fonts/inter-latin-wght-normal.woff2') format('woff2'); +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/ibm-plex-mono-latin-400-normal.woff2') format('woff2'); +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/ibm-plex-mono-latin-500-normal.woff2') format('woff2'); +} + +/* IBM Plex Mono has no box-drawing glyphs. DejaVu Sans Mono fills them in, and draws whole + diagrams so every cell has the same advance width. */ +@font-face { + font-family: 'SQLClean Box Drawing'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/dejavu-sans-mono-diagram.woff2') format('woff2'); + unicode-range: U+2500-259F, U+2260; +} + +@font-face { + font-family: 'SQLClean Diagram'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/dejavu-sans-mono-diagram.woff2') format('woff2'); +} diff --git a/website/src/styles/sqlclean.css b/website/src/styles/sqlclean.css new file mode 100644 index 0000000..701075d --- /dev/null +++ b/website/src/styles/sqlclean.css @@ -0,0 +1,446 @@ +/* Starlight theme mapped onto the homepage tokens in public/styles.css. */ +:root { + --sl-font: 'Inter', system-ui, sans-serif; + --sl-font-mono: 'IBM Plex Mono', 'SQLClean Box Drawing', ui-monospace, monospace; + --sl-nav-height: 60px; + --sl-content-width: 50rem; + --sl-sidebar-width: 18rem; + --sqc-toc-width: 16rem; + --sqc-frame-width: 1440px; + --sl-text-h1: 2rem; + --sl-text-h2: 1.45rem; + --sl-text-h3: 1.15rem; + --sl-text-h4: 1rem; + --sl-line-height-headings: 1.25; + + /* Dark (Starlight's default) */ + --sqc-bg: #0d0f12; + --sqc-panel: #171a1f; + --sqc-fg: #e9ebef; + --sqc-muted: #9ba1ac; + --sqc-faint: #626975; + --sqc-line: rgba(176, 184, 198, 0.1); + --sqc-line-strong: rgba(176, 184, 198, 0.18); + --sqc-primary: #1fd8b8; + --sqc-accent-soft: rgba(31, 216, 184, 0.12); + --sqc-code-bg: #111418; + --sqc-warning: #f0c267; + + --sl-color-accent-low: #0b3a33; + --sl-color-accent: #1fd8b8; + --sl-color-accent-high: #a6f2e4; + --sl-color-white: #e9ebef; + --sl-color-gray-1: #d4d7dd; + --sl-color-gray-2: #b4b9c2; + --sl-color-gray-3: #9ba1ac; + --sl-color-gray-4: #626975; + --sl-color-gray-5: #2a2f37; + --sl-color-gray-6: #171a1f; + --sl-color-black: #0d0f12; +} + +:root[data-theme='light'] { + --sqc-bg: #ffffff; + --sqc-panel: #ffffff; + --sqc-fg: #171a1f; + --sqc-muted: #626a76; + --sqc-faint: #98a0ad; + --sqc-line: rgba(28, 32, 38, 0.08); + --sqc-line-strong: rgba(28, 32, 38, 0.16); + --sqc-primary: #0b8a74; + --sqc-accent-soft: rgba(11, 138, 116, 0.08); + --sqc-code-bg: #fbfcfe; + --sqc-warning: #aa6c16; + + --sl-color-accent-low: #dff6f1; + --sl-color-accent: #0b8a74; + --sl-color-accent-high: #075a4c; + --sl-color-white: #171a1f; + --sl-color-gray-1: #2b3038; + --sl-color-gray-2: #434a55; + --sl-color-gray-3: #626a76; + --sl-color-gray-4: #98a0ad; + --sl-color-gray-5: #dfe3e8; + --sl-color-gray-6: #f4f6f8; + --sl-color-gray-7: #fafbfc; + --sl-color-black: #ffffff; +} + +:root { + --sl-color-bg: var(--sqc-bg); + --sl-color-bg-nav: var(--sqc-bg); + --sl-color-bg-sidebar: var(--sqc-bg); + --sl-color-bg-inline-code: var(--sqc-accent-soft); + --sl-color-hairline: var(--sqc-line); + --sl-color-hairline-light: var(--sqc-line); + --sl-color-hairline-shade: var(--sqc-line); + --sl-color-text: var(--sqc-fg); + --sl-color-text-accent: var(--sqc-primary); +} + +body { + -webkit-font-smoothing: antialiased; +} + +/* Header */ +site-search button[data-open-modal] { + border-radius: 2px; + border-color: var(--sqc-line-strong); + background: transparent; + color: var(--sqc-muted); +} + +site-search button[data-open-modal]:hover { + border-color: var(--sqc-primary); + color: var(--sqc-fg); +} + +/* Sidebar: bold top-level headings, nested groups styled like links (as in common docs sites). */ +.sidebar-content ul ul li { + margin-inline-start: 0; + padding-inline-start: 0; + border-inline-start: 0; +} + +.sidebar-content .top-level > li + li { + margin-top: 1.1rem; +} + +.sidebar-content .top-level > li > details > summary { + padding: 0.2rem 0.5rem 0.35rem; + pointer-events: none; +} + +.sidebar-content .top-level > li > details > summary .caret { + display: none; +} + +.sidebar-content .top-level > li > details > summary .large { + color: var(--sqc-fg); + font-size: 14px; + font-weight: 600; +} + +.sidebar-content ul ul summary { + padding: 0.3rem 0.5rem; + border-radius: 2px; +} + +.sidebar-content ul ul summary:hover { + background: var(--sqc-accent-soft); +} + +.sidebar-content ul ul summary .large { + color: var(--sqc-muted); + font-size: 14px; + font-weight: 400; + line-height: 1.4; +} + +.sidebar-content ul ul summary:hover .large { + color: var(--sqc-fg); +} + +.sidebar-content ul ul summary .caret { + width: 1rem; + height: 1rem; + color: var(--sqc-faint); +} + +.sidebar-content ul ul ul { + margin-inline-start: 0.75rem; +} + +.sidebar-content a { + padding: 0.3rem 0.5rem; + border-radius: 2px; + font-size: 14px; + line-height: 1.4; + color: var(--sqc-muted); +} + +.sidebar-content a:hover { + background: var(--sqc-accent-soft); + color: var(--sqc-fg); +} + +.sidebar-content a[aria-current='page'], +.sidebar-content a[aria-current='page']:hover { + background: var(--sqc-accent-soft); + color: var(--sqc-primary); + font-weight: 500; +} + +/* Page title and on-this-page */ +.content-panel { + border-top-color: var(--sqc-line); +} + +h1#_top { + font-weight: 650; + letter-spacing: -0.02em; +} + +.sl-markdown-content :is(h2, h3, h4) { + font-weight: 620; + letter-spacing: -0.01em; + color: var(--sqc-fg); +} + +.sl-markdown-content .sl-heading-wrapper.level-h2 { + margin-top: 2.75rem; +} + +/* Title and body share one panel, with no dividing line. */ +.content-panel + .content-panel { + border-top: 0; + padding-top: 0; +} + +.content-panel:first-of-type { + padding-bottom: 2.25rem; +} + +.sl-markdown-content p, +.sl-markdown-content li { + color: var(--sqc-muted); +} + +.sl-markdown-content strong { + color: var(--sqc-fg); + font-weight: 600; +} + +starlight-toc h2 { + margin-bottom: 0.5rem; + color: var(--sqc-fg); + font-size: 13px; + font-weight: 600; +} + +starlight-toc li:has(> a[href='#_top']) { + display: none; +} + +starlight-toc a, +mobile-starlight-toc a { + padding-block: 0.3rem; + font-size: 13.5px; + line-height: 1.4; + color: var(--sqc-muted); +} + +starlight-toc a:hover { + color: var(--sqc-fg); +} + +starlight-toc a[aria-current='true'], +starlight-toc a[aria-current='true']:hover { + color: var(--sqc-primary); + font-weight: 500; +} + +/* Links */ +.sl-markdown-content a:not(.sqc-card) { + color: var(--sqc-fg); + font-weight: 500; + text-decoration: underline; + text-decoration-color: var(--sqc-primary); + text-decoration-thickness: 1px; + text-underline-offset: 3px; +} + +.sl-markdown-content a:not(.sqc-card):hover { + color: var(--sqc-primary); + text-decoration-thickness: 2px; +} + +/* Code blocks: a quiet copy icon that is always visible, as in common docs sites. */ +.expressive-code .copy button { + opacity: 0.6 !important; +} + +.expressive-code .copy button:hover { + opacity: 1 !important; +} + +/* Inline code */ +.sl-markdown-content :not(pre) > code { + border: 0; + border-radius: 2px; + padding: 0.1em 0.35em; + font-size: 0.86em; + color: var(--sqc-fg); +} + +/* Tables */ +.sl-markdown-content table { + display: table; + width: 100%; + border-collapse: collapse; + font-size: 14px; + line-height: 1.5; +} + +@media (max-width: 50rem) { + .sl-markdown-content table { + display: block; + overflow-x: auto; + } +} + +.sl-markdown-content :is(th, td) { + border: 0; + border-bottom: 1px solid var(--sqc-line); + padding: 0.55rem 0.9rem 0.55rem 0; + vertical-align: top; +} + +.sl-markdown-content th { + background: transparent; + color: var(--sqc-fg); + font-weight: 600; + white-space: nowrap; + border-bottom-color: var(--sqc-line-strong); +} + +.sl-markdown-content tr:nth-child(2n) { + background: transparent; +} + +.sl-markdown-content td { + color: var(--sqc-muted); +} + +.sl-markdown-content td:first-child code { + white-space: nowrap; +} + +/* Search */ +site-search { + --pagefind-ui-border-radius: 2px; +} + +site-search dialog { + border-radius: 2px; +} + +/* Callouts */ +.sqc-callout { + display: flex; + gap: 12px; + padding: 14px 16px; + border: 1px solid color-mix(in srgb, var(--sqc-callout) 30%, transparent); + border-radius: 2px; + background: color-mix(in srgb, var(--sqc-callout) 8%, transparent); + font-size: 14px; + line-height: 1.6; +} + +.sqc-callout.note { + --sqc-callout: var(--sqc-primary); +} + +.sqc-callout.warning { + --sqc-callout: var(--sqc-warning); +} + +.sl-markdown-content .sqc-callout-icon { + width: 16px; + height: 16px; +} + +.sl-markdown-content .sqc-callout > .sqc-callout-body { + margin: 0; +} + +.sqc-callout-icon { + flex: none; + margin-top: 4px; + color: var(--sqc-callout); +} + +.sl-markdown-content .sqc-callout-body > * { + margin: 0; + color: var(--sqc-fg); +} + +.sl-markdown-content .sqc-callout-body > * + * { + margin-top: 0.6rem; +} + +/* Docs components: Card, CardGroup, Frame, Note and Warning */ +.sqc-card-group { + display: grid; + grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr)); + gap: 12px; + margin-top: 1.25rem; +} + +@media (max-width: 50rem) { + .sqc-card-group { + grid-template-columns: 1fr; + } +} + +.sl-markdown-content .sqc-card { + display: flex; + flex-direction: column; + margin: 0; + padding: 18px 20px; + border: 1px solid var(--sqc-line-strong); + border-radius: 2px; + background: var(--sqc-panel); + color: var(--sqc-muted); + text-decoration: none; + font-size: 14px; + line-height: 1.5; +} + +.sl-markdown-content a.sqc-card:hover { + border-color: var(--sqc-primary); +} + +.sqc-card-icon { + margin-bottom: 12px; + color: var(--sqc-primary); +} + +.sqc-card-title { + color: var(--sqc-fg); + font-weight: 600; + font-size: 15px; +} + +.sqc-card-body { + margin-top: 4px; +} + +.sqc-card-body > * { + margin: 0; +} + +.sqc-frame { + margin: 1rem 0; + padding: 0; + border: 1px solid var(--sqc-line); +} + +.sqc-frame img { + display: block; + width: 100%; +} + +/* Mobile controls */ +.sl-menu-button { + border: 1px solid var(--sqc-line-strong) !important; + border-radius: 2px !important; + background: var(--sqc-bg) !important; + color: var(--sqc-muted) !important; + box-shadow: none !important; +} + +mobile-starlight-toc summary .toggle { + border-radius: 2px; + border-color: var(--sqc-line-strong); +}