diff --git a/.github/workflows/build-version.yml b/.github/workflows/build-version.yml index 86ec3a7..4c6aff9 100644 --- a/.github/workflows/build-version.yml +++ b/.github/workflows/build-version.yml @@ -17,6 +17,7 @@ permissions: contents: read env: + DATAFLOW_IMAGE: ghcr.io/${{ github.repository }}/dataflow RUNTIME_IMAGE: ghcr.io/${{ github.repository }}/database-frontend CLUSTER_IMAGE: ghcr.io/${{ github.repository }}/database-cluster SEALOS_VERSION: v5.1.2-rc5 @@ -58,6 +59,12 @@ jobs: helm lint charts/database-frontend helm template dbprovider-frontend charts/database-frontend --namespace dbprovider-frontend > /dev/null python3 ../../../tests/test_deployment_compatibility.py + - name: Validate DataFlow and combined installer + run: | + python3 scripts/test-bundle.py + bash -n deploy/install.sh scripts/prepare-bundle.sh dataflow/deploy/install.sh + helm lint dataflow/deploy/charts/dataflow -f dataflow/deploy/charts/dataflow/dataflow-values.yaml + helm template dataflow dataflow/deploy/charts/dataflow --namespace dataflow-system -f dataflow/deploy/charts/dataflow/dataflow-values.yaml > /dev/null runtime: needs: metadata @@ -96,6 +103,19 @@ jobs: tags: ${{ env.RUNTIME_IMAGE }}:${{ needs.metadata.outputs.tag }}-${{ matrix.arch }} cache-from: type=gha,scope=database-${{ inputs.source }}-${{ matrix.arch }} cache-to: type=gha,scope=database-${{ inputs.source }}-${{ matrix.arch }},mode=max + - name: Build DataFlow runtime (${{ matrix.arch }}) + uses: docker/build-push-action@v6 + with: + context: ./dataflow + file: ./dataflow/core/Dockerfile + build-args: | + VERSION=${{ needs.metadata.outputs.tag }} + PLATFORM=sealos + platforms: linux/${{ matrix.arch }} + push: ${{ inputs.publish }} + tags: ${{ env.DATAFLOW_IMAGE }}:${{ needs.metadata.outputs.tag }}-${{ matrix.arch }} + cache-from: type=gha,scope=dataflow-${{ matrix.arch }} + cache-to: type=gha,scope=dataflow-${{ matrix.arch }},mode=max runtime-release: needs: [metadata, runtime] @@ -118,6 +138,9 @@ jobs: set -euo pipefail docker buildx imagetools create -t "${RUNTIME_IMAGE}:${TAG}" \ "${RUNTIME_IMAGE}:${TAG}-amd64" "${RUNTIME_IMAGE}:${TAG}-arm64" + docker buildx imagetools create -t "${DATAFLOW_IMAGE}:${TAG}" \ + "${DATAFLOW_IMAGE}:${TAG}-amd64" "${DATAFLOW_IMAGE}:${TAG}-arm64" + docker buildx imagetools inspect "${DATAFLOW_IMAGE}:${TAG}" docker buildx imagetools inspect "${RUNTIME_IMAGE}:${TAG}" cluster: @@ -155,15 +178,15 @@ jobs: GH_USER: ${{ github.actor }} run: sudo sealos login -u "$GH_USER" -p "$GH_TOKEN" ghcr.io - name: Build, push and archive cluster image - working-directory: ${{ inputs.source }}/database/deploy env: TAG: ${{ needs.metadata.outputs.tag }} ARCH: ${{ matrix.arch }} + SOURCE: ${{ inputs.source }} run: | set -euo pipefail - export RUNTIME_REF="${RUNTIME_IMAGE}:${TAG}" - # Change only the chart's runtime image, preserving all other defaults. - ruby -ryaml -e 'p = "charts/database-frontend/values.yaml"; d = YAML.load_file(p); d["image"] = ENV.fetch("RUNTIME_REF"); File.write(p, YAML.dump(d))' + bundle_dir="${RUNNER_TEMP}/database-bundle-${SOURCE}-${ARCH}" + bash scripts/prepare-bundle.sh "$SOURCE" "$bundle_dir" "$RUNTIME_IMAGE" "$DATAFLOW_IMAGE" "$TAG" + cd "$bundle_dir" sudo sealos registry save --registry-dir=registry --arch "$ARCH" . ref="${CLUSTER_IMAGE}:${TAG}-${ARCH}" sudo sealos build -t "$ref" --platform "linux/${ARCH}" -f Kubefile . @@ -215,6 +238,7 @@ jobs: SOURCE: ${{ inputs.source }} run: | set -euo pipefail + docker buildx imagetools create -t "${DATAFLOW_IMAGE}:${SOURCE}" "${DATAFLOW_IMAGE}:${TAG}" docker buildx imagetools create -t "${RUNTIME_IMAGE}:${SOURCE}" "${RUNTIME_IMAGE}:${TAG}" docker buildx imagetools create -t "${CLUSTER_IMAGE}:${SOURCE}" "${CLUSTER_IMAGE}:${TAG}" - uses: actions/download-artifact@v4 diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..2f5151b --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,29 @@ +# Public upstream test TLS fixtures and the client-side PostHog ingestion key. +# Verified byte-for-byte against sealos-apps/dataflow commit +# a6fbbf53906d2ac9f4a0817af7f50030f90c86d3; no operational credentials. +# Exact file/rule/line fingerprints keep other findings visible. +dataflow/core/src/env/env.go:generic-api-key:92 +dataflow/core/src/plugins/ssl/tls_test.go:private-key:58 +dataflow/dev/certs/ca/clickhouse/ca-key.pem:private-key:1 +dataflow/dev/certs/ca/elasticsearch/ca-key.pem:private-key:1 +dataflow/dev/certs/ca/mariadb/ca-key.pem:private-key:1 +dataflow/dev/certs/ca/mysql/ca-key.pem:private-key:1 +dataflow/dev/certs/ca/mongodb/ca-key.pem:private-key:1 +dataflow/dev/certs/ca/postgres/ca-key.pem:private-key:1 +dataflow/dev/certs/ca/redis/ca-key.pem:private-key:1 +dataflow/dev/certs/client/clickhouse/client-key.pem:private-key:1 +dataflow/dev/certs/client/elasticsearch/client-key.pem:private-key:1 +dataflow/dev/certs/client/mariadb/client-key.pem:private-key:1 +dataflow/dev/certs/client/mysql/client-key.pem:private-key:1 +dataflow/dev/certs/client/postgres/client-key.pem:private-key:1 +dataflow/dev/certs/client/mongodb/client-key.pem:private-key:1 +dataflow/dev/certs/server/clickhouse/server-key.pem:private-key:1 +dataflow/dev/certs/client/redis/client-key.pem:private-key:1 +dataflow/dev/certs/server/elasticsearch/server-key.pem:private-key:1 +dataflow/dev/certs/server/mariadb/server-key.pem:private-key:1 +dataflow/dev/certs/server/mongodb/mongodb.pem:private-key:25 +dataflow/dev/certs/server/redis/server-key.pem:private-key:1 +dataflow/dev/certs/server/mysql/server-key.pem:private-key:1 +dataflow/dev/certs/server/postgres/server-key.pem:private-key:1 +dataflow/dev/certs/server/redis/redis.pem:private-key:25 +dataflow/dev/certs/server/mongodb/server-key.pem:private-key:1 diff --git a/README.md b/README.md index 98b9605..dbf5e47 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,11 @@ source directories, not the branch that triggers CI. | Push a `v*` tag | Both workspaces | Yes | Yes | | Manual run | Both workspaces | `publish_images` or `upload_oss` | `upload_oss` | +DataFlow is imported once under `dataflow/` (provenance in +[dataflow/IMPORT.md](dataflow/IMPORT.md)). Both source channels build the same +DataFlow source alongside their respective Database frontend. Their cluster +images install both services using [deploy/install.sh](deploy/install.sh). + Every runtime image and cluster image supports `linux/amd64` and `linux/arm64`. Runtime builds use each workspace's root Dockerfile with `name=database` and `path=database`. Cluster images include the Helm chart, entrypoint @@ -21,6 +26,7 @@ and the runtime image saved by Sealos for the selected architecture. Images are published to: - `ghcr.io/sealos-apps/database/database-frontend:` +- `ghcr.io/sealos-apps/database/dataflow:` - `ghcr.io/sealos-apps/database/database-cluster:` Tags are `-` for branch/manual builds and @@ -191,3 +197,72 @@ a cluster or write database data: node --test tests/test_metrics_sdk.cjs python3 tests/test_deployment_compatibility.py ``` + +## Combined Database + DataFlow installation + +The cluster package stages the selected Database chart and the shared DataFlow +chart under its root `charts/` directory so Sealos can discover both runtime +images. The installer subdirectories link to this shared chart directory. Runtime references are pinned to the same source/version tag. Install +keeps two independent Helm releases and Deployments: + +| Component | Default release | Default namespace | Persisted user values | +| --- | --- | --- | --- | +| Database | `dbprovider-frontend` | `dbprovider-frontend` | main: `/root/.sealos/cloud/values/core/dbprovider-frontend-values.yaml`; release-v5.1: `/root/.sealos/cloud/values/apps/database/dbprovider-values.yaml` | +| DataFlow | `dataflow` | `dataflow-system` | `/root/.sealos/cloud/values/apps/dataflow/dataflow-values.yaml` | + +Database retains its existing `RELEASE_NAME`, `RELEASE_NAMESPACE`, `HELM_OPTS` +and `HELM_OPTIONS` controls. DataFlow uses `DATAFLOW_RELEASE_NAME`, +`DATAFLOW_NAMESPACE`, `DATAFLOW_HELM_OPTS` and optionally +`DATAFLOW_USER_VALUES_DIR`. Both use the Sealos cloud configuration; a +`SEALOS_CLOUD_DOMAIN` override applies to both. DataFlow installs first. A +failure stops installation before Database is upgraded. The two Helm upgrades +are not a transaction: if Database fails afterward, DataFlow remains upgraded. + +See [deploy/env.template](deploy/env.template) for defaults, purposes and the +shell environment override method; the installer does not load it automatically. + +DataFlow keeps the hidden `app-system/dataflow` Desktop App and internal +`dataflow.sealos.io/health-url` annotation. Existing Database “Manage data” +buttons continue opening `system-dataflow` with resource name, database type, +language and theme. No React dependency merge or new cross-service API is needed. + +### Upgrading an existing installation + +Use the existing DataFlow release and namespace, and preserve the user values +file. If that file is absent but the release exists, the installer recovers +its effective Helm values before upgrading. This preserves external metadata +DSNs, custom resource names, storage settings and custom runtime Secrets. +The installer resolves the configured runtime Secret from the chart and reuses +its session encryption key; it stops if an existing release's key or values +cannot be read. Fresh installations generate a key once. Recovered files use +restricted permissions because Helm values may contain credentials. + +The combined package supplies versioned image pins even when saved values +contain older image references. `DATAFLOW_HELM_OPTS` remains an explicit override +for DataFlow; Database always uses the bundled runtime image. Existing custom +DataFlow App names must match Database's `app-system/dataflow` discovery contract. + +Keep the DataFlow release, PostgreSQL resource/PVC and Secret in place. Do not +uninstall the old DataFlow release: it is now upgraded by the combined package, +not replaced by a new release. Existing charts can delete their metadata cluster +on uninstall. No SQL/data-copy migration is performed by this source migration. +Rolling back requires rolling back the two Helm releases separately, with the +same storage and encryption key; application schema compatibility must be checked +before downgrading DataFlow. + +### Local validation + +```bash +python3 scripts/test-bundle.py +bash scripts/prepare-bundle.sh main /tmp/database-bundle \ + ghcr.io/sealos-apps/database/database-frontend \ + ghcr.io/sealos-apps/database/dataflow main- +``` + +The output directory must not exist. The tests use fake Helm/kubectl for upgrade +checks and real Helm rendering for both source packages; they never access a +cluster or write to a database. CI runs these checks and builds both runtime +architectures. Actual installation may initialize/upgrade DataFlow metadata; +local source/build checks do not prove live storage, authentication or browser +acceptance. Live deployment requires a chosen cluster and a separately authorized +metadata modification if initialization or schema migration is needed. diff --git a/dataflow/.codegraph/.gitignore b/dataflow/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/dataflow/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/dataflow/.dockerignore b/dataflow/.dockerignore new file mode 100644 index 0000000..477d88e --- /dev/null +++ b/dataflow/.dockerignore @@ -0,0 +1,16 @@ +# Ignore everything by default +* + +# Core Dockerfile needs: dataflow/ and core/ +!dataflow/ +!dataflow/** +!core/ +!core/** + +# Exclude test/dev artifacts within allowed directories +**/node_modules +dataflow/build +dataflow/docs/superpowers +dataflow/public/monaco-editor +dataflow/.env +core/tmp diff --git a/dataflow/.github/scripts/install.sh b/dataflow/.github/scripts/install.sh new file mode 100644 index 0000000..8d2d48c --- /dev/null +++ b/dataflow/.github/scripts/install.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +if command -v sealos >/dev/null 2>&1; then + sealos version + exit 0 +fi + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "${tmp_dir}"' EXIT + +arch="$(uname -m)" +case "${arch}" in + x86_64|amd64) + sealos_arch="amd64" + ;; + aarch64|arm64) + sealos_arch="arm64" + ;; + *) + echo "Unsupported architecture: ${arch}" >&2 + exit 1 + ;; +esac + +cd "${tmp_dir}" +until curl -sSfLo sealos.tar.gz "https://github.com/labring/sealos/releases/download/v5.1.0-beta3/sealos_5.1.0-beta3_linux_${sealos_arch}.tar.gz"; do + sleep 3 +done + +tar -zxf sealos.tar.gz sealos +chmod +x sealos +mv sealos /usr/bin/sealos +sealos version diff --git a/dataflow/.github/workflows/pr-docker-build.yml b/dataflow/.github/workflows/pr-docker-build.yml new file mode 100644 index 0000000..8f22f63 --- /dev/null +++ b/dataflow/.github/workflows/pr-docker-build.yml @@ -0,0 +1,139 @@ +name: PR Docker Build + +permissions: + contents: read + +concurrency: + group: pr-docker-${{ github.event.pull_request.number }} + cancel-in-progress: true + +on: + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + +env: + DOCKERFILE: core/Dockerfile + LOCAL_RUNTIME_IMAGE: dataflow-pr-runtime + LOCAL_SEALOS_IMAGE: dataflow-cluster-pr + LOCAL_REGISTRY: 127.0.0.1:5000/dataflow-pr + +jobs: + docker: + name: Build Docker and Sealos Image (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Generate image tag + id: meta + run: | + set -euo pipefail + SHORT_SHA="$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7)" + PR_TAG="pr-${{ github.event.pull_request.number }}-${SHORT_SHA}" + echo "short_sha=${SHORT_SHA}" >> "${GITHUB_OUTPUT}" + echo "pr_tag=${PR_TAG}" >> "${GITHUB_OUTPUT}" + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ env.DOCKERFILE }} + push: false + load: true + platforms: linux/${{ matrix.arch }} + tags: ${{ env.LOCAL_RUNTIME_IMAGE }}:${{ steps.meta.outputs.pr_tag }}-${{ matrix.arch }} + build-args: | + VERSION=${{ steps.meta.outputs.pr_tag }} + TARGETARCH=${{ matrix.arch }} + PLATFORM=pr + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.event.pull_request.head.sha }} + org.opencontainers.image.version=${{ steps.meta.outputs.pr_tag }} + org.opencontainers.image.architecture=${{ matrix.arch }} + runnumber=${{ github.run_id }} + cache-from: type=gha,scope=dataflow-pr-runtime-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=dataflow-pr-runtime-${{ matrix.arch }} + + - name: Start temporary local registry + run: | + set -euo pipefail + docker rm -f dataflow-pr-registry >/dev/null 2>&1 || true + docker run -d -p 5000:5000 --name dataflow-pr-registry registry:2 + + - name: Push runtime image to temporary local registry + run: | + set -euo pipefail + RUNTIME_TAG="${{ steps.meta.outputs.pr_tag }}" + RUNTIME_LOCAL_IMAGE="${LOCAL_RUNTIME_IMAGE}:${RUNTIME_TAG}-${{ matrix.arch }}" + REGISTRY_IMAGE="${LOCAL_REGISTRY}/runtime:${RUNTIME_TAG}" + docker tag "${RUNTIME_LOCAL_IMAGE}" "${REGISTRY_IMAGE}" + docker push "${REGISTRY_IMAGE}" + + - name: Install cache images tools + run: sudo bash ./.github/scripts/install.sh + + - name: Cache images for sealos + working-directory: deploy + run: | + set -euo pipefail + RUNTIME_TAG="${{ steps.meta.outputs.pr_tag }}" + sed -i "/^image:/,/^[^[:space:]]/ s#^\([[:space:]]*repository:[[:space:]]*\).*#\1${LOCAL_REGISTRY}/runtime#" charts/dataflow/values.yaml + sed -i "/^image:/,/^[^[:space:]]/ s#^\([[:space:]]*tag:[[:space:]]*\).*#\1${RUNTIME_TAG}#" charts/dataflow/values.yaml + sudo sealos registry save --registry-dir=registry_${{ matrix.arch }} --arch ${{ matrix.arch }} . + + - name: Build sealos image + uses: docker/build-push-action@v6 + with: + context: ./deploy + file: ./deploy/Kubefile + push: false + load: true + platforms: linux/${{ matrix.arch }} + tags: ${{ env.LOCAL_SEALOS_IMAGE }}:${{ steps.meta.outputs.pr_tag }}-${{ matrix.arch }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.event.pull_request.head.sha }} + org.opencontainers.image.version=${{ steps.meta.outputs.pr_tag }} + org.opencontainers.image.architecture=${{ matrix.arch }} + runnumber=${{ github.run_id }} + cache-from: type=gha,scope=dataflow-pr-sealos-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=dataflow-pr-sealos-${{ matrix.arch }} + + - name: Save sealos image tar + run: | + set -euo pipefail + mkdir -p artifacts + TAR_NAME="dataflow-cluster-pr-${{ github.event.pull_request.number }}-${{ steps.meta.outputs.short_sha }}-${{ matrix.arch }}.tar" + docker save "${LOCAL_SEALOS_IMAGE}:${{ steps.meta.outputs.pr_tag }}-${{ matrix.arch }}" -o "artifacts/${TAR_NAME}" + gzip -f "artifacts/${TAR_NAME}" + + - name: Upload sealos image tar + uses: actions/upload-artifact@v4 + with: + name: dataflow-cluster-pr-${{ github.event.pull_request.number }}-${{ steps.meta.outputs.short_sha }}-${{ matrix.arch }} + path: artifacts/*.tar.gz + if-no-files-found: error + retention-days: 14 + + - name: Cleanup temporary local registry + if: always() + run: docker rm -f dataflow-pr-registry >/dev/null 2>&1 || true diff --git a/dataflow/.github/workflows/release.yaml b/dataflow/.github/workflows/release.yaml new file mode 100644 index 0000000..eb91a90 --- /dev/null +++ b/dataflow/.github/workflows/release.yaml @@ -0,0 +1,412 @@ +name: Release + +on: + push: + branches: + - main + tags: + - 'v*.*.*' + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +env: + DOCKERFILE: core/Dockerfile + GHCR_RUNTIME_IMAGE: ghcr.io/${{ github.repository }}/dataflow + GHCR_SEALOS_IMAGE: ghcr.io/${{ github.repository }}/dataflow-cluster + +jobs: + meta: + name: Release Metadata + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + cluster_tag: ${{ steps.meta.outputs.cluster_tag }} + is_release: ${{ steps.meta.outputs.is_release }} + oss_prefix: ${{ steps.meta.outputs.oss_prefix }} + short_sha: ${{ steps.meta.outputs.short_sha }} + tag_name: ${{ steps.meta.outputs.tag_name }} + tag_with_sha: ${{ steps.meta.outputs.tag_with_sha }} + version: ${{ steps.meta.outputs.version }} + + steps: + - name: Compute image tags + id: meta + run: | + set -euo pipefail + SHORT_SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)" + + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + TAG_NAME="${GITHUB_REF_NAME}" + VERSION="${TAG_NAME}" + TAG_WITH_SHA="${TAG_NAME}-${SHORT_SHA}" + CLUSTER_TAG="${TAG_NAME}" + OSS_PREFIX="release/${CLUSTER_TAG}" + IS_RELEASE="true" + else + TAG_NAME="main" + VERSION="main-${SHORT_SHA}" + TAG_WITH_SHA="${VERSION}" + CLUSTER_TAG="${VERSION}" + OSS_PREFIX="ci/main/${SHORT_SHA}" + IS_RELEASE="false" + fi + + echo "cluster_tag=${CLUSTER_TAG}" >> "${GITHUB_OUTPUT}" + echo "is_release=${IS_RELEASE}" >> "${GITHUB_OUTPUT}" + echo "oss_prefix=${OSS_PREFIX}" >> "${GITHUB_OUTPUT}" + echo "short_sha=${SHORT_SHA}" >> "${GITHUB_OUTPUT}" + echo "tag_name=${TAG_NAME}" >> "${GITHUB_OUTPUT}" + echo "tag_with_sha=${TAG_WITH_SHA}" >> "${GITHUB_OUTPUT}" + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + + docker-build: + name: Build and Push Docker Image (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + needs: meta + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ env.DOCKERFILE }} + push: true + platforms: linux/${{ matrix.arch }} + tags: | + ${{ env.GHCR_RUNTIME_IMAGE }}:${{ needs.meta.outputs.tag_name }}-${{ matrix.arch }} + ${{ env.GHCR_RUNTIME_IMAGE }}:${{ needs.meta.outputs.tag_with_sha }}-${{ matrix.arch }} + build-args: | + VERSION=${{ needs.meta.outputs.version }} + TARGETARCH=${{ matrix.arch }} + PLATFORM=ghcr + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ needs.meta.outputs.version }} + org.opencontainers.image.architecture=${{ matrix.arch }} + runnumber=${{ github.run_id }} + cache-from: type=gha,scope=dataflow-docker-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=dataflow-docker-${{ matrix.arch }} + + docker-manifest: + name: Publish Docker Manifests + runs-on: ubuntu-latest + needs: + - meta + - docker-build + permissions: + packages: write + + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create multi-arch manifests + run: | + set -euo pipefail + TAG_NAME="${{ needs.meta.outputs.tag_name }}" + TAG_WITH_SHA="${{ needs.meta.outputs.tag_with_sha }}" + + docker buildx imagetools create \ + -t "${GHCR_RUNTIME_IMAGE}:${TAG_NAME}" \ + "${GHCR_RUNTIME_IMAGE}:${TAG_NAME}-amd64" \ + "${GHCR_RUNTIME_IMAGE}:${TAG_NAME}-arm64" + + docker buildx imagetools create \ + -t "${GHCR_RUNTIME_IMAGE}:${TAG_WITH_SHA}" \ + "${GHCR_RUNTIME_IMAGE}:${TAG_WITH_SHA}-amd64" \ + "${GHCR_RUNTIME_IMAGE}:${TAG_WITH_SHA}-arm64" + + - name: Update latest tag + if: ${{ needs.meta.outputs.is_release == 'true' }} + run: | + set -euo pipefail + TAG_NAME="${{ needs.meta.outputs.tag_name }}" + + docker buildx imagetools create \ + -t "${GHCR_RUNTIME_IMAGE}:latest" \ + "${GHCR_RUNTIME_IMAGE}:${TAG_NAME}-amd64" \ + "${GHCR_RUNTIME_IMAGE}:${TAG_NAME}-arm64" + + - name: Inspect multi-arch manifests + run: | + set -euo pipefail + TAG_NAME="${{ needs.meta.outputs.tag_name }}" + TAG_WITH_SHA="${{ needs.meta.outputs.tag_with_sha }}" + + docker buildx imagetools inspect "${GHCR_RUNTIME_IMAGE}:${TAG_NAME}" + docker buildx imagetools inspect "${GHCR_RUNTIME_IMAGE}:${TAG_WITH_SHA}" + + if [ "${{ needs.meta.outputs.is_release }}" = "true" ]; then + docker buildx imagetools inspect "${GHCR_RUNTIME_IMAGE}:latest" + fi + + sealos-build: + name: Build and Push Sealos Image (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + needs: + - meta + - docker-manifest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install cache images tools + run: sudo bash ./.github/scripts/install.sh + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Cache images for sealos + working-directory: deploy + run: | + set -euo pipefail + TAG_WITH_SHA="${{ needs.meta.outputs.tag_with_sha }}" + sudo sealos login -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}" ghcr.io + sed -i "/^image:/,/^[^[:space:]]/ s#^\([[:space:]]*repository:[[:space:]]*\).*#\1${GHCR_RUNTIME_IMAGE}#" charts/dataflow/values.yaml + sed -i "/^image:/,/^[^[:space:]]/ s#^\([[:space:]]*tag:[[:space:]]*\).*#\1${TAG_WITH_SHA}#" charts/dataflow/values.yaml + sudo sealos registry save --registry-dir=registry_${{ matrix.arch }} --arch ${{ matrix.arch }} . + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push sealos image + uses: docker/build-push-action@v6 + with: + context: ./deploy + file: ./deploy/Kubefile + push: true + platforms: linux/${{ matrix.arch }} + tags: | + ${{ env.GHCR_SEALOS_IMAGE }}:${{ needs.meta.outputs.tag_name }}-${{ matrix.arch }} + ${{ env.GHCR_SEALOS_IMAGE }}:${{ needs.meta.outputs.tag_with_sha }}-${{ matrix.arch }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ needs.meta.outputs.version }} + org.opencontainers.image.architecture=${{ matrix.arch }} + runnumber=${{ github.run_id }} + cache-from: type=gha,scope=dataflow-sealos-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=dataflow-sealos-${{ matrix.arch }} + + - name: Save sealos image package + run: | + set -euo pipefail + CLUSTER_TAG="${{ needs.meta.outputs.cluster_tag }}" + TAG_WITH_SHA="${{ needs.meta.outputs.tag_with_sha }}" + docker pull "${GHCR_SEALOS_IMAGE}:${TAG_WITH_SHA}-${{ matrix.arch }}" + mkdir -p release-assets + docker save "${GHCR_SEALOS_IMAGE}:${TAG_WITH_SHA}-${{ matrix.arch }}" | gzip > "release-assets/dataflow-cluster-${CLUSTER_TAG}-${{ matrix.arch }}.tar.gz" + + - name: Upload sealos image package + uses: actions/upload-artifact@v4 + with: + name: sealos-image-${{ needs.meta.outputs.cluster_tag }}-${{ matrix.arch }} + path: release-assets/dataflow-cluster-${{ needs.meta.outputs.cluster_tag }}-${{ matrix.arch }}.tar.gz + if-no-files-found: error + retention-days: 14 + + sealos-manifest: + name: Publish Sealos Manifests + runs-on: ubuntu-latest + needs: + - meta + - sealos-build + permissions: + packages: write + + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create multi-arch manifests + run: | + set -euo pipefail + TAG_NAME="${{ needs.meta.outputs.tag_name }}" + TAG_WITH_SHA="${{ needs.meta.outputs.tag_with_sha }}" + + docker buildx imagetools create \ + -t "${GHCR_SEALOS_IMAGE}:${TAG_NAME}" \ + "${GHCR_SEALOS_IMAGE}:${TAG_NAME}-amd64" \ + "${GHCR_SEALOS_IMAGE}:${TAG_NAME}-arm64" + + docker buildx imagetools create \ + -t "${GHCR_SEALOS_IMAGE}:${TAG_WITH_SHA}" \ + "${GHCR_SEALOS_IMAGE}:${TAG_WITH_SHA}-amd64" \ + "${GHCR_SEALOS_IMAGE}:${TAG_WITH_SHA}-arm64" + + - name: Update latest tag + if: ${{ needs.meta.outputs.is_release == 'true' }} + run: | + set -euo pipefail + TAG_NAME="${{ needs.meta.outputs.tag_name }}" + + docker buildx imagetools create \ + -t "${GHCR_SEALOS_IMAGE}:latest" \ + "${GHCR_SEALOS_IMAGE}:${TAG_NAME}-amd64" \ + "${GHCR_SEALOS_IMAGE}:${TAG_NAME}-arm64" + + - name: Inspect multi-arch manifests + run: | + set -euo pipefail + TAG_NAME="${{ needs.meta.outputs.tag_name }}" + TAG_WITH_SHA="${{ needs.meta.outputs.tag_with_sha }}" + + docker buildx imagetools inspect "${GHCR_SEALOS_IMAGE}:${TAG_NAME}" + docker buildx imagetools inspect "${GHCR_SEALOS_IMAGE}:${TAG_WITH_SHA}" + + if [ "${{ needs.meta.outputs.is_release }}" = "true" ]; then + docker buildx imagetools inspect "${GHCR_SEALOS_IMAGE}:latest" + fi + + sync-image-aliyun: + name: Sync Sealos Image to OSS (${{ matrix.arch }}) + runs-on: ubuntu-latest + needs: + - meta + - sealos-manifest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + arch: + - amd64 + - arm64 + + steps: + - name: Download sealos packages + uses: actions/download-artifact@v4 + with: + path: release-assets + pattern: sealos-image-${{ needs.meta.outputs.cluster_tag }}-${{ matrix.arch }} + merge-multiple: true + + - name: Install oss tools + run: | + set -euo pipefail + sudo -v + curl https://gosspublic.alicdn.com/ossutil/install.sh | sudo bash + + - name: Sync to OSS + run: | + set -euo pipefail + TAR_NAME="release-assets/dataflow-cluster-${{ needs.meta.outputs.cluster_tag }}-${{ matrix.arch }}.tar.gz" + md5sum "${TAR_NAME}" > "${TAR_NAME}.md5" + OSS_TAR_NAME="dataflow-cluster-${{ needs.meta.outputs.cluster_tag }}-${{ matrix.arch }}.tar.gz" + + ossutil64 cp -f -e "${{ secrets.OSS_ENDPOINT }}" \ + -i "${{ secrets.OSS_ACCESS_KEY_ID }}" \ + -k "${{ secrets.OSS_ACCESS_KEY_SECRET }}" \ + "${TAR_NAME}" \ + "oss://${{ vars.OSS_BUCKET }}/${{ needs.meta.outputs.oss_prefix }}/${OSS_TAR_NAME}" + + ossutil64 cp -f -e "${{ secrets.OSS_ENDPOINT }}" \ + -i "${{ secrets.OSS_ACCESS_KEY_ID }}" \ + -k "${{ secrets.OSS_ACCESS_KEY_SECRET }}" \ + "${TAR_NAME}.md5" \ + "oss://${{ vars.OSS_BUCKET }}/${{ needs.meta.outputs.oss_prefix }}/${OSS_TAR_NAME}.md5" + + release: + name: Publish GitHub Release + runs-on: ubuntu-latest + needs: + - meta + - docker-manifest + - sealos-build + - sealos-manifest + if: ${{ needs.meta.outputs.is_release == 'true' }} + permissions: + contents: write + + steps: + - name: Download sealos packages + uses: actions/download-artifact@v4 + with: + path: release-assets + pattern: sealos-image-${{ needs.meta.outputs.tag_name }}-* + merge-multiple: true + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.meta.outputs.version }} + name: ${{ needs.meta.outputs.version }} + draft: false + prerelease: false + generate_release_notes: true + body: | + Runtime images: + - `${{ env.GHCR_RUNTIME_IMAGE }}:${{ needs.meta.outputs.version }}` + - `${{ env.GHCR_RUNTIME_IMAGE }}:${{ needs.meta.outputs.tag_with_sha }}` + - `${{ env.GHCR_RUNTIME_IMAGE }}:latest` + + Sealos cluster images: + - `${{ env.GHCR_SEALOS_IMAGE }}:${{ needs.meta.outputs.version }}` + - `${{ env.GHCR_SEALOS_IMAGE }}:${{ needs.meta.outputs.tag_with_sha }}` + - `${{ env.GHCR_SEALOS_IMAGE }}:latest` + files: | + release-assets/dataflow-cluster-${{ needs.meta.outputs.version }}-amd64.tar.gz + release-assets/dataflow-cluster-${{ needs.meta.outputs.version }}-arm64.tar.gz + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/dataflow/.gitignore b/dataflow/.gitignore new file mode 100644 index 0000000..17000cf --- /dev/null +++ b/dataflow/.gitignore @@ -0,0 +1,55 @@ +/.idea +*.swp +*.swo +*~ +DS_Store +.DS_Store +Thumbs.db +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +vendor/ +__debug* +*.db +/dev/elasticsearch +tmp/ +core/tmp/ +*node_modules* +worktree/ +.gemini/ +gha-creds-*.json +cypress-results/ +*.bak +test-cert.* +.gocache +.claude/skills +.claude/agents +.mcp.json +.serena +tools/ +.playwright-mcp +dev_docs/ +dataflow/.next +dataflow/build/ +*.env.local +docs/superpowers/ +dataflow/docs/superpowers/ + +# DataFlow derived files +dataflow/public/monaco-editor/ + +dataflow/.env + +.agents/ +.worktrees/ + +.claude/ + +.github/skills/ +# Keep the upstream tracked Go tool declaration visible during import. +!core/tools/ +!core/tools/tools.go diff --git a/dataflow/.impeccable/live/config.json b/dataflow/.impeccable/live/config.json new file mode 100644 index 0000000..5c91534 --- /dev/null +++ b/dataflow/.impeccable/live/config.json @@ -0,0 +1,6 @@ +{ + "files": ["dataflow/index.html"], + "insertBefore": "", + "commentSyntax": "html", + "cspChecked": true +} diff --git a/dataflow/AGENTS.md b/dataflow/AGENTS.md new file mode 100644 index 0000000..4851a28 --- /dev/null +++ b/dataflow/AGENTS.md @@ -0,0 +1,114 @@ +# WhoDB Development Guide + +WhoDB is a database management tool with a Go backend (`/core`) and a React frontend (`/dataflow`). + +## Non-Negotiable Rules + +1. **Analyze before coding** - Read relevant files and understand patterns before writing code. Always check to see if something existing was done, or if an existing pattern can be reused or adapted. +2. **GraphQL-first** - All new API functionality via GraphQL. Never add HTTP resolvers unless explicitly needed (e.g., file downloads) +3. **No SQL injection** - Never use `fmt.Sprintf` with user input for SQL. Use parameterized queries or GORM builders. See `.Codex/docs/sql-security.md` +4. **Plugin architecture** - Never use `switch dbType` or `if dbType ==` in shared code. All database-specific logic goes in plugins. See `.Codex/docs/plugin-architecture.md` +5. **Documentation requirements** - All exported Go functions/types need doc comments. All exported TypeScript functions/components need JSDoc. See `.Codex/docs/documentation.md` +7. **Localization requirements** - All user-facing strings must use `t()` with YAML keys. No fallback strings. No hardcoded UI text. See `.Codex/docs/localization.md` +8. **Verify before completing** - After finishing any task, verify: (1) `cd dataflow && pnpm run typecheck && pnpm run build`, (2) `cd core && go build ./...`, (3) no linting errors, (4) all added code is actually used (no dead code). See `.Codex/docs/verification.md` +9. **Fallback clarification** - Do not include fallback logic UNLESS you were asked to. If you think the project could benefit from fallback logic, first ask and clarify +10. **Show proof** - When making a claim about how something outside of our codebase works, for example a 3rd party library or function, always provide official documentation or the actual code to back that up. Check online if you have to. +11. **No defensive code** - Do not program defensively. If there is an edge or use case that you think needs to be handled, first ask. + +## Project Structure + +``` +core/ # CE backend (Go) + server.go # Entry point (func main) + src/src.go # Engine initialization, plugin registration + src/engine/plugin.go # PluginFunctions interface + src/env/ # Environment variable declarations (pure, no log dependency) + src/envconfig/ # Config-loading functions that need both env and log + src/plugins/ # Database connectors (each implements PluginFunctions) + graph/schema.graphqls # GraphQL schema + graph/*.resolvers.go # GraphQL resolvers + +dataflow/ # DataFlow analysis platform (React 19/TypeScript/Vite) + src/main.tsx # Entry point (@/* alias maps to src/) + src/stores/ # Zustand state (auth, connection, tab, layout, analysis*) + src/config/ # Apollo client, auth headers, sealos runtime config + src/graphql/ # .graphql operation files (queries/, mutations/) + src/generated/ # GraphQL codegen output (graphql.tsx) + src/i18n/ # I18nProvider + locales/{en,zh} + src/components/ # layout, sidebar, dashboard-sidebar, database (sql/mongodb/redis/shared), + # analysis (chart-create, editor), editor (Monaco/SQL), ui (shadcn-style) + src/utils/ # database-export, ddl-sql, mongodb-shell, sql-split, search-parser + src/lib/utils.ts # cn() — clsx + tailwind-merge + src/test/ # Vitest tests + renderWithI18n helper + docs/PRD.md # Product requirements + +dev/ # Docker compose, test scripts, sample data +docs/ # Integration plan and analysis +``` + +Additional docs: `.Codex/docs/testing.md` (testing). + +## Testing + +See `.Codex/docs/testing.md` for comprehensive testing documentation including: +- DataFlow type/build/test checks +- Docker container setup for test databases +- Go backend unit and integration tests + +Quick reference: +```bash +# DataFlow checks +cd dataflow && pnpm run typecheck +cd dataflow && pnpm run build +cd dataflow && pnpm run test + +# Backend Go tests +bash dev/run-backend-tests.sh all # Unit + integration +``` + +## When Working on Backend (Go) + +- Use `any` instead of `interface{}` (Go 1.18+) +- Use `plugins.WithConnection()` for all database operations - handles connection lifecycle +- SQL plugins should extend `GormPlugin` base class (`core/src/plugins/gorm/plugin.go`) +- When adding plugin functionality: add to `PluginFunctions` interface, implement in each plugin +- Use `ErrorHandler` (`core/src/plugins/gorm/errors.go`) for user-friendly error messages +- Never log sensitive data (passwords, API keys, tokens, connection strings) +- `env` package is for pure env var declarations only (no `log` import). Functions that parse env vars and need `log` for error reporting go in `envconfig` +- Delete build binaries after testing (`go build` artifacts) + +## When Working on Frontend — DataFlow (`/dataflow`) + +- Use PNPM, not NPM. Use pnpx, not npx +- Vite SPA with React 19, TypeScript, Tailwind CSS 4 +- State management: Zustand (`dataflow/src/stores/`) +- Path alias: `@/*` maps to `src/` (configured in `vite.config.ts` and `tsconfig.json`) +- Styling: `cn()` utility (clsx + tailwind-merge), CSS variables in `src/globals.css` +- GraphQL codegen: define operations in `.graphql` files, run `pnpm run generate` +- Key libraries: Apollo Client, Monaco Editor, ECharts, react-grid-layout, xlsx, jszip + +## When Updating Dependencies + +Use `core/go.mod` as the reference point for dependency versions. + +## Commands Quick Reference + +See `.Codex/docs/commands.md` for full reference. + +```bash +# Backend: cd core && go run . +# Frontend: cd dataflow && pnpm dev +``` + +## Development Principles + +- Clean, readable code over clever code +- Only add what is required - no overengineering +- Do not modify existing functionality without justification +- Do not rename variables/files unless necessary +- Remove unused code - no leftovers +- Only comment edge cases and complex logic, not obvious code +- Ask questions to understand requirements fully +- Use subagents to accomplish tasks faster +- Maintain professional, neutral tone without excessive enthusiasm +- When you finish a task, go back and check your work. Check that it is correct and that it is not over-engineered diff --git a/dataflow/CLAUDE.md b/dataflow/CLAUDE.md new file mode 100644 index 0000000..641def6 --- /dev/null +++ b/dataflow/CLAUDE.md @@ -0,0 +1,114 @@ +# WhoDB Development Guide + +WhoDB is a database management tool with a Go backend (`/core`) and a React frontend (`/dataflow`). + +## Non-Negotiable Rules + +1. **Analyze before coding** - Read relevant files and understand patterns before writing code. Always check to see if something existing was done, or if an existing pattern can be reused or adapted. +2. **GraphQL-first** - All new API functionality via GraphQL. Never add HTTP resolvers unless explicitly needed (e.g., file downloads) +3. **No SQL injection** - Never use `fmt.Sprintf` with user input for SQL. Use parameterized queries or GORM builders. See `.claude/docs/sql-security.md` +4. **Plugin architecture** - Never use `switch dbType` or `if dbType ==` in shared code. All database-specific logic goes in plugins. See `.claude/docs/plugin-architecture.md` +5. **Documentation requirements** - All exported Go functions/types need doc comments. All exported TypeScript functions/components need JSDoc. See `.claude/docs/documentation.md` +7. **Localization requirements** - All user-facing strings must use `t()` with YAML keys. No fallback strings. No hardcoded UI text. See `.claude/docs/localization.md` +8. **Verify before completing** - After finishing any task, verify: (1) `cd dataflow && pnpm run typecheck && pnpm run build`, (2) `cd core && go build ./...`, (3) no linting errors, (4) all added code is actually used (no dead code). See `.claude/docs/verification.md` +9. **Fallback clarification** - Do not include fallback logic UNLESS you were asked to. If you think the project could benefit from fallback logic, first ask and clarify +10. **Show proof** - When making a claim about how something outside of our codebase works, for example a 3rd party library or function, always provide official documentation or the actual code to back that up. Check online if you have to. +11. **No defensive code** - Do not program defensively. If there is an edge or use case that you think needs to be handled, first ask. + +## Project Structure + +``` +core/ # CE backend (Go) + server.go # Entry point (func main) + src/src.go # Engine initialization, plugin registration + src/engine/plugin.go # PluginFunctions interface + src/env/ # Environment variable declarations (pure, no log dependency) + src/envconfig/ # Config-loading functions that need both env and log + src/plugins/ # Database connectors (each implements PluginFunctions) + graph/schema.graphqls # GraphQL schema + graph/*.resolvers.go # GraphQL resolvers + +dataflow/ # DataFlow analysis platform (React 19/TypeScript/Vite) + src/main.tsx # Entry point (@/* alias maps to src/) + src/stores/ # Zustand state (auth, connection, tab, layout, analysis*) + src/config/ # Apollo client, auth headers, sealos runtime config + src/graphql/ # .graphql operation files (queries/, mutations/) + src/generated/ # GraphQL codegen output (graphql.tsx) + src/i18n/ # I18nProvider + locales/{en,zh} + src/components/ # layout, sidebar, dashboard-sidebar, database (sql/mongodb/redis/shared), + # analysis (chart-create, editor), editor (Monaco/SQL), ui (shadcn-style) + src/utils/ # database-export, ddl-sql, mongodb-shell, sql-split, search-parser + src/lib/utils.ts # cn() — clsx + tailwind-merge + src/test/ # Vitest tests + renderWithI18n helper + docs/PRD.md # Product requirements + +dev/ # Docker compose, test scripts, sample data +docs/ # Integration plan and analysis +``` + +Additional docs: `.claude/docs/testing.md` (testing). + +## Testing + +See `.claude/docs/testing.md` for comprehensive testing documentation including: +- DataFlow type/build/test checks +- Docker container setup for test databases +- Go backend unit and integration tests + +Quick reference: +```bash +# DataFlow checks +cd dataflow && pnpm run typecheck +cd dataflow && pnpm run build +cd dataflow && pnpm run test + +# Backend Go tests +bash dev/run-backend-tests.sh all # Unit + integration +``` + +## When Working on Backend (Go) + +- Use `any` instead of `interface{}` (Go 1.18+) +- Use `plugins.WithConnection()` for all database operations - handles connection lifecycle +- SQL plugins should extend `GormPlugin` base class (`core/src/plugins/gorm/plugin.go`) +- When adding plugin functionality: add to `PluginFunctions` interface, implement in each plugin +- Use `ErrorHandler` (`core/src/plugins/gorm/errors.go`) for user-friendly error messages +- Never log sensitive data (passwords, API keys, tokens, connection strings) +- `env` package is for pure env var declarations only (no `log` import). Functions that parse env vars and need `log` for error reporting go in `envconfig` +- Delete build binaries after testing (`go build` artifacts) + +## When Working on Frontend — DataFlow (`/dataflow`) + +- Use PNPM, not NPM. Use pnpx, not npx +- Vite SPA with React 19, TypeScript, Tailwind CSS 4 +- State management: Zustand (`dataflow/src/stores/`) +- Path alias: `@/*` maps to `src/` (configured in `vite.config.ts` and `tsconfig.json`) +- Styling: `cn()` utility (clsx + tailwind-merge), CSS variables in `src/globals.css` +- GraphQL codegen: define operations in `.graphql` files, run `pnpm run generate` +- Key libraries: Apollo Client, Monaco Editor, ECharts, react-grid-layout, xlsx, jszip + +## When Updating Dependencies + +Use `core/go.mod` as the reference point for dependency versions. + +## Commands Quick Reference + +See `.claude/docs/commands.md` for full reference. + +```bash +# Backend: cd core && go run . +# Frontend: cd dataflow && pnpm dev +``` + +## Development Principles + +- Clean, readable code over clever code +- Only add what is required - no overengineering +- Do not modify existing functionality without justification +- Do not rename variables/files unless necessary +- Remove unused code - no leftovers +- Only comment edge cases and complex logic, not obvious code +- Ask questions to understand requirements fully +- Use subagents to accomplish tasks faster +- Maintain professional, neutral tone without excessive enthusiasm +- When you finish a task, go back and check your work. Check that it is correct and that it is not over-engineered diff --git a/dataflow/CONTEXT.md b/dataflow/CONTEXT.md new file mode 100644 index 0000000..cdd659d --- /dev/null +++ b/dataflow/CONTEXT.md @@ -0,0 +1,175 @@ +# WhoDB DataFlow Context + +DataFlow is the database workspace in WhoDB: users explore connected databases, run database commands, edit data, import or export data, and turn results into analysis dashboards. This context records stable product language only; feature acceptance details and implementation trade-offs belong in specs, tests, or ADRs. + +## Language + +**Database Workspace**: +The workspace area for browsing database resources, running commands, and editing data. +_Avoid_: connections tab, database page + +**Dashboard Workspace**: +The workspace area for creating and editing analysis dashboards. +_Avoid_: analysis tab, chart page + +**Database Connection**: +A configured access path to one database engine or database service. +_Avoid_: account, cluster, datasource + +**Database Instance**: +A platform-managed deployment of one database engine whose lifecycle is independent from the logical databases and schemas hosted inside it. +_Avoid_: database, logical database, Database Connection + +**Database Instance Identity**: +The lifecycle-bound identity of a **Database Instance**. Deleting an instance ends that identity; recreating an instance creates a new identity even when the same name is reused. +_Avoid_: resource name, display name, Database Connection name + +**Sealos Database Session**: +A time-bounded DataFlow authorization created when Sealos opens a **Database Instance**. It is bound to exactly one **Database Instance Identity**. +_Avoid_: Sealos Desktop session, browser session, Database Connection + +**Database Resource**: +A user-visible database object that can be located and acted on from the workspace. +_Avoid_: storage unit, file, asset + +**Workspace Tab**: +An open database exploration surface tied to a query or a **Database Resource**. +_Avoid_: browser tab, panel + +**SQL Table**: +A relational **Database Resource** made of rows and columns. +_Avoid_: dataset, spreadsheet + +**SQL View**: +A relational **Database Resource** whose rows are derived from a stored database query. +_Avoid_: table + +**MongoDB Collection**: +A MongoDB **Database Resource** made of documents that may not all share the same fields. +_Avoid_: MongoDB table + +**MongoDB Document**: +A single record inside a **MongoDB Collection**. +_Avoid_: row, JSON row + +**Collection Table View**: +A MongoDB collection view that presents documents in a grid while preserving MongoDB document terminology. +_Avoid_: MongoDB table + +**JSON View**: +A MongoDB collection view that shows each **MongoDB Document** as editable JSON. +_Avoid_: card-only view + +**Redis Key**: +A Redis **Database Resource** addressed by key name. +_Avoid_: table, collection + +**System Object**: +A **Database Resource** provisioned by the database engine, an installed extension, or platform tooling rather than authored by the user. +_Avoid_: internal table, hidden table + +**Database Export**: +A database operation where a user extracts database content from the active **Database Connection** into a downloadable format. +_Avoid_: backup when the export is not restorable, dump when only row data is exported + +**SQL Data Export**: +A **Database Export** where relational row data is written as SQL INSERT statements. +_Avoid_: database dump, backup, restore script + +**SQL Update Export**: +A **SQL Data Export** where relational row data is written as SQL UPDATE statements that identify rows by primary key. +_Avoid_: upsert export, import mapping, freeform update script + +**Database Dump**: +A **Database Export** intended to recreate database content from database-native or database-equivalent script output. +_Avoid_: SQL data export, CSV export, table export + +**Database Import**: +A database operation where a user brings external database content into the active **Database Connection**. +_Avoid_: restore-only flow, SQL import as the top-level flow + +**Import Method**: +The kind of external content a **Database Import** uses. +_Avoid_: import mode, export format + +**SQL Script Import**: +A **Database Import** method where a user provides SQL statements to execute against a SQL database. +_Avoid_: table file import, CSV import, database dump import + +**Table File Import**: +A **Database Import** method where a user maps rows from a CSV or Excel file into one existing or newly created **SQL Table**. +_Avoid_: SQL script import, database dump import + +**Collection File Import**: +A **Database Import** method where a user loads records from a JSON, CSV, or Excel file into one existing or newly created **MongoDB Collection**. +_Avoid_: table file import, SQL script import + +**Dashboard**: +An analysis surface made of charts created from database query results. +_Avoid_: report, database view + +**Chart**: +A visual summary of query result data used inside a **Dashboard**. +_Avoid_: widget when the data visualization is meant + +## Relationships + +- The **Database Workspace** and **Dashboard Workspace** are separate workspace areas. +- A **Database Workspace** uses one or more **Database Connections**. +- A **Database Instance** has one **Database Instance Identity** for each lifecycle. +- Reusing an instance name does not reuse its **Database Instance Identity**. +- A **Sealos Database Session** authorizes one **Database Instance Identity** and cannot migrate to another identity. +- A **Database Connection** exposes zero or more **Database Resources**. +- A **Workspace Tab** belongs to the **Database Workspace**. +- A **Workspace Tab** can be tied to one **Database Resource**. +- A **SQL Table**, **SQL View**, **MongoDB Collection**, and **Redis Key** are each a kind of **Database Resource**. +- A **System Object** is a **Database Resource** that stays hidden until a user reveals system objects for its database. +- A **MongoDB Collection** contains zero or more **MongoDB Documents**. +- A **Collection Table View** and a **JSON View** are alternate views of a **MongoDB Collection**. +- A **Database Export** extracts content from exactly one active **Database Connection**. +- A **SQL Data Export** is a **Database Export** and is not a **Database Dump**. +- A **SQL Update Export** is a **SQL Data Export** and requires the source **SQL Table** to have a primary key. +- A **Database Dump** is a **Database Export** and may include database structure as well as data. +- A **Database Import** has exactly one **Import Method** for a single import run. +- A **SQL Script Import**, a **Table File Import**, and a **Collection File Import** are separate **Import Methods**. +- A **Table File Import** targets exactly one **SQL Table**. +- A **Table File Import** does not target a **MongoDB Collection** or **Redis Key**. +- A **Collection File Import** targets exactly one **MongoDB Collection** and does not target a **SQL Table** or **Redis Key**. +- A **Table File Import** that creates a new **SQL Table** may create that table without a primary key. +- When a **Table File Import** creates a new **SQL Table**, any selected primary key columns come from the source file columns rather than from DataFlow-generated columns. +- A **Dashboard** contains one or more **Charts**. +- A **Chart** is created from database query result data. + +## Example Dialogue + +> **Dev:** "Can we call every browsable thing a table in the sidebar?" +> **Domain expert:** "No. Use **Database Resource** only when you need a generic term. Say **SQL Table**, **MongoDB Collection**, or **Redis Key** when the database model matters." +> +> **Dev:** "Should MongoDB browsing use the same language as relational browsing?" +> **Domain expert:** "No. The grid is a **Collection Table View**, but the stored records are still **MongoDB Documents**, not rows." +> +> **Dev:** "When users upload SQL, is that the whole import feature?" +> **Domain expert:** "No. **SQL Script Import** is one **Import Method** inside **Database Import**. A CSV or Excel upload into a table is a **Table File Import**." +> +> **Dev:** "Can we call exported INSERT statements a dump?" +> **Domain expert:** "No. That is a **SQL Data Export**. A **Database Dump** is the restorable export path and has a stronger product promise." +> +> **Dev:** "If SQL export can generate UPDATE statements, are we configuring an import?" +> **Domain expert:** "No. A **SQL Update Export** still exports a script; it simply requires a primary key so each exported UPDATE statement targets one existing row." +> +> **Dev:** "Are charts part of database browsing?" +> **Domain expert:** "They start from query result data, but a saved analysis surface belongs to the **Dashboard Workspace** as a **Dashboard**." + +## Flagged Ambiguities + +- "table" means **SQL Table** unless explicitly referring to the MongoDB **Collection Table View**. +- "MongoDB table" is not a domain term; use **MongoDB Collection** or **Collection Table View** depending on whether the discussion is about storage or UI. +- "row" means a relational row unless the discussion is explicitly about a grid rendering; a stored MongoDB record is a **MongoDB Document**. +- "storage unit" is an internal or old documentation term; use **Database Resource** in product context. +- "import" means **Database Import** unless a specific **Import Method** such as **SQL Script Import** or **Table File Import** is named. +- "CSV/Excel import" means **Table File Import** when the target is a **SQL Table**. +- "primary key" in a new-table **Table File Import** means a source file column selected as the new **SQL Table** primary key; it does not mean DataFlow adds a generated id column. +- "SQL export" means **SQL Data Export** unless the discussion explicitly includes database structure or restore semantics. +- "update export" means **SQL Update Export**, not upsert or an import workflow. +- "dump" means **Database Dump**, not **SQL Data Export**. +- "dashboard" means an analysis surface in the **Dashboard Workspace**, not the database browsing workspace. diff --git a/dataflow/DESIGN.md b/dataflow/DESIGN.md new file mode 100644 index 0000000..fbafe8e --- /dev/null +++ b/dataflow/DESIGN.md @@ -0,0 +1,198 @@ +--- +name: DataFlow +description: Sealos-native database workspace for inspection, editing, querying, and lightweight analysis. +colors: + background: "#ffffff" + foreground: "#18181b" + surface: "#ffffff" + sidebar: "#fafafa" + muted: "#f4f4f5" + muted-foreground: "#71717a" + border: "#e4e4e7" + primary: "#27272a" + primary-foreground: "#fafafa" + destructive: "#e5484d" + success: "#28a980" + warning: "#d99b19" + highlight: "#3366d8" +typography: + body: + fontFamily: "Geist Sans, ui-sans-serif, system-ui, sans-serif" + fontSize: "0.875rem" + fontWeight: 400 + lineHeight: 1.5 + letterSpacing: "0" + title: + fontFamily: "Geist Sans, ui-sans-serif, system-ui, sans-serif" + fontSize: "1rem" + fontWeight: 600 + lineHeight: 1.4 + letterSpacing: "0" + label: + fontFamily: "Geist Sans, ui-sans-serif, system-ui, sans-serif" + fontSize: "0.75rem" + fontWeight: 500 + lineHeight: 1.33 + letterSpacing: "0" + mono: + fontFamily: "Geist Mono, ui-monospace, SFMono-Regular, monospace" +rounded: + sm: "6px" + md: "8px" + lg: "10px" + xl: "14px" +spacing: + xs: "4px" + sm: "8px" + md: "16px" + lg: "24px" +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.primary-foreground}" + rounded: "{rounded.md}" + padding: "8px 12px" + button-ghost: + backgroundColor: "transparent" + textColor: "{colors.foreground}" + rounded: "{rounded.md}" + padding: "8px" + input: + backgroundColor: "{colors.surface}" + textColor: "{colors.foreground}" + rounded: "{rounded.md}" + padding: "8px 12px" +--- + +# Design System: DataFlow + +## 1. Overview + +**Creative North Star: "Calm Workbench"** + +DataFlow is a task-first database workspace. It should feel quiet, trustworthy, and capable: closer to a focused editor than a decorative dashboard. The product carries dense database state, pending edits, query output, and chart composition in one shell, so the visual system favors restrained contrast, clear affordances, and predictable component behavior. + +The interface explicitly rejects enterprise-console clutter, casual treatment of destructive database work, and decorative visual treatments that compete with records or query results. The product can be dense, but each dense surface should be scannable through alignment, stable spacing, and consistent controls. + +**Key Characteristics:** + +- White and near-neutral surfaces with strong text contrast. +- A fixed app shell with activity rail, resizable sidebar, tab strip, and main work area. +- Icons from `lucide-react`, paired with concise labels only where the label improves scan speed. +- State-driven styling for active tabs, pending edits, errors, success, warning, and selected rows. + +## 2. Colors + +The palette is restrained and neutral, with semantic color reserved for state and database-risk communication. + +### Primary + +- **Workbench Ink** (`--primary`, `oklch(0.205 0 0)`): primary actions, selected controls, and strong UI emphasis. +- **Focus Blue** (`--highlight`, `oklch(0.623 0.214 259.815)`): highlighted matches, active semantic emphasis, and focused workflow moments. + +### Secondary + +- **Success Green** (`--success`, `oklch(0.696 0.17 162.48)`): successful export, save, or apply states. +- **Warning Amber** (`--warning`, `oklch(0.769 0.188 70.08)`): caution states before potentially risky changes. +- **Destructive Red** (`--destructive`, `oklch(0.577 0.245 27.325)`): delete, drop, clear, and failed states. + +### Neutral + +- **Canvas White** (`--background`, `oklch(1 0 0)`): application base. +- **Sidebar Wash** (`--sidebar`, `oklch(0.985 0 0)`): navigation, sidebar, and workbench framing. +- **Muted Panel** (`--muted`, `oklch(0.97 0 0)`): inactive controls, subtle rows, and toolbar backgrounds. +- **Divider Gray** (`--border`, `oklch(0.922 0 0)`): table borders, panel dividers, input strokes. +- **Readable Gray** (`--muted-foreground`, `oklch(0.556 0 0)`): secondary text only, never the main explanation for a destructive or blocking state. + +### Named Rules + +**The State Color Rule.** Use saturated color for user state, validation, and selected workflow feedback. Do not use it as page decoration. + +**The Mutation Clarity Rule.** Destructive or database-writing actions must use semantic color, explicit labels, and confirmation state rather than relying on placement alone. + +## 3. Typography + +**Display Font:** Geist Sans with system sans fallback +**Body Font:** Geist Sans with system sans fallback +**Label/Mono Font:** Geist Mono for code, SQL, JSON, Redis commands, identifiers, and technical values + +**Character:** The type system is product-native and compact. It uses weight and spacing instead of display-scale typography because the app is an operational tool. + +### Hierarchy + +- **Display**: Rare in the app shell. Avoid large marketing-scale headings inside authenticated surfaces. +- **Headline** (`text-lg` to `text-xl`, semibold): modal titles, empty-state titles, and major dashboard headings. +- **Title** (`text-sm` to `text-base`, medium or semibold): panel headers, tab labels, toolbar titles. +- **Body** (`text-sm`, regular): forms, explanatory copy, table supporting text. Keep prose blocks short and avoid repeating the visible heading. +- **Label** (`text-xs` to `text-sm`, medium): field labels, badges, table metadata, icon button tooltips. +- **Mono** (`font-mono`, `text-xs` to `text-sm`): SQL, JSON, shell fragments, raw values, and IDs. + +### Named Rules + +**The Compact Type Rule.** Keep product controls on a fixed rem scale. Do not scale app-shell or toolbar type with viewport width. + +## 4. Elevation + +DataFlow is flat by default. Depth comes from borders, background changes, sticky table separators, and modal overlays rather than large shadows. This preserves the feel of a workbench and keeps attention on data. + +### Shadow Vocabulary + +- **Modal Layer**: Dialogs may use the shared dialog overlay and surface styling from `src/components/ui/dialog`. +- **Interactive State**: Prefer background and border changes for hover, active, selected, and focus states. + +### Named Rules + +**The Flat Surface Rule.** Tables, sidebars, tabs, toolbars, and dashboard canvases should not gain decorative drop shadows at rest. + +## 5. Components + +### Buttons + +- **Shape:** restrained rounded rectangles using the shared radius scale, generally 6 to 10px. +- **Primary:** dark neutral background with high-contrast text for the main action in a local context. +- **Ghost:** transparent at rest, muted background on hover, used for toolbars and icon actions. +- **Focus:** visible focus ring via the shared `ring` token and Tailwind focus utilities. +- **Disabled:** preserve layout and icon position while reducing affordance with muted text and disabled cursor. + +### Chips + +- **Style:** small, compact labels for status, filters, data type, or selected resource metadata. +- **State:** selected chips should show an explicit selected background or border, not color alone. + +### Cards / Containers + +- **Corner Style:** 8 to 10px for repeated cards and dialogs; avoid oversized rounded panels. +- **Background:** use `--card`, `--sidebar`, and `--muted` for hierarchy. +- **Shadow Strategy:** flat by default; rely on borders and tonal contrast. +- **Internal Padding:** compact by default, usually 8 to 16px in panels and 16 to 24px in dialogs. + +### Inputs / Fields + +- **Style:** neutral border, white surface, compact height, monospace for query or JSON fields. +- **Focus:** ring or border emphasis that does not resize the field. +- **Error / Disabled:** semantic message plus visual state. Never use color alone for errors. + +### Navigation + +- **Activity Bar:** fixed 80px rail with icon plus short label for the two workspaces. +- **Sidebar:** resizable context tree for database browsing or dashboard management. +- **Tabs:** database workspace tabs own query and storage-unit surfaces; tabs can show unsaved database edit state. +- **Leave Guard:** only blocks actions that would discard database edits, not ordinary switching between open workspace tabs. + +## 6. Do's and Don'ts + +**Do** + +- Keep data tables, collection grids, and query results visually stable under loading, hover, and edit states. +- Use `lucide-react` icons in toolbars and pair unfamiliar icon-only actions with tooltips. +- Preserve MongoDB terminology: Collection, Document, Collection Table View, JSON View. +- Localize all user-facing strings through the existing i18n message system. +- Add `data-testid` and `data-qa-*` semantics to new interactive surfaces when they matter for automation. + +**Don't** + +- Do not make database mutation feel playful or casual. +- Do not add decorative cards around already-framed tools. +- Do not use large display headings inside compact panels or modals. +- Do not introduce new color families for one-off components. +- Do not call MongoDB collections tables except for the explicit Collection Table View concept. diff --git a/dataflow/IMPORT.md b/dataflow/IMPORT.md new file mode 100644 index 0000000..74dcbf3 --- /dev/null +++ b/dataflow/IMPORT.md @@ -0,0 +1,18 @@ +# DataFlow source import + +Source: https://github.com/sealos-apps/dataflow +Branch: main +Commit: a6fbbf53906d2ac9f4a0817af7f50030f90c86d3 + +Imported tracked files with `git archive`, without a nested Git repository. +The root Database CI builds this source for both Database source channels. +Nested `.github/` workflows are retained as upstream reference; GitHub only runs +workflows in the repository-root `.github/workflows/` directory. +Integration changes after import are tracked in this repository. Keep the +upstream LICENSE and file copyright notices when updating the snapshot. + +Local integration changes: `deploy/install.sh` preserves effective release values, +resolves custom runtime Secrets, refuses unsafe session-key rotation, accepts +package image pins and an optional cloud-domain/user-values-directory override. +The local `.gitignore` also re-includes five upstream tracked Go build placeholders +and tool declarations that would otherwise be hidden by ignore rules during import. diff --git a/dataflow/LICENSE b/dataflow/LICENSE new file mode 100644 index 0000000..5d887a0 --- /dev/null +++ b/dataflow/LICENSE @@ -0,0 +1,190 @@ + 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 + + Copyright 2025 Clidey, Inc. + + 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. \ No newline at end of file diff --git a/dataflow/Makefile b/dataflow/Makefile new file mode 100644 index 0000000..430109a --- /dev/null +++ b/dataflow/Makefile @@ -0,0 +1,49 @@ +SERVICE_NAME = dataflow +DOCKER_USERNAME ?= +IMAGE_TAG ?= latest +TARGETARCH ?= amd64 +PLATFORM ?= docker +GOPROXY ?= https://proxy.golang.org,direct +BAML_RELEASE_BASE_URL ?= https://github.com/boundaryml/baml/releases/download +IMG ?= $(if $(DOCKER_USERNAME),$(DOCKER_USERNAME)/,)$(SERVICE_NAME):$(IMAGE_TAG) + +.PHONY: all +all: build + +##@ General + +.PHONY: help +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +##@ Build + +.PHONY: build +build: ## Build DataFlow frontend. + cd dataflow && pnpm run build + +.PHONY: run +run: ## Run DataFlow dev server from host. + cd dataflow && pnpm run dev + +##@ Docker + +.PHONY: docker-build +docker-build: ## Build docker image. + docker buildx build -f core/Dockerfile --platform linux/$(TARGETARCH) --build-arg VERSION=$(IMAGE_TAG) --build-arg TARGETARCH=$(TARGETARCH) --build-arg PLATFORM=$(PLATFORM) --build-arg GOPROXY=$(GOPROXY) --build-arg BAML_RELEASE_BASE_URL=$(BAML_RELEASE_BASE_URL) -t $(IMG) . + +.PHONY: docker-build-no-cache +docker-build-no-cache: ## Build docker image without cache. + docker buildx build --no-cache -f core/Dockerfile --platform linux/$(TARGETARCH) --build-arg VERSION=$(IMAGE_TAG) --build-arg TARGETARCH=$(TARGETARCH) --build-arg PLATFORM=$(PLATFORM) --build-arg GOPROXY=$(GOPROXY) --build-arg BAML_RELEASE_BASE_URL=$(BAML_RELEASE_BASE_URL) -t $(IMG) . + +.PHONY: docker-push +docker-push: ## Push docker image. + docker push $(IMG) + +.PHONY: docker-build-push +docker-build-push: ## Build and push docker image. + docker buildx build -f core/Dockerfile --platform linux/$(TARGETARCH) --build-arg VERSION=$(IMAGE_TAG) --build-arg TARGETARCH=$(TARGETARCH) --build-arg PLATFORM=$(PLATFORM) --build-arg GOPROXY=$(GOPROXY) --build-arg BAML_RELEASE_BASE_URL=$(BAML_RELEASE_BASE_URL) -t $(IMG) --push . + +.PHONY: docker-build-push-no-cache +docker-build-push-no-cache: ## Build docker image without cache and push it. + docker buildx build --no-cache -f core/Dockerfile --platform linux/$(TARGETARCH) --build-arg VERSION=$(IMAGE_TAG) --build-arg TARGETARCH=$(TARGETARCH) --build-arg PLATFORM=$(PLATFORM) --build-arg GOPROXY=$(GOPROXY) --build-arg BAML_RELEASE_BASE_URL=$(BAML_RELEASE_BASE_URL) -t $(IMG) --push . diff --git a/dataflow/PRODUCT.md b/dataflow/PRODUCT.md new file mode 100644 index 0000000..84ef575 --- /dev/null +++ b/dataflow/PRODUCT.md @@ -0,0 +1,33 @@ +# Product + +## Register + +product + +## Users + +DataFlow is used by developers, operators, and data-facing teammates who need to inspect and maintain databases from a single workspace. They are usually browsing collections, tables, keys, and query results while keeping enough context to avoid accidental data changes. + +## Product Purpose + +DataFlow provides a Sealos-native database workspace for browsing records, running SQL, MongoDB, and Redis commands, and turning query results into charts and dashboards. Success means users can move from database structure to data editing or analysis with clear state, predictable controls, and low friction. + +## Brand Personality + +Lightweight, capable, calm. The interface should feel less heavy than a traditional admin console while still earning trust for database operations. + +## Anti-references + +Avoid dense enterprise-console clutter, decorative dashboards that slow down the task, and playful visual treatments that make resource mutation feel casual. Avoid calling MongoDB collections tables except when referring specifically to the Collection Table View. + +## Design Principles + +- Keep the workspace light enough for daily use. +- Make database state and pending mutations visible before action. +- Use familiar product UI patterns for high-frequency controls. +- Preserve MongoDB's document model while offering grid-based browsing. +- Let analysis tools sit close to the data without competing with core browsing. + +## Accessibility & Inclusion + +Target WCAG AA for contrast, focus visibility, keyboard access, and readable control labels. Respect reduced-motion preferences and avoid using color alone to communicate state. diff --git a/dataflow/README.md b/dataflow/README.md new file mode 100644 index 0000000..5d89da5 --- /dev/null +++ b/dataflow/README.md @@ -0,0 +1,126 @@ +## Overview + +DataFlow is the web database workspace in this repository. It runs on top of the Go backend in [`core/`](./core) and is positioned as a Sealos-native database GUI plus a lightweight BI panel. + +In the current version, the product is centered on three primary jobs: + +- Browse and maintain database objects and records +- Write SQL, MongoDB, or Redis queries and commands +- Turn query results into charts and reusable dashboards + +### Supported Databases + +| Relational | Document / Key-Value | Analytics | +|------------|----------------------|-----------| +| PostgreSQL | MongoDB | ClickHouse | +| MySQL | Redis | | + +### Core Workflows + +- Browse databases, schemas, tables, collections, and keys +- View, edit, insert, delete, and export data +- Run SQL, Mongo shell-style, and Redis commands in editors +- Build charts and dashboard widgets from query results +- Work across PostgreSQL, MySQL, MongoDB, Redis, and ClickHouse in one workspace + +## Local Development + +### Prerequisites + +- Go 1.21+ +- Node.js 22+ +- pnpm 10+ + +### Start the App + +```bash +# Install frontend dependencies +cd dataflow +pnpm install + +# Terminal 1: backend +cd core +set -a +source .env.local +set +a +go run . + +# Terminal 2: frontend +cd ../dataflow +pnpm dev +``` + +The frontend dev server runs at `http://localhost:5173` and proxies API requests to the backend. + +## Frontend Checks + +```bash +cd dataflow +pnpm run typecheck +pnpm run build +pnpm run test +``` + +## Build an Embedded Binary + +To build a production binary that embeds the frontend assets: + +```bash +cd dataflow +pnpm install +pnpm run build + +cd .. +rm -rf core/build +cp -R dataflow/build core/build + +cd core +go build -tags prod -o dataflow-server . +``` + +## Build a Docker Image + +```bash +docker build -f core/Dockerfile -t dataflow-local . +docker run --rm -p 8080:8080 dataflow-local +``` + +Open `http://localhost:8080` after the container starts. + +## Build a Sealos Cluster Image + +Sealos packaging files live under [`deploy/`](./deploy). + +- PR workflow: [`/.github/workflows/pr-docker-build.yml`](./.github/workflows/pr-docker-build.yml) +- Release workflow: [`/.github/workflows/release.yaml`](./.github/workflows/release.yaml) +- Packaging details: [`deploy/README.md`](./deploy/README.md) +- User override template: [`deploy/charts/dataflow/dataflow-values.yaml`](./deploy/charts/dataflow/dataflow-values.yaml) + +## Project Structure + +```text +core/ # Go backend + server.go # Entry point + src/plugins/ # Database connectors + graph/ # GraphQL schema and resolvers + Dockerfile # Production image build + +dataflow/ # React 19 + TypeScript frontend + src/main.tsx # Entry point + src/stores/ # Zustand stores + src/components/ # Database, editor, analysis, and layout UI + +deploy/ # Sealos cluster image packaging + +dev/ # Local database fixtures and helper scripts +docs/ # Product and engineering docs +``` + +## Tech Stack + +- Backend: Go, GraphQL (gqlgen), Chi, GORM +- Frontend: React 19, TypeScript, Zustand, Apollo Client, Vite, Tailwind CSS 4, Monaco Editor, ECharts, react-grid-layout + +## License + +Apache 2.0. See [`LICENSE`](./LICENSE). diff --git a/dataflow/ROADMAP.md b/dataflow/ROADMAP.md new file mode 100644 index 0000000..fa06a2b --- /dev/null +++ b/dataflow/ROADMAP.md @@ -0,0 +1,43 @@ +# Roadmap + +This roadmap is a planning guide for DataFlow release work. It should stay tied to current code and product direction rather than speculative platform ideas. + +## Current Release Focus + +- Stabilize MongoDB collection browsing and editing: + - Collection Table View as the default browsing surface. + - Preserved top-level document field order. + - Inline scalar editing, focused JSON editing for complex fields, and whole-document replacement edits. +- Protect user work in the database workspace: + - Unsaved database edit indicators on tabs. + - Leave guard for tab close, browser refresh or close, and switching away from the database workspace. +- Keep release packaging reliable: + - Runtime image build through `core/Dockerfile`. + - Sealos cluster image packaging through `deploy/Kubefile` and `.github/workflows/release.yaml`. + - Release assets for both `amd64` and `arm64` produced by GitHub Actions. + +## Near-Term Priorities + +1. Keep MongoDB data editing predictable and covered by unit tests. +2. Reduce frontend bundle size where it materially improves startup or editing workflows. +3. Improve release notes and tag discipline so public GitHub Releases match version tags. +4. Keep Helm values and Sealos install behavior aligned with runtime environment variables. +5. Expand documentation for architecture, runbook, and UI interaction contracts as release scope grows. + +## Later Candidates + +- Database expansion candidates are tracked in [docs/database-expansion-plan.md](docs/database-expansion-plan.md). +- Dashboard persistence and collaboration should be revisited only after core browsing and editing workflows are stable. +- Advanced import/export workflows should be prioritized based on user data-size and format feedback. + +## Release Gate + +Before a release tag is pushed: + +- `cd dataflow && pnpm run typecheck && pnpm run build && pnpm run test && pnpm run lint` +- `cd core && go build ./...` +- `cd core && go test ./...` or a documented equivalent when native-library setup is required +- `helm lint deploy/charts/dataflow` +- `helm template dataflow deploy/charts/dataflow` +- Confirm GitHub Actions `Release` and `CodeQL` are green for the target commit. +- Confirm whether the release notes should be based on the latest version tag or the latest published GitHub Release. diff --git a/dataflow/core/.env.example b/dataflow/core/.env.example new file mode 100644 index 0000000..f7ae205 --- /dev/null +++ b/dataflow/core/.env.example @@ -0,0 +1,16 @@ +# Local backend environment for standalone login. +# Copy to core/.env or source directly before running `go run .`. + +PORT=8080 +WHODB_STANDALONE_LOGIN_ENABLED=true +WHODB_DISABLE_CREDENTIAL_FORM=false + +# Standalone and Sealos bootstrap sessions are stored server-side. +# Use an explicit local PostgreSQL session store and an explicit 32-byte key. +WHODB_SESSION_DSN=postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable +WHODB_METADATA_DSN=postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable +WHODB_SESSION_ENCRYPTION_KEY=12345678901234567890123456789012 +WHODB_SESSION_TTL=24h + +# Leave Sealos bootstrap enabled for local URL-bootstrap testing. +WHODB_SEALOS_BOOTSTRAP_ENABLED=true diff --git a/dataflow/core/.gitignore b/dataflow/core/.gitignore new file mode 100644 index 0000000..119f9de --- /dev/null +++ b/dataflow/core/.gitignore @@ -0,0 +1,34 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +build/ +.env +*.pb.go + +gorm.db + +tmp/ +# Keep upstream tracked embed placeholders, excluding generated assets. +!build/ +build/* +!build/app.js +!build/index.html diff --git a/dataflow/core/Dockerfile b/dataflow/core/Dockerfile new file mode 100644 index 0000000..1b252fc --- /dev/null +++ b/dataflow/core/Dockerfile @@ -0,0 +1,84 @@ +# Copyright 2025 Clidey, Inc. +# +# 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. + +FROM node:25.6.0-alpine3.23 AS build-stage +RUN npm i -g pnpm@10.29.3 +WORKDIR /app +COPY ./dataflow/package.json ./dataflow/pnpm-lock.yaml ./ +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 +RUN pnpm install +COPY ./dataflow/ ./ + +ARG VERSION=dev +ENV VITE_APP_VERSION=${VERSION} +RUN pnpm run build + +FROM golang:1.26-alpine3.23 AS backend-stage +RUN apk add --no-cache \ + gcc \ + musl-dev \ + git \ + ca-certificates \ + curl +WORKDIR /app + +# Copy core module files +COPY ./core/go.mod ./core/go.sum ./ +ARG GOPROXY=https://proxy.golang.org,direct +ENV GOPROXY=${GOPROXY} +RUN go mod download +COPY ./core/ ./ + + +COPY --from=build-stage /app/build/ ./build/ + +# Build backend with version and environment +ARG VERSION +ARG TARGETARCH +ARG PLATFORM +RUN if [ -z "${TARGETARCH}" ] || [ -z "${PLATFORM}" ]; then echo "TARGETARCH and PLATFORM build args are required" >&2; exit 1; fi && \ + CGO_CFLAGS="-Os -ffunction-sections -fdata-sections -fno-asynchronous-unwind-tables -fno-unwind-tables -flto -fvisibility=hidden" \ + CGO_LDFLAGS="-Wl,--gc-sections -Wl,--as-needed -flto" \ + CGO_ENABLED=1 GOOS=linux GOARCH=${TARGETARCH} go build -tags prod -trimpath \ + -ldflags="-s -w -X github.com/clidey/whodb/core/src/env.ApplicationVersion=${VERSION} -X github.com/clidey/whodb/core/src/env.ApplicationEnvironment=${PLATFORM}-${TARGETARCH}" \ + -o /core + +# Pre-download BAML native library for the target architecture (musl for Alpine) +# Extract BAML version from go.mod to stay in sync +ARG TARGETARCH +ARG BAML_RELEASE_BASE_URL=https://github.com/boundaryml/baml/releases/download +RUN if [ -z "$TARGETARCH" ]; then echo "TARGETARCH build arg is required" >&2; exit 1; fi && \ + BAML_VERSION=$(grep 'github.com/boundaryml/baml ' go.mod | awk '{print $2}' | sed 's/^v//') && \ + if [ "$TARGETARCH" = "amd64" ]; then \ + BAML_ARCH="x86_64"; \ + elif [ "$TARGETARCH" = "arm64" ]; then \ + BAML_ARCH="aarch64"; \ + else \ + echo "Unsupported architecture: $TARGETARCH" && exit 1; \ + fi && \ + mkdir -p /baml-lib && \ + curl --fail --location --retry 5 --retry-delay 3 --retry-all-errors -o /baml-lib/libbaml_cffi.so \ + "${BAML_RELEASE_BASE_URL}/${BAML_VERSION}/libbaml_cffi-${BAML_ARCH}-unknown-linux-musl.so" && \ + echo "Downloaded BAML musl library for ${BAML_ARCH}" + +FROM alpine:3.23 +RUN apk add --no-cache ca-certificates libgcc +WORKDIR /app +COPY --from=backend-stage /core /core +# Copy BAML musl library and set path via env var +# (BAML Go client's isMusl() is not implemented, so we must use BAML_LIBRARY_PATH) +COPY --from=backend-stage /baml-lib/libbaml_cffi.so /usr/local/lib/libbaml_cffi.so +ENV BAML_LIBRARY_PATH=/usr/local/lib/libbaml_cffi.so + +CMD ["/core"] diff --git a/dataflow/core/arm_warning.go b/dataflow/core/arm_warning.go new file mode 100644 index 0000000..e143640 --- /dev/null +++ b/dataflow/core/arm_warning.go @@ -0,0 +1,31 @@ +//go:build arm || riscv64 + +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package main + +import ( + "fmt" + "os" +) + +func init() { + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "⚠️ Notice: AI features are unavailable on this architecture (arm/riscv64).") + fmt.Fprintln(os.Stderr, " All other database features work normally.") + fmt.Fprintln(os.Stderr, "") +} diff --git a/dataflow/core/baml_client/baml_source_map.go b/dataflow/core/baml_client/baml_source_map.go new file mode 100644 index 0000000..bf82495 --- /dev/null +++ b/dataflow/core/baml_client/baml_source_map.go @@ -0,0 +1,25 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package baml_client + +var file_map = map[string]string{ + + "clients.baml": "// Learn more about clients at https://docs.boundaryml.com/docs/snippets/clients/overview\n//\n// NOTE: These are fallback clients used when dynamic client creation fails.\n// Primary client configuration is done at runtime via CreateDynamicBAMLClient()\n// using user-selected providers, models, and API keys from the UI.\n\n// Default fallback client - uses OpenAI if environment variable is set\nclient DefaultClient {\n provider openai\n retry_policy Exponential\n options {\n model \"gpt-4o\"\n api_key env.OPENAI_API_KEY\n }\n}\n\n// Retry policies for robust API calls\nretry_policy Constant {\n max_retries 3\n strategy {\n type constant_delay\n delay_ms 200\n }\n}\n\nretry_policy Exponential {\n max_retries 2\n strategy {\n type exponential_backoff\n delay_ms 300\n multiplier 1.5\n max_delay_ms 10000\n }\n}\n", + "generators.baml": "// This helps use auto generate libraries you can use in the language of\n// your choice. You can have multiple generators if you use multiple languages.\n// Just ensure that the output_dir is different for each generator.\ngenerator target {\n // Valid values: \"python/pydantic\", \"typescript\", \"ruby/sorbet\", \"rest/openapi\"\n output_type \"go\"\n\n // Where the generated code will be saved (relative to baml_src/)\n output_dir \"../\"\n\n // The version of the BAML package you have installed (e.g. same version as your baml-py or @boundaryml/baml).\n // The BAML VSCode extension version should also match this version.\n version \"0.218.1\"\n\n // 'baml-cli generate' will run this after generating go code\n // This command will be run from within $output_dir/baml_client\n on_generate \"gofmt -w . && goimports -w .\"\n\n // Your Go packages name as specified in go.mod\n // We need this to generate correct imports in the generated baml_client\n client_package_name \"github.com/clidey/whodb/core\"\n}\n", + "sql_chat.baml": "// SQL Query Generation for WhoDB\n// This BAML schema defines structured SQL generation using AI models\n\n// Database operation types\nenum OperationType {\n GET @alias(\"get\") @description(\"SELECT queries to retrieve data\")\n INSERT @alias(\"insert\") @description(\"INSERT queries to add data\")\n UPDATE @alias(\"update\") @description(\"UPDATE queries to modify data\")\n DELETE @alias(\"delete\") @description(\"DELETE queries to remove data\")\n CREATE @alias(\"create\") @description(\"CREATE TABLE, CREATE INDEX and other schema creation operations\")\n ALTER @alias(\"alter\") @description(\"ALTER TABLE and other schema modification operations\")\n DROP @alias(\"drop\") @description(\"DROP TABLE, DROP INDEX and other schema deletion operations\")\n TEXT @alias(\"text\") @description(\"General text responses without SQL\")\n}\n\n// Chat message type\nenum ChatMessageType {\n SQL @alias(\"sql\") @description(\"SQL query response\")\n MESSAGE @alias(\"message\") @description(\"Textual response without query\")\n ERROR @alias(\"error\") @description(\"Error response\")\n}\n\n// Chat response structure matching engine.ChatMessage\nclass ChatResponse {\n type ChatMessageType @description(\"Type of response: sql or message\")\n operation OperationType? @description(\"SQL operation type if applicable\")\n text string @description(\"The SQL query or response text\")\n}\n\n// Database context for SQL generation\nclass DatabaseContext {\n database_type string @description(\"Database type (e.g., PostgreSQL, MySQL)\")\n schema string @description(\"Database schema name\")\n tables_and_fields string @description(\"Available tables with their field definitions\")\n previous_conversation string @description(\"Previous conversation history for context\")\n}\n\n// Main function to generate SQL queries from natural language\nfunction GenerateSQLQuery(\n context: DatabaseContext,\n user_query: string\n) -> ChatResponse[] {\n client DefaultClient\n prompt #\"\n You are a friendly and helpful data engineer working with a {{ context.database_type }} database. Think of yourself as a colleague who's here to help explore and understand the data.\n\n Database Context:\n Schema: {{ context.schema }}\n\n Available Tables and Fields:\n {{ context.tables_and_fields }}\n\n ### CRITICAL: JSON FORMAT ONLY - NO EXCEPTIONS\n\n YOU MUST RETURN ONLY A JSON ARRAY. START YOUR RESPONSE WITH [ AND END WITH ]\n NO PLAIN TEXT. NO EXPLANATIONS OUTSIDE JSON. ONLY THE JSON ARRAY.\n\n EXACT FORMAT:\n [{\"type\": \"message\", \"operation\": null, \"text\": \"your message here\"}]\n\n For ONLY simple greetings (Hi, Hello, Hey - nothing else):\n [{\"type\": \"message\", \"operation\": null, \"text\": \"Hi! I'm here to help you work with the {% if context.schema %}{{ context.schema }} {% endif %}database. What would you like to know?\"}]\n\n For any other request (even if it starts with Hi), ANSWER THE ACTUAL REQUEST, don't just greet.\n\n For data requests, return TWO items (message explaining + SQL query):\n [{\"type\": \"message\", \"operation\": null, \"text\": \"I'll get the users for you.\"}, {\"type\": \"sql\", \"operation\": \"get\", \"text\": \"SELECT * FROM {% if context.schema %}{{ context.schema }}.{% endif %}users LIMIT 100;\"}]\n\n ### CRITICAL: ALWAYS RESPOND TO THE USER'S ACTUAL QUESTION\n - READ the user's request carefully and answer THAT SPECIFIC question\n - DO NOT give generic greetings unless the user is specifically greeting you (hi, hello, etc.)\n - If the user asks you to do something specific (create data, run a query, etc.), DO THAT - don't give an introduction\n - ONLY mention tables that ACTUALLY EXIST in the \"Available Tables and Fields\" section above\n - If NO tables are listed in \"Available Tables and Fields\", DO NOT mention any table names\n - NEVER hallucinate or make up table names, database names, or data that isn't provided in the context\n\n ### Your Personality and Approach:\n - Be warm, conversational, and approachable - like a helpful colleague, not a robot\n - When greeting or introducing yourself, ONLY talk about DATA that ACTUALLY EXISTS in the context above\n - Show genuine interest in helping the user understand their data\n - Provide context and insights about the data, not just queries\n - Ask clarifying questions when needed\n - Explain your reasoning and thought process\n - Be proactive in suggesting relevant analyses based on ACTUAL available tables only\n\n ### Response Format:\n You must structure your responses with these fields:\n\n **type** (required):\n - \"message\" - for conversational text, explanations, greetings, or insights\n - \"sql\" - when generating an actual SQL query\n\n **operation** (required when type=\"sql\", otherwise null):\n - \"get\" - for SELECT queries to retrieve data\n - \"insert\" - for adding new records\n - \"update\" - for modifying existing records\n - \"delete\" - for removing records\n - \"create\" - for CREATE TABLE, CREATE INDEX and other schema creation operations\n - \"alter\" - for ALTER TABLE and other schema modification operations\n - \"drop\" - for DROP TABLE, DROP INDEX and other schema deletion operations\n\n **text** (required):\n - Your conversational message, explanation, or SQL query\n\n ### CRITICAL SQL RULE - READ THIS CAREFULLY:\n {% if context.schema %}\n **EVERY table reference in SQL queries MUST include the schema name: {{ context.schema }}.table_name**\n\n ❌ WRONG: SELECT * FROM users\n ❌ WRONG: SELECT * FROM orders\n ❌ WRONG: SELECT * FROM test_casting\n\n ✅ CORRECT: SELECT * FROM {{ context.schema }}.users\n ✅ CORRECT: SELECT * FROM {{ context.schema }}.orders\n ✅ CORRECT: SELECT * FROM {{ context.schema }}.test_casting\n\n This is MANDATORY for all queries. Queries without schema names WILL FAIL.\n {% else %}\n **This database does NOT use schemas. Reference tables by name only, WITHOUT any schema prefix.**\n\n ❌ WRONG: SELECT * FROM .users (no leading dot!)\n ❌ WRONG: SELECT * FROM schema.users (no schema prefix!)\n\n ✅ CORRECT: SELECT * FROM users\n ✅ CORRECT: SELECT * FROM orders\n ✅ CORRECT: SELECT * FROM test_casting\n\n This is MANDATORY for all queries. Do NOT add any schema or dot prefix.\n {% endif %}\n\n ### How to Handle Common Scenarios:\n\n {% if context.tables_and_fields and context.tables_and_fields|trim %}\n **When tables exist - Greetings (Hi, Hello, etc.):**\n - Warmly greet the user\n - Briefly mention what database/schema you're working with\n - Mention 2-3 ACTUAL tables from the available tables (ONLY use tables from the \"Available Tables and Fields\" section above - NEVER make up table names)\n - Invite them to ask about specific data or analyses\n - Example: \"Hi! I'm here to help you explore the {% if context.schema %}{{ context.schema }} {% endif %}database. I can see you have tables like [ACTUAL TABLE NAMES FROM CONTEXT]. What would you like to know?\"\n\n **When tables exist - \"What can you do?\" or similar:**\n - Focus on the DATA that ACTUALLY EXISTS\n - Mention the types of analyses possible with their ACTUAL tables (from \"Available Tables and Fields\")\n - Give concrete examples based on ONLY the tables listed above - NEVER mention tables that aren't in the list\n - Example: \"I can help you explore your data! You have [ACTUAL TABLE NAMES] - we could analyze patterns, look at relationships, or explore any specific metrics. What would you like to explore?\"\n {% else %}\n **When NO tables exist - Empty database:**\n - Acknowledge that the database/schema currently has no tables\n - Explain that you can help create tables or run queries once tables exist\n - Be honest and helpful\n - Example: \"Hi! I'm here to help you work with the {% if context.schema %}{{ context.schema }} {% endif %}database. It looks like there are no tables set up yet. I can help you create tables or run queries once your schema has data. What would you like to do?\"\n\n **When NO tables exist - Data requests:**\n - Politely explain that no tables exist yet\n - Cannot generate SELECT queries for non-existent tables\n - Offer to help create tables instead\n - Example: \"I don't see any tables in the {% if context.schema %}{{ context.schema }} {% endif %}database yet. Would you like me to help you create some tables first?\"\n {% endif %}\n\n **Data exploration requests:**\n - First provide a brief explanation of what you'll retrieve and why it's useful\n - Then provide the SQL query {% if context.schema %}with FULL schema names{% else %}WITHOUT any schema prefix{% endif %}\n - Return both a message and a sql response\n - Example query structure: SELECT * FROM {% if context.schema %}{{ context.schema }}.{% endif %}users LIMIT 100;\n\n **Schema modification requests (CREATE TABLE, ALTER TABLE, DROP TABLE):**\n - First provide a brief explanation of what you'll do\n - Then provide the DDL SQL query {% if context.schema %}with FULL schema names{% else %}WITHOUT any schema prefix{% endif %}\n - Return both a message and a sql response with the appropriate operation type (create/alter/drop)\n - Always use appropriate data types for the target database\n - Example: [{\"type\": \"message\", \"operation\": null, \"text\": \"I'll create a countries table with id and name columns.\"}, {\"type\": \"sql\", \"operation\": \"create\", \"text\": \"CREATE TABLE {% if context.schema %}{{ context.schema }}.{% endif %}countries (id INTEGER PRIMARY KEY, name VARCHAR(100));\"}]\n\n **Ambiguous requests:**\n - Ask friendly clarifying questions\n - Suggest a few specific options based on available data\n - Help narrow down what they're looking for\n\n ### SQL Query Examples ({% if context.schema %}Always use schema prefix!{% else %}NO schema prefix for this database{% endif %}):\n\n **Simple select:**\n SELECT * FROM {% if context.schema %}{{ context.schema }}.{% endif %}users LIMIT 100;\n\n **Join query:**\n SELECT u.name, o.total\n FROM {% if context.schema %}{{ context.schema }}.{% endif %}users u\n JOIN {% if context.schema %}{{ context.schema }}.{% endif %}orders o ON u.id = o.user_id\n LIMIT 50;\n\n **Aggregate query:**\n SELECT status, COUNT(*) as count\n FROM {% if context.schema %}{{ context.schema }}.{% endif %}orders\n GROUP BY status;\n\n ### Additional Guidelines:\n - For large result sets, warn the user and add LIMIT clauses\n - Before DELETE/UPDATE operations, always confirm with the user first\n - When you can provide both explanation and query, do so (message first, then sql)\n\n ### CRITICAL: ONE SQL STATEMENT PER RESPONSE OBJECT\n **Each SQL query MUST be in its own separate ChatResponse object in the array.**\n\n ❌ WRONG - Multiple statements in one text field:\n [{\"type\": \"sql\", \"operation\": \"get\", \"text\": \"SELECT * FROM schema.users; SELECT * FROM schema.orders;\"}]\n\n ✅ CORRECT - Separate objects for each query:\n [\n {\"type\": \"message\", \"operation\": null, \"text\": \"I'll get the users and orders for you.\"},\n {\"type\": \"sql\", \"operation\": \"get\", \"text\": \"SELECT * FROM schema.users LIMIT 100;\"},\n {\"type\": \"sql\", \"operation\": \"get\", \"text\": \"SELECT * FROM schema.orders LIMIT 100;\"}\n ]\n\n This is MANDATORY because databases can only execute one statement at a time.\n For complex requests requiring multiple queries, return multiple sql objects.\n\n Previous Conversation:\n {{ context.previous_conversation }}\n\n User's Request:\n {{ user_query }}\n\n {{ ctx.output_format }}\n \"#\n}\n\n// Test cases\ntest simple_select {\n functions [GenerateSQLQuery]\n args {\n context {\n database_type \"PostgreSQL\"\n schema \"public\"\n tables_and_fields #\"\n table: users\n - id (integer)\n - name (varchar)\n - email (varchar)\n - created_at (timestamp)\n \"#\n previous_conversation \"\"\n }\n user_query \"Show me all users\"\n }\n}\n\ntest complex_query_with_join {\n functions [GenerateSQLQuery]\n args {\n context {\n database_type \"PostgreSQL\"\n schema \"public\"\n tables_and_fields #\"\n table: users\n - id (integer)\n - name (varchar)\n - email (varchar)\n\n table: orders\n - id (integer)\n - user_id (integer)\n - total (decimal)\n - created_at (timestamp)\n \"#\n previous_conversation \"\"\n }\n user_query \"Show me all orders with user names for orders over $100\"\n }\n}\n\n// Function to generate a short chat title from the first user query\nfunction GenerateChatTitle(\n user_query: string\n) -> string {\n client DefaultClient\n prompt #\"\n Generate a simple, natural title (maximum 4 words) for a chat about: \"{{ user_query }}\"\n\n Use casual, everyday language. Avoid formal words like \"Request\", \"Query\", \"Generation\", \"Analysis\".\n\n Examples:\n - \"Show me all users\" → \"All Users\"\n - \"Give me all the users\" → \"All Users\"\n - \"What are the total sales by region?\" → \"Sales by Region\"\n - \"How many orders were placed last month?\" → \"Last Month Orders\"\n - \"Create a table for products\" → \"Create Products Table\"\n\n Return ONLY the title. No quotes, no explanations, no punctuation.\n\n {{ ctx.output_format }}\n \"#\n}\n", +} + +func getBamlFiles() map[string]string { + return file_map +} diff --git a/dataflow/core/baml_client/functions.go b/dataflow/core/baml_client/functions.go new file mode 100644 index 0000000..5d2c0d5 --- /dev/null +++ b/dataflow/core/baml_client/functions.go @@ -0,0 +1,170 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package baml_client + +import ( + "context" + "fmt" + + baml "github.com/boundaryml/baml/engine/language_client_go/pkg" + "github.com/clidey/whodb/core/baml_client/types" +) + +func GenerateChatTitle(ctx context.Context, user_query string, opts ...CallOptionFunc) (string, error) { + + var callOpts callOption + for _, opt := range opts { + opt(&callOpts) + } + + // Resolve client option to clientRegistry (client takes precedence) + if callOpts.client != nil { + if callOpts.clientRegistry == nil { + callOpts.clientRegistry = baml.NewClientRegistry() + } + callOpts.clientRegistry.SetPrimaryClient(*callOpts.client) + } + + args := baml.BamlFunctionArguments{ + Kwargs: map[string]any{"user_query": user_query}, + Env: getEnvVars(callOpts.env), + } + + if callOpts.clientRegistry != nil { + args.ClientRegistry = callOpts.clientRegistry + } + + if callOpts.collectors != nil { + args.Collectors = callOpts.collectors + } + + if callOpts.typeBuilder != nil { + args.TypeBuilder = callOpts.typeBuilder + } + + if callOpts.tags != nil { + args.Tags = callOpts.tags + } + + encoded, err := args.Encode() + if err != nil { + panic(err) + } + + if callOpts.onTick == nil { + result, err := bamlRuntime.CallFunction(ctx, "GenerateChatTitle", encoded, callOpts.onTick) + if err != nil { + return "", err + } + + if result.Error != nil { + return "", result.Error + } + + casted := (result.Data).(string) + + return casted, nil + } else { + channel, err := bamlRuntime.CallFunctionStream(ctx, "GenerateChatTitle", encoded, callOpts.onTick) + if err != nil { + return "", err + } + + for result := range channel { + if result.Error != nil { + return "", result.Error + } + + if result.HasData { + return result.Data.(string), nil + } + } + + return "", fmt.Errorf("No data returned from stream") + } +} + +func GenerateSQLQuery(ctx context.Context, context types.DatabaseContext, user_query string, opts ...CallOptionFunc) ([]types.ChatResponse, error) { + + var callOpts callOption + for _, opt := range opts { + opt(&callOpts) + } + + // Resolve client option to clientRegistry (client takes precedence) + if callOpts.client != nil { + if callOpts.clientRegistry == nil { + callOpts.clientRegistry = baml.NewClientRegistry() + } + callOpts.clientRegistry.SetPrimaryClient(*callOpts.client) + } + + args := baml.BamlFunctionArguments{ + Kwargs: map[string]any{"context": context, "user_query": user_query}, + Env: getEnvVars(callOpts.env), + } + + if callOpts.clientRegistry != nil { + args.ClientRegistry = callOpts.clientRegistry + } + + if callOpts.collectors != nil { + args.Collectors = callOpts.collectors + } + + if callOpts.typeBuilder != nil { + args.TypeBuilder = callOpts.typeBuilder + } + + if callOpts.tags != nil { + args.Tags = callOpts.tags + } + + encoded, err := args.Encode() + if err != nil { + panic(err) + } + + if callOpts.onTick == nil { + result, err := bamlRuntime.CallFunction(ctx, "GenerateSQLQuery", encoded, callOpts.onTick) + if err != nil { + return nil, err + } + + if result.Error != nil { + return nil, result.Error + } + + casted := (result.Data).([]types.ChatResponse) + + return casted, nil + } else { + channel, err := bamlRuntime.CallFunctionStream(ctx, "GenerateSQLQuery", encoded, callOpts.onTick) + if err != nil { + return nil, err + } + + for result := range channel { + if result.Error != nil { + return nil, result.Error + } + + if result.HasData { + return result.Data.([]types.ChatResponse), nil + } + } + + return nil, fmt.Errorf("No data returned from stream") + } +} diff --git a/dataflow/core/baml_client/functions_parse.go b/dataflow/core/baml_client/functions_parse.go new file mode 100644 index 0000000..3821228 --- /dev/null +++ b/dataflow/core/baml_client/functions_parse.go @@ -0,0 +1,120 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package baml_client + +import ( + "context" + "fmt" + + baml "github.com/boundaryml/baml/engine/language_client_go/pkg" + "github.com/clidey/whodb/core/baml_client/types" +) + +type parse struct{} + +var Parse = &parse{} + +// / Parse version of GenerateChatTitle (Takes in string and returns string) +func (*parse) GenerateChatTitle(text string, opts ...CallOptionFunc) (string, error) { + + var callOpts callOption + for _, opt := range opts { + opt(&callOpts) + } + + args := baml.BamlFunctionArguments{ + Kwargs: map[string]any{"text": text, "stream": false}, + Env: getEnvVars(callOpts.env), + } + + if callOpts.clientRegistry != nil { + args.ClientRegistry = callOpts.clientRegistry + } + + if callOpts.collectors != nil { + args.Collectors = callOpts.collectors + } + + if callOpts.typeBuilder != nil { + args.TypeBuilder = callOpts.typeBuilder + } + + if callOpts.tags != nil { + args.Tags = callOpts.tags + } + + encoded, err := args.Encode() + if err != nil { + // This should never happen. if it does, please file an issue at https://github.com/boundaryml/baml/issues + // and include the type of the args you're passing in. + wrapped_err := fmt.Errorf("BAML INTERNAL ERROR: GenerateChatTitle: %w", err) + panic(wrapped_err) + } + + result, err := bamlRuntime.CallFunctionParse(context.Background(), "GenerateChatTitle", encoded) + if err != nil { + return "", err + } + + casted := (result).(string) + + return casted, nil +} + +// / Parse version of GenerateSQLQuery (Takes in string and returns []types.ChatResponse) +func (*parse) GenerateSQLQuery(text string, opts ...CallOptionFunc) ([]types.ChatResponse, error) { + + var callOpts callOption + for _, opt := range opts { + opt(&callOpts) + } + + args := baml.BamlFunctionArguments{ + Kwargs: map[string]any{"text": text, "stream": false}, + Env: getEnvVars(callOpts.env), + } + + if callOpts.clientRegistry != nil { + args.ClientRegistry = callOpts.clientRegistry + } + + if callOpts.collectors != nil { + args.Collectors = callOpts.collectors + } + + if callOpts.typeBuilder != nil { + args.TypeBuilder = callOpts.typeBuilder + } + + if callOpts.tags != nil { + args.Tags = callOpts.tags + } + + encoded, err := args.Encode() + if err != nil { + // This should never happen. if it does, please file an issue at https://github.com/boundaryml/baml/issues + // and include the type of the args you're passing in. + wrapped_err := fmt.Errorf("BAML INTERNAL ERROR: GenerateSQLQuery: %w", err) + panic(wrapped_err) + } + + result, err := bamlRuntime.CallFunctionParse(context.Background(), "GenerateSQLQuery", encoded) + if err != nil { + return nil, err + } + + casted := (result).([]types.ChatResponse) + + return casted, nil +} diff --git a/dataflow/core/baml_client/functions_parse_stream.go b/dataflow/core/baml_client/functions_parse_stream.go new file mode 100644 index 0000000..6f4fd35 --- /dev/null +++ b/dataflow/core/baml_client/functions_parse_stream.go @@ -0,0 +1,120 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package baml_client + +import ( + "context" + "fmt" + + baml "github.com/boundaryml/baml/engine/language_client_go/pkg" + "github.com/clidey/whodb/core/baml_client/stream_types" +) + +type parse_stream struct{} + +var ParseStream = &parse_stream{} + +// / Parse version of GenerateChatTitle (Takes in string and returns string) +func (*parse_stream) GenerateChatTitle(text string, opts ...CallOptionFunc) (string, error) { + + var callOpts callOption + for _, opt := range opts { + opt(&callOpts) + } + + args := baml.BamlFunctionArguments{ + Kwargs: map[string]any{"text": text, "stream": true}, + Env: getEnvVars(callOpts.env), + } + + if callOpts.clientRegistry != nil { + args.ClientRegistry = callOpts.clientRegistry + } + + if callOpts.collectors != nil { + args.Collectors = callOpts.collectors + } + + if callOpts.typeBuilder != nil { + args.TypeBuilder = callOpts.typeBuilder + } + + if callOpts.tags != nil { + args.Tags = callOpts.tags + } + + encoded, err := args.Encode() + if err != nil { + // This should never happen. if it does, please file an issue at https://github.com/boundaryml/baml/issues + // and include the type of the args you're passing in. + wrapped_err := fmt.Errorf("BAML INTERNAL ERROR: GenerateChatTitle: %w", err) + panic(wrapped_err) + } + + result, err := bamlRuntime.CallFunctionParse(context.Background(), "GenerateChatTitle", encoded) + if err != nil { + return "", err + } + + casted := (result).(string) + + return casted, nil +} + +// / Parse version of GenerateSQLQuery (Takes in string and returns []stream_types.ChatResponse) +func (*parse_stream) GenerateSQLQuery(text string, opts ...CallOptionFunc) ([]stream_types.ChatResponse, error) { + + var callOpts callOption + for _, opt := range opts { + opt(&callOpts) + } + + args := baml.BamlFunctionArguments{ + Kwargs: map[string]any{"text": text, "stream": true}, + Env: getEnvVars(callOpts.env), + } + + if callOpts.clientRegistry != nil { + args.ClientRegistry = callOpts.clientRegistry + } + + if callOpts.collectors != nil { + args.Collectors = callOpts.collectors + } + + if callOpts.typeBuilder != nil { + args.TypeBuilder = callOpts.typeBuilder + } + + if callOpts.tags != nil { + args.Tags = callOpts.tags + } + + encoded, err := args.Encode() + if err != nil { + // This should never happen. if it does, please file an issue at https://github.com/boundaryml/baml/issues + // and include the type of the args you're passing in. + wrapped_err := fmt.Errorf("BAML INTERNAL ERROR: GenerateSQLQuery: %w", err) + panic(wrapped_err) + } + + result, err := bamlRuntime.CallFunctionParse(context.Background(), "GenerateSQLQuery", encoded) + if err != nil { + return nil, err + } + + casted := (result).([]stream_types.ChatResponse) + + return casted, nil +} diff --git a/dataflow/core/baml_client/functions_stream.go b/dataflow/core/baml_client/functions_stream.go new file mode 100644 index 0000000..a3dd224 --- /dev/null +++ b/dataflow/core/baml_client/functions_stream.go @@ -0,0 +1,191 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package baml_client + +import ( + "context" + "fmt" + + baml "github.com/boundaryml/baml/engine/language_client_go/pkg" + "github.com/clidey/whodb/core/baml_client/stream_types" + "github.com/clidey/whodb/core/baml_client/types" +) + +type stream struct{} + +var Stream = &stream{} + +type StreamValue[TStream any, TFinal any] struct { + IsError bool + Error error + IsFinal bool + as_final *TFinal + as_stream *TStream +} + +func (s *StreamValue[TStream, TFinal]) Final() *TFinal { + return s.as_final +} + +func (s *StreamValue[TStream, TFinal]) Stream() *TStream { + return s.as_stream +} + +// / Streaming version of GenerateChatTitle +func (*stream) GenerateChatTitle(ctx context.Context, user_query string, opts ...CallOptionFunc) (<-chan StreamValue[string, string], error) { + + var callOpts callOption + for _, opt := range opts { + opt(&callOpts) + } + + args := baml.BamlFunctionArguments{ + Kwargs: map[string]any{"user_query": user_query}, + Env: getEnvVars(callOpts.env), + } + + if callOpts.clientRegistry != nil { + args.ClientRegistry = callOpts.clientRegistry + } + + if callOpts.collectors != nil { + args.Collectors = callOpts.collectors + } + + if callOpts.typeBuilder != nil { + args.TypeBuilder = callOpts.typeBuilder + } + + if callOpts.tags != nil { + args.Tags = callOpts.tags + } + + encoded, err := args.Encode() + if err != nil { + // This should never happen. if it does, please file an issue at https://github.com/boundaryml/baml/issues + // and include the type of the args you're passing in. + wrapped_err := fmt.Errorf("BAML INTERNAL ERROR: GenerateChatTitle: %w", err) + panic(wrapped_err) + } + + internal_channel, err := bamlRuntime.CallFunctionStream(ctx, "GenerateChatTitle", encoded, callOpts.onTick) + if err != nil { + return nil, err + } + + channel := make(chan StreamValue[string, string]) + go func() { + for result := range internal_channel { + if result.Error != nil { + channel <- StreamValue[string, string]{ + IsError: true, + Error: result.Error, + } + close(channel) + return + } + if result.HasData { + data := (result.Data).(string) + channel <- StreamValue[string, string]{ + IsFinal: true, + as_final: &data, + } + } else { + data := (result.StreamData).(string) + channel <- StreamValue[string, string]{ + IsFinal: false, + as_stream: &data, + } + } + } + + // when internal_channel is closed, close the output too + close(channel) + }() + return channel, nil +} + +// / Streaming version of GenerateSQLQuery +func (*stream) GenerateSQLQuery(ctx context.Context, context types.DatabaseContext, user_query string, opts ...CallOptionFunc) (<-chan StreamValue[[]stream_types.ChatResponse, []types.ChatResponse], error) { + + var callOpts callOption + for _, opt := range opts { + opt(&callOpts) + } + + args := baml.BamlFunctionArguments{ + Kwargs: map[string]any{"context": context, "user_query": user_query}, + Env: getEnvVars(callOpts.env), + } + + if callOpts.clientRegistry != nil { + args.ClientRegistry = callOpts.clientRegistry + } + + if callOpts.collectors != nil { + args.Collectors = callOpts.collectors + } + + if callOpts.typeBuilder != nil { + args.TypeBuilder = callOpts.typeBuilder + } + + if callOpts.tags != nil { + args.Tags = callOpts.tags + } + + encoded, err := args.Encode() + if err != nil { + // This should never happen. if it does, please file an issue at https://github.com/boundaryml/baml/issues + // and include the type of the args you're passing in. + wrapped_err := fmt.Errorf("BAML INTERNAL ERROR: GenerateSQLQuery: %w", err) + panic(wrapped_err) + } + + internal_channel, err := bamlRuntime.CallFunctionStream(ctx, "GenerateSQLQuery", encoded, callOpts.onTick) + if err != nil { + return nil, err + } + + channel := make(chan StreamValue[[]stream_types.ChatResponse, []types.ChatResponse]) + go func() { + for result := range internal_channel { + if result.Error != nil { + channel <- StreamValue[[]stream_types.ChatResponse, []types.ChatResponse]{ + IsError: true, + Error: result.Error, + } + close(channel) + return + } + if result.HasData { + data := (result.Data).([]types.ChatResponse) + channel <- StreamValue[[]stream_types.ChatResponse, []types.ChatResponse]{ + IsFinal: true, + as_final: &data, + } + } else { + data := (result.StreamData).([]stream_types.ChatResponse) + channel <- StreamValue[[]stream_types.ChatResponse, []types.ChatResponse]{ + IsFinal: false, + as_stream: &data, + } + } + } + + // when internal_channel is closed, close the output too + close(channel) + }() + return channel, nil +} diff --git a/dataflow/core/baml_client/runtime.go b/dataflow/core/baml_client/runtime.go new file mode 100644 index 0000000..063ef77 --- /dev/null +++ b/dataflow/core/baml_client/runtime.go @@ -0,0 +1,223 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package baml_client + +import ( + "fmt" + "os" + "strings" + + baml "github.com/boundaryml/baml/engine/language_client_go/pkg" + "github.com/clidey/whodb/core/baml_client/type_builder" +) + +var bamlRuntime *baml.BamlRuntime + +func getEnvVars(overrides map[string]string) map[string]string { + var env = map[string]string{} + for _, env_var := range os.Environ() { + key, value, _ := strings.Cut(env_var, "=") + env[key] = value + } + // Since go uses empty strings as zero values for string and not a `null` value, we unset env vars that are empty. + for key, value := range overrides { + if value != "" { + env[key] = value + } else { + delete(env, key) + } + } + return env +} + +func init() { + baml.SetTypeMap(typeMap) + runtime, err := baml.CreateRuntime("./baml_src", getBamlFiles(), getEnvVars(nil)) + if err != nil { + panic(err) + } + bamlRuntime = &runtime +} + +type callOption struct { + client *string + clientRegistry *baml.ClientRegistry + env map[string]string + tags map[string]string + collectors []baml.Collector + onTick baml.OnTickCallbackData + typeBuilder baml.TypeBuilder +} + +type CallOptionFunc func(*callOption) + +// Set the client for the specific function call (shorthand for ClientRegistry with primary). +// If both WithClient and WithClientRegistry are used, WithClient takes precedence. +func WithClient(client string) CallOptionFunc { + return func(o *callOption) { + o.client = &client + } +} + +// Add a client registry to the specific function call. +func WithClientRegistry(clientRegistry *baml.ClientRegistry) CallOptionFunc { + return func(o *callOption) { + o.clientRegistry = clientRegistry + } +} + +// Modify environment variables for the specific function call. +func WithEnv(env map[string]string) CallOptionFunc { + return func(o *callOption) { + o.env = env + } +} + +// Add tags to the specific function call. +func WithTags(tags map[string]string) CallOptionFunc { + return func(o *callOption) { + o.tags = tags + } +} + +// Add collector to the specific function call. +func WithCollector(collector baml.Collector) CallOptionFunc { + return func(o *callOption) { + if o.collectors == nil { + o.collectors = []baml.Collector{} + } + o.collectors = append(o.collectors, collector) + } +} + +type onTickCallbackData struct { + collector baml.Collector + onTick baml.TickCallback +} + +func (o *onTickCallbackData) Collector() baml.Collector { + return o.collector +} + +func (o *onTickCallbackData) OnTick() baml.TickCallback { + return o.onTick +} + +func WithOnTick(onTick baml.TickCallback) CallOptionFunc { + return func(o *callOption) { + collector, err := bamlRuntime.NewCollector("on-tick-collector") + if err != nil { + panic(err) + } + if o.collectors == nil { + o.collectors = []baml.Collector{} + } + o.collectors = append(o.collectors, collector) + o.onTick = &onTickCallbackData{ + collector: collector, + onTick: onTick, + } + } +} + +// Deprecated: Use WithOnTick instead. +func WithExperimentalOnTick(onTick baml.TickCallback) CallOptionFunc { + fmt.Println("Warning: WithExperimentalOnTick is deprecated. Use WithOnTick instead.") + return WithOnTick(onTick) +} + +// Add multiple collectors to the specific function call. +func WithCollectors(collectors []baml.Collector) CallOptionFunc { + return func(o *callOption) { + if collectors == nil { + return + } + if o.collectors == nil { + o.collectors = []baml.Collector{} + } + o.collectors = append(o.collectors, collectors...) + } +} + +// Constructors +type Collector = baml.Collector +type Image = baml.Image +type Audio = baml.Audio +type PDF = baml.PDF +type Video = baml.Video + +func NewCollector(name string) (Collector, error) { + return bamlRuntime.NewCollector(name) +} + +func NewImageFromBase64(base64 string, mimeType *string) (Image, error) { + return bamlRuntime.NewImageFromBase64(base64, mimeType) +} + +func NewAudioFromBase64(base64 string, mimeType *string) (Audio, error) { + return bamlRuntime.NewAudioFromBase64(base64, mimeType) +} + +func NewPDFFromBase64(base64 string, mimeType *string) (PDF, error) { + return bamlRuntime.NewPDFFromBase64(base64, mimeType) +} + +func NewVideoFromBase64(base64 string, mimeType *string) (Video, error) { + return bamlRuntime.NewVideoFromBase64(base64, mimeType) +} + +func NewImageFromUrl(url string, mimeType *string) (Image, error) { + return bamlRuntime.NewImageFromUrl(url, mimeType) +} + +func NewAudioFromUrl(url string, mimeType *string) (Audio, error) { + return bamlRuntime.NewAudioFromUrl(url, mimeType) +} + +func NewPDFFromUrl(url string, mimeType *string) (PDF, error) { + return bamlRuntime.NewPDFFromUrl(url, mimeType) +} + +func NewVideoFromUrl(url string, mimeType *string) (Video, error) { + return bamlRuntime.NewVideoFromUrl(url, mimeType) +} + +type TypeBuilder = type_builder.TypeBuilder + +func NewTypeBuilder() (*TypeBuilder, error) { + tb, err := bamlRuntime.NewTypeBuilder() + if err != nil { + return nil, err + } + return type_builder.InternalNewTypeBuilder(tb), nil +} + +func WithTypeBuilder(tb *TypeBuilder) CallOptionFunc { + return func(o *callOption) { + o.typeBuilder = tb.InternalExport() + } +} diff --git a/dataflow/core/baml_client/stream_types/classes.go b/dataflow/core/baml_client/stream_types/classes.go new file mode 100644 index 0000000..c852b85 --- /dev/null +++ b/dataflow/core/baml_client/stream_types/classes.go @@ -0,0 +1,137 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package stream_types + +import ( + "fmt" + + baml "github.com/boundaryml/baml/engine/language_client_go/pkg" + "github.com/boundaryml/baml/engine/language_client_go/pkg/cffi" + + "github.com/clidey/whodb/core/baml_client/types" +) + +type ChatResponse struct { + Type *types.ChatMessageType `json:"type"` + Operation *types.OperationType `json:"operation"` + Text *string `json:"text"` +} + +func (c *ChatResponse) Decode(holder *cffi.CFFIValueClass, typeMap baml.TypeMap) { + typeName := holder.Name + if typeName.Namespace != cffi.CFFITypeNamespace_STREAM_TYPES { + panic(fmt.Sprintf("expected cffi.CFFITypeNamespace_STREAM_TYPES, got %s", string(typeName.Namespace.String()))) + } + if typeName.Name != "ChatResponse" { + panic(fmt.Sprintf("expected ChatResponse, got %s", typeName.Name)) + } + + for _, field := range holder.Fields { + key := field.Key + valueHolder := field.Value + switch key { + + case "type": + c.Type = baml.Decode(valueHolder).Interface().(*types.ChatMessageType) + + case "operation": + c.Operation = baml.Decode(valueHolder).Interface().(*types.OperationType) + + case "text": + c.Text = baml.Decode(valueHolder).Interface().(*string) + + default: + + panic(fmt.Sprintf("unexpected field: %s in class ChatResponse", key)) + + } + } + +} + +func (c ChatResponse) Encode() (*cffi.HostValue, error) { + fields := map[string]any{} + + fields["type"] = c.Type + + fields["operation"] = c.Operation + + fields["text"] = c.Text + + return baml.EncodeClass("ChatResponse", fields, nil) +} + +func (c ChatResponse) BamlTypeName() string { + return "ChatResponse" +} + +type DatabaseContext struct { + Database_type *string `json:"database_type"` + Schema *string `json:"schema"` + Tables_and_fields *string `json:"tables_and_fields"` + Previous_conversation *string `json:"previous_conversation"` +} + +func (c *DatabaseContext) Decode(holder *cffi.CFFIValueClass, typeMap baml.TypeMap) { + typeName := holder.Name + if typeName.Namespace != cffi.CFFITypeNamespace_STREAM_TYPES { + panic(fmt.Sprintf("expected cffi.CFFITypeNamespace_STREAM_TYPES, got %s", string(typeName.Namespace.String()))) + } + if typeName.Name != "DatabaseContext" { + panic(fmt.Sprintf("expected DatabaseContext, got %s", typeName.Name)) + } + + for _, field := range holder.Fields { + key := field.Key + valueHolder := field.Value + switch key { + + case "database_type": + c.Database_type = baml.Decode(valueHolder).Interface().(*string) + + case "schema": + c.Schema = baml.Decode(valueHolder).Interface().(*string) + + case "tables_and_fields": + c.Tables_and_fields = baml.Decode(valueHolder).Interface().(*string) + + case "previous_conversation": + c.Previous_conversation = baml.Decode(valueHolder).Interface().(*string) + + default: + + panic(fmt.Sprintf("unexpected field: %s in class DatabaseContext", key)) + + } + } + +} + +func (c DatabaseContext) Encode() (*cffi.HostValue, error) { + fields := map[string]any{} + + fields["database_type"] = c.Database_type + + fields["schema"] = c.Schema + + fields["tables_and_fields"] = c.Tables_and_fields + + fields["previous_conversation"] = c.Previous_conversation + + return baml.EncodeClass("DatabaseContext", fields, nil) +} + +func (c DatabaseContext) BamlTypeName() string { + return "DatabaseContext" +} diff --git a/dataflow/core/baml_client/stream_types/type_aliases.go b/dataflow/core/baml_client/stream_types/type_aliases.go new file mode 100644 index 0000000..5ab412b --- /dev/null +++ b/dataflow/core/baml_client/stream_types/type_aliases.go @@ -0,0 +1,14 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package stream_types diff --git a/dataflow/core/baml_client/stream_types/unions.go b/dataflow/core/baml_client/stream_types/unions.go new file mode 100644 index 0000000..5ab412b --- /dev/null +++ b/dataflow/core/baml_client/stream_types/unions.go @@ -0,0 +1,14 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package stream_types diff --git a/dataflow/core/baml_client/stream_types/utils.go b/dataflow/core/baml_client/stream_types/utils.go new file mode 100644 index 0000000..5ab412b --- /dev/null +++ b/dataflow/core/baml_client/stream_types/utils.go @@ -0,0 +1,14 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package stream_types diff --git a/dataflow/core/baml_client/type_builder/classes.go b/dataflow/core/baml_client/type_builder/classes.go new file mode 100644 index 0000000..5027c86 --- /dev/null +++ b/dataflow/core/baml_client/type_builder/classes.go @@ -0,0 +1,100 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package type_builder + +import baml "github.com/boundaryml/baml/engine/language_client_go/pkg" + +type ChatResponseClassView struct { + inner baml.ClassBuilder +} + +func (t *ChatResponseClassView) ListProperties() ([]ClassPropertyView, error) { + result, err := t.inner.ListProperties() + if err != nil { + return nil, err + } + builders := make([]ClassPropertyView, len(result)) + for i, p := range result { + builders[i] = p + } + return builders, nil +} + +func (t *ChatResponseClassView) PropertyType() (ClassPropertyView, error) { + return t.inner.Property("type") +} + +func (t *ChatResponseClassView) PropertyOperation() (ClassPropertyView, error) { + return t.inner.Property("operation") +} + +func (t *ChatResponseClassView) PropertyText() (ClassPropertyView, error) { + return t.inner.Property("text") +} + +func (t *TypeBuilder) ChatResponse() (*ChatResponseClassView, error) { + bld, err := t.inner.Class("ChatResponse") + if err != nil { + return nil, err + } + return &ChatResponseClassView{inner: bld}, nil +} + +func (t *ChatResponseClassView) Type() (baml.Type, error) { + return t.inner.Type() +} + +type DatabaseContextClassView struct { + inner baml.ClassBuilder +} + +func (t *DatabaseContextClassView) ListProperties() ([]ClassPropertyView, error) { + result, err := t.inner.ListProperties() + if err != nil { + return nil, err + } + builders := make([]ClassPropertyView, len(result)) + for i, p := range result { + builders[i] = p + } + return builders, nil +} + +func (t *DatabaseContextClassView) PropertyDatabase_type() (ClassPropertyView, error) { + return t.inner.Property("database_type") +} + +func (t *DatabaseContextClassView) PropertySchema() (ClassPropertyView, error) { + return t.inner.Property("schema") +} + +func (t *DatabaseContextClassView) PropertyTables_and_fields() (ClassPropertyView, error) { + return t.inner.Property("tables_and_fields") +} + +func (t *DatabaseContextClassView) PropertyPrevious_conversation() (ClassPropertyView, error) { + return t.inner.Property("previous_conversation") +} + +func (t *TypeBuilder) DatabaseContext() (*DatabaseContextClassView, error) { + bld, err := t.inner.Class("DatabaseContext") + if err != nil { + return nil, err + } + return &DatabaseContextClassView{inner: bld}, nil +} + +func (t *DatabaseContextClassView) Type() (baml.Type, error) { + return t.inner.Type() +} diff --git a/dataflow/core/baml_client/type_builder/enums.go b/dataflow/core/baml_client/type_builder/enums.go new file mode 100644 index 0000000..7ddfdd7 --- /dev/null +++ b/dataflow/core/baml_client/type_builder/enums.go @@ -0,0 +1,116 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package type_builder + +import baml "github.com/boundaryml/baml/engine/language_client_go/pkg" + +type ChatMessageTypeEnumView struct { + inner baml.EnumBuilder +} + +func (t *ChatMessageTypeEnumView) ListValues() ([]EnumValueView, error) { + result, err := t.inner.ListValues() + if err != nil { + return nil, err + } + builders := make([]EnumValueView, len(result)) + for i, p := range result { + builders[i] = p + } + return builders, nil +} + +func (t *ChatMessageTypeEnumView) ValueSQL() (EnumValueView, error) { + return t.inner.Value("SQL") +} + +func (t *ChatMessageTypeEnumView) ValueMESSAGE() (EnumValueView, error) { + return t.inner.Value("MESSAGE") +} + +func (t *ChatMessageTypeEnumView) ValueERROR() (EnumValueView, error) { + return t.inner.Value("ERROR") +} + +func (t *TypeBuilder) ChatMessageType() (*ChatMessageTypeEnumView, error) { + bld, err := t.inner.Enum("ChatMessageType") + if err != nil { + return nil, err + } + return &ChatMessageTypeEnumView{inner: bld}, nil +} + +func (t *ChatMessageTypeEnumView) Type() (baml.Type, error) { + return t.inner.Type() +} + +type OperationTypeEnumView struct { + inner baml.EnumBuilder +} + +func (t *OperationTypeEnumView) ListValues() ([]EnumValueView, error) { + result, err := t.inner.ListValues() + if err != nil { + return nil, err + } + builders := make([]EnumValueView, len(result)) + for i, p := range result { + builders[i] = p + } + return builders, nil +} + +func (t *OperationTypeEnumView) ValueGET() (EnumValueView, error) { + return t.inner.Value("GET") +} + +func (t *OperationTypeEnumView) ValueINSERT() (EnumValueView, error) { + return t.inner.Value("INSERT") +} + +func (t *OperationTypeEnumView) ValueUPDATE() (EnumValueView, error) { + return t.inner.Value("UPDATE") +} + +func (t *OperationTypeEnumView) ValueDELETE() (EnumValueView, error) { + return t.inner.Value("DELETE") +} + +func (t *OperationTypeEnumView) ValueCREATE() (EnumValueView, error) { + return t.inner.Value("CREATE") +} + +func (t *OperationTypeEnumView) ValueALTER() (EnumValueView, error) { + return t.inner.Value("ALTER") +} + +func (t *OperationTypeEnumView) ValueDROP() (EnumValueView, error) { + return t.inner.Value("DROP") +} + +func (t *OperationTypeEnumView) ValueTEXT() (EnumValueView, error) { + return t.inner.Value("TEXT") +} + +func (t *TypeBuilder) OperationType() (*OperationTypeEnumView, error) { + bld, err := t.inner.Enum("OperationType") + if err != nil { + return nil, err + } + return &OperationTypeEnumView{inner: bld}, nil +} + +func (t *OperationTypeEnumView) Type() (baml.Type, error) { + return t.inner.Type() +} diff --git a/dataflow/core/baml_client/type_builder/type_builder.go b/dataflow/core/baml_client/type_builder/type_builder.go new file mode 100644 index 0000000..9796eec --- /dev/null +++ b/dataflow/core/baml_client/type_builder/type_builder.go @@ -0,0 +1,235 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package type_builder + +import ( + "fmt" + + baml "github.com/boundaryml/baml/engine/language_client_go/pkg" +) + +type Type = baml.Type + +type llmViewOnly interface { + Description() (*string, error) + Alias() (*string, error) + From() (baml.ASTNodeSource, error) + Name() (string, error) +} + +type llmWriteOnly interface { + SetDescription(description string) error + SetAlias(alias string) error +} + +type enumBase interface { + llmViewOnly + Type() (Type, error) +} + +type EnumView interface { + enumBase + ListValues() ([]EnumValueView, error) + Value(name string) (EnumValueView, error) +} + +type EnumBuilder interface { + enumBase + llmWriteOnly + ListValues() ([]EnumValueBuilder, error) + AddValue(value string) (EnumValueBuilder, error) + Value(name string) (EnumValueBuilder, error) +} + +type EnumValueView interface { + llmViewOnly +} + +type EnumValueBuilder interface { + EnumValueView + llmWriteOnly + SetSkip(skip bool) error +} + +type classBase interface { + llmViewOnly + Type() (Type, error) +} + +type ClassView interface { + classBase + ListProperties() ([]ClassPropertyView, error) + Property(name string) (ClassPropertyView, error) +} + +type ClassBuilder interface { + classBase + llmWriteOnly + ListProperties() ([]ClassPropertyBuilder, error) + AddProperty(name string, fieldType Type) (ClassPropertyBuilder, error) + Property(name string) (ClassPropertyBuilder, error) +} + +type ClassPropertyView interface { + llmViewOnly + Type() (Type, error) +} + +type ClassPropertyBuilder interface { + ClassPropertyView + llmWriteOnly + SetType(fieldType Type) error +} + +type TypeBuilder struct { + inner baml.TypeBuilder +} + +func (t *TypeBuilder) InternalExport() baml.TypeBuilder { + return t.inner +} + +func InternalNewTypeBuilder(inner baml.TypeBuilder) *TypeBuilder { + return &TypeBuilder{inner: inner} +} + +func (t *TypeBuilder) String() (Type, error) { + return t.inner.String() +} + +func (t *TypeBuilder) Int() (Type, error) { + return t.inner.Int() +} + +func (t *TypeBuilder) Float() (Type, error) { + return t.inner.Float() +} + +func (t *TypeBuilder) Bool() (Type, error) { + return t.inner.Bool() +} + +func (t *TypeBuilder) Null() (Type, error) { + return t.inner.Null() +} + +func (t *TypeBuilder) LiteralString(value string) (Type, error) { + return t.inner.LiteralString(value) +} + +func (t *TypeBuilder) LiteralInt(value int64) (Type, error) { + return t.inner.LiteralInt(value) +} + +func (t *TypeBuilder) LiteralBool(value bool) (Type, error) { + return t.inner.LiteralBool(value) +} + +func (t *TypeBuilder) Map(key Type, value baml.Type) (Type, error) { + return t.inner.Map(key, value) +} + +func (t *TypeBuilder) List(inner baml.Type) (Type, error) { + return t.inner.List(inner) +} + +func (t *TypeBuilder) Optional(inner baml.Type) (Type, error) { + return t.inner.Optional(inner) +} + +func (t *TypeBuilder) Union(types []baml.Type) (Type, error) { + return t.inner.Union(types) +} + +func (t *TypeBuilder) AddBaml(baml string) error { + return t.inner.AddBaml(baml) +} + +func (t *TypeBuilder) AddEnum(name string) (EnumBuilder, error) { + bld, err := t.inner.AddEnum(name) + if err != nil { + return nil, err + } + return &dynamicEnumBuilder{EnumBuilder: bld}, nil +} + +func (t *TypeBuilder) AddClass(name string) (ClassBuilder, error) { + bld, err := t.inner.AddClass(name) + if err != nil { + return nil, err + } + return &dynamicClassBuilder{ClassBuilder: bld}, nil +} + +// override the default stringer to use the type builder's print method +func (t *TypeBuilder) Format(f fmt.State, verb rune) { + display := t.inner.Print() + fmt.Fprint(f, display) +} + +// ---------------------------------------------------------------------------- +// Dynamic builders: Enums (enums defined purely in the type builder) +// ---------------------------------------------------------------------------- + +type dynamicEnumBuilder struct { + baml.EnumBuilder +} + +func (t dynamicEnumBuilder) AddValue(value string) (EnumValueBuilder, error) { + return t.EnumBuilder.AddValue(value) +} + +func (t dynamicEnumBuilder) ListValues() ([]EnumValueBuilder, error) { + values, err := t.EnumBuilder.ListValues() + if err != nil { + return nil, err + } + builders := make([]EnumValueBuilder, len(values)) + for i, v := range values { + builders[i] = v + } + return builders, nil +} + +func (t dynamicEnumBuilder) Value(name string) (EnumValueBuilder, error) { + return t.EnumBuilder.Value(name) +} + +// ---------------------------------------------------------------------------- +// Dynamic builders: Classes (classes defined purely in the type builder) +// ---------------------------------------------------------------------------- + +type dynamicClassBuilder struct { + baml.ClassBuilder +} + +func (t dynamicClassBuilder) AddProperty(name string, fieldType Type) (ClassPropertyBuilder, error) { + return t.ClassBuilder.AddProperty(name, fieldType) +} + +func (t dynamicClassBuilder) ListProperties() ([]ClassPropertyBuilder, error) { + properties, err := t.ClassBuilder.ListProperties() + if err != nil { + return nil, err + } + builders := make([]ClassPropertyBuilder, len(properties)) + for i, p := range properties { + builders[i] = p + } + return builders, nil +} + +func (t dynamicClassBuilder) Property(name string) (ClassPropertyBuilder, error) { + return t.ClassBuilder.Property(name) +} diff --git a/dataflow/core/baml_client/type_map.go b/dataflow/core/baml_client/type_map.go new file mode 100644 index 0000000..357125a --- /dev/null +++ b/dataflow/core/baml_client/type_map.go @@ -0,0 +1,31 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package baml_client + +import ( + "reflect" + + "github.com/clidey/whodb/core/baml_client/stream_types" + "github.com/clidey/whodb/core/baml_client/types" +) + +var typeMap = map[string]reflect.Type{ + "TYPES.ChatResponse": reflect.TypeOf(types.ChatResponse{}), + "STREAM_TYPES.ChatResponse": reflect.TypeOf(stream_types.ChatResponse{}), + "TYPES.DatabaseContext": reflect.TypeOf(types.DatabaseContext{}), + "STREAM_TYPES.DatabaseContext": reflect.TypeOf(stream_types.DatabaseContext{}), + + "TYPES.ChatMessageType": reflect.TypeOf(types.ChatMessageType("")), + "TYPES.OperationType": reflect.TypeOf(types.OperationType("")), +} diff --git a/dataflow/core/baml_client/types/classes.go b/dataflow/core/baml_client/types/classes.go new file mode 100644 index 0000000..e14f716 --- /dev/null +++ b/dataflow/core/baml_client/types/classes.go @@ -0,0 +1,135 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package types + +import ( + "fmt" + + baml "github.com/boundaryml/baml/engine/language_client_go/pkg" + "github.com/boundaryml/baml/engine/language_client_go/pkg/cffi" +) + +type ChatResponse struct { + Type ChatMessageType `json:"type"` + Operation *OperationType `json:"operation"` + Text string `json:"text"` +} + +func (c *ChatResponse) Decode(holder *cffi.CFFIValueClass, typeMap baml.TypeMap) { + typeName := holder.Name + if typeName.Namespace != cffi.CFFITypeNamespace_TYPES { + panic(fmt.Sprintf("expected cffi.CFFITypeNamespace_TYPES, got %s", string(typeName.Namespace.String()))) + } + if typeName.Name != "ChatResponse" { + panic(fmt.Sprintf("expected ChatResponse, got %s", typeName.Name)) + } + + for _, field := range holder.Fields { + key := field.Key + valueHolder := field.Value + switch key { + + case "type": + c.Type = baml.Decode(valueHolder).Interface().(ChatMessageType) + + case "operation": + c.Operation = baml.Decode(valueHolder).Interface().(*OperationType) + + case "text": + c.Text = baml.Decode(valueHolder).Interface().(string) + + default: + + panic(fmt.Sprintf("unexpected field: %s in class ChatResponse", key)) + + } + } + +} + +func (c ChatResponse) Encode() (*cffi.HostValue, error) { + fields := map[string]any{} + + fields["type"] = c.Type + + fields["operation"] = c.Operation + + fields["text"] = c.Text + + return baml.EncodeClass("ChatResponse", fields, nil) +} + +func (c ChatResponse) BamlTypeName() string { + return "ChatResponse" +} + +type DatabaseContext struct { + Database_type string `json:"database_type"` + Schema string `json:"schema"` + Tables_and_fields string `json:"tables_and_fields"` + Previous_conversation string `json:"previous_conversation"` +} + +func (c *DatabaseContext) Decode(holder *cffi.CFFIValueClass, typeMap baml.TypeMap) { + typeName := holder.Name + if typeName.Namespace != cffi.CFFITypeNamespace_TYPES { + panic(fmt.Sprintf("expected cffi.CFFITypeNamespace_TYPES, got %s", string(typeName.Namespace.String()))) + } + if typeName.Name != "DatabaseContext" { + panic(fmt.Sprintf("expected DatabaseContext, got %s", typeName.Name)) + } + + for _, field := range holder.Fields { + key := field.Key + valueHolder := field.Value + switch key { + + case "database_type": + c.Database_type = baml.Decode(valueHolder).Interface().(string) + + case "schema": + c.Schema = baml.Decode(valueHolder).Interface().(string) + + case "tables_and_fields": + c.Tables_and_fields = baml.Decode(valueHolder).Interface().(string) + + case "previous_conversation": + c.Previous_conversation = baml.Decode(valueHolder).Interface().(string) + + default: + + panic(fmt.Sprintf("unexpected field: %s in class DatabaseContext", key)) + + } + } + +} + +func (c DatabaseContext) Encode() (*cffi.HostValue, error) { + fields := map[string]any{} + + fields["database_type"] = c.Database_type + + fields["schema"] = c.Schema + + fields["tables_and_fields"] = c.Tables_and_fields + + fields["previous_conversation"] = c.Previous_conversation + + return baml.EncodeClass("DatabaseContext", fields, nil) +} + +func (c DatabaseContext) BamlTypeName() string { + return "DatabaseContext" +} diff --git a/dataflow/core/baml_client/types/enums.go b/dataflow/core/baml_client/types/enums.go new file mode 100644 index 0000000..ea33465 --- /dev/null +++ b/dataflow/core/baml_client/types/enums.go @@ -0,0 +1,166 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package types + +import ( + "encoding/json" + "fmt" + + baml "github.com/boundaryml/baml/engine/language_client_go/pkg" + "github.com/boundaryml/baml/engine/language_client_go/pkg/cffi" +) + +type ChatMessageType string + +const ( + ChatMessageTypeSQL ChatMessageType = "SQL" + ChatMessageTypeMESSAGE ChatMessageType = "MESSAGE" + ChatMessageTypeERROR ChatMessageType = "ERROR" +) + +// Values returns all allowed values for the ChatMessageType type. +func (ChatMessageType) Values() []ChatMessageType { + return []ChatMessageType{ + ChatMessageTypeSQL, + ChatMessageTypeMESSAGE, + ChatMessageTypeERROR, + } +} + +// IsValid checks whether the given ChatMessageType value is valid. +func (e ChatMessageType) IsValid() bool { + + for _, v := range e.Values() { + if e == v { + return true + } + } + return false + +} + +// MarshalJSON customizes JSON marshaling for ChatMessageType. +func (e ChatMessageType) MarshalJSON() ([]byte, error) { + if !e.IsValid() { + return nil, fmt.Errorf("invalid ChatMessageType: %q", e) + } + return json.Marshal(string(e)) +} + +// UnmarshalJSON customizes JSON unmarshaling for ChatMessageType. +func (e *ChatMessageType) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *e = ChatMessageType(s) + if !e.IsValid() { + return fmt.Errorf("invalid ChatMessageType: %q", s) + } + return nil +} + +func (e *ChatMessageType) Decode(holder *cffi.CFFIValueEnum, typeMap baml.TypeMap) { + name := holder.Name + if name.Name != "ChatMessageType" && name.Namespace != cffi.CFFITypeNamespace_TYPES { + panic(fmt.Sprintf("expected types.ChatMessageType, got %s.%s", string(name.Namespace.String()), string(name.Name))) + } + value := holder.Value + *e = ChatMessageType(value) +} + +func (e ChatMessageType) Encode() (*cffi.HostValue, error) { + return baml.EncodeEnum("ChatMessageType", string(e), false) +} + +func (e ChatMessageType) BamlTypeName() string { + return "ChatMessageType" +} + +type OperationType string + +const ( + OperationTypeGET OperationType = "GET" + OperationTypeINSERT OperationType = "INSERT" + OperationTypeUPDATE OperationType = "UPDATE" + OperationTypeDELETE OperationType = "DELETE" + OperationTypeCREATE OperationType = "CREATE" + OperationTypeALTER OperationType = "ALTER" + OperationTypeDROP OperationType = "DROP" + OperationTypeTEXT OperationType = "TEXT" +) + +// Values returns all allowed values for the OperationType type. +func (OperationType) Values() []OperationType { + return []OperationType{ + OperationTypeGET, + OperationTypeINSERT, + OperationTypeUPDATE, + OperationTypeDELETE, + OperationTypeCREATE, + OperationTypeALTER, + OperationTypeDROP, + OperationTypeTEXT, + } +} + +// IsValid checks whether the given OperationType value is valid. +func (e OperationType) IsValid() bool { + + for _, v := range e.Values() { + if e == v { + return true + } + } + return false + +} + +// MarshalJSON customizes JSON marshaling for OperationType. +func (e OperationType) MarshalJSON() ([]byte, error) { + if !e.IsValid() { + return nil, fmt.Errorf("invalid OperationType: %q", e) + } + return json.Marshal(string(e)) +} + +// UnmarshalJSON customizes JSON unmarshaling for OperationType. +func (e *OperationType) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *e = OperationType(s) + if !e.IsValid() { + return fmt.Errorf("invalid OperationType: %q", s) + } + return nil +} + +func (e *OperationType) Decode(holder *cffi.CFFIValueEnum, typeMap baml.TypeMap) { + name := holder.Name + if name.Name != "OperationType" && name.Namespace != cffi.CFFITypeNamespace_TYPES { + panic(fmt.Sprintf("expected types.OperationType, got %s.%s", string(name.Namespace.String()), string(name.Name))) + } + value := holder.Value + *e = OperationType(value) +} + +func (e OperationType) Encode() (*cffi.HostValue, error) { + return baml.EncodeEnum("OperationType", string(e), false) +} + +func (e OperationType) BamlTypeName() string { + return "OperationType" +} diff --git a/dataflow/core/baml_client/types/type_aliases.go b/dataflow/core/baml_client/types/type_aliases.go new file mode 100644 index 0000000..1cd53a9 --- /dev/null +++ b/dataflow/core/baml_client/types/type_aliases.go @@ -0,0 +1,14 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package types diff --git a/dataflow/core/baml_client/types/unions.go b/dataflow/core/baml_client/types/unions.go new file mode 100644 index 0000000..1cd53a9 --- /dev/null +++ b/dataflow/core/baml_client/types/unions.go @@ -0,0 +1,14 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package types diff --git a/dataflow/core/baml_client/types/utils.go b/dataflow/core/baml_client/types/utils.go new file mode 100644 index 0000000..9a5dfbf --- /dev/null +++ b/dataflow/core/baml_client/types/utils.go @@ -0,0 +1,25 @@ +// ---------------------------------------------------------------------------- +// +// Welcome to Baml! To use this generated code, please run the following: +// +// $ go get github.com/boundaryml/baml +// +// ---------------------------------------------------------------------------- + +// This file was generated by BAML: please do not edit it. Instead, edit the +// BAML files and re-generate this code using: baml-cli generate +// You can install baml-cli with: +// $ go install github.com/boundaryml/baml/baml-cli + +package types + +import ( + baml "github.com/boundaryml/baml/engine/language_client_go/pkg" +) + +type Checked[T any] = baml.Checked[T] + +type Image baml.Image +type Audio baml.Audio +type Video baml.Video +type PDF baml.PDF diff --git a/dataflow/core/baml_src/clients.baml b/dataflow/core/baml_src/clients.baml new file mode 100644 index 0000000..ba15718 --- /dev/null +++ b/dataflow/core/baml_src/clients.baml @@ -0,0 +1,34 @@ +// Learn more about clients at https://docs.boundaryml.com/docs/snippets/clients/overview +// +// NOTE: These are fallback clients used when dynamic client creation fails. +// Primary client configuration is done at runtime via CreateDynamicBAMLClient() +// using user-selected providers, models, and API keys from the UI. + +// Default fallback client - uses OpenAI if environment variable is set +client DefaultClient { + provider openai + retry_policy Exponential + options { + model "gpt-4o" + api_key env.OPENAI_API_KEY + } +} + +// Retry policies for robust API calls +retry_policy Constant { + max_retries 3 + strategy { + type constant_delay + delay_ms 200 + } +} + +retry_policy Exponential { + max_retries 2 + strategy { + type exponential_backoff + delay_ms 300 + multiplier 1.5 + max_delay_ms 10000 + } +} diff --git a/dataflow/core/baml_src/generators.baml b/dataflow/core/baml_src/generators.baml new file mode 100644 index 0000000..66ad21e --- /dev/null +++ b/dataflow/core/baml_src/generators.baml @@ -0,0 +1,22 @@ +// This helps use auto generate libraries you can use in the language of +// your choice. You can have multiple generators if you use multiple languages. +// Just ensure that the output_dir is different for each generator. +generator target { + // Valid values: "python/pydantic", "typescript", "ruby/sorbet", "rest/openapi" + output_type "go" + + // Where the generated code will be saved (relative to baml_src/) + output_dir "../" + + // The version of the BAML package you have installed (e.g. same version as your baml-py or @boundaryml/baml). + // The BAML VSCode extension version should also match this version. + version "0.218.1" + + // 'baml-cli generate' will run this after generating go code + // This command will be run from within $output_dir/baml_client + on_generate "gofmt -w . && goimports -w ." + + // Your Go packages name as specified in go.mod + // We need this to generate correct imports in the generated baml_client + client_package_name "github.com/clidey/whodb/core" +} diff --git a/dataflow/core/baml_src/sql_chat.baml b/dataflow/core/baml_src/sql_chat.baml new file mode 100644 index 0000000..61e8693 --- /dev/null +++ b/dataflow/core/baml_src/sql_chat.baml @@ -0,0 +1,303 @@ +// SQL Query Generation for WhoDB +// This BAML schema defines structured SQL generation using AI models + +// Database operation types +enum OperationType { + GET @alias("get") @description("SELECT queries to retrieve data") + INSERT @alias("insert") @description("INSERT queries to add data") + UPDATE @alias("update") @description("UPDATE queries to modify data") + DELETE @alias("delete") @description("DELETE queries to remove data") + CREATE @alias("create") @description("CREATE TABLE, CREATE INDEX and other schema creation operations") + ALTER @alias("alter") @description("ALTER TABLE and other schema modification operations") + DROP @alias("drop") @description("DROP TABLE, DROP INDEX and other schema deletion operations") + TEXT @alias("text") @description("General text responses without SQL") +} + +// Chat message type +enum ChatMessageType { + SQL @alias("sql") @description("SQL query response") + MESSAGE @alias("message") @description("Textual response without query") + ERROR @alias("error") @description("Error response") +} + +// Chat response structure matching engine.ChatMessage +class ChatResponse { + type ChatMessageType @description("Type of response: sql or message") + operation OperationType? @description("SQL operation type if applicable") + text string @description("The SQL query or response text") +} + +// Database context for SQL generation +class DatabaseContext { + database_type string @description("Database type (e.g., PostgreSQL, MySQL)") + schema string @description("Database schema name") + tables_and_fields string @description("Available tables with their field definitions") + previous_conversation string @description("Previous conversation history for context") +} + +// Main function to generate SQL queries from natural language +function GenerateSQLQuery( + context: DatabaseContext, + user_query: string +) -> ChatResponse[] { + client DefaultClient + prompt #" + You are a friendly and helpful data engineer working with a {{ context.database_type }} database. Think of yourself as a colleague who's here to help explore and understand the data. + + Database Context: + Schema: {{ context.schema }} + + Available Tables and Fields: + {{ context.tables_and_fields }} + + ### CRITICAL: JSON FORMAT ONLY - NO EXCEPTIONS + + YOU MUST RETURN ONLY A JSON ARRAY. START YOUR RESPONSE WITH [ AND END WITH ] + NO PLAIN TEXT. NO EXPLANATIONS OUTSIDE JSON. ONLY THE JSON ARRAY. + + EXACT FORMAT: + [{"type": "message", "operation": null, "text": "your message here"}] + + For ONLY simple greetings (Hi, Hello, Hey - nothing else): + [{"type": "message", "operation": null, "text": "Hi! I'm here to help you work with the {% if context.schema %}{{ context.schema }} {% endif %}database. What would you like to know?"}] + + For any other request (even if it starts with Hi), ANSWER THE ACTUAL REQUEST, don't just greet. + + For data requests, return TWO items (message explaining + SQL query): + [{"type": "message", "operation": null, "text": "I'll get the users for you."}, {"type": "sql", "operation": "get", "text": "SELECT * FROM {% if context.schema %}{{ context.schema }}.{% endif %}users LIMIT 100;"}] + + ### CRITICAL: ALWAYS RESPOND TO THE USER'S ACTUAL QUESTION + - READ the user's request carefully and answer THAT SPECIFIC question + - DO NOT give generic greetings unless the user is specifically greeting you (hi, hello, etc.) + - If the user asks you to do something specific (create data, run a query, etc.), DO THAT - don't give an introduction + - ONLY mention tables that ACTUALLY EXIST in the "Available Tables and Fields" section above + - If NO tables are listed in "Available Tables and Fields", DO NOT mention any table names + - NEVER hallucinate or make up table names, database names, or data that isn't provided in the context + + ### Your Personality and Approach: + - Be warm, conversational, and approachable - like a helpful colleague, not a robot + - When greeting or introducing yourself, ONLY talk about DATA that ACTUALLY EXISTS in the context above + - Show genuine interest in helping the user understand their data + - Provide context and insights about the data, not just queries + - Ask clarifying questions when needed + - Explain your reasoning and thought process + - Be proactive in suggesting relevant analyses based on ACTUAL available tables only + + ### Response Format: + You must structure your responses with these fields: + + **type** (required): + - "message" - for conversational text, explanations, greetings, or insights + - "sql" - when generating an actual SQL query + + **operation** (required when type="sql", otherwise null): + - "get" - for SELECT queries to retrieve data + - "insert" - for adding new records + - "update" - for modifying existing records + - "delete" - for removing records + - "create" - for CREATE TABLE, CREATE INDEX and other schema creation operations + - "alter" - for ALTER TABLE and other schema modification operations + - "drop" - for DROP TABLE, DROP INDEX and other schema deletion operations + + **text** (required): + - Your conversational message, explanation, or SQL query + + ### CRITICAL SQL RULE - READ THIS CAREFULLY: + {% if context.schema %} + **EVERY table reference in SQL queries MUST include the schema name: {{ context.schema }}.table_name** + + ❌ WRONG: SELECT * FROM users + ❌ WRONG: SELECT * FROM orders + ❌ WRONG: SELECT * FROM test_casting + + ✅ CORRECT: SELECT * FROM {{ context.schema }}.users + ✅ CORRECT: SELECT * FROM {{ context.schema }}.orders + ✅ CORRECT: SELECT * FROM {{ context.schema }}.test_casting + + This is MANDATORY for all queries. Queries without schema names WILL FAIL. + {% else %} + **This database does NOT use schemas. Reference tables by name only, WITHOUT any schema prefix.** + + ❌ WRONG: SELECT * FROM .users (no leading dot!) + ❌ WRONG: SELECT * FROM schema.users (no schema prefix!) + + ✅ CORRECT: SELECT * FROM users + ✅ CORRECT: SELECT * FROM orders + ✅ CORRECT: SELECT * FROM test_casting + + This is MANDATORY for all queries. Do NOT add any schema or dot prefix. + {% endif %} + + ### How to Handle Common Scenarios: + + {% if context.tables_and_fields and context.tables_and_fields|trim %} + **When tables exist - Greetings (Hi, Hello, etc.):** + - Warmly greet the user + - Briefly mention what database/schema you're working with + - Mention 2-3 ACTUAL tables from the available tables (ONLY use tables from the "Available Tables and Fields" section above - NEVER make up table names) + - Invite them to ask about specific data or analyses + - Example: "Hi! I'm here to help you explore the {% if context.schema %}{{ context.schema }} {% endif %}database. I can see you have tables like [ACTUAL TABLE NAMES FROM CONTEXT]. What would you like to know?" + + **When tables exist - "What can you do?" or similar:** + - Focus on the DATA that ACTUALLY EXISTS + - Mention the types of analyses possible with their ACTUAL tables (from "Available Tables and Fields") + - Give concrete examples based on ONLY the tables listed above - NEVER mention tables that aren't in the list + - Example: "I can help you explore your data! You have [ACTUAL TABLE NAMES] - we could analyze patterns, look at relationships, or explore any specific metrics. What would you like to explore?" + {% else %} + **When NO tables exist - Empty database:** + - Acknowledge that the database/schema currently has no tables + - Explain that you can help create tables or run queries once tables exist + - Be honest and helpful + - Example: "Hi! I'm here to help you work with the {% if context.schema %}{{ context.schema }} {% endif %}database. It looks like there are no tables set up yet. I can help you create tables or run queries once your schema has data. What would you like to do?" + + **When NO tables exist - Data requests:** + - Politely explain that no tables exist yet + - Cannot generate SELECT queries for non-existent tables + - Offer to help create tables instead + - Example: "I don't see any tables in the {% if context.schema %}{{ context.schema }} {% endif %}database yet. Would you like me to help you create some tables first?" + {% endif %} + + **Data exploration requests:** + - First provide a brief explanation of what you'll retrieve and why it's useful + - Then provide the SQL query {% if context.schema %}with FULL schema names{% else %}WITHOUT any schema prefix{% endif %} + - Return both a message and a sql response + - Example query structure: SELECT * FROM {% if context.schema %}{{ context.schema }}.{% endif %}users LIMIT 100; + + **Schema modification requests (CREATE TABLE, ALTER TABLE, DROP TABLE):** + - First provide a brief explanation of what you'll do + - Then provide the DDL SQL query {% if context.schema %}with FULL schema names{% else %}WITHOUT any schema prefix{% endif %} + - Return both a message and a sql response with the appropriate operation type (create/alter/drop) + - Always use appropriate data types for the target database + - Example: [{"type": "message", "operation": null, "text": "I'll create a countries table with id and name columns."}, {"type": "sql", "operation": "create", "text": "CREATE TABLE {% if context.schema %}{{ context.schema }}.{% endif %}countries (id INTEGER PRIMARY KEY, name VARCHAR(100));"}] + + **Ambiguous requests:** + - Ask friendly clarifying questions + - Suggest a few specific options based on available data + - Help narrow down what they're looking for + + ### SQL Query Examples ({% if context.schema %}Always use schema prefix!{% else %}NO schema prefix for this database{% endif %}): + + **Simple select:** + SELECT * FROM {% if context.schema %}{{ context.schema }}.{% endif %}users LIMIT 100; + + **Join query:** + SELECT u.name, o.total + FROM {% if context.schema %}{{ context.schema }}.{% endif %}users u + JOIN {% if context.schema %}{{ context.schema }}.{% endif %}orders o ON u.id = o.user_id + LIMIT 50; + + **Aggregate query:** + SELECT status, COUNT(*) as count + FROM {% if context.schema %}{{ context.schema }}.{% endif %}orders + GROUP BY status; + + ### Additional Guidelines: + - For large result sets, warn the user and add LIMIT clauses + - Before DELETE/UPDATE operations, always confirm with the user first + - When you can provide both explanation and query, do so (message first, then sql) + + ### CRITICAL: ONE SQL STATEMENT PER RESPONSE OBJECT + **Each SQL query MUST be in its own separate ChatResponse object in the array.** + + ❌ WRONG - Multiple statements in one text field: + [{"type": "sql", "operation": "get", "text": "SELECT * FROM schema.users; SELECT * FROM schema.orders;"}] + + ✅ CORRECT - Separate objects for each query: + [ + {"type": "message", "operation": null, "text": "I'll get the users and orders for you."}, + {"type": "sql", "operation": "get", "text": "SELECT * FROM schema.users LIMIT 100;"}, + {"type": "sql", "operation": "get", "text": "SELECT * FROM schema.orders LIMIT 100;"} + ] + + This is MANDATORY because databases can only execute one statement at a time. + For complex requests requiring multiple queries, return multiple sql objects. + + Previous Conversation: + {{ context.previous_conversation }} + + User's Request: + {{ user_query }} + + {{ ctx.output_format }} + "# +} + +// Test cases +test simple_select { + functions [GenerateSQLQuery] + args { + context { + database_type "PostgreSQL" + schema "public" + tables_and_fields #" + table: users + - id (integer) + - name (varchar) + - email (varchar) + - created_at (timestamp) + "# + previous_conversation "" + } + user_query "Show me all users" + } +} + +test complex_query_with_join { + functions [GenerateSQLQuery] + args { + context { + database_type "PostgreSQL" + schema "public" + tables_and_fields #" + table: users + - id (integer) + - name (varchar) + - email (varchar) + + table: orders + - id (integer) + - user_id (integer) + - total (decimal) + - created_at (timestamp) + "# + previous_conversation "" + } + user_query "Show me all orders with user names for orders over $100" + } +} + +// Function to generate a short chat title from the first user query +function GenerateChatTitle( + user_query: string +) -> string { + client DefaultClient + prompt #" + Generate a simple, natural title (maximum 4 words) for a chat that starts with: "{{ user_query }}" + + Use casual, everyday language. Avoid formal words like "Request", "Query", "Generation", "Analysis". + + IMPORTANT RULES: + 1. If the query is unclear, too vague, or doesn't have enough context to create a meaningful title, return an EMPTY string (nothing at all). + 2. If you can create a meaningful, specific title, return it. + 3. Do NOT return generic titles like "General Chat", "New Chat", "Conversation", "Support", "Help" - instead return EMPTY. + + Examples of when to return EMPTY: + - "Hi" → (empty) + - "Hello" → (empty) + - "Hey" → (empty) + - "Blah" → (empty) + - "Test" → (empty) + - "?" → (empty) + + Examples of good titles: + - "Show me all users" → "All Users" + - "Give me all the users" → "All Users" + - "What are the total sales by region?" → "Sales by Region" + - "How many orders were placed last month?" → "Last Month Orders" + - "Create a table for products" → "Create Products Table" + + Return ONLY the title or empty. No quotes, no explanations, no punctuation. + + {{ ctx.output_format }} + "# +} diff --git a/dataflow/core/build/app.js b/dataflow/core/build/app.js new file mode 100644 index 0000000..d728904 --- /dev/null +++ b/dataflow/core/build/app.js @@ -0,0 +1 @@ +console.log('WhoDB test build placeholder'); diff --git a/dataflow/core/build/index.html b/dataflow/core/build/index.html new file mode 100644 index 0000000..99bce4a --- /dev/null +++ b/dataflow/core/build/index.html @@ -0,0 +1,12 @@ + + + + + + WhoDB Test Build + + +
WhoDB test build placeholder
+ + + diff --git a/dataflow/core/embed_dev.go b/dataflow/core/embed_dev.go new file mode 100644 index 0000000..bf15bde --- /dev/null +++ b/dataflow/core/embed_dev.go @@ -0,0 +1,25 @@ +//go:build !prod + +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package main + +import "embed" + +// In dev mode, DataFlow is served separately (for example: `cd dataflow && pnpm dev`). +// This provides an empty embed.FS so compilation succeeds without build/. +var staticFiles embed.FS diff --git a/dataflow/core/embed_prod.go b/dataflow/core/embed_prod.go new file mode 100644 index 0000000..cbe9320 --- /dev/null +++ b/dataflow/core/embed_prod.go @@ -0,0 +1,24 @@ +//go:build prod + +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package main + +import "embed" + +//go:embed build/* +var staticFiles embed.FS diff --git a/dataflow/core/go.mod b/dataflow/core/go.mod new file mode 100644 index 0000000..fa40790 --- /dev/null +++ b/dataflow/core/go.mod @@ -0,0 +1,144 @@ +module github.com/clidey/whodb/core + +go 1.26.1 + +require ( + github.com/99designs/gqlgen v0.17.88 + github.com/ClickHouse/clickhouse-go/v2 v2.43.0 + github.com/aws/aws-sdk-go-v2 v1.41.4 + github.com/aws/aws-sdk-go-v2/config v1.32.12 + github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.20 + github.com/aws/aws-sdk-go-v2/service/docdb v1.48.12 + github.com/aws/aws-sdk-go-v2/service/elasticache v1.51.12 + github.com/aws/aws-sdk-go-v2/service/rds v1.116.3 + github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 + github.com/aws/smithy-go v1.24.2 + github.com/boundaryml/baml v0.218.1 + github.com/brianvoe/gofakeit/v7 v7.14.1 + github.com/deckarep/golang-set/v2 v2.8.0 + github.com/dromara/carbon/v2 v2.6.16 + github.com/elastic/go-elasticsearch/v8 v8.19.3 + github.com/go-chi/chi/v5 v5.2.5 + github.com/go-chi/cors v1.2.2 + github.com/go-redis/redis/v8 v8.11.5 + github.com/go-sql-driver/mysql v1.9.3 + github.com/google/uuid v1.6.0 + github.com/hashicorp/go-version v1.8.0 + github.com/jackc/pgx/v5 v5.8.0 + github.com/posthog/posthog-go v1.11.1 + github.com/shopspring/decimal v1.4.0 + github.com/sirupsen/logrus v1.9.4 + github.com/twpayne/go-geom v1.6.1 + github.com/vektah/gqlparser/v2 v2.5.32 + github.com/xuri/excelize/v2 v2.10.1 + github.com/zalando/go-keyring v0.2.6 + go.mongodb.org/mongo-driver v1.17.9 + go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/sync v0.20.0 + gorm.io/driver/clickhouse v0.7.0 + gorm.io/driver/mysql v1.6.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 + k8s.io/apimachinery v0.34.1 + k8s.io/client-go v0.34.1 +) + +require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect + filippo.io/edwards25519 v1.1.1 // indirect + github.com/ClickHouse/ch-go v0.71.0 // indirect + github.com/agnivade/levenshtein v1.2.1 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/elastic/elastic-transport-go/v8 v8.8.0 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/paulmach/orb v0.12.0 // indirect + github.com/pierrec/lz4/v4 v4.1.25 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/richardlehane/mscfb v1.0.6 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/sosodev/duration v1.4.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect + github.com/urfave/cli/v3 v3.7.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/oauth2 v0.27.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.9.0 // indirect + golang.org/x/tools v0.42.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.34.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/dataflow/core/go.sum b/dataflow/core/go.sum new file mode 100644 index 0000000..36ef8f3 --- /dev/null +++ b/dataflow/core/go.sum @@ -0,0 +1,421 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= +filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc= +github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic= +github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= +github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= +github.com/ClickHouse/clickhouse-go/v2 v2.43.0 h1:fUR05TrF1GyvLDa/mAQjkx7KbgwdLRffs2n9O3WobtE= +github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= +github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= +github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= +github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= +github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= +github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.20 h1:nBtAkfvLanKNwKfmsxfpLqYAjKpTAO9yRfuXAKconUY= +github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.20/go.mod h1:wtCkeFPPKHdxFPrZGkdT5tKR4boa3GvW54sYdGNWPHg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= +github.com/aws/aws-sdk-go-v2/service/docdb v1.48.12 h1:OFKK/k8nTpRr6LDzLUwHlpXImFhcDNRxuk6PeYar97k= +github.com/aws/aws-sdk-go-v2/service/docdb v1.48.12/go.mod h1:2W6TgFzcJxTzA4xfilWG1DDwmmdrdEdlYkrxgkqjSXc= +github.com/aws/aws-sdk-go-v2/service/elasticache v1.51.12 h1:S066ajzfPRCSW4lsSHOYglne6SNi2CHt1u5omzW1RBg= +github.com/aws/aws-sdk-go-v2/service/elasticache v1.51.12/go.mod h1:86SE4NcXxbxr8KTG3yOyDmd4HyiFmKl8TexXnhYJ+Bw= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= +github.com/aws/aws-sdk-go-v2/service/rds v1.116.3 h1:H/ZYZ6QR4EXJAYElI5xkIM/yCz+A4uHIvWpzl+IfJks= +github.com/aws/aws-sdk-go-v2/service/rds v1.116.3/go.mod h1:QbXW4coAMakHQhf1qhE0eVVCen9gwB/Kvn+HHHKhpGY= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/boundaryml/baml v0.218.1 h1:FVq6kbDzSDSodNfd2AOwpEdSiwcGGT8o9eo++U4zObA= +github.com/boundaryml/baml v0.218.1/go.mod h1:dzmyDMNDXIVxJX75q9KTjuTUADsYSGUEbGyi76Cwkew= +github.com/brianvoe/gofakeit/v7 v7.14.1 h1:a7fe3fonbj0cW3wgl5VwIKfZtiH9C3cLnwcIXWT7sow= +github.com/brianvoe/gofakeit/v7 v7.14.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= +github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/dromara/carbon/v2 v2.6.16 h1:AbxrnW1kJhR3KHdS8G96NFmxDwPFyre+t+xSiJIUD1I= +github.com/dromara/carbon/v2 v2.6.16/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo= +github.com/elastic/elastic-transport-go/v8 v8.8.0 h1:7k1Ua+qluFr6p1jfJjGDl97ssJS/P7cHNInzfxgBQAo= +github.com/elastic/elastic-transport-go/v8 v8.8.0/go.mod h1:YLHer5cj0csTzNFXoNQ8qhtGY1GTvSqPnKWKaqQE3Hk= +github.com/elastic/go-elasticsearch/v8 v8.19.3 h1:5LDg0hfGJXBa9Y+2QlUgRTsNJ/7rm7oNidydtFAq0LI= +github.com/elastic/go-elasticsearch/v8 v8.19.3/go.mod h1:tHJQdInFa6abmDbDCEH2LJja07l/SIpaGpJcm13nt7s= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/ghetzel/testify v1.4.1 h1:wpJirdM+znAnxWruGDBdIys5aU+wGJHNUTkgEo4PYwk= +github.com/ghetzel/testify v1.4.1/go.mod h1:FwvFn1OiGEUgzhS3ySCjTBG7/sez0WRvOAxz5uQU8so= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= +github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posthog/posthog-go v1.11.1 h1:P0MHlerMW9rNpjW+1szNsJ5HbdYJUv/9lF2DWZCHztE= +github.com/posthog/posthog-go v1.11.1/go.mod h1:wB3/9Q7d9gGb1P/yf/Wri9VBlbP8oA8z++prRzL5OcY= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE= +github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= +github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4= +github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028= +github.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U= +github.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc= +github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= +github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= +go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= +go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= +go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/clickhouse v0.7.0 h1:BCrqvgONayvZRgtuA6hdya+eAW5P2QVagV3OlEp1vtA= +gorm.io/driver/clickhouse v0.7.0/go.mod h1:TmNo0wcVTsD4BBObiRnCahUgHJHjBIwuRejHwYt3JRs= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= +k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= +k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/dataflow/core/gqlgen.yml b/dataflow/core/gqlgen.yml new file mode 100644 index 0000000..dd6257c --- /dev/null +++ b/dataflow/core/gqlgen.yml @@ -0,0 +1,104 @@ +# Copyright 2025 Clidey, Inc. +# +# 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. + +# Where are all the schema files located? globs are supported eg src/**/*.graphqls +schema: + - graph/*.graphqls + +# Where should the generated server code go? +exec: + filename: graph/generated.go + package: graph + +# Uncomment to enable federation +# federation: +# filename: graph/federation.go +# package: graph + +# Where should any generated models go? +model: + filename: graph/model/models_gen.go + package: model + +# Where should the resolver implementations go? +resolver: + layout: follow-schema + dir: graph + package: graph + filename_template: "{name}.resolvers.go" + # Optional: turn on to not generate template comments above resolvers + # omit_template_comment: false + +# Optional: turn on use ` + "`" + `gqlgen:"fieldName"` + "`" + ` tags in your models +# struct_tag: json + +# Optional: turn on to use []Thing instead of []*Thing +# omit_slice_element_pointers: false + +# Optional: turn on to omit Is() methods to interface and unions +# omit_interface_checks : true + +# Optional: turn on to skip generation of ComplexityRoot struct content and Complexity function +# omit_complexity: false + +# Optional: turn on to not generate any file notice comments in generated files +# omit_gqlgen_file_notice: false + +# Optional: turn on to exclude the gqlgen version in the generated file notice. No effect if `omit_gqlgen_file_notice` is true. +# omit_gqlgen_version_in_file_notice: false + +# Optional: turn off to make struct-type struct fields not use pointers +# e.g. type Thing struct { FieldA OtherThing } instead of { FieldA *OtherThing } +# struct_fields_always_pointers: true + +# Optional: turn off to make resolvers return values instead of pointers for structs +# resolvers_always_return_pointers: true + +# Optional: turn on to return pointers instead of values in unmarshalInput +# return_pointers_in_unmarshalinput: false + +# Optional: wrap nullable input fields with Omittable +# nullable_input_omittable: true + +# Optional: set to speed up generation time by not performing a final validation pass. +# skip_validation: true + +# Optional: set to skip running `go mod tidy` when generating server code +skip_mod_tidy: true + +# gqlgen will search for any type names in the schema in these go packages +# if they match it will use them, otherwise it will generate them. +autobind: +# - "github.com/clidey/whodb/core/graph/model" + +# This section declares type mapping between the GraphQL and go type systems +# +# The first line in each type will be used as defaults for resolver arguments and +# modelgen, the others will be allowed when binding to fields. Configure them to +# your liking +models: + ID: + model: + - github.com/99designs/gqlgen/graphql.ID + - github.com/99designs/gqlgen/graphql.Int + - github.com/99designs/gqlgen/graphql.Int64 + - github.com/99designs/gqlgen/graphql.Int32 + Int: + model: + - github.com/99designs/gqlgen/graphql.Int + - github.com/99designs/gqlgen/graphql.Int64 + - github.com/99designs/gqlgen/graphql.Int32 + Upload: + model: + - github.com/99designs/gqlgen/graphql.Upload diff --git a/dataflow/core/graph/auth_session_helpers.go b/dataflow/core/graph/auth_session_helpers.go new file mode 100644 index 0000000..efcf616 --- /dev/null +++ b/dataflow/core/graph/auth_session_helpers.go @@ -0,0 +1,57 @@ +package graph + +import ( + "fmt" + "time" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/engine" +) + +func loginCredentialsToEngineCredentials(credentials model.LoginCredentials) *engine.Credentials { + advanced := make([]engine.Record, 0, len(credentials.Advanced)) + for _, recordInput := range credentials.Advanced { + advanced = append(advanced, engine.Record{ + Key: recordInput.Key, + Value: recordInput.Value, + }) + } + + return &engine.Credentials{ + Id: credentials.ID, + Type: credentials.Type, + Hostname: credentials.Hostname, + Username: credentials.Username, + Password: credentials.Password, + Database: credentials.Database, + Advanced: advanced, + } +} + +func authSessionPayload(token string, expiresAt time.Time, dbType, host, port, database string) *model.AuthSessionPayload { + return &model.AuthSessionPayload{ + SessionToken: token, + ExpiresAt: expiresAt.Format(time.RFC3339), + Type: dbType, + Hostname: host, + Port: port, + Database: database, + DisplayName: standaloneDisplayName(dbType, host, database), + } +} + +func standaloneDisplayName(dbType, host, database string) string { + if database == "" { + return fmt.Sprintf("%s @ %s", dbType, host) + } + return fmt.Sprintf("%s @ %s/%s", dbType, host, database) +} + +func advancedValue(records []engine.Record, key string) string { + for _, record := range records { + if record.Key == key { + return record.Value + } + } + return "" +} diff --git a/dataflow/core/graph/dashboard_graphql_test.go b/dataflow/core/graph/dashboard_graphql_test.go new file mode 100644 index 0000000..80078b0 --- /dev/null +++ b/dataflow/core/graph/dashboard_graphql_test.go @@ -0,0 +1,144 @@ +package graph + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/99designs/gqlgen/graphql/handler" + "github.com/clidey/whodb/core/src/auth" + "github.com/clidey/whodb/core/src/dashboard" + "github.com/clidey/whodb/core/src/engine" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestGetDashboardsReturnsScopedDashboards(t *testing.T) { + service := newDashboardServiceForGraphTest(t) + ctx := contextWithScope("primary") + + dash, err := service.CreateDashboard(ctx, dashboard.CreateDashboardParams{ + Name: "Revenue", + RefreshRule: dashboard.RefreshRuleOnDemand, + }) + if err != nil { + t.Fatalf("create dashboard: %v", err) + } + _, err = service.AddWidget(ctx, dash.ID, dashboard.WidgetInput{ + Type: dashboard.WidgetTypeChart, + Title: "Monthly revenue", + Layout: `{"i":"widget-1","x":0,"y":0,"w":4,"h":6}`, + }) + if err != nil { + t.Fatalf("add widget: %v", err) + } + + body, _ := json.Marshal(map[string]any{ + "query": `query { GetDashboards { ID Name Widgets { ID Type Layout } } }`, + }) + recorder := executeDashboardGraphQL(t, service, ctx, body) + + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "Revenue") { + t.Fatalf("unexpected response: %d %s", recorder.Code, recorder.Body.String()) + } +} + +func TestAddWidgetRejectsCrossScopeMutation(t *testing.T) { + service := newDashboardServiceForGraphTest(t) + ownerCtx := contextWithScope("primary") + otherCtx := contextWithScope("secondary") + + dash, err := service.CreateDashboard(ownerCtx, dashboard.CreateDashboardParams{ + Name: "Revenue", + RefreshRule: dashboard.RefreshRuleOnDemand, + }) + if err != nil { + t.Fatalf("create dashboard: %v", err) + } + + mutation := fmt.Sprintf( + `mutation { AddWidget(dashboardId:%q, input:{Type:"chart", Title:"Blocked", Layout:"{\"i\":\"widget-1\",\"x\":0,\"y\":0,\"w\":4,\"h\":6}"}) { ID } }`, + dash.ID, + ) + body, _ := json.Marshal(map[string]any{"query": mutation}) + recorder := executeDashboardGraphQL(t, service, otherCtx, body) + + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), dashboard.ErrScopeMismatch.Error()) { + t.Fatalf("expected scope error, got %d %s", recorder.Code, recorder.Body.String()) + } +} + +func TestUpdateWidgetSnapshotAcceptsSnapshotPayload(t *testing.T) { + service := newDashboardServiceForGraphTest(t) + ctx := contextWithScope("primary") + + dash, err := service.CreateDashboard(ctx, dashboard.CreateDashboardParams{ + Name: "Revenue", + RefreshRule: dashboard.RefreshRuleOnDemand, + }) + if err != nil { + t.Fatalf("create dashboard: %v", err) + } + widget, err := service.AddWidget(ctx, dash.ID, dashboard.WidgetInput{ + Type: dashboard.WidgetTypeChart, + Title: "Monthly revenue", + Layout: `{"i":"widget-1","x":0,"y":0,"w":4,"h":6}`, + }) + if err != nil { + t.Fatalf("add widget: %v", err) + } + + mutation := fmt.Sprintf( + `mutation { UpdateWidgetSnapshot(id:%q, snapshot:{Config:"{\"type\":\"bar\"}", Data:"{}", ExecutedAt:"2026-04-02T00:00:00Z"}) { Status } }`, + widget.ID, + ) + body, _ := json.Marshal(map[string]any{"query": mutation}) + recorder := executeDashboardGraphQL(t, service, ctx, body) + + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"Status":true`) { + t.Fatalf("expected success, got %d %s", recorder.Code, recorder.Body.String()) + } +} + +func executeDashboardGraphQL(t *testing.T, service dashboard.ServiceAPI, ctx context.Context, body []byte) *httptest.ResponseRecorder { + t.Helper() + + srv := handler.NewDefaultServer(NewExecutableSchema(Config{ + Resolvers: &Resolver{DashboardService: service}, + })) + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBuffer(body)) + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + srv.ServeHTTP(recorder, req) + return recorder +} + +func newDashboardServiceForGraphTest(t *testing.T) *dashboard.Service { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + + service, err := dashboard.NewService(db) + if err != nil { + t.Fatalf("new service: %v", err) + } + + return service +} + +func contextWithScope(database string) context.Context { + return context.WithValue(context.Background(), auth.AuthKey_Credentials, &engine.Credentials{ + Type: "Postgres", + Hostname: "db.internal", + Database: database, + }) +} diff --git a/dataflow/core/graph/export_http.go b/dataflow/core/graph/export_http.go new file mode 100644 index 0000000..44805f8 --- /dev/null +++ b/dataflow/core/graph/export_http.go @@ -0,0 +1,415 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/clidey/whodb/core/src" + "github.com/clidey/whodb/core/src/auth" + "github.com/clidey/whodb/core/src/engine" + "github.com/xuri/excelize/v2" +) + +const ( + // Maximum rows for Excel export to prevent memory issues + MaxExcelRows = 100000 // 100k rows + + // Default CSV delimiter + DefaultCSVDelimiter = "," +) + +// NDJSONExporter allows plugins to stream newline-delimited JSON. +type NDJSONExporter interface { + ExportDataNDJSON(config *engine.PluginConfig, schema string, storageUnit string, writer func(string) error, selectedRows []map[string]any) error +} + +// InvalidDelimiters contains characters that cannot be used as CSV delimiters +var InvalidDelimiters = map[byte]string{ + '=': "formula indicator", + '+': "formula indicator", + '-': "formula indicator", + '@': "formula indicator", + '\t': "tab character", + '\r': "carriage return", + '\'': "single quote (used for escaping)", + '"': "double quote (CSV escape character)", +} + +var mongoCollectionNamePattern = regexp.MustCompile(`^[A-Za-z_][\w.]*$`) + +// validateDelimiter checks if a delimiter is valid for CSV export +func validateDelimiter(delimiter string) error { + if len(delimiter) != 1 { + return fmt.Errorf("delimiter must be a single character") + } + + delimChar := delimiter[0] + if reason, invalid := InvalidDelimiters[delimChar]; invalid { + return fmt.Errorf("invalid delimiter '%c': %s", delimChar, reason) + } + + return nil +} + +// HandleExport handles HTTP requests for data export (CSV, Excel, or NDJSON). +func HandleExport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Schema string `json:"schema"` + StorageUnit string `json:"storageUnit"` + Delimiter string `json:"delimiter,omitempty"` + Format string `json:"format,omitempty"` + Filter string `json:"filter,omitempty"` + Limit int `json:"limit,omitempty"` + SelectedRows []map[string]any `json:"selectedRows,omitempty"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + schema := req.Schema + storageUnit := req.StorageUnit + delimiter := req.Delimiter + format := strings.ToLower(strings.TrimSpace(req.Format)) + + if format == "" { + format = "csv" + } + + switch format { + case "csv", "excel", "ndjson": + default: + http.Error(w, "Invalid format. Must be 'csv', 'excel', or 'ndjson'", http.StatusBadRequest) + return + } + + if format == "csv" { + if delimiter == "" { + delimiter = DefaultCSVDelimiter + } + + if err := validateDelimiter(delimiter); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + + if schema == "" && storageUnit == "" { + http.Error(w, "Missing required parameters", http.StatusBadRequest) + return + } + + credentials := auth.GetCredentials(r.Context()) + if credentials == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + pluginConfig := engine.NewPluginConfig(credentials) + plugin := src.MainEngine.Choose(engine.DatabaseType(credentials.Type)) + + selectedRows := req.SelectedRows + if plugin.Type == engine.DatabaseType_MongoDB && (strings.TrimSpace(req.Filter) != "" || req.Limit > 0) { + rows, err := fetchMongoExportRows(plugin, pluginConfig, storageUnit, req.Filter, req.Limit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + selectedRows = rows + } + + switch format { + case "excel": + handleExcelExport(w, plugin, pluginConfig, schema, storageUnit, selectedRows) + case "ndjson": + handleNDJSONExport(w, plugin, pluginConfig, schema, storageUnit, selectedRows) + default: + handleCSVExport(w, plugin, pluginConfig, schema, storageUnit, delimiter, selectedRows) + } +} + +func fetchMongoExportRows(plugin *engine.Plugin, pluginConfig *engine.PluginConfig, storageUnit, filter string, limit int) ([]map[string]any, error) { + query := buildMongoExportQuery(storageUnit, filter, limit) + result, err := plugin.RawExecute(pluginConfig, query) + if err != nil { + return nil, err + } + + return rawRowsToSelectedRows(result), nil +} + +func buildMongoExportQuery(storageUnit, filter string, limit int) string { + stages := make([]string, 0, 2) + if trimmedFilter := strings.TrimSpace(filter); trimmedFilter != "" { + stages = append(stages, fmt.Sprintf(`{ $match: %s }`, trimmedFilter)) + } + if limit > 0 { + stages = append(stages, fmt.Sprintf(`{ $limit: %d }`, limit)) + } + + return fmt.Sprintf(`%s.aggregate([%s])`, buildMongoCollectionAccessor(storageUnit), strings.Join(stages, ", ")) +} + +func buildMongoCollectionAccessor(storageUnit string) string { + if mongoCollectionNamePattern.MatchString(storageUnit) { + return "db." + storageUnit + } + + return `db.getCollection(` + strconv.Quote(storageUnit) + `)` +} + +func rawRowsToSelectedRows(result *engine.GetRowsResult) []map[string]any { + if result == nil || len(result.Columns) == 0 || len(result.Rows) == 0 { + return nil + } + + rows := make([]map[string]any, 0, len(result.Rows)) + for _, row := range result.Rows { + record := make(map[string]any, len(result.Columns)) + for index, column := range result.Columns { + if index >= len(row) { + record[column.Name] = "" + continue + } + record[column.Name] = parseRawCellValue(row[index]) + } + rows = append(rows, record) + } + + return rows +} + +func parseRawCellValue(value string) any { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + + var parsed any + if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil { + return parsed + } + + return value +} + +func handleCSVExport(w http.ResponseWriter, plugin *engine.Plugin, pluginConfig *engine.PluginConfig, schema, storageUnit, delimiter string, selectedRows []map[string]any) { + delimRune := rune(delimiter[0]) + + var filename string + if schema != "" { + filename = fmt.Sprintf("%s_%s.csv", schema, storageUnit) + } else { + filename = fmt.Sprintf("%s.csv", storageUnit) + } + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + + csvWriter := csv.NewWriter(w) + csvWriter.Comma = delimRune // Use user-specified delimiter + defer csvWriter.Flush() + + // Track if we've written anything + rowsWritten := 0 + + writerFunc := func(row []string) error { + if err := csvWriter.Write(row); err != nil { + return err + } + rowsWritten++ + + // Flush every 100 rows to ensure streaming + if rowsWritten%100 == 0 { + csvWriter.Flush() + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + return nil + } + + // Export rows (all or selected) using the unified method + err := plugin.ExportData(pluginConfig, schema, storageUnit, writerFunc, selectedRows) + if err != nil { + if rowsWritten == 0 { + http.Error(w, "Export failed. Please check your file and try again.", http.StatusInternalServerError) + return + } + } +} + +func handleExcelExport(w http.ResponseWriter, plugin *engine.Plugin, pluginConfig *engine.PluginConfig, schema, storageUnit string, selectedRows []map[string]any) { + f := excelize.NewFile() + defer f.Close() + + sheetName := "Data" + index, err := f.NewSheet(sheetName) + if err != nil { + http.Error(w, "Failed to create Excel sheet", http.StatusInternalServerError) + return + } + f.SetActiveSheet(index) + f.DeleteSheet("Sheet1") + + streamWriter, err := f.NewStreamWriter(sheetName) + if err != nil { + http.Error(w, "Failed to create Excel stream writer", http.StatusInternalServerError) + return + } + + var headers []string + rowCount := 0 + currentRow := 1 + + writerFunc := func(row []string) error { + if rowCount >= MaxExcelRows { + return fmt.Errorf("excel export limit exceeded: maximum %d rows allowed", MaxExcelRows) + } + + if len(headers) == 0 { + headers = row + styleID, _ := f.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true}, + Fill: excelize.Fill{ + Type: "pattern", + Color: []string{"#E0E0E0"}, + Pattern: 1, + }, + }) + + // Write headers with style + cells := make([]any, len(row)) + for i, header := range row { + cells[i] = excelize.Cell{StyleID: styleID, Value: header} + } + + cell, _ := excelize.CoordinatesToCellName(1, currentRow) + if err := streamWriter.SetRow(cell, cells); err != nil { + return err + } + } else { + // Write data row + cells := make([]any, len(row)) + for i, value := range row { + cells[i] = value + } + + cell, _ := excelize.CoordinatesToCellName(1, currentRow) + if err := streamWriter.SetRow(cell, cells); err != nil { + return err + } + } + + rowCount++ + currentRow++ + return nil + } + + // Export rows using the plugin + err = plugin.ExportData(pluginConfig, schema, storageUnit, writerFunc, selectedRows) + if err != nil { + http.Error(w, "Export failed", http.StatusInternalServerError) + return + } + + if len(headers) > 0 { + for i := 0; i < len(headers); i++ { + streamWriter.SetColWidth(i+1, i+1, 15) + } + } + + // Flush the stream writer + if err := streamWriter.Flush(); err != nil { + http.Error(w, "Failed to flush Excel data", http.StatusInternalServerError) + return + } + + // Include schema in filename only if it exists (for SQLite, schema is empty) + var filename string + if schema != "" { + filename = fmt.Sprintf("%s_%s.xlsx", schema, storageUnit) + } else { + filename = fmt.Sprintf("%s.xlsx", storageUnit) + } + w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + + // Write Excel file to response + if err := f.Write(w); err != nil { + http.Error(w, "Failed to generate Excel file", http.StatusInternalServerError) + return + } +} + +func handleNDJSONExport(w http.ResponseWriter, plugin *engine.Plugin, pluginConfig *engine.PluginConfig, schema, storageUnit string, selectedRows []map[string]any) { + exporter, ok := plugin.PluginFunctions.(NDJSONExporter) + if !ok { + http.Error(w, "NDJSON export not supported for this database", http.StatusBadRequest) + return + } + + var filename string + if schema != "" { + filename = fmt.Sprintf("%s_%s.ndjson", schema, storageUnit) + } else { + filename = fmt.Sprintf("%s.ndjson", storageUnit) + } + + w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + + rowsWritten := 0 + writerFunc := func(line string) error { + if _, err := w.Write([]byte(line + "\n")); err != nil { + return err + } + rowsWritten++ + if rowsWritten%100 == 0 { + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + return nil + } + + if err := exporter.ExportDataNDJSON(pluginConfig, schema, storageUnit, writerFunc, selectedRows); err != nil && rowsWritten == 0 { + http.Error(w, "Export failed. Please try again.", http.StatusInternalServerError) + return + } +} diff --git a/dataflow/core/graph/generated.go b/dataflow/core/graph/generated.go new file mode 100644 index 0000000..c108eed --- /dev/null +++ b/dataflow/core/graph/generated.go @@ -0,0 +1,21078 @@ +// Code generated by github.com/99designs/gqlgen, DO NOT EDIT. + +package graph + +import ( + "bytes" + "context" + "embed" + "errors" + "fmt" + "strconv" + "sync/atomic" + + "github.com/99designs/gqlgen/graphql" + "github.com/99designs/gqlgen/graphql/introspection" + "github.com/clidey/whodb/core/graph/model" + gqlparser "github.com/vektah/gqlparser/v2" + "github.com/vektah/gqlparser/v2/ast" +) + +// region ************************** generated!.gotpl ************************** + +// NewExecutableSchema creates an ExecutableSchema from the ResolverRoot interface. +func NewExecutableSchema(cfg Config) graphql.ExecutableSchema { + return &executableSchema{SchemaData: cfg.Schema, Resolvers: cfg.Resolvers, Directives: cfg.Directives, ComplexityRoot: cfg.Complexity} +} + +type Config = graphql.Config[ResolverRoot, DirectiveRoot, ComplexityRoot] + +type ResolverRoot interface { + Mutation() MutationResolver + Query() QueryResolver +} + +type DirectiveRoot struct { +} + +type ComplexityRoot struct { + AIChatMessage struct { + RequiresConfirmation func(childComplexity int) int + Result func(childComplexity int) int + Text func(childComplexity int) int + Type func(childComplexity int) int + } + + AIProvider struct { + IsEnvironmentDefined func(childComplexity int) int + IsGeneric func(childComplexity int) int + Name func(childComplexity int) int + ProviderID func(childComplexity int) int + Type func(childComplexity int) int + } + + AWSProvider struct { + DiscoverDocumentDb func(childComplexity int) int + DiscoverElastiCache func(childComplexity int) int + DiscoverRds func(childComplexity int) int + DiscoveredCount func(childComplexity int) int + Error func(childComplexity int) int + ID func(childComplexity int) int + LastDiscoveryAt func(childComplexity int) int + Name func(childComplexity int) int + ProfileName func(childComplexity int) int + ProviderType func(childComplexity int) int + Region func(childComplexity int) int + Status func(childComplexity int) int + } + + AWSRegion struct { + Description func(childComplexity int) int + ID func(childComplexity int) int + Partition func(childComplexity int) int + } + + AuthSessionPayload struct { + Database func(childComplexity int) int + DisplayName func(childComplexity int) int + ExpiresAt func(childComplexity int) int + Hostname func(childComplexity int) int + InstanceUID func(childComplexity int) int + Port func(childComplexity int) int + SessionToken func(childComplexity int) int + Type func(childComplexity int) int + } + + Capabilities struct { + SupportsChat func(childComplexity int) int + SupportsDatabaseSwitch func(childComplexity int) int + SupportsGraph func(childComplexity int) int + SupportsModifiers func(childComplexity int) int + SupportsSchema func(childComplexity int) int + SupportsScratchpad func(childComplexity int) int + } + + CollectionImportError struct { + Index func(childComplexity int) int + Reason func(childComplexity int) int + } + + CollectionImportPreview struct { + Columns func(childComplexity int) int + Count func(childComplexity int) int + Documents func(childComplexity int) int + Format func(childComplexity int) int + Rows func(childComplexity int) int + Sheet func(childComplexity int) int + Sheets func(childComplexity int) int + Truncated func(childComplexity int) int + ValidationError func(childComplexity int) int + } + + CollectionImportResult struct { + Detail func(childComplexity int) int + Errors func(childComplexity int) int + ImportedCount func(childComplexity int) int + MatchedCount func(childComplexity int) int + Message func(childComplexity int) int + ModifiedCount func(childComplexity int) int + SkippedCount func(childComplexity int) int + Status func(childComplexity int) int + UpsertedCount func(childComplexity int) int + } + + Column struct { + IsForeignKey func(childComplexity int) int + IsPrimary func(childComplexity int) int + Length func(childComplexity int) int + Name func(childComplexity int) int + Precision func(childComplexity int) int + ReferencedColumn func(childComplexity int) int + ReferencedTable func(childComplexity int) int + Scale func(childComplexity int) int + Type func(childComplexity int) int + } + + Dashboard struct { + CreatedAt func(childComplexity int) int + Description func(childComplexity int) int + ID func(childComplexity int) int + Name func(childComplexity int) int + RefreshRule func(childComplexity int) int + UpdatedAt func(childComplexity int) int + Widgets func(childComplexity int) int + } + + DashboardWidget struct { + Description func(childComplexity int) int + ID func(childComplexity int) int + Layout func(childComplexity int) int + Query func(childComplexity int) int + QueryContext func(childComplexity int) int + Snapshot func(childComplexity int) int + SortOrder func(childComplexity int) int + Title func(childComplexity int) int + Type func(childComplexity int) int + Visualization func(childComplexity int) int + } + + DatabaseMetadata struct { + AliasMap func(childComplexity int) int + Capabilities func(childComplexity int) int + DatabaseType func(childComplexity int) int + Operators func(childComplexity int) int + SystemSchemas func(childComplexity int) int + TypeDefinitions func(childComplexity int) int + } + + DatabaseQuerySuggestion struct { + Category func(childComplexity int) int + Description func(childComplexity int) int + } + + DiscoveredConnection struct { + DatabaseType func(childComplexity int) int + ID func(childComplexity int) int + Metadata func(childComplexity int) int + Name func(childComplexity int) int + ProviderID func(childComplexity int) int + ProviderType func(childComplexity int) int + Region func(childComplexity int) int + Status func(childComplexity int) int + } + + ExportDownload struct { + ContentType func(childComplexity int) int + DownloadURL func(childComplexity int) int + Filename func(childComplexity int) int + ID func(childComplexity int) int + Size func(childComplexity int) int + } + + GenerateChatTitleResponse struct { + Title func(childComplexity int) int + } + + GraphUnit struct { + Relations func(childComplexity int) int + Unit func(childComplexity int) int + } + + GraphUnitRelationship struct { + Name func(childComplexity int) int + Relationship func(childComplexity int) int + SourceColumn func(childComplexity int) int + TargetColumn func(childComplexity int) int + } + + HealthStatus struct { + Database func(childComplexity int) int + Server func(childComplexity int) int + } + + ImportColumnMappingPreview struct { + SourceColumn func(childComplexity int) int + TargetColumn func(childComplexity int) int + } + + ImportNewTableColumnPreview struct { + Nullable func(childComplexity int) int + Primary func(childComplexity int) int + Skip func(childComplexity int) int + SourceColumn func(childComplexity int) int + TargetColumn func(childComplexity int) int + Type func(childComplexity int) int + } + + ImportNewTablePreview struct { + Columns func(childComplexity int) int + Issues func(childComplexity int) int + TableName func(childComplexity int) int + } + + ImportPreview struct { + AutoGeneratedColumns func(childComplexity int) int + Columns func(childComplexity int) int + Mapping func(childComplexity int) int + NewTable func(childComplexity int) int + RequiresAllowAutoGenerated func(childComplexity int) int + Rows func(childComplexity int) int + Sheet func(childComplexity int) int + Sheets func(childComplexity int) int + Truncated func(childComplexity int) int + ValidationError func(childComplexity int) int + } + + ImportResult struct { + Detail func(childComplexity int) int + Message func(childComplexity int) int + Status func(childComplexity int) int + } + + ImportSuggestionIssue struct { + Field func(childComplexity int) int + Key func(childComplexity int) int + SourceColumn func(childComplexity int) int + } + + LocalAWSProfile struct { + AuthType func(childComplexity int) int + IsDefault func(childComplexity int) int + Name func(childComplexity int) int + Region func(childComplexity int) int + Source func(childComplexity int) int + } + + LoginProfile struct { + Alias func(childComplexity int) int + Database func(childComplexity int) int + Hostname func(childComplexity int) int + ID func(childComplexity int) int + IsEnvironmentDefined func(childComplexity int) int + SSLConfigured func(childComplexity int) int + Source func(childComplexity int) int + Type func(childComplexity int) int + } + + MockDataDependencyAnalysis struct { + Error func(childComplexity int) int + GenerationOrder func(childComplexity int) int + Tables func(childComplexity int) int + TotalRows func(childComplexity int) int + Warnings func(childComplexity int) int + } + + MockDataGenerationStatus struct { + AmountGenerated func(childComplexity int) int + Details func(childComplexity int) int + } + + MockDataTableDetail struct { + RowsGenerated func(childComplexity int) int + Table func(childComplexity int) int + UsedExistingData func(childComplexity int) int + } + + MockDataTableInfo struct { + IsBlocked func(childComplexity int) int + RowsToGenerate func(childComplexity int) int + Table func(childComplexity int) int + UsesExistingData func(childComplexity int) int + } + + Mutation struct { + AddAWSProvider func(childComplexity int, input model.AWSProviderInput) int + AddRow func(childComplexity int, schema string, storageUnit string, values []*model.RecordInput) int + AddStorageUnit func(childComplexity int, schema string, storageUnit string, fields []*model.RecordInput) int + AddWidget func(childComplexity int, dashboardID string, input model.WidgetInput) int + BootstrapSealosSession func(childComplexity int, input model.SealosBootstrapInput) int + CreateDashboard func(childComplexity int, name string, description *string, refreshRule string) int + CreateSQLDataExport func(childComplexity int, input model.SQLDataExportInput) int + CreateStandaloneSession func(childComplexity int, credentials model.LoginCredentials) int + DeleteDashboard func(childComplexity int, id string) int + DeleteRow func(childComplexity int, schema string, storageUnit string, values []*model.RecordInput) int + DeleteWidget func(childComplexity int, id string) int + ExecuteConfirmedSQL func(childComplexity int, query string, operationType string) int + GenerateChatTitle func(childComplexity int, input model.GenerateChatTitleInput) int + GenerateMockData func(childComplexity int, input model.MockDataGenerationInput) int + GenerateRDSAuthToken func(childComplexity int, providerID string, endpoint string, port int, region string, username string) int + ImportCollectionFile func(childComplexity int, input model.ImportCollectionFileInput) int + ImportCollectionPreview func(childComplexity int, input model.ImportCollectionPreviewInput) int + ImportPreview func(childComplexity int, input model.ImportPreviewInput) int + ImportSQL func(childComplexity int, input model.ImportSQLInput) int + ImportTableFile func(childComplexity int, input model.ImportFileInput) int + Login func(childComplexity int, credentials model.LoginCredentials) int + LoginWithProfile func(childComplexity int, profile model.LoginProfileInput) int + Logout func(childComplexity int) int + RefreshCloudProvider func(childComplexity int, id string) int + RemoveCloudProvider func(childComplexity int, id string) int + ReplaceRow func(childComplexity int, schema string, storageUnit string, values []*model.RecordInput) int + TestAWSCredentials func(childComplexity int, input model.AWSProviderInput) int + TestCloudProvider func(childComplexity int, id string) int + UpdateAWSProvider func(childComplexity int, id string, input model.AWSProviderInput) int + UpdateDashboard func(childComplexity int, id string, name *string, description *string, refreshRule *string) int + UpdateSettings func(childComplexity int, newSettings model.SettingsConfigInput) int + UpdateStorageUnit func(childComplexity int, schema string, storageUnit string, values []*model.RecordInput, updatedColumns []string) int + UpdateWidget func(childComplexity int, id string, input model.UpdateWidgetInput) int + UpdateWidgetLayouts func(childComplexity int, dashboardID string, layouts []*model.LayoutInput) int + UpdateWidgetSnapshot func(childComplexity int, id string, snapshot model.SnapshotInput) int + } + + Query struct { + AIChat func(childComplexity int, providerID *string, modelType string, token *string, schema string, input model.ChatInput) int + AIModel func(childComplexity int, providerID *string, modelType string, token *string) int + AIProviders func(childComplexity int) int + AWSRegions func(childComplexity int) int + AnalyzeMockDataDependencies func(childComplexity int, schema string, storageUnit string, rowCount int, fkDensityRatio *int) int + CloudProvider func(childComplexity int, id string) int + CloudProviders func(childComplexity int) int + Columns func(childComplexity int, schema string, storageUnit string) int + ColumnsBatch func(childComplexity int, schema string, storageUnits []string) int + Database func(childComplexity int, typeArg string) int + DatabaseMetadata func(childComplexity int) int + DatabaseQuerySuggestions func(childComplexity int, schema string) int + DiscoveredConnections func(childComplexity int) int + GetDashboards func(childComplexity int) int + Graph func(childComplexity int, schema string) int + Health func(childComplexity int) int + LocalAWSProfiles func(childComplexity int) int + MockDataMaxRowCount func(childComplexity int) int + Profiles func(childComplexity int) int + ProviderConnections func(childComplexity int, providerID string) int + RawExecute func(childComplexity int, query string) int + ResolveSealosInstanceIdentity func(childComplexity int, input model.SealosInstanceIdentityInput) int + Row func(childComplexity int, schema string, storageUnit string, where *model.WhereCondition, sort []*model.SortCondition, pageSize int, pageOffset int) int + SSLStatus func(childComplexity int) int + Schema func(childComplexity int) int + SettingsConfig func(childComplexity int) int + StorageUnit func(childComplexity int, schema string) int + UpdateInfo func(childComplexity int) int + Version func(childComplexity int) int + } + + Record struct { + Key func(childComplexity int) int + Value func(childComplexity int) int + } + + RowsResult struct { + Columns func(childComplexity int) int + DisableUpdate func(childComplexity int) int + Rows func(childComplexity int) int + TotalCount func(childComplexity int) int + } + + SSLStatus struct { + IsEnabled func(childComplexity int) int + Mode func(childComplexity int) int + } + + SealosInstanceIdentity struct { + Namespace func(childComplexity int) int + ResourceName func(childComplexity int) int + UID func(childComplexity int) int + } + + SettingsConfig struct { + CloudProvidersEnabled func(childComplexity int) int + DisableCredentialForm func(childComplexity int) int + MaxPageSize func(childComplexity int) int + MetricsEnabled func(childComplexity int) int + StandaloneLoginEnabled func(childComplexity int) int + } + + StatusResponse struct { + Status func(childComplexity int) int + } + + StorageUnit struct { + Attributes func(childComplexity int) int + IsMockDataGenerationAllowed func(childComplexity int) int + Name func(childComplexity int) int + } + + StorageUnitColumns struct { + Columns func(childComplexity int) int + StorageUnit func(childComplexity int) int + } + + TypeDefinition struct { + Category func(childComplexity int) int + DefaultLength func(childComplexity int) int + DefaultPrecision func(childComplexity int) int + HasLength func(childComplexity int) int + HasPrecision func(childComplexity int) int + ID func(childComplexity int) int + Label func(childComplexity int) int + } + + UpdateInfo struct { + CurrentVersion func(childComplexity int) int + LatestVersion func(childComplexity int) int + ReleaseURL func(childComplexity int) int + UpdateAvailable func(childComplexity int) int + } +} + +type MutationResolver interface { + BootstrapSealosSession(ctx context.Context, input model.SealosBootstrapInput) (*model.AuthSessionPayload, error) + CreateStandaloneSession(ctx context.Context, credentials model.LoginCredentials) (*model.AuthSessionPayload, error) + Login(ctx context.Context, credentials model.LoginCredentials) (*model.StatusResponse, error) + LoginWithProfile(ctx context.Context, profile model.LoginProfileInput) (*model.StatusResponse, error) + Logout(ctx context.Context) (*model.StatusResponse, error) + UpdateSettings(ctx context.Context, newSettings model.SettingsConfigInput) (*model.StatusResponse, error) + CreateDashboard(ctx context.Context, name string, description *string, refreshRule string) (*model.Dashboard, error) + UpdateDashboard(ctx context.Context, id string, name *string, description *string, refreshRule *string) (*model.Dashboard, error) + DeleteDashboard(ctx context.Context, id string) (*model.StatusResponse, error) + AddWidget(ctx context.Context, dashboardID string, input model.WidgetInput) (*model.DashboardWidget, error) + UpdateWidget(ctx context.Context, id string, input model.UpdateWidgetInput) (*model.DashboardWidget, error) + DeleteWidget(ctx context.Context, id string) (*model.StatusResponse, error) + UpdateWidgetLayouts(ctx context.Context, dashboardID string, layouts []*model.LayoutInput) (*model.StatusResponse, error) + UpdateWidgetSnapshot(ctx context.Context, id string, snapshot model.SnapshotInput) (*model.StatusResponse, error) + AddStorageUnit(ctx context.Context, schema string, storageUnit string, fields []*model.RecordInput) (*model.StatusResponse, error) + UpdateStorageUnit(ctx context.Context, schema string, storageUnit string, values []*model.RecordInput, updatedColumns []string) (*model.StatusResponse, error) + ReplaceRow(ctx context.Context, schema string, storageUnit string, values []*model.RecordInput) (*model.StatusResponse, error) + AddRow(ctx context.Context, schema string, storageUnit string, values []*model.RecordInput) (*model.StatusResponse, error) + DeleteRow(ctx context.Context, schema string, storageUnit string, values []*model.RecordInput) (*model.StatusResponse, error) + CreateSQLDataExport(ctx context.Context, input model.SQLDataExportInput) (*model.ExportDownload, error) + GenerateMockData(ctx context.Context, input model.MockDataGenerationInput) (*model.MockDataGenerationStatus, error) + ImportSQL(ctx context.Context, input model.ImportSQLInput) (*model.ImportResult, error) + ImportPreview(ctx context.Context, input model.ImportPreviewInput) (*model.ImportPreview, error) + ImportTableFile(ctx context.Context, input model.ImportFileInput) (*model.ImportResult, error) + ImportCollectionPreview(ctx context.Context, input model.ImportCollectionPreviewInput) (*model.CollectionImportPreview, error) + ImportCollectionFile(ctx context.Context, input model.ImportCollectionFileInput) (*model.CollectionImportResult, error) + ExecuteConfirmedSQL(ctx context.Context, query string, operationType string) (*model.AIChatMessage, error) + GenerateChatTitle(ctx context.Context, input model.GenerateChatTitleInput) (*model.GenerateChatTitleResponse, error) + AddAWSProvider(ctx context.Context, input model.AWSProviderInput) (*model.AWSProvider, error) + UpdateAWSProvider(ctx context.Context, id string, input model.AWSProviderInput) (*model.AWSProvider, error) + TestAWSCredentials(ctx context.Context, input model.AWSProviderInput) (model.CloudProviderStatus, error) + RemoveCloudProvider(ctx context.Context, id string) (*model.StatusResponse, error) + TestCloudProvider(ctx context.Context, id string) (model.CloudProviderStatus, error) + RefreshCloudProvider(ctx context.Context, id string) (*model.AWSProvider, error) + GenerateRDSAuthToken(ctx context.Context, providerID string, endpoint string, port int, region string, username string) (string, error) +} +type QueryResolver interface { + ResolveSealosInstanceIdentity(ctx context.Context, input model.SealosInstanceIdentityInput) (*model.SealosInstanceIdentity, error) + Version(ctx context.Context) (string, error) + UpdateInfo(ctx context.Context) (*model.UpdateInfo, error) + Health(ctx context.Context) (*model.HealthStatus, error) + GetDashboards(ctx context.Context) ([]*model.Dashboard, error) + Profiles(ctx context.Context) ([]*model.LoginProfile, error) + Database(ctx context.Context, typeArg string) ([]string, error) + Schema(ctx context.Context) ([]string, error) + StorageUnit(ctx context.Context, schema string) ([]*model.StorageUnit, error) + Row(ctx context.Context, schema string, storageUnit string, where *model.WhereCondition, sort []*model.SortCondition, pageSize int, pageOffset int) (*model.RowsResult, error) + Columns(ctx context.Context, schema string, storageUnit string) ([]*model.Column, error) + ColumnsBatch(ctx context.Context, schema string, storageUnits []string) ([]*model.StorageUnitColumns, error) + RawExecute(ctx context.Context, query string) (*model.RowsResult, error) + Graph(ctx context.Context, schema string) ([]*model.GraphUnit, error) + AIProviders(ctx context.Context) ([]*model.AIProvider, error) + AIModel(ctx context.Context, providerID *string, modelType string, token *string) ([]string, error) + AIChat(ctx context.Context, providerID *string, modelType string, token *string, schema string, input model.ChatInput) ([]*model.AIChatMessage, error) + SettingsConfig(ctx context.Context) (*model.SettingsConfig, error) + MockDataMaxRowCount(ctx context.Context) (int, error) + AnalyzeMockDataDependencies(ctx context.Context, schema string, storageUnit string, rowCount int, fkDensityRatio *int) (*model.MockDataDependencyAnalysis, error) + DatabaseMetadata(ctx context.Context) (*model.DatabaseMetadata, error) + SSLStatus(ctx context.Context) (*model.SSLStatus, error) + DatabaseQuerySuggestions(ctx context.Context, schema string) ([]*model.DatabaseQuerySuggestion, error) + CloudProviders(ctx context.Context) ([]*model.AWSProvider, error) + CloudProvider(ctx context.Context, id string) (*model.AWSProvider, error) + DiscoveredConnections(ctx context.Context) ([]*model.DiscoveredConnection, error) + ProviderConnections(ctx context.Context, providerID string) ([]*model.DiscoveredConnection, error) + LocalAWSProfiles(ctx context.Context) ([]*model.LocalAWSProfile, error) + AWSRegions(ctx context.Context) ([]*model.AWSRegion, error) +} + +type executableSchema graphql.ExecutableSchemaState[ResolverRoot, DirectiveRoot, ComplexityRoot] + +func (e *executableSchema) Schema() *ast.Schema { + if e.SchemaData != nil { + return e.SchemaData + } + return parsedSchema +} + +func (e *executableSchema) Complexity(ctx context.Context, typeName, field string, childComplexity int, rawArgs map[string]any) (int, bool) { + ec := newExecutionContext(nil, e, nil) + _ = ec + switch typeName + "." + field { + + case "AIChatMessage.RequiresConfirmation": + if e.ComplexityRoot.AIChatMessage.RequiresConfirmation == nil { + break + } + + return e.ComplexityRoot.AIChatMessage.RequiresConfirmation(childComplexity), true + case "AIChatMessage.Result": + if e.ComplexityRoot.AIChatMessage.Result == nil { + break + } + + return e.ComplexityRoot.AIChatMessage.Result(childComplexity), true + case "AIChatMessage.Text": + if e.ComplexityRoot.AIChatMessage.Text == nil { + break + } + + return e.ComplexityRoot.AIChatMessage.Text(childComplexity), true + case "AIChatMessage.Type": + if e.ComplexityRoot.AIChatMessage.Type == nil { + break + } + + return e.ComplexityRoot.AIChatMessage.Type(childComplexity), true + + case "AIProvider.IsEnvironmentDefined": + if e.ComplexityRoot.AIProvider.IsEnvironmentDefined == nil { + break + } + + return e.ComplexityRoot.AIProvider.IsEnvironmentDefined(childComplexity), true + case "AIProvider.IsGeneric": + if e.ComplexityRoot.AIProvider.IsGeneric == nil { + break + } + + return e.ComplexityRoot.AIProvider.IsGeneric(childComplexity), true + case "AIProvider.Name": + if e.ComplexityRoot.AIProvider.Name == nil { + break + } + + return e.ComplexityRoot.AIProvider.Name(childComplexity), true + case "AIProvider.ProviderId": + if e.ComplexityRoot.AIProvider.ProviderID == nil { + break + } + + return e.ComplexityRoot.AIProvider.ProviderID(childComplexity), true + case "AIProvider.Type": + if e.ComplexityRoot.AIProvider.Type == nil { + break + } + + return e.ComplexityRoot.AIProvider.Type(childComplexity), true + + case "AWSProvider.DiscoverDocumentDB": + if e.ComplexityRoot.AWSProvider.DiscoverDocumentDb == nil { + break + } + + return e.ComplexityRoot.AWSProvider.DiscoverDocumentDb(childComplexity), true + case "AWSProvider.DiscoverElastiCache": + if e.ComplexityRoot.AWSProvider.DiscoverElastiCache == nil { + break + } + + return e.ComplexityRoot.AWSProvider.DiscoverElastiCache(childComplexity), true + case "AWSProvider.DiscoverRDS": + if e.ComplexityRoot.AWSProvider.DiscoverRds == nil { + break + } + + return e.ComplexityRoot.AWSProvider.DiscoverRds(childComplexity), true + case "AWSProvider.DiscoveredCount": + if e.ComplexityRoot.AWSProvider.DiscoveredCount == nil { + break + } + + return e.ComplexityRoot.AWSProvider.DiscoveredCount(childComplexity), true + case "AWSProvider.Error": + if e.ComplexityRoot.AWSProvider.Error == nil { + break + } + + return e.ComplexityRoot.AWSProvider.Error(childComplexity), true + case "AWSProvider.Id": + if e.ComplexityRoot.AWSProvider.ID == nil { + break + } + + return e.ComplexityRoot.AWSProvider.ID(childComplexity), true + case "AWSProvider.LastDiscoveryAt": + if e.ComplexityRoot.AWSProvider.LastDiscoveryAt == nil { + break + } + + return e.ComplexityRoot.AWSProvider.LastDiscoveryAt(childComplexity), true + case "AWSProvider.Name": + if e.ComplexityRoot.AWSProvider.Name == nil { + break + } + + return e.ComplexityRoot.AWSProvider.Name(childComplexity), true + case "AWSProvider.ProfileName": + if e.ComplexityRoot.AWSProvider.ProfileName == nil { + break + } + + return e.ComplexityRoot.AWSProvider.ProfileName(childComplexity), true + case "AWSProvider.ProviderType": + if e.ComplexityRoot.AWSProvider.ProviderType == nil { + break + } + + return e.ComplexityRoot.AWSProvider.ProviderType(childComplexity), true + case "AWSProvider.Region": + if e.ComplexityRoot.AWSProvider.Region == nil { + break + } + + return e.ComplexityRoot.AWSProvider.Region(childComplexity), true + case "AWSProvider.Status": + if e.ComplexityRoot.AWSProvider.Status == nil { + break + } + + return e.ComplexityRoot.AWSProvider.Status(childComplexity), true + + case "AWSRegion.Description": + if e.ComplexityRoot.AWSRegion.Description == nil { + break + } + + return e.ComplexityRoot.AWSRegion.Description(childComplexity), true + case "AWSRegion.Id": + if e.ComplexityRoot.AWSRegion.ID == nil { + break + } + + return e.ComplexityRoot.AWSRegion.ID(childComplexity), true + case "AWSRegion.Partition": + if e.ComplexityRoot.AWSRegion.Partition == nil { + break + } + + return e.ComplexityRoot.AWSRegion.Partition(childComplexity), true + + case "AuthSessionPayload.database": + if e.ComplexityRoot.AuthSessionPayload.Database == nil { + break + } + + return e.ComplexityRoot.AuthSessionPayload.Database(childComplexity), true + case "AuthSessionPayload.displayName": + if e.ComplexityRoot.AuthSessionPayload.DisplayName == nil { + break + } + + return e.ComplexityRoot.AuthSessionPayload.DisplayName(childComplexity), true + case "AuthSessionPayload.expiresAt": + if e.ComplexityRoot.AuthSessionPayload.ExpiresAt == nil { + break + } + + return e.ComplexityRoot.AuthSessionPayload.ExpiresAt(childComplexity), true + case "AuthSessionPayload.hostname": + if e.ComplexityRoot.AuthSessionPayload.Hostname == nil { + break + } + + return e.ComplexityRoot.AuthSessionPayload.Hostname(childComplexity), true + case "AuthSessionPayload.instanceUid": + if e.ComplexityRoot.AuthSessionPayload.InstanceUID == nil { + break + } + + return e.ComplexityRoot.AuthSessionPayload.InstanceUID(childComplexity), true + case "AuthSessionPayload.port": + if e.ComplexityRoot.AuthSessionPayload.Port == nil { + break + } + + return e.ComplexityRoot.AuthSessionPayload.Port(childComplexity), true + case "AuthSessionPayload.sessionToken": + if e.ComplexityRoot.AuthSessionPayload.SessionToken == nil { + break + } + + return e.ComplexityRoot.AuthSessionPayload.SessionToken(childComplexity), true + case "AuthSessionPayload.type": + if e.ComplexityRoot.AuthSessionPayload.Type == nil { + break + } + + return e.ComplexityRoot.AuthSessionPayload.Type(childComplexity), true + + case "Capabilities.supportsChat": + if e.ComplexityRoot.Capabilities.SupportsChat == nil { + break + } + + return e.ComplexityRoot.Capabilities.SupportsChat(childComplexity), true + case "Capabilities.supportsDatabaseSwitch": + if e.ComplexityRoot.Capabilities.SupportsDatabaseSwitch == nil { + break + } + + return e.ComplexityRoot.Capabilities.SupportsDatabaseSwitch(childComplexity), true + case "Capabilities.supportsGraph": + if e.ComplexityRoot.Capabilities.SupportsGraph == nil { + break + } + + return e.ComplexityRoot.Capabilities.SupportsGraph(childComplexity), true + case "Capabilities.supportsModifiers": + if e.ComplexityRoot.Capabilities.SupportsModifiers == nil { + break + } + + return e.ComplexityRoot.Capabilities.SupportsModifiers(childComplexity), true + case "Capabilities.supportsSchema": + if e.ComplexityRoot.Capabilities.SupportsSchema == nil { + break + } + + return e.ComplexityRoot.Capabilities.SupportsSchema(childComplexity), true + case "Capabilities.supportsScratchpad": + if e.ComplexityRoot.Capabilities.SupportsScratchpad == nil { + break + } + + return e.ComplexityRoot.Capabilities.SupportsScratchpad(childComplexity), true + + case "CollectionImportError.Index": + if e.ComplexityRoot.CollectionImportError.Index == nil { + break + } + + return e.ComplexityRoot.CollectionImportError.Index(childComplexity), true + case "CollectionImportError.Reason": + if e.ComplexityRoot.CollectionImportError.Reason == nil { + break + } + + return e.ComplexityRoot.CollectionImportError.Reason(childComplexity), true + + case "CollectionImportPreview.Columns": + if e.ComplexityRoot.CollectionImportPreview.Columns == nil { + break + } + + return e.ComplexityRoot.CollectionImportPreview.Columns(childComplexity), true + case "CollectionImportPreview.Count": + if e.ComplexityRoot.CollectionImportPreview.Count == nil { + break + } + + return e.ComplexityRoot.CollectionImportPreview.Count(childComplexity), true + case "CollectionImportPreview.Documents": + if e.ComplexityRoot.CollectionImportPreview.Documents == nil { + break + } + + return e.ComplexityRoot.CollectionImportPreview.Documents(childComplexity), true + case "CollectionImportPreview.Format": + if e.ComplexityRoot.CollectionImportPreview.Format == nil { + break + } + + return e.ComplexityRoot.CollectionImportPreview.Format(childComplexity), true + case "CollectionImportPreview.Rows": + if e.ComplexityRoot.CollectionImportPreview.Rows == nil { + break + } + + return e.ComplexityRoot.CollectionImportPreview.Rows(childComplexity), true + case "CollectionImportPreview.Sheet": + if e.ComplexityRoot.CollectionImportPreview.Sheet == nil { + break + } + + return e.ComplexityRoot.CollectionImportPreview.Sheet(childComplexity), true + case "CollectionImportPreview.Sheets": + if e.ComplexityRoot.CollectionImportPreview.Sheets == nil { + break + } + + return e.ComplexityRoot.CollectionImportPreview.Sheets(childComplexity), true + case "CollectionImportPreview.Truncated": + if e.ComplexityRoot.CollectionImportPreview.Truncated == nil { + break + } + + return e.ComplexityRoot.CollectionImportPreview.Truncated(childComplexity), true + case "CollectionImportPreview.ValidationError": + if e.ComplexityRoot.CollectionImportPreview.ValidationError == nil { + break + } + + return e.ComplexityRoot.CollectionImportPreview.ValidationError(childComplexity), true + + case "CollectionImportResult.Detail": + if e.ComplexityRoot.CollectionImportResult.Detail == nil { + break + } + + return e.ComplexityRoot.CollectionImportResult.Detail(childComplexity), true + case "CollectionImportResult.Errors": + if e.ComplexityRoot.CollectionImportResult.Errors == nil { + break + } + + return e.ComplexityRoot.CollectionImportResult.Errors(childComplexity), true + case "CollectionImportResult.ImportedCount": + if e.ComplexityRoot.CollectionImportResult.ImportedCount == nil { + break + } + + return e.ComplexityRoot.CollectionImportResult.ImportedCount(childComplexity), true + case "CollectionImportResult.MatchedCount": + if e.ComplexityRoot.CollectionImportResult.MatchedCount == nil { + break + } + + return e.ComplexityRoot.CollectionImportResult.MatchedCount(childComplexity), true + case "CollectionImportResult.Message": + if e.ComplexityRoot.CollectionImportResult.Message == nil { + break + } + + return e.ComplexityRoot.CollectionImportResult.Message(childComplexity), true + case "CollectionImportResult.ModifiedCount": + if e.ComplexityRoot.CollectionImportResult.ModifiedCount == nil { + break + } + + return e.ComplexityRoot.CollectionImportResult.ModifiedCount(childComplexity), true + case "CollectionImportResult.SkippedCount": + if e.ComplexityRoot.CollectionImportResult.SkippedCount == nil { + break + } + + return e.ComplexityRoot.CollectionImportResult.SkippedCount(childComplexity), true + case "CollectionImportResult.Status": + if e.ComplexityRoot.CollectionImportResult.Status == nil { + break + } + + return e.ComplexityRoot.CollectionImportResult.Status(childComplexity), true + case "CollectionImportResult.UpsertedCount": + if e.ComplexityRoot.CollectionImportResult.UpsertedCount == nil { + break + } + + return e.ComplexityRoot.CollectionImportResult.UpsertedCount(childComplexity), true + + case "Column.IsForeignKey": + if e.ComplexityRoot.Column.IsForeignKey == nil { + break + } + + return e.ComplexityRoot.Column.IsForeignKey(childComplexity), true + case "Column.IsPrimary": + if e.ComplexityRoot.Column.IsPrimary == nil { + break + } + + return e.ComplexityRoot.Column.IsPrimary(childComplexity), true + case "Column.Length": + if e.ComplexityRoot.Column.Length == nil { + break + } + + return e.ComplexityRoot.Column.Length(childComplexity), true + case "Column.Name": + if e.ComplexityRoot.Column.Name == nil { + break + } + + return e.ComplexityRoot.Column.Name(childComplexity), true + case "Column.Precision": + if e.ComplexityRoot.Column.Precision == nil { + break + } + + return e.ComplexityRoot.Column.Precision(childComplexity), true + case "Column.ReferencedColumn": + if e.ComplexityRoot.Column.ReferencedColumn == nil { + break + } + + return e.ComplexityRoot.Column.ReferencedColumn(childComplexity), true + case "Column.ReferencedTable": + if e.ComplexityRoot.Column.ReferencedTable == nil { + break + } + + return e.ComplexityRoot.Column.ReferencedTable(childComplexity), true + case "Column.Scale": + if e.ComplexityRoot.Column.Scale == nil { + break + } + + return e.ComplexityRoot.Column.Scale(childComplexity), true + case "Column.Type": + if e.ComplexityRoot.Column.Type == nil { + break + } + + return e.ComplexityRoot.Column.Type(childComplexity), true + + case "Dashboard.CreatedAt": + if e.ComplexityRoot.Dashboard.CreatedAt == nil { + break + } + + return e.ComplexityRoot.Dashboard.CreatedAt(childComplexity), true + case "Dashboard.Description": + if e.ComplexityRoot.Dashboard.Description == nil { + break + } + + return e.ComplexityRoot.Dashboard.Description(childComplexity), true + case "Dashboard.ID": + if e.ComplexityRoot.Dashboard.ID == nil { + break + } + + return e.ComplexityRoot.Dashboard.ID(childComplexity), true + case "Dashboard.Name": + if e.ComplexityRoot.Dashboard.Name == nil { + break + } + + return e.ComplexityRoot.Dashboard.Name(childComplexity), true + case "Dashboard.RefreshRule": + if e.ComplexityRoot.Dashboard.RefreshRule == nil { + break + } + + return e.ComplexityRoot.Dashboard.RefreshRule(childComplexity), true + case "Dashboard.UpdatedAt": + if e.ComplexityRoot.Dashboard.UpdatedAt == nil { + break + } + + return e.ComplexityRoot.Dashboard.UpdatedAt(childComplexity), true + case "Dashboard.Widgets": + if e.ComplexityRoot.Dashboard.Widgets == nil { + break + } + + return e.ComplexityRoot.Dashboard.Widgets(childComplexity), true + + case "DashboardWidget.Description": + if e.ComplexityRoot.DashboardWidget.Description == nil { + break + } + + return e.ComplexityRoot.DashboardWidget.Description(childComplexity), true + case "DashboardWidget.ID": + if e.ComplexityRoot.DashboardWidget.ID == nil { + break + } + + return e.ComplexityRoot.DashboardWidget.ID(childComplexity), true + case "DashboardWidget.Layout": + if e.ComplexityRoot.DashboardWidget.Layout == nil { + break + } + + return e.ComplexityRoot.DashboardWidget.Layout(childComplexity), true + case "DashboardWidget.Query": + if e.ComplexityRoot.DashboardWidget.Query == nil { + break + } + + return e.ComplexityRoot.DashboardWidget.Query(childComplexity), true + case "DashboardWidget.QueryContext": + if e.ComplexityRoot.DashboardWidget.QueryContext == nil { + break + } + + return e.ComplexityRoot.DashboardWidget.QueryContext(childComplexity), true + case "DashboardWidget.Snapshot": + if e.ComplexityRoot.DashboardWidget.Snapshot == nil { + break + } + + return e.ComplexityRoot.DashboardWidget.Snapshot(childComplexity), true + case "DashboardWidget.SortOrder": + if e.ComplexityRoot.DashboardWidget.SortOrder == nil { + break + } + + return e.ComplexityRoot.DashboardWidget.SortOrder(childComplexity), true + case "DashboardWidget.Title": + if e.ComplexityRoot.DashboardWidget.Title == nil { + break + } + + return e.ComplexityRoot.DashboardWidget.Title(childComplexity), true + case "DashboardWidget.Type": + if e.ComplexityRoot.DashboardWidget.Type == nil { + break + } + + return e.ComplexityRoot.DashboardWidget.Type(childComplexity), true + case "DashboardWidget.Visualization": + if e.ComplexityRoot.DashboardWidget.Visualization == nil { + break + } + + return e.ComplexityRoot.DashboardWidget.Visualization(childComplexity), true + + case "DatabaseMetadata.aliasMap": + if e.ComplexityRoot.DatabaseMetadata.AliasMap == nil { + break + } + + return e.ComplexityRoot.DatabaseMetadata.AliasMap(childComplexity), true + case "DatabaseMetadata.capabilities": + if e.ComplexityRoot.DatabaseMetadata.Capabilities == nil { + break + } + + return e.ComplexityRoot.DatabaseMetadata.Capabilities(childComplexity), true + case "DatabaseMetadata.databaseType": + if e.ComplexityRoot.DatabaseMetadata.DatabaseType == nil { + break + } + + return e.ComplexityRoot.DatabaseMetadata.DatabaseType(childComplexity), true + case "DatabaseMetadata.operators": + if e.ComplexityRoot.DatabaseMetadata.Operators == nil { + break + } + + return e.ComplexityRoot.DatabaseMetadata.Operators(childComplexity), true + case "DatabaseMetadata.systemSchemas": + if e.ComplexityRoot.DatabaseMetadata.SystemSchemas == nil { + break + } + + return e.ComplexityRoot.DatabaseMetadata.SystemSchemas(childComplexity), true + case "DatabaseMetadata.typeDefinitions": + if e.ComplexityRoot.DatabaseMetadata.TypeDefinitions == nil { + break + } + + return e.ComplexityRoot.DatabaseMetadata.TypeDefinitions(childComplexity), true + + case "DatabaseQuerySuggestion.category": + if e.ComplexityRoot.DatabaseQuerySuggestion.Category == nil { + break + } + + return e.ComplexityRoot.DatabaseQuerySuggestion.Category(childComplexity), true + case "DatabaseQuerySuggestion.description": + if e.ComplexityRoot.DatabaseQuerySuggestion.Description == nil { + break + } + + return e.ComplexityRoot.DatabaseQuerySuggestion.Description(childComplexity), true + + case "DiscoveredConnection.DatabaseType": + if e.ComplexityRoot.DiscoveredConnection.DatabaseType == nil { + break + } + + return e.ComplexityRoot.DiscoveredConnection.DatabaseType(childComplexity), true + case "DiscoveredConnection.Id": + if e.ComplexityRoot.DiscoveredConnection.ID == nil { + break + } + + return e.ComplexityRoot.DiscoveredConnection.ID(childComplexity), true + case "DiscoveredConnection.Metadata": + if e.ComplexityRoot.DiscoveredConnection.Metadata == nil { + break + } + + return e.ComplexityRoot.DiscoveredConnection.Metadata(childComplexity), true + case "DiscoveredConnection.Name": + if e.ComplexityRoot.DiscoveredConnection.Name == nil { + break + } + + return e.ComplexityRoot.DiscoveredConnection.Name(childComplexity), true + case "DiscoveredConnection.ProviderID": + if e.ComplexityRoot.DiscoveredConnection.ProviderID == nil { + break + } + + return e.ComplexityRoot.DiscoveredConnection.ProviderID(childComplexity), true + case "DiscoveredConnection.ProviderType": + if e.ComplexityRoot.DiscoveredConnection.ProviderType == nil { + break + } + + return e.ComplexityRoot.DiscoveredConnection.ProviderType(childComplexity), true + case "DiscoveredConnection.Region": + if e.ComplexityRoot.DiscoveredConnection.Region == nil { + break + } + + return e.ComplexityRoot.DiscoveredConnection.Region(childComplexity), true + case "DiscoveredConnection.Status": + if e.ComplexityRoot.DiscoveredConnection.Status == nil { + break + } + + return e.ComplexityRoot.DiscoveredConnection.Status(childComplexity), true + + case "ExportDownload.ContentType": + if e.ComplexityRoot.ExportDownload.ContentType == nil { + break + } + + return e.ComplexityRoot.ExportDownload.ContentType(childComplexity), true + case "ExportDownload.DownloadURL": + if e.ComplexityRoot.ExportDownload.DownloadURL == nil { + break + } + + return e.ComplexityRoot.ExportDownload.DownloadURL(childComplexity), true + case "ExportDownload.Filename": + if e.ComplexityRoot.ExportDownload.Filename == nil { + break + } + + return e.ComplexityRoot.ExportDownload.Filename(childComplexity), true + case "ExportDownload.ID": + if e.ComplexityRoot.ExportDownload.ID == nil { + break + } + + return e.ComplexityRoot.ExportDownload.ID(childComplexity), true + case "ExportDownload.Size": + if e.ComplexityRoot.ExportDownload.Size == nil { + break + } + + return e.ComplexityRoot.ExportDownload.Size(childComplexity), true + + case "GenerateChatTitleResponse.Title": + if e.ComplexityRoot.GenerateChatTitleResponse.Title == nil { + break + } + + return e.ComplexityRoot.GenerateChatTitleResponse.Title(childComplexity), true + + case "GraphUnit.Relations": + if e.ComplexityRoot.GraphUnit.Relations == nil { + break + } + + return e.ComplexityRoot.GraphUnit.Relations(childComplexity), true + case "GraphUnit.Unit": + if e.ComplexityRoot.GraphUnit.Unit == nil { + break + } + + return e.ComplexityRoot.GraphUnit.Unit(childComplexity), true + + case "GraphUnitRelationship.Name": + if e.ComplexityRoot.GraphUnitRelationship.Name == nil { + break + } + + return e.ComplexityRoot.GraphUnitRelationship.Name(childComplexity), true + case "GraphUnitRelationship.Relationship": + if e.ComplexityRoot.GraphUnitRelationship.Relationship == nil { + break + } + + return e.ComplexityRoot.GraphUnitRelationship.Relationship(childComplexity), true + case "GraphUnitRelationship.SourceColumn": + if e.ComplexityRoot.GraphUnitRelationship.SourceColumn == nil { + break + } + + return e.ComplexityRoot.GraphUnitRelationship.SourceColumn(childComplexity), true + case "GraphUnitRelationship.TargetColumn": + if e.ComplexityRoot.GraphUnitRelationship.TargetColumn == nil { + break + } + + return e.ComplexityRoot.GraphUnitRelationship.TargetColumn(childComplexity), true + + case "HealthStatus.Database": + if e.ComplexityRoot.HealthStatus.Database == nil { + break + } + + return e.ComplexityRoot.HealthStatus.Database(childComplexity), true + case "HealthStatus.Server": + if e.ComplexityRoot.HealthStatus.Server == nil { + break + } + + return e.ComplexityRoot.HealthStatus.Server(childComplexity), true + + case "ImportColumnMappingPreview.SourceColumn": + if e.ComplexityRoot.ImportColumnMappingPreview.SourceColumn == nil { + break + } + + return e.ComplexityRoot.ImportColumnMappingPreview.SourceColumn(childComplexity), true + case "ImportColumnMappingPreview.TargetColumn": + if e.ComplexityRoot.ImportColumnMappingPreview.TargetColumn == nil { + break + } + + return e.ComplexityRoot.ImportColumnMappingPreview.TargetColumn(childComplexity), true + + case "ImportNewTableColumnPreview.Nullable": + if e.ComplexityRoot.ImportNewTableColumnPreview.Nullable == nil { + break + } + + return e.ComplexityRoot.ImportNewTableColumnPreview.Nullable(childComplexity), true + case "ImportNewTableColumnPreview.Primary": + if e.ComplexityRoot.ImportNewTableColumnPreview.Primary == nil { + break + } + + return e.ComplexityRoot.ImportNewTableColumnPreview.Primary(childComplexity), true + case "ImportNewTableColumnPreview.Skip": + if e.ComplexityRoot.ImportNewTableColumnPreview.Skip == nil { + break + } + + return e.ComplexityRoot.ImportNewTableColumnPreview.Skip(childComplexity), true + case "ImportNewTableColumnPreview.SourceColumn": + if e.ComplexityRoot.ImportNewTableColumnPreview.SourceColumn == nil { + break + } + + return e.ComplexityRoot.ImportNewTableColumnPreview.SourceColumn(childComplexity), true + case "ImportNewTableColumnPreview.TargetColumn": + if e.ComplexityRoot.ImportNewTableColumnPreview.TargetColumn == nil { + break + } + + return e.ComplexityRoot.ImportNewTableColumnPreview.TargetColumn(childComplexity), true + case "ImportNewTableColumnPreview.Type": + if e.ComplexityRoot.ImportNewTableColumnPreview.Type == nil { + break + } + + return e.ComplexityRoot.ImportNewTableColumnPreview.Type(childComplexity), true + + case "ImportNewTablePreview.Columns": + if e.ComplexityRoot.ImportNewTablePreview.Columns == nil { + break + } + + return e.ComplexityRoot.ImportNewTablePreview.Columns(childComplexity), true + case "ImportNewTablePreview.Issues": + if e.ComplexityRoot.ImportNewTablePreview.Issues == nil { + break + } + + return e.ComplexityRoot.ImportNewTablePreview.Issues(childComplexity), true + case "ImportNewTablePreview.TableName": + if e.ComplexityRoot.ImportNewTablePreview.TableName == nil { + break + } + + return e.ComplexityRoot.ImportNewTablePreview.TableName(childComplexity), true + + case "ImportPreview.AutoGeneratedColumns": + if e.ComplexityRoot.ImportPreview.AutoGeneratedColumns == nil { + break + } + + return e.ComplexityRoot.ImportPreview.AutoGeneratedColumns(childComplexity), true + case "ImportPreview.Columns": + if e.ComplexityRoot.ImportPreview.Columns == nil { + break + } + + return e.ComplexityRoot.ImportPreview.Columns(childComplexity), true + case "ImportPreview.Mapping": + if e.ComplexityRoot.ImportPreview.Mapping == nil { + break + } + + return e.ComplexityRoot.ImportPreview.Mapping(childComplexity), true + case "ImportPreview.NewTable": + if e.ComplexityRoot.ImportPreview.NewTable == nil { + break + } + + return e.ComplexityRoot.ImportPreview.NewTable(childComplexity), true + case "ImportPreview.RequiresAllowAutoGenerated": + if e.ComplexityRoot.ImportPreview.RequiresAllowAutoGenerated == nil { + break + } + + return e.ComplexityRoot.ImportPreview.RequiresAllowAutoGenerated(childComplexity), true + case "ImportPreview.Rows": + if e.ComplexityRoot.ImportPreview.Rows == nil { + break + } + + return e.ComplexityRoot.ImportPreview.Rows(childComplexity), true + case "ImportPreview.Sheet": + if e.ComplexityRoot.ImportPreview.Sheet == nil { + break + } + + return e.ComplexityRoot.ImportPreview.Sheet(childComplexity), true + case "ImportPreview.Sheets": + if e.ComplexityRoot.ImportPreview.Sheets == nil { + break + } + + return e.ComplexityRoot.ImportPreview.Sheets(childComplexity), true + case "ImportPreview.Truncated": + if e.ComplexityRoot.ImportPreview.Truncated == nil { + break + } + + return e.ComplexityRoot.ImportPreview.Truncated(childComplexity), true + case "ImportPreview.ValidationError": + if e.ComplexityRoot.ImportPreview.ValidationError == nil { + break + } + + return e.ComplexityRoot.ImportPreview.ValidationError(childComplexity), true + + case "ImportResult.Detail": + if e.ComplexityRoot.ImportResult.Detail == nil { + break + } + + return e.ComplexityRoot.ImportResult.Detail(childComplexity), true + case "ImportResult.Message": + if e.ComplexityRoot.ImportResult.Message == nil { + break + } + + return e.ComplexityRoot.ImportResult.Message(childComplexity), true + case "ImportResult.Status": + if e.ComplexityRoot.ImportResult.Status == nil { + break + } + + return e.ComplexityRoot.ImportResult.Status(childComplexity), true + + case "ImportSuggestionIssue.Field": + if e.ComplexityRoot.ImportSuggestionIssue.Field == nil { + break + } + + return e.ComplexityRoot.ImportSuggestionIssue.Field(childComplexity), true + case "ImportSuggestionIssue.Key": + if e.ComplexityRoot.ImportSuggestionIssue.Key == nil { + break + } + + return e.ComplexityRoot.ImportSuggestionIssue.Key(childComplexity), true + case "ImportSuggestionIssue.SourceColumn": + if e.ComplexityRoot.ImportSuggestionIssue.SourceColumn == nil { + break + } + + return e.ComplexityRoot.ImportSuggestionIssue.SourceColumn(childComplexity), true + + case "LocalAWSProfile.AuthType": + if e.ComplexityRoot.LocalAWSProfile.AuthType == nil { + break + } + + return e.ComplexityRoot.LocalAWSProfile.AuthType(childComplexity), true + case "LocalAWSProfile.IsDefault": + if e.ComplexityRoot.LocalAWSProfile.IsDefault == nil { + break + } + + return e.ComplexityRoot.LocalAWSProfile.IsDefault(childComplexity), true + case "LocalAWSProfile.Name": + if e.ComplexityRoot.LocalAWSProfile.Name == nil { + break + } + + return e.ComplexityRoot.LocalAWSProfile.Name(childComplexity), true + case "LocalAWSProfile.Region": + if e.ComplexityRoot.LocalAWSProfile.Region == nil { + break + } + + return e.ComplexityRoot.LocalAWSProfile.Region(childComplexity), true + case "LocalAWSProfile.Source": + if e.ComplexityRoot.LocalAWSProfile.Source == nil { + break + } + + return e.ComplexityRoot.LocalAWSProfile.Source(childComplexity), true + + case "LoginProfile.Alias": + if e.ComplexityRoot.LoginProfile.Alias == nil { + break + } + + return e.ComplexityRoot.LoginProfile.Alias(childComplexity), true + case "LoginProfile.Database": + if e.ComplexityRoot.LoginProfile.Database == nil { + break + } + + return e.ComplexityRoot.LoginProfile.Database(childComplexity), true + case "LoginProfile.Hostname": + if e.ComplexityRoot.LoginProfile.Hostname == nil { + break + } + + return e.ComplexityRoot.LoginProfile.Hostname(childComplexity), true + case "LoginProfile.Id": + if e.ComplexityRoot.LoginProfile.ID == nil { + break + } + + return e.ComplexityRoot.LoginProfile.ID(childComplexity), true + case "LoginProfile.IsEnvironmentDefined": + if e.ComplexityRoot.LoginProfile.IsEnvironmentDefined == nil { + break + } + + return e.ComplexityRoot.LoginProfile.IsEnvironmentDefined(childComplexity), true + case "LoginProfile.SSLConfigured": + if e.ComplexityRoot.LoginProfile.SSLConfigured == nil { + break + } + + return e.ComplexityRoot.LoginProfile.SSLConfigured(childComplexity), true + case "LoginProfile.Source": + if e.ComplexityRoot.LoginProfile.Source == nil { + break + } + + return e.ComplexityRoot.LoginProfile.Source(childComplexity), true + case "LoginProfile.Type": + if e.ComplexityRoot.LoginProfile.Type == nil { + break + } + + return e.ComplexityRoot.LoginProfile.Type(childComplexity), true + + case "MockDataDependencyAnalysis.Error": + if e.ComplexityRoot.MockDataDependencyAnalysis.Error == nil { + break + } + + return e.ComplexityRoot.MockDataDependencyAnalysis.Error(childComplexity), true + case "MockDataDependencyAnalysis.GenerationOrder": + if e.ComplexityRoot.MockDataDependencyAnalysis.GenerationOrder == nil { + break + } + + return e.ComplexityRoot.MockDataDependencyAnalysis.GenerationOrder(childComplexity), true + case "MockDataDependencyAnalysis.Tables": + if e.ComplexityRoot.MockDataDependencyAnalysis.Tables == nil { + break + } + + return e.ComplexityRoot.MockDataDependencyAnalysis.Tables(childComplexity), true + case "MockDataDependencyAnalysis.TotalRows": + if e.ComplexityRoot.MockDataDependencyAnalysis.TotalRows == nil { + break + } + + return e.ComplexityRoot.MockDataDependencyAnalysis.TotalRows(childComplexity), true + case "MockDataDependencyAnalysis.Warnings": + if e.ComplexityRoot.MockDataDependencyAnalysis.Warnings == nil { + break + } + + return e.ComplexityRoot.MockDataDependencyAnalysis.Warnings(childComplexity), true + + case "MockDataGenerationStatus.AmountGenerated": + if e.ComplexityRoot.MockDataGenerationStatus.AmountGenerated == nil { + break + } + + return e.ComplexityRoot.MockDataGenerationStatus.AmountGenerated(childComplexity), true + case "MockDataGenerationStatus.Details": + if e.ComplexityRoot.MockDataGenerationStatus.Details == nil { + break + } + + return e.ComplexityRoot.MockDataGenerationStatus.Details(childComplexity), true + + case "MockDataTableDetail.RowsGenerated": + if e.ComplexityRoot.MockDataTableDetail.RowsGenerated == nil { + break + } + + return e.ComplexityRoot.MockDataTableDetail.RowsGenerated(childComplexity), true + case "MockDataTableDetail.Table": + if e.ComplexityRoot.MockDataTableDetail.Table == nil { + break + } + + return e.ComplexityRoot.MockDataTableDetail.Table(childComplexity), true + case "MockDataTableDetail.UsedExistingData": + if e.ComplexityRoot.MockDataTableDetail.UsedExistingData == nil { + break + } + + return e.ComplexityRoot.MockDataTableDetail.UsedExistingData(childComplexity), true + + case "MockDataTableInfo.IsBlocked": + if e.ComplexityRoot.MockDataTableInfo.IsBlocked == nil { + break + } + + return e.ComplexityRoot.MockDataTableInfo.IsBlocked(childComplexity), true + case "MockDataTableInfo.RowsToGenerate": + if e.ComplexityRoot.MockDataTableInfo.RowsToGenerate == nil { + break + } + + return e.ComplexityRoot.MockDataTableInfo.RowsToGenerate(childComplexity), true + case "MockDataTableInfo.Table": + if e.ComplexityRoot.MockDataTableInfo.Table == nil { + break + } + + return e.ComplexityRoot.MockDataTableInfo.Table(childComplexity), true + case "MockDataTableInfo.UsesExistingData": + if e.ComplexityRoot.MockDataTableInfo.UsesExistingData == nil { + break + } + + return e.ComplexityRoot.MockDataTableInfo.UsesExistingData(childComplexity), true + + case "Mutation.AddAWSProvider": + if e.ComplexityRoot.Mutation.AddAWSProvider == nil { + break + } + + args, err := ec.field_Mutation_AddAWSProvider_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.AddAWSProvider(childComplexity, args["input"].(model.AWSProviderInput)), true + case "Mutation.AddRow": + if e.ComplexityRoot.Mutation.AddRow == nil { + break + } + + args, err := ec.field_Mutation_AddRow_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.AddRow(childComplexity, args["schema"].(string), args["storageUnit"].(string), args["values"].([]*model.RecordInput)), true + case "Mutation.AddStorageUnit": + if e.ComplexityRoot.Mutation.AddStorageUnit == nil { + break + } + + args, err := ec.field_Mutation_AddStorageUnit_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.AddStorageUnit(childComplexity, args["schema"].(string), args["storageUnit"].(string), args["fields"].([]*model.RecordInput)), true + case "Mutation.AddWidget": + if e.ComplexityRoot.Mutation.AddWidget == nil { + break + } + + args, err := ec.field_Mutation_AddWidget_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.AddWidget(childComplexity, args["dashboardId"].(string), args["input"].(model.WidgetInput)), true + case "Mutation.BootstrapSealosSession": + if e.ComplexityRoot.Mutation.BootstrapSealosSession == nil { + break + } + + args, err := ec.field_Mutation_BootstrapSealosSession_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.BootstrapSealosSession(childComplexity, args["input"].(model.SealosBootstrapInput)), true + case "Mutation.CreateDashboard": + if e.ComplexityRoot.Mutation.CreateDashboard == nil { + break + } + + args, err := ec.field_Mutation_CreateDashboard_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.CreateDashboard(childComplexity, args["name"].(string), args["description"].(*string), args["refreshRule"].(string)), true + case "Mutation.CreateSQLDataExport": + if e.ComplexityRoot.Mutation.CreateSQLDataExport == nil { + break + } + + args, err := ec.field_Mutation_CreateSQLDataExport_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.CreateSQLDataExport(childComplexity, args["input"].(model.SQLDataExportInput)), true + case "Mutation.CreateStandaloneSession": + if e.ComplexityRoot.Mutation.CreateStandaloneSession == nil { + break + } + + args, err := ec.field_Mutation_CreateStandaloneSession_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.CreateStandaloneSession(childComplexity, args["credentials"].(model.LoginCredentials)), true + case "Mutation.DeleteDashboard": + if e.ComplexityRoot.Mutation.DeleteDashboard == nil { + break + } + + args, err := ec.field_Mutation_DeleteDashboard_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.DeleteDashboard(childComplexity, args["id"].(string)), true + case "Mutation.DeleteRow": + if e.ComplexityRoot.Mutation.DeleteRow == nil { + break + } + + args, err := ec.field_Mutation_DeleteRow_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.DeleteRow(childComplexity, args["schema"].(string), args["storageUnit"].(string), args["values"].([]*model.RecordInput)), true + case "Mutation.DeleteWidget": + if e.ComplexityRoot.Mutation.DeleteWidget == nil { + break + } + + args, err := ec.field_Mutation_DeleteWidget_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.DeleteWidget(childComplexity, args["id"].(string)), true + case "Mutation.ExecuteConfirmedSQL": + if e.ComplexityRoot.Mutation.ExecuteConfirmedSQL == nil { + break + } + + args, err := ec.field_Mutation_ExecuteConfirmedSQL_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.ExecuteConfirmedSQL(childComplexity, args["query"].(string), args["operationType"].(string)), true + case "Mutation.GenerateChatTitle": + if e.ComplexityRoot.Mutation.GenerateChatTitle == nil { + break + } + + args, err := ec.field_Mutation_GenerateChatTitle_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.GenerateChatTitle(childComplexity, args["input"].(model.GenerateChatTitleInput)), true + case "Mutation.GenerateMockData": + if e.ComplexityRoot.Mutation.GenerateMockData == nil { + break + } + + args, err := ec.field_Mutation_GenerateMockData_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.GenerateMockData(childComplexity, args["input"].(model.MockDataGenerationInput)), true + case "Mutation.GenerateRDSAuthToken": + if e.ComplexityRoot.Mutation.GenerateRDSAuthToken == nil { + break + } + + args, err := ec.field_Mutation_GenerateRDSAuthToken_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.GenerateRDSAuthToken(childComplexity, args["providerID"].(string), args["endpoint"].(string), args["port"].(int), args["region"].(string), args["username"].(string)), true + case "Mutation.ImportCollectionFile": + if e.ComplexityRoot.Mutation.ImportCollectionFile == nil { + break + } + + args, err := ec.field_Mutation_ImportCollectionFile_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.ImportCollectionFile(childComplexity, args["input"].(model.ImportCollectionFileInput)), true + case "Mutation.ImportCollectionPreview": + if e.ComplexityRoot.Mutation.ImportCollectionPreview == nil { + break + } + + args, err := ec.field_Mutation_ImportCollectionPreview_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.ImportCollectionPreview(childComplexity, args["input"].(model.ImportCollectionPreviewInput)), true + case "Mutation.ImportPreview": + if e.ComplexityRoot.Mutation.ImportPreview == nil { + break + } + + args, err := ec.field_Mutation_ImportPreview_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.ImportPreview(childComplexity, args["input"].(model.ImportPreviewInput)), true + case "Mutation.ImportSQL": + if e.ComplexityRoot.Mutation.ImportSQL == nil { + break + } + + args, err := ec.field_Mutation_ImportSQL_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.ImportSQL(childComplexity, args["input"].(model.ImportSQLInput)), true + case "Mutation.ImportTableFile": + if e.ComplexityRoot.Mutation.ImportTableFile == nil { + break + } + + args, err := ec.field_Mutation_ImportTableFile_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.ImportTableFile(childComplexity, args["input"].(model.ImportFileInput)), true + case "Mutation.Login": + if e.ComplexityRoot.Mutation.Login == nil { + break + } + + args, err := ec.field_Mutation_Login_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.Login(childComplexity, args["credentials"].(model.LoginCredentials)), true + case "Mutation.LoginWithProfile": + if e.ComplexityRoot.Mutation.LoginWithProfile == nil { + break + } + + args, err := ec.field_Mutation_LoginWithProfile_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.LoginWithProfile(childComplexity, args["profile"].(model.LoginProfileInput)), true + case "Mutation.Logout": + if e.ComplexityRoot.Mutation.Logout == nil { + break + } + + return e.ComplexityRoot.Mutation.Logout(childComplexity), true + case "Mutation.RefreshCloudProvider": + if e.ComplexityRoot.Mutation.RefreshCloudProvider == nil { + break + } + + args, err := ec.field_Mutation_RefreshCloudProvider_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.RefreshCloudProvider(childComplexity, args["id"].(string)), true + case "Mutation.RemoveCloudProvider": + if e.ComplexityRoot.Mutation.RemoveCloudProvider == nil { + break + } + + args, err := ec.field_Mutation_RemoveCloudProvider_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.RemoveCloudProvider(childComplexity, args["id"].(string)), true + case "Mutation.ReplaceRow": + if e.ComplexityRoot.Mutation.ReplaceRow == nil { + break + } + + args, err := ec.field_Mutation_ReplaceRow_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.ReplaceRow(childComplexity, args["schema"].(string), args["storageUnit"].(string), args["values"].([]*model.RecordInput)), true + case "Mutation.TestAWSCredentials": + if e.ComplexityRoot.Mutation.TestAWSCredentials == nil { + break + } + + args, err := ec.field_Mutation_TestAWSCredentials_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.TestAWSCredentials(childComplexity, args["input"].(model.AWSProviderInput)), true + case "Mutation.TestCloudProvider": + if e.ComplexityRoot.Mutation.TestCloudProvider == nil { + break + } + + args, err := ec.field_Mutation_TestCloudProvider_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.TestCloudProvider(childComplexity, args["id"].(string)), true + case "Mutation.UpdateAWSProvider": + if e.ComplexityRoot.Mutation.UpdateAWSProvider == nil { + break + } + + args, err := ec.field_Mutation_UpdateAWSProvider_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.UpdateAWSProvider(childComplexity, args["id"].(string), args["input"].(model.AWSProviderInput)), true + case "Mutation.UpdateDashboard": + if e.ComplexityRoot.Mutation.UpdateDashboard == nil { + break + } + + args, err := ec.field_Mutation_UpdateDashboard_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.UpdateDashboard(childComplexity, args["id"].(string), args["name"].(*string), args["description"].(*string), args["refreshRule"].(*string)), true + case "Mutation.UpdateSettings": + if e.ComplexityRoot.Mutation.UpdateSettings == nil { + break + } + + args, err := ec.field_Mutation_UpdateSettings_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.UpdateSettings(childComplexity, args["newSettings"].(model.SettingsConfigInput)), true + case "Mutation.UpdateStorageUnit": + if e.ComplexityRoot.Mutation.UpdateStorageUnit == nil { + break + } + + args, err := ec.field_Mutation_UpdateStorageUnit_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.UpdateStorageUnit(childComplexity, args["schema"].(string), args["storageUnit"].(string), args["values"].([]*model.RecordInput), args["updatedColumns"].([]string)), true + case "Mutation.UpdateWidget": + if e.ComplexityRoot.Mutation.UpdateWidget == nil { + break + } + + args, err := ec.field_Mutation_UpdateWidget_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.UpdateWidget(childComplexity, args["id"].(string), args["input"].(model.UpdateWidgetInput)), true + case "Mutation.UpdateWidgetLayouts": + if e.ComplexityRoot.Mutation.UpdateWidgetLayouts == nil { + break + } + + args, err := ec.field_Mutation_UpdateWidgetLayouts_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.UpdateWidgetLayouts(childComplexity, args["dashboardId"].(string), args["layouts"].([]*model.LayoutInput)), true + case "Mutation.UpdateWidgetSnapshot": + if e.ComplexityRoot.Mutation.UpdateWidgetSnapshot == nil { + break + } + + args, err := ec.field_Mutation_UpdateWidgetSnapshot_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.UpdateWidgetSnapshot(childComplexity, args["id"].(string), args["snapshot"].(model.SnapshotInput)), true + + case "Query.AIChat": + if e.ComplexityRoot.Query.AIChat == nil { + break + } + + args, err := ec.field_Query_AIChat_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.AIChat(childComplexity, args["providerId"].(*string), args["modelType"].(string), args["token"].(*string), args["schema"].(string), args["input"].(model.ChatInput)), true + case "Query.AIModel": + if e.ComplexityRoot.Query.AIModel == nil { + break + } + + args, err := ec.field_Query_AIModel_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.AIModel(childComplexity, args["providerId"].(*string), args["modelType"].(string), args["token"].(*string)), true + case "Query.AIProviders": + if e.ComplexityRoot.Query.AIProviders == nil { + break + } + + return e.ComplexityRoot.Query.AIProviders(childComplexity), true + case "Query.AWSRegions": + if e.ComplexityRoot.Query.AWSRegions == nil { + break + } + + return e.ComplexityRoot.Query.AWSRegions(childComplexity), true + case "Query.AnalyzeMockDataDependencies": + if e.ComplexityRoot.Query.AnalyzeMockDataDependencies == nil { + break + } + + args, err := ec.field_Query_AnalyzeMockDataDependencies_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.AnalyzeMockDataDependencies(childComplexity, args["schema"].(string), args["storageUnit"].(string), args["rowCount"].(int), args["fkDensityRatio"].(*int)), true + case "Query.CloudProvider": + if e.ComplexityRoot.Query.CloudProvider == nil { + break + } + + args, err := ec.field_Query_CloudProvider_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.CloudProvider(childComplexity, args["id"].(string)), true + case "Query.CloudProviders": + if e.ComplexityRoot.Query.CloudProviders == nil { + break + } + + return e.ComplexityRoot.Query.CloudProviders(childComplexity), true + case "Query.Columns": + if e.ComplexityRoot.Query.Columns == nil { + break + } + + args, err := ec.field_Query_Columns_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.Columns(childComplexity, args["schema"].(string), args["storageUnit"].(string)), true + case "Query.ColumnsBatch": + if e.ComplexityRoot.Query.ColumnsBatch == nil { + break + } + + args, err := ec.field_Query_ColumnsBatch_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.ColumnsBatch(childComplexity, args["schema"].(string), args["storageUnits"].([]string)), true + case "Query.Database": + if e.ComplexityRoot.Query.Database == nil { + break + } + + args, err := ec.field_Query_Database_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.Database(childComplexity, args["type"].(string)), true + case "Query.DatabaseMetadata": + if e.ComplexityRoot.Query.DatabaseMetadata == nil { + break + } + + return e.ComplexityRoot.Query.DatabaseMetadata(childComplexity), true + case "Query.DatabaseQuerySuggestions": + if e.ComplexityRoot.Query.DatabaseQuerySuggestions == nil { + break + } + + args, err := ec.field_Query_DatabaseQuerySuggestions_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.DatabaseQuerySuggestions(childComplexity, args["schema"].(string)), true + case "Query.DiscoveredConnections": + if e.ComplexityRoot.Query.DiscoveredConnections == nil { + break + } + + return e.ComplexityRoot.Query.DiscoveredConnections(childComplexity), true + case "Query.GetDashboards": + if e.ComplexityRoot.Query.GetDashboards == nil { + break + } + + return e.ComplexityRoot.Query.GetDashboards(childComplexity), true + case "Query.Graph": + if e.ComplexityRoot.Query.Graph == nil { + break + } + + args, err := ec.field_Query_Graph_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.Graph(childComplexity, args["schema"].(string)), true + case "Query.Health": + if e.ComplexityRoot.Query.Health == nil { + break + } + + return e.ComplexityRoot.Query.Health(childComplexity), true + + case "Query.LocalAWSProfiles": + if e.ComplexityRoot.Query.LocalAWSProfiles == nil { + break + } + + return e.ComplexityRoot.Query.LocalAWSProfiles(childComplexity), true + case "Query.MockDataMaxRowCount": + if e.ComplexityRoot.Query.MockDataMaxRowCount == nil { + break + } + + return e.ComplexityRoot.Query.MockDataMaxRowCount(childComplexity), true + case "Query.Profiles": + if e.ComplexityRoot.Query.Profiles == nil { + break + } + + return e.ComplexityRoot.Query.Profiles(childComplexity), true + case "Query.ProviderConnections": + if e.ComplexityRoot.Query.ProviderConnections == nil { + break + } + + args, err := ec.field_Query_ProviderConnections_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.ProviderConnections(childComplexity, args["providerID"].(string)), true + case "Query.RawExecute": + if e.ComplexityRoot.Query.RawExecute == nil { + break + } + + args, err := ec.field_Query_RawExecute_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.RawExecute(childComplexity, args["query"].(string)), true + case "Query.ResolveSealosInstanceIdentity": + if e.ComplexityRoot.Query.ResolveSealosInstanceIdentity == nil { + break + } + + args, err := ec.field_Query_ResolveSealosInstanceIdentity_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.ResolveSealosInstanceIdentity(childComplexity, args["input"].(model.SealosInstanceIdentityInput)), true + case "Query.Row": + if e.ComplexityRoot.Query.Row == nil { + break + } + + args, err := ec.field_Query_Row_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.Row(childComplexity, args["schema"].(string), args["storageUnit"].(string), args["where"].(*model.WhereCondition), args["sort"].([]*model.SortCondition), args["pageSize"].(int), args["pageOffset"].(int)), true + case "Query.SSLStatus": + if e.ComplexityRoot.Query.SSLStatus == nil { + break + } + + return e.ComplexityRoot.Query.SSLStatus(childComplexity), true + case "Query.Schema": + if e.ComplexityRoot.Query.Schema == nil { + break + } + + return e.ComplexityRoot.Query.Schema(childComplexity), true + case "Query.SettingsConfig": + if e.ComplexityRoot.Query.SettingsConfig == nil { + break + } + + return e.ComplexityRoot.Query.SettingsConfig(childComplexity), true + case "Query.StorageUnit": + if e.ComplexityRoot.Query.StorageUnit == nil { + break + } + + args, err := ec.field_Query_StorageUnit_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.StorageUnit(childComplexity, args["schema"].(string)), true + case "Query.UpdateInfo": + if e.ComplexityRoot.Query.UpdateInfo == nil { + break + } + + return e.ComplexityRoot.Query.UpdateInfo(childComplexity), true + case "Query.Version": + if e.ComplexityRoot.Query.Version == nil { + break + } + + return e.ComplexityRoot.Query.Version(childComplexity), true + + case "Record.Key": + if e.ComplexityRoot.Record.Key == nil { + break + } + + return e.ComplexityRoot.Record.Key(childComplexity), true + case "Record.Value": + if e.ComplexityRoot.Record.Value == nil { + break + } + + return e.ComplexityRoot.Record.Value(childComplexity), true + + case "RowsResult.Columns": + if e.ComplexityRoot.RowsResult.Columns == nil { + break + } + + return e.ComplexityRoot.RowsResult.Columns(childComplexity), true + case "RowsResult.DisableUpdate": + if e.ComplexityRoot.RowsResult.DisableUpdate == nil { + break + } + + return e.ComplexityRoot.RowsResult.DisableUpdate(childComplexity), true + case "RowsResult.Rows": + if e.ComplexityRoot.RowsResult.Rows == nil { + break + } + + return e.ComplexityRoot.RowsResult.Rows(childComplexity), true + case "RowsResult.TotalCount": + if e.ComplexityRoot.RowsResult.TotalCount == nil { + break + } + + return e.ComplexityRoot.RowsResult.TotalCount(childComplexity), true + + case "SSLStatus.IsEnabled": + if e.ComplexityRoot.SSLStatus.IsEnabled == nil { + break + } + + return e.ComplexityRoot.SSLStatus.IsEnabled(childComplexity), true + case "SSLStatus.Mode": + if e.ComplexityRoot.SSLStatus.Mode == nil { + break + } + + return e.ComplexityRoot.SSLStatus.Mode(childComplexity), true + + case "SealosInstanceIdentity.namespace": + if e.ComplexityRoot.SealosInstanceIdentity.Namespace == nil { + break + } + + return e.ComplexityRoot.SealosInstanceIdentity.Namespace(childComplexity), true + case "SealosInstanceIdentity.resourceName": + if e.ComplexityRoot.SealosInstanceIdentity.ResourceName == nil { + break + } + + return e.ComplexityRoot.SealosInstanceIdentity.ResourceName(childComplexity), true + case "SealosInstanceIdentity.uid": + if e.ComplexityRoot.SealosInstanceIdentity.UID == nil { + break + } + + return e.ComplexityRoot.SealosInstanceIdentity.UID(childComplexity), true + + case "SettingsConfig.CloudProvidersEnabled": + if e.ComplexityRoot.SettingsConfig.CloudProvidersEnabled == nil { + break + } + + return e.ComplexityRoot.SettingsConfig.CloudProvidersEnabled(childComplexity), true + case "SettingsConfig.DisableCredentialForm": + if e.ComplexityRoot.SettingsConfig.DisableCredentialForm == nil { + break + } + + return e.ComplexityRoot.SettingsConfig.DisableCredentialForm(childComplexity), true + case "SettingsConfig.MaxPageSize": + if e.ComplexityRoot.SettingsConfig.MaxPageSize == nil { + break + } + + return e.ComplexityRoot.SettingsConfig.MaxPageSize(childComplexity), true + case "SettingsConfig.MetricsEnabled": + if e.ComplexityRoot.SettingsConfig.MetricsEnabled == nil { + break + } + + return e.ComplexityRoot.SettingsConfig.MetricsEnabled(childComplexity), true + case "SettingsConfig.StandaloneLoginEnabled": + if e.ComplexityRoot.SettingsConfig.StandaloneLoginEnabled == nil { + break + } + + return e.ComplexityRoot.SettingsConfig.StandaloneLoginEnabled(childComplexity), true + + case "StatusResponse.Status": + if e.ComplexityRoot.StatusResponse.Status == nil { + break + } + + return e.ComplexityRoot.StatusResponse.Status(childComplexity), true + + case "StorageUnit.Attributes": + if e.ComplexityRoot.StorageUnit.Attributes == nil { + break + } + + return e.ComplexityRoot.StorageUnit.Attributes(childComplexity), true + case "StorageUnit.IsMockDataGenerationAllowed": + if e.ComplexityRoot.StorageUnit.IsMockDataGenerationAllowed == nil { + break + } + + return e.ComplexityRoot.StorageUnit.IsMockDataGenerationAllowed(childComplexity), true + case "StorageUnit.Name": + if e.ComplexityRoot.StorageUnit.Name == nil { + break + } + + return e.ComplexityRoot.StorageUnit.Name(childComplexity), true + + case "StorageUnitColumns.Columns": + if e.ComplexityRoot.StorageUnitColumns.Columns == nil { + break + } + + return e.ComplexityRoot.StorageUnitColumns.Columns(childComplexity), true + case "StorageUnitColumns.StorageUnit": + if e.ComplexityRoot.StorageUnitColumns.StorageUnit == nil { + break + } + + return e.ComplexityRoot.StorageUnitColumns.StorageUnit(childComplexity), true + + case "TypeDefinition.category": + if e.ComplexityRoot.TypeDefinition.Category == nil { + break + } + + return e.ComplexityRoot.TypeDefinition.Category(childComplexity), true + case "TypeDefinition.defaultLength": + if e.ComplexityRoot.TypeDefinition.DefaultLength == nil { + break + } + + return e.ComplexityRoot.TypeDefinition.DefaultLength(childComplexity), true + case "TypeDefinition.defaultPrecision": + if e.ComplexityRoot.TypeDefinition.DefaultPrecision == nil { + break + } + + return e.ComplexityRoot.TypeDefinition.DefaultPrecision(childComplexity), true + case "TypeDefinition.hasLength": + if e.ComplexityRoot.TypeDefinition.HasLength == nil { + break + } + + return e.ComplexityRoot.TypeDefinition.HasLength(childComplexity), true + case "TypeDefinition.hasPrecision": + if e.ComplexityRoot.TypeDefinition.HasPrecision == nil { + break + } + + return e.ComplexityRoot.TypeDefinition.HasPrecision(childComplexity), true + case "TypeDefinition.id": + if e.ComplexityRoot.TypeDefinition.ID == nil { + break + } + + return e.ComplexityRoot.TypeDefinition.ID(childComplexity), true + case "TypeDefinition.label": + if e.ComplexityRoot.TypeDefinition.Label == nil { + break + } + + return e.ComplexityRoot.TypeDefinition.Label(childComplexity), true + + case "UpdateInfo.currentVersion": + if e.ComplexityRoot.UpdateInfo.CurrentVersion == nil { + break + } + + return e.ComplexityRoot.UpdateInfo.CurrentVersion(childComplexity), true + case "UpdateInfo.latestVersion": + if e.ComplexityRoot.UpdateInfo.LatestVersion == nil { + break + } + + return e.ComplexityRoot.UpdateInfo.LatestVersion(childComplexity), true + case "UpdateInfo.releaseURL": + if e.ComplexityRoot.UpdateInfo.ReleaseURL == nil { + break + } + + return e.ComplexityRoot.UpdateInfo.ReleaseURL(childComplexity), true + case "UpdateInfo.updateAvailable": + if e.ComplexityRoot.UpdateInfo.UpdateAvailable == nil { + break + } + + return e.ComplexityRoot.UpdateInfo.UpdateAvailable(childComplexity), true + + } + return 0, false +} + +func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { + opCtx := graphql.GetOperationContext(ctx) + ec := newExecutionContext(opCtx, e, make(chan graphql.DeferredResult)) + inputUnmarshalMap := graphql.BuildUnmarshalerMap( + ec.unmarshalInputAWSProviderInput, + ec.unmarshalInputAtomicWhereCondition, + ec.unmarshalInputChatInput, + ec.unmarshalInputGenerateChatTitleInput, + ec.unmarshalInputImportCollectionFileInput, + ec.unmarshalInputImportCollectionPreviewInput, + ec.unmarshalInputImportColumnMapping, + ec.unmarshalInputImportExistingTableInput, + ec.unmarshalInputImportExistingTablePreviewInput, + ec.unmarshalInputImportFileInput, + ec.unmarshalInputImportFileOptions, + ec.unmarshalInputImportNewTableColumnInput, + ec.unmarshalInputImportNewTableInput, + ec.unmarshalInputImportNewTablePreviewInput, + ec.unmarshalInputImportPreviewInput, + ec.unmarshalInputImportSQLInput, + ec.unmarshalInputLayoutInput, + ec.unmarshalInputLoginCredentials, + ec.unmarshalInputLoginProfileInput, + ec.unmarshalInputMockDataGenerationInput, + ec.unmarshalInputOperationWhereCondition, + ec.unmarshalInputRecordInput, + ec.unmarshalInputSQLDataExportInput, + ec.unmarshalInputSealosBootstrapInput, + ec.unmarshalInputSealosInstanceIdentityInput, + ec.unmarshalInputSettingsConfigInput, + ec.unmarshalInputSnapshotInput, + ec.unmarshalInputSortCondition, + ec.unmarshalInputUpdateWidgetInput, + ec.unmarshalInputWhereCondition, + ec.unmarshalInputWidgetInput, + ) + first := true + + switch opCtx.Operation.Operation { + case ast.Query: + return func(ctx context.Context) *graphql.Response { + var response graphql.Response + var data graphql.Marshaler + if first { + first = false + ctx = graphql.WithUnmarshalerMap(ctx, inputUnmarshalMap) + data = ec._Query(ctx, opCtx.Operation.SelectionSet) + } else { + if atomic.LoadInt32(&ec.PendingDeferred) > 0 { + result := <-ec.DeferredResults + atomic.AddInt32(&ec.PendingDeferred, -1) + data = result.Result + response.Path = result.Path + response.Label = result.Label + response.Errors = result.Errors + } else { + return nil + } + } + var buf bytes.Buffer + data.MarshalGQL(&buf) + response.Data = buf.Bytes() + if atomic.LoadInt32(&ec.Deferred) > 0 { + hasNext := atomic.LoadInt32(&ec.PendingDeferred) > 0 + response.HasNext = &hasNext + } + + return &response + } + case ast.Mutation: + return func(ctx context.Context) *graphql.Response { + if !first { + return nil + } + first = false + ctx = graphql.WithUnmarshalerMap(ctx, inputUnmarshalMap) + data := ec._Mutation(ctx, opCtx.Operation.SelectionSet) + var buf bytes.Buffer + data.MarshalGQL(&buf) + + return &graphql.Response{ + Data: buf.Bytes(), + } + } + + default: + return graphql.OneShot(graphql.ErrorResponse(ctx, "unsupported GraphQL operation")) + } +} + +type executionContext struct { + *graphql.ExecutionContextState[ResolverRoot, DirectiveRoot, ComplexityRoot] +} + +func newExecutionContext( + opCtx *graphql.OperationContext, + execSchema *executableSchema, + deferredResults chan graphql.DeferredResult, +) executionContext { + return executionContext{ + ExecutionContextState: graphql.NewExecutionContextState[ResolverRoot, DirectiveRoot, ComplexityRoot]( + opCtx, + (*graphql.ExecutableSchemaState[ResolverRoot, DirectiveRoot, ComplexityRoot])(execSchema), + parsedSchema, + deferredResults, + ), + } +} + +//go:embed "schema.graphqls" +var sourcesFS embed.FS + +func sourceData(filename string) string { + data, err := sourcesFS.ReadFile(filename) + if err != nil { + panic(fmt.Sprintf("codegen problem: %s not available", filename)) + } + return string(data) +} + +var sources = []*ast.Source{ + {Name: "schema.graphqls", Input: sourceData("schema.graphqls"), BuiltIn: false}, +} +var parsedSchema = gqlparser.MustLoadSchema(sources...) + +// endregion ************************** generated!.gotpl ************************** + +// region ***************************** args.gotpl ***************************** + +func (ec *executionContext) field_Mutation_AddAWSProvider_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNAWSProviderInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSProviderInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_AddRow_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "schema", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["schema"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "storageUnit", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["storageUnit"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "values", ec.unmarshalNRecordInput2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecordInputᚄ) + if err != nil { + return nil, err + } + args["values"] = arg2 + return args, nil +} + +func (ec *executionContext) field_Mutation_AddStorageUnit_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "schema", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["schema"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "storageUnit", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["storageUnit"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "fields", ec.unmarshalNRecordInput2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecordInputᚄ) + if err != nil { + return nil, err + } + args["fields"] = arg2 + return args, nil +} + +func (ec *executionContext) field_Mutation_AddWidget_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "dashboardId", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["dashboardId"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNWidgetInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐWidgetInput) + if err != nil { + return nil, err + } + args["input"] = arg1 + return args, nil +} + +func (ec *executionContext) field_Mutation_BootstrapSealosSession_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNSealosBootstrapInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSealosBootstrapInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_CreateDashboard_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "name", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["name"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "description", ec.unmarshalOString2ᚖstring) + if err != nil { + return nil, err + } + args["description"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "refreshRule", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["refreshRule"] = arg2 + return args, nil +} + +func (ec *executionContext) field_Mutation_CreateSQLDataExport_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNSQLDataExportInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSQLDataExportInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_CreateStandaloneSession_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "credentials", ec.unmarshalNLoginCredentials2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLoginCredentials) + if err != nil { + return nil, err + } + args["credentials"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_DeleteDashboard_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["id"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_DeleteRow_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "schema", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["schema"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "storageUnit", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["storageUnit"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "values", ec.unmarshalNRecordInput2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecordInputᚄ) + if err != nil { + return nil, err + } + args["values"] = arg2 + return args, nil +} + +func (ec *executionContext) field_Mutation_DeleteWidget_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["id"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_ExecuteConfirmedSQL_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "query", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["query"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "operationType", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["operationType"] = arg1 + return args, nil +} + +func (ec *executionContext) field_Mutation_GenerateChatTitle_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNGenerateChatTitleInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐGenerateChatTitleInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_GenerateMockData_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNMockDataGenerationInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐMockDataGenerationInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_GenerateRDSAuthToken_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "providerID", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["providerID"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "endpoint", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["endpoint"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "port", ec.unmarshalNInt2int) + if err != nil { + return nil, err + } + args["port"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "region", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["region"] = arg3 + arg4, err := graphql.ProcessArgField(ctx, rawArgs, "username", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["username"] = arg4 + return args, nil +} + +func (ec *executionContext) field_Mutation_ImportCollectionFile_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNImportCollectionFileInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportCollectionFileInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_ImportCollectionPreview_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNImportCollectionPreviewInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportCollectionPreviewInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_ImportPreview_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNImportPreviewInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportPreviewInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_ImportSQL_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNImportSQLInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportSQLInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_ImportTableFile_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNImportFileInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportFileInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_LoginWithProfile_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "profile", ec.unmarshalNLoginProfileInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLoginProfileInput) + if err != nil { + return nil, err + } + args["profile"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_Login_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "credentials", ec.unmarshalNLoginCredentials2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLoginCredentials) + if err != nil { + return nil, err + } + args["credentials"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_RefreshCloudProvider_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["id"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_RemoveCloudProvider_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["id"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_ReplaceRow_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "schema", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["schema"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "storageUnit", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["storageUnit"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "values", ec.unmarshalNRecordInput2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecordInputᚄ) + if err != nil { + return nil, err + } + args["values"] = arg2 + return args, nil +} + +func (ec *executionContext) field_Mutation_TestAWSCredentials_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNAWSProviderInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSProviderInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_TestCloudProvider_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["id"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_UpdateAWSProvider_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["id"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNAWSProviderInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSProviderInput) + if err != nil { + return nil, err + } + args["input"] = arg1 + return args, nil +} + +func (ec *executionContext) field_Mutation_UpdateDashboard_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["id"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "name", ec.unmarshalOString2ᚖstring) + if err != nil { + return nil, err + } + args["name"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "description", ec.unmarshalOString2ᚖstring) + if err != nil { + return nil, err + } + args["description"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "refreshRule", ec.unmarshalOString2ᚖstring) + if err != nil { + return nil, err + } + args["refreshRule"] = arg3 + return args, nil +} + +func (ec *executionContext) field_Mutation_UpdateSettings_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "newSettings", ec.unmarshalNSettingsConfigInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSettingsConfigInput) + if err != nil { + return nil, err + } + args["newSettings"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Mutation_UpdateStorageUnit_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "schema", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["schema"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "storageUnit", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["storageUnit"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "values", ec.unmarshalNRecordInput2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecordInputᚄ) + if err != nil { + return nil, err + } + args["values"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "updatedColumns", ec.unmarshalNString2ᚕstringᚄ) + if err != nil { + return nil, err + } + args["updatedColumns"] = arg3 + return args, nil +} + +func (ec *executionContext) field_Mutation_UpdateWidgetLayouts_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "dashboardId", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["dashboardId"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "layouts", ec.unmarshalNLayoutInput2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLayoutInputᚄ) + if err != nil { + return nil, err + } + args["layouts"] = arg1 + return args, nil +} + +func (ec *executionContext) field_Mutation_UpdateWidgetSnapshot_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["id"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "snapshot", ec.unmarshalNSnapshotInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSnapshotInput) + if err != nil { + return nil, err + } + args["snapshot"] = arg1 + return args, nil +} + +func (ec *executionContext) field_Mutation_UpdateWidget_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["id"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNUpdateWidgetInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐUpdateWidgetInput) + if err != nil { + return nil, err + } + args["input"] = arg1 + return args, nil +} + +func (ec *executionContext) field_Query_AIChat_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "providerId", ec.unmarshalOString2ᚖstring) + if err != nil { + return nil, err + } + args["providerId"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "modelType", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["modelType"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "token", ec.unmarshalOString2ᚖstring) + if err != nil { + return nil, err + } + args["token"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "schema", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["schema"] = arg3 + arg4, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNChatInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐChatInput) + if err != nil { + return nil, err + } + args["input"] = arg4 + return args, nil +} + +func (ec *executionContext) field_Query_AIModel_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "providerId", ec.unmarshalOString2ᚖstring) + if err != nil { + return nil, err + } + args["providerId"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "modelType", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["modelType"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "token", ec.unmarshalOString2ᚖstring) + if err != nil { + return nil, err + } + args["token"] = arg2 + return args, nil +} + +func (ec *executionContext) field_Query_AnalyzeMockDataDependencies_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "schema", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["schema"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "storageUnit", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["storageUnit"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "rowCount", ec.unmarshalNInt2int) + if err != nil { + return nil, err + } + args["rowCount"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "fkDensityRatio", ec.unmarshalOInt2ᚖint) + if err != nil { + return nil, err + } + args["fkDensityRatio"] = arg3 + return args, nil +} + +func (ec *executionContext) field_Query_CloudProvider_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["id"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Query_ColumnsBatch_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "schema", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["schema"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "storageUnits", ec.unmarshalNString2ᚕstringᚄ) + if err != nil { + return nil, err + } + args["storageUnits"] = arg1 + return args, nil +} + +func (ec *executionContext) field_Query_Columns_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "schema", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["schema"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "storageUnit", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["storageUnit"] = arg1 + return args, nil +} + +func (ec *executionContext) field_Query_DatabaseQuerySuggestions_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "schema", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["schema"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Query_Database_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "type", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["type"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Query_Graph_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "schema", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["schema"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Query_ProviderConnections_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "providerID", ec.unmarshalNID2string) + if err != nil { + return nil, err + } + args["providerID"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Query_RawExecute_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "query", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["query"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Query_ResolveSealosInstanceIdentity_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNSealosInstanceIdentityInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSealosInstanceIdentityInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Query_Row_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "schema", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["schema"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "storageUnit", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["storageUnit"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "where", ec.unmarshalOWhereCondition2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐWhereCondition) + if err != nil { + return nil, err + } + args["where"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "sort", ec.unmarshalOSortCondition2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSortConditionᚄ) + if err != nil { + return nil, err + } + args["sort"] = arg3 + arg4, err := graphql.ProcessArgField(ctx, rawArgs, "pageSize", ec.unmarshalNInt2int) + if err != nil { + return nil, err + } + args["pageSize"] = arg4 + arg5, err := graphql.ProcessArgField(ctx, rawArgs, "pageOffset", ec.unmarshalNInt2int) + if err != nil { + return nil, err + } + args["pageOffset"] = arg5 + return args, nil +} + +func (ec *executionContext) field_Query_StorageUnit_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "schema", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["schema"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Query___type_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "name", ec.unmarshalNString2string) + if err != nil { + return nil, err + } + args["name"] = arg0 + return args, nil +} + +func (ec *executionContext) field___Directive_args_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "includeDeprecated", ec.unmarshalOBoolean2ᚖbool) + if err != nil { + return nil, err + } + args["includeDeprecated"] = arg0 + return args, nil +} + +func (ec *executionContext) field___Field_args_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "includeDeprecated", ec.unmarshalOBoolean2ᚖbool) + if err != nil { + return nil, err + } + args["includeDeprecated"] = arg0 + return args, nil +} + +func (ec *executionContext) field___Type_enumValues_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "includeDeprecated", ec.unmarshalOBoolean2bool) + if err != nil { + return nil, err + } + args["includeDeprecated"] = arg0 + return args, nil +} + +func (ec *executionContext) field___Type_fields_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "includeDeprecated", ec.unmarshalOBoolean2bool) + if err != nil { + return nil, err + } + args["includeDeprecated"] = arg0 + return args, nil +} + +// endregion ***************************** args.gotpl ***************************** + +// region ************************** directives.gotpl ************************** + +// endregion ************************** directives.gotpl ************************** + +// region **************************** field.gotpl ***************************** + +func (ec *executionContext) _AIChatMessage_Type(ctx context.Context, field graphql.CollectedField, obj *model.AIChatMessage) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AIChatMessage_Type, + func(ctx context.Context) (any, error) { + return obj.Type, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AIChatMessage_Type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AIChatMessage", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AIChatMessage_Result(ctx context.Context, field graphql.CollectedField, obj *model.AIChatMessage) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AIChatMessage_Result, + func(ctx context.Context) (any, error) { + return obj.Result, nil + }, + nil, + ec.marshalORowsResult2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRowsResult, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_AIChatMessage_Result(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AIChatMessage", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Columns": + return ec.fieldContext_RowsResult_Columns(ctx, field) + case "Rows": + return ec.fieldContext_RowsResult_Rows(ctx, field) + case "DisableUpdate": + return ec.fieldContext_RowsResult_DisableUpdate(ctx, field) + case "TotalCount": + return ec.fieldContext_RowsResult_TotalCount(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type RowsResult", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _AIChatMessage_Text(ctx context.Context, field graphql.CollectedField, obj *model.AIChatMessage) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AIChatMessage_Text, + func(ctx context.Context) (any, error) { + return obj.Text, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AIChatMessage_Text(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AIChatMessage", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AIChatMessage_RequiresConfirmation(ctx context.Context, field graphql.CollectedField, obj *model.AIChatMessage) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AIChatMessage_RequiresConfirmation, + func(ctx context.Context) (any, error) { + return obj.RequiresConfirmation, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AIChatMessage_RequiresConfirmation(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AIChatMessage", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AIProvider_Type(ctx context.Context, field graphql.CollectedField, obj *model.AIProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AIProvider_Type, + func(ctx context.Context) (any, error) { + return obj.Type, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AIProvider_Type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AIProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AIProvider_Name(ctx context.Context, field graphql.CollectedField, obj *model.AIProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AIProvider_Name, + func(ctx context.Context) (any, error) { + return obj.Name, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AIProvider_Name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AIProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AIProvider_ProviderId(ctx context.Context, field graphql.CollectedField, obj *model.AIProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AIProvider_ProviderId, + func(ctx context.Context) (any, error) { + return obj.ProviderID, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AIProvider_ProviderId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AIProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AIProvider_IsEnvironmentDefined(ctx context.Context, field graphql.CollectedField, obj *model.AIProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AIProvider_IsEnvironmentDefined, + func(ctx context.Context) (any, error) { + return obj.IsEnvironmentDefined, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AIProvider_IsEnvironmentDefined(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AIProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AIProvider_IsGeneric(ctx context.Context, field graphql.CollectedField, obj *model.AIProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AIProvider_IsGeneric, + func(ctx context.Context) (any, error) { + return obj.IsGeneric, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AIProvider_IsGeneric(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AIProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AWSProvider_Id(ctx context.Context, field graphql.CollectedField, obj *model.AWSProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AWSProvider_Id, + func(ctx context.Context) (any, error) { + return obj.ID, nil + }, + nil, + ec.marshalNID2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AWSProvider_Id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AWSProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AWSProvider_ProviderType(ctx context.Context, field graphql.CollectedField, obj *model.AWSProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AWSProvider_ProviderType, + func(ctx context.Context) (any, error) { + return obj.ProviderType, nil + }, + nil, + ec.marshalNCloudProviderType2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCloudProviderType, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AWSProvider_ProviderType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AWSProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type CloudProviderType does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AWSProvider_Name(ctx context.Context, field graphql.CollectedField, obj *model.AWSProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AWSProvider_Name, + func(ctx context.Context) (any, error) { + return obj.Name, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AWSProvider_Name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AWSProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AWSProvider_Region(ctx context.Context, field graphql.CollectedField, obj *model.AWSProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AWSProvider_Region, + func(ctx context.Context) (any, error) { + return obj.Region, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AWSProvider_Region(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AWSProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AWSProvider_Status(ctx context.Context, field graphql.CollectedField, obj *model.AWSProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AWSProvider_Status, + func(ctx context.Context) (any, error) { + return obj.Status, nil + }, + nil, + ec.marshalNCloudProviderStatus2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCloudProviderStatus, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AWSProvider_Status(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AWSProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type CloudProviderStatus does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AWSProvider_LastDiscoveryAt(ctx context.Context, field graphql.CollectedField, obj *model.AWSProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AWSProvider_LastDiscoveryAt, + func(ctx context.Context) (any, error) { + return obj.LastDiscoveryAt, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_AWSProvider_LastDiscoveryAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AWSProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AWSProvider_DiscoveredCount(ctx context.Context, field graphql.CollectedField, obj *model.AWSProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AWSProvider_DiscoveredCount, + func(ctx context.Context) (any, error) { + return obj.DiscoveredCount, nil + }, + nil, + ec.marshalNInt2int, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AWSProvider_DiscoveredCount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AWSProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AWSProvider_Error(ctx context.Context, field graphql.CollectedField, obj *model.AWSProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AWSProvider_Error, + func(ctx context.Context) (any, error) { + return obj.Error, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_AWSProvider_Error(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AWSProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AWSProvider_ProfileName(ctx context.Context, field graphql.CollectedField, obj *model.AWSProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AWSProvider_ProfileName, + func(ctx context.Context) (any, error) { + return obj.ProfileName, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_AWSProvider_ProfileName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AWSProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AWSProvider_DiscoverRDS(ctx context.Context, field graphql.CollectedField, obj *model.AWSProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AWSProvider_DiscoverRDS, + func(ctx context.Context) (any, error) { + return obj.DiscoverRds, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AWSProvider_DiscoverRDS(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AWSProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AWSProvider_DiscoverElastiCache(ctx context.Context, field graphql.CollectedField, obj *model.AWSProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AWSProvider_DiscoverElastiCache, + func(ctx context.Context) (any, error) { + return obj.DiscoverElastiCache, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AWSProvider_DiscoverElastiCache(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AWSProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AWSProvider_DiscoverDocumentDB(ctx context.Context, field graphql.CollectedField, obj *model.AWSProvider) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AWSProvider_DiscoverDocumentDB, + func(ctx context.Context) (any, error) { + return obj.DiscoverDocumentDb, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AWSProvider_DiscoverDocumentDB(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AWSProvider", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AWSRegion_Id(ctx context.Context, field graphql.CollectedField, obj *model.AWSRegion) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AWSRegion_Id, + func(ctx context.Context) (any, error) { + return obj.ID, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AWSRegion_Id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AWSRegion", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AWSRegion_Description(ctx context.Context, field graphql.CollectedField, obj *model.AWSRegion) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AWSRegion_Description, + func(ctx context.Context) (any, error) { + return obj.Description, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AWSRegion_Description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AWSRegion", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AWSRegion_Partition(ctx context.Context, field graphql.CollectedField, obj *model.AWSRegion) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AWSRegion_Partition, + func(ctx context.Context) (any, error) { + return obj.Partition, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AWSRegion_Partition(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AWSRegion", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AuthSessionPayload_sessionToken(ctx context.Context, field graphql.CollectedField, obj *model.AuthSessionPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AuthSessionPayload_sessionToken, + func(ctx context.Context) (any, error) { + return obj.SessionToken, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AuthSessionPayload_sessionToken(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AuthSessionPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AuthSessionPayload_expiresAt(ctx context.Context, field graphql.CollectedField, obj *model.AuthSessionPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AuthSessionPayload_expiresAt, + func(ctx context.Context) (any, error) { + return obj.ExpiresAt, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AuthSessionPayload_expiresAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AuthSessionPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AuthSessionPayload_type(ctx context.Context, field graphql.CollectedField, obj *model.AuthSessionPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AuthSessionPayload_type, + func(ctx context.Context) (any, error) { + return obj.Type, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AuthSessionPayload_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AuthSessionPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AuthSessionPayload_hostname(ctx context.Context, field graphql.CollectedField, obj *model.AuthSessionPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AuthSessionPayload_hostname, + func(ctx context.Context) (any, error) { + return obj.Hostname, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AuthSessionPayload_hostname(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AuthSessionPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AuthSessionPayload_port(ctx context.Context, field graphql.CollectedField, obj *model.AuthSessionPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AuthSessionPayload_port, + func(ctx context.Context) (any, error) { + return obj.Port, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AuthSessionPayload_port(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AuthSessionPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AuthSessionPayload_database(ctx context.Context, field graphql.CollectedField, obj *model.AuthSessionPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AuthSessionPayload_database, + func(ctx context.Context) (any, error) { + return obj.Database, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AuthSessionPayload_database(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AuthSessionPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AuthSessionPayload_displayName(ctx context.Context, field graphql.CollectedField, obj *model.AuthSessionPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AuthSessionPayload_displayName, + func(ctx context.Context) (any, error) { + return obj.DisplayName, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_AuthSessionPayload_displayName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AuthSessionPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AuthSessionPayload_instanceUid(ctx context.Context, field graphql.CollectedField, obj *model.AuthSessionPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_AuthSessionPayload_instanceUid, + func(ctx context.Context) (any, error) { + return obj.InstanceUID, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_AuthSessionPayload_instanceUid(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AuthSessionPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Capabilities_supportsScratchpad(ctx context.Context, field graphql.CollectedField, obj *model.Capabilities) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Capabilities_supportsScratchpad, + func(ctx context.Context) (any, error) { + return obj.SupportsScratchpad, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Capabilities_supportsScratchpad(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Capabilities", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Capabilities_supportsChat(ctx context.Context, field graphql.CollectedField, obj *model.Capabilities) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Capabilities_supportsChat, + func(ctx context.Context) (any, error) { + return obj.SupportsChat, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Capabilities_supportsChat(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Capabilities", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Capabilities_supportsGraph(ctx context.Context, field graphql.CollectedField, obj *model.Capabilities) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Capabilities_supportsGraph, + func(ctx context.Context) (any, error) { + return obj.SupportsGraph, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Capabilities_supportsGraph(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Capabilities", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Capabilities_supportsSchema(ctx context.Context, field graphql.CollectedField, obj *model.Capabilities) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Capabilities_supportsSchema, + func(ctx context.Context) (any, error) { + return obj.SupportsSchema, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Capabilities_supportsSchema(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Capabilities", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Capabilities_supportsDatabaseSwitch(ctx context.Context, field graphql.CollectedField, obj *model.Capabilities) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Capabilities_supportsDatabaseSwitch, + func(ctx context.Context) (any, error) { + return obj.SupportsDatabaseSwitch, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Capabilities_supportsDatabaseSwitch(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Capabilities", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Capabilities_supportsModifiers(ctx context.Context, field graphql.CollectedField, obj *model.Capabilities) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Capabilities_supportsModifiers, + func(ctx context.Context) (any, error) { + return obj.SupportsModifiers, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Capabilities_supportsModifiers(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Capabilities", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportError_Index(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportError) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportError_Index, + func(ctx context.Context) (any, error) { + return obj.Index, nil + }, + nil, + ec.marshalNInt2int, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportError_Index(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportError", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportError_Reason(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportError) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportError_Reason, + func(ctx context.Context) (any, error) { + return obj.Reason, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportError_Reason(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportError", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportPreview_Format(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportPreview_Format, + func(ctx context.Context) (any, error) { + return obj.Format, nil + }, + nil, + ec.marshalNCollectionImportFormat2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCollectionImportFormat, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportPreview_Format(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type CollectionImportFormat does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportPreview_Sheet(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportPreview_Sheet, + func(ctx context.Context) (any, error) { + return obj.Sheet, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportPreview_Sheet(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportPreview_Sheets(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportPreview_Sheets, + func(ctx context.Context) (any, error) { + return obj.Sheets, nil + }, + nil, + ec.marshalNString2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportPreview_Sheets(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportPreview_Columns(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportPreview_Columns, + func(ctx context.Context) (any, error) { + return obj.Columns, nil + }, + nil, + ec.marshalNString2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportPreview_Columns(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportPreview_Rows(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportPreview_Rows, + func(ctx context.Context) (any, error) { + return obj.Rows, nil + }, + nil, + ec.marshalNString2ᚕᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportPreview_Rows(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportPreview_Documents(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportPreview_Documents, + func(ctx context.Context) (any, error) { + return obj.Documents, nil + }, + nil, + ec.marshalNString2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportPreview_Documents(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportPreview_Count(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportPreview_Count, + func(ctx context.Context) (any, error) { + return obj.Count, nil + }, + nil, + ec.marshalOInt2ᚖint, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportPreview_Count(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportPreview_Truncated(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportPreview_Truncated, + func(ctx context.Context) (any, error) { + return obj.Truncated, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportPreview_Truncated(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportPreview_ValidationError(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportPreview_ValidationError, + func(ctx context.Context) (any, error) { + return obj.ValidationError, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportPreview_ValidationError(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportResult_Status(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportResult_Status, + func(ctx context.Context) (any, error) { + return obj.Status, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportResult_Status(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportResult_ImportedCount(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportResult_ImportedCount, + func(ctx context.Context) (any, error) { + return obj.ImportedCount, nil + }, + nil, + ec.marshalNInt2int, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportResult_ImportedCount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportResult_SkippedCount(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportResult_SkippedCount, + func(ctx context.Context) (any, error) { + return obj.SkippedCount, nil + }, + nil, + ec.marshalNInt2int, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportResult_SkippedCount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportResult_MatchedCount(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportResult_MatchedCount, + func(ctx context.Context) (any, error) { + return obj.MatchedCount, nil + }, + nil, + ec.marshalOInt2ᚖint, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportResult_MatchedCount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportResult_ModifiedCount(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportResult_ModifiedCount, + func(ctx context.Context) (any, error) { + return obj.ModifiedCount, nil + }, + nil, + ec.marshalOInt2ᚖint, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportResult_ModifiedCount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportResult_UpsertedCount(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportResult_UpsertedCount, + func(ctx context.Context) (any, error) { + return obj.UpsertedCount, nil + }, + nil, + ec.marshalOInt2ᚖint, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportResult_UpsertedCount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportResult_Errors(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportResult_Errors, + func(ctx context.Context) (any, error) { + return obj.Errors, nil + }, + nil, + ec.marshalNCollectionImportError2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCollectionImportErrorᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportResult_Errors(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Index": + return ec.fieldContext_CollectionImportError_Index(ctx, field) + case "Reason": + return ec.fieldContext_CollectionImportError_Reason(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type CollectionImportError", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportResult_Message(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportResult_Message, + func(ctx context.Context) (any, error) { + return obj.Message, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportResult_Message(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CollectionImportResult_Detail(ctx context.Context, field graphql.CollectedField, obj *model.CollectionImportResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_CollectionImportResult_Detail, + func(ctx context.Context) (any, error) { + return obj.Detail, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_CollectionImportResult_Detail(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CollectionImportResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Column_Type(ctx context.Context, field graphql.CollectedField, obj *model.Column) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Column_Type, + func(ctx context.Context) (any, error) { + return obj.Type, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Column_Type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Column", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Column_Name(ctx context.Context, field graphql.CollectedField, obj *model.Column) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Column_Name, + func(ctx context.Context) (any, error) { + return obj.Name, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Column_Name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Column", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Column_IsPrimary(ctx context.Context, field graphql.CollectedField, obj *model.Column) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Column_IsPrimary, + func(ctx context.Context) (any, error) { + return obj.IsPrimary, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Column_IsPrimary(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Column", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Column_IsForeignKey(ctx context.Context, field graphql.CollectedField, obj *model.Column) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Column_IsForeignKey, + func(ctx context.Context) (any, error) { + return obj.IsForeignKey, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Column_IsForeignKey(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Column", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Column_ReferencedTable(ctx context.Context, field graphql.CollectedField, obj *model.Column) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Column_ReferencedTable, + func(ctx context.Context) (any, error) { + return obj.ReferencedTable, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Column_ReferencedTable(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Column", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Column_ReferencedColumn(ctx context.Context, field graphql.CollectedField, obj *model.Column) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Column_ReferencedColumn, + func(ctx context.Context) (any, error) { + return obj.ReferencedColumn, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Column_ReferencedColumn(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Column", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Column_Length(ctx context.Context, field graphql.CollectedField, obj *model.Column) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Column_Length, + func(ctx context.Context) (any, error) { + return obj.Length, nil + }, + nil, + ec.marshalOInt2ᚖint, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Column_Length(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Column", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Column_Precision(ctx context.Context, field graphql.CollectedField, obj *model.Column) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Column_Precision, + func(ctx context.Context) (any, error) { + return obj.Precision, nil + }, + nil, + ec.marshalOInt2ᚖint, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Column_Precision(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Column", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Column_Scale(ctx context.Context, field graphql.CollectedField, obj *model.Column) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Column_Scale, + func(ctx context.Context) (any, error) { + return obj.Scale, nil + }, + nil, + ec.marshalOInt2ᚖint, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Column_Scale(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Column", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Dashboard_ID(ctx context.Context, field graphql.CollectedField, obj *model.Dashboard) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Dashboard_ID, + func(ctx context.Context) (any, error) { + return obj.ID, nil + }, + nil, + ec.marshalNID2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Dashboard_ID(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Dashboard", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Dashboard_Name(ctx context.Context, field graphql.CollectedField, obj *model.Dashboard) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Dashboard_Name, + func(ctx context.Context) (any, error) { + return obj.Name, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Dashboard_Name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Dashboard", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Dashboard_Description(ctx context.Context, field graphql.CollectedField, obj *model.Dashboard) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Dashboard_Description, + func(ctx context.Context) (any, error) { + return obj.Description, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Dashboard_Description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Dashboard", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Dashboard_RefreshRule(ctx context.Context, field graphql.CollectedField, obj *model.Dashboard) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Dashboard_RefreshRule, + func(ctx context.Context) (any, error) { + return obj.RefreshRule, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Dashboard_RefreshRule(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Dashboard", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Dashboard_Widgets(ctx context.Context, field graphql.CollectedField, obj *model.Dashboard) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Dashboard_Widgets, + func(ctx context.Context) (any, error) { + return obj.Widgets, nil + }, + nil, + ec.marshalNDashboardWidget2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDashboardWidgetᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Dashboard_Widgets(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Dashboard", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "ID": + return ec.fieldContext_DashboardWidget_ID(ctx, field) + case "Type": + return ec.fieldContext_DashboardWidget_Type(ctx, field) + case "Title": + return ec.fieldContext_DashboardWidget_Title(ctx, field) + case "Description": + return ec.fieldContext_DashboardWidget_Description(ctx, field) + case "Layout": + return ec.fieldContext_DashboardWidget_Layout(ctx, field) + case "Query": + return ec.fieldContext_DashboardWidget_Query(ctx, field) + case "QueryContext": + return ec.fieldContext_DashboardWidget_QueryContext(ctx, field) + case "Visualization": + return ec.fieldContext_DashboardWidget_Visualization(ctx, field) + case "Snapshot": + return ec.fieldContext_DashboardWidget_Snapshot(ctx, field) + case "SortOrder": + return ec.fieldContext_DashboardWidget_SortOrder(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type DashboardWidget", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Dashboard_CreatedAt(ctx context.Context, field graphql.CollectedField, obj *model.Dashboard) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Dashboard_CreatedAt, + func(ctx context.Context) (any, error) { + return obj.CreatedAt, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Dashboard_CreatedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Dashboard", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Dashboard_UpdatedAt(ctx context.Context, field graphql.CollectedField, obj *model.Dashboard) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Dashboard_UpdatedAt, + func(ctx context.Context) (any, error) { + return obj.UpdatedAt, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Dashboard_UpdatedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Dashboard", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DashboardWidget_ID(ctx context.Context, field graphql.CollectedField, obj *model.DashboardWidget) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DashboardWidget_ID, + func(ctx context.Context) (any, error) { + return obj.ID, nil + }, + nil, + ec.marshalNID2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DashboardWidget_ID(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DashboardWidget", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DashboardWidget_Type(ctx context.Context, field graphql.CollectedField, obj *model.DashboardWidget) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DashboardWidget_Type, + func(ctx context.Context) (any, error) { + return obj.Type, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DashboardWidget_Type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DashboardWidget", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DashboardWidget_Title(ctx context.Context, field graphql.CollectedField, obj *model.DashboardWidget) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DashboardWidget_Title, + func(ctx context.Context) (any, error) { + return obj.Title, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DashboardWidget_Title(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DashboardWidget", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DashboardWidget_Description(ctx context.Context, field graphql.CollectedField, obj *model.DashboardWidget) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DashboardWidget_Description, + func(ctx context.Context) (any, error) { + return obj.Description, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_DashboardWidget_Description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DashboardWidget", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DashboardWidget_Layout(ctx context.Context, field graphql.CollectedField, obj *model.DashboardWidget) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DashboardWidget_Layout, + func(ctx context.Context) (any, error) { + return obj.Layout, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DashboardWidget_Layout(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DashboardWidget", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DashboardWidget_Query(ctx context.Context, field graphql.CollectedField, obj *model.DashboardWidget) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DashboardWidget_Query, + func(ctx context.Context) (any, error) { + return obj.Query, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_DashboardWidget_Query(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DashboardWidget", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DashboardWidget_QueryContext(ctx context.Context, field graphql.CollectedField, obj *model.DashboardWidget) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DashboardWidget_QueryContext, + func(ctx context.Context) (any, error) { + return obj.QueryContext, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_DashboardWidget_QueryContext(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DashboardWidget", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DashboardWidget_Visualization(ctx context.Context, field graphql.CollectedField, obj *model.DashboardWidget) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DashboardWidget_Visualization, + func(ctx context.Context) (any, error) { + return obj.Visualization, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_DashboardWidget_Visualization(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DashboardWidget", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DashboardWidget_Snapshot(ctx context.Context, field graphql.CollectedField, obj *model.DashboardWidget) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DashboardWidget_Snapshot, + func(ctx context.Context) (any, error) { + return obj.Snapshot, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_DashboardWidget_Snapshot(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DashboardWidget", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DashboardWidget_SortOrder(ctx context.Context, field graphql.CollectedField, obj *model.DashboardWidget) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DashboardWidget_SortOrder, + func(ctx context.Context) (any, error) { + return obj.SortOrder, nil + }, + nil, + ec.marshalNInt2int, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DashboardWidget_SortOrder(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DashboardWidget", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DatabaseMetadata_databaseType(ctx context.Context, field graphql.CollectedField, obj *model.DatabaseMetadata) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DatabaseMetadata_databaseType, + func(ctx context.Context) (any, error) { + return obj.DatabaseType, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DatabaseMetadata_databaseType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DatabaseMetadata", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DatabaseMetadata_typeDefinitions(ctx context.Context, field graphql.CollectedField, obj *model.DatabaseMetadata) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DatabaseMetadata_typeDefinitions, + func(ctx context.Context) (any, error) { + return obj.TypeDefinitions, nil + }, + nil, + ec.marshalNTypeDefinition2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐTypeDefinitionᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DatabaseMetadata_typeDefinitions(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DatabaseMetadata", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_TypeDefinition_id(ctx, field) + case "label": + return ec.fieldContext_TypeDefinition_label(ctx, field) + case "hasLength": + return ec.fieldContext_TypeDefinition_hasLength(ctx, field) + case "hasPrecision": + return ec.fieldContext_TypeDefinition_hasPrecision(ctx, field) + case "defaultLength": + return ec.fieldContext_TypeDefinition_defaultLength(ctx, field) + case "defaultPrecision": + return ec.fieldContext_TypeDefinition_defaultPrecision(ctx, field) + case "category": + return ec.fieldContext_TypeDefinition_category(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type TypeDefinition", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _DatabaseMetadata_operators(ctx context.Context, field graphql.CollectedField, obj *model.DatabaseMetadata) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DatabaseMetadata_operators, + func(ctx context.Context) (any, error) { + return obj.Operators, nil + }, + nil, + ec.marshalNString2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DatabaseMetadata_operators(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DatabaseMetadata", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DatabaseMetadata_aliasMap(ctx context.Context, field graphql.CollectedField, obj *model.DatabaseMetadata) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DatabaseMetadata_aliasMap, + func(ctx context.Context) (any, error) { + return obj.AliasMap, nil + }, + nil, + ec.marshalNRecord2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecordᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DatabaseMetadata_aliasMap(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DatabaseMetadata", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Key": + return ec.fieldContext_Record_Key(ctx, field) + case "Value": + return ec.fieldContext_Record_Value(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Record", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _DatabaseMetadata_capabilities(ctx context.Context, field graphql.CollectedField, obj *model.DatabaseMetadata) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DatabaseMetadata_capabilities, + func(ctx context.Context) (any, error) { + return obj.Capabilities, nil + }, + nil, + ec.marshalNCapabilities2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCapabilities, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DatabaseMetadata_capabilities(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DatabaseMetadata", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "supportsScratchpad": + return ec.fieldContext_Capabilities_supportsScratchpad(ctx, field) + case "supportsChat": + return ec.fieldContext_Capabilities_supportsChat(ctx, field) + case "supportsGraph": + return ec.fieldContext_Capabilities_supportsGraph(ctx, field) + case "supportsSchema": + return ec.fieldContext_Capabilities_supportsSchema(ctx, field) + case "supportsDatabaseSwitch": + return ec.fieldContext_Capabilities_supportsDatabaseSwitch(ctx, field) + case "supportsModifiers": + return ec.fieldContext_Capabilities_supportsModifiers(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Capabilities", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _DatabaseMetadata_systemSchemas(ctx context.Context, field graphql.CollectedField, obj *model.DatabaseMetadata) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DatabaseMetadata_systemSchemas, + func(ctx context.Context) (any, error) { + return obj.SystemSchemas, nil + }, + nil, + ec.marshalNString2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DatabaseMetadata_systemSchemas(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DatabaseMetadata", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DatabaseQuerySuggestion_description(ctx context.Context, field graphql.CollectedField, obj *model.DatabaseQuerySuggestion) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DatabaseQuerySuggestion_description, + func(ctx context.Context) (any, error) { + return obj.Description, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DatabaseQuerySuggestion_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DatabaseQuerySuggestion", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DatabaseQuerySuggestion_category(ctx context.Context, field graphql.CollectedField, obj *model.DatabaseQuerySuggestion) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DatabaseQuerySuggestion_category, + func(ctx context.Context) (any, error) { + return obj.Category, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DatabaseQuerySuggestion_category(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DatabaseQuerySuggestion", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DiscoveredConnection_Id(ctx context.Context, field graphql.CollectedField, obj *model.DiscoveredConnection) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DiscoveredConnection_Id, + func(ctx context.Context) (any, error) { + return obj.ID, nil + }, + nil, + ec.marshalNID2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DiscoveredConnection_Id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DiscoveredConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DiscoveredConnection_ProviderType(ctx context.Context, field graphql.CollectedField, obj *model.DiscoveredConnection) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DiscoveredConnection_ProviderType, + func(ctx context.Context) (any, error) { + return obj.ProviderType, nil + }, + nil, + ec.marshalNCloudProviderType2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCloudProviderType, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DiscoveredConnection_ProviderType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DiscoveredConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type CloudProviderType does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DiscoveredConnection_ProviderID(ctx context.Context, field graphql.CollectedField, obj *model.DiscoveredConnection) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DiscoveredConnection_ProviderID, + func(ctx context.Context) (any, error) { + return obj.ProviderID, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DiscoveredConnection_ProviderID(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DiscoveredConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DiscoveredConnection_Name(ctx context.Context, field graphql.CollectedField, obj *model.DiscoveredConnection) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DiscoveredConnection_Name, + func(ctx context.Context) (any, error) { + return obj.Name, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DiscoveredConnection_Name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DiscoveredConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DiscoveredConnection_DatabaseType(ctx context.Context, field graphql.CollectedField, obj *model.DiscoveredConnection) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DiscoveredConnection_DatabaseType, + func(ctx context.Context) (any, error) { + return obj.DatabaseType, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DiscoveredConnection_DatabaseType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DiscoveredConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DiscoveredConnection_Region(ctx context.Context, field graphql.CollectedField, obj *model.DiscoveredConnection) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DiscoveredConnection_Region, + func(ctx context.Context) (any, error) { + return obj.Region, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_DiscoveredConnection_Region(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DiscoveredConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DiscoveredConnection_Status(ctx context.Context, field graphql.CollectedField, obj *model.DiscoveredConnection) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DiscoveredConnection_Status, + func(ctx context.Context) (any, error) { + return obj.Status, nil + }, + nil, + ec.marshalNConnectionStatus2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐConnectionStatus, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DiscoveredConnection_Status(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DiscoveredConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ConnectionStatus does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DiscoveredConnection_Metadata(ctx context.Context, field graphql.CollectedField, obj *model.DiscoveredConnection) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_DiscoveredConnection_Metadata, + func(ctx context.Context) (any, error) { + return obj.Metadata, nil + }, + nil, + ec.marshalNRecord2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecordᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_DiscoveredConnection_Metadata(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DiscoveredConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Key": + return ec.fieldContext_Record_Key(ctx, field) + case "Value": + return ec.fieldContext_Record_Value(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Record", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _ExportDownload_ID(ctx context.Context, field graphql.CollectedField, obj *model.ExportDownload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ExportDownload_ID, + func(ctx context.Context) (any, error) { + return obj.ID, nil + }, + nil, + ec.marshalNID2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ExportDownload_ID(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ExportDownload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ExportDownload_Filename(ctx context.Context, field graphql.CollectedField, obj *model.ExportDownload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ExportDownload_Filename, + func(ctx context.Context) (any, error) { + return obj.Filename, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ExportDownload_Filename(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ExportDownload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ExportDownload_ContentType(ctx context.Context, field graphql.CollectedField, obj *model.ExportDownload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ExportDownload_ContentType, + func(ctx context.Context) (any, error) { + return obj.ContentType, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ExportDownload_ContentType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ExportDownload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ExportDownload_DownloadURL(ctx context.Context, field graphql.CollectedField, obj *model.ExportDownload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ExportDownload_DownloadURL, + func(ctx context.Context) (any, error) { + return obj.DownloadURL, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ExportDownload_DownloadURL(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ExportDownload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ExportDownload_Size(ctx context.Context, field graphql.CollectedField, obj *model.ExportDownload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ExportDownload_Size, + func(ctx context.Context) (any, error) { + return obj.Size, nil + }, + nil, + ec.marshalNInt2int, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ExportDownload_Size(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ExportDownload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _GenerateChatTitleResponse_Title(ctx context.Context, field graphql.CollectedField, obj *model.GenerateChatTitleResponse) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_GenerateChatTitleResponse_Title, + func(ctx context.Context) (any, error) { + return obj.Title, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_GenerateChatTitleResponse_Title(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "GenerateChatTitleResponse", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _GraphUnit_Unit(ctx context.Context, field graphql.CollectedField, obj *model.GraphUnit) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_GraphUnit_Unit, + func(ctx context.Context) (any, error) { + return obj.Unit, nil + }, + nil, + ec.marshalNStorageUnit2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStorageUnit, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_GraphUnit_Unit(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "GraphUnit", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Name": + return ec.fieldContext_StorageUnit_Name(ctx, field) + case "Attributes": + return ec.fieldContext_StorageUnit_Attributes(ctx, field) + case "IsMockDataGenerationAllowed": + return ec.fieldContext_StorageUnit_IsMockDataGenerationAllowed(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StorageUnit", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _GraphUnit_Relations(ctx context.Context, field graphql.CollectedField, obj *model.GraphUnit) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_GraphUnit_Relations, + func(ctx context.Context) (any, error) { + return obj.Relations, nil + }, + nil, + ec.marshalNGraphUnitRelationship2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐGraphUnitRelationshipᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_GraphUnit_Relations(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "GraphUnit", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Name": + return ec.fieldContext_GraphUnitRelationship_Name(ctx, field) + case "Relationship": + return ec.fieldContext_GraphUnitRelationship_Relationship(ctx, field) + case "SourceColumn": + return ec.fieldContext_GraphUnitRelationship_SourceColumn(ctx, field) + case "TargetColumn": + return ec.fieldContext_GraphUnitRelationship_TargetColumn(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type GraphUnitRelationship", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _GraphUnitRelationship_Name(ctx context.Context, field graphql.CollectedField, obj *model.GraphUnitRelationship) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_GraphUnitRelationship_Name, + func(ctx context.Context) (any, error) { + return obj.Name, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_GraphUnitRelationship_Name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "GraphUnitRelationship", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _GraphUnitRelationship_Relationship(ctx context.Context, field graphql.CollectedField, obj *model.GraphUnitRelationship) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_GraphUnitRelationship_Relationship, + func(ctx context.Context) (any, error) { + return obj.Relationship, nil + }, + nil, + ec.marshalNGraphUnitRelationshipType2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐGraphUnitRelationshipType, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_GraphUnitRelationship_Relationship(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "GraphUnitRelationship", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type GraphUnitRelationshipType does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _GraphUnitRelationship_SourceColumn(ctx context.Context, field graphql.CollectedField, obj *model.GraphUnitRelationship) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_GraphUnitRelationship_SourceColumn, + func(ctx context.Context) (any, error) { + return obj.SourceColumn, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_GraphUnitRelationship_SourceColumn(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "GraphUnitRelationship", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _GraphUnitRelationship_TargetColumn(ctx context.Context, field graphql.CollectedField, obj *model.GraphUnitRelationship) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_GraphUnitRelationship_TargetColumn, + func(ctx context.Context) (any, error) { + return obj.TargetColumn, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_GraphUnitRelationship_TargetColumn(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "GraphUnitRelationship", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _HealthStatus_Server(ctx context.Context, field graphql.CollectedField, obj *model.HealthStatus) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_HealthStatus_Server, + func(ctx context.Context) (any, error) { + return obj.Server, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_HealthStatus_Server(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "HealthStatus", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _HealthStatus_Database(ctx context.Context, field graphql.CollectedField, obj *model.HealthStatus) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_HealthStatus_Database, + func(ctx context.Context) (any, error) { + return obj.Database, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_HealthStatus_Database(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "HealthStatus", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportColumnMappingPreview_SourceColumn(ctx context.Context, field graphql.CollectedField, obj *model.ImportColumnMappingPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportColumnMappingPreview_SourceColumn, + func(ctx context.Context) (any, error) { + return obj.SourceColumn, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportColumnMappingPreview_SourceColumn(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportColumnMappingPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportColumnMappingPreview_TargetColumn(ctx context.Context, field graphql.CollectedField, obj *model.ImportColumnMappingPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportColumnMappingPreview_TargetColumn, + func(ctx context.Context) (any, error) { + return obj.TargetColumn, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportColumnMappingPreview_TargetColumn(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportColumnMappingPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportNewTableColumnPreview_SourceColumn(ctx context.Context, field graphql.CollectedField, obj *model.ImportNewTableColumnPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportNewTableColumnPreview_SourceColumn, + func(ctx context.Context) (any, error) { + return obj.SourceColumn, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportNewTableColumnPreview_SourceColumn(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportNewTableColumnPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportNewTableColumnPreview_TargetColumn(ctx context.Context, field graphql.CollectedField, obj *model.ImportNewTableColumnPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportNewTableColumnPreview_TargetColumn, + func(ctx context.Context) (any, error) { + return obj.TargetColumn, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportNewTableColumnPreview_TargetColumn(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportNewTableColumnPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportNewTableColumnPreview_Type(ctx context.Context, field graphql.CollectedField, obj *model.ImportNewTableColumnPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportNewTableColumnPreview_Type, + func(ctx context.Context) (any, error) { + return obj.Type, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportNewTableColumnPreview_Type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportNewTableColumnPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportNewTableColumnPreview_Nullable(ctx context.Context, field graphql.CollectedField, obj *model.ImportNewTableColumnPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportNewTableColumnPreview_Nullable, + func(ctx context.Context) (any, error) { + return obj.Nullable, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportNewTableColumnPreview_Nullable(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportNewTableColumnPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportNewTableColumnPreview_Primary(ctx context.Context, field graphql.CollectedField, obj *model.ImportNewTableColumnPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportNewTableColumnPreview_Primary, + func(ctx context.Context) (any, error) { + return obj.Primary, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportNewTableColumnPreview_Primary(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportNewTableColumnPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportNewTableColumnPreview_Skip(ctx context.Context, field graphql.CollectedField, obj *model.ImportNewTableColumnPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportNewTableColumnPreview_Skip, + func(ctx context.Context) (any, error) { + return obj.Skip, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportNewTableColumnPreview_Skip(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportNewTableColumnPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportNewTablePreview_TableName(ctx context.Context, field graphql.CollectedField, obj *model.ImportNewTablePreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportNewTablePreview_TableName, + func(ctx context.Context) (any, error) { + return obj.TableName, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportNewTablePreview_TableName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportNewTablePreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportNewTablePreview_Columns(ctx context.Context, field graphql.CollectedField, obj *model.ImportNewTablePreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportNewTablePreview_Columns, + func(ctx context.Context) (any, error) { + return obj.Columns, nil + }, + nil, + ec.marshalNImportNewTableColumnPreview2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportNewTableColumnPreviewᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportNewTablePreview_Columns(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportNewTablePreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "SourceColumn": + return ec.fieldContext_ImportNewTableColumnPreview_SourceColumn(ctx, field) + case "TargetColumn": + return ec.fieldContext_ImportNewTableColumnPreview_TargetColumn(ctx, field) + case "Type": + return ec.fieldContext_ImportNewTableColumnPreview_Type(ctx, field) + case "Nullable": + return ec.fieldContext_ImportNewTableColumnPreview_Nullable(ctx, field) + case "Primary": + return ec.fieldContext_ImportNewTableColumnPreview_Primary(ctx, field) + case "Skip": + return ec.fieldContext_ImportNewTableColumnPreview_Skip(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ImportNewTableColumnPreview", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportNewTablePreview_Issues(ctx context.Context, field graphql.CollectedField, obj *model.ImportNewTablePreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportNewTablePreview_Issues, + func(ctx context.Context) (any, error) { + return obj.Issues, nil + }, + nil, + ec.marshalNImportSuggestionIssue2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportSuggestionIssueᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportNewTablePreview_Issues(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportNewTablePreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Key": + return ec.fieldContext_ImportSuggestionIssue_Key(ctx, field) + case "Field": + return ec.fieldContext_ImportSuggestionIssue_Field(ctx, field) + case "SourceColumn": + return ec.fieldContext_ImportSuggestionIssue_SourceColumn(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ImportSuggestionIssue", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportPreview_Sheet(ctx context.Context, field graphql.CollectedField, obj *model.ImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportPreview_Sheet, + func(ctx context.Context) (any, error) { + return obj.Sheet, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_ImportPreview_Sheet(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportPreview_Sheets(ctx context.Context, field graphql.CollectedField, obj *model.ImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportPreview_Sheets, + func(ctx context.Context) (any, error) { + return obj.Sheets, nil + }, + nil, + ec.marshalNString2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportPreview_Sheets(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportPreview_Columns(ctx context.Context, field graphql.CollectedField, obj *model.ImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportPreview_Columns, + func(ctx context.Context) (any, error) { + return obj.Columns, nil + }, + nil, + ec.marshalNString2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportPreview_Columns(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportPreview_Rows(ctx context.Context, field graphql.CollectedField, obj *model.ImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportPreview_Rows, + func(ctx context.Context) (any, error) { + return obj.Rows, nil + }, + nil, + ec.marshalNString2ᚕᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportPreview_Rows(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportPreview_Truncated(ctx context.Context, field graphql.CollectedField, obj *model.ImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportPreview_Truncated, + func(ctx context.Context) (any, error) { + return obj.Truncated, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportPreview_Truncated(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportPreview_ValidationError(ctx context.Context, field graphql.CollectedField, obj *model.ImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportPreview_ValidationError, + func(ctx context.Context) (any, error) { + return obj.ValidationError, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_ImportPreview_ValidationError(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportPreview_Mapping(ctx context.Context, field graphql.CollectedField, obj *model.ImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportPreview_Mapping, + func(ctx context.Context) (any, error) { + return obj.Mapping, nil + }, + nil, + ec.marshalOImportColumnMappingPreview2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportColumnMappingPreviewᚄ, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_ImportPreview_Mapping(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "SourceColumn": + return ec.fieldContext_ImportColumnMappingPreview_SourceColumn(ctx, field) + case "TargetColumn": + return ec.fieldContext_ImportColumnMappingPreview_TargetColumn(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ImportColumnMappingPreview", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportPreview_RequiresAllowAutoGenerated(ctx context.Context, field graphql.CollectedField, obj *model.ImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportPreview_RequiresAllowAutoGenerated, + func(ctx context.Context) (any, error) { + return obj.RequiresAllowAutoGenerated, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportPreview_RequiresAllowAutoGenerated(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportPreview_AutoGeneratedColumns(ctx context.Context, field graphql.CollectedField, obj *model.ImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportPreview_AutoGeneratedColumns, + func(ctx context.Context) (any, error) { + return obj.AutoGeneratedColumns, nil + }, + nil, + ec.marshalNString2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportPreview_AutoGeneratedColumns(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportPreview_NewTable(ctx context.Context, field graphql.CollectedField, obj *model.ImportPreview) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportPreview_NewTable, + func(ctx context.Context) (any, error) { + return obj.NewTable, nil + }, + nil, + ec.marshalOImportNewTablePreview2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportNewTablePreview, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_ImportPreview_NewTable(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportPreview", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "TableName": + return ec.fieldContext_ImportNewTablePreview_TableName(ctx, field) + case "Columns": + return ec.fieldContext_ImportNewTablePreview_Columns(ctx, field) + case "Issues": + return ec.fieldContext_ImportNewTablePreview_Issues(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ImportNewTablePreview", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportResult_Status(ctx context.Context, field graphql.CollectedField, obj *model.ImportResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportResult_Status, + func(ctx context.Context) (any, error) { + return obj.Status, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportResult_Status(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportResult_Message(ctx context.Context, field graphql.CollectedField, obj *model.ImportResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportResult_Message, + func(ctx context.Context) (any, error) { + return obj.Message, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportResult_Message(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportResult_Detail(ctx context.Context, field graphql.CollectedField, obj *model.ImportResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportResult_Detail, + func(ctx context.Context) (any, error) { + return obj.Detail, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_ImportResult_Detail(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportSuggestionIssue_Key(ctx context.Context, field graphql.CollectedField, obj *model.ImportSuggestionIssue) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportSuggestionIssue_Key, + func(ctx context.Context) (any, error) { + return obj.Key, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportSuggestionIssue_Key(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportSuggestionIssue", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportSuggestionIssue_Field(ctx context.Context, field graphql.CollectedField, obj *model.ImportSuggestionIssue) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportSuggestionIssue_Field, + func(ctx context.Context) (any, error) { + return obj.Field, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_ImportSuggestionIssue_Field(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportSuggestionIssue", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _ImportSuggestionIssue_SourceColumn(ctx context.Context, field graphql.CollectedField, obj *model.ImportSuggestionIssue) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ImportSuggestionIssue_SourceColumn, + func(ctx context.Context) (any, error) { + return obj.SourceColumn, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_ImportSuggestionIssue_SourceColumn(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ImportSuggestionIssue", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _LocalAWSProfile_Name(ctx context.Context, field graphql.CollectedField, obj *model.LocalAWSProfile) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_LocalAWSProfile_Name, + func(ctx context.Context) (any, error) { + return obj.Name, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_LocalAWSProfile_Name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "LocalAWSProfile", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _LocalAWSProfile_Region(ctx context.Context, field graphql.CollectedField, obj *model.LocalAWSProfile) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_LocalAWSProfile_Region, + func(ctx context.Context) (any, error) { + return obj.Region, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_LocalAWSProfile_Region(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "LocalAWSProfile", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _LocalAWSProfile_Source(ctx context.Context, field graphql.CollectedField, obj *model.LocalAWSProfile) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_LocalAWSProfile_Source, + func(ctx context.Context) (any, error) { + return obj.Source, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_LocalAWSProfile_Source(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "LocalAWSProfile", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _LocalAWSProfile_AuthType(ctx context.Context, field graphql.CollectedField, obj *model.LocalAWSProfile) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_LocalAWSProfile_AuthType, + func(ctx context.Context) (any, error) { + return obj.AuthType, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_LocalAWSProfile_AuthType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "LocalAWSProfile", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _LocalAWSProfile_IsDefault(ctx context.Context, field graphql.CollectedField, obj *model.LocalAWSProfile) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_LocalAWSProfile_IsDefault, + func(ctx context.Context) (any, error) { + return obj.IsDefault, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_LocalAWSProfile_IsDefault(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "LocalAWSProfile", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _LoginProfile_Alias(ctx context.Context, field graphql.CollectedField, obj *model.LoginProfile) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_LoginProfile_Alias, + func(ctx context.Context) (any, error) { + return obj.Alias, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_LoginProfile_Alias(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "LoginProfile", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _LoginProfile_Id(ctx context.Context, field graphql.CollectedField, obj *model.LoginProfile) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_LoginProfile_Id, + func(ctx context.Context) (any, error) { + return obj.ID, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_LoginProfile_Id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "LoginProfile", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _LoginProfile_Type(ctx context.Context, field graphql.CollectedField, obj *model.LoginProfile) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_LoginProfile_Type, + func(ctx context.Context) (any, error) { + return obj.Type, nil + }, + nil, + ec.marshalNDatabaseType2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDatabaseType, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_LoginProfile_Type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "LoginProfile", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type DatabaseType does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _LoginProfile_Hostname(ctx context.Context, field graphql.CollectedField, obj *model.LoginProfile) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_LoginProfile_Hostname, + func(ctx context.Context) (any, error) { + return obj.Hostname, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_LoginProfile_Hostname(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "LoginProfile", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _LoginProfile_Database(ctx context.Context, field graphql.CollectedField, obj *model.LoginProfile) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_LoginProfile_Database, + func(ctx context.Context) (any, error) { + return obj.Database, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_LoginProfile_Database(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "LoginProfile", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _LoginProfile_IsEnvironmentDefined(ctx context.Context, field graphql.CollectedField, obj *model.LoginProfile) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_LoginProfile_IsEnvironmentDefined, + func(ctx context.Context) (any, error) { + return obj.IsEnvironmentDefined, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_LoginProfile_IsEnvironmentDefined(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "LoginProfile", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _LoginProfile_Source(ctx context.Context, field graphql.CollectedField, obj *model.LoginProfile) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_LoginProfile_Source, + func(ctx context.Context) (any, error) { + return obj.Source, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_LoginProfile_Source(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "LoginProfile", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _LoginProfile_SSLConfigured(ctx context.Context, field graphql.CollectedField, obj *model.LoginProfile) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_LoginProfile_SSLConfigured, + func(ctx context.Context) (any, error) { + return obj.SSLConfigured, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_LoginProfile_SSLConfigured(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "LoginProfile", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _MockDataDependencyAnalysis_GenerationOrder(ctx context.Context, field graphql.CollectedField, obj *model.MockDataDependencyAnalysis) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_MockDataDependencyAnalysis_GenerationOrder, + func(ctx context.Context) (any, error) { + return obj.GenerationOrder, nil + }, + nil, + ec.marshalNString2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_MockDataDependencyAnalysis_GenerationOrder(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "MockDataDependencyAnalysis", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _MockDataDependencyAnalysis_Tables(ctx context.Context, field graphql.CollectedField, obj *model.MockDataDependencyAnalysis) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_MockDataDependencyAnalysis_Tables, + func(ctx context.Context) (any, error) { + return obj.Tables, nil + }, + nil, + ec.marshalNMockDataTableInfo2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐMockDataTableInfoᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_MockDataDependencyAnalysis_Tables(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "MockDataDependencyAnalysis", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Table": + return ec.fieldContext_MockDataTableInfo_Table(ctx, field) + case "RowsToGenerate": + return ec.fieldContext_MockDataTableInfo_RowsToGenerate(ctx, field) + case "IsBlocked": + return ec.fieldContext_MockDataTableInfo_IsBlocked(ctx, field) + case "UsesExistingData": + return ec.fieldContext_MockDataTableInfo_UsesExistingData(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type MockDataTableInfo", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _MockDataDependencyAnalysis_TotalRows(ctx context.Context, field graphql.CollectedField, obj *model.MockDataDependencyAnalysis) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_MockDataDependencyAnalysis_TotalRows, + func(ctx context.Context) (any, error) { + return obj.TotalRows, nil + }, + nil, + ec.marshalNInt2int, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_MockDataDependencyAnalysis_TotalRows(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "MockDataDependencyAnalysis", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _MockDataDependencyAnalysis_Warnings(ctx context.Context, field graphql.CollectedField, obj *model.MockDataDependencyAnalysis) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_MockDataDependencyAnalysis_Warnings, + func(ctx context.Context) (any, error) { + return obj.Warnings, nil + }, + nil, + ec.marshalNString2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_MockDataDependencyAnalysis_Warnings(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "MockDataDependencyAnalysis", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _MockDataDependencyAnalysis_Error(ctx context.Context, field graphql.CollectedField, obj *model.MockDataDependencyAnalysis) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_MockDataDependencyAnalysis_Error, + func(ctx context.Context) (any, error) { + return obj.Error, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_MockDataDependencyAnalysis_Error(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "MockDataDependencyAnalysis", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _MockDataGenerationStatus_AmountGenerated(ctx context.Context, field graphql.CollectedField, obj *model.MockDataGenerationStatus) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_MockDataGenerationStatus_AmountGenerated, + func(ctx context.Context) (any, error) { + return obj.AmountGenerated, nil + }, + nil, + ec.marshalNInt2int, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_MockDataGenerationStatus_AmountGenerated(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "MockDataGenerationStatus", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _MockDataGenerationStatus_Details(ctx context.Context, field graphql.CollectedField, obj *model.MockDataGenerationStatus) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_MockDataGenerationStatus_Details, + func(ctx context.Context) (any, error) { + return obj.Details, nil + }, + nil, + ec.marshalOMockDataTableDetail2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐMockDataTableDetailᚄ, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_MockDataGenerationStatus_Details(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "MockDataGenerationStatus", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Table": + return ec.fieldContext_MockDataTableDetail_Table(ctx, field) + case "RowsGenerated": + return ec.fieldContext_MockDataTableDetail_RowsGenerated(ctx, field) + case "UsedExistingData": + return ec.fieldContext_MockDataTableDetail_UsedExistingData(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type MockDataTableDetail", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _MockDataTableDetail_Table(ctx context.Context, field graphql.CollectedField, obj *model.MockDataTableDetail) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_MockDataTableDetail_Table, + func(ctx context.Context) (any, error) { + return obj.Table, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_MockDataTableDetail_Table(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "MockDataTableDetail", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _MockDataTableDetail_RowsGenerated(ctx context.Context, field graphql.CollectedField, obj *model.MockDataTableDetail) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_MockDataTableDetail_RowsGenerated, + func(ctx context.Context) (any, error) { + return obj.RowsGenerated, nil + }, + nil, + ec.marshalNInt2int, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_MockDataTableDetail_RowsGenerated(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "MockDataTableDetail", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _MockDataTableDetail_UsedExistingData(ctx context.Context, field graphql.CollectedField, obj *model.MockDataTableDetail) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_MockDataTableDetail_UsedExistingData, + func(ctx context.Context) (any, error) { + return obj.UsedExistingData, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_MockDataTableDetail_UsedExistingData(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "MockDataTableDetail", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _MockDataTableInfo_Table(ctx context.Context, field graphql.CollectedField, obj *model.MockDataTableInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_MockDataTableInfo_Table, + func(ctx context.Context) (any, error) { + return obj.Table, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_MockDataTableInfo_Table(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "MockDataTableInfo", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _MockDataTableInfo_RowsToGenerate(ctx context.Context, field graphql.CollectedField, obj *model.MockDataTableInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_MockDataTableInfo_RowsToGenerate, + func(ctx context.Context) (any, error) { + return obj.RowsToGenerate, nil + }, + nil, + ec.marshalNInt2int, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_MockDataTableInfo_RowsToGenerate(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "MockDataTableInfo", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _MockDataTableInfo_IsBlocked(ctx context.Context, field graphql.CollectedField, obj *model.MockDataTableInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_MockDataTableInfo_IsBlocked, + func(ctx context.Context) (any, error) { + return obj.IsBlocked, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_MockDataTableInfo_IsBlocked(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "MockDataTableInfo", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _MockDataTableInfo_UsesExistingData(ctx context.Context, field graphql.CollectedField, obj *model.MockDataTableInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_MockDataTableInfo_UsesExistingData, + func(ctx context.Context) (any, error) { + return obj.UsesExistingData, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_MockDataTableInfo_UsesExistingData(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "MockDataTableInfo", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Mutation_BootstrapSealosSession(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_BootstrapSealosSession, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().BootstrapSealosSession(ctx, fc.Args["input"].(model.SealosBootstrapInput)) + }, + nil, + ec.marshalNAuthSessionPayload2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAuthSessionPayload, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_BootstrapSealosSession(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "sessionToken": + return ec.fieldContext_AuthSessionPayload_sessionToken(ctx, field) + case "expiresAt": + return ec.fieldContext_AuthSessionPayload_expiresAt(ctx, field) + case "type": + return ec.fieldContext_AuthSessionPayload_type(ctx, field) + case "hostname": + return ec.fieldContext_AuthSessionPayload_hostname(ctx, field) + case "port": + return ec.fieldContext_AuthSessionPayload_port(ctx, field) + case "database": + return ec.fieldContext_AuthSessionPayload_database(ctx, field) + case "displayName": + return ec.fieldContext_AuthSessionPayload_displayName(ctx, field) + case "instanceUid": + return ec.fieldContext_AuthSessionPayload_instanceUid(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AuthSessionPayload", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_BootstrapSealosSession_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_CreateStandaloneSession(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_CreateStandaloneSession, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().CreateStandaloneSession(ctx, fc.Args["credentials"].(model.LoginCredentials)) + }, + nil, + ec.marshalNAuthSessionPayload2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAuthSessionPayload, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_CreateStandaloneSession(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "sessionToken": + return ec.fieldContext_AuthSessionPayload_sessionToken(ctx, field) + case "expiresAt": + return ec.fieldContext_AuthSessionPayload_expiresAt(ctx, field) + case "type": + return ec.fieldContext_AuthSessionPayload_type(ctx, field) + case "hostname": + return ec.fieldContext_AuthSessionPayload_hostname(ctx, field) + case "port": + return ec.fieldContext_AuthSessionPayload_port(ctx, field) + case "database": + return ec.fieldContext_AuthSessionPayload_database(ctx, field) + case "displayName": + return ec.fieldContext_AuthSessionPayload_displayName(ctx, field) + case "instanceUid": + return ec.fieldContext_AuthSessionPayload_instanceUid(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AuthSessionPayload", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_CreateStandaloneSession_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_Login(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_Login, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().Login(ctx, fc.Args["credentials"].(model.LoginCredentials)) + }, + nil, + ec.marshalNStatusResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStatusResponse, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_Login(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_StatusResponse_Status(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StatusResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_Login_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_LoginWithProfile(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_LoginWithProfile, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().LoginWithProfile(ctx, fc.Args["profile"].(model.LoginProfileInput)) + }, + nil, + ec.marshalNStatusResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStatusResponse, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_LoginWithProfile(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_StatusResponse_Status(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StatusResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_LoginWithProfile_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_Logout(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_Logout, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Mutation().Logout(ctx) + }, + nil, + ec.marshalNStatusResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStatusResponse, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_Logout(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_StatusResponse_Status(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StatusResponse", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Mutation_UpdateSettings(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_UpdateSettings, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().UpdateSettings(ctx, fc.Args["newSettings"].(model.SettingsConfigInput)) + }, + nil, + ec.marshalNStatusResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStatusResponse, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_UpdateSettings(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_StatusResponse_Status(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StatusResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_UpdateSettings_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_CreateDashboard(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_CreateDashboard, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().CreateDashboard(ctx, fc.Args["name"].(string), fc.Args["description"].(*string), fc.Args["refreshRule"].(string)) + }, + nil, + ec.marshalNDashboard2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDashboard, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_CreateDashboard(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "ID": + return ec.fieldContext_Dashboard_ID(ctx, field) + case "Name": + return ec.fieldContext_Dashboard_Name(ctx, field) + case "Description": + return ec.fieldContext_Dashboard_Description(ctx, field) + case "RefreshRule": + return ec.fieldContext_Dashboard_RefreshRule(ctx, field) + case "Widgets": + return ec.fieldContext_Dashboard_Widgets(ctx, field) + case "CreatedAt": + return ec.fieldContext_Dashboard_CreatedAt(ctx, field) + case "UpdatedAt": + return ec.fieldContext_Dashboard_UpdatedAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Dashboard", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_CreateDashboard_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_UpdateDashboard(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_UpdateDashboard, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().UpdateDashboard(ctx, fc.Args["id"].(string), fc.Args["name"].(*string), fc.Args["description"].(*string), fc.Args["refreshRule"].(*string)) + }, + nil, + ec.marshalNDashboard2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDashboard, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_UpdateDashboard(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "ID": + return ec.fieldContext_Dashboard_ID(ctx, field) + case "Name": + return ec.fieldContext_Dashboard_Name(ctx, field) + case "Description": + return ec.fieldContext_Dashboard_Description(ctx, field) + case "RefreshRule": + return ec.fieldContext_Dashboard_RefreshRule(ctx, field) + case "Widgets": + return ec.fieldContext_Dashboard_Widgets(ctx, field) + case "CreatedAt": + return ec.fieldContext_Dashboard_CreatedAt(ctx, field) + case "UpdatedAt": + return ec.fieldContext_Dashboard_UpdatedAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Dashboard", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_UpdateDashboard_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_DeleteDashboard(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_DeleteDashboard, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().DeleteDashboard(ctx, fc.Args["id"].(string)) + }, + nil, + ec.marshalNStatusResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStatusResponse, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_DeleteDashboard(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_StatusResponse_Status(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StatusResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_DeleteDashboard_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_AddWidget(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_AddWidget, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().AddWidget(ctx, fc.Args["dashboardId"].(string), fc.Args["input"].(model.WidgetInput)) + }, + nil, + ec.marshalNDashboardWidget2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDashboardWidget, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_AddWidget(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "ID": + return ec.fieldContext_DashboardWidget_ID(ctx, field) + case "Type": + return ec.fieldContext_DashboardWidget_Type(ctx, field) + case "Title": + return ec.fieldContext_DashboardWidget_Title(ctx, field) + case "Description": + return ec.fieldContext_DashboardWidget_Description(ctx, field) + case "Layout": + return ec.fieldContext_DashboardWidget_Layout(ctx, field) + case "Query": + return ec.fieldContext_DashboardWidget_Query(ctx, field) + case "QueryContext": + return ec.fieldContext_DashboardWidget_QueryContext(ctx, field) + case "Visualization": + return ec.fieldContext_DashboardWidget_Visualization(ctx, field) + case "Snapshot": + return ec.fieldContext_DashboardWidget_Snapshot(ctx, field) + case "SortOrder": + return ec.fieldContext_DashboardWidget_SortOrder(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type DashboardWidget", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_AddWidget_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_UpdateWidget(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_UpdateWidget, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().UpdateWidget(ctx, fc.Args["id"].(string), fc.Args["input"].(model.UpdateWidgetInput)) + }, + nil, + ec.marshalNDashboardWidget2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDashboardWidget, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_UpdateWidget(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "ID": + return ec.fieldContext_DashboardWidget_ID(ctx, field) + case "Type": + return ec.fieldContext_DashboardWidget_Type(ctx, field) + case "Title": + return ec.fieldContext_DashboardWidget_Title(ctx, field) + case "Description": + return ec.fieldContext_DashboardWidget_Description(ctx, field) + case "Layout": + return ec.fieldContext_DashboardWidget_Layout(ctx, field) + case "Query": + return ec.fieldContext_DashboardWidget_Query(ctx, field) + case "QueryContext": + return ec.fieldContext_DashboardWidget_QueryContext(ctx, field) + case "Visualization": + return ec.fieldContext_DashboardWidget_Visualization(ctx, field) + case "Snapshot": + return ec.fieldContext_DashboardWidget_Snapshot(ctx, field) + case "SortOrder": + return ec.fieldContext_DashboardWidget_SortOrder(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type DashboardWidget", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_UpdateWidget_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_DeleteWidget(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_DeleteWidget, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().DeleteWidget(ctx, fc.Args["id"].(string)) + }, + nil, + ec.marshalNStatusResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStatusResponse, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_DeleteWidget(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_StatusResponse_Status(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StatusResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_DeleteWidget_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_UpdateWidgetLayouts(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_UpdateWidgetLayouts, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().UpdateWidgetLayouts(ctx, fc.Args["dashboardId"].(string), fc.Args["layouts"].([]*model.LayoutInput)) + }, + nil, + ec.marshalNStatusResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStatusResponse, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_UpdateWidgetLayouts(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_StatusResponse_Status(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StatusResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_UpdateWidgetLayouts_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_UpdateWidgetSnapshot(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_UpdateWidgetSnapshot, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().UpdateWidgetSnapshot(ctx, fc.Args["id"].(string), fc.Args["snapshot"].(model.SnapshotInput)) + }, + nil, + ec.marshalNStatusResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStatusResponse, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_UpdateWidgetSnapshot(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_StatusResponse_Status(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StatusResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_UpdateWidgetSnapshot_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_AddStorageUnit(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_AddStorageUnit, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().AddStorageUnit(ctx, fc.Args["schema"].(string), fc.Args["storageUnit"].(string), fc.Args["fields"].([]*model.RecordInput)) + }, + nil, + ec.marshalNStatusResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStatusResponse, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_AddStorageUnit(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_StatusResponse_Status(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StatusResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_AddStorageUnit_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_UpdateStorageUnit(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_UpdateStorageUnit, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().UpdateStorageUnit(ctx, fc.Args["schema"].(string), fc.Args["storageUnit"].(string), fc.Args["values"].([]*model.RecordInput), fc.Args["updatedColumns"].([]string)) + }, + nil, + ec.marshalNStatusResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStatusResponse, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_UpdateStorageUnit(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_StatusResponse_Status(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StatusResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_UpdateStorageUnit_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_ReplaceRow(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_ReplaceRow, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().ReplaceRow(ctx, fc.Args["schema"].(string), fc.Args["storageUnit"].(string), fc.Args["values"].([]*model.RecordInput)) + }, + nil, + ec.marshalNStatusResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStatusResponse, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_ReplaceRow(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_StatusResponse_Status(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StatusResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_ReplaceRow_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_AddRow(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_AddRow, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().AddRow(ctx, fc.Args["schema"].(string), fc.Args["storageUnit"].(string), fc.Args["values"].([]*model.RecordInput)) + }, + nil, + ec.marshalNStatusResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStatusResponse, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_AddRow(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_StatusResponse_Status(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StatusResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_AddRow_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_DeleteRow(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_DeleteRow, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().DeleteRow(ctx, fc.Args["schema"].(string), fc.Args["storageUnit"].(string), fc.Args["values"].([]*model.RecordInput)) + }, + nil, + ec.marshalNStatusResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStatusResponse, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_DeleteRow(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_StatusResponse_Status(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StatusResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_DeleteRow_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_CreateSQLDataExport(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_CreateSQLDataExport, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().CreateSQLDataExport(ctx, fc.Args["input"].(model.SQLDataExportInput)) + }, + nil, + ec.marshalNExportDownload2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐExportDownload, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_CreateSQLDataExport(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "ID": + return ec.fieldContext_ExportDownload_ID(ctx, field) + case "Filename": + return ec.fieldContext_ExportDownload_Filename(ctx, field) + case "ContentType": + return ec.fieldContext_ExportDownload_ContentType(ctx, field) + case "DownloadURL": + return ec.fieldContext_ExportDownload_DownloadURL(ctx, field) + case "Size": + return ec.fieldContext_ExportDownload_Size(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ExportDownload", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_CreateSQLDataExport_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_GenerateMockData(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_GenerateMockData, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().GenerateMockData(ctx, fc.Args["input"].(model.MockDataGenerationInput)) + }, + nil, + ec.marshalNMockDataGenerationStatus2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐMockDataGenerationStatus, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_GenerateMockData(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "AmountGenerated": + return ec.fieldContext_MockDataGenerationStatus_AmountGenerated(ctx, field) + case "Details": + return ec.fieldContext_MockDataGenerationStatus_Details(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type MockDataGenerationStatus", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_GenerateMockData_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_ImportSQL(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_ImportSQL, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().ImportSQL(ctx, fc.Args["input"].(model.ImportSQLInput)) + }, + nil, + ec.marshalNImportResult2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportResult, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_ImportSQL(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_ImportResult_Status(ctx, field) + case "Message": + return ec.fieldContext_ImportResult_Message(ctx, field) + case "Detail": + return ec.fieldContext_ImportResult_Detail(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ImportResult", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_ImportSQL_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_ImportPreview(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_ImportPreview, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().ImportPreview(ctx, fc.Args["input"].(model.ImportPreviewInput)) + }, + nil, + ec.marshalNImportPreview2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportPreview, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_ImportPreview(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Sheet": + return ec.fieldContext_ImportPreview_Sheet(ctx, field) + case "Sheets": + return ec.fieldContext_ImportPreview_Sheets(ctx, field) + case "Columns": + return ec.fieldContext_ImportPreview_Columns(ctx, field) + case "Rows": + return ec.fieldContext_ImportPreview_Rows(ctx, field) + case "Truncated": + return ec.fieldContext_ImportPreview_Truncated(ctx, field) + case "ValidationError": + return ec.fieldContext_ImportPreview_ValidationError(ctx, field) + case "Mapping": + return ec.fieldContext_ImportPreview_Mapping(ctx, field) + case "RequiresAllowAutoGenerated": + return ec.fieldContext_ImportPreview_RequiresAllowAutoGenerated(ctx, field) + case "AutoGeneratedColumns": + return ec.fieldContext_ImportPreview_AutoGeneratedColumns(ctx, field) + case "NewTable": + return ec.fieldContext_ImportPreview_NewTable(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ImportPreview", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_ImportPreview_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_ImportTableFile(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_ImportTableFile, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().ImportTableFile(ctx, fc.Args["input"].(model.ImportFileInput)) + }, + nil, + ec.marshalNImportResult2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportResult, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_ImportTableFile(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_ImportResult_Status(ctx, field) + case "Message": + return ec.fieldContext_ImportResult_Message(ctx, field) + case "Detail": + return ec.fieldContext_ImportResult_Detail(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ImportResult", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_ImportTableFile_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_ImportCollectionPreview(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_ImportCollectionPreview, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().ImportCollectionPreview(ctx, fc.Args["input"].(model.ImportCollectionPreviewInput)) + }, + nil, + ec.marshalNCollectionImportPreview2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCollectionImportPreview, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_ImportCollectionPreview(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Format": + return ec.fieldContext_CollectionImportPreview_Format(ctx, field) + case "Sheet": + return ec.fieldContext_CollectionImportPreview_Sheet(ctx, field) + case "Sheets": + return ec.fieldContext_CollectionImportPreview_Sheets(ctx, field) + case "Columns": + return ec.fieldContext_CollectionImportPreview_Columns(ctx, field) + case "Rows": + return ec.fieldContext_CollectionImportPreview_Rows(ctx, field) + case "Documents": + return ec.fieldContext_CollectionImportPreview_Documents(ctx, field) + case "Count": + return ec.fieldContext_CollectionImportPreview_Count(ctx, field) + case "Truncated": + return ec.fieldContext_CollectionImportPreview_Truncated(ctx, field) + case "ValidationError": + return ec.fieldContext_CollectionImportPreview_ValidationError(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type CollectionImportPreview", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_ImportCollectionPreview_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_ImportCollectionFile(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_ImportCollectionFile, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().ImportCollectionFile(ctx, fc.Args["input"].(model.ImportCollectionFileInput)) + }, + nil, + ec.marshalNCollectionImportResult2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCollectionImportResult, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_ImportCollectionFile(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_CollectionImportResult_Status(ctx, field) + case "ImportedCount": + return ec.fieldContext_CollectionImportResult_ImportedCount(ctx, field) + case "SkippedCount": + return ec.fieldContext_CollectionImportResult_SkippedCount(ctx, field) + case "MatchedCount": + return ec.fieldContext_CollectionImportResult_MatchedCount(ctx, field) + case "ModifiedCount": + return ec.fieldContext_CollectionImportResult_ModifiedCount(ctx, field) + case "UpsertedCount": + return ec.fieldContext_CollectionImportResult_UpsertedCount(ctx, field) + case "Errors": + return ec.fieldContext_CollectionImportResult_Errors(ctx, field) + case "Message": + return ec.fieldContext_CollectionImportResult_Message(ctx, field) + case "Detail": + return ec.fieldContext_CollectionImportResult_Detail(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type CollectionImportResult", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_ImportCollectionFile_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_ExecuteConfirmedSQL(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_ExecuteConfirmedSQL, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().ExecuteConfirmedSQL(ctx, fc.Args["query"].(string), fc.Args["operationType"].(string)) + }, + nil, + ec.marshalNAIChatMessage2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAIChatMessage, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_ExecuteConfirmedSQL(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Type": + return ec.fieldContext_AIChatMessage_Type(ctx, field) + case "Result": + return ec.fieldContext_AIChatMessage_Result(ctx, field) + case "Text": + return ec.fieldContext_AIChatMessage_Text(ctx, field) + case "RequiresConfirmation": + return ec.fieldContext_AIChatMessage_RequiresConfirmation(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AIChatMessage", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_ExecuteConfirmedSQL_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_GenerateChatTitle(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_GenerateChatTitle, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().GenerateChatTitle(ctx, fc.Args["input"].(model.GenerateChatTitleInput)) + }, + nil, + ec.marshalNGenerateChatTitleResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐGenerateChatTitleResponse, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_GenerateChatTitle(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Title": + return ec.fieldContext_GenerateChatTitleResponse_Title(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type GenerateChatTitleResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_GenerateChatTitle_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_AddAWSProvider(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_AddAWSProvider, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().AddAWSProvider(ctx, fc.Args["input"].(model.AWSProviderInput)) + }, + nil, + ec.marshalNAWSProvider2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSProvider, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_AddAWSProvider(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Id": + return ec.fieldContext_AWSProvider_Id(ctx, field) + case "ProviderType": + return ec.fieldContext_AWSProvider_ProviderType(ctx, field) + case "Name": + return ec.fieldContext_AWSProvider_Name(ctx, field) + case "Region": + return ec.fieldContext_AWSProvider_Region(ctx, field) + case "Status": + return ec.fieldContext_AWSProvider_Status(ctx, field) + case "LastDiscoveryAt": + return ec.fieldContext_AWSProvider_LastDiscoveryAt(ctx, field) + case "DiscoveredCount": + return ec.fieldContext_AWSProvider_DiscoveredCount(ctx, field) + case "Error": + return ec.fieldContext_AWSProvider_Error(ctx, field) + case "ProfileName": + return ec.fieldContext_AWSProvider_ProfileName(ctx, field) + case "DiscoverRDS": + return ec.fieldContext_AWSProvider_DiscoverRDS(ctx, field) + case "DiscoverElastiCache": + return ec.fieldContext_AWSProvider_DiscoverElastiCache(ctx, field) + case "DiscoverDocumentDB": + return ec.fieldContext_AWSProvider_DiscoverDocumentDB(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AWSProvider", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_AddAWSProvider_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_UpdateAWSProvider(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_UpdateAWSProvider, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().UpdateAWSProvider(ctx, fc.Args["id"].(string), fc.Args["input"].(model.AWSProviderInput)) + }, + nil, + ec.marshalNAWSProvider2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSProvider, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_UpdateAWSProvider(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Id": + return ec.fieldContext_AWSProvider_Id(ctx, field) + case "ProviderType": + return ec.fieldContext_AWSProvider_ProviderType(ctx, field) + case "Name": + return ec.fieldContext_AWSProvider_Name(ctx, field) + case "Region": + return ec.fieldContext_AWSProvider_Region(ctx, field) + case "Status": + return ec.fieldContext_AWSProvider_Status(ctx, field) + case "LastDiscoveryAt": + return ec.fieldContext_AWSProvider_LastDiscoveryAt(ctx, field) + case "DiscoveredCount": + return ec.fieldContext_AWSProvider_DiscoveredCount(ctx, field) + case "Error": + return ec.fieldContext_AWSProvider_Error(ctx, field) + case "ProfileName": + return ec.fieldContext_AWSProvider_ProfileName(ctx, field) + case "DiscoverRDS": + return ec.fieldContext_AWSProvider_DiscoverRDS(ctx, field) + case "DiscoverElastiCache": + return ec.fieldContext_AWSProvider_DiscoverElastiCache(ctx, field) + case "DiscoverDocumentDB": + return ec.fieldContext_AWSProvider_DiscoverDocumentDB(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AWSProvider", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_UpdateAWSProvider_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_TestAWSCredentials(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_TestAWSCredentials, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().TestAWSCredentials(ctx, fc.Args["input"].(model.AWSProviderInput)) + }, + nil, + ec.marshalNCloudProviderStatus2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCloudProviderStatus, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_TestAWSCredentials(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type CloudProviderStatus does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_TestAWSCredentials_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_RemoveCloudProvider(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_RemoveCloudProvider, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().RemoveCloudProvider(ctx, fc.Args["id"].(string)) + }, + nil, + ec.marshalNStatusResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStatusResponse, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_RemoveCloudProvider(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Status": + return ec.fieldContext_StatusResponse_Status(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StatusResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_RemoveCloudProvider_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_TestCloudProvider(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_TestCloudProvider, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().TestCloudProvider(ctx, fc.Args["id"].(string)) + }, + nil, + ec.marshalNCloudProviderStatus2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCloudProviderStatus, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_TestCloudProvider(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type CloudProviderStatus does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_TestCloudProvider_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_RefreshCloudProvider(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_RefreshCloudProvider, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().RefreshCloudProvider(ctx, fc.Args["id"].(string)) + }, + nil, + ec.marshalNAWSProvider2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSProvider, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_RefreshCloudProvider(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Id": + return ec.fieldContext_AWSProvider_Id(ctx, field) + case "ProviderType": + return ec.fieldContext_AWSProvider_ProviderType(ctx, field) + case "Name": + return ec.fieldContext_AWSProvider_Name(ctx, field) + case "Region": + return ec.fieldContext_AWSProvider_Region(ctx, field) + case "Status": + return ec.fieldContext_AWSProvider_Status(ctx, field) + case "LastDiscoveryAt": + return ec.fieldContext_AWSProvider_LastDiscoveryAt(ctx, field) + case "DiscoveredCount": + return ec.fieldContext_AWSProvider_DiscoveredCount(ctx, field) + case "Error": + return ec.fieldContext_AWSProvider_Error(ctx, field) + case "ProfileName": + return ec.fieldContext_AWSProvider_ProfileName(ctx, field) + case "DiscoverRDS": + return ec.fieldContext_AWSProvider_DiscoverRDS(ctx, field) + case "DiscoverElastiCache": + return ec.fieldContext_AWSProvider_DiscoverElastiCache(ctx, field) + case "DiscoverDocumentDB": + return ec.fieldContext_AWSProvider_DiscoverDocumentDB(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AWSProvider", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_RefreshCloudProvider_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_GenerateRDSAuthToken(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_GenerateRDSAuthToken, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().GenerateRDSAuthToken(ctx, fc.Args["providerID"].(string), fc.Args["endpoint"].(string), fc.Args["port"].(int), fc.Args["region"].(string), fc.Args["username"].(string)) + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_GenerateRDSAuthToken(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_GenerateRDSAuthToken_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_ResolveSealosInstanceIdentity(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_ResolveSealosInstanceIdentity, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().ResolveSealosInstanceIdentity(ctx, fc.Args["input"].(model.SealosInstanceIdentityInput)) + }, + nil, + ec.marshalNSealosInstanceIdentity2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSealosInstanceIdentity, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_ResolveSealosInstanceIdentity(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "uid": + return ec.fieldContext_SealosInstanceIdentity_uid(ctx, field) + case "namespace": + return ec.fieldContext_SealosInstanceIdentity_namespace(ctx, field) + case "resourceName": + return ec.fieldContext_SealosInstanceIdentity_resourceName(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SealosInstanceIdentity", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_ResolveSealosInstanceIdentity_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_Version(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_Version, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().Version(ctx) + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_Version(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_UpdateInfo(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_UpdateInfo, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().UpdateInfo(ctx) + }, + nil, + ec.marshalNUpdateInfo2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐUpdateInfo, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_UpdateInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "currentVersion": + return ec.fieldContext_UpdateInfo_currentVersion(ctx, field) + case "latestVersion": + return ec.fieldContext_UpdateInfo_latestVersion(ctx, field) + case "updateAvailable": + return ec.fieldContext_UpdateInfo_updateAvailable(ctx, field) + case "releaseURL": + return ec.fieldContext_UpdateInfo_releaseURL(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type UpdateInfo", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_Health(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_Health, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().Health(ctx) + }, + nil, + ec.marshalNHealthStatus2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐHealthStatus, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_Health(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Server": + return ec.fieldContext_HealthStatus_Server(ctx, field) + case "Database": + return ec.fieldContext_HealthStatus_Database(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type HealthStatus", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_GetDashboards(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_GetDashboards, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().GetDashboards(ctx) + }, + nil, + ec.marshalNDashboard2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDashboardᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_GetDashboards(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "ID": + return ec.fieldContext_Dashboard_ID(ctx, field) + case "Name": + return ec.fieldContext_Dashboard_Name(ctx, field) + case "Description": + return ec.fieldContext_Dashboard_Description(ctx, field) + case "RefreshRule": + return ec.fieldContext_Dashboard_RefreshRule(ctx, field) + case "Widgets": + return ec.fieldContext_Dashboard_Widgets(ctx, field) + case "CreatedAt": + return ec.fieldContext_Dashboard_CreatedAt(ctx, field) + case "UpdatedAt": + return ec.fieldContext_Dashboard_UpdatedAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Dashboard", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_Profiles(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_Profiles, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().Profiles(ctx) + }, + nil, + ec.marshalNLoginProfile2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLoginProfileᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_Profiles(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Alias": + return ec.fieldContext_LoginProfile_Alias(ctx, field) + case "Id": + return ec.fieldContext_LoginProfile_Id(ctx, field) + case "Type": + return ec.fieldContext_LoginProfile_Type(ctx, field) + case "Hostname": + return ec.fieldContext_LoginProfile_Hostname(ctx, field) + case "Database": + return ec.fieldContext_LoginProfile_Database(ctx, field) + case "IsEnvironmentDefined": + return ec.fieldContext_LoginProfile_IsEnvironmentDefined(ctx, field) + case "Source": + return ec.fieldContext_LoginProfile_Source(ctx, field) + case "SSLConfigured": + return ec.fieldContext_LoginProfile_SSLConfigured(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type LoginProfile", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_Database(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_Database, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().Database(ctx, fc.Args["type"].(string)) + }, + nil, + ec.marshalNString2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_Database(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_Database_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_Schema(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_Schema, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().Schema(ctx) + }, + nil, + ec.marshalNString2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_Schema(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_StorageUnit(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_StorageUnit, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().StorageUnit(ctx, fc.Args["schema"].(string)) + }, + nil, + ec.marshalNStorageUnit2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStorageUnitᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_StorageUnit(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Name": + return ec.fieldContext_StorageUnit_Name(ctx, field) + case "Attributes": + return ec.fieldContext_StorageUnit_Attributes(ctx, field) + case "IsMockDataGenerationAllowed": + return ec.fieldContext_StorageUnit_IsMockDataGenerationAllowed(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StorageUnit", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_StorageUnit_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_Row(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_Row, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().Row(ctx, fc.Args["schema"].(string), fc.Args["storageUnit"].(string), fc.Args["where"].(*model.WhereCondition), fc.Args["sort"].([]*model.SortCondition), fc.Args["pageSize"].(int), fc.Args["pageOffset"].(int)) + }, + nil, + ec.marshalNRowsResult2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRowsResult, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_Row(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Columns": + return ec.fieldContext_RowsResult_Columns(ctx, field) + case "Rows": + return ec.fieldContext_RowsResult_Rows(ctx, field) + case "DisableUpdate": + return ec.fieldContext_RowsResult_DisableUpdate(ctx, field) + case "TotalCount": + return ec.fieldContext_RowsResult_TotalCount(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type RowsResult", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_Row_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_Columns(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_Columns, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().Columns(ctx, fc.Args["schema"].(string), fc.Args["storageUnit"].(string)) + }, + nil, + ec.marshalNColumn2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐColumnᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_Columns(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Type": + return ec.fieldContext_Column_Type(ctx, field) + case "Name": + return ec.fieldContext_Column_Name(ctx, field) + case "IsPrimary": + return ec.fieldContext_Column_IsPrimary(ctx, field) + case "IsForeignKey": + return ec.fieldContext_Column_IsForeignKey(ctx, field) + case "ReferencedTable": + return ec.fieldContext_Column_ReferencedTable(ctx, field) + case "ReferencedColumn": + return ec.fieldContext_Column_ReferencedColumn(ctx, field) + case "Length": + return ec.fieldContext_Column_Length(ctx, field) + case "Precision": + return ec.fieldContext_Column_Precision(ctx, field) + case "Scale": + return ec.fieldContext_Column_Scale(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Column", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_Columns_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_ColumnsBatch(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_ColumnsBatch, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().ColumnsBatch(ctx, fc.Args["schema"].(string), fc.Args["storageUnits"].([]string)) + }, + nil, + ec.marshalNStorageUnitColumns2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStorageUnitColumnsᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_ColumnsBatch(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "StorageUnit": + return ec.fieldContext_StorageUnitColumns_StorageUnit(ctx, field) + case "Columns": + return ec.fieldContext_StorageUnitColumns_Columns(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type StorageUnitColumns", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_ColumnsBatch_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_RawExecute(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_RawExecute, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().RawExecute(ctx, fc.Args["query"].(string)) + }, + nil, + ec.marshalNRowsResult2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRowsResult, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_RawExecute(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Columns": + return ec.fieldContext_RowsResult_Columns(ctx, field) + case "Rows": + return ec.fieldContext_RowsResult_Rows(ctx, field) + case "DisableUpdate": + return ec.fieldContext_RowsResult_DisableUpdate(ctx, field) + case "TotalCount": + return ec.fieldContext_RowsResult_TotalCount(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type RowsResult", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_RawExecute_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_Graph(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_Graph, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().Graph(ctx, fc.Args["schema"].(string)) + }, + nil, + ec.marshalNGraphUnit2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐGraphUnitᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_Graph(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Unit": + return ec.fieldContext_GraphUnit_Unit(ctx, field) + case "Relations": + return ec.fieldContext_GraphUnit_Relations(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type GraphUnit", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_Graph_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_AIProviders(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_AIProviders, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().AIProviders(ctx) + }, + nil, + ec.marshalNAIProvider2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAIProviderᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_AIProviders(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Type": + return ec.fieldContext_AIProvider_Type(ctx, field) + case "Name": + return ec.fieldContext_AIProvider_Name(ctx, field) + case "ProviderId": + return ec.fieldContext_AIProvider_ProviderId(ctx, field) + case "IsEnvironmentDefined": + return ec.fieldContext_AIProvider_IsEnvironmentDefined(ctx, field) + case "IsGeneric": + return ec.fieldContext_AIProvider_IsGeneric(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AIProvider", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_AIModel(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_AIModel, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().AIModel(ctx, fc.Args["providerId"].(*string), fc.Args["modelType"].(string), fc.Args["token"].(*string)) + }, + nil, + ec.marshalNString2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_AIModel(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_AIModel_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_AIChat(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_AIChat, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().AIChat(ctx, fc.Args["providerId"].(*string), fc.Args["modelType"].(string), fc.Args["token"].(*string), fc.Args["schema"].(string), fc.Args["input"].(model.ChatInput)) + }, + nil, + ec.marshalNAIChatMessage2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAIChatMessageᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_AIChat(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Type": + return ec.fieldContext_AIChatMessage_Type(ctx, field) + case "Result": + return ec.fieldContext_AIChatMessage_Result(ctx, field) + case "Text": + return ec.fieldContext_AIChatMessage_Text(ctx, field) + case "RequiresConfirmation": + return ec.fieldContext_AIChatMessage_RequiresConfirmation(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AIChatMessage", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_AIChat_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_SettingsConfig(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_SettingsConfig, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().SettingsConfig(ctx) + }, + nil, + ec.marshalNSettingsConfig2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSettingsConfig, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_SettingsConfig(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "MetricsEnabled": + return ec.fieldContext_SettingsConfig_MetricsEnabled(ctx, field) + case "CloudProvidersEnabled": + return ec.fieldContext_SettingsConfig_CloudProvidersEnabled(ctx, field) + case "DisableCredentialForm": + return ec.fieldContext_SettingsConfig_DisableCredentialForm(ctx, field) + case "StandaloneLoginEnabled": + return ec.fieldContext_SettingsConfig_StandaloneLoginEnabled(ctx, field) + case "MaxPageSize": + return ec.fieldContext_SettingsConfig_MaxPageSize(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SettingsConfig", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_MockDataMaxRowCount(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_MockDataMaxRowCount, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().MockDataMaxRowCount(ctx) + }, + nil, + ec.marshalNInt2int, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_MockDataMaxRowCount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_AnalyzeMockDataDependencies(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_AnalyzeMockDataDependencies, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().AnalyzeMockDataDependencies(ctx, fc.Args["schema"].(string), fc.Args["storageUnit"].(string), fc.Args["rowCount"].(int), fc.Args["fkDensityRatio"].(*int)) + }, + nil, + ec.marshalNMockDataDependencyAnalysis2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐMockDataDependencyAnalysis, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_AnalyzeMockDataDependencies(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "GenerationOrder": + return ec.fieldContext_MockDataDependencyAnalysis_GenerationOrder(ctx, field) + case "Tables": + return ec.fieldContext_MockDataDependencyAnalysis_Tables(ctx, field) + case "TotalRows": + return ec.fieldContext_MockDataDependencyAnalysis_TotalRows(ctx, field) + case "Warnings": + return ec.fieldContext_MockDataDependencyAnalysis_Warnings(ctx, field) + case "Error": + return ec.fieldContext_MockDataDependencyAnalysis_Error(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type MockDataDependencyAnalysis", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_AnalyzeMockDataDependencies_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_DatabaseMetadata(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_DatabaseMetadata, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().DatabaseMetadata(ctx) + }, + nil, + ec.marshalODatabaseMetadata2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDatabaseMetadata, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Query_DatabaseMetadata(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "databaseType": + return ec.fieldContext_DatabaseMetadata_databaseType(ctx, field) + case "typeDefinitions": + return ec.fieldContext_DatabaseMetadata_typeDefinitions(ctx, field) + case "operators": + return ec.fieldContext_DatabaseMetadata_operators(ctx, field) + case "aliasMap": + return ec.fieldContext_DatabaseMetadata_aliasMap(ctx, field) + case "capabilities": + return ec.fieldContext_DatabaseMetadata_capabilities(ctx, field) + case "systemSchemas": + return ec.fieldContext_DatabaseMetadata_systemSchemas(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type DatabaseMetadata", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_SSLStatus(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_SSLStatus, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().SSLStatus(ctx) + }, + nil, + ec.marshalOSSLStatus2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSSLStatus, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Query_SSLStatus(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "IsEnabled": + return ec.fieldContext_SSLStatus_IsEnabled(ctx, field) + case "Mode": + return ec.fieldContext_SSLStatus_Mode(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SSLStatus", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_DatabaseQuerySuggestions(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_DatabaseQuerySuggestions, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().DatabaseQuerySuggestions(ctx, fc.Args["schema"].(string)) + }, + nil, + ec.marshalNDatabaseQuerySuggestion2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDatabaseQuerySuggestionᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_DatabaseQuerySuggestions(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "description": + return ec.fieldContext_DatabaseQuerySuggestion_description(ctx, field) + case "category": + return ec.fieldContext_DatabaseQuerySuggestion_category(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type DatabaseQuerySuggestion", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_DatabaseQuerySuggestions_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_CloudProviders(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_CloudProviders, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().CloudProviders(ctx) + }, + nil, + ec.marshalNAWSProvider2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSProviderᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_CloudProviders(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Id": + return ec.fieldContext_AWSProvider_Id(ctx, field) + case "ProviderType": + return ec.fieldContext_AWSProvider_ProviderType(ctx, field) + case "Name": + return ec.fieldContext_AWSProvider_Name(ctx, field) + case "Region": + return ec.fieldContext_AWSProvider_Region(ctx, field) + case "Status": + return ec.fieldContext_AWSProvider_Status(ctx, field) + case "LastDiscoveryAt": + return ec.fieldContext_AWSProvider_LastDiscoveryAt(ctx, field) + case "DiscoveredCount": + return ec.fieldContext_AWSProvider_DiscoveredCount(ctx, field) + case "Error": + return ec.fieldContext_AWSProvider_Error(ctx, field) + case "ProfileName": + return ec.fieldContext_AWSProvider_ProfileName(ctx, field) + case "DiscoverRDS": + return ec.fieldContext_AWSProvider_DiscoverRDS(ctx, field) + case "DiscoverElastiCache": + return ec.fieldContext_AWSProvider_DiscoverElastiCache(ctx, field) + case "DiscoverDocumentDB": + return ec.fieldContext_AWSProvider_DiscoverDocumentDB(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AWSProvider", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_CloudProvider(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_CloudProvider, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().CloudProvider(ctx, fc.Args["id"].(string)) + }, + nil, + ec.marshalOAWSProvider2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSProvider, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Query_CloudProvider(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Id": + return ec.fieldContext_AWSProvider_Id(ctx, field) + case "ProviderType": + return ec.fieldContext_AWSProvider_ProviderType(ctx, field) + case "Name": + return ec.fieldContext_AWSProvider_Name(ctx, field) + case "Region": + return ec.fieldContext_AWSProvider_Region(ctx, field) + case "Status": + return ec.fieldContext_AWSProvider_Status(ctx, field) + case "LastDiscoveryAt": + return ec.fieldContext_AWSProvider_LastDiscoveryAt(ctx, field) + case "DiscoveredCount": + return ec.fieldContext_AWSProvider_DiscoveredCount(ctx, field) + case "Error": + return ec.fieldContext_AWSProvider_Error(ctx, field) + case "ProfileName": + return ec.fieldContext_AWSProvider_ProfileName(ctx, field) + case "DiscoverRDS": + return ec.fieldContext_AWSProvider_DiscoverRDS(ctx, field) + case "DiscoverElastiCache": + return ec.fieldContext_AWSProvider_DiscoverElastiCache(ctx, field) + case "DiscoverDocumentDB": + return ec.fieldContext_AWSProvider_DiscoverDocumentDB(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AWSProvider", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_CloudProvider_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_DiscoveredConnections(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_DiscoveredConnections, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().DiscoveredConnections(ctx) + }, + nil, + ec.marshalNDiscoveredConnection2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDiscoveredConnectionᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_DiscoveredConnections(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Id": + return ec.fieldContext_DiscoveredConnection_Id(ctx, field) + case "ProviderType": + return ec.fieldContext_DiscoveredConnection_ProviderType(ctx, field) + case "ProviderID": + return ec.fieldContext_DiscoveredConnection_ProviderID(ctx, field) + case "Name": + return ec.fieldContext_DiscoveredConnection_Name(ctx, field) + case "DatabaseType": + return ec.fieldContext_DiscoveredConnection_DatabaseType(ctx, field) + case "Region": + return ec.fieldContext_DiscoveredConnection_Region(ctx, field) + case "Status": + return ec.fieldContext_DiscoveredConnection_Status(ctx, field) + case "Metadata": + return ec.fieldContext_DiscoveredConnection_Metadata(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type DiscoveredConnection", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_ProviderConnections(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_ProviderConnections, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().ProviderConnections(ctx, fc.Args["providerID"].(string)) + }, + nil, + ec.marshalNDiscoveredConnection2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDiscoveredConnectionᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_ProviderConnections(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Id": + return ec.fieldContext_DiscoveredConnection_Id(ctx, field) + case "ProviderType": + return ec.fieldContext_DiscoveredConnection_ProviderType(ctx, field) + case "ProviderID": + return ec.fieldContext_DiscoveredConnection_ProviderID(ctx, field) + case "Name": + return ec.fieldContext_DiscoveredConnection_Name(ctx, field) + case "DatabaseType": + return ec.fieldContext_DiscoveredConnection_DatabaseType(ctx, field) + case "Region": + return ec.fieldContext_DiscoveredConnection_Region(ctx, field) + case "Status": + return ec.fieldContext_DiscoveredConnection_Status(ctx, field) + case "Metadata": + return ec.fieldContext_DiscoveredConnection_Metadata(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type DiscoveredConnection", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_ProviderConnections_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_LocalAWSProfiles(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_LocalAWSProfiles, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().LocalAWSProfiles(ctx) + }, + nil, + ec.marshalNLocalAWSProfile2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLocalAWSProfileᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_LocalAWSProfiles(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Name": + return ec.fieldContext_LocalAWSProfile_Name(ctx, field) + case "Region": + return ec.fieldContext_LocalAWSProfile_Region(ctx, field) + case "Source": + return ec.fieldContext_LocalAWSProfile_Source(ctx, field) + case "AuthType": + return ec.fieldContext_LocalAWSProfile_AuthType(ctx, field) + case "IsDefault": + return ec.fieldContext_LocalAWSProfile_IsDefault(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type LocalAWSProfile", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Query_AWSRegions(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_AWSRegions, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().AWSRegions(ctx) + }, + nil, + ec.marshalNAWSRegion2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSRegionᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_AWSRegions(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Id": + return ec.fieldContext_AWSRegion_Id(ctx, field) + case "Description": + return ec.fieldContext_AWSRegion_Description(ctx, field) + case "Partition": + return ec.fieldContext_AWSRegion_Partition(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AWSRegion", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query___type, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.IntrospectType(fc.Args["name"].(string)) + }, + nil, + ec.marshalO__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Query___type(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query___type_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query___schema(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query___schema, + func(ctx context.Context) (any, error) { + return ec.IntrospectSchema() + }, + nil, + ec.marshalO__Schema2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐSchema, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Query___schema(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "description": + return ec.fieldContext___Schema_description(ctx, field) + case "types": + return ec.fieldContext___Schema_types(ctx, field) + case "queryType": + return ec.fieldContext___Schema_queryType(ctx, field) + case "mutationType": + return ec.fieldContext___Schema_mutationType(ctx, field) + case "subscriptionType": + return ec.fieldContext___Schema_subscriptionType(ctx, field) + case "directives": + return ec.fieldContext___Schema_directives(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Schema", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Record_Key(ctx context.Context, field graphql.CollectedField, obj *model.Record) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Record_Key, + func(ctx context.Context) (any, error) { + return obj.Key, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Record_Key(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Record", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Record_Value(ctx context.Context, field graphql.CollectedField, obj *model.Record) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Record_Value, + func(ctx context.Context) (any, error) { + return obj.Value, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Record_Value(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Record", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _RowsResult_Columns(ctx context.Context, field graphql.CollectedField, obj *model.RowsResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_RowsResult_Columns, + func(ctx context.Context) (any, error) { + return obj.Columns, nil + }, + nil, + ec.marshalNColumn2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐColumnᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_RowsResult_Columns(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "RowsResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Type": + return ec.fieldContext_Column_Type(ctx, field) + case "Name": + return ec.fieldContext_Column_Name(ctx, field) + case "IsPrimary": + return ec.fieldContext_Column_IsPrimary(ctx, field) + case "IsForeignKey": + return ec.fieldContext_Column_IsForeignKey(ctx, field) + case "ReferencedTable": + return ec.fieldContext_Column_ReferencedTable(ctx, field) + case "ReferencedColumn": + return ec.fieldContext_Column_ReferencedColumn(ctx, field) + case "Length": + return ec.fieldContext_Column_Length(ctx, field) + case "Precision": + return ec.fieldContext_Column_Precision(ctx, field) + case "Scale": + return ec.fieldContext_Column_Scale(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Column", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _RowsResult_Rows(ctx context.Context, field graphql.CollectedField, obj *model.RowsResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_RowsResult_Rows, + func(ctx context.Context) (any, error) { + return obj.Rows, nil + }, + nil, + ec.marshalNString2ᚕᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_RowsResult_Rows(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "RowsResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _RowsResult_DisableUpdate(ctx context.Context, field graphql.CollectedField, obj *model.RowsResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_RowsResult_DisableUpdate, + func(ctx context.Context) (any, error) { + return obj.DisableUpdate, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_RowsResult_DisableUpdate(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "RowsResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _RowsResult_TotalCount(ctx context.Context, field graphql.CollectedField, obj *model.RowsResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_RowsResult_TotalCount, + func(ctx context.Context) (any, error) { + return obj.TotalCount, nil + }, + nil, + ec.marshalNInt2int, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_RowsResult_TotalCount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "RowsResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SSLStatus_IsEnabled(ctx context.Context, field graphql.CollectedField, obj *model.SSLStatus) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SSLStatus_IsEnabled, + func(ctx context.Context) (any, error) { + return obj.IsEnabled, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SSLStatus_IsEnabled(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SSLStatus", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SSLStatus_Mode(ctx context.Context, field graphql.CollectedField, obj *model.SSLStatus) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SSLStatus_Mode, + func(ctx context.Context) (any, error) { + return obj.Mode, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SSLStatus_Mode(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SSLStatus", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SealosInstanceIdentity_uid(ctx context.Context, field graphql.CollectedField, obj *model.SealosInstanceIdentity) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SealosInstanceIdentity_uid, + func(ctx context.Context) (any, error) { + return obj.UID, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SealosInstanceIdentity_uid(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SealosInstanceIdentity", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SealosInstanceIdentity_namespace(ctx context.Context, field graphql.CollectedField, obj *model.SealosInstanceIdentity) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SealosInstanceIdentity_namespace, + func(ctx context.Context) (any, error) { + return obj.Namespace, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SealosInstanceIdentity_namespace(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SealosInstanceIdentity", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SealosInstanceIdentity_resourceName(ctx context.Context, field graphql.CollectedField, obj *model.SealosInstanceIdentity) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SealosInstanceIdentity_resourceName, + func(ctx context.Context) (any, error) { + return obj.ResourceName, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SealosInstanceIdentity_resourceName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SealosInstanceIdentity", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SettingsConfig_MetricsEnabled(ctx context.Context, field graphql.CollectedField, obj *model.SettingsConfig) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SettingsConfig_MetricsEnabled, + func(ctx context.Context) (any, error) { + return obj.MetricsEnabled, nil + }, + nil, + ec.marshalOBoolean2ᚖbool, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_SettingsConfig_MetricsEnabled(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SettingsConfig", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SettingsConfig_CloudProvidersEnabled(ctx context.Context, field graphql.CollectedField, obj *model.SettingsConfig) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SettingsConfig_CloudProvidersEnabled, + func(ctx context.Context) (any, error) { + return obj.CloudProvidersEnabled, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SettingsConfig_CloudProvidersEnabled(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SettingsConfig", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SettingsConfig_DisableCredentialForm(ctx context.Context, field graphql.CollectedField, obj *model.SettingsConfig) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SettingsConfig_DisableCredentialForm, + func(ctx context.Context) (any, error) { + return obj.DisableCredentialForm, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SettingsConfig_DisableCredentialForm(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SettingsConfig", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SettingsConfig_StandaloneLoginEnabled(ctx context.Context, field graphql.CollectedField, obj *model.SettingsConfig) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SettingsConfig_StandaloneLoginEnabled, + func(ctx context.Context) (any, error) { + return obj.StandaloneLoginEnabled, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SettingsConfig_StandaloneLoginEnabled(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SettingsConfig", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SettingsConfig_MaxPageSize(ctx context.Context, field graphql.CollectedField, obj *model.SettingsConfig) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SettingsConfig_MaxPageSize, + func(ctx context.Context) (any, error) { + return obj.MaxPageSize, nil + }, + nil, + ec.marshalNInt2int, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SettingsConfig_MaxPageSize(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SettingsConfig", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _StatusResponse_Status(ctx context.Context, field graphql.CollectedField, obj *model.StatusResponse) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_StatusResponse_Status, + func(ctx context.Context) (any, error) { + return obj.Status, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_StatusResponse_Status(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StatusResponse", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _StorageUnit_Name(ctx context.Context, field graphql.CollectedField, obj *model.StorageUnit) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_StorageUnit_Name, + func(ctx context.Context) (any, error) { + return obj.Name, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_StorageUnit_Name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StorageUnit", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _StorageUnit_Attributes(ctx context.Context, field graphql.CollectedField, obj *model.StorageUnit) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_StorageUnit_Attributes, + func(ctx context.Context) (any, error) { + return obj.Attributes, nil + }, + nil, + ec.marshalNRecord2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecordᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_StorageUnit_Attributes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StorageUnit", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Key": + return ec.fieldContext_Record_Key(ctx, field) + case "Value": + return ec.fieldContext_Record_Value(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Record", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _StorageUnit_IsMockDataGenerationAllowed(ctx context.Context, field graphql.CollectedField, obj *model.StorageUnit) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_StorageUnit_IsMockDataGenerationAllowed, + func(ctx context.Context) (any, error) { + return obj.IsMockDataGenerationAllowed, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_StorageUnit_IsMockDataGenerationAllowed(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StorageUnit", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _StorageUnitColumns_StorageUnit(ctx context.Context, field graphql.CollectedField, obj *model.StorageUnitColumns) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_StorageUnitColumns_StorageUnit, + func(ctx context.Context) (any, error) { + return obj.StorageUnit, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_StorageUnitColumns_StorageUnit(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StorageUnitColumns", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _StorageUnitColumns_Columns(ctx context.Context, field graphql.CollectedField, obj *model.StorageUnitColumns) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_StorageUnitColumns_Columns, + func(ctx context.Context) (any, error) { + return obj.Columns, nil + }, + nil, + ec.marshalNColumn2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐColumnᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_StorageUnitColumns_Columns(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "StorageUnitColumns", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "Type": + return ec.fieldContext_Column_Type(ctx, field) + case "Name": + return ec.fieldContext_Column_Name(ctx, field) + case "IsPrimary": + return ec.fieldContext_Column_IsPrimary(ctx, field) + case "IsForeignKey": + return ec.fieldContext_Column_IsForeignKey(ctx, field) + case "ReferencedTable": + return ec.fieldContext_Column_ReferencedTable(ctx, field) + case "ReferencedColumn": + return ec.fieldContext_Column_ReferencedColumn(ctx, field) + case "Length": + return ec.fieldContext_Column_Length(ctx, field) + case "Precision": + return ec.fieldContext_Column_Precision(ctx, field) + case "Scale": + return ec.fieldContext_Column_Scale(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Column", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _TypeDefinition_id(ctx context.Context, field graphql.CollectedField, obj *model.TypeDefinition) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_TypeDefinition_id, + func(ctx context.Context) (any, error) { + return obj.ID, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_TypeDefinition_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TypeDefinition", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _TypeDefinition_label(ctx context.Context, field graphql.CollectedField, obj *model.TypeDefinition) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_TypeDefinition_label, + func(ctx context.Context) (any, error) { + return obj.Label, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_TypeDefinition_label(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TypeDefinition", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _TypeDefinition_hasLength(ctx context.Context, field graphql.CollectedField, obj *model.TypeDefinition) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_TypeDefinition_hasLength, + func(ctx context.Context) (any, error) { + return obj.HasLength, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_TypeDefinition_hasLength(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TypeDefinition", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _TypeDefinition_hasPrecision(ctx context.Context, field graphql.CollectedField, obj *model.TypeDefinition) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_TypeDefinition_hasPrecision, + func(ctx context.Context) (any, error) { + return obj.HasPrecision, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_TypeDefinition_hasPrecision(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TypeDefinition", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _TypeDefinition_defaultLength(ctx context.Context, field graphql.CollectedField, obj *model.TypeDefinition) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_TypeDefinition_defaultLength, + func(ctx context.Context) (any, error) { + return obj.DefaultLength, nil + }, + nil, + ec.marshalOInt2ᚖint, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_TypeDefinition_defaultLength(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TypeDefinition", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _TypeDefinition_defaultPrecision(ctx context.Context, field graphql.CollectedField, obj *model.TypeDefinition) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_TypeDefinition_defaultPrecision, + func(ctx context.Context) (any, error) { + return obj.DefaultPrecision, nil + }, + nil, + ec.marshalOInt2ᚖint, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_TypeDefinition_defaultPrecision(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TypeDefinition", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _TypeDefinition_category(ctx context.Context, field graphql.CollectedField, obj *model.TypeDefinition) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_TypeDefinition_category, + func(ctx context.Context) (any, error) { + return obj.Category, nil + }, + nil, + ec.marshalNTypeCategory2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐTypeCategory, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_TypeDefinition_category(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TypeDefinition", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type TypeCategory does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _UpdateInfo_currentVersion(ctx context.Context, field graphql.CollectedField, obj *model.UpdateInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_UpdateInfo_currentVersion, + func(ctx context.Context) (any, error) { + return obj.CurrentVersion, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_UpdateInfo_currentVersion(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "UpdateInfo", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _UpdateInfo_latestVersion(ctx context.Context, field graphql.CollectedField, obj *model.UpdateInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_UpdateInfo_latestVersion, + func(ctx context.Context) (any, error) { + return obj.LatestVersion, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_UpdateInfo_latestVersion(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "UpdateInfo", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _UpdateInfo_updateAvailable(ctx context.Context, field graphql.CollectedField, obj *model.UpdateInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_UpdateInfo_updateAvailable, + func(ctx context.Context) (any, error) { + return obj.UpdateAvailable, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_UpdateInfo_updateAvailable(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "UpdateInfo", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _UpdateInfo_releaseURL(ctx context.Context, field graphql.CollectedField, obj *model.UpdateInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_UpdateInfo_releaseURL, + func(ctx context.Context) (any, error) { + return obj.ReleaseURL, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_UpdateInfo_releaseURL(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "UpdateInfo", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Directive_name(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Directive_name, + func(ctx context.Context) (any, error) { + return obj.Name, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___Directive_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Directive", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Directive_description(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Directive_description, + func(ctx context.Context) (any, error) { + return obj.Description(), nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___Directive_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Directive", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Directive_isRepeatable(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Directive_isRepeatable, + func(ctx context.Context) (any, error) { + return obj.IsRepeatable, nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___Directive_isRepeatable(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Directive", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Directive_locations(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Directive_locations, + func(ctx context.Context) (any, error) { + return obj.Locations, nil + }, + nil, + ec.marshalN__DirectiveLocation2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___Directive_locations(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Directive", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type __DirectiveLocation does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Directive_args(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Directive_args, + func(ctx context.Context) (any, error) { + return obj.Args, nil + }, + nil, + ec.marshalN__InputValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValueᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___Directive_args(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Directive", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "name": + return ec.fieldContext___InputValue_name(ctx, field) + case "description": + return ec.fieldContext___InputValue_description(ctx, field) + case "type": + return ec.fieldContext___InputValue_type(ctx, field) + case "defaultValue": + return ec.fieldContext___InputValue_defaultValue(ctx, field) + case "isDeprecated": + return ec.fieldContext___InputValue_isDeprecated(ctx, field) + case "deprecationReason": + return ec.fieldContext___InputValue_deprecationReason(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __InputValue", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field___Directive_args_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) ___EnumValue_name(ctx context.Context, field graphql.CollectedField, obj *introspection.EnumValue) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___EnumValue_name, + func(ctx context.Context) (any, error) { + return obj.Name, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___EnumValue_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__EnumValue", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___EnumValue_description(ctx context.Context, field graphql.CollectedField, obj *introspection.EnumValue) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___EnumValue_description, + func(ctx context.Context) (any, error) { + return obj.Description(), nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___EnumValue_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__EnumValue", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___EnumValue_isDeprecated(ctx context.Context, field graphql.CollectedField, obj *introspection.EnumValue) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___EnumValue_isDeprecated, + func(ctx context.Context) (any, error) { + return obj.IsDeprecated(), nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___EnumValue_isDeprecated(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__EnumValue", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___EnumValue_deprecationReason(ctx context.Context, field graphql.CollectedField, obj *introspection.EnumValue) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___EnumValue_deprecationReason, + func(ctx context.Context) (any, error) { + return obj.DeprecationReason(), nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___EnumValue_deprecationReason(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__EnumValue", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Field_name(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Field_name, + func(ctx context.Context) (any, error) { + return obj.Name, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___Field_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Field", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Field_description(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Field_description, + func(ctx context.Context) (any, error) { + return obj.Description(), nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___Field_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Field", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Field_args(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Field_args, + func(ctx context.Context) (any, error) { + return obj.Args, nil + }, + nil, + ec.marshalN__InputValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValueᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___Field_args(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Field", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "name": + return ec.fieldContext___InputValue_name(ctx, field) + case "description": + return ec.fieldContext___InputValue_description(ctx, field) + case "type": + return ec.fieldContext___InputValue_type(ctx, field) + case "defaultValue": + return ec.fieldContext___InputValue_defaultValue(ctx, field) + case "isDeprecated": + return ec.fieldContext___InputValue_isDeprecated(ctx, field) + case "deprecationReason": + return ec.fieldContext___InputValue_deprecationReason(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __InputValue", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field___Field_args_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) ___Field_type(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Field_type, + func(ctx context.Context) (any, error) { + return obj.Type, nil + }, + nil, + ec.marshalN__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___Field_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Field", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Field_isDeprecated(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Field_isDeprecated, + func(ctx context.Context) (any, error) { + return obj.IsDeprecated(), nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___Field_isDeprecated(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Field", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Field_deprecationReason(ctx context.Context, field graphql.CollectedField, obj *introspection.Field) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Field_deprecationReason, + func(ctx context.Context) (any, error) { + return obj.DeprecationReason(), nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___Field_deprecationReason(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Field", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___InputValue_name(ctx context.Context, field graphql.CollectedField, obj *introspection.InputValue) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___InputValue_name, + func(ctx context.Context) (any, error) { + return obj.Name, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___InputValue_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__InputValue", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___InputValue_description(ctx context.Context, field graphql.CollectedField, obj *introspection.InputValue) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___InputValue_description, + func(ctx context.Context) (any, error) { + return obj.Description(), nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___InputValue_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__InputValue", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___InputValue_type(ctx context.Context, field graphql.CollectedField, obj *introspection.InputValue) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___InputValue_type, + func(ctx context.Context) (any, error) { + return obj.Type, nil + }, + nil, + ec.marshalN__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___InputValue_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__InputValue", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___InputValue_defaultValue(ctx context.Context, field graphql.CollectedField, obj *introspection.InputValue) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___InputValue_defaultValue, + func(ctx context.Context) (any, error) { + return obj.DefaultValue, nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___InputValue_defaultValue(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__InputValue", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___InputValue_isDeprecated(ctx context.Context, field graphql.CollectedField, obj *introspection.InputValue) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___InputValue_isDeprecated, + func(ctx context.Context) (any, error) { + return obj.IsDeprecated(), nil + }, + nil, + ec.marshalNBoolean2bool, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___InputValue_isDeprecated(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__InputValue", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___InputValue_deprecationReason(ctx context.Context, field graphql.CollectedField, obj *introspection.InputValue) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___InputValue_deprecationReason, + func(ctx context.Context) (any, error) { + return obj.DeprecationReason(), nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___InputValue_deprecationReason(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__InputValue", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Schema_description(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Schema_description, + func(ctx context.Context) (any, error) { + return obj.Description(), nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___Schema_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Schema", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Schema_types(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Schema_types, + func(ctx context.Context) (any, error) { + return obj.Types(), nil + }, + nil, + ec.marshalN__Type2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐTypeᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___Schema_types(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Schema", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Schema_queryType(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Schema_queryType, + func(ctx context.Context) (any, error) { + return obj.QueryType(), nil + }, + nil, + ec.marshalN__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___Schema_queryType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Schema", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Schema_mutationType(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Schema_mutationType, + func(ctx context.Context) (any, error) { + return obj.MutationType(), nil + }, + nil, + ec.marshalO__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___Schema_mutationType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Schema", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Schema_subscriptionType(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Schema_subscriptionType, + func(ctx context.Context) (any, error) { + return obj.SubscriptionType(), nil + }, + nil, + ec.marshalO__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___Schema_subscriptionType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Schema", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Schema_directives(ctx context.Context, field graphql.CollectedField, obj *introspection.Schema) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Schema_directives, + func(ctx context.Context) (any, error) { + return obj.Directives(), nil + }, + nil, + ec.marshalN__Directive2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐDirectiveᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___Schema_directives(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Schema", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "name": + return ec.fieldContext___Directive_name(ctx, field) + case "description": + return ec.fieldContext___Directive_description(ctx, field) + case "isRepeatable": + return ec.fieldContext___Directive_isRepeatable(ctx, field) + case "locations": + return ec.fieldContext___Directive_locations(ctx, field) + case "args": + return ec.fieldContext___Directive_args(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Directive", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Type_kind(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Type_kind, + func(ctx context.Context) (any, error) { + return obj.Kind(), nil + }, + nil, + ec.marshalN__TypeKind2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext___Type_kind(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type __TypeKind does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Type_name(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Type_name, + func(ctx context.Context) (any, error) { + return obj.Name(), nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___Type_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Type_description(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Type_description, + func(ctx context.Context) (any, error) { + return obj.Description(), nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___Type_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Type_specifiedByURL(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Type_specifiedByURL, + func(ctx context.Context) (any, error) { + return obj.SpecifiedByURL(), nil + }, + nil, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___Type_specifiedByURL(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) ___Type_fields(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Type_fields, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return obj.Fields(fc.Args["includeDeprecated"].(bool)), nil + }, + nil, + ec.marshalO__Field2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐFieldᚄ, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___Type_fields(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "name": + return ec.fieldContext___Field_name(ctx, field) + case "description": + return ec.fieldContext___Field_description(ctx, field) + case "args": + return ec.fieldContext___Field_args(ctx, field) + case "type": + return ec.fieldContext___Field_type(ctx, field) + case "isDeprecated": + return ec.fieldContext___Field_isDeprecated(ctx, field) + case "deprecationReason": + return ec.fieldContext___Field_deprecationReason(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Field", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field___Type_fields_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) ___Type_interfaces(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Type_interfaces, + func(ctx context.Context) (any, error) { + return obj.Interfaces(), nil + }, + nil, + ec.marshalO__Type2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐTypeᚄ, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___Type_interfaces(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Type_possibleTypes(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Type_possibleTypes, + func(ctx context.Context) (any, error) { + return obj.PossibleTypes(), nil + }, + nil, + ec.marshalO__Type2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐTypeᚄ, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___Type_possibleTypes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Type_enumValues(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Type_enumValues, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return obj.EnumValues(fc.Args["includeDeprecated"].(bool)), nil + }, + nil, + ec.marshalO__EnumValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐEnumValueᚄ, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___Type_enumValues(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "name": + return ec.fieldContext___EnumValue_name(ctx, field) + case "description": + return ec.fieldContext___EnumValue_description(ctx, field) + case "isDeprecated": + return ec.fieldContext___EnumValue_isDeprecated(ctx, field) + case "deprecationReason": + return ec.fieldContext___EnumValue_deprecationReason(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __EnumValue", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field___Type_enumValues_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) ___Type_inputFields(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Type_inputFields, + func(ctx context.Context) (any, error) { + return obj.InputFields(), nil + }, + nil, + ec.marshalO__InputValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValueᚄ, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___Type_inputFields(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "name": + return ec.fieldContext___InputValue_name(ctx, field) + case "description": + return ec.fieldContext___InputValue_description(ctx, field) + case "type": + return ec.fieldContext___InputValue_type(ctx, field) + case "defaultValue": + return ec.fieldContext___InputValue_defaultValue(ctx, field) + case "isDeprecated": + return ec.fieldContext___InputValue_isDeprecated(ctx, field) + case "deprecationReason": + return ec.fieldContext___InputValue_deprecationReason(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __InputValue", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Type_ofType(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Type_ofType, + func(ctx context.Context) (any, error) { + return obj.OfType(), nil + }, + nil, + ec.marshalO__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___Type_ofType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "kind": + return ec.fieldContext___Type_kind(ctx, field) + case "name": + return ec.fieldContext___Type_name(ctx, field) + case "description": + return ec.fieldContext___Type_description(ctx, field) + case "specifiedByURL": + return ec.fieldContext___Type_specifiedByURL(ctx, field) + case "fields": + return ec.fieldContext___Type_fields(ctx, field) + case "interfaces": + return ec.fieldContext___Type_interfaces(ctx, field) + case "possibleTypes": + return ec.fieldContext___Type_possibleTypes(ctx, field) + case "enumValues": + return ec.fieldContext___Type_enumValues(ctx, field) + case "inputFields": + return ec.fieldContext___Type_inputFields(ctx, field) + case "ofType": + return ec.fieldContext___Type_ofType(ctx, field) + case "isOneOf": + return ec.fieldContext___Type_isOneOf(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type __Type", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) ___Type_isOneOf(ctx context.Context, field graphql.CollectedField, obj *introspection.Type) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext___Type_isOneOf, + func(ctx context.Context) (any, error) { + return obj.IsOneOf(), nil + }, + nil, + ec.marshalOBoolean2bool, + true, + false, + ) +} + +func (ec *executionContext) fieldContext___Type_isOneOf(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "__Type", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +// endregion **************************** field.gotpl ***************************** + +// region **************************** input.gotpl ***************************** + +func (ec *executionContext) unmarshalInputAWSProviderInput(ctx context.Context, obj any) (model.AWSProviderInput, error) { + var it model.AWSProviderInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Name", "Region", "ProfileName", "DiscoverRDS", "DiscoverElastiCache", "DiscoverDocumentDB"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Name": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Name")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Name = data + case "Region": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Region")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Region = data + case "ProfileName": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("ProfileName")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.ProfileName = data + case "DiscoverRDS": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("DiscoverRDS")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.DiscoverRds = data + case "DiscoverElastiCache": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("DiscoverElastiCache")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.DiscoverElastiCache = data + case "DiscoverDocumentDB": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("DiscoverDocumentDB")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.DiscoverDocumentDb = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputAtomicWhereCondition(ctx context.Context, obj any) (model.AtomicWhereCondition, error) { + var it model.AtomicWhereCondition + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"ColumnType", "Key", "Operator", "Value"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "ColumnType": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("ColumnType")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.ColumnType = data + case "Key": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Key")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Key = data + case "Operator": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Operator")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Operator = data + case "Value": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Value")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Value = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputChatInput(ctx context.Context, obj any) (model.ChatInput, error) { + var it model.ChatInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"PreviousConversation", "Query", "Model", "Token"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "PreviousConversation": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("PreviousConversation")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.PreviousConversation = data + case "Query": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Query")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Query = data + case "Model": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Model")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Model = data + case "Token": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Token")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Token = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputGenerateChatTitleInput(ctx context.Context, obj any) (model.GenerateChatTitleInput, error) { + var it model.GenerateChatTitleInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Query", "ModelType", "ProviderId", "Token", "Model", "Endpoint"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Query": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Query")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Query = data + case "ModelType": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("ModelType")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.ModelType = data + case "ProviderId": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("ProviderId")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.ProviderID = data + case "Token": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Token")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Token = data + case "Model": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Model")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Model = data + case "Endpoint": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Endpoint")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Endpoint = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputImportCollectionFileInput(ctx context.Context, obj any) (model.ImportCollectionFileInput, error) { + var it model.ImportCollectionFileInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Schema", "Collection", "Format", "Mode", "UpsertKeys", "SkipColumns", "Delimiter", "Sheet", "File"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Schema": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Schema")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Schema = data + case "Collection": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Collection")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Collection = data + case "Format": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Format")) + data, err := ec.unmarshalNCollectionImportFormat2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCollectionImportFormat(ctx, v) + if err != nil { + return it, err + } + it.Format = data + case "Mode": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Mode")) + data, err := ec.unmarshalNImportMode2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportMode(ctx, v) + if err != nil { + return it, err + } + it.Mode = data + case "UpsertKeys": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("UpsertKeys")) + data, err := ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.UpsertKeys = data + case "SkipColumns": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("SkipColumns")) + data, err := ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.SkipColumns = data + case "Delimiter": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Delimiter")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Delimiter = data + case "Sheet": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Sheet")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Sheet = data + case "File": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("File")) + data, err := ec.unmarshalNUpload2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚐUpload(ctx, v) + if err != nil { + return it, err + } + it.File = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputImportCollectionPreviewInput(ctx context.Context, obj any) (model.ImportCollectionPreviewInput, error) { + var it model.ImportCollectionPreviewInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Format", "Delimiter", "Sheet", "File"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Format": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Format")) + data, err := ec.unmarshalNCollectionImportFormat2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCollectionImportFormat(ctx, v) + if err != nil { + return it, err + } + it.Format = data + case "Delimiter": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Delimiter")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Delimiter = data + case "Sheet": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Sheet")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Sheet = data + case "File": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("File")) + data, err := ec.unmarshalNUpload2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚐUpload(ctx, v) + if err != nil { + return it, err + } + it.File = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputImportColumnMapping(ctx context.Context, obj any) (model.ImportColumnMapping, error) { + var it model.ImportColumnMapping + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"SourceColumn", "TargetColumn", "Skip"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "SourceColumn": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("SourceColumn")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.SourceColumn = data + case "TargetColumn": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("TargetColumn")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.TargetColumn = data + case "Skip": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Skip")) + data, err := ec.unmarshalNBoolean2bool(ctx, v) + if err != nil { + return it, err + } + it.Skip = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputImportExistingTableInput(ctx context.Context, obj any) (model.ImportExistingTableInput, error) { + var it model.ImportExistingTableInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Schema", "StorageUnit", "Mode", "Mapping", "AllowAutoGenerated"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Schema": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Schema")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Schema = data + case "StorageUnit": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("StorageUnit")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.StorageUnit = data + case "Mode": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Mode")) + data, err := ec.unmarshalNImportMode2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportMode(ctx, v) + if err != nil { + return it, err + } + it.Mode = data + case "Mapping": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Mapping")) + data, err := ec.unmarshalNImportColumnMapping2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportColumnMappingᚄ(ctx, v) + if err != nil { + return it, err + } + it.Mapping = data + case "AllowAutoGenerated": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("AllowAutoGenerated")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.AllowAutoGenerated = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputImportExistingTablePreviewInput(ctx context.Context, obj any) (model.ImportExistingTablePreviewInput, error) { + var it model.ImportExistingTablePreviewInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Schema", "StorageUnit", "UseHeaderMapping"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Schema": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Schema")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Schema = data + case "StorageUnit": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("StorageUnit")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.StorageUnit = data + case "UseHeaderMapping": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("UseHeaderMapping")) + data, err := ec.unmarshalNBoolean2bool(ctx, v) + if err != nil { + return it, err + } + it.UseHeaderMapping = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputImportFileInput(ctx context.Context, obj any) (model.ImportFileInput, error) { + var it model.ImportFileInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"TargetMode", "ExistingTable", "NewTable", "Options", "File"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "TargetMode": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("TargetMode")) + data, err := ec.unmarshalNImportTargetMode2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportTargetMode(ctx, v) + if err != nil { + return it, err + } + it.TargetMode = data + case "ExistingTable": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("ExistingTable")) + data, err := ec.unmarshalOImportExistingTableInput2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportExistingTableInput(ctx, v) + if err != nil { + return it, err + } + it.ExistingTable = data + case "NewTable": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("NewTable")) + data, err := ec.unmarshalOImportNewTableInput2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportNewTableInput(ctx, v) + if err != nil { + return it, err + } + it.NewTable = data + case "Options": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Options")) + data, err := ec.unmarshalNImportFileOptions2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportFileOptions(ctx, v) + if err != nil { + return it, err + } + it.Options = data + case "File": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("File")) + data, err := ec.unmarshalNUpload2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚐUpload(ctx, v) + if err != nil { + return it, err + } + it.File = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputImportFileOptions(ctx context.Context, obj any) (model.ImportFileOptions, error) { + var it model.ImportFileOptions + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Format", "Delimiter", "Sheet"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Format": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Format")) + data, err := ec.unmarshalNImportFileFormat2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportFileFormat(ctx, v) + if err != nil { + return it, err + } + it.Format = data + case "Delimiter": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Delimiter")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Delimiter = data + case "Sheet": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Sheet")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Sheet = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputImportNewTableColumnInput(ctx context.Context, obj any) (model.ImportNewTableColumnInput, error) { + var it model.ImportNewTableColumnInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"SourceColumn", "TargetColumn", "Type", "Nullable", "Primary", "Skip"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "SourceColumn": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("SourceColumn")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.SourceColumn = data + case "TargetColumn": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("TargetColumn")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.TargetColumn = data + case "Type": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Type")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Type = data + case "Nullable": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Nullable")) + data, err := ec.unmarshalNBoolean2bool(ctx, v) + if err != nil { + return it, err + } + it.Nullable = data + case "Primary": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Primary")) + data, err := ec.unmarshalNBoolean2bool(ctx, v) + if err != nil { + return it, err + } + it.Primary = data + case "Skip": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Skip")) + data, err := ec.unmarshalNBoolean2bool(ctx, v) + if err != nil { + return it, err + } + it.Skip = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputImportNewTableInput(ctx context.Context, obj any) (model.ImportNewTableInput, error) { + var it model.ImportNewTableInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Schema", "TableName", "Columns"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Schema": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Schema")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Schema = data + case "TableName": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("TableName")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.TableName = data + case "Columns": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Columns")) + data, err := ec.unmarshalNImportNewTableColumnInput2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportNewTableColumnInputᚄ(ctx, v) + if err != nil { + return it, err + } + it.Columns = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputImportNewTablePreviewInput(ctx context.Context, obj any) (model.ImportNewTablePreviewInput, error) { + var it model.ImportNewTablePreviewInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Schema"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Schema": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Schema")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Schema = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputImportPreviewInput(ctx context.Context, obj any) (model.ImportPreviewInput, error) { + var it model.ImportPreviewInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"TargetMode", "ExistingTable", "NewTable", "Options", "File"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "TargetMode": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("TargetMode")) + data, err := ec.unmarshalNImportTargetMode2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportTargetMode(ctx, v) + if err != nil { + return it, err + } + it.TargetMode = data + case "ExistingTable": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("ExistingTable")) + data, err := ec.unmarshalOImportExistingTablePreviewInput2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportExistingTablePreviewInput(ctx, v) + if err != nil { + return it, err + } + it.ExistingTable = data + case "NewTable": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("NewTable")) + data, err := ec.unmarshalOImportNewTablePreviewInput2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportNewTablePreviewInput(ctx, v) + if err != nil { + return it, err + } + it.NewTable = data + case "Options": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Options")) + data, err := ec.unmarshalNImportFileOptions2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportFileOptions(ctx, v) + if err != nil { + return it, err + } + it.Options = data + case "File": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("File")) + data, err := ec.unmarshalNUpload2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚐUpload(ctx, v) + if err != nil { + return it, err + } + it.File = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputImportSQLInput(ctx context.Context, obj any) (model.ImportSQLInput, error) { + var it model.ImportSQLInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Script", "File", "Filename"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Script": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Script")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Script = data + case "File": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("File")) + data, err := ec.unmarshalOUpload2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚐUpload(ctx, v) + if err != nil { + return it, err + } + it.File = data + case "Filename": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Filename")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Filename = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputLayoutInput(ctx context.Context, obj any) (model.LayoutInput, error) { + var it model.LayoutInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"WidgetID", "Layout"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "WidgetID": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("WidgetID")) + data, err := ec.unmarshalNID2string(ctx, v) + if err != nil { + return it, err + } + it.WidgetID = data + case "Layout": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Layout")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Layout = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputLoginCredentials(ctx context.Context, obj any) (model.LoginCredentials, error) { + var it model.LoginCredentials + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Id", "Type", "Hostname", "Username", "Password", "Database", "Advanced"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Id": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Id")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.ID = data + case "Type": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Type")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Type = data + case "Hostname": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Hostname")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Hostname = data + case "Username": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Username")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Username = data + case "Password": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Password")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Password = data + case "Database": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Database")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Database = data + case "Advanced": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Advanced")) + data, err := ec.unmarshalORecordInput2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecordInputᚄ(ctx, v) + if err != nil { + return it, err + } + it.Advanced = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputLoginProfileInput(ctx context.Context, obj any) (model.LoginProfileInput, error) { + var it model.LoginProfileInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Id", "Type", "Database"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Id": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Id")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.ID = data + case "Type": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Type")) + data, err := ec.unmarshalNDatabaseType2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDatabaseType(ctx, v) + if err != nil { + return it, err + } + it.Type = data + case "Database": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Database")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Database = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputMockDataGenerationInput(ctx context.Context, obj any) (model.MockDataGenerationInput, error) { + var it model.MockDataGenerationInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Schema", "StorageUnit", "RowCount", "Method", "OverwriteExisting", "FkDensityRatio"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Schema": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Schema")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Schema = data + case "StorageUnit": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("StorageUnit")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.StorageUnit = data + case "RowCount": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("RowCount")) + data, err := ec.unmarshalNInt2int(ctx, v) + if err != nil { + return it, err + } + it.RowCount = data + case "Method": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Method")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Method = data + case "OverwriteExisting": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("OverwriteExisting")) + data, err := ec.unmarshalNBoolean2bool(ctx, v) + if err != nil { + return it, err + } + it.OverwriteExisting = data + case "FkDensityRatio": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("FkDensityRatio")) + data, err := ec.unmarshalOInt2ᚖint(ctx, v) + if err != nil { + return it, err + } + it.FkDensityRatio = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputOperationWhereCondition(ctx context.Context, obj any) (model.OperationWhereCondition, error) { + var it model.OperationWhereCondition + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Children"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Children": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Children")) + data, err := ec.unmarshalNWhereCondition2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐWhereConditionᚄ(ctx, v) + if err != nil { + return it, err + } + it.Children = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputRecordInput(ctx context.Context, obj any) (model.RecordInput, error) { + var it model.RecordInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Key", "Value", "Extra"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Key": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Key")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Key = data + case "Value": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Value")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Value = data + case "Extra": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Extra")) + data, err := ec.unmarshalORecordInput2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecordInputᚄ(ctx, v) + if err != nil { + return it, err + } + it.Extra = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputSQLDataExportInput(ctx context.Context, obj any) (model.SQLDataExportInput, error) { + var it model.SQLDataExportInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Schema", "StorageUnit", "Mode", "Where", "Sort", "Limit"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Schema": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Schema")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Schema = data + case "StorageUnit": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("StorageUnit")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.StorageUnit = data + case "Mode": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Mode")) + data, err := ec.unmarshalNSQLDataExportMode2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSQLDataExportMode(ctx, v) + if err != nil { + return it, err + } + it.Mode = data + case "Where": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Where")) + data, err := ec.unmarshalOWhereCondition2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐWhereCondition(ctx, v) + if err != nil { + return it, err + } + it.Where = data + case "Sort": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Sort")) + data, err := ec.unmarshalOSortCondition2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSortConditionᚄ(ctx, v) + if err != nil { + return it, err + } + it.Sort = data + case "Limit": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Limit")) + data, err := ec.unmarshalOInt2ᚖint(ctx, v) + if err != nil { + return it, err + } + it.Limit = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputSealosBootstrapInput(ctx context.Context, obj any) (model.SealosBootstrapInput, error) { + var it model.SealosBootstrapInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"kubeconfig", "dbType", "resourceName", "databaseName", "host", "port", "namespace"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "kubeconfig": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("kubeconfig")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Kubeconfig = data + case "dbType": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("dbType")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.DbType = data + case "resourceName": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("resourceName")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.ResourceName = data + case "databaseName": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("databaseName")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.DatabaseName = data + case "host": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("host")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Host = data + case "port": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("port")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Port = data + case "namespace": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("namespace")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Namespace = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputSealosInstanceIdentityInput(ctx context.Context, obj any) (model.SealosInstanceIdentityInput, error) { + var it model.SealosInstanceIdentityInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"kubeconfig", "resourceName", "namespace"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "kubeconfig": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("kubeconfig")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Kubeconfig = data + case "resourceName": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("resourceName")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.ResourceName = data + case "namespace": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("namespace")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Namespace = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputSettingsConfigInput(ctx context.Context, obj any) (model.SettingsConfigInput, error) { + var it model.SettingsConfigInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"MetricsEnabled"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "MetricsEnabled": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("MetricsEnabled")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.MetricsEnabled = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputSnapshotInput(ctx context.Context, obj any) (model.SnapshotInput, error) { + var it model.SnapshotInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Config", "Data", "ExecutedAt"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Config": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Config")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Config = data + case "Data": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Data")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Data = data + case "ExecutedAt": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("ExecutedAt")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.ExecutedAt = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputSortCondition(ctx context.Context, obj any) (model.SortCondition, error) { + var it model.SortCondition + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Column", "Direction"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Column": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Column")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Column = data + case "Direction": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Direction")) + data, err := ec.unmarshalNSortDirection2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSortDirection(ctx, v) + if err != nil { + return it, err + } + it.Direction = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputUpdateWidgetInput(ctx context.Context, obj any) (model.UpdateWidgetInput, error) { + var it model.UpdateWidgetInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Title", "Description", "Layout", "Query", "QueryContext", "Visualization", "Snapshot", "SortOrder"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Title": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Title")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Title = data + case "Description": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Description")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Description = data + case "Layout": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Layout")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Layout = data + case "Query": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Query")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Query = data + case "QueryContext": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("QueryContext")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.QueryContext = data + case "Visualization": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Visualization")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Visualization = data + case "Snapshot": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Snapshot")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Snapshot = data + case "SortOrder": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("SortOrder")) + data, err := ec.unmarshalOInt2ᚖint(ctx, v) + if err != nil { + return it, err + } + it.SortOrder = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputWhereCondition(ctx context.Context, obj any) (model.WhereCondition, error) { + var it model.WhereCondition + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Type", "Atomic", "And", "Or"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Type": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Type")) + data, err := ec.unmarshalNWhereConditionType2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐWhereConditionType(ctx, v) + if err != nil { + return it, err + } + it.Type = data + case "Atomic": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Atomic")) + data, err := ec.unmarshalOAtomicWhereCondition2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAtomicWhereCondition(ctx, v) + if err != nil { + return it, err + } + it.Atomic = data + case "And": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("And")) + data, err := ec.unmarshalOOperationWhereCondition2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐOperationWhereCondition(ctx, v) + if err != nil { + return it, err + } + it.And = data + case "Or": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Or")) + data, err := ec.unmarshalOOperationWhereCondition2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐOperationWhereCondition(ctx, v) + if err != nil { + return it, err + } + it.Or = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputWidgetInput(ctx context.Context, obj any) (model.WidgetInput, error) { + var it model.WidgetInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"Type", "Title", "Description", "Layout", "Query", "QueryContext", "Visualization", "Snapshot", "SortOrder"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "Type": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Type")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Type = data + case "Title": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Title")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Title = data + case "Description": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Description")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Description = data + case "Layout": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Layout")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Layout = data + case "Query": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Query")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Query = data + case "QueryContext": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("QueryContext")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.QueryContext = data + case "Visualization": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Visualization")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Visualization = data + case "Snapshot": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("Snapshot")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Snapshot = data + case "SortOrder": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("SortOrder")) + data, err := ec.unmarshalOInt2ᚖint(ctx, v) + if err != nil { + return it, err + } + it.SortOrder = data + } + } + return it, nil +} + +// endregion **************************** input.gotpl ***************************** + +// region ************************** interface.gotpl *************************** + +func (ec *executionContext) _CloudProvider(ctx context.Context, sel ast.SelectionSet, obj model.CloudProvider) graphql.Marshaler { + switch obj := (obj).(type) { + case nil: + return graphql.Null + case model.AWSProvider: + return ec._AWSProvider(ctx, sel, &obj) + case *model.AWSProvider: + if obj == nil { + return graphql.Null + } + return ec._AWSProvider(ctx, sel, obj) + default: + if typedObj, ok := obj.(graphql.Marshaler); ok { + return typedObj + } else { + panic(fmt.Errorf("unexpected type %T; non-generated variants of CloudProvider must implement graphql.Marshaler", obj)) + } + } +} + +// endregion ************************** interface.gotpl *************************** + +// region **************************** object.gotpl **************************** + +var aIChatMessageImplementors = []string{"AIChatMessage"} + +func (ec *executionContext) _AIChatMessage(ctx context.Context, sel ast.SelectionSet, obj *model.AIChatMessage) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, aIChatMessageImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("AIChatMessage") + case "Type": + out.Values[i] = ec._AIChatMessage_Type(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Result": + out.Values[i] = ec._AIChatMessage_Result(ctx, field, obj) + case "Text": + out.Values[i] = ec._AIChatMessage_Text(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "RequiresConfirmation": + out.Values[i] = ec._AIChatMessage_RequiresConfirmation(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var aIProviderImplementors = []string{"AIProvider"} + +func (ec *executionContext) _AIProvider(ctx context.Context, sel ast.SelectionSet, obj *model.AIProvider) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, aIProviderImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("AIProvider") + case "Type": + out.Values[i] = ec._AIProvider_Type(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Name": + out.Values[i] = ec._AIProvider_Name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "ProviderId": + out.Values[i] = ec._AIProvider_ProviderId(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "IsEnvironmentDefined": + out.Values[i] = ec._AIProvider_IsEnvironmentDefined(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "IsGeneric": + out.Values[i] = ec._AIProvider_IsGeneric(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var aWSProviderImplementors = []string{"AWSProvider", "CloudProvider"} + +func (ec *executionContext) _AWSProvider(ctx context.Context, sel ast.SelectionSet, obj *model.AWSProvider) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, aWSProviderImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("AWSProvider") + case "Id": + out.Values[i] = ec._AWSProvider_Id(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "ProviderType": + out.Values[i] = ec._AWSProvider_ProviderType(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Name": + out.Values[i] = ec._AWSProvider_Name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Region": + out.Values[i] = ec._AWSProvider_Region(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Status": + out.Values[i] = ec._AWSProvider_Status(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "LastDiscoveryAt": + out.Values[i] = ec._AWSProvider_LastDiscoveryAt(ctx, field, obj) + case "DiscoveredCount": + out.Values[i] = ec._AWSProvider_DiscoveredCount(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Error": + out.Values[i] = ec._AWSProvider_Error(ctx, field, obj) + case "ProfileName": + out.Values[i] = ec._AWSProvider_ProfileName(ctx, field, obj) + case "DiscoverRDS": + out.Values[i] = ec._AWSProvider_DiscoverRDS(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "DiscoverElastiCache": + out.Values[i] = ec._AWSProvider_DiscoverElastiCache(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "DiscoverDocumentDB": + out.Values[i] = ec._AWSProvider_DiscoverDocumentDB(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var aWSRegionImplementors = []string{"AWSRegion"} + +func (ec *executionContext) _AWSRegion(ctx context.Context, sel ast.SelectionSet, obj *model.AWSRegion) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, aWSRegionImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("AWSRegion") + case "Id": + out.Values[i] = ec._AWSRegion_Id(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Description": + out.Values[i] = ec._AWSRegion_Description(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Partition": + out.Values[i] = ec._AWSRegion_Partition(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var authSessionPayloadImplementors = []string{"AuthSessionPayload"} + +func (ec *executionContext) _AuthSessionPayload(ctx context.Context, sel ast.SelectionSet, obj *model.AuthSessionPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, authSessionPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("AuthSessionPayload") + case "sessionToken": + out.Values[i] = ec._AuthSessionPayload_sessionToken(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "expiresAt": + out.Values[i] = ec._AuthSessionPayload_expiresAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "type": + out.Values[i] = ec._AuthSessionPayload_type(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "hostname": + out.Values[i] = ec._AuthSessionPayload_hostname(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "port": + out.Values[i] = ec._AuthSessionPayload_port(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "database": + out.Values[i] = ec._AuthSessionPayload_database(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "displayName": + out.Values[i] = ec._AuthSessionPayload_displayName(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "instanceUid": + out.Values[i] = ec._AuthSessionPayload_instanceUid(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var capabilitiesImplementors = []string{"Capabilities"} + +func (ec *executionContext) _Capabilities(ctx context.Context, sel ast.SelectionSet, obj *model.Capabilities) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, capabilitiesImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Capabilities") + case "supportsScratchpad": + out.Values[i] = ec._Capabilities_supportsScratchpad(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "supportsChat": + out.Values[i] = ec._Capabilities_supportsChat(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "supportsGraph": + out.Values[i] = ec._Capabilities_supportsGraph(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "supportsSchema": + out.Values[i] = ec._Capabilities_supportsSchema(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "supportsDatabaseSwitch": + out.Values[i] = ec._Capabilities_supportsDatabaseSwitch(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "supportsModifiers": + out.Values[i] = ec._Capabilities_supportsModifiers(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var collectionImportErrorImplementors = []string{"CollectionImportError"} + +func (ec *executionContext) _CollectionImportError(ctx context.Context, sel ast.SelectionSet, obj *model.CollectionImportError) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, collectionImportErrorImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("CollectionImportError") + case "Index": + out.Values[i] = ec._CollectionImportError_Index(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Reason": + out.Values[i] = ec._CollectionImportError_Reason(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var collectionImportPreviewImplementors = []string{"CollectionImportPreview"} + +func (ec *executionContext) _CollectionImportPreview(ctx context.Context, sel ast.SelectionSet, obj *model.CollectionImportPreview) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, collectionImportPreviewImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("CollectionImportPreview") + case "Format": + out.Values[i] = ec._CollectionImportPreview_Format(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Sheet": + out.Values[i] = ec._CollectionImportPreview_Sheet(ctx, field, obj) + case "Sheets": + out.Values[i] = ec._CollectionImportPreview_Sheets(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Columns": + out.Values[i] = ec._CollectionImportPreview_Columns(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Rows": + out.Values[i] = ec._CollectionImportPreview_Rows(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Documents": + out.Values[i] = ec._CollectionImportPreview_Documents(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Count": + out.Values[i] = ec._CollectionImportPreview_Count(ctx, field, obj) + case "Truncated": + out.Values[i] = ec._CollectionImportPreview_Truncated(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "ValidationError": + out.Values[i] = ec._CollectionImportPreview_ValidationError(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var collectionImportResultImplementors = []string{"CollectionImportResult"} + +func (ec *executionContext) _CollectionImportResult(ctx context.Context, sel ast.SelectionSet, obj *model.CollectionImportResult) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, collectionImportResultImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("CollectionImportResult") + case "Status": + out.Values[i] = ec._CollectionImportResult_Status(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "ImportedCount": + out.Values[i] = ec._CollectionImportResult_ImportedCount(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "SkippedCount": + out.Values[i] = ec._CollectionImportResult_SkippedCount(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "MatchedCount": + out.Values[i] = ec._CollectionImportResult_MatchedCount(ctx, field, obj) + case "ModifiedCount": + out.Values[i] = ec._CollectionImportResult_ModifiedCount(ctx, field, obj) + case "UpsertedCount": + out.Values[i] = ec._CollectionImportResult_UpsertedCount(ctx, field, obj) + case "Errors": + out.Values[i] = ec._CollectionImportResult_Errors(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Message": + out.Values[i] = ec._CollectionImportResult_Message(ctx, field, obj) + case "Detail": + out.Values[i] = ec._CollectionImportResult_Detail(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var columnImplementors = []string{"Column"} + +func (ec *executionContext) _Column(ctx context.Context, sel ast.SelectionSet, obj *model.Column) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, columnImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Column") + case "Type": + out.Values[i] = ec._Column_Type(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Name": + out.Values[i] = ec._Column_Name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "IsPrimary": + out.Values[i] = ec._Column_IsPrimary(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "IsForeignKey": + out.Values[i] = ec._Column_IsForeignKey(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "ReferencedTable": + out.Values[i] = ec._Column_ReferencedTable(ctx, field, obj) + case "ReferencedColumn": + out.Values[i] = ec._Column_ReferencedColumn(ctx, field, obj) + case "Length": + out.Values[i] = ec._Column_Length(ctx, field, obj) + case "Precision": + out.Values[i] = ec._Column_Precision(ctx, field, obj) + case "Scale": + out.Values[i] = ec._Column_Scale(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var dashboardImplementors = []string{"Dashboard"} + +func (ec *executionContext) _Dashboard(ctx context.Context, sel ast.SelectionSet, obj *model.Dashboard) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, dashboardImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Dashboard") + case "ID": + out.Values[i] = ec._Dashboard_ID(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Name": + out.Values[i] = ec._Dashboard_Name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Description": + out.Values[i] = ec._Dashboard_Description(ctx, field, obj) + case "RefreshRule": + out.Values[i] = ec._Dashboard_RefreshRule(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Widgets": + out.Values[i] = ec._Dashboard_Widgets(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "CreatedAt": + out.Values[i] = ec._Dashboard_CreatedAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "UpdatedAt": + out.Values[i] = ec._Dashboard_UpdatedAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var dashboardWidgetImplementors = []string{"DashboardWidget"} + +func (ec *executionContext) _DashboardWidget(ctx context.Context, sel ast.SelectionSet, obj *model.DashboardWidget) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, dashboardWidgetImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("DashboardWidget") + case "ID": + out.Values[i] = ec._DashboardWidget_ID(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Type": + out.Values[i] = ec._DashboardWidget_Type(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Title": + out.Values[i] = ec._DashboardWidget_Title(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Description": + out.Values[i] = ec._DashboardWidget_Description(ctx, field, obj) + case "Layout": + out.Values[i] = ec._DashboardWidget_Layout(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Query": + out.Values[i] = ec._DashboardWidget_Query(ctx, field, obj) + case "QueryContext": + out.Values[i] = ec._DashboardWidget_QueryContext(ctx, field, obj) + case "Visualization": + out.Values[i] = ec._DashboardWidget_Visualization(ctx, field, obj) + case "Snapshot": + out.Values[i] = ec._DashboardWidget_Snapshot(ctx, field, obj) + case "SortOrder": + out.Values[i] = ec._DashboardWidget_SortOrder(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var databaseMetadataImplementors = []string{"DatabaseMetadata"} + +func (ec *executionContext) _DatabaseMetadata(ctx context.Context, sel ast.SelectionSet, obj *model.DatabaseMetadata) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, databaseMetadataImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("DatabaseMetadata") + case "databaseType": + out.Values[i] = ec._DatabaseMetadata_databaseType(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "typeDefinitions": + out.Values[i] = ec._DatabaseMetadata_typeDefinitions(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "operators": + out.Values[i] = ec._DatabaseMetadata_operators(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "aliasMap": + out.Values[i] = ec._DatabaseMetadata_aliasMap(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "capabilities": + out.Values[i] = ec._DatabaseMetadata_capabilities(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "systemSchemas": + out.Values[i] = ec._DatabaseMetadata_systemSchemas(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var databaseQuerySuggestionImplementors = []string{"DatabaseQuerySuggestion"} + +func (ec *executionContext) _DatabaseQuerySuggestion(ctx context.Context, sel ast.SelectionSet, obj *model.DatabaseQuerySuggestion) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, databaseQuerySuggestionImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("DatabaseQuerySuggestion") + case "description": + out.Values[i] = ec._DatabaseQuerySuggestion_description(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "category": + out.Values[i] = ec._DatabaseQuerySuggestion_category(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var discoveredConnectionImplementors = []string{"DiscoveredConnection"} + +func (ec *executionContext) _DiscoveredConnection(ctx context.Context, sel ast.SelectionSet, obj *model.DiscoveredConnection) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, discoveredConnectionImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("DiscoveredConnection") + case "Id": + out.Values[i] = ec._DiscoveredConnection_Id(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "ProviderType": + out.Values[i] = ec._DiscoveredConnection_ProviderType(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "ProviderID": + out.Values[i] = ec._DiscoveredConnection_ProviderID(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Name": + out.Values[i] = ec._DiscoveredConnection_Name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "DatabaseType": + out.Values[i] = ec._DiscoveredConnection_DatabaseType(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Region": + out.Values[i] = ec._DiscoveredConnection_Region(ctx, field, obj) + case "Status": + out.Values[i] = ec._DiscoveredConnection_Status(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Metadata": + out.Values[i] = ec._DiscoveredConnection_Metadata(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var exportDownloadImplementors = []string{"ExportDownload"} + +func (ec *executionContext) _ExportDownload(ctx context.Context, sel ast.SelectionSet, obj *model.ExportDownload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, exportDownloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ExportDownload") + case "ID": + out.Values[i] = ec._ExportDownload_ID(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Filename": + out.Values[i] = ec._ExportDownload_Filename(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "ContentType": + out.Values[i] = ec._ExportDownload_ContentType(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "DownloadURL": + out.Values[i] = ec._ExportDownload_DownloadURL(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Size": + out.Values[i] = ec._ExportDownload_Size(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var generateChatTitleResponseImplementors = []string{"GenerateChatTitleResponse"} + +func (ec *executionContext) _GenerateChatTitleResponse(ctx context.Context, sel ast.SelectionSet, obj *model.GenerateChatTitleResponse) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, generateChatTitleResponseImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("GenerateChatTitleResponse") + case "Title": + out.Values[i] = ec._GenerateChatTitleResponse_Title(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var graphUnitImplementors = []string{"GraphUnit"} + +func (ec *executionContext) _GraphUnit(ctx context.Context, sel ast.SelectionSet, obj *model.GraphUnit) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, graphUnitImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("GraphUnit") + case "Unit": + out.Values[i] = ec._GraphUnit_Unit(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Relations": + out.Values[i] = ec._GraphUnit_Relations(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var graphUnitRelationshipImplementors = []string{"GraphUnitRelationship"} + +func (ec *executionContext) _GraphUnitRelationship(ctx context.Context, sel ast.SelectionSet, obj *model.GraphUnitRelationship) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, graphUnitRelationshipImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("GraphUnitRelationship") + case "Name": + out.Values[i] = ec._GraphUnitRelationship_Name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Relationship": + out.Values[i] = ec._GraphUnitRelationship_Relationship(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "SourceColumn": + out.Values[i] = ec._GraphUnitRelationship_SourceColumn(ctx, field, obj) + case "TargetColumn": + out.Values[i] = ec._GraphUnitRelationship_TargetColumn(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var healthStatusImplementors = []string{"HealthStatus"} + +func (ec *executionContext) _HealthStatus(ctx context.Context, sel ast.SelectionSet, obj *model.HealthStatus) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, healthStatusImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("HealthStatus") + case "Server": + out.Values[i] = ec._HealthStatus_Server(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Database": + out.Values[i] = ec._HealthStatus_Database(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var importColumnMappingPreviewImplementors = []string{"ImportColumnMappingPreview"} + +func (ec *executionContext) _ImportColumnMappingPreview(ctx context.Context, sel ast.SelectionSet, obj *model.ImportColumnMappingPreview) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, importColumnMappingPreviewImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ImportColumnMappingPreview") + case "SourceColumn": + out.Values[i] = ec._ImportColumnMappingPreview_SourceColumn(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "TargetColumn": + out.Values[i] = ec._ImportColumnMappingPreview_TargetColumn(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var importNewTableColumnPreviewImplementors = []string{"ImportNewTableColumnPreview"} + +func (ec *executionContext) _ImportNewTableColumnPreview(ctx context.Context, sel ast.SelectionSet, obj *model.ImportNewTableColumnPreview) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, importNewTableColumnPreviewImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ImportNewTableColumnPreview") + case "SourceColumn": + out.Values[i] = ec._ImportNewTableColumnPreview_SourceColumn(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "TargetColumn": + out.Values[i] = ec._ImportNewTableColumnPreview_TargetColumn(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Type": + out.Values[i] = ec._ImportNewTableColumnPreview_Type(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Nullable": + out.Values[i] = ec._ImportNewTableColumnPreview_Nullable(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Primary": + out.Values[i] = ec._ImportNewTableColumnPreview_Primary(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Skip": + out.Values[i] = ec._ImportNewTableColumnPreview_Skip(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var importNewTablePreviewImplementors = []string{"ImportNewTablePreview"} + +func (ec *executionContext) _ImportNewTablePreview(ctx context.Context, sel ast.SelectionSet, obj *model.ImportNewTablePreview) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, importNewTablePreviewImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ImportNewTablePreview") + case "TableName": + out.Values[i] = ec._ImportNewTablePreview_TableName(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Columns": + out.Values[i] = ec._ImportNewTablePreview_Columns(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Issues": + out.Values[i] = ec._ImportNewTablePreview_Issues(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var importPreviewImplementors = []string{"ImportPreview"} + +func (ec *executionContext) _ImportPreview(ctx context.Context, sel ast.SelectionSet, obj *model.ImportPreview) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, importPreviewImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ImportPreview") + case "Sheet": + out.Values[i] = ec._ImportPreview_Sheet(ctx, field, obj) + case "Sheets": + out.Values[i] = ec._ImportPreview_Sheets(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Columns": + out.Values[i] = ec._ImportPreview_Columns(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Rows": + out.Values[i] = ec._ImportPreview_Rows(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Truncated": + out.Values[i] = ec._ImportPreview_Truncated(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "ValidationError": + out.Values[i] = ec._ImportPreview_ValidationError(ctx, field, obj) + case "Mapping": + out.Values[i] = ec._ImportPreview_Mapping(ctx, field, obj) + case "RequiresAllowAutoGenerated": + out.Values[i] = ec._ImportPreview_RequiresAllowAutoGenerated(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "AutoGeneratedColumns": + out.Values[i] = ec._ImportPreview_AutoGeneratedColumns(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "NewTable": + out.Values[i] = ec._ImportPreview_NewTable(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var importResultImplementors = []string{"ImportResult"} + +func (ec *executionContext) _ImportResult(ctx context.Context, sel ast.SelectionSet, obj *model.ImportResult) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, importResultImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ImportResult") + case "Status": + out.Values[i] = ec._ImportResult_Status(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Message": + out.Values[i] = ec._ImportResult_Message(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Detail": + out.Values[i] = ec._ImportResult_Detail(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var importSuggestionIssueImplementors = []string{"ImportSuggestionIssue"} + +func (ec *executionContext) _ImportSuggestionIssue(ctx context.Context, sel ast.SelectionSet, obj *model.ImportSuggestionIssue) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, importSuggestionIssueImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ImportSuggestionIssue") + case "Key": + out.Values[i] = ec._ImportSuggestionIssue_Key(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Field": + out.Values[i] = ec._ImportSuggestionIssue_Field(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "SourceColumn": + out.Values[i] = ec._ImportSuggestionIssue_SourceColumn(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var localAWSProfileImplementors = []string{"LocalAWSProfile"} + +func (ec *executionContext) _LocalAWSProfile(ctx context.Context, sel ast.SelectionSet, obj *model.LocalAWSProfile) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, localAWSProfileImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("LocalAWSProfile") + case "Name": + out.Values[i] = ec._LocalAWSProfile_Name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Region": + out.Values[i] = ec._LocalAWSProfile_Region(ctx, field, obj) + case "Source": + out.Values[i] = ec._LocalAWSProfile_Source(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "AuthType": + out.Values[i] = ec._LocalAWSProfile_AuthType(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "IsDefault": + out.Values[i] = ec._LocalAWSProfile_IsDefault(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var loginProfileImplementors = []string{"LoginProfile"} + +func (ec *executionContext) _LoginProfile(ctx context.Context, sel ast.SelectionSet, obj *model.LoginProfile) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, loginProfileImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("LoginProfile") + case "Alias": + out.Values[i] = ec._LoginProfile_Alias(ctx, field, obj) + case "Id": + out.Values[i] = ec._LoginProfile_Id(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Type": + out.Values[i] = ec._LoginProfile_Type(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Hostname": + out.Values[i] = ec._LoginProfile_Hostname(ctx, field, obj) + case "Database": + out.Values[i] = ec._LoginProfile_Database(ctx, field, obj) + case "IsEnvironmentDefined": + out.Values[i] = ec._LoginProfile_IsEnvironmentDefined(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Source": + out.Values[i] = ec._LoginProfile_Source(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "SSLConfigured": + out.Values[i] = ec._LoginProfile_SSLConfigured(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var mockDataDependencyAnalysisImplementors = []string{"MockDataDependencyAnalysis"} + +func (ec *executionContext) _MockDataDependencyAnalysis(ctx context.Context, sel ast.SelectionSet, obj *model.MockDataDependencyAnalysis) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, mockDataDependencyAnalysisImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("MockDataDependencyAnalysis") + case "GenerationOrder": + out.Values[i] = ec._MockDataDependencyAnalysis_GenerationOrder(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Tables": + out.Values[i] = ec._MockDataDependencyAnalysis_Tables(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "TotalRows": + out.Values[i] = ec._MockDataDependencyAnalysis_TotalRows(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Warnings": + out.Values[i] = ec._MockDataDependencyAnalysis_Warnings(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Error": + out.Values[i] = ec._MockDataDependencyAnalysis_Error(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var mockDataGenerationStatusImplementors = []string{"MockDataGenerationStatus"} + +func (ec *executionContext) _MockDataGenerationStatus(ctx context.Context, sel ast.SelectionSet, obj *model.MockDataGenerationStatus) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, mockDataGenerationStatusImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("MockDataGenerationStatus") + case "AmountGenerated": + out.Values[i] = ec._MockDataGenerationStatus_AmountGenerated(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Details": + out.Values[i] = ec._MockDataGenerationStatus_Details(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var mockDataTableDetailImplementors = []string{"MockDataTableDetail"} + +func (ec *executionContext) _MockDataTableDetail(ctx context.Context, sel ast.SelectionSet, obj *model.MockDataTableDetail) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, mockDataTableDetailImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("MockDataTableDetail") + case "Table": + out.Values[i] = ec._MockDataTableDetail_Table(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "RowsGenerated": + out.Values[i] = ec._MockDataTableDetail_RowsGenerated(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "UsedExistingData": + out.Values[i] = ec._MockDataTableDetail_UsedExistingData(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var mockDataTableInfoImplementors = []string{"MockDataTableInfo"} + +func (ec *executionContext) _MockDataTableInfo(ctx context.Context, sel ast.SelectionSet, obj *model.MockDataTableInfo) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, mockDataTableInfoImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("MockDataTableInfo") + case "Table": + out.Values[i] = ec._MockDataTableInfo_Table(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "RowsToGenerate": + out.Values[i] = ec._MockDataTableInfo_RowsToGenerate(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "IsBlocked": + out.Values[i] = ec._MockDataTableInfo_IsBlocked(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "UsesExistingData": + out.Values[i] = ec._MockDataTableInfo_UsesExistingData(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var mutationImplementors = []string{"Mutation"} + +func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, mutationImplementors) + ctx = graphql.WithFieldContext(ctx, &graphql.FieldContext{ + Object: "Mutation", + }) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + innerCtx := graphql.WithRootFieldContext(ctx, &graphql.RootFieldContext{ + Object: field.Name, + Field: field, + }) + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Mutation") + case "BootstrapSealosSession": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_BootstrapSealosSession(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "CreateStandaloneSession": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_CreateStandaloneSession(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Login": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_Login(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "LoginWithProfile": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_LoginWithProfile(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Logout": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_Logout(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "UpdateSettings": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_UpdateSettings(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "CreateDashboard": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_CreateDashboard(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "UpdateDashboard": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_UpdateDashboard(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "DeleteDashboard": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_DeleteDashboard(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "AddWidget": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_AddWidget(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "UpdateWidget": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_UpdateWidget(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "DeleteWidget": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_DeleteWidget(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "UpdateWidgetLayouts": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_UpdateWidgetLayouts(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "UpdateWidgetSnapshot": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_UpdateWidgetSnapshot(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "AddStorageUnit": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_AddStorageUnit(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "UpdateStorageUnit": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_UpdateStorageUnit(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "ReplaceRow": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_ReplaceRow(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "AddRow": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_AddRow(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "DeleteRow": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_DeleteRow(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "CreateSQLDataExport": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_CreateSQLDataExport(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "GenerateMockData": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_GenerateMockData(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "ImportSQL": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_ImportSQL(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "ImportPreview": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_ImportPreview(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "ImportTableFile": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_ImportTableFile(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "ImportCollectionPreview": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_ImportCollectionPreview(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "ImportCollectionFile": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_ImportCollectionFile(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "ExecuteConfirmedSQL": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_ExecuteConfirmedSQL(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "GenerateChatTitle": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_GenerateChatTitle(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "AddAWSProvider": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_AddAWSProvider(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "UpdateAWSProvider": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_UpdateAWSProvider(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "TestAWSCredentials": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_TestAWSCredentials(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "RemoveCloudProvider": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_RemoveCloudProvider(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "TestCloudProvider": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_TestCloudProvider(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "RefreshCloudProvider": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_RefreshCloudProvider(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "GenerateRDSAuthToken": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_GenerateRDSAuthToken(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var queryImplementors = []string{"Query"} + +func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, queryImplementors) + ctx = graphql.WithFieldContext(ctx, &graphql.FieldContext{ + Object: "Query", + }) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + innerCtx := graphql.WithRootFieldContext(ctx, &graphql.RootFieldContext{ + Object: field.Name, + Field: field, + }) + + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Query") + case "ResolveSealosInstanceIdentity": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_ResolveSealosInstanceIdentity(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "Version": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_Version(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "UpdateInfo": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_UpdateInfo(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "Health": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_Health(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "GetDashboards": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_GetDashboards(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "Profiles": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_Profiles(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "Database": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_Database(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "Schema": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_Schema(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "StorageUnit": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_StorageUnit(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "Row": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_Row(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "Columns": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_Columns(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "ColumnsBatch": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_ColumnsBatch(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "RawExecute": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_RawExecute(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "Graph": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_Graph(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "AIProviders": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_AIProviders(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "AIModel": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_AIModel(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "AIChat": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_AIChat(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "SettingsConfig": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_SettingsConfig(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "MockDataMaxRowCount": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_MockDataMaxRowCount(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "AnalyzeMockDataDependencies": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_AnalyzeMockDataDependencies(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "DatabaseMetadata": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_DatabaseMetadata(ctx, field) + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "SSLStatus": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_SSLStatus(ctx, field) + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "DatabaseQuerySuggestions": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_DatabaseQuerySuggestions(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "CloudProviders": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_CloudProviders(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "CloudProvider": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_CloudProvider(ctx, field) + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "DiscoveredConnections": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_DiscoveredConnections(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "ProviderConnections": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_ProviderConnections(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "LocalAWSProfiles": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_LocalAWSProfiles(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "AWSRegions": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_AWSRegions(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "__type": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Query___type(ctx, field) + }) + case "__schema": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Query___schema(ctx, field) + }) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var recordImplementors = []string{"Record"} + +func (ec *executionContext) _Record(ctx context.Context, sel ast.SelectionSet, obj *model.Record) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, recordImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("Record") + case "Key": + out.Values[i] = ec._Record_Key(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Value": + out.Values[i] = ec._Record_Value(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var rowsResultImplementors = []string{"RowsResult"} + +func (ec *executionContext) _RowsResult(ctx context.Context, sel ast.SelectionSet, obj *model.RowsResult) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, rowsResultImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("RowsResult") + case "Columns": + out.Values[i] = ec._RowsResult_Columns(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Rows": + out.Values[i] = ec._RowsResult_Rows(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "DisableUpdate": + out.Values[i] = ec._RowsResult_DisableUpdate(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "TotalCount": + out.Values[i] = ec._RowsResult_TotalCount(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var sSLStatusImplementors = []string{"SSLStatus"} + +func (ec *executionContext) _SSLStatus(ctx context.Context, sel ast.SelectionSet, obj *model.SSLStatus) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, sSLStatusImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("SSLStatus") + case "IsEnabled": + out.Values[i] = ec._SSLStatus_IsEnabled(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Mode": + out.Values[i] = ec._SSLStatus_Mode(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var sealosInstanceIdentityImplementors = []string{"SealosInstanceIdentity"} + +func (ec *executionContext) _SealosInstanceIdentity(ctx context.Context, sel ast.SelectionSet, obj *model.SealosInstanceIdentity) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, sealosInstanceIdentityImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("SealosInstanceIdentity") + case "uid": + out.Values[i] = ec._SealosInstanceIdentity_uid(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "namespace": + out.Values[i] = ec._SealosInstanceIdentity_namespace(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "resourceName": + out.Values[i] = ec._SealosInstanceIdentity_resourceName(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var settingsConfigImplementors = []string{"SettingsConfig"} + +func (ec *executionContext) _SettingsConfig(ctx context.Context, sel ast.SelectionSet, obj *model.SettingsConfig) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, settingsConfigImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("SettingsConfig") + case "MetricsEnabled": + out.Values[i] = ec._SettingsConfig_MetricsEnabled(ctx, field, obj) + case "CloudProvidersEnabled": + out.Values[i] = ec._SettingsConfig_CloudProvidersEnabled(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "DisableCredentialForm": + out.Values[i] = ec._SettingsConfig_DisableCredentialForm(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "StandaloneLoginEnabled": + out.Values[i] = ec._SettingsConfig_StandaloneLoginEnabled(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "MaxPageSize": + out.Values[i] = ec._SettingsConfig_MaxPageSize(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var statusResponseImplementors = []string{"StatusResponse"} + +func (ec *executionContext) _StatusResponse(ctx context.Context, sel ast.SelectionSet, obj *model.StatusResponse) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, statusResponseImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("StatusResponse") + case "Status": + out.Values[i] = ec._StatusResponse_Status(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var storageUnitImplementors = []string{"StorageUnit"} + +func (ec *executionContext) _StorageUnit(ctx context.Context, sel ast.SelectionSet, obj *model.StorageUnit) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, storageUnitImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("StorageUnit") + case "Name": + out.Values[i] = ec._StorageUnit_Name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Attributes": + out.Values[i] = ec._StorageUnit_Attributes(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "IsMockDataGenerationAllowed": + out.Values[i] = ec._StorageUnit_IsMockDataGenerationAllowed(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var storageUnitColumnsImplementors = []string{"StorageUnitColumns"} + +func (ec *executionContext) _StorageUnitColumns(ctx context.Context, sel ast.SelectionSet, obj *model.StorageUnitColumns) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, storageUnitColumnsImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("StorageUnitColumns") + case "StorageUnit": + out.Values[i] = ec._StorageUnitColumns_StorageUnit(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "Columns": + out.Values[i] = ec._StorageUnitColumns_Columns(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var typeDefinitionImplementors = []string{"TypeDefinition"} + +func (ec *executionContext) _TypeDefinition(ctx context.Context, sel ast.SelectionSet, obj *model.TypeDefinition) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, typeDefinitionImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("TypeDefinition") + case "id": + out.Values[i] = ec._TypeDefinition_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "label": + out.Values[i] = ec._TypeDefinition_label(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "hasLength": + out.Values[i] = ec._TypeDefinition_hasLength(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "hasPrecision": + out.Values[i] = ec._TypeDefinition_hasPrecision(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "defaultLength": + out.Values[i] = ec._TypeDefinition_defaultLength(ctx, field, obj) + case "defaultPrecision": + out.Values[i] = ec._TypeDefinition_defaultPrecision(ctx, field, obj) + case "category": + out.Values[i] = ec._TypeDefinition_category(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var updateInfoImplementors = []string{"UpdateInfo"} + +func (ec *executionContext) _UpdateInfo(ctx context.Context, sel ast.SelectionSet, obj *model.UpdateInfo) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, updateInfoImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("UpdateInfo") + case "currentVersion": + out.Values[i] = ec._UpdateInfo_currentVersion(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "latestVersion": + out.Values[i] = ec._UpdateInfo_latestVersion(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "updateAvailable": + out.Values[i] = ec._UpdateInfo_updateAvailable(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "releaseURL": + out.Values[i] = ec._UpdateInfo_releaseURL(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var __DirectiveImplementors = []string{"__Directive"} + +func (ec *executionContext) ___Directive(ctx context.Context, sel ast.SelectionSet, obj *introspection.Directive) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, __DirectiveImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__Directive") + case "name": + out.Values[i] = ec.___Directive_name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "description": + out.Values[i] = ec.___Directive_description(ctx, field, obj) + case "isRepeatable": + out.Values[i] = ec.___Directive_isRepeatable(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "locations": + out.Values[i] = ec.___Directive_locations(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "args": + out.Values[i] = ec.___Directive_args(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var __EnumValueImplementors = []string{"__EnumValue"} + +func (ec *executionContext) ___EnumValue(ctx context.Context, sel ast.SelectionSet, obj *introspection.EnumValue) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, __EnumValueImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__EnumValue") + case "name": + out.Values[i] = ec.___EnumValue_name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "description": + out.Values[i] = ec.___EnumValue_description(ctx, field, obj) + case "isDeprecated": + out.Values[i] = ec.___EnumValue_isDeprecated(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "deprecationReason": + out.Values[i] = ec.___EnumValue_deprecationReason(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var __FieldImplementors = []string{"__Field"} + +func (ec *executionContext) ___Field(ctx context.Context, sel ast.SelectionSet, obj *introspection.Field) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, __FieldImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__Field") + case "name": + out.Values[i] = ec.___Field_name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "description": + out.Values[i] = ec.___Field_description(ctx, field, obj) + case "args": + out.Values[i] = ec.___Field_args(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "type": + out.Values[i] = ec.___Field_type(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "isDeprecated": + out.Values[i] = ec.___Field_isDeprecated(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "deprecationReason": + out.Values[i] = ec.___Field_deprecationReason(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var __InputValueImplementors = []string{"__InputValue"} + +func (ec *executionContext) ___InputValue(ctx context.Context, sel ast.SelectionSet, obj *introspection.InputValue) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, __InputValueImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__InputValue") + case "name": + out.Values[i] = ec.___InputValue_name(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "description": + out.Values[i] = ec.___InputValue_description(ctx, field, obj) + case "type": + out.Values[i] = ec.___InputValue_type(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "defaultValue": + out.Values[i] = ec.___InputValue_defaultValue(ctx, field, obj) + case "isDeprecated": + out.Values[i] = ec.___InputValue_isDeprecated(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "deprecationReason": + out.Values[i] = ec.___InputValue_deprecationReason(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var __SchemaImplementors = []string{"__Schema"} + +func (ec *executionContext) ___Schema(ctx context.Context, sel ast.SelectionSet, obj *introspection.Schema) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, __SchemaImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__Schema") + case "description": + out.Values[i] = ec.___Schema_description(ctx, field, obj) + case "types": + out.Values[i] = ec.___Schema_types(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "queryType": + out.Values[i] = ec.___Schema_queryType(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "mutationType": + out.Values[i] = ec.___Schema_mutationType(ctx, field, obj) + case "subscriptionType": + out.Values[i] = ec.___Schema_subscriptionType(ctx, field, obj) + case "directives": + out.Values[i] = ec.___Schema_directives(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var __TypeImplementors = []string{"__Type"} + +func (ec *executionContext) ___Type(ctx context.Context, sel ast.SelectionSet, obj *introspection.Type) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, __TypeImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("__Type") + case "kind": + out.Values[i] = ec.___Type_kind(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "name": + out.Values[i] = ec.___Type_name(ctx, field, obj) + case "description": + out.Values[i] = ec.___Type_description(ctx, field, obj) + case "specifiedByURL": + out.Values[i] = ec.___Type_specifiedByURL(ctx, field, obj) + case "fields": + out.Values[i] = ec.___Type_fields(ctx, field, obj) + case "interfaces": + out.Values[i] = ec.___Type_interfaces(ctx, field, obj) + case "possibleTypes": + out.Values[i] = ec.___Type_possibleTypes(ctx, field, obj) + case "enumValues": + out.Values[i] = ec.___Type_enumValues(ctx, field, obj) + case "inputFields": + out.Values[i] = ec.___Type_inputFields(ctx, field, obj) + case "ofType": + out.Values[i] = ec.___Type_ofType(ctx, field, obj) + case "isOneOf": + out.Values[i] = ec.___Type_isOneOf(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +// endregion **************************** object.gotpl **************************** + +// region ***************************** type.gotpl ***************************** + +func (ec *executionContext) marshalNAIChatMessage2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAIChatMessage(ctx context.Context, sel ast.SelectionSet, v model.AIChatMessage) graphql.Marshaler { + return ec._AIChatMessage(ctx, sel, &v) +} + +func (ec *executionContext) marshalNAIChatMessage2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAIChatMessageᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.AIChatMessage) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNAIChatMessage2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAIChatMessage(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNAIChatMessage2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAIChatMessage(ctx context.Context, sel ast.SelectionSet, v *model.AIChatMessage) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._AIChatMessage(ctx, sel, v) +} + +func (ec *executionContext) marshalNAIProvider2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAIProviderᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.AIProvider) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNAIProvider2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAIProvider(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNAIProvider2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAIProvider(ctx context.Context, sel ast.SelectionSet, v *model.AIProvider) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._AIProvider(ctx, sel, v) +} + +func (ec *executionContext) marshalNAWSProvider2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSProvider(ctx context.Context, sel ast.SelectionSet, v model.AWSProvider) graphql.Marshaler { + return ec._AWSProvider(ctx, sel, &v) +} + +func (ec *executionContext) marshalNAWSProvider2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSProviderᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.AWSProvider) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNAWSProvider2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSProvider(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNAWSProvider2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSProvider(ctx context.Context, sel ast.SelectionSet, v *model.AWSProvider) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._AWSProvider(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNAWSProviderInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSProviderInput(ctx context.Context, v any) (model.AWSProviderInput, error) { + res, err := ec.unmarshalInputAWSProviderInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNAWSRegion2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSRegionᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.AWSRegion) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNAWSRegion2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSRegion(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNAWSRegion2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSRegion(ctx context.Context, sel ast.SelectionSet, v *model.AWSRegion) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._AWSRegion(ctx, sel, v) +} + +func (ec *executionContext) marshalNAuthSessionPayload2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAuthSessionPayload(ctx context.Context, sel ast.SelectionSet, v model.AuthSessionPayload) graphql.Marshaler { + return ec._AuthSessionPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNAuthSessionPayload2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAuthSessionPayload(ctx context.Context, sel ast.SelectionSet, v *model.AuthSessionPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._AuthSessionPayload(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNBoolean2bool(ctx context.Context, v any) (bool, error) { + res, err := graphql.UnmarshalBoolean(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNBoolean2bool(ctx context.Context, sel ast.SelectionSet, v bool) graphql.Marshaler { + _ = sel + res := graphql.MarshalBoolean(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) marshalNCapabilities2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCapabilities(ctx context.Context, sel ast.SelectionSet, v *model.Capabilities) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._Capabilities(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNChatInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐChatInput(ctx context.Context, v any) (model.ChatInput, error) { + res, err := ec.unmarshalInputChatInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalNCloudProviderStatus2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCloudProviderStatus(ctx context.Context, v any) (model.CloudProviderStatus, error) { + var res model.CloudProviderStatus + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNCloudProviderStatus2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCloudProviderStatus(ctx context.Context, sel ast.SelectionSet, v model.CloudProviderStatus) graphql.Marshaler { + return v +} + +func (ec *executionContext) unmarshalNCloudProviderType2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCloudProviderType(ctx context.Context, v any) (model.CloudProviderType, error) { + var res model.CloudProviderType + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNCloudProviderType2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCloudProviderType(ctx context.Context, sel ast.SelectionSet, v model.CloudProviderType) graphql.Marshaler { + return v +} + +func (ec *executionContext) marshalNCollectionImportError2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCollectionImportErrorᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.CollectionImportError) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNCollectionImportError2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCollectionImportError(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNCollectionImportError2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCollectionImportError(ctx context.Context, sel ast.SelectionSet, v *model.CollectionImportError) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._CollectionImportError(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNCollectionImportFormat2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCollectionImportFormat(ctx context.Context, v any) (model.CollectionImportFormat, error) { + var res model.CollectionImportFormat + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNCollectionImportFormat2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCollectionImportFormat(ctx context.Context, sel ast.SelectionSet, v model.CollectionImportFormat) graphql.Marshaler { + return v +} + +func (ec *executionContext) marshalNCollectionImportPreview2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCollectionImportPreview(ctx context.Context, sel ast.SelectionSet, v model.CollectionImportPreview) graphql.Marshaler { + return ec._CollectionImportPreview(ctx, sel, &v) +} + +func (ec *executionContext) marshalNCollectionImportPreview2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCollectionImportPreview(ctx context.Context, sel ast.SelectionSet, v *model.CollectionImportPreview) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._CollectionImportPreview(ctx, sel, v) +} + +func (ec *executionContext) marshalNCollectionImportResult2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCollectionImportResult(ctx context.Context, sel ast.SelectionSet, v model.CollectionImportResult) graphql.Marshaler { + return ec._CollectionImportResult(ctx, sel, &v) +} + +func (ec *executionContext) marshalNCollectionImportResult2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐCollectionImportResult(ctx context.Context, sel ast.SelectionSet, v *model.CollectionImportResult) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._CollectionImportResult(ctx, sel, v) +} + +func (ec *executionContext) marshalNColumn2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐColumnᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.Column) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNColumn2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐColumn(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNColumn2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐColumn(ctx context.Context, sel ast.SelectionSet, v *model.Column) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._Column(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNConnectionStatus2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐConnectionStatus(ctx context.Context, v any) (model.ConnectionStatus, error) { + var res model.ConnectionStatus + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNConnectionStatus2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐConnectionStatus(ctx context.Context, sel ast.SelectionSet, v model.ConnectionStatus) graphql.Marshaler { + return v +} + +func (ec *executionContext) marshalNDashboard2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDashboard(ctx context.Context, sel ast.SelectionSet, v model.Dashboard) graphql.Marshaler { + return ec._Dashboard(ctx, sel, &v) +} + +func (ec *executionContext) marshalNDashboard2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDashboardᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.Dashboard) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNDashboard2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDashboard(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNDashboard2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDashboard(ctx context.Context, sel ast.SelectionSet, v *model.Dashboard) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._Dashboard(ctx, sel, v) +} + +func (ec *executionContext) marshalNDashboardWidget2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDashboardWidget(ctx context.Context, sel ast.SelectionSet, v model.DashboardWidget) graphql.Marshaler { + return ec._DashboardWidget(ctx, sel, &v) +} + +func (ec *executionContext) marshalNDashboardWidget2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDashboardWidgetᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.DashboardWidget) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNDashboardWidget2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDashboardWidget(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNDashboardWidget2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDashboardWidget(ctx context.Context, sel ast.SelectionSet, v *model.DashboardWidget) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._DashboardWidget(ctx, sel, v) +} + +func (ec *executionContext) marshalNDatabaseQuerySuggestion2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDatabaseQuerySuggestionᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.DatabaseQuerySuggestion) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNDatabaseQuerySuggestion2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDatabaseQuerySuggestion(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNDatabaseQuerySuggestion2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDatabaseQuerySuggestion(ctx context.Context, sel ast.SelectionSet, v *model.DatabaseQuerySuggestion) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._DatabaseQuerySuggestion(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNDatabaseType2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDatabaseType(ctx context.Context, v any) (model.DatabaseType, error) { + var res model.DatabaseType + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNDatabaseType2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDatabaseType(ctx context.Context, sel ast.SelectionSet, v model.DatabaseType) graphql.Marshaler { + return v +} + +func (ec *executionContext) marshalNDiscoveredConnection2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDiscoveredConnectionᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.DiscoveredConnection) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNDiscoveredConnection2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDiscoveredConnection(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNDiscoveredConnection2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDiscoveredConnection(ctx context.Context, sel ast.SelectionSet, v *model.DiscoveredConnection) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._DiscoveredConnection(ctx, sel, v) +} + +func (ec *executionContext) marshalNExportDownload2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐExportDownload(ctx context.Context, sel ast.SelectionSet, v model.ExportDownload) graphql.Marshaler { + return ec._ExportDownload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNExportDownload2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐExportDownload(ctx context.Context, sel ast.SelectionSet, v *model.ExportDownload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._ExportDownload(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNGenerateChatTitleInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐGenerateChatTitleInput(ctx context.Context, v any) (model.GenerateChatTitleInput, error) { + res, err := ec.unmarshalInputGenerateChatTitleInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNGenerateChatTitleResponse2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐGenerateChatTitleResponse(ctx context.Context, sel ast.SelectionSet, v model.GenerateChatTitleResponse) graphql.Marshaler { + return ec._GenerateChatTitleResponse(ctx, sel, &v) +} + +func (ec *executionContext) marshalNGenerateChatTitleResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐGenerateChatTitleResponse(ctx context.Context, sel ast.SelectionSet, v *model.GenerateChatTitleResponse) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._GenerateChatTitleResponse(ctx, sel, v) +} + +func (ec *executionContext) marshalNGraphUnit2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐGraphUnitᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.GraphUnit) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNGraphUnit2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐGraphUnit(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNGraphUnit2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐGraphUnit(ctx context.Context, sel ast.SelectionSet, v *model.GraphUnit) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._GraphUnit(ctx, sel, v) +} + +func (ec *executionContext) marshalNGraphUnitRelationship2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐGraphUnitRelationshipᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.GraphUnitRelationship) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNGraphUnitRelationship2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐGraphUnitRelationship(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNGraphUnitRelationship2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐGraphUnitRelationship(ctx context.Context, sel ast.SelectionSet, v *model.GraphUnitRelationship) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._GraphUnitRelationship(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNGraphUnitRelationshipType2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐGraphUnitRelationshipType(ctx context.Context, v any) (model.GraphUnitRelationshipType, error) { + var res model.GraphUnitRelationshipType + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNGraphUnitRelationshipType2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐGraphUnitRelationshipType(ctx context.Context, sel ast.SelectionSet, v model.GraphUnitRelationshipType) graphql.Marshaler { + return v +} + +func (ec *executionContext) marshalNHealthStatus2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐHealthStatus(ctx context.Context, sel ast.SelectionSet, v model.HealthStatus) graphql.Marshaler { + return ec._HealthStatus(ctx, sel, &v) +} + +func (ec *executionContext) marshalNHealthStatus2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐHealthStatus(ctx context.Context, sel ast.SelectionSet, v *model.HealthStatus) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._HealthStatus(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNID2string(ctx context.Context, v any) (string, error) { + res, err := graphql.UnmarshalID(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNID2string(ctx context.Context, sel ast.SelectionSet, v string) graphql.Marshaler { + _ = sel + res := graphql.MarshalID(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) unmarshalNImportCollectionFileInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportCollectionFileInput(ctx context.Context, v any) (model.ImportCollectionFileInput, error) { + res, err := ec.unmarshalInputImportCollectionFileInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalNImportCollectionPreviewInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportCollectionPreviewInput(ctx context.Context, v any) (model.ImportCollectionPreviewInput, error) { + res, err := ec.unmarshalInputImportCollectionPreviewInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalNImportColumnMapping2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportColumnMappingᚄ(ctx context.Context, v any) ([]*model.ImportColumnMapping, error) { + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]*model.ImportColumnMapping, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNImportColumnMapping2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportColumnMapping(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) unmarshalNImportColumnMapping2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportColumnMapping(ctx context.Context, v any) (*model.ImportColumnMapping, error) { + res, err := ec.unmarshalInputImportColumnMapping(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNImportColumnMappingPreview2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportColumnMappingPreview(ctx context.Context, sel ast.SelectionSet, v *model.ImportColumnMappingPreview) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._ImportColumnMappingPreview(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNImportFileFormat2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportFileFormat(ctx context.Context, v any) (model.ImportFileFormat, error) { + var res model.ImportFileFormat + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNImportFileFormat2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportFileFormat(ctx context.Context, sel ast.SelectionSet, v model.ImportFileFormat) graphql.Marshaler { + return v +} + +func (ec *executionContext) unmarshalNImportFileInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportFileInput(ctx context.Context, v any) (model.ImportFileInput, error) { + res, err := ec.unmarshalInputImportFileInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalNImportFileOptions2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportFileOptions(ctx context.Context, v any) (*model.ImportFileOptions, error) { + res, err := ec.unmarshalInputImportFileOptions(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalNImportMode2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportMode(ctx context.Context, v any) (model.ImportMode, error) { + var res model.ImportMode + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNImportMode2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportMode(ctx context.Context, sel ast.SelectionSet, v model.ImportMode) graphql.Marshaler { + return v +} + +func (ec *executionContext) unmarshalNImportNewTableColumnInput2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportNewTableColumnInputᚄ(ctx context.Context, v any) ([]*model.ImportNewTableColumnInput, error) { + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]*model.ImportNewTableColumnInput, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNImportNewTableColumnInput2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportNewTableColumnInput(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) unmarshalNImportNewTableColumnInput2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportNewTableColumnInput(ctx context.Context, v any) (*model.ImportNewTableColumnInput, error) { + res, err := ec.unmarshalInputImportNewTableColumnInput(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNImportNewTableColumnPreview2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportNewTableColumnPreviewᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.ImportNewTableColumnPreview) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNImportNewTableColumnPreview2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportNewTableColumnPreview(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNImportNewTableColumnPreview2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportNewTableColumnPreview(ctx context.Context, sel ast.SelectionSet, v *model.ImportNewTableColumnPreview) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._ImportNewTableColumnPreview(ctx, sel, v) +} + +func (ec *executionContext) marshalNImportPreview2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportPreview(ctx context.Context, sel ast.SelectionSet, v model.ImportPreview) graphql.Marshaler { + return ec._ImportPreview(ctx, sel, &v) +} + +func (ec *executionContext) marshalNImportPreview2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportPreview(ctx context.Context, sel ast.SelectionSet, v *model.ImportPreview) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._ImportPreview(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNImportPreviewInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportPreviewInput(ctx context.Context, v any) (model.ImportPreviewInput, error) { + res, err := ec.unmarshalInputImportPreviewInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNImportResult2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportResult(ctx context.Context, sel ast.SelectionSet, v model.ImportResult) graphql.Marshaler { + return ec._ImportResult(ctx, sel, &v) +} + +func (ec *executionContext) marshalNImportResult2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportResult(ctx context.Context, sel ast.SelectionSet, v *model.ImportResult) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._ImportResult(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNImportSQLInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportSQLInput(ctx context.Context, v any) (model.ImportSQLInput, error) { + res, err := ec.unmarshalInputImportSQLInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNImportSuggestionIssue2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportSuggestionIssueᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.ImportSuggestionIssue) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNImportSuggestionIssue2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportSuggestionIssue(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNImportSuggestionIssue2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportSuggestionIssue(ctx context.Context, sel ast.SelectionSet, v *model.ImportSuggestionIssue) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._ImportSuggestionIssue(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNImportTargetMode2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportTargetMode(ctx context.Context, v any) (model.ImportTargetMode, error) { + var res model.ImportTargetMode + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNImportTargetMode2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportTargetMode(ctx context.Context, sel ast.SelectionSet, v model.ImportTargetMode) graphql.Marshaler { + return v +} + +func (ec *executionContext) unmarshalNInt2int(ctx context.Context, v any) (int, error) { + res, err := graphql.UnmarshalInt(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNInt2int(ctx context.Context, sel ast.SelectionSet, v int) graphql.Marshaler { + _ = sel + res := graphql.MarshalInt(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) unmarshalNLayoutInput2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLayoutInputᚄ(ctx context.Context, v any) ([]*model.LayoutInput, error) { + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]*model.LayoutInput, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNLayoutInput2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLayoutInput(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) unmarshalNLayoutInput2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLayoutInput(ctx context.Context, v any) (*model.LayoutInput, error) { + res, err := ec.unmarshalInputLayoutInput(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNLocalAWSProfile2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLocalAWSProfileᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.LocalAWSProfile) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNLocalAWSProfile2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLocalAWSProfile(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNLocalAWSProfile2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLocalAWSProfile(ctx context.Context, sel ast.SelectionSet, v *model.LocalAWSProfile) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._LocalAWSProfile(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNLoginCredentials2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLoginCredentials(ctx context.Context, v any) (model.LoginCredentials, error) { + res, err := ec.unmarshalInputLoginCredentials(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNLoginProfile2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLoginProfileᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.LoginProfile) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNLoginProfile2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLoginProfile(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNLoginProfile2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLoginProfile(ctx context.Context, sel ast.SelectionSet, v *model.LoginProfile) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._LoginProfile(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNLoginProfileInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐLoginProfileInput(ctx context.Context, v any) (model.LoginProfileInput, error) { + res, err := ec.unmarshalInputLoginProfileInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNMockDataDependencyAnalysis2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐMockDataDependencyAnalysis(ctx context.Context, sel ast.SelectionSet, v model.MockDataDependencyAnalysis) graphql.Marshaler { + return ec._MockDataDependencyAnalysis(ctx, sel, &v) +} + +func (ec *executionContext) marshalNMockDataDependencyAnalysis2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐMockDataDependencyAnalysis(ctx context.Context, sel ast.SelectionSet, v *model.MockDataDependencyAnalysis) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._MockDataDependencyAnalysis(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNMockDataGenerationInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐMockDataGenerationInput(ctx context.Context, v any) (model.MockDataGenerationInput, error) { + res, err := ec.unmarshalInputMockDataGenerationInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNMockDataGenerationStatus2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐMockDataGenerationStatus(ctx context.Context, sel ast.SelectionSet, v model.MockDataGenerationStatus) graphql.Marshaler { + return ec._MockDataGenerationStatus(ctx, sel, &v) +} + +func (ec *executionContext) marshalNMockDataGenerationStatus2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐMockDataGenerationStatus(ctx context.Context, sel ast.SelectionSet, v *model.MockDataGenerationStatus) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._MockDataGenerationStatus(ctx, sel, v) +} + +func (ec *executionContext) marshalNMockDataTableDetail2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐMockDataTableDetail(ctx context.Context, sel ast.SelectionSet, v *model.MockDataTableDetail) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._MockDataTableDetail(ctx, sel, v) +} + +func (ec *executionContext) marshalNMockDataTableInfo2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐMockDataTableInfoᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.MockDataTableInfo) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNMockDataTableInfo2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐMockDataTableInfo(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNMockDataTableInfo2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐMockDataTableInfo(ctx context.Context, sel ast.SelectionSet, v *model.MockDataTableInfo) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._MockDataTableInfo(ctx, sel, v) +} + +func (ec *executionContext) marshalNRecord2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecordᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.Record) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNRecord2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecord(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNRecord2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecord(ctx context.Context, sel ast.SelectionSet, v *model.Record) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._Record(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNRecordInput2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecordInputᚄ(ctx context.Context, v any) ([]*model.RecordInput, error) { + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]*model.RecordInput, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNRecordInput2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecordInput(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) unmarshalNRecordInput2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecordInput(ctx context.Context, v any) (*model.RecordInput, error) { + res, err := ec.unmarshalInputRecordInput(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNRowsResult2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRowsResult(ctx context.Context, sel ast.SelectionSet, v model.RowsResult) graphql.Marshaler { + return ec._RowsResult(ctx, sel, &v) +} + +func (ec *executionContext) marshalNRowsResult2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRowsResult(ctx context.Context, sel ast.SelectionSet, v *model.RowsResult) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._RowsResult(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNSQLDataExportInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSQLDataExportInput(ctx context.Context, v any) (model.SQLDataExportInput, error) { + res, err := ec.unmarshalInputSQLDataExportInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalNSQLDataExportMode2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSQLDataExportMode(ctx context.Context, v any) (model.SQLDataExportMode, error) { + var res model.SQLDataExportMode + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNSQLDataExportMode2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSQLDataExportMode(ctx context.Context, sel ast.SelectionSet, v model.SQLDataExportMode) graphql.Marshaler { + return v +} + +func (ec *executionContext) unmarshalNSealosBootstrapInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSealosBootstrapInput(ctx context.Context, v any) (model.SealosBootstrapInput, error) { + res, err := ec.unmarshalInputSealosBootstrapInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNSealosInstanceIdentity2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSealosInstanceIdentity(ctx context.Context, sel ast.SelectionSet, v model.SealosInstanceIdentity) graphql.Marshaler { + return ec._SealosInstanceIdentity(ctx, sel, &v) +} + +func (ec *executionContext) marshalNSealosInstanceIdentity2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSealosInstanceIdentity(ctx context.Context, sel ast.SelectionSet, v *model.SealosInstanceIdentity) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._SealosInstanceIdentity(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNSealosInstanceIdentityInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSealosInstanceIdentityInput(ctx context.Context, v any) (model.SealosInstanceIdentityInput, error) { + res, err := ec.unmarshalInputSealosInstanceIdentityInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNSettingsConfig2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSettingsConfig(ctx context.Context, sel ast.SelectionSet, v model.SettingsConfig) graphql.Marshaler { + return ec._SettingsConfig(ctx, sel, &v) +} + +func (ec *executionContext) marshalNSettingsConfig2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSettingsConfig(ctx context.Context, sel ast.SelectionSet, v *model.SettingsConfig) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._SettingsConfig(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNSettingsConfigInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSettingsConfigInput(ctx context.Context, v any) (model.SettingsConfigInput, error) { + res, err := ec.unmarshalInputSettingsConfigInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalNSnapshotInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSnapshotInput(ctx context.Context, v any) (model.SnapshotInput, error) { + res, err := ec.unmarshalInputSnapshotInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalNSortCondition2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSortCondition(ctx context.Context, v any) (*model.SortCondition, error) { + res, err := ec.unmarshalInputSortCondition(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalNSortDirection2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSortDirection(ctx context.Context, v any) (model.SortDirection, error) { + var res model.SortDirection + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNSortDirection2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSortDirection(ctx context.Context, sel ast.SelectionSet, v model.SortDirection) graphql.Marshaler { + return v +} + +func (ec *executionContext) marshalNStatusResponse2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStatusResponse(ctx context.Context, sel ast.SelectionSet, v model.StatusResponse) graphql.Marshaler { + return ec._StatusResponse(ctx, sel, &v) +} + +func (ec *executionContext) marshalNStatusResponse2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStatusResponse(ctx context.Context, sel ast.SelectionSet, v *model.StatusResponse) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._StatusResponse(ctx, sel, v) +} + +func (ec *executionContext) marshalNStorageUnit2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStorageUnitᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.StorageUnit) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNStorageUnit2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStorageUnit(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNStorageUnit2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStorageUnit(ctx context.Context, sel ast.SelectionSet, v *model.StorageUnit) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._StorageUnit(ctx, sel, v) +} + +func (ec *executionContext) marshalNStorageUnitColumns2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStorageUnitColumnsᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.StorageUnitColumns) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNStorageUnitColumns2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStorageUnitColumns(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNStorageUnitColumns2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐStorageUnitColumns(ctx context.Context, sel ast.SelectionSet, v *model.StorageUnitColumns) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._StorageUnitColumns(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNString2string(ctx context.Context, v any) (string, error) { + res, err := graphql.UnmarshalString(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNString2string(ctx context.Context, sel ast.SelectionSet, v string) graphql.Marshaler { + _ = sel + res := graphql.MarshalString(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) unmarshalNString2ᚕstringᚄ(ctx context.Context, v any) ([]string, error) { + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]string, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNString2string(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) marshalNString2ᚕstringᚄ(ctx context.Context, sel ast.SelectionSet, v []string) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + for i := range v { + ret[i] = ec.marshalNString2string(ctx, sel, v[i]) + } + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) unmarshalNString2ᚕᚕstringᚄ(ctx context.Context, v any) ([][]string, error) { + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([][]string, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNString2ᚕstringᚄ(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) marshalNString2ᚕᚕstringᚄ(ctx context.Context, sel ast.SelectionSet, v [][]string) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + for i := range v { + ret[i] = ec.marshalNString2ᚕstringᚄ(ctx, sel, v[i]) + } + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) unmarshalNTypeCategory2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐTypeCategory(ctx context.Context, v any) (model.TypeCategory, error) { + var res model.TypeCategory + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNTypeCategory2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐTypeCategory(ctx context.Context, sel ast.SelectionSet, v model.TypeCategory) graphql.Marshaler { + return v +} + +func (ec *executionContext) marshalNTypeDefinition2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐTypeDefinitionᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.TypeDefinition) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNTypeDefinition2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐTypeDefinition(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNTypeDefinition2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐTypeDefinition(ctx context.Context, sel ast.SelectionSet, v *model.TypeDefinition) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._TypeDefinition(ctx, sel, v) +} + +func (ec *executionContext) marshalNUpdateInfo2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐUpdateInfo(ctx context.Context, sel ast.SelectionSet, v model.UpdateInfo) graphql.Marshaler { + return ec._UpdateInfo(ctx, sel, &v) +} + +func (ec *executionContext) marshalNUpdateInfo2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐUpdateInfo(ctx context.Context, sel ast.SelectionSet, v *model.UpdateInfo) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._UpdateInfo(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNUpdateWidgetInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐUpdateWidgetInput(ctx context.Context, v any) (model.UpdateWidgetInput, error) { + res, err := ec.unmarshalInputUpdateWidgetInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalNUpload2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚐUpload(ctx context.Context, v any) (graphql.Upload, error) { + res, err := graphql.UnmarshalUpload(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNUpload2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚐUpload(ctx context.Context, sel ast.SelectionSet, v graphql.Upload) graphql.Marshaler { + _ = sel + res := graphql.MarshalUpload(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) unmarshalNWhereCondition2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐWhereConditionᚄ(ctx context.Context, v any) ([]*model.WhereCondition, error) { + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]*model.WhereCondition, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNWhereCondition2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐWhereCondition(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) unmarshalNWhereCondition2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐWhereCondition(ctx context.Context, v any) (*model.WhereCondition, error) { + res, err := ec.unmarshalInputWhereCondition(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalNWhereConditionType2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐWhereConditionType(ctx context.Context, v any) (model.WhereConditionType, error) { + var res model.WhereConditionType + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNWhereConditionType2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐWhereConditionType(ctx context.Context, sel ast.SelectionSet, v model.WhereConditionType) graphql.Marshaler { + return v +} + +func (ec *executionContext) unmarshalNWidgetInput2githubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐWidgetInput(ctx context.Context, v any) (model.WidgetInput, error) { + res, err := ec.unmarshalInputWidgetInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalN__Directive2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐDirective(ctx context.Context, sel ast.SelectionSet, v introspection.Directive) graphql.Marshaler { + return ec.___Directive(ctx, sel, &v) +} + +func (ec *executionContext) marshalN__Directive2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐDirectiveᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.Directive) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalN__Directive2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐDirective(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) unmarshalN__DirectiveLocation2string(ctx context.Context, v any) (string, error) { + res, err := graphql.UnmarshalString(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalN__DirectiveLocation2string(ctx context.Context, sel ast.SelectionSet, v string) graphql.Marshaler { + _ = sel + res := graphql.MarshalString(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) unmarshalN__DirectiveLocation2ᚕstringᚄ(ctx context.Context, v any) ([]string, error) { + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]string, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalN__DirectiveLocation2string(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) marshalN__DirectiveLocation2ᚕstringᚄ(ctx context.Context, sel ast.SelectionSet, v []string) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalN__DirectiveLocation2string(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalN__EnumValue2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐEnumValue(ctx context.Context, sel ast.SelectionSet, v introspection.EnumValue) graphql.Marshaler { + return ec.___EnumValue(ctx, sel, &v) +} + +func (ec *executionContext) marshalN__Field2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐField(ctx context.Context, sel ast.SelectionSet, v introspection.Field) graphql.Marshaler { + return ec.___Field(ctx, sel, &v) +} + +func (ec *executionContext) marshalN__InputValue2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValue(ctx context.Context, sel ast.SelectionSet, v introspection.InputValue) graphql.Marshaler { + return ec.___InputValue(ctx, sel, &v) +} + +func (ec *executionContext) marshalN__InputValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValueᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.InputValue) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalN__InputValue2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValue(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalN__Type2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx context.Context, sel ast.SelectionSet, v introspection.Type) graphql.Marshaler { + return ec.___Type(ctx, sel, &v) +} + +func (ec *executionContext) marshalN__Type2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐTypeᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.Type) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalN__Type2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalN__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx context.Context, sel ast.SelectionSet, v *introspection.Type) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec.___Type(ctx, sel, v) +} + +func (ec *executionContext) unmarshalN__TypeKind2string(ctx context.Context, v any) (string, error) { + res, err := graphql.UnmarshalString(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalN__TypeKind2string(ctx context.Context, sel ast.SelectionSet, v string) graphql.Marshaler { + _ = sel + res := graphql.MarshalString(v) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +func (ec *executionContext) marshalOAWSProvider2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAWSProvider(ctx context.Context, sel ast.SelectionSet, v *model.AWSProvider) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._AWSProvider(ctx, sel, v) +} + +func (ec *executionContext) unmarshalOAtomicWhereCondition2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐAtomicWhereCondition(ctx context.Context, v any) (*model.AtomicWhereCondition, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputAtomicWhereCondition(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalOBoolean2bool(ctx context.Context, v any) (bool, error) { + res, err := graphql.UnmarshalBoolean(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOBoolean2bool(ctx context.Context, sel ast.SelectionSet, v bool) graphql.Marshaler { + _ = sel + _ = ctx + res := graphql.MarshalBoolean(v) + return res +} + +func (ec *executionContext) unmarshalOBoolean2ᚖbool(ctx context.Context, v any) (*bool, error) { + if v == nil { + return nil, nil + } + res, err := graphql.UnmarshalBoolean(v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOBoolean2ᚖbool(ctx context.Context, sel ast.SelectionSet, v *bool) graphql.Marshaler { + if v == nil { + return graphql.Null + } + _ = sel + _ = ctx + res := graphql.MarshalBoolean(*v) + return res +} + +func (ec *executionContext) marshalODatabaseMetadata2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐDatabaseMetadata(ctx context.Context, sel ast.SelectionSet, v *model.DatabaseMetadata) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._DatabaseMetadata(ctx, sel, v) +} + +func (ec *executionContext) marshalOImportColumnMappingPreview2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportColumnMappingPreviewᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.ImportColumnMappingPreview) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNImportColumnMappingPreview2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportColumnMappingPreview(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) unmarshalOImportExistingTableInput2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportExistingTableInput(ctx context.Context, v any) (*model.ImportExistingTableInput, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputImportExistingTableInput(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalOImportExistingTablePreviewInput2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportExistingTablePreviewInput(ctx context.Context, v any) (*model.ImportExistingTablePreviewInput, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputImportExistingTablePreviewInput(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalOImportNewTableInput2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportNewTableInput(ctx context.Context, v any) (*model.ImportNewTableInput, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputImportNewTableInput(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOImportNewTablePreview2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportNewTablePreview(ctx context.Context, sel ast.SelectionSet, v *model.ImportNewTablePreview) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._ImportNewTablePreview(ctx, sel, v) +} + +func (ec *executionContext) unmarshalOImportNewTablePreviewInput2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐImportNewTablePreviewInput(ctx context.Context, v any) (*model.ImportNewTablePreviewInput, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputImportNewTablePreviewInput(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalOInt2ᚖint(ctx context.Context, v any) (*int, error) { + if v == nil { + return nil, nil + } + res, err := graphql.UnmarshalInt(v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOInt2ᚖint(ctx context.Context, sel ast.SelectionSet, v *int) graphql.Marshaler { + if v == nil { + return graphql.Null + } + _ = sel + _ = ctx + res := graphql.MarshalInt(*v) + return res +} + +func (ec *executionContext) marshalOMockDataTableDetail2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐMockDataTableDetailᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.MockDataTableDetail) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNMockDataTableDetail2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐMockDataTableDetail(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) unmarshalOOperationWhereCondition2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐOperationWhereCondition(ctx context.Context, v any) (*model.OperationWhereCondition, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputOperationWhereCondition(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalORecordInput2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecordInputᚄ(ctx context.Context, v any) ([]*model.RecordInput, error) { + if v == nil { + return nil, nil + } + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]*model.RecordInput, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNRecordInput2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRecordInput(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) marshalORowsResult2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐRowsResult(ctx context.Context, sel ast.SelectionSet, v *model.RowsResult) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._RowsResult(ctx, sel, v) +} + +func (ec *executionContext) marshalOSSLStatus2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSSLStatus(ctx context.Context, sel ast.SelectionSet, v *model.SSLStatus) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._SSLStatus(ctx, sel, v) +} + +func (ec *executionContext) unmarshalOSortCondition2ᚕᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSortConditionᚄ(ctx context.Context, v any) ([]*model.SortCondition, error) { + if v == nil { + return nil, nil + } + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]*model.SortCondition, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNSortCondition2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐSortCondition(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) unmarshalOString2ᚕstringᚄ(ctx context.Context, v any) ([]string, error) { + if v == nil { + return nil, nil + } + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]string, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNString2string(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) marshalOString2ᚕstringᚄ(ctx context.Context, sel ast.SelectionSet, v []string) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := make(graphql.Array, len(v)) + for i := range v { + ret[i] = ec.marshalNString2string(ctx, sel, v[i]) + } + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) unmarshalOString2ᚖstring(ctx context.Context, v any) (*string, error) { + if v == nil { + return nil, nil + } + res, err := graphql.UnmarshalString(v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOString2ᚖstring(ctx context.Context, sel ast.SelectionSet, v *string) graphql.Marshaler { + if v == nil { + return graphql.Null + } + _ = sel + _ = ctx + res := graphql.MarshalString(*v) + return res +} + +func (ec *executionContext) unmarshalOUpload2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚐUpload(ctx context.Context, v any) (*graphql.Upload, error) { + if v == nil { + return nil, nil + } + res, err := graphql.UnmarshalUpload(v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOUpload2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚐUpload(ctx context.Context, sel ast.SelectionSet, v *graphql.Upload) graphql.Marshaler { + if v == nil { + return graphql.Null + } + _ = sel + _ = ctx + res := graphql.MarshalUpload(*v) + return res +} + +func (ec *executionContext) unmarshalOWhereCondition2ᚖgithubᚗcomᚋclideyᚋwhodbᚋcoreᚋgraphᚋmodelᚐWhereCondition(ctx context.Context, v any) (*model.WhereCondition, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputWhereCondition(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalO__EnumValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐEnumValueᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.EnumValue) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalN__EnumValue2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐEnumValue(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalO__Field2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐFieldᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.Field) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalN__Field2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐField(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalO__InputValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValueᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.InputValue) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalN__InputValue2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐInputValue(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalO__Schema2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐSchema(ctx context.Context, sel ast.SelectionSet, v *introspection.Schema) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec.___Schema(ctx, sel, v) +} + +func (ec *executionContext) marshalO__Type2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐTypeᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.Type) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalN__Type2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalO__Type2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐType(ctx context.Context, sel ast.SelectionSet, v *introspection.Type) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec.___Type(ctx, sel, v) +} + +// endregion ***************************** type.gotpl ***************************** diff --git a/dataflow/core/graph/graphql_chat_title.go b/dataflow/core/graph/graphql_chat_title.go new file mode 100644 index 0000000..2c7894a --- /dev/null +++ b/dataflow/core/graph/graphql_chat_title.go @@ -0,0 +1,126 @@ +//go:build !arm && !riscv64 + +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import ( + ctx "context" + "fmt" + "strings" + + "github.com/clidey/whodb/core/baml_client" + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/envconfig" + "github.com/clidey/whodb/core/src/log" +) + +// generateChatTitleImpl generates a chat title using BAML +func generateChatTitleImpl(c ctx.Context, input model.GenerateChatTitleInput) (*model.GenerateChatTitleResponse, error) { + log.Debugf("Generate Chat Title: Started with query=%s, model=%s", input.Query, input.Model) + + // Handle very short or unclear inputs - return empty to keep default name + query := strings.TrimSpace(input.Query) + queryLower := strings.ToLower(query) + + // For very short inputs, greetings, or unclear queries, skip title generation + // This signals the frontend to keep the default "Chat X" name + if len(query) <= 3 || + queryLower == "hi" || + queryLower == "hello" || + queryLower == "hey" || + queryLower == "hi!" || + queryLower == "hello!" || + queryLower == "hey!" || + queryLower == "blah" || + queryLower == "test" { + return &model.GenerateChatTitleResponse{ + Title: "", // Empty title means "keep default" + }, nil + } + + // Resolve credentials from environment if providerId is set + providerId := "" + if input.ProviderID != nil { + providerId = *input.ProviderID + } + requestToken := "" + if input.Token != nil { + requestToken = *input.Token + } + requestEndpoint := "" + if input.Endpoint != nil { + requestEndpoint = *input.Endpoint + } + creds := envconfig.ResolveProviderCredentials(providerId, requestToken, requestEndpoint, input.ModelType) + + // Build ExternalModel for BAML + externalModel := &engine.ExternalModel{ + Type: creds.ModelType, + Token: creds.Token, + Model: input.Model, + Endpoint: creds.Endpoint, + } + + // Create the prompt for title generation + titlePrompt := fmt.Sprintf( + "Generate a very short title (maximum 5 words) for a chat conversation that starts with this question: \"%s\"\n\n"+ + "Return ONLY the title text, nothing else. No quotes, no explanations. The title should be concise and descriptive.", + input.Query, + ) + + log.Debugf("Generate Chat Title: Calling BAML GenerateChatTitle") + + // Setup BAML context and call + callOpts := common.SetupAIClient(externalModel) + stream, err := baml_client.Stream.GenerateChatTitle(c, titlePrompt, callOpts...) + if err != nil { + log.Debugf("Generate Chat Title: BAML call failed: %v", err) + return nil, fmt.Errorf("failed to generate title: %w", err) + } + + // Wait for the response + var title string + for chunk := range stream { + if chunk.IsError { + log.Debugf("Generate Chat Title: Stream error: %v", chunk.Error) + return nil, fmt.Errorf("failed to generate title: %w", chunk.Error) + } + if chunk.IsFinal { + finalTitle := chunk.Final() + if finalTitle != nil { + title = *finalTitle + } + break + } + } + + // Clean up the title (remove quotes, trim, limit length) + title = strings.Trim(title, `"'`) + title = strings.TrimSpace(title) + if len(title) > 50 { + title = title[:50] + } + + log.Debugf("Generate Chat Title: Generated title=%s", title) + + return &model.GenerateChatTitleResponse{ + Title: title, + }, nil +} diff --git a/dataflow/core/graph/graphql_chat_title_unsupported.go b/dataflow/core/graph/graphql_chat_title_unsupported.go new file mode 100644 index 0000000..445892b --- /dev/null +++ b/dataflow/core/graph/graphql_chat_title_unsupported.go @@ -0,0 +1,31 @@ +//go:build arm || riscv64 + +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import ( + ctx "context" + "fmt" + + "github.com/clidey/whodb/core/graph/model" +) + +// generateChatTitleImpl is not supported on ARM/RISCV64 architectures +func generateChatTitleImpl(c ctx.Context, input model.GenerateChatTitleInput) (*model.GenerateChatTitleResponse, error) { + return nil, fmt.Errorf("chat title generation not supported on this platform") +} diff --git a/dataflow/core/graph/graphql_integration_test.go b/dataflow/core/graph/graphql_integration_test.go new file mode 100644 index 0000000..83bd2dc --- /dev/null +++ b/dataflow/core/graph/graphql_integration_test.go @@ -0,0 +1,243 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/99designs/gqlgen/graphql/handler" + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/internal/testutil" + "github.com/clidey/whodb/core/src" + "github.com/clidey/whodb/core/src/auth" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/types" +) + +func TestGraphQLAddRowMutation(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.StorageUnitExistsFunc = func(*engine.PluginConfig, string, string) (bool, error) { return true, nil } + mock.AddRowFunc = func(*engine.PluginConfig, string, string, []engine.Record) (bool, error) { return true, nil } + setEngineMock(t, mock) + + srv := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: &Resolver{}})) + + query := ` + mutation AddRow($schema: String!, $table: String!) { + AddRow(schema: $schema, storageUnit: $table, values: [{Key:"id", Value:"1"}]) { + Status + } + }` + body := map[string]any{ + "query": query, + "variables": map[string]any{"schema": "public", "table": "users"}, + } + payload, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewReader(payload)) + ctx := context.WithValue(req.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"}) + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 response, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + Data struct { + AddRow model.StatusResponse `json:"AddRow"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if !resp.Data.AddRow.Status { + t.Fatalf("expected AddRow status true, got %+v", resp.Data.AddRow) + } +} + +func TestGraphQLDatabaseMetadataQueryReturnsNilWhenNotProvided(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.GetDatabaseMetadataFunc = func() *engine.DatabaseMetadata { return nil } + setEngineMock(t, mock) + + srv := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: &Resolver{}})) + + query := `query { DatabaseMetadata { databaseType } }` + body := map[string]any{"query": query} + payload, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewReader(payload)) + ctx := context.WithValue(req.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"}) + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 response, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + Data struct { + DatabaseMetadata *model.DatabaseMetadata `json:"DatabaseMetadata"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if resp.Data.DatabaseMetadata != nil { + t.Fatalf("expected nil metadata when plugin returns nil, got %+v", resp.Data.DatabaseMetadata) + } +} + +func TestGraphQLRowQueryWithSortAndWhere(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.StorageUnitExistsFunc = func(*engine.PluginConfig, string, string) (bool, error) { return true, nil } + mock.GetRowsFunc = func(_ *engine.PluginConfig, req *engine.GetRowsRequest) (*engine.GetRowsResult, error) { + where, sort := req.Where, req.Sort + if where == nil || where.Atomic == nil || where.Atomic.Key != "id" || where.Atomic.Operator != "=" || where.Atomic.Value != "1" { + t.Fatalf("unexpected where clause passed to plugin: %#v", where) + } + if len(sort) != 1 || sort[0].Column != "id" || sort[0].Direction != model.SortDirectionAsc { + t.Fatalf("unexpected sort: %#v", sort) + } + return &engine.GetRowsResult{ + Columns: []engine.Column{{Name: "id", Type: "int"}}, + Rows: [][]string{{"1"}}, + }, nil + } + setEngineMock(t, mock) + + srv := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: &Resolver{}})) + + query := ` + query Row($schema:String!, $table:String!){ + Row(schema:$schema, storageUnit:$table, where:{Type:Atomic, Atomic:{Key:"id", Operator:"=", Value:"1", ColumnType:"int"}}, sort:[{Column:"id", Direction:ASC}], pageSize:10, pageOffset:0){ + Rows + } + }` + body := map[string]any{ + "query": query, + "variables": map[string]any{"schema": "public", "table": "users"}, + } + payload, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewReader(payload)) + ctx := context.WithValue(req.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"}) + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 response, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + Data struct { + Row *model.RowsResult `json:"Row"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if resp.Data.Row == nil || len(resp.Data.Row.Rows) != 1 { + t.Fatalf("expected one row, got %#v body=%s", resp.Data.Row, w.Body.String()) + } +} + +func TestGraphQLProfilesQueryUsesEngineProfiles(t *testing.T) { + origEngine := src.MainEngine + src.MainEngine = &engine.Engine{} + src.MainEngine.AddLoginProfile(types.DatabaseCredentials{ + Alias: "alias", + Hostname: "db.local", + Username: "alice", + Database: "app", + Type: "Test", + }) + t.Cleanup(func() { src.MainEngine = origEngine }) + + srv := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: &Resolver{}})) + query := `query { Profiles { Id Alias Type Database IsEnvironmentDefined } }` + body, _ := json.Marshal(map[string]any{"query": query}) + + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewReader(body)) + req = req.WithContext(context.WithValue(req.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 response, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + Data struct { + Profiles []struct { + ID string `json:"Id"` + Alias *string `json:"Alias"` + Type string `json:"Type"` + Database *string `json:"Database"` + IsEnvironmentDefined bool `json:"IsEnvironmentDefined"` + } `json:"Profiles"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if len(resp.Data.Profiles) != 1 || resp.Data.Profiles[0].ID == "" || resp.Data.Profiles[0].Database == nil { + t.Fatalf("expected profile to be returned, got %#v", resp.Data.Profiles) + } +} + +func TestGraphQLVersionUsesEnvFallback(t *testing.T) { + origVersion := env.ApplicationVersion + env.ApplicationVersion = "" + t.Cleanup(func() { env.ApplicationVersion = origVersion }) + + srv := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: &Resolver{}})) + body, _ := json.Marshal(map[string]any{"query": `query { Version }`}) + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 response, got %d: %s", w.Code, w.Body.String()) + } + var resp struct { + Data struct { + Version string `json:"Version"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if resp.Data.Version != "development" { + t.Fatalf("expected development fallback version, got %s", resp.Data.Version) + } +} diff --git a/dataflow/core/graph/graphql_queries_test.go b/dataflow/core/graph/graphql_queries_test.go new file mode 100644 index 0000000..666cb55 --- /dev/null +++ b/dataflow/core/graph/graphql_queries_test.go @@ -0,0 +1,183 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "errors" + "github.com/99designs/gqlgen/graphql/handler" + "github.com/clidey/whodb/core/internal/testutil" + "github.com/clidey/whodb/core/src/auth" + "github.com/clidey/whodb/core/src/engine" +) + +func TestSchemaQuerySuccess(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.GetAllSchemasFunc = func(*engine.PluginConfig) ([]string, error) { return []string{"public"}, nil } + setEngineMock(t, mock) + + srv := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: &Resolver{}})) + body, _ := json.Marshal(map[string]any{"query": `query { Schema }`}) + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBuffer(body)) + req = req.WithContext(context.WithValue(req.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "public") { + t.Fatalf("expected schema list in response, got %s", w.Body.String()) + } +} + +func TestDatabaseQueryError(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.GetDatabasesFunc = func(*engine.PluginConfig) ([]string, error) { return nil, errors.New("db error") } + setEngineMock(t, mock) + + srv := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: &Resolver{}})) + body, _ := json.Marshal(map[string]any{"query": `query { Database(type:"Test") }`}) + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBuffer(body)) + req = req.WithContext(context.WithValue(req.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "db error") { + t.Fatalf("expected graphql error for db error, got code=%d body=%s", w.Code, w.Body.String()) + } +} + +func TestStorageUnitQuerySuccess(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.GetStorageUnitsFunc = func(*engine.PluginConfig, string) ([]engine.StorageUnit, error) { + return []engine.StorageUnit{{Name: "users"}}, nil + } + setEngineMock(t, mock) + + srv := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: &Resolver{}})) + body, _ := json.Marshal(map[string]any{"query": `query { StorageUnit(schema:"public"){ Name } }`}) + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBuffer(body)) + req = req.WithContext(context.WithValue(req.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "users") { + t.Fatalf("expected storage units in response, got code=%d body=%s", w.Code, w.Body.String()) + } +} + +func TestRawExecuteQueryError(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.RawExecuteFunc = func(*engine.PluginConfig, string, ...any) (*engine.GetRowsResult, error) { + return nil, errors.New("raw error") + } + setEngineMock(t, mock) + + srv := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: &Resolver{}})) + body, _ := json.Marshal(map[string]any{"query": `query { RawExecute(query:"select 1"){ Rows } }`}) + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBuffer(body)) + req = req.WithContext(context.WithValue(req.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "raw error") { + t.Fatalf("expected raw execute error surfaced, got code=%d body=%s", w.Code, w.Body.String()) + } +} + +func TestReplaceRowMutationErrorReturnsGraphQLResponse(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.StorageUnitExistsFunc = func(*engine.PluginConfig, string, string) (bool, error) { return true, nil } + mock.ReplaceRowFunc = func(*engine.PluginConfig, string, string, map[string]string) (bool, error) { + return false, errors.New("replace failed") + } + setEngineMock(t, mock) + + srv := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: &Resolver{}})) + body, _ := json.Marshal(map[string]any{ + "query": `mutation ReplaceRow($schema: String!, $storageUnit: String!, $values: [RecordInput!]!) { + ReplaceRow(schema: $schema, storageUnit: $storageUnit, values: $values) { Status } + }`, + "variables": map[string]any{ + "schema": "public", + "storageUnit": "users", + "values": []map[string]any{ + {"Key": "document", "Value": `{"_id":"1","name":"alice"}`}, + }, + }, + }) + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBuffer(body)) + req = req.WithContext(context.WithValue(req.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "replace failed") { + t.Fatalf("expected replace error in graphql response, got code=%d body=%s", w.Code, w.Body.String()) + } +} + +func TestAIModelQueryMissingAPIKey(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + setEngineMock(t, mock) + srv := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: &Resolver{}})) + + body, _ := json.Marshal(map[string]any{"query": `query { AIModel(modelType:"OpenAI", token:"") }`}) + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBuffer(body)) + req = req.WithContext(context.WithValue(req.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "API key is required") { + t.Fatalf("expected API key error, got code=%d body=%s", w.Code, w.Body.String()) + } +} + +func TestAIChatQueryError(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.ChatFunc = func(*engine.PluginConfig, string, string, string) ([]*engine.ChatMessage, error) { + return nil, errors.New("chat failed") + } + setEngineMock(t, mock) + + srv := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: &Resolver{}})) + body, _ := json.Marshal(map[string]any{ + "query": `query { AIChat(modelType:"Test", schema:"public", input:{PreviousConversation:"", Query:"hi", Model:"m"}){ Text } }`, + }) + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBuffer(body)) + req = req.WithContext(context.WithValue(req.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "chat failed") { + t.Fatalf("expected chat error surfaced, got code=%d body=%s", w.Code, w.Body.String()) + } +} diff --git a/dataflow/core/graph/http.resolvers.go b/dataflow/core/graph/http.resolvers.go new file mode 100644 index 0000000..6a207ef --- /dev/null +++ b/dataflow/core/graph/http.resolvers.go @@ -0,0 +1,283 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/clidey/whodb/core/graph/model" + "github.com/go-chi/chi/v5" +) + +// SetupHTTPServer registers REST API endpoints that wrap GraphQL resolvers for clients +// that cannot use GraphQL directly (e.g., simple HTTP clients, legacy integrations). +func SetupHTTPServer(router chi.Router) { + router.Get("/api/profiles", getProfilesHandler) + router.Get("/api/databases", getDatabasesHandler) + router.Get("/api/schema", getSchemaHandler) + router.Get("/api/storage-units", getStorageUnitsHandler) + router.Get("/api/rows", getRowsHandler) + router.Post("/api/raw-execute", rawExecuteHandler) + router.Get("/api/graph", getGraphHandler) + router.Get("/api/ai-models", getAIModelsHandler) + router.Post("/api/ai-chat", aiChatHandler) + router.Post("/api/ai-chat/stream", aiChatStreamHandler) + router.Post("/api/sql-agent/stream", sqlAgentStreamHandler) + router.Post("/api/sql-agent/permit", sqlAgentPermitHandler) + + router.Post("/api/storage-units", addStorageUnitHandler) + router.Post("/api/rows", addRowHandler) + router.Delete("/api/rows", deleteRowHandler) + + router.Post("/api/export", HandleExport) + router.Get("/api/exports/{id}/download", handleSQLDataExportDownload) + + // AI chat streaming endpoint is registered via build tags in http_ai_stream.go (!arm) / http_ai_stream_arm.go (arm) +} + +var resolver = mutationResolver{} + +func getProfilesHandler(w http.ResponseWriter, r *http.Request) { + profiles, err := resolver.Query().Profiles(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + err = json.NewEncoder(w).Encode(profiles) + if err != nil { + return + } +} + +func getDatabasesHandler(w http.ResponseWriter, r *http.Request) { + typeArg := r.URL.Query().Get("type") + databases, err := resolver.Query().Database(r.Context(), typeArg) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + err = json.NewEncoder(w).Encode(databases) + if err != nil { + return + } +} + +func getSchemaHandler(w http.ResponseWriter, r *http.Request) { + schemas, err := resolver.Query().Schema(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + err = json.NewEncoder(w).Encode(schemas) + if err != nil { + return + } +} + +func getStorageUnitsHandler(w http.ResponseWriter, r *http.Request) { + schema := r.URL.Query().Get("schema") + storageUnits, err := resolver.Query().StorageUnit(r.Context(), schema) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + err = json.NewEncoder(w).Encode(storageUnits) + if err != nil { + return + } +} + +func getRowsHandler(w http.ResponseWriter, r *http.Request) { + schema := r.URL.Query().Get("schema") + storageUnit := r.URL.Query().Get("storageUnit") + pageSize := parseQueryParamToInt(r.URL.Query().Get("pageSize")) + pageOffset := parseQueryParamToInt(r.URL.Query().Get("pageOffset")) + + // TODO: Add where condition parsing from query params if needed + rowsResult, err := resolver.Query().Row(r.Context(), schema, storageUnit, nil, []*model.SortCondition{}, pageSize, pageOffset) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + err = json.NewEncoder(w).Encode(rowsResult) + if err != nil { + return + } +} + +func rawExecuteHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Query string `json:"query"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + rowsResult, err := resolver.Query().RawExecute(r.Context(), req.Query) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + err = json.NewEncoder(w).Encode(rowsResult) + if err != nil { + return + } +} + +func getGraphHandler(w http.ResponseWriter, r *http.Request) { + schema := r.URL.Query().Get("schema") + graphUnits, err := resolver.Query().Graph(r.Context(), schema) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + err = json.NewEncoder(w).Encode(graphUnits) + if err != nil { + return + } +} + +func getAIModelsHandler(w http.ResponseWriter, r *http.Request) { + modelType := r.URL.Query().Get("modelType") + token := r.URL.Query().Get("token") + models, err := resolver.Query().AIModel(r.Context(), nil, modelType, &token) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + err = json.NewEncoder(w).Encode(models) + if err != nil { + return + } +} + +func aiChatHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + ModelType string `json:"modelType"` + Token string `json:"token"` + Model string `json:"model"` + Endpoint string `json:"endpoint"` + Schema string `json:"schema"` + Input model.ChatInput `json:"input"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Copy top-level model to input.Model for GraphQL resolver compatibility + // Frontend sends model at top level (matching streaming endpoint format) + if req.Model != "" && req.Input.Model == "" { + req.Input.Model = req.Model + } + + messages, err := resolver.Query().AIChat(r.Context(), nil, req.ModelType, &req.Token, req.Schema, req.Input) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + err = json.NewEncoder(w).Encode(messages) + if err != nil { + return + } +} + +func addStorageUnitHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Schema string `json:"schema"` + StorageUnit string `json:"storageUnit"` + Fields []*model.RecordInput `json:"fields"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + status, err := resolver.Mutation().AddStorageUnit(r.Context(), req.Schema, req.StorageUnit, req.Fields) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + err = json.NewEncoder(w).Encode(status) + if err != nil { + return + } +} + +func addRowHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Schema string `json:"schema"` + StorageUnit string `json:"storageUnit"` + Values []*model.RecordInput `json:"values"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + status, err := resolver.Mutation().AddRow(r.Context(), req.Schema, req.StorageUnit, req.Values) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + err = json.NewEncoder(w).Encode(status) + if err != nil { + return + } +} + +func deleteRowHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Schema string `json:"schema"` + StorageUnit string `json:"storageUnit"` + Values []*model.RecordInput `json:"values"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + status, err := resolver.Mutation().DeleteRow(r.Context(), req.Schema, req.StorageUnit, req.Values) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + err = json.NewEncoder(w).Encode(status) + if err != nil { + return + } +} + +func parseQueryParamToInt(queryParam string) int { + if queryParam == "" { + return 0 + } + value, err := strconv.Atoi(queryParam) + if err != nil { + return 0 + } + return value +} diff --git a/dataflow/core/graph/http_ai_stream.go b/dataflow/core/graph/http_ai_stream.go new file mode 100644 index 0000000..b90b343 --- /dev/null +++ b/dataflow/core/graph/http_ai_stream.go @@ -0,0 +1,387 @@ +//go:build !arm && !riscv64 + +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import ( + ctx "context" + "encoding/json" + "net/http" + + "github.com/clidey/whodb/core/baml_client" + "github.com/clidey/whodb/core/baml_client/stream_types" + "github.com/clidey/whodb/core/baml_client/types" + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/envconfig" + "github.com/clidey/whodb/core/src/log" +) + +func init() { + RegisterAIChatStreamHandler(ceAIChatStreamHandler) +} + +func ceAIChatStreamHandler(w http.ResponseWriter, r *http.Request) { + log.Debugf("AI Chat Stream: Handler started") + + // Parse request + req, err := ParseStreamRequest(r) + if err != nil { + log.Debugf("AI Chat Stream: ParseStreamRequest failed: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + log.Debugf("AI Chat Stream: Request parsed - model=%s, schema=%s, query=%s", req.ModelType, req.Schema, req.Input.Query) + + // Setup SSE - check if streaming is supported + flusher := SetupSSEHeaders(w) + streamingSupported := flusher != nil + log.Debugf("AI Chat Stream: Streaming supported: %v", streamingSupported) + + // If streaming not supported (e.g., Wails), fall back to non-streaming mode + if !streamingSupported { + handleNonStreamingAIChat(w, r, req) + return + } + log.Debugf("AI Chat Stream: SSE headers set, flusher available") + + // Get plugin and config + plugin, config := GetPluginForContext(r.Context()) + if plugin == nil { + log.Debugf("AI Chat Stream: Plugin is nil") + SendSSEError(w, flusher, "No database plugin available") + return + } + if config == nil || config.Credentials == nil { + log.Debugf("AI Chat Stream: Config or credentials is nil") + SendSSEError(w, flusher, "No credentials available") + return + } + log.Debugf("AI Chat Stream: Plugin=%s, DB=%s", config.Credentials.Type, config.Credentials.Database) + + // Build ExternalModel, resolving credentials from environment if providerId is set + creds := envconfig.ResolveProviderCredentials(req.ProviderId, req.Token, req.Endpoint, req.ModelType) + config.ExternalModel = &engine.ExternalModel{ + Type: creds.ModelType, + Token: creds.Token, + Model: req.Model, + Endpoint: creds.Endpoint, + } + + // Build table details + log.Debugf("AI Chat Stream: Building table details for schema=%s", req.Schema) + tableDetails, err := BuildTableDetails(plugin, config, req.Schema) + if err != nil { + log.Debugf("AI Chat Stream: BuildTableDetails failed: %v", err) + SendSSEError(w, flusher, "Failed to get table info: "+err.Error()) + return + } + log.Debugf("AI Chat Stream: Table details built, length=%d", len(tableDetails)) + + // Setup BAML context + dbContext := types.DatabaseContext{ + Database_type: config.Credentials.Type, + Schema: req.Schema, + Tables_and_fields: tableDetails, + Previous_conversation: req.Input.PreviousConversation, + } + log.Debugf("AI Chat Stream: BAML context created") + + // Create BAML stream + log.Debugf("AI Chat Stream: Setting up AI client...") + callOpts := common.SetupAIClient(config.ExternalModel) + log.Debugf("AI Chat Stream: Starting BAML GenerateSQLQuery stream...") + stream, err := baml_client.Stream.GenerateSQLQuery(ctx.Background(), dbContext, req.Input.Query, callOpts...) + if err != nil { + log.Debugf("AI Chat Stream: GenerateSQLQuery failed: %v", err) + SendSSEError(w, flusher, "Failed to start stream: "+err.Error()) + return + } + log.Debugf("AI Chat Stream: BAML stream created successfully") + + // Process stream + log.Debugf("AI Chat Stream: Starting to process stream...") + processStream(w, flusher, stream, plugin, config) + log.Debugf("AI Chat Stream: Stream processing completed") +} + +func processStream( + w http.ResponseWriter, + flusher http.Flusher, + stream <-chan baml_client.StreamValue[[]stream_types.ChatResponse, []types.ChatResponse], + plugin *engine.Plugin, + config *engine.PluginConfig, +) { + for chunk := range stream { + if chunk.IsError { + SendSSEError(w, flusher, chunk.Error.Error()) + return + } + + if chunk.IsFinal { + processFinalChunk(w, flusher, chunk.Final(), plugin, config) + SendSSEDone(w, flusher) + return + } + + if chunk.Stream() != nil { + for _, bamlResp := range *chunk.Stream() { + SendSSEChunk(w, flusher, convertStreamResponse(&bamlResp)) + } + } + } +} + +func processFinalChunk(w http.ResponseWriter, flusher http.Flusher, responses *[]types.ChatResponse, plugin *engine.Plugin, config *engine.PluginConfig) { + if responses == nil { + return + } + + for _, bamlResp := range *responses { + if bamlResp.Type == types.ChatMessageTypeSQL { + message := executeSQLResponse(&bamlResp, plugin, config) + SendSSEMessage(w, flusher, message) + } + } +} + +func executeSQLResponse(bamlResp *types.ChatResponse, plugin *engine.Plugin, config *engine.PluginConfig) *model.AIChatMessage { + message := &model.AIChatMessage{ + Type: string(bamlResp.Type), + Text: bamlResp.Text, + RequiresConfirmation: false, + } + + if bamlResp.Operation == nil { + result, err := plugin.RawExecute(config, bamlResp.Text) + if err != nil { + message.Type = "error" + message.Text = err.Error() + } else { + message.Result = ConvertResultToMessage(result) + message.Type = "sql:get" + } + return message + } + + // Check if mutation + isMutation := *bamlResp.Operation == types.OperationTypeINSERT || + *bamlResp.Operation == types.OperationTypeUPDATE || + *bamlResp.Operation == types.OperationTypeDELETE || + *bamlResp.Operation == types.OperationTypeCREATE || + *bamlResp.Operation == types.OperationTypeALTER || + *bamlResp.Operation == types.OperationTypeDROP + + if isMutation { + message.Type = convertOperationType(*bamlResp.Operation) + message.RequiresConfirmation = true + } else { + result, err := plugin.RawExecute(config, bamlResp.Text) + if err != nil { + message.Type = "error" + message.Text = err.Error() + } else { + message.Result = ConvertResultToMessage(result) + message.Type = convertOperationType(*bamlResp.Operation) + } + } + + return message +} + +func convertStreamResponse(bamlResp *stream_types.ChatResponse) map[string]any { + typeStr := "" + if bamlResp.Type != nil { + switch *bamlResp.Type { + case types.ChatMessageTypeSQL: + typeStr = "sql" + case types.ChatMessageTypeMESSAGE: + typeStr = "message" + case types.ChatMessageTypeERROR: + typeStr = "error" + default: + typeStr = string(*bamlResp.Type) + } + } + + opStr := "" + if bamlResp.Operation != nil { + opStr = operationToString(*bamlResp.Operation) + } + + textStr := "" + if bamlResp.Text != nil { + textStr = *bamlResp.Text + } + + return map[string]any{ + "type": typeStr, + "text": textStr, + "operation": opStr, + } +} + +func operationToString(op types.OperationType) string { + switch op { + case types.OperationTypeGET: + return "get" + case types.OperationTypeINSERT: + return "insert" + case types.OperationTypeUPDATE: + return "update" + case types.OperationTypeDELETE: + return "delete" + case types.OperationTypeCREATE: + return "create" + case types.OperationTypeALTER: + return "alter" + case types.OperationTypeDROP: + return "drop" + case types.OperationTypeTEXT: + return "text" + default: + return string(op) + } +} + +func convertOperationType(operation types.OperationType) string { + return "sql:" + operationToString(operation) +} + +// handleNonStreamingAIChat handles AI chat when SSE streaming is not supported (e.g., Wails desktop) +// It uses the non-streaming BAML client and returns a JSON response +func handleNonStreamingAIChat(w http.ResponseWriter, r *http.Request, req *StreamRequest) { + // Get plugin and config + plugin, config := GetPluginForContext(r.Context()) + if plugin == nil { + http.Error(w, "No database plugin available", http.StatusInternalServerError) + return + } + if config == nil || config.Credentials == nil { + http.Error(w, "No credentials available", http.StatusUnauthorized) + return + } + + // Build ExternalModel, resolving credentials from environment if providerId is set + creds := envconfig.ResolveProviderCredentials(req.ProviderId, req.Token, req.Endpoint, req.ModelType) + config.ExternalModel = &engine.ExternalModel{ + Type: creds.ModelType, + Token: creds.Token, + Model: req.Model, + Endpoint: creds.Endpoint, + } + + // Build table details + tableDetails, err := BuildTableDetails(plugin, config, req.Schema) + if err != nil { + http.Error(w, "Failed to get table info: "+err.Error(), http.StatusInternalServerError) + return + } + + // Setup BAML context + dbContext := types.DatabaseContext{ + Database_type: config.Credentials.Type, + Schema: req.Schema, + Tables_and_fields: tableDetails, + Previous_conversation: req.Input.PreviousConversation, + } + + // Use non-streaming BAML client + callOpts := common.SetupAIClient(config.ExternalModel) + responses, err := baml_client.GenerateSQLQuery(ctx.Background(), dbContext, req.Input.Query, callOpts...) + if err != nil { + http.Error(w, "AI query failed: "+err.Error(), http.StatusInternalServerError) + return + } + + // Convert responses to messages + var messages []*model.AIChatMessage + for _, bamlResp := range responses { + msg := convertBamlResponseToMessage(&bamlResp, plugin, config) + messages = append(messages, msg) + } + + // Return as JSON (mimicking SSE done event format for frontend compatibility) + w.Header().Set("Content-Type", "application/json") + response := map[string]any{ + "messages": messages, + "done": true, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + + } +} + +// convertBamlResponseToMessage converts a BAML response to an AIChatMessage +func convertBamlResponseToMessage(bamlResp *types.ChatResponse, plugin *engine.Plugin, config *engine.PluginConfig) *model.AIChatMessage { + // Convert BAML type to lowercase frontend-compatible format + typeStr := "message" // default + switch bamlResp.Type { + case types.ChatMessageTypeSQL: + typeStr = "sql" + case types.ChatMessageTypeMESSAGE: + typeStr = "message" + case types.ChatMessageTypeERROR: + typeStr = "error" + } + + message := &model.AIChatMessage{ + Type: typeStr, + Text: bamlResp.Text, + RequiresConfirmation: false, + } + + // For SQL responses, execute or mark for confirmation + if bamlResp.Type == types.ChatMessageTypeSQL { + if bamlResp.Operation == nil { + result, err := plugin.RawExecute(config, bamlResp.Text) + if err != nil { + message.Type = "error" + message.Text = err.Error() + } else { + message.Result = ConvertResultToMessage(result) + message.Type = "sql:get" + } + } else { + isMutation := *bamlResp.Operation == types.OperationTypeINSERT || + *bamlResp.Operation == types.OperationTypeUPDATE || + *bamlResp.Operation == types.OperationTypeDELETE || + *bamlResp.Operation == types.OperationTypeCREATE || + *bamlResp.Operation == types.OperationTypeALTER || + *bamlResp.Operation == types.OperationTypeDROP + + if isMutation { + message.Type = convertOperationType(*bamlResp.Operation) + message.RequiresConfirmation = true + } else { + result, err := plugin.RawExecute(config, bamlResp.Text) + if err != nil { + message.Type = "error" + message.Text = err.Error() + } else { + message.Result = ConvertResultToMessage(result) + message.Type = convertOperationType(*bamlResp.Operation) + } + } + } + } + + return message +} diff --git a/dataflow/core/graph/http_ai_stream_handler.go b/dataflow/core/graph/http_ai_stream_handler.go new file mode 100644 index 0000000..4ed04b1 --- /dev/null +++ b/dataflow/core/graph/http_ai_stream_handler.go @@ -0,0 +1,43 @@ +//go:build !arm && !riscv64 + +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import "net/http" + +// AIChatStreamHandlerFunc is the type for the AI chat stream handler +// EE can override this by registering its own implementation +type AIChatStreamHandlerFunc func(w http.ResponseWriter, r *http.Request) + +// registeredAIChatStreamHandler holds the handler implementation +// Default is nil, will be set by CE or EE implementations +var registeredAIChatStreamHandler AIChatStreamHandlerFunc + +// RegisterAIChatStreamHandler allows CE/EE implementations to register their handler +func RegisterAIChatStreamHandler(handler AIChatStreamHandlerFunc) { + registeredAIChatStreamHandler = handler +} + +// aiChatStreamHandler delegates to registered implementation +func aiChatStreamHandler(w http.ResponseWriter, r *http.Request) { + if registeredAIChatStreamHandler != nil { + registeredAIChatStreamHandler(w, r) + return + } + http.Error(w, "AI chat streaming not available", http.StatusNotImplemented) +} diff --git a/dataflow/core/graph/http_ai_stream_shared.go b/dataflow/core/graph/http_ai_stream_shared.go new file mode 100644 index 0000000..cddbeeb --- /dev/null +++ b/dataflow/core/graph/http_ai_stream_shared.go @@ -0,0 +1,251 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "golang.org/x/sync/errgroup" +) + +// StreamRequest represents the incoming SSE request +type StreamRequest struct { + ModelType string `json:"modelType"` + Token string `json:"token"` + Model string `json:"model"` + Endpoint string `json:"endpoint"` + Schema string `json:"schema"` + ProviderId string `json:"providerId"` + Input model.ChatInput `json:"input"` +} + +// StreamContext contains all context needed for streaming +type StreamContext struct { + Writer http.ResponseWriter + Flusher http.Flusher + Plugin *engine.Plugin + Config *engine.PluginConfig + Request *StreamRequest +} + +// ParseStreamRequest parses and validates the SSE request +func ParseStreamRequest(r *http.Request) (*StreamRequest, error) { + var req StreamRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return nil, err + } + return &req, nil +} + +// SetupSSEHeaders configures the response for Server-Sent Events +func SetupSSEHeaders(w http.ResponseWriter) http.Flusher { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + flusher, _ := w.(http.Flusher) + return flusher +} + +// BuildTableDetails builds the table schema string for BAML context. +// Uses concurrent column fetching (up to 10 parallel) to avoid N+1 queries. +func BuildTableDetails(plugin *engine.Plugin, config *engine.PluginConfig, schema string) (string, error) { + storageUnits, err := plugin.GetStorageUnits(config, schema) + if err != nil { + return "", err + } + + type tableResult struct { + name string + columns []engine.Column + } + + results := make([]tableResult, len(storageUnits)) + var mu sync.Mutex + g := new(errgroup.Group) + g.SetLimit(10) + + for i, unit := range storageUnits { + i, unit := i, unit + g.Go(func() error { + columns, err := plugin.GetColumnsForTable(config, schema, unit.Name) + if err != nil { + log.WithError(err).Warnf("Failed to get columns for table %s in streaming chat", unit.Name) + return nil + } + mu.Lock() + results[i] = tableResult{name: unit.Name, columns: columns} + mu.Unlock() + return nil + }) + } + + if err := g.Wait(); err != nil { + return "", err + } + + var b strings.Builder + for _, r := range results { + if r.name == "" { + continue + } + fmt.Fprintf(&b, "table: %s\n", r.name) + for _, col := range r.columns { + fmt.Fprintf(&b, "- %s (%s)\n", col.Name, col.Type) + } + } + return b.String(), nil +} + +// SendSSEMessage sends a complete message via SSE +func SendSSEMessage(w http.ResponseWriter, flusher http.Flusher, message *model.AIChatMessage) { + data, err := json.Marshal(message) + if err != nil { + log.WithError(err).Error("Failed to marshal SSE message") + return + } + fmt.Fprintf(w, "event: message\ndata: %s\n\n", data) + flusher.Flush() +} + +// SendSSEChunk sends a streaming chunk via SSE +func SendSSEChunk(w http.ResponseWriter, flusher http.Flusher, chunk map[string]any) { + data, err := json.Marshal(chunk) + if err != nil { + log.WithError(err).Error("Failed to marshal SSE chunk") + return + } + fmt.Fprintf(w, "event: chunk\ndata: %s\n\n", data) + flusher.Flush() +} + +// SendSSEError sends an error via SSE and completes the stream +func SendSSEError(w http.ResponseWriter, flusher http.Flusher, errorMsg string) { + // Sanitize error message to avoid leaking technical details + sanitized := sanitizeErrorMessage(errorMsg) + + // For better UX, send errors as chat messages instead of error events + // This makes them appear inline in the chat + SendSSEMessage(w, flusher, &model.AIChatMessage{ + Type: "error", + Text: sanitized, + }) + + // Send done event to stop loading spinner + SendSSEDone(w, flusher) +} + +// sanitizeErrorMessage removes technical details and JSON from error messages +func sanitizeErrorMessage(msg string) string { + // Check for specific error patterns and return user-friendly messages + + // LLM Service errors + if strings.Contains(msg, "LLM Failure:") || strings.Contains(msg, "ServiceError") { + // Extract any meaningful message after the error type + if strings.Contains(msg, "throttling") || strings.Contains(msg, "rate limit") { + return "AI service is busy. Please try again in a moment." + } + if strings.Contains(msg, "access denied") || strings.Contains(msg, "AccessDenied") { + return "Access denied. Please check your credentials." + } + if strings.Contains(msg, "validation") || strings.Contains(msg, "ValidationException") { + return "Invalid request. Please check your model selection." + } + return "AI service error. Please try again." + } + + // BAML/Network errors + if strings.Contains(msg, "reqwest::Error") || strings.Contains(msg, "Failed to build request") { + return "Unable to connect to AI service. Please check your configuration." + } + + if strings.Contains(msg, "RelativeUrlWithoutBase") { + return "AI service configuration error. Please contact support." + } + + // Stream/connection errors + if strings.Contains(msg, "Failed to start stream") || strings.Contains(msg, "Failed to stream") { + return "Unable to query. Please try again." + } + + // API key/auth errors + if strings.Contains(msg, "API key") || strings.Contains(msg, "api_key") || + strings.Contains(msg, "OPENAI_API_KEY") || strings.Contains(msg, "unauthorized") { + return "AI service not configured. Please set up your API key." + } + + // Model errors + if strings.Contains(msg, "model") && (strings.Contains(msg, "not found") || strings.Contains(msg, "invalid")) { + return "Selected model is not available. Please choose a different model." + } + + // If error contains technical markers (JSON, stack traces, etc.), simplify + if len(msg) > 150 || strings.Contains(msg, "{") || strings.Contains(msg, "---") || + strings.Contains(msg, "Error {") || strings.Contains(msg, "Prompt:") { + // Try to extract first meaningful sentence + for _, prefix := range []string{"Failed to", "Unable to", "Error:", "error:"} { + if idx := strings.Index(msg, prefix); idx >= 0 { + remaining := msg[idx:] + // Find end of sentence (period, colon, newline) + for _, end := range []string{".", ":", "\n"} { + if endIdx := strings.Index(remaining, end); endIdx > 10 && endIdx < 100 { + return remaining[:endIdx] + "." + } + } + } + } + // Couldn't extract meaningful message, return generic + return "Unable to query. Please try again." + } + + // Return original if it's short and doesn't contain technical details + return msg +} + +// SendSSEDone sends the done event +func SendSSEDone(w http.ResponseWriter, flusher http.Flusher) { + fmt.Fprintf(w, "event: done\ndata: {}\n\n") + flusher.Flush() +} + +// ConvertResultToMessage converts engine result to API message +func ConvertResultToMessage(result *engine.GetRowsResult) *model.RowsResult { + if result == nil { + return nil + } + + var columns []*model.Column + for _, column := range result.Columns { + columns = append(columns, &model.Column{ + Type: column.Type, + Name: column.Name, + }) + } + + return &model.RowsResult{ + Columns: columns, + Rows: result.Rows, + } +} diff --git a/dataflow/core/graph/http_ai_stream_unsupported.go b/dataflow/core/graph/http_ai_stream_unsupported.go new file mode 100644 index 0000000..dc43e94 --- /dev/null +++ b/dataflow/core/graph/http_ai_stream_unsupported.go @@ -0,0 +1,50 @@ +//go:build arm || riscv64 + +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +// http_ai_stream_unsupported.go provides stub implementations for armv7 platforms +// where BAML is not supported. AI streaming features will return user-friendly errors. + +package graph + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// aiChatStreamHandler returns an error on unsupported platforms +func aiChatStreamHandler(w http.ResponseWriter, r *http.Request) { + // Set SSE headers + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported", http.StatusInternalServerError) + return + } + + // Send error via SSE + errorData := map[string]string{ + "error": "AI features are not supported on this platform (arm/riscv64). BAML requires amd64 or arm64 architecture.", + } + data, _ := json.Marshal(errorData) + fmt.Fprintf(w, "event: error\ndata: %s\n\n", data) + flusher.Flush() +} diff --git a/dataflow/core/graph/http_resolvers_test.go b/dataflow/core/graph/http_resolvers_test.go new file mode 100644 index 0000000..ce6b287 --- /dev/null +++ b/dataflow/core/graph/http_resolvers_test.go @@ -0,0 +1,233 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/clidey/whodb/core/internal/testutil" + "github.com/clidey/whodb/core/src/auth" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/env" + "github.com/go-chi/chi/v5" +) + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestRESTHandlersAddRowAndGetRows(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.StorageUnitExistsFunc = func(*engine.PluginConfig, string, string) (bool, error) { return true, nil } + mock.AddRowFunc = func(*engine.PluginConfig, string, string, []engine.Record) (bool, error) { return true, nil } + mock.GetRowsFunc = func(*engine.PluginConfig, *engine.GetRowsRequest) (*engine.GetRowsResult, error) { + return &engine.GetRowsResult{ + Columns: []engine.Column{{Name: "id", Type: "int"}}, + Rows: [][]string{{"1"}}, + }, nil + } + setEngineMock(t, mock) + + router := chi.NewRouter() + SetupHTTPServer(router) + + // Add row via REST + addPayload := `{"schema":"public","storageUnit":"users","values":[{"Key":"id","Value":"1"}]}` + addReq := httptest.NewRequest(http.MethodPost, "/api/rows", bytes.NewBufferString(addPayload)) + addReq = addReq.WithContext(context.WithValue(addReq.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + addRec := httptest.NewRecorder() + router.ServeHTTP(addRec, addReq) + if addRec.Code != http.StatusOK { + t.Fatalf("expected add row HTTP 200, got %d (%s)", addRec.Code, addRec.Body.String()) + } + + // Get rows via REST + rowsReq := httptest.NewRequest(http.MethodGet, "/api/rows?schema=public&storageUnit=users&pageSize=10&pageOffset=0", nil) + rowsReq = rowsReq.WithContext(context.WithValue(rowsReq.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + rowsRec := httptest.NewRecorder() + router.ServeHTTP(rowsRec, rowsReq) + if rowsRec.Code != http.StatusOK { + t.Fatalf("expected get rows HTTP 200, got %d (%s)", rowsRec.Code, rowsRec.Body.String()) + } + var parsed engine.GetRowsResult + if err := json.Unmarshal(rowsRec.Body.Bytes(), &parsed); err != nil { + t.Fatalf("failed to decode rows response: %v", err) + } + if parsed.TotalCount != 0 && len(parsed.Rows) == 0 { + t.Fatalf("expected rows in response, got %#v", parsed) + } +} + +func TestRESTHandlersHandleErrors(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.GetDatabasesFunc = func(*engine.PluginConfig) ([]string, error) { return nil, errors.New("db error") } + setEngineMock(t, mock) + + router := chi.NewRouter() + SetupHTTPServer(router) + + req := httptest.NewRequest(http.MethodGet, "/api/databases?type=Test", nil) + req = req.WithContext(context.WithValue(req.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500 when resolver returns error, got %d", rec.Code) + } +} + +func TestRESTHandlersAIModelsAndChat(t *testing.T) { + // Avoid httptest.NewServer() to keep this test hermetic in environments where + // binding a local TCP port is not permitted. + previousTransport := http.DefaultTransport + t.Cleanup(func() { http.DefaultTransport = previousTransport }) + http.DefaultTransport = roundTripperFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Scheme == "http" && r.URL.Host == "ollama.test:11434" && r.URL.Path == "/api/tags" { + body := io.NopCloser(strings.NewReader(`{"models":[]}`)) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: body, + Request: r, + }, nil + } + return nil, fmt.Errorf("unexpected http request: %s %s", r.Method, r.URL.String()) + }) + + t.Setenv("WHODB_OLLAMA_HOST", "ollama.test") + t.Setenv("WHODB_OLLAMA_PORT", "11434") + origHost, origPort := env.OllamaHost, env.OllamaPort + env.OllamaHost = "ollama.test" + env.OllamaPort = "11434" + t.Cleanup(func() { + env.OllamaHost = origHost + env.OllamaPort = origPort + }) + + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.ChatFunc = func(*engine.PluginConfig, string, string, string) ([]*engine.ChatMessage, error) { + return nil, errors.New("chat error") + } + mock.GetDatabasesFunc = func(*engine.PluginConfig) ([]string, error) { return []string{"db"}, nil } + setEngineMock(t, mock) + + router := chi.NewRouter() + SetupHTTPServer(router) + + // AI models + modelReq := httptest.NewRequest(http.MethodGet, "/api/ai-models?modelType=Ollama&token=", nil) + modelReq = modelReq.WithContext(context.WithValue(modelReq.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + modelRec := httptest.NewRecorder() + router.ServeHTTP(modelRec, modelReq) + if modelRec.Code != http.StatusOK { + t.Fatalf("expected ai-models to return 200, got %d", modelRec.Code) + } + + // AI chat + // AI chat with unsupported type should surface error (no network) + payload := `{"modelType":"Test","token":"","schema":"public","input":{"PreviousConversation":"","Query":"select","Model":"gpt-4"}}` + chatReq := httptest.NewRequest(http.MethodPost, "/api/ai-chat", bytes.NewBufferString(payload)) + chatReq = chatReq.WithContext(context.WithValue(chatReq.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + chatRec := httptest.NewRecorder() + router.ServeHTTP(chatRec, chatReq) + if chatRec.Code != http.StatusInternalServerError { + t.Fatalf("expected ai-chat to return 500 when chat fails, got %d (%s)", chatRec.Code, chatRec.Body.String()) + } +} + +func TestRESTRawExecutePropagatesErrors(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.RawExecuteFunc = func(*engine.PluginConfig, string, ...any) (*engine.GetRowsResult, error) { + return nil, errors.New("raw error") + } + setEngineMock(t, mock) + + router := chi.NewRouter() + SetupHTTPServer(router) + + body := `{"query":"select 1"}` + req := httptest.NewRequest(http.MethodPost, "/api/raw-execute", bytes.NewBufferString(body)) + req = req.WithContext(context.WithValue(req.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected raw execute to return 500 on error, got %d", rec.Code) + } +} + +func TestRESTHandlersEnforceAuthForProtectedRoutes(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.GetDatabasesFunc = func(*engine.PluginConfig) ([]string, error) { return nil, errors.New("unauthorized") } + setEngineMock(t, mock) + + router := chi.NewRouter() + SetupHTTPServer(router) + + req := httptest.NewRequest(http.MethodGet, "/api/databases?type=Test", nil) + // Provide empty credentials to avoid panic but simulate unauthorized flow + req = req.WithContext(context.WithValue(req.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected error when plugin returns unauthorized, got %d", rec.Code) + } +} + +func TestRESTExportSuccessAndFailure(t *testing.T) { + successMock := testutil.NewPluginMock(engine.DatabaseType("Test")) + successMock.ExportDataFunc = func(*engine.PluginConfig, string, string, func([]string) error, []map[string]any) error { + return nil + } + setEngineMock(t, successMock) + + router := chi.NewRouter() + SetupHTTPServer(router) + + payload := `{"schema":"public","storageUnit":"users","rows":[{"id":1}]}` + req := httptest.NewRequest(http.MethodPost, "/api/export", bytes.NewBufferString(payload)) + req = req.WithContext(context.WithValue(req.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected export to succeed, got %d", rec.Code) + } + + // Failure case + failMock := testutil.NewPluginMock(engine.DatabaseType("Test")) + failMock.ExportDataFunc = func(*engine.PluginConfig, string, string, func([]string) error, []map[string]any) error { + return errors.New("export failed") + } + setEngineMock(t, failMock) + + req = httptest.NewRequest(http.MethodPost, "/api/export", bytes.NewBufferString(payload)) + req = req.WithContext(context.WithValue(req.Context(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"})) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected export failure to return 500, got %d", rec.Code) + } +} diff --git a/dataflow/core/graph/http_sql_agent_handler.go b/dataflow/core/graph/http_sql_agent_handler.go new file mode 100644 index 0000000..dd40d3b --- /dev/null +++ b/dataflow/core/graph/http_sql_agent_handler.go @@ -0,0 +1,56 @@ +//go:build !arm && !riscv64 + +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import "net/http" + +// SQLAgentHandlerFunc is the type for the SQL agent stream handler. +// EE registers its implementation via RegisterSQLAgentHandler. +type SQLAgentHandlerFunc func(w http.ResponseWriter, r *http.Request) + +var registeredSQLAgentHandler SQLAgentHandlerFunc +var registeredPermitHandler SQLAgentHandlerFunc + +// RegisterSQLAgentHandler allows EE to register its SQL agent stream handler. +func RegisterSQLAgentHandler(handler SQLAgentHandlerFunc) { + registeredSQLAgentHandler = handler +} + +// RegisterPermitHandler allows EE to register its SQL agent permission handler. +func RegisterPermitHandler(handler SQLAgentHandlerFunc) { + registeredPermitHandler = handler +} + +// sqlAgentStreamHandler delegates to the registered implementation. +func sqlAgentStreamHandler(w http.ResponseWriter, r *http.Request) { + if registeredSQLAgentHandler != nil { + registeredSQLAgentHandler(w, r) + return + } + http.Error(w, "SQL Agent not available in this edition", http.StatusNotImplemented) +} + +// sqlAgentPermitHandler delegates to the registered implementation. +func sqlAgentPermitHandler(w http.ResponseWriter, r *http.Request) { + if registeredPermitHandler != nil { + registeredPermitHandler(w, r) + return + } + http.Error(w, "SQL Agent not available in this edition", http.StatusNotImplemented) +} diff --git a/dataflow/core/graph/http_sql_agent_unsupported.go b/dataflow/core/graph/http_sql_agent_unsupported.go new file mode 100644 index 0000000..b14d605 --- /dev/null +++ b/dataflow/core/graph/http_sql_agent_unsupported.go @@ -0,0 +1,34 @@ +//go:build arm || riscv64 + +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +// http_sql_agent_unsupported.go provides a stub for arm/riscv64 platforms +// where the SQL Agent is not supported. + +package graph + +import "net/http" + +// sqlAgentStreamHandler returns not-implemented on unsupported platforms. +func sqlAgentStreamHandler(w http.ResponseWriter, r *http.Request) { + http.Error(w, "SQL Agent not available on this platform", http.StatusNotImplemented) +} + +// sqlAgentPermitHandler returns not-implemented on unsupported platforms. +func sqlAgentPermitHandler(w http.ResponseWriter, r *http.Request) { + http.Error(w, "SQL Agent not available on this platform", http.StatusNotImplemented) +} diff --git a/dataflow/core/graph/import_collection.go b/dataflow/core/graph/import_collection.go new file mode 100644 index 0000000..a51ecf5 --- /dev/null +++ b/dataflow/core/graph/import_collection.go @@ -0,0 +1,267 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import ( + "bytes" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/engine" + "go.mongodb.org/mongo-driver/bson" +) + +// collectionImportFileFormat maps the tabular collection formats to the shared +// ImportFileFormat. JSON is not tabular and returns ok=false. +func collectionImportFileFormat(format model.CollectionImportFormat) (model.ImportFileFormat, bool) { + switch format { + case model.CollectionImportFormatCSV: + return model.ImportFileFormatCSV, true + case model.CollectionImportFormatExcel: + return model.ImportFileFormatExcel, true + default: + return "", false + } +} + +// parseCollectionDocuments parses an uploaded file into documents ready for a +// MongoDB collection. JSON preserves nested structure and types via Extended +// JSON; CSV/Excel rows become flat string-valued documents. +func parseCollectionDocuments(data []byte, format model.CollectionImportFormat, delimiter *string, sheet *string, skipColumns []string, maxRows int, enforceRowCap bool) ([]map[string]any, bool, error) { + if format == model.CollectionImportFormatJSON { + return parseJSONDocuments(data, maxRows, enforceRowCap) + } + + fileFormat, ok := collectionImportFileFormat(format) + if !ok { + return nil, false, newImportValidationError(importValidationUnsupportedFormat) + } + + options := &model.ImportFileOptions{Format: fileFormat, Delimiter: delimiter, Sheet: sheet} + parsed, err := parseImportFile(data, options, maxRows, enforceRowCap) + if err != nil { + return nil, false, err + } + return buildDocumentsFromRows(parsed, skipColumns), parsed.truncated, nil +} + +// parseJSONDocuments parses a JSON import file into documents. It accepts either +// a top-level JSON array of objects or newline-delimited JSON (one object per +// line), chosen by the first non-whitespace byte. Values use MongoDB Extended +// JSON (relaxed) semantics, matching the JSON document editor. +func parseJSONDocuments(data []byte, maxRows int, enforceRowCap bool) ([]map[string]any, bool, error) { + trimmed := bytes.TrimLeft(data, " \t\r\n") + if len(trimmed) == 0 { + return nil, false, newImportValidationError(importValidationParseFailed) + } + + var documents []map[string]any + if trimmed[0] == '[' { + if err := bson.UnmarshalExtJSON(trimmed, false, &documents); err != nil { + return nil, false, newImportValidationError(importValidationParseFailed) + } + } else { + for _, raw := range bytes.Split(trimmed, []byte("\n")) { + line := bytes.TrimSpace(raw) + if len(line) == 0 { + continue + } + var doc map[string]any + if err := bson.UnmarshalExtJSON(line, false, &doc); err != nil { + return nil, false, newImportValidationError(importValidationParseFailed) + } + documents = append(documents, doc) + } + } + + if maxRows > 0 && len(documents) > maxRows { + if enforceRowCap { + return nil, false, newImportValidationError(importValidationRowLimitExceeded) + } + return documents[:maxRows], true, nil + } + return documents, false, nil +} + +// buildDocumentsFromRows converts parsed tabular rows into documents. Each row +// becomes one document keyed by the header columns; skipped columns are dropped +// and empty cells are omitted so the field is simply absent. Values stay strings +// (no type inference), matching the documented Collection File Import behavior. +func buildDocumentsFromRows(parsed *importParseResult, skipColumns []string) []map[string]any { + skip := make(map[string]bool, len(skipColumns)) + for _, column := range skipColumns { + skip[column] = true + } + + documents := make([]map[string]any, 0, len(parsed.rows)) + for _, row := range parsed.rows { + document := make(map[string]any, len(parsed.columns)) + for i, column := range parsed.columns { + if skip[column] || i >= len(row) || row[i] == "" { + continue + } + document[column] = row[i] + } + documents = append(documents, document) + } + return documents +} + +// buildCollectionImportPreview parses a sample of the file for display. It never +// returns a hard error for malformed content; parse problems are reported in the +// preview's ValidationError so the modal can show them inline. +func buildCollectionImportPreview(data []byte, input model.ImportCollectionPreviewInput) *model.CollectionImportPreview { + preview := &model.CollectionImportPreview{ + Format: input.Format, + Sheets: []string{}, + Columns: []string{}, + Rows: [][]string{}, + Documents: []string{}, + } + + if input.Format == model.CollectionImportFormatJSON { + documents, _, err := parseJSONDocuments(data, maxImportRows, false) + if err != nil { + preview.ValidationError = validationKeyPtr(err) + return preview + } + total := len(documents) + preview.Count = &total + limit := total + if limit > importPreviewRowLimit { + limit = importPreviewRowLimit + preview.Truncated = true + } + rendered := make([]string, 0, limit) + for _, document := range documents[:limit] { + text, marshalErr := bson.MarshalExtJSON(document, false, false) + if marshalErr != nil { + continue + } + rendered = append(rendered, string(text)) + } + preview.Documents = rendered + return preview + } + + fileFormat, ok := collectionImportFileFormat(input.Format) + if !ok { + key := importValidationUnsupportedFormat + preview.ValidationError = &key + return preview + } + + options := &model.ImportFileOptions{Format: fileFormat, Delimiter: input.Delimiter, Sheet: input.Sheet} + parsed, err := parseImportFile(data, options, importPreviewRowLimit, false) + if err != nil { + preview.ValidationError = validationKeyPtr(err) + return preview + } + if parsed.sheet != "" { + preview.Sheet = &parsed.sheet + } + preview.Sheets = parsed.sheets + preview.Columns = parsed.columns + preview.Rows = parsed.rows + preview.Truncated = parsed.truncated + return preview +} + +// runCollectionImport executes a parsed import against the collection importer, +// applying the requested mode. Overwrite clears the collection first, then +// inserts; the clear is not transactional and cannot be rolled back. +func runCollectionImport(plugin *engine.Plugin, config *engine.PluginConfig, importer engine.CollectionImporter, input model.ImportCollectionFileInput, documents []map[string]any) (*model.CollectionImportResult, error) { + if input.Mode == model.ImportModeOverwrite { + if _, err := plugin.ClearTableData(config, input.Schema, input.Collection); err != nil { + return collectionImportFailure(importErrorClearFailed, err.Error()), nil + } + } + + if input.Mode == model.ImportModeUpsert { + outcome, failures, err := importer.UpsertDocuments(config, input.Schema, input.Collection, input.UpsertKeys, documents) + if err != nil { + return collectionImportFailure(importErrorImportFailed, err.Error()), nil + } + return collectionUpsertResult(len(documents), outcome, failures), nil + } + + inserted, failures, err := importer.InsertDocuments(config, input.Schema, input.Collection, documents) + if err != nil { + return collectionImportFailure(importErrorImportFailed, err.Error()), nil + } + return collectionInsertResult(inserted, failures), nil +} + +// collectionInsertResult builds a successful result for insert-based modes. +func collectionInsertResult(inserted int, failures []engine.DocumentImportFailure) *model.CollectionImportResult { + return &model.CollectionImportResult{ + Status: true, + ImportedCount: inserted, + SkippedCount: len(failures), + Errors: collectionImportErrors(failures), + } +} + +// collectionUpsertResult builds a successful result for upsert mode, exposing the +// matched/modified/upserted breakdown alongside the processed and skipped counts. +func collectionUpsertResult(total int, outcome engine.DocumentUpsertResult, failures []engine.DocumentImportFailure) *model.CollectionImportResult { + matched := outcome.Matched + modified := outcome.Modified + upserted := outcome.Upserted + return &model.CollectionImportResult{ + Status: true, + ImportedCount: total - len(failures), + SkippedCount: len(failures), + MatchedCount: &matched, + ModifiedCount: &modified, + UpsertedCount: &upserted, + Errors: collectionImportErrors(failures), + } +} + +// collectionImportFailure builds a failed result carrying an i18n detail key and +// an optional raw message. +func collectionImportFailure(detail string, message ...string) *model.CollectionImportResult { + result := &model.CollectionImportResult{ + Status: false, + Errors: []*model.CollectionImportError{}, + Detail: &detail, + } + if len(message) > 0 && message[0] != "" { + result.Message = &message[0] + } + return result +} + +// collectionImportErrors converts skipped-document failures into result errors, +// capped at collectionImportErrorLimit to avoid flooding the client. +func collectionImportErrors(failures []engine.DocumentImportFailure) []*model.CollectionImportError { + limit := len(failures) + if limit > collectionImportErrorLimit { + limit = collectionImportErrorLimit + } + errors := make([]*model.CollectionImportError, 0, limit) + for _, failure := range failures[:limit] { + errors = append(errors, &model.CollectionImportError{Index: failure.Index, Reason: failure.Reason}) + } + return errors +} + +// validationKeyPtr returns a pointer to the i18n validation key for err. +func validationKeyPtr(err error) *string { + key := validationKeyFromError(err) + return &key +} diff --git a/dataflow/core/graph/import_collection_test.go b/dataflow/core/graph/import_collection_test.go new file mode 100644 index 0000000..4a7734a --- /dev/null +++ b/dataflow/core/graph/import_collection_test.go @@ -0,0 +1,267 @@ +package graph + +import ( + "testing" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/engine" + "github.com/xuri/excelize/v2" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestParseJSONDocumentsArray(t *testing.T) { + docs, truncated, err := parseJSONDocuments([]byte(`[{"a":"x"},{"a":"y"}]`), 0, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if truncated { + t.Fatalf("did not expect truncation") + } + if len(docs) != 2 { + t.Fatalf("expected 2 documents, got %d", len(docs)) + } + if docs[0]["a"] != "x" || docs[1]["a"] != "y" { + t.Fatalf("unexpected documents: %+v", docs) + } +} + +func TestParseJSONDocumentsNDJSON(t *testing.T) { + docs, _, err := parseJSONDocuments([]byte("{\"a\":\"1\"}\n\n{\"a\":\"2\"}\n"), 0, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(docs) != 2 { + t.Fatalf("expected 2 documents (blank line skipped), got %d", len(docs)) + } +} + +func TestParseJSONDocumentsExtendedJSONTypes(t *testing.T) { + data := []byte(`[{"_id":{"$oid":"5d505646cf6d4fe581014ab2"},"n":{"$numberInt":"5"},"big":{"$numberLong":"9000000000"}}]`) + docs, _, err := parseJSONDocuments(data, 0, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := docs[0]["_id"].(primitive.ObjectID); !ok { + t.Fatalf("expected _id to be ObjectID, got %T", docs[0]["_id"]) + } + if v, ok := docs[0]["n"].(int32); !ok || v != 5 { + t.Fatalf("expected n to be int32(5), got %T %v", docs[0]["n"], docs[0]["n"]) + } + if v, ok := docs[0]["big"].(int64); !ok || v != 9000000000 { + t.Fatalf("expected big to be int64, got %T %v", docs[0]["big"], docs[0]["big"]) + } +} + +func TestParseJSONDocumentsEmptyAndMalformed(t *testing.T) { + if _, _, err := parseJSONDocuments([]byte(" \n "), 0, false); err == nil { + t.Fatalf("expected error for empty input") + } + if _, _, err := parseJSONDocuments([]byte(`[{"a":}]`), 0, false); err == nil { + t.Fatalf("expected error for malformed array") + } + if _, _, err := parseJSONDocuments([]byte("{\"a\":1}\n{bad}"), 0, false); err == nil { + t.Fatalf("expected error for malformed NDJSON line") + } +} + +func TestParseJSONDocumentsRowCap(t *testing.T) { + data := []byte(`[{"a":"1"},{"a":"2"},{"a":"3"}]`) + + if _, _, err := parseJSONDocuments(data, 2, true); err == nil || validationKeyFromError(err) != importValidationRowLimitExceeded { + t.Fatalf("expected row limit error, got %v", err) + } + + docs, truncated, err := parseJSONDocuments(data, 2, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !truncated || len(docs) != 2 { + t.Fatalf("expected truncation to 2 docs, got %d truncated=%v", len(docs), truncated) + } +} + +func TestBuildDocumentsFromRows(t *testing.T) { + parsed := &importParseResult{ + columns: []string{"name", "age", "note"}, + rows: [][]string{ + {"Alice", "30", "hi"}, + {"", "25", ""}, + }, + } + + docs := buildDocumentsFromRows(parsed, []string{"note"}) + if len(docs) != 2 { + t.Fatalf("expected 2 documents, got %d", len(docs)) + } + // note column is skipped everywhere + if _, ok := docs[0]["note"]; ok { + t.Fatalf("expected note column to be skipped") + } + // empty cells omitted + if _, ok := docs[1]["name"]; ok { + t.Fatalf("expected empty name cell to be omitted") + } + if docs[0]["name"] != "Alice" || docs[0]["age"] != "30" { + t.Fatalf("unexpected first document: %+v", docs[0]) + } + // values stay strings (no inference) + if _, ok := docs[0]["age"].(string); !ok { + t.Fatalf("expected age to remain a string, got %T", docs[0]["age"]) + } +} + +func TestCollectionImportFileFormat(t *testing.T) { + if f, ok := collectionImportFileFormat(model.CollectionImportFormatCSV); !ok || f != model.ImportFileFormatCSV { + t.Fatalf("expected CSV mapping, got %v ok=%v", f, ok) + } + if f, ok := collectionImportFileFormat(model.CollectionImportFormatExcel); !ok || f != model.ImportFileFormatExcel { + t.Fatalf("expected Excel mapping, got %v ok=%v", f, ok) + } + if _, ok := collectionImportFileFormat(model.CollectionImportFormatJSON); ok { + t.Fatalf("expected JSON to not map to a tabular format") + } +} + +func TestParseCollectionDocumentsCSV(t *testing.T) { + data := []byte("name,age\nAlice,30\nBob,40\n") + docs, _, err := parseCollectionDocuments(data, model.CollectionImportFormatCSV, nil, nil, []string{"age"}, 0, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(docs) != 2 { + t.Fatalf("expected 2 documents, got %d", len(docs)) + } + if _, ok := docs[0]["age"]; ok { + t.Fatalf("expected age column to be skipped") + } + if docs[0]["name"] != "Alice" { + t.Fatalf("unexpected document: %+v", docs[0]) + } +} + +func TestBuildCollectionImportPreviewJSON(t *testing.T) { + data := []byte(`[{"a":"1"},{"a":"2"}]`) + preview := buildCollectionImportPreview(data, model.ImportCollectionPreviewInput{Format: model.CollectionImportFormatJSON}) + if preview.ValidationError != nil { + t.Fatalf("unexpected validation error: %v", *preview.ValidationError) + } + if preview.Count == nil || *preview.Count != 2 { + t.Fatalf("expected count 2, got %v", preview.Count) + } + if len(preview.Documents) != 2 { + t.Fatalf("expected 2 rendered documents, got %d", len(preview.Documents)) + } +} + +func TestBuildCollectionImportPreviewInvalidJSON(t *testing.T) { + preview := buildCollectionImportPreview([]byte("not json"), model.ImportCollectionPreviewInput{Format: model.CollectionImportFormatJSON}) + if preview.ValidationError == nil { + t.Fatalf("expected a validation error for invalid JSON") + } +} + +func TestBuildCollectionImportPreviewCSV(t *testing.T) { + data := []byte("name,age\nAlice,30\n") + preview := buildCollectionImportPreview(data, model.ImportCollectionPreviewInput{Format: model.CollectionImportFormatCSV}) + if preview.ValidationError != nil { + t.Fatalf("unexpected validation error: %v", *preview.ValidationError) + } + if len(preview.Columns) != 2 || preview.Columns[0] != "name" { + t.Fatalf("unexpected columns: %+v", preview.Columns) + } + if len(preview.Rows) != 1 { + t.Fatalf("expected 1 preview row, got %d", len(preview.Rows)) + } +} + +func TestBuildCollectionImportPreviewExcel(t *testing.T) { + f := excelize.NewFile() + sheet := f.GetSheetName(0) + secondSheet := "Second" + _, _ = f.NewSheet(secondSheet) + _ = f.SetSheetRow(sheet, "A1", &[]any{"name", "age"}) + _ = f.SetSheetRow(sheet, "A2", &[]any{"Alice", "30"}) + _ = f.SetSheetRow(secondSheet, "A1", &[]any{"title"}) + _ = f.SetSheetRow(secondSheet, "A2", &[]any{"Report"}) + buf, err := f.WriteToBuffer() + if err != nil { + t.Fatalf("failed to build excel: %v", err) + } + preview := buildCollectionImportPreview(buf.Bytes(), model.ImportCollectionPreviewInput{Format: model.CollectionImportFormatExcel}) + if preview.ValidationError != nil { + t.Fatalf("unexpected validation error: %v", *preview.ValidationError) + } + if len(preview.Columns) != 2 { + t.Fatalf("expected 2 columns, got %+v", preview.Columns) + } + if preview.Sheet == nil || *preview.Sheet != sheet { + t.Fatalf("expected default sheet %q, got %v", sheet, preview.Sheet) + } + if len(preview.Sheets) != 2 || preview.Sheets[0] != sheet || preview.Sheets[1] != secondSheet { + t.Fatalf("unexpected sheets: %+v", preview.Sheets) + } + + preview = buildCollectionImportPreview(buf.Bytes(), model.ImportCollectionPreviewInput{ + Format: model.CollectionImportFormatExcel, + Sheet: &secondSheet, + }) + if preview.ValidationError != nil { + t.Fatalf("unexpected selected sheet validation error: %v", *preview.ValidationError) + } + if preview.Sheet == nil || *preview.Sheet != secondSheet { + t.Fatalf("expected selected sheet %q, got %v", secondSheet, preview.Sheet) + } + if len(preview.Columns) != 1 || preview.Columns[0] != "title" { + t.Fatalf("unexpected selected sheet columns: %+v", preview.Columns) + } +} + +func TestCollectionImportErrorsCap(t *testing.T) { + failures := make([]engine.DocumentImportFailure, collectionImportErrorLimit+10) + for i := range failures { + failures[i] = engine.DocumentImportFailure{Index: i, Reason: "dup"} + } + errs := collectionImportErrors(failures) + if len(errs) != collectionImportErrorLimit { + t.Fatalf("expected errors capped at %d, got %d", collectionImportErrorLimit, len(errs)) + } +} + +func TestCollectionResultBuilders(t *testing.T) { + failures := []engine.DocumentImportFailure{{Index: 1, Reason: "dup"}} + + insert := collectionInsertResult(9, failures) + if !insert.Status || insert.ImportedCount != 9 || insert.SkippedCount != 1 { + t.Fatalf("unexpected insert result: %+v", insert) + } + + upsert := collectionUpsertResult(10, engine.DocumentUpsertResult{Matched: 4, Modified: 3, Upserted: 5}, failures) + if upsert.ImportedCount != 9 || upsert.SkippedCount != 1 { + t.Fatalf("unexpected upsert counts: %+v", upsert) + } + if upsert.MatchedCount == nil || *upsert.MatchedCount != 4 { + t.Fatalf("expected matched 4, got %v", upsert.MatchedCount) + } + if upsert.UpsertedCount == nil || *upsert.UpsertedCount != 5 { + t.Fatalf("expected upserted 5, got %v", upsert.UpsertedCount) + } +} + +// ensure failure builder marshals the detail key without panicking on no message +func TestCollectionImportFailure(t *testing.T) { + res := collectionImportFailure(importErrorCollectionUnsupported) + if res.Status { + t.Fatalf("expected failure result") + } + if res.Detail == nil || *res.Detail != importErrorCollectionUnsupported { + t.Fatalf("expected detail key, got %v", res.Detail) + } + if len(res.Errors) != 0 { + t.Fatalf("expected no per-document errors") + } + + withMsg := collectionImportFailure(importErrorImportFailed, "boom") + if withMsg.Message == nil || *withMsg.Message != "boom" { + t.Fatalf("expected message, got %v", withMsg.Message) + } +} diff --git a/dataflow/core/graph/import_constants.go b/dataflow/core/graph/import_constants.go new file mode 100644 index 0000000..77d5df2 --- /dev/null +++ b/dataflow/core/graph/import_constants.go @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +const ( + maxImportRows = 100000 + maxImportFileSizeBytes = int64(50 * 1024 * 1024) + importPreviewRowLimit = 25 + importBatchSize = 1000 + + importValidationGeneric = "import.validation.generic" + importValidationMissingFile = "import.validation.missing_file" + importValidationFileTooLarge = "import.validation.file_too_large" + importValidationInvalidOptions = "import.validation.invalid_options" + importValidationUnsupportedFormat = "import.validation.unsupported_format" + importValidationAmbiguousDelimiter = "import.validation.ambiguous_delimiter" + importValidationInvalidDelimiter = "import.validation.invalid_delimiter" + importValidationParseFailed = "import.validation.parse_failed" + importValidationNoColumns = "import.validation.no_columns" + importValidationRowTooManyColumns = "import.validation.row_too_many_columns" + importValidationRowLimitExceeded = "import.validation.row_limit_exceeded" + importValidationDuplicateHeader = "import.validation.duplicate_header" + importValidationEmptyHeader = "import.validation.empty_header" + importValidationColumnCountMismatch = "import.validation.column_count_mismatch" + importValidationColumnCountInsertable = "import.validation.column_count_insertable" + importValidationUnknownColumn = "import.validation.column_unknown" + importValidationGeneratedColumn = "import.validation.column_generated" + importValidationAutoGeneratedColumn = "import.validation.column_auto_generated" + importValidationMappingInvalid = "import.validation.mapping_invalid" + importValidationAutoGeneratedToggle = "import.validation.auto_generated_requires_toggle" + importValidationTargetModeInvalid = "import.validation.target_mode_invalid" + importValidationTableNameEmpty = "import.validation.table_name_empty" + importValidationTableExists = "import.validation.table_exists" + importValidationColumnNameEmpty = "import.validation.column_name_empty" + importValidationColumnDuplicate = "import.validation.column_duplicate" + importValidationColumnTypeInvalid = "import.validation.column_type_invalid" + importValidationAllColumnsSkipped = "import.validation.all_columns_skipped" + importValidationPrimarySkipped = "import.validation.primary_skipped" + importValidationPrimaryNullable = "import.validation.primary_nullable" + + // SQL import error keys + importErrorSQLSourceBoth = "import.error.sql_source_both" + importErrorSQLSourceMissing = "import.error.sql_source_missing" + importErrorSQLTooLarge = "import.error.sql_too_large" + importErrorSQLFailed = "import.error.sql_failed" + importErrorSQLFileFailed = "import.error.sql_file_failed" + importErrorSQLMultiStatementUnsupported = "import.error.sql_multi_statement_unsupported" + importErrorNoRows = "import.error.no_rows" + importErrorTableColumns = "import.error.table_columns" + importErrorImportFailed = "import.error.import_failed" + importErrorClearFailed = "import.error.clear_failed" + importErrorInsertFailed = "import.error.insert_failed" + importErrorUpsertNoPK = "import.error.upsert_no_pk" + importErrorUpsertNotSupported = "import.error.upsert_not_supported" + + // Collection (document) import error keys + importErrorCollectionUnsupported = "import.error.collection_unsupported" + + // collectionImportErrorLimit caps how many per-document failures are returned + // to the client; SkippedCount still reflects the true total. + collectionImportErrorLimit = 50 +) diff --git a/dataflow/core/graph/import_file_resolvers.go b/dataflow/core/graph/import_file_resolvers.go new file mode 100644 index 0000000..89f2c92 --- /dev/null +++ b/dataflow/core/graph/import_file_resolvers.go @@ -0,0 +1,222 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import ( + "fmt" + "strings" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" +) + +func importMappingPreviewItems(mappings []*model.ImportColumnMapping) []*model.ImportColumnMappingPreview { + previewItems := make([]*model.ImportColumnMappingPreview, 0, len(mappings)) + for _, mapping := range mappings { + if mapping == nil || mapping.TargetColumn == nil { + continue + } + previewItems = append(previewItems, &model.ImportColumnMappingPreview{ + SourceColumn: mapping.SourceColumn, + TargetColumn: *mapping.TargetColumn, + }) + } + return previewItems +} + +func importExistingTableFile(plugin *engine.Plugin, config *engine.PluginConfig, parsed *importParseResult, target *model.ImportExistingTableInput) (*model.ImportResult, error) { + columns, err := plugin.GetColumnsForTable(config, target.Schema, target.StorageUnit) + if err != nil { + return importResult(false, importErrorTableColumns), nil + } + if err := plugin.MarkGeneratedColumns(config, target.Schema, target.StorageUnit, columns); err != nil { + log.WithError(err).Warn("Failed to mark generated columns for import") + } + + allowAutoGenerated := false + if target.AllowAutoGenerated != nil { + allowAutoGenerated = *target.AllowAutoGenerated + } + + mappings, err := resolveImportMappings(parsed.columns, target.Mapping, columns, allowAutoGenerated) + if err != nil { + return importResult(false, validationKeyFromError(err)), nil + } + + err = plugin.WithTransaction(config, func(tx any) error { + cloned := *config + if tx != nil { + cloned.Transaction = tx + } + txConfig := &cloned + + if target.Mode == model.ImportModeOverwrite { + graph, err := plugin.GetGraph(txConfig, target.Schema) + if err != nil { + return fmt.Errorf(importErrorClearFailed+": %w", err) + } + if err := plugins.ClearTableWithDependencies(plugin, txConfig, target.Schema, target.StorageUnit, graph); err != nil { + return fmt.Errorf(importErrorClearFailed+": %w", err) + } + } + + if target.Mode == model.ImportModeAppend || target.Mode == model.ImportModeUpsert { + var pkCols []string + for _, col := range columns { + if col.IsPrimary { + pkCols = append(pkCols, col.Name) + } + } + if target.Mode == model.ImportModeUpsert { + if len(pkCols) == 0 { + return fmt.Errorf(importErrorUpsertNoPK) + } + txConfig.UpsertPKColumns = pkCols + } else if len(pkCols) > 0 { + txConfig.SkipConflictPKColumns = pkCols + } + } + + return importRowsInBatches(plugin, txConfig, target.Schema, target.StorageUnit, parsed.rows, mappings) + }) + if err != nil { + errMsg := err.Error() + if errMsg == importErrorUpsertNoPK || errMsg == importErrorUpsertNotSupported { + return importResult(false, errMsg), nil + } + log.WithError(err).Error("Import failed") + return importResultWithMessage(false, importErrorImportFailed, err.Error()), nil + } + + return importResult(true, ""), nil +} + +func importNewTableFile(plugin *engine.Plugin, config *engine.PluginConfig, parsed *importParseResult, target *model.ImportNewTableInput) (*model.ImportResult, error) { + tableName := strings.TrimSpace(target.TableName) + if tableName == "" { + return importResult(false, importValidationTableNameEmpty), nil + } + + exists, err := plugin.StorageUnitExists(config, target.Schema, tableName) + if err != nil { + return importResultWithMessage(false, importErrorImportFailed, err.Error()), nil + } + if exists { + return importResult(false, importValidationTableExists), nil + } + + resolvedColumns, fields, err := resolveNewTableImportColumns(parsed.columns, target.Columns, plugin.GetDatabaseMetadata()) + if err != nil { + return importResult(false, validationKeyFromError(err)), nil + } + + tableCreated := false + err = plugin.WithTransaction(config, func(tx any) error { + cloned := *config + if tx != nil { + cloned.Transaction = tx + } + txConfig := &cloned + + status, err := plugin.AddStorageUnitWithOptions(txConfig, target.Schema, tableName, fields, engine.CreateStorageUnitOptions{ + PrimaryKeysUseSourceValues: true, + }) + if err != nil { + return err + } + if !status { + return fmt.Errorf(importErrorImportFailed) + } + tableCreated = true + + return importNewTableRowsInBatches(plugin, txConfig, target.Schema, tableName, parsed.rows, resolvedColumns) + }) + if err != nil { + if tableCreated { + if _, dropErr := plugin.DropStorageUnit(config, target.Schema, tableName); dropErr != nil { + return importResultWithMessage(false, importErrorImportFailed, fmt.Sprintf("%s; cleanup failed: %s", err.Error(), dropErr.Error())), nil + } + } + log.WithError(err).Error("New table import failed") + return importResultWithMessage(false, importErrorImportFailed, err.Error()), nil + } + + return importResult(true, ""), nil +} + +func importRowsInBatches(plugin *engine.Plugin, config *engine.PluginConfig, schema string, storageUnit string, rows [][]string, mappings []importMapping) error { + for start := 0; start < len(rows); start += importBatchSize { + end := start + importBatchSize + if end > len(rows) { + end = len(rows) + } + + batch := rows[start:end] + records := make([][]engine.Record, 0, len(batch)) + for _, row := range batch { + recordRow := make([]engine.Record, 0, len(mappings)) + for _, mapping := range mappings { + value := "" + if mapping.sourceIndex < len(row) { + value = row[mapping.sourceIndex] + } + + extra := map[string]string{ + "Type": mapping.targetType, + } + if mapping.isNullable { + extra["IsNullable"] = "true" + } + + recordRow = append(recordRow, engine.Record{ + Key: mapping.targetName, + Value: value, + Extra: extra, + }) + } + records = append(records, recordRow) + } + + status, err := plugin.BulkAddRows(config, schema, storageUnit, records) + if err != nil { + return err + } + if !status { + return fmt.Errorf(importErrorInsertFailed) + } + } + + return nil +} + +func importNewTableRowsInBatches(plugin *engine.Plugin, config *engine.PluginConfig, schema string, storageUnit string, rows [][]string, columns []importNewTableColumn) error { + mappings := make([]importMapping, 0, len(columns)) + for _, column := range columns { + if column.skip { + continue + } + mappings = append(mappings, importMapping{ + sourceIndex: column.sourceIndex, + targetName: column.targetName, + targetType: column.targetType, + isNullable: column.isNullable, + }) + } + return importRowsInBatches(plugin, config, schema, storageUnit, rows, mappings) +} diff --git a/dataflow/core/graph/import_helpers.go b/dataflow/core/graph/import_helpers.go new file mode 100644 index 0000000..bf22e28 --- /dev/null +++ b/dataflow/core/graph/import_helpers.go @@ -0,0 +1,756 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import ( + "bytes" + "encoding/csv" + "errors" + "io" + "path/filepath" + "sort" + "strings" + "unicode" + + "github.com/99designs/gqlgen/graphql" + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/engine" + "github.com/xuri/excelize/v2" +) + +type importParseResult struct { + columns []string + rows [][]string + truncated bool + sheet string + sheets []string +} + +type importMapping struct { + sourceIndex int + targetName string + targetType string + isNullable bool +} + +type importNewTableColumn struct { + sourceIndex int + sourceColumn string + targetName string + targetType string + isNullable bool + isPrimary bool + skip bool +} + +type importValidationError struct { + key string +} + +func (err importValidationError) Error() string { + return err.key +} + +func newImportValidationError(key string) error { + return importValidationError{key: key} +} + +func isImportValidationKey(value string) bool { + return strings.HasPrefix(value, "import.validation.") +} + +func validationKeyFromError(err error) string { + if err == nil { + return "" + } + var validationErr importValidationError + if errors.As(err, &validationErr) { + return validationErr.key + } + if isImportValidationKey(err.Error()) { + return err.Error() + } + return importValidationGeneric +} + +func readUploadBytes(upload graphql.Upload, maxBytes int64) ([]byte, error) { + if upload.File == nil { + return nil, newImportValidationError(importValidationMissingFile) + } + + if closer, ok := upload.File.(io.Closer); ok { + defer closer.Close() + } + + if upload.Size > maxBytes { + return nil, newImportValidationError(importValidationFileTooLarge) + } + + limited := io.LimitReader(upload.File, maxBytes+1) + data, err := io.ReadAll(limited) + if err != nil { + return nil, err + } + if int64(len(data)) > maxBytes { + return nil, newImportValidationError(importValidationFileTooLarge) + } + + return data, nil +} + +func parseImportFile(data []byte, options *model.ImportFileOptions, maxRows int, enforceRowCap bool) (*importParseResult, error) { + if options == nil { + return nil, newImportValidationError(importValidationInvalidOptions) + } + + switch options.Format { + case model.ImportFileFormatCSV: + return parseCSVImport(data, options, maxRows, enforceRowCap) + case model.ImportFileFormatExcel: + return parseExcelImport(data, options, maxRows, enforceRowCap) + default: + return nil, newImportValidationError(importValidationUnsupportedFormat) + } +} + +func parseCSVImport(data []byte, options *model.ImportFileOptions, maxRows int, enforceRowCap bool) (*importParseResult, error) { + delimiter := "" + if options.Delimiter != nil { + delimiter = *options.Delimiter + } + if delimiter == "" { + detected, err := detectCSVDelimiter(data) + if err != nil { + return nil, err + } + delimiter = detected + } + if err := validateDelimiter(delimiter); err != nil { + return nil, newImportValidationError(importValidationInvalidDelimiter) + } + + reader := csv.NewReader(bytes.NewReader(data)) + reader.Comma = rune(delimiter[0]) + reader.FieldsPerRecord = -1 + + result := &importParseResult{sheets: []string{}} + firstRow := true + + for { + row, err := reader.Read() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, newImportValidationError(importValidationParseFailed) + } + + if firstRow { + firstRow = false + if err := validateHeaderColumns(row); err != nil { + return nil, err + } + result.columns = row + continue + } + + normalized, err := normalizeRow(row, len(result.columns)) + if err != nil { + return nil, err + } + + result.rows = append(result.rows, normalized) + if stop, err := applyRowLimit(result, maxRows, enforceRowCap); err != nil { + return nil, err + } else if stop { + break + } + } + + if len(result.columns) == 0 { + return nil, newImportValidationError(importValidationNoColumns) + } + + return result, nil +} + +func parseExcelImport(data []byte, options *model.ImportFileOptions, maxRows int, enforceRowCap bool) (*importParseResult, error) { + file, err := excelize.OpenReader(bytes.NewReader(data), excelize.Options{ + UnzipSizeLimit: maxImportFileSizeBytes, + UnzipXMLSizeLimit: maxImportFileSizeBytes, + }) + if err != nil { + return nil, newImportValidationError(importValidationParseFailed) + } + defer file.Close() + + sheetName := "" + if options.Sheet != nil { + sheetName = *options.Sheet + } + sheets := file.GetSheetList() + if len(sheets) == 0 { + return nil, newImportValidationError(importValidationNoColumns) + } + if sheetName == "" { + sheetName = sheets[0] + } + + rows, err := file.Rows(sheetName) + if err != nil { + return nil, newImportValidationError(importValidationParseFailed) + } + defer rows.Close() + + result := &importParseResult{sheet: sheetName, sheets: sheets} + firstRow := true + + for rows.Next() { + row, err := rows.Columns() + if err != nil { + return nil, newImportValidationError(importValidationParseFailed) + } + + if firstRow { + firstRow = false + if err := validateHeaderColumns(row); err != nil { + return nil, err + } + result.columns = row + continue + } + + normalized, err := normalizeRow(row, len(result.columns)) + if err != nil { + return nil, err + } + + result.rows = append(result.rows, normalized) + if stop, err := applyRowLimit(result, maxRows, enforceRowCap); err != nil { + return nil, err + } else if stop { + break + } + } + + if len(result.columns) == 0 { + return nil, newImportValidationError(importValidationNoColumns) + } + + return result, nil +} + +// applyRowLimit enforces the row threshold after a row was appended to result. +// maxRows is the single threshold: when the source exceeds it, enforceRowCap +// (commit) rejects the file, otherwise (preview) the rows are trimmed back to +// maxRows and the result is marked truncated. Returns stop=true when reading +// should halt. maxRows <= 0 means no limit. +func applyRowLimit(result *importParseResult, maxRows int, enforceRowCap bool) (bool, error) { + if maxRows <= 0 || len(result.rows) <= maxRows { + return false, nil + } + if enforceRowCap { + return false, newImportValidationError(importValidationRowLimitExceeded) + } + result.rows = result.rows[:maxRows] + result.truncated = true + return true, nil +} + +func normalizeRow(row []string, columnCount int) ([]string, error) { + if len(row) > columnCount { + return nil, newImportValidationError(importValidationRowTooManyColumns) + } + + if len(row) == columnCount { + return row, nil + } + + normalized := make([]string, columnCount) + copy(normalized, row) + for i := len(row); i < columnCount; i++ { + normalized[i] = "" + } + return normalized, nil +} + +func validateHeaderColumns(columns []string) error { + seen := make(map[string]struct{}, len(columns)) + for _, column := range columns { + if column == "" { + return newImportValidationError(importValidationEmptyHeader) + } + if _, exists := seen[column]; exists { + return newImportValidationError(importValidationDuplicateHeader) + } + seen[column] = struct{}{} + } + return nil +} + +func detectCSVDelimiter(data []byte) (string, error) { + candidates := []rune{',', ';', '|'} + valid := make([]string, 0, len(candidates)) + + for _, candidate := range candidates { + reader := csv.NewReader(bytes.NewReader(data)) + reader.Comma = candidate + reader.FieldsPerRecord = -1 + + records := 0 + expectedFields := -1 + consistent := true + for records < 5 { + row, err := reader.Read() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + consistent = false + break + } + + if len(row) == 0 || (len(row) == 1 && row[0] == "") { + continue + } + + fieldCount := len(row) + if expectedFields == -1 { + expectedFields = fieldCount + } else if expectedFields != fieldCount { + consistent = false + break + } + records++ + } + + if !consistent || records == 0 { + continue + } + if expectedFields <= 1 { + continue + } + valid = append(valid, string(candidate)) + } + + if len(valid) == 0 { + return ",", nil + } + + if len(valid) > 1 { + return "", newImportValidationError(importValidationAmbiguousDelimiter) + } + + return valid[0], nil +} + +func resolveImportMappings( + sourceColumns []string, + mappings []*model.ImportColumnMapping, + targetColumns []engine.Column, + allowAutoGenerated bool, +) ([]importMapping, error) { + if len(mappings) == 0 { + return nil, newImportValidationError(importValidationMappingInvalid) + } + if len(mappings) != len(sourceColumns) { + return nil, newImportValidationError(importValidationMappingInvalid) + } + + sourceIndex := make(map[string]int, len(sourceColumns)) + for idx, col := range sourceColumns { + if _, exists := sourceIndex[col]; exists { + return nil, newImportValidationError(importValidationMappingInvalid) + } + sourceIndex[col] = idx + } + + targetTypes := make(map[string]engine.Column, len(targetColumns)) + for _, column := range targetColumns { + targetTypes[column.Name] = column + } + + seenSources := make(map[string]struct{}, len(sourceColumns)) + seenTargets := make(map[string]struct{}, len(sourceColumns)) + resolved := make([]importMapping, 0, len(sourceColumns)) + mappedCount := 0 + + for _, mapping := range mappings { + if mapping == nil { + return nil, newImportValidationError(importValidationMappingInvalid) + } + if _, exists := sourceIndex[mapping.SourceColumn]; !exists { + return nil, newImportValidationError(importValidationMappingInvalid) + } + if _, exists := seenSources[mapping.SourceColumn]; exists { + return nil, newImportValidationError(importValidationMappingInvalid) + } + seenSources[mapping.SourceColumn] = struct{}{} + + targetName := "" + if mapping.TargetColumn != nil { + targetName = *mapping.TargetColumn + } + + if mapping.Skip { + return nil, newImportValidationError(importValidationMappingInvalid) + } + + if targetName == "" { + return nil, newImportValidationError(importValidationMappingInvalid) + } + + if _, exists := seenTargets[targetName]; exists { + return nil, newImportValidationError(importValidationMappingInvalid) + } + seenTargets[targetName] = struct{}{} + + column, exists := targetTypes[targetName] + if !exists { + return nil, newImportValidationError(importValidationUnknownColumn) + } + if column.IsComputed { + return nil, newImportValidationError(importValidationGeneratedColumn) + } + if column.IsAutoIncrement && !allowAutoGenerated { + return nil, newImportValidationError(importValidationAutoGeneratedColumn) + } + + resolved = append(resolved, importMapping{ + sourceIndex: sourceIndex[mapping.SourceColumn], + targetName: targetName, + targetType: column.Type, + isNullable: column.IsNullable, + }) + mappedCount++ + } + + if len(seenSources) != len(sourceColumns) { + return nil, newImportValidationError(importValidationMappingInvalid) + } + if mappedCount == 0 { + return nil, newImportValidationError(importValidationMappingInvalid) + } + + return resolved, nil +} + +func buildImportMappingInputs( + sourceColumns []string, + targetColumns []engine.Column, + useHeaderMapping bool, + allowAutoGenerated bool, +) ([]*model.ImportColumnMapping, []string, error) { + if len(sourceColumns) == 0 { + return nil, nil, newImportValidationError(importValidationNoColumns) + } + + if useHeaderMapping { + targetByName := make(map[string]engine.Column, len(targetColumns)) + for _, column := range targetColumns { + targetByName[column.Name] = column + } + + mappings := make([]*model.ImportColumnMapping, 0, len(sourceColumns)) + autoGeneratedColumns := make([]string, 0) + for _, source := range sourceColumns { + column, ok := targetByName[source] + if !ok { + return nil, nil, newImportValidationError(importValidationUnknownColumn) + } + if column.IsComputed { + return nil, nil, newImportValidationError(importValidationGeneratedColumn) + } + if column.IsAutoIncrement { + autoGeneratedColumns = append(autoGeneratedColumns, column.Name) + } + targetCopy := column.Name + mappings = append(mappings, &model.ImportColumnMapping{ + SourceColumn: source, + TargetColumn: &targetCopy, + Skip: false, + }) + } + + if !allowAutoGenerated && len(autoGeneratedColumns) > 0 { + return mappings, autoGeneratedColumns, newImportValidationError(importValidationAutoGeneratedToggle) + } + + if _, err := resolveImportMappings(sourceColumns, mappings, targetColumns, allowAutoGenerated); err != nil { + return nil, autoGeneratedColumns, err + } + return mappings, autoGeneratedColumns, nil + } + + insertable := make([]engine.Column, 0, len(targetColumns)) + autoGeneratedColumns := make([]string, 0) + for _, column := range targetColumns { + if column.IsComputed { + continue + } + if column.IsAutoIncrement { + autoGeneratedColumns = append(autoGeneratedColumns, column.Name) + continue + } + insertable = append(insertable, column) + } + + if len(sourceColumns) != len(insertable) { + if len(autoGeneratedColumns) > 0 { + sort.Strings(autoGeneratedColumns) + return nil, autoGeneratedColumns, newImportValidationError(importValidationColumnCountInsertable) + } + return nil, nil, newImportValidationError(importValidationColumnCountMismatch) + } + + mappings := make([]*model.ImportColumnMapping, 0, len(sourceColumns)) + for idx, source := range sourceColumns { + targetCopy := insertable[idx].Name + mappings = append(mappings, &model.ImportColumnMapping{ + SourceColumn: source, + TargetColumn: &targetCopy, + Skip: false, + }) + } + + if _, err := resolveImportMappings(sourceColumns, mappings, targetColumns, false); err != nil { + return nil, nil, err + } + return mappings, nil, nil +} + +func buildNewTablePreview(filename string, sourceColumns []string, metadata *engine.DatabaseMetadata) *model.ImportNewTablePreview { + tableName := normalizeImportIdentifier(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))) + defaultType := defaultImportTextType(metadata) + columns := make([]*model.ImportNewTableColumnPreview, 0, len(sourceColumns)) + issues := make([]*model.ImportSuggestionIssue, 0) + + if tableName == "" { + issues = append(issues, importSuggestionIssue(importValidationTableNameEmpty, "tableName", nil)) + } + + seenTargets := make(map[string]string, len(sourceColumns)) + for _, sourceColumn := range sourceColumns { + targetColumn := normalizeImportIdentifier(sourceColumn) + if targetColumn == "" { + sourceCopy := sourceColumn + issues = append(issues, importSuggestionIssue(importValidationColumnNameEmpty, "targetColumn", &sourceCopy)) + } else if firstSource, exists := seenTargets[targetColumn]; exists { + sourceCopy := sourceColumn + issues = append(issues, importSuggestionIssue(importValidationColumnDuplicate, "targetColumn", &sourceCopy)) + firstSourceCopy := firstSource + issues = append(issues, importSuggestionIssue(importValidationColumnDuplicate, "targetColumn", &firstSourceCopy)) + } else { + seenTargets[targetColumn] = sourceColumn + } + + columns = append(columns, &model.ImportNewTableColumnPreview{ + SourceColumn: sourceColumn, + TargetColumn: targetColumn, + Type: defaultType, + Nullable: true, + Primary: false, + Skip: false, + }) + } + + return &model.ImportNewTablePreview{ + TableName: tableName, + Columns: columns, + Issues: issues, + } +} + +func importSuggestionIssue(key string, field string, sourceColumn *string) *model.ImportSuggestionIssue { + return &model.ImportSuggestionIssue{ + Key: key, + Field: field, + SourceColumn: sourceColumn, + } +} + +func normalizeImportIdentifier(value string) string { + var builder strings.Builder + lastUnderscore := false + + for _, r := range strings.ToLower(strings.TrimSpace(value)) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + builder.WriteRune(r) + lastUnderscore = false + continue + } + if r == '_' || unicode.IsSpace(r) || r == '-' { + if builder.Len() > 0 && !lastUnderscore { + builder.WriteByte('_') + lastUnderscore = true + } + continue + } + if builder.Len() > 0 && !lastUnderscore { + builder.WriteByte('_') + lastUnderscore = true + } + } + + normalized := strings.Trim(builder.String(), "_") + if normalized == "" { + return "" + } + if normalized[0] >= '0' && normalized[0] <= '9' { + return "_" + normalized + } + return normalized +} + +func defaultImportTextType(metadata *engine.DatabaseMetadata) string { + if metadata == nil { + return "" + } + for _, preferred := range []string{"TEXT", "String"} { + for _, typeDefinition := range metadata.TypeDefinitions { + if typeDefinition.ID == preferred { + return typeDefinition.ID + } + } + } + for _, typeDefinition := range metadata.TypeDefinitions { + if typeDefinition.Category == engine.TypeCategoryText { + return typeDefinition.ID + } + } + return "" +} + +func resolveNewTableImportColumns(sourceColumns []string, inputs []*model.ImportNewTableColumnInput, metadata *engine.DatabaseMetadata) ([]importNewTableColumn, []engine.Record, error) { + if len(inputs) == 0 || len(inputs) != len(sourceColumns) { + return nil, nil, newImportValidationError(importValidationMappingInvalid) + } + + sourceIndex := make(map[string]int, len(sourceColumns)) + for idx, column := range sourceColumns { + if _, exists := sourceIndex[column]; exists { + return nil, nil, newImportValidationError(importValidationMappingInvalid) + } + sourceIndex[column] = idx + } + + seenSources := make(map[string]struct{}, len(sourceColumns)) + seenTargets := make(map[string]struct{}, len(sourceColumns)) + resolved := make([]importNewTableColumn, 0, len(inputs)) + fields := make([]engine.Record, 0, len(inputs)) + includedCount := 0 + + for _, input := range inputs { + if input == nil { + return nil, nil, newImportValidationError(importValidationMappingInvalid) + } + idx, exists := sourceIndex[input.SourceColumn] + if !exists { + return nil, nil, newImportValidationError(importValidationMappingInvalid) + } + if _, exists := seenSources[input.SourceColumn]; exists { + return nil, nil, newImportValidationError(importValidationMappingInvalid) + } + seenSources[input.SourceColumn] = struct{}{} + + if input.Skip { + if input.Primary { + return nil, nil, newImportValidationError(importValidationPrimarySkipped) + } + resolved = append(resolved, importNewTableColumn{ + sourceIndex: idx, + sourceColumn: input.SourceColumn, + skip: true, + }) + continue + } + + targetName := "" + if input.TargetColumn != nil { + targetName = strings.TrimSpace(*input.TargetColumn) + } + if targetName == "" { + return nil, nil, newImportValidationError(importValidationColumnNameEmpty) + } + if _, exists := seenTargets[targetName]; exists { + return nil, nil, newImportValidationError(importValidationColumnDuplicate) + } + seenTargets[targetName] = struct{}{} + + targetType := "" + if input.Type != nil { + targetType = strings.TrimSpace(*input.Type) + } + if targetType == "" { + return nil, nil, newImportValidationError(importValidationColumnTypeInvalid) + } + if err := engine.ValidateColumnType(targetType, metadata); err != nil { + return nil, nil, newImportValidationError(importValidationColumnTypeInvalid) + } + if input.Primary && input.Nullable { + return nil, nil, newImportValidationError(importValidationPrimaryNullable) + } + + resolved = append(resolved, importNewTableColumn{ + sourceIndex: idx, + sourceColumn: input.SourceColumn, + targetName: targetName, + targetType: targetType, + isNullable: input.Nullable, + isPrimary: input.Primary, + }) + fields = append(fields, engine.Record{ + Key: targetName, + Value: targetType, + Extra: map[string]string{ + "Nullable": boolString(input.Nullable), + "Primary": boolString(input.Primary), + }, + }) + includedCount++ + } + + if len(seenSources) != len(sourceColumns) { + return nil, nil, newImportValidationError(importValidationMappingInvalid) + } + if includedCount == 0 { + return nil, nil, newImportValidationError(importValidationAllColumnsSkipped) + } + + return resolved, fields, nil +} + +func boolString(value bool) string { + if value { + return "true" + } + return "false" +} + +func importResult(success bool, detail string) *model.ImportResult { + result := &model.ImportResult{Status: success} + if detail != "" { + result.Detail = &detail + } + return result +} + +func importResultWithMessage(success bool, detail string, message string) *model.ImportResult { + result := importResult(success, detail) + result.Message = message + return result +} diff --git a/dataflow/core/graph/import_helpers_test.go b/dataflow/core/graph/import_helpers_test.go new file mode 100644 index 0000000..1764b72 --- /dev/null +++ b/dataflow/core/graph/import_helpers_test.go @@ -0,0 +1,544 @@ +package graph + +import ( + "bytes" + "errors" + "testing" + + "github.com/99designs/gqlgen/graphql" + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/engine" + "github.com/xuri/excelize/v2" +) + +func TestValidationKeyFromError(t *testing.T) { + if got := validationKeyFromError(nil); got != "" { + t.Fatalf("expected empty key for nil error, got %q", got) + } + + if got := validationKeyFromError(newImportValidationError(importValidationMissingFile)); got != importValidationMissingFile { + t.Fatalf("expected %q, got %q", importValidationMissingFile, got) + } + + // Should accept raw key strings as errors + if got := validationKeyFromError(errors.New(importValidationInvalidDelimiter)); got != importValidationInvalidDelimiter { + t.Fatalf("expected %q, got %q", importValidationInvalidDelimiter, got) + } + + if got := validationKeyFromError(errors.New("some other error")); got != importValidationGeneric { + t.Fatalf("expected generic key, got %q", got) + } +} + +func TestReadUploadBytesValidations(t *testing.T) { + t.Run("missing file", func(t *testing.T) { + _, err := readUploadBytes(graphql.Upload{}, 10) + if err == nil || validationKeyFromError(err) != importValidationMissingFile { + t.Fatalf("expected %q error, got %v", importValidationMissingFile, err) + } + }) + + t.Run("too large by advertised size", func(t *testing.T) { + up := graphql.Upload{ + File: bytes.NewReader([]byte("123")), + Size: 100, + } + _, err := readUploadBytes(up, 10) + if err == nil || validationKeyFromError(err) != importValidationFileTooLarge { + t.Fatalf("expected %q error, got %v", importValidationFileTooLarge, err) + } + }) + + t.Run("too large by actual read", func(t *testing.T) { + up := graphql.Upload{ + File: bytes.NewReader([]byte("0123456789ABCDEF")), + Size: 1, + } + _, err := readUploadBytes(up, 10) + if err == nil || validationKeyFromError(err) != importValidationFileTooLarge { + t.Fatalf("expected %q error, got %v", importValidationFileTooLarge, err) + } + }) + + t.Run("success", func(t *testing.T) { + data := []byte("hello") + up := graphql.Upload{ + File: bytes.NewReader(data), + Size: int64(len(data)), + } + got, err := readUploadBytes(up, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(got, data) { + t.Fatalf("expected %q, got %q", string(data), string(got)) + } + }) +} + +func TestDetectCSVDelimiter(t *testing.T) { + t.Run("comma", func(t *testing.T) { + delim, err := detectCSVDelimiter([]byte("a,b\n1,2\n")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if delim != "," { + t.Fatalf("expected ',', got %q", delim) + } + }) + + t.Run("semicolon", func(t *testing.T) { + delim, err := detectCSVDelimiter([]byte("a;b\n1;2\n")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if delim != ";" { + t.Fatalf("expected ';', got %q", delim) + } + }) + + t.Run("pipe", func(t *testing.T) { + delim, err := detectCSVDelimiter([]byte("a|b\n1|2\n")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if delim != "|" { + t.Fatalf("expected '|', got %q", delim) + } + }) + + t.Run("ambiguous", func(t *testing.T) { + _, err := detectCSVDelimiter([]byte("a,b;c\n1,2;3\n4,5;6\n")) + if err == nil || validationKeyFromError(err) != importValidationAmbiguousDelimiter { + t.Fatalf("expected %q error, got %v", importValidationAmbiguousDelimiter, err) + } + }) + + t.Run("defaults to comma when no candidates", func(t *testing.T) { + delim, err := detectCSVDelimiter([]byte("only\none\ncol\n")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if delim != "," { + t.Fatalf("expected ',', got %q", delim) + } + }) +} + +func TestParseCSVImport(t *testing.T) { + opts := &model.ImportFileOptions{ + Format: model.ImportFileFormatCSV, + } + + t.Run("invalid options", func(t *testing.T) { + _, err := parseImportFile([]byte("a,b\n1,2\n"), nil, 0, false) + if err == nil || validationKeyFromError(err) != importValidationInvalidOptions { + t.Fatalf("expected %q error, got %v", importValidationInvalidOptions, err) + } + }) + + t.Run("header validation", func(t *testing.T) { + _, err := parseImportFile([]byte(",b\n1,2\n"), opts, 0, false) + if err == nil || validationKeyFromError(err) != importValidationEmptyHeader { + t.Fatalf("expected %q error, got %v", importValidationEmptyHeader, err) + } + }) + + t.Run("duplicate header", func(t *testing.T) { + _, err := parseImportFile([]byte("a,a\n1,2\n"), opts, 0, false) + if err == nil || validationKeyFromError(err) != importValidationDuplicateHeader { + t.Fatalf("expected %q error, got %v", importValidationDuplicateHeader, err) + } + }) + + t.Run("row normalization and truncation", func(t *testing.T) { + res, err := parseImportFile([]byte("a,b\n1\n2,3\n"), opts, 1, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(res.columns) != 2 || res.columns[0] != "a" { + t.Fatalf("unexpected columns: %#v", res.columns) + } + if len(res.rows) != 1 || len(res.rows[0]) != 2 || res.rows[0][0] != "1" || res.rows[0][1] != "" { + t.Fatalf("unexpected rows: %#v", res.rows) + } + if !res.truncated { + t.Fatalf("expected truncated=true") + } + }) + + t.Run("row too many columns", func(t *testing.T) { + _, err := parseImportFile([]byte("a,b\n1,2,3\n"), opts, 0, false) + if err == nil || validationKeyFromError(err) != importValidationRowTooManyColumns { + t.Fatalf("expected %q error, got %v", importValidationRowTooManyColumns, err) + } + }) + + t.Run("explicit invalid delimiter", func(t *testing.T) { + delim := "=" + optsBad := &model.ImportFileOptions{ + Format: model.ImportFileFormatCSV, + Delimiter: &delim, + } + _, err := parseImportFile([]byte("a=b\n1=2\n"), optsBad, 0, false) + if err == nil || validationKeyFromError(err) != importValidationInvalidDelimiter { + t.Fatalf("expected %q error, got %v", importValidationInvalidDelimiter, err) + } + }) +} + +func TestParseExcelImport(t *testing.T) { + file := excelize.NewFile() + sheet := file.GetSheetName(file.GetActiveSheetIndex()) + secondSheet := "Second" + if _, err := file.NewSheet(secondSheet); err != nil { + t.Fatalf("create sheet failed: %v", err) + } + if err := file.SetCellValue(sheet, "A1", "a"); err != nil { + t.Fatalf("set cell failed: %v", err) + } + if err := file.SetCellValue(sheet, "B1", "b"); err != nil { + t.Fatalf("set cell failed: %v", err) + } + if err := file.SetCellValue(sheet, "A2", "1"); err != nil { + t.Fatalf("set cell failed: %v", err) + } + if err := file.SetCellValue(sheet, "B2", "2"); err != nil { + t.Fatalf("set cell failed: %v", err) + } + if err := file.SetCellValue(secondSheet, "A1", "name"); err != nil { + t.Fatalf("set second sheet cell failed: %v", err) + } + if err := file.SetCellValue(secondSheet, "A2", "alice"); err != nil { + t.Fatalf("set second sheet cell failed: %v", err) + } + + buf, err := file.WriteToBuffer() + if err != nil { + t.Fatalf("failed to serialize excel file: %v", err) + } + + opts := &model.ImportFileOptions{Format: model.ImportFileFormatExcel} + res, err := parseImportFile(buf.Bytes(), opts, 0, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.sheet != sheet { + t.Fatalf("expected default sheet %q, got %q", sheet, res.sheet) + } + if len(res.sheets) != 2 || res.sheets[0] != sheet || res.sheets[1] != secondSheet { + t.Fatalf("expected detected sheet list, got %#v selected=%q", res.sheets, res.sheet) + } + if len(res.columns) != 2 || res.columns[0] != "a" || res.columns[1] != "b" { + t.Fatalf("unexpected columns: %#v", res.columns) + } + if len(res.rows) != 1 || len(res.rows[0]) != 2 || res.rows[0][0] != "1" || res.rows[0][1] != "2" { + t.Fatalf("unexpected rows: %#v", res.rows) + } + + opts.Sheet = &secondSheet + res, err = parseImportFile(buf.Bytes(), opts, 0, false) + if err != nil { + t.Fatalf("unexpected second sheet error: %v", err) + } + if res.sheet != secondSheet { + t.Fatalf("expected selected sheet %q, got %q", secondSheet, res.sheet) + } + if len(res.columns) != 1 || res.columns[0] != "name" { + t.Fatalf("unexpected second sheet columns: %#v", res.columns) + } + if len(res.rows) != 1 || res.rows[0][0] != "alice" { + t.Fatalf("unexpected second sheet rows: %#v", res.rows) + } +} + +func TestParseImportFileRowCap(t *testing.T) { + opts := &model.ImportFileOptions{Format: model.ImportFileFormatCSV} + // Header plus three data rows; the cap threshold is two rows. + data := []byte("a,b\n1,1\n2,2\n3,3\n") + + // Commit semantics: exceeding the cap is rejected rather than silently + // dropping the overflow rows. + if _, err := parseImportFile(data, opts, 2, true); err == nil || validationKeyFromError(err) != importValidationRowLimitExceeded { + t.Fatalf("expected row limit error, got %v", err) + } + + // Preview semantics: the same overflow truncates to the cap. + res, err := parseImportFile(data, opts, 2, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.truncated || len(res.rows) != 2 { + t.Fatalf("expected truncation to 2 rows, got %d truncated=%v", len(res.rows), res.truncated) + } + + // At exactly the cap nothing is dropped and the commit succeeds. + exact := []byte("a,b\n1,1\n2,2\n") + res, err = parseImportFile(exact, opts, 2, true) + if err != nil { + t.Fatalf("unexpected error at exact cap: %v", err) + } + if res.truncated || len(res.rows) != 2 { + t.Fatalf("expected 2 rows without truncation, got %d truncated=%v", len(res.rows), res.truncated) + } +} + +func TestResolveImportMappings(t *testing.T) { + source := []string{"a", "b"} + targetCols := []engine.Column{ + {Name: "col1", Type: "TEXT", IsNullable: true}, + {Name: "col2", Type: "INT", IsNullable: false}, + } + + col1 := "col1" + col2 := "col2" + mappings := []*model.ImportColumnMapping{ + {SourceColumn: "a", TargetColumn: &col1}, + {SourceColumn: "b", TargetColumn: &col2}, + } + + resolved, err := resolveImportMappings(source, mappings, targetCols, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resolved) != 2 || resolved[0].sourceIndex != 0 || resolved[1].sourceIndex != 1 { + t.Fatalf("unexpected mapping result: %#v", resolved) + } + if !resolved[0].isNullable { + t.Fatalf("expected col1 mapping to have isNullable=true") + } + if resolved[1].isNullable { + t.Fatalf("expected col2 mapping to have isNullable=false") + } + + t.Run("unknown target", func(t *testing.T) { + unknown := "nope" + _, err := resolveImportMappings(source, []*model.ImportColumnMapping{ + {SourceColumn: "a", TargetColumn: &unknown}, + {SourceColumn: "b", TargetColumn: &col2}, + }, targetCols, false) + if err == nil || validationKeyFromError(err) != importValidationUnknownColumn { + t.Fatalf("expected %q error, got %v", importValidationUnknownColumn, err) + } + }) + + t.Run("computed target", func(t *testing.T) { + computedCols := []engine.Column{ + {Name: "col1", Type: "TEXT", IsComputed: true}, + {Name: "col2", Type: "INT"}, + } + _, err := resolveImportMappings(source, mappings, computedCols, false) + if err == nil || validationKeyFromError(err) != importValidationGeneratedColumn { + t.Fatalf("expected %q error, got %v", importValidationGeneratedColumn, err) + } + }) + + t.Run("auto increment requires toggle", func(t *testing.T) { + autoCols := []engine.Column{ + {Name: "col1", Type: "TEXT", IsAutoIncrement: true}, + {Name: "col2", Type: "INT"}, + } + _, err := resolveImportMappings(source, mappings, autoCols, false) + if err == nil || validationKeyFromError(err) != importValidationAutoGeneratedColumn { + t.Fatalf("expected %q error, got %v", importValidationAutoGeneratedColumn, err) + } + }) +} + +func TestBuildImportMappingInputs(t *testing.T) { + source := []string{"col1", "col2"} + targetCols := []engine.Column{ + {Name: "col1", Type: "TEXT", IsNullable: true}, + {Name: "col2", Type: "INT", IsNullable: false}, + } + + t.Run("header mapping ok", func(t *testing.T) { + mappings, auto, err := buildImportMappingInputs(source, targetCols, true, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(mappings) != 2 { + t.Fatalf("expected 2 mappings, got %d", len(mappings)) + } + if len(auto) != 0 { + t.Fatalf("expected no auto columns, got %v", auto) + } + }) + + t.Run("header mapping requires toggle", func(t *testing.T) { + targetAuto := []engine.Column{ + {Name: "col1", Type: "TEXT", IsAutoIncrement: true}, + {Name: "col2", Type: "INT"}, + } + mappings, auto, err := buildImportMappingInputs(source, targetAuto, true, false) + if err == nil || validationKeyFromError(err) != importValidationAutoGeneratedToggle { + t.Fatalf("expected %q error, got %v", importValidationAutoGeneratedToggle, err) + } + if len(mappings) != 2 || len(auto) != 1 || auto[0] != "col1" { + t.Fatalf("unexpected return values: mappings=%v auto=%v", mappings, auto) + } + }) + + t.Run("positional mapping count mismatch", func(t *testing.T) { + _, _, err := buildImportMappingInputs([]string{"a"}, targetCols, false, false) + if err == nil || validationKeyFromError(err) != importValidationColumnCountMismatch { + t.Fatalf("expected %q error, got %v", importValidationColumnCountMismatch, err) + } + }) + + t.Run("positional mapping insertable mismatch surfaces auto columns", func(t *testing.T) { + targetAuto := []engine.Column{ + {Name: "id", Type: "INT", IsAutoIncrement: true}, + {Name: "col1", Type: "TEXT"}, + {Name: "col2", Type: "INT"}, + } + + // Source includes the auto-generated id column; positional mapping cannot + // safely map this without enabling the auto-generated toggle. + sourceWithID := []string{"id", "col1", "col2"} + _, auto, err := buildImportMappingInputs(sourceWithID, targetAuto, false, false) + if err == nil || validationKeyFromError(err) != importValidationColumnCountInsertable { + t.Fatalf("expected %q error, got %v", importValidationColumnCountInsertable, err) + } + if len(auto) != 1 || auto[0] != "id" { + t.Fatalf("expected auto columns to be returned, got %v", auto) + } + }) +} + +func TestBuildNewTablePreview(t *testing.T) { + metadata := &engine.DatabaseMetadata{ + TypeDefinitions: []engine.TypeDefinition{ + {ID: "VARCHAR", Category: engine.TypeCategoryText}, + {ID: "TEXT", Category: engine.TypeCategoryText}, + }, + } + + preview := buildNewTablePreview("Customer Import.csv", []string{"Full Name", "E-mail", "123 Code"}, metadata) + if preview.TableName != "customer_import" { + t.Fatalf("unexpected table name: %q", preview.TableName) + } + if len(preview.Columns) != 3 { + t.Fatalf("expected 3 columns, got %d", len(preview.Columns)) + } + if preview.Columns[0].TargetColumn != "full_name" || preview.Columns[1].TargetColumn != "e_mail" || preview.Columns[2].TargetColumn != "_123_code" { + t.Fatalf("unexpected target columns: %#v", preview.Columns) + } + if preview.Columns[0].Type != "TEXT" { + t.Fatalf("expected TEXT default type, got %q", preview.Columns[0].Type) + } + if len(preview.Issues) != 0 { + t.Fatalf("expected no issues, got %#v", preview.Issues) + } + + duplicatePreview := buildNewTablePreview("!!!.csv", []string{"A B", "A-B"}, metadata) + if duplicatePreview.TableName != "" { + t.Fatalf("expected empty table name, got %q", duplicatePreview.TableName) + } + if len(duplicatePreview.Issues) != 3 { + t.Fatalf("expected table-name plus duplicate issues, got %#v", duplicatePreview.Issues) + } +} + +func TestResolveNewTableImportColumns(t *testing.T) { + metadata := &engine.DatabaseMetadata{ + TypeDefinitions: []engine.TypeDefinition{ + {ID: "TEXT", Category: engine.TypeCategoryText}, + {ID: "INT", Category: engine.TypeCategoryNumeric}, + }, + } + source := []string{"id", "name", "notes"} + + inputs := []*model.ImportNewTableColumnInput{ + { + SourceColumn: "id", + TargetColumn: stringPointer("id"), + Type: stringPointer("INT"), + Nullable: false, + Primary: true, + }, + { + SourceColumn: "name", + TargetColumn: stringPointer("name"), + Type: stringPointer("TEXT"), + Nullable: true, + }, + { + SourceColumn: "notes", + TargetColumn: stringPointer("notes"), + Type: stringPointer("TEXT"), + Nullable: true, + Skip: true, + }, + } + + resolved, fields, err := resolveNewTableImportColumns(source, inputs, metadata) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resolved) != 3 || !resolved[2].skip { + t.Fatalf("unexpected resolved columns: %#v", resolved) + } + if len(fields) != 2 { + t.Fatalf("expected 2 fields after skipping one column, got %d", len(fields)) + } + if fields[0].Extra["Primary"] != "true" || fields[0].Extra["Nullable"] != "false" { + t.Fatalf("unexpected primary field metadata: %#v", fields[0].Extra) + } + + t.Run("all columns skipped", func(t *testing.T) { + _, _, err := resolveNewTableImportColumns([]string{"a"}, []*model.ImportNewTableColumnInput{ + {SourceColumn: "a", Skip: true, Nullable: true}, + }, metadata) + if err == nil || validationKeyFromError(err) != importValidationAllColumnsSkipped { + t.Fatalf("expected %q error, got %v", importValidationAllColumnsSkipped, err) + } + }) + + t.Run("primary skipped", func(t *testing.T) { + _, _, err := resolveNewTableImportColumns([]string{"a"}, []*model.ImportNewTableColumnInput{ + {SourceColumn: "a", Primary: true, Skip: true}, + }, metadata) + if err == nil || validationKeyFromError(err) != importValidationPrimarySkipped { + t.Fatalf("expected %q error, got %v", importValidationPrimarySkipped, err) + } + }) + + t.Run("primary nullable", func(t *testing.T) { + _, _, err := resolveNewTableImportColumns([]string{"a"}, []*model.ImportNewTableColumnInput{ + { + SourceColumn: "a", + TargetColumn: stringPointer("a"), + Type: stringPointer("INT"), + Nullable: true, + Primary: true, + }, + }, metadata) + if err == nil || validationKeyFromError(err) != importValidationPrimaryNullable { + t.Fatalf("expected %q error, got %v", importValidationPrimaryNullable, err) + } + }) + + t.Run("duplicate target column", func(t *testing.T) { + _, _, err := resolveNewTableImportColumns([]string{"a", "b"}, []*model.ImportNewTableColumnInput{ + {SourceColumn: "a", TargetColumn: stringPointer("same"), Type: stringPointer("TEXT"), Nullable: true}, + {SourceColumn: "b", TargetColumn: stringPointer("same"), Type: stringPointer("TEXT"), Nullable: true}, + }, metadata) + if err == nil || validationKeyFromError(err) != importValidationColumnDuplicate { + t.Fatalf("expected %q error, got %v", importValidationColumnDuplicate, err) + } + }) + + t.Run("invalid column type", func(t *testing.T) { + _, _, err := resolveNewTableImportColumns([]string{"a"}, []*model.ImportNewTableColumnInput{ + {SourceColumn: "a", TargetColumn: stringPointer("a"), Type: stringPointer("NOPE"), Nullable: true}, + }, metadata) + if err == nil || validationKeyFromError(err) != importValidationColumnTypeInvalid { + t.Fatalf("expected %q error, got %v", importValidationColumnTypeInvalid, err) + } + }) +} + +func stringPointer(value string) *string { + return &value +} diff --git a/dataflow/core/graph/mockdata_resolver_test.go b/dataflow/core/graph/mockdata_resolver_test.go new file mode 100644 index 0000000..b02db16 --- /dev/null +++ b/dataflow/core/graph/mockdata_resolver_test.go @@ -0,0 +1,182 @@ +package graph + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/internal/testutil" + "github.com/clidey/whodb/core/src" + "github.com/clidey/whodb/core/src/auth" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/mockdata" +) + +func TestGenerateMockDataRejectsWhenNotAllowed(t *testing.T) { + originalFlag := env.DisableMockDataGeneration + t.Cleanup(func() { env.DisableMockDataGeneration = originalFlag }) + env.DisableMockDataGeneration = "*" + + r := &mutationResolver{} + _, err := r.GenerateMockData(context.Background(), model.MockDataGenerationInput{ + Schema: "public", + StorageUnit: "users", + RowCount: 10, + Method: "default", + OverwriteExisting: false, + }) + if err == nil { + t.Fatalf("expected mock data generation to be rejected when disabled") + } +} + +func TestGenerateMockDataHandlesSchemaAndConstraintErrors(t *testing.T) { + r := &mutationResolver{} + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + columnsCalled := false + mock.GetColumnsForTableFunc = func(_ *engine.PluginConfig, _, _ string) ([]engine.Column, error) { + columnsCalled = true + return nil, errors.New("failed to fetch columns") + } + + ctx := context.WithValue(context.Background(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"}) + src.MainEngine = &engine.Engine{} + src.MainEngine.RegistryPlugin(mock.AsPlugin()) + + _, err := r.GenerateMockData(ctx, model.MockDataGenerationInput{ + Schema: "public", + StorageUnit: "orders", + RowCount: 5, + Method: "default", + OverwriteExisting: false, + }) + if err == nil || !columnsCalled { + t.Fatalf("expected error when GetColumnsForTable fails and function to be called") + } +} + +func TestGenerateMockDataSucceedsForNoSQLPlugin(t *testing.T) { + r := &mutationResolver{} + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.GetColumnsForTableFunc = func(_ *engine.PluginConfig, _, _ string) ([]engine.Column, error) { + return []engine.Column{{Name: "name", Type: "text"}}, nil + } + mock.GetColumnConstraintsFunc = func(*engine.PluginConfig, string, string) (map[string]map[string]any, error) { + return map[string]map[string]any{ + "name": {"nullable": false}, + }, nil + } + + bulkCalled := false + mock.BulkAddRowsFunc = func(_ *engine.PluginConfig, _, _ string, rows [][]engine.Record) (bool, error) { + bulkCalled = true + if len(rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(rows)) + } + for i, row := range rows { + if len(row) == 0 || row[0].Key != "name" { + t.Fatalf("row %d: expected generated value for name column, got %#v", i, row) + } + } + return true, nil + } + + ctx := context.WithValue(context.Background(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"}) + origEngine := src.MainEngine + src.MainEngine = &engine.Engine{} + src.MainEngine.RegistryPlugin(mock.AsPlugin()) + t.Cleanup(func() { src.MainEngine = origEngine }) + + status, err := r.GenerateMockData(ctx, model.MockDataGenerationInput{ + Schema: "public", + StorageUnit: "orders", + RowCount: 2, + Method: "default", + OverwriteExisting: false, + }) + if err != nil { + t.Fatalf("expected mock data generation to succeed, got %v", err) + } + if status == nil || status.AmountGenerated != 2 { + t.Fatalf("expected two rows generated, got %#v", status) + } + if !bulkCalled { + t.Fatalf("expected BulkAddRows to be called") + } +} + +func TestGenerateMockDataStopsWhenExceedingMax(t *testing.T) { + r := &mutationResolver{} + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.GetColumnsForTableFunc = func(_ *engine.PluginConfig, _, _ string) ([]engine.Column, error) { + return []engine.Column{{Name: "name", Type: "text"}}, nil + } + mock.GetColumnConstraintsFunc = func(*engine.PluginConfig, string, string) (map[string]map[string]any, error) { + return map[string]map[string]any{}, nil + } + mock.BulkAddRowsFunc = func(_ *engine.PluginConfig, _, _ string, _ [][]engine.Record) (bool, error) { return true, nil } + + ctx := context.WithValue(context.Background(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"}) + origEngine := src.MainEngine + src.MainEngine = &engine.Engine{} + src.MainEngine.RegistryPlugin(mock.AsPlugin()) + t.Cleanup(func() { src.MainEngine = origEngine }) + + _, err := r.GenerateMockData(ctx, model.MockDataGenerationInput{ + Schema: "public", + StorageUnit: "orders", + RowCount: mockdata.GetMockDataGenerationMaxRowCount() + 1, + Method: "default", + OverwriteExisting: false, + }) + if err == nil { + t.Fatalf("expected error when requested rows exceed max limit") + } +} + +func TestGenerateMockDataFailsWhenClearTableFails(t *testing.T) { + r := &mutationResolver{} + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.GetColumnsForTableFunc = func(_ *engine.PluginConfig, _, _ string) ([]engine.Column, error) { + return []engine.Column{{Name: "name", Type: "text"}}, nil + } + mock.GetColumnConstraintsFunc = func(*engine.PluginConfig, string, string) (map[string]map[string]any, error) { + return map[string]map[string]any{}, nil + } + clearCalled := false + mock.ClearTableDataFunc = func(*engine.PluginConfig, string, string) (bool, error) { + clearCalled = true + return false, errors.New("clear failed") + } + mock.BulkAddRowsFunc = func(_ *engine.PluginConfig, _, _ string, rows [][]engine.Record) (bool, error) { + return true, nil + } + + ctx := context.WithValue(context.Background(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"}) + origEngine := src.MainEngine + src.MainEngine = &engine.Engine{} + src.MainEngine.RegistryPlugin(mock.AsPlugin()) + t.Cleanup(func() { src.MainEngine = origEngine }) + + // Overwrite mode requires clearing the table first + // If clear fails, the entire operation should fail to prevent duplicate data + _, err := r.GenerateMockData(ctx, model.MockDataGenerationInput{ + Schema: "public", + StorageUnit: "orders", + RowCount: 1, + Method: "default", + OverwriteExisting: true, + }) + if err == nil { + t.Fatalf("expected error when clear fails in overwrite mode") + } + if !clearCalled { + t.Fatalf("expected ClearTableData to be called") + } + if !strings.Contains(err.Error(), "clear") { + t.Fatalf("expected error message to mention clear failure, got %v", err) + } +} diff --git a/dataflow/core/graph/model/models_gen.go b/dataflow/core/graph/model/models_gen.go new file mode 100644 index 0000000..4e77c25 --- /dev/null +++ b/dataflow/core/graph/model/models_gen.go @@ -0,0 +1,1354 @@ +// Code generated by github.com/99designs/gqlgen, DO NOT EDIT. + +package model + +import ( + "bytes" + "fmt" + "io" + "strconv" + + "github.com/99designs/gqlgen/graphql" +) + +type CloudProvider interface { + IsCloudProvider() + GetID() string + GetProviderType() CloudProviderType + GetName() string + GetRegion() string + GetStatus() CloudProviderStatus + GetLastDiscoveryAt() *string + GetDiscoveredCount() int + GetError() *string +} + +type AIChatMessage struct { + Type string `json:"Type"` + Result *RowsResult `json:"Result,omitempty"` + Text string `json:"Text"` + RequiresConfirmation bool `json:"RequiresConfirmation"` +} + +type AIProvider struct { + Type string `json:"Type"` + Name string `json:"Name"` + ProviderID string `json:"ProviderId"` + IsEnvironmentDefined bool `json:"IsEnvironmentDefined"` + IsGeneric bool `json:"IsGeneric"` +} + +type AWSProvider struct { + ID string `json:"Id"` + ProviderType CloudProviderType `json:"ProviderType"` + Name string `json:"Name"` + Region string `json:"Region"` + Status CloudProviderStatus `json:"Status"` + LastDiscoveryAt *string `json:"LastDiscoveryAt,omitempty"` + DiscoveredCount int `json:"DiscoveredCount"` + Error *string `json:"Error,omitempty"` + ProfileName *string `json:"ProfileName,omitempty"` + DiscoverRds bool `json:"DiscoverRDS"` + DiscoverElastiCache bool `json:"DiscoverElastiCache"` + DiscoverDocumentDb bool `json:"DiscoverDocumentDB"` +} + +func (AWSProvider) IsCloudProvider() {} +func (this AWSProvider) GetID() string { return this.ID } +func (this AWSProvider) GetProviderType() CloudProviderType { return this.ProviderType } +func (this AWSProvider) GetName() string { return this.Name } +func (this AWSProvider) GetRegion() string { return this.Region } +func (this AWSProvider) GetStatus() CloudProviderStatus { return this.Status } +func (this AWSProvider) GetLastDiscoveryAt() *string { return this.LastDiscoveryAt } +func (this AWSProvider) GetDiscoveredCount() int { return this.DiscoveredCount } +func (this AWSProvider) GetError() *string { return this.Error } + +type AWSProviderInput struct { + Name string `json:"Name"` + Region string `json:"Region"` + ProfileName *string `json:"ProfileName,omitempty"` + DiscoverRds *bool `json:"DiscoverRDS,omitempty"` + DiscoverElastiCache *bool `json:"DiscoverElastiCache,omitempty"` + DiscoverDocumentDb *bool `json:"DiscoverDocumentDB,omitempty"` +} + +type AWSRegion struct { + ID string `json:"Id"` + Description string `json:"Description"` + Partition string `json:"Partition"` +} + +type AtomicWhereCondition struct { + ColumnType string `json:"ColumnType"` + Key string `json:"Key"` + Operator string `json:"Operator"` + Value string `json:"Value"` +} + +type AuthSessionPayload struct { + SessionToken string `json:"sessionToken"` + ExpiresAt string `json:"expiresAt"` + Type string `json:"type"` + Hostname string `json:"hostname"` + Port string `json:"port"` + Database string `json:"database"` + DisplayName string `json:"displayName"` + InstanceUID *string `json:"instanceUid,omitempty"` +} + +type Capabilities struct { + SupportsScratchpad bool `json:"supportsScratchpad"` + SupportsChat bool `json:"supportsChat"` + SupportsGraph bool `json:"supportsGraph"` + SupportsSchema bool `json:"supportsSchema"` + SupportsDatabaseSwitch bool `json:"supportsDatabaseSwitch"` + SupportsModifiers bool `json:"supportsModifiers"` +} + +type ChatInput struct { + PreviousConversation string `json:"PreviousConversation"` + Query string `json:"Query"` + Model string `json:"Model"` + Token *string `json:"Token,omitempty"` +} + +type CollectionImportError struct { + Index int `json:"Index"` + Reason string `json:"Reason"` +} + +type CollectionImportPreview struct { + Format CollectionImportFormat `json:"Format"` + Sheet *string `json:"Sheet,omitempty"` + Sheets []string `json:"Sheets"` + Columns []string `json:"Columns"` + Rows [][]string `json:"Rows"` + Documents []string `json:"Documents"` + Count *int `json:"Count,omitempty"` + Truncated bool `json:"Truncated"` + ValidationError *string `json:"ValidationError,omitempty"` +} + +type CollectionImportResult struct { + Status bool `json:"Status"` + ImportedCount int `json:"ImportedCount"` + SkippedCount int `json:"SkippedCount"` + MatchedCount *int `json:"MatchedCount,omitempty"` + ModifiedCount *int `json:"ModifiedCount,omitempty"` + UpsertedCount *int `json:"UpsertedCount,omitempty"` + Errors []*CollectionImportError `json:"Errors"` + Message *string `json:"Message,omitempty"` + Detail *string `json:"Detail,omitempty"` +} + +type Column struct { + Type string `json:"Type"` + Name string `json:"Name"` + IsPrimary bool `json:"IsPrimary"` + IsForeignKey bool `json:"IsForeignKey"` + ReferencedTable *string `json:"ReferencedTable,omitempty"` + ReferencedColumn *string `json:"ReferencedColumn,omitempty"` + Length *int `json:"Length,omitempty"` + Precision *int `json:"Precision,omitempty"` + Scale *int `json:"Scale,omitempty"` +} + +type Dashboard struct { + ID string `json:"ID"` + Name string `json:"Name"` + Description *string `json:"Description,omitempty"` + RefreshRule string `json:"RefreshRule"` + Widgets []*DashboardWidget `json:"Widgets"` + CreatedAt string `json:"CreatedAt"` + UpdatedAt string `json:"UpdatedAt"` +} + +type DashboardWidget struct { + ID string `json:"ID"` + Type string `json:"Type"` + Title string `json:"Title"` + Description *string `json:"Description,omitempty"` + Layout string `json:"Layout"` + Query *string `json:"Query,omitempty"` + QueryContext *string `json:"QueryContext,omitempty"` + Visualization *string `json:"Visualization,omitempty"` + Snapshot *string `json:"Snapshot,omitempty"` + SortOrder int `json:"SortOrder"` +} + +type DatabaseMetadata struct { + DatabaseType string `json:"databaseType"` + TypeDefinitions []*TypeDefinition `json:"typeDefinitions"` + Operators []string `json:"operators"` + AliasMap []*Record `json:"aliasMap"` + Capabilities *Capabilities `json:"capabilities"` + SystemSchemas []string `json:"systemSchemas"` +} + +type DatabaseQuerySuggestion struct { + Description string `json:"description"` + Category string `json:"category"` +} + +type DiscoveredConnection struct { + ID string `json:"Id"` + ProviderType CloudProviderType `json:"ProviderType"` + ProviderID string `json:"ProviderID"` + Name string `json:"Name"` + DatabaseType string `json:"DatabaseType"` + Region *string `json:"Region,omitempty"` + Status ConnectionStatus `json:"Status"` + Metadata []*Record `json:"Metadata"` +} + +type ExportDownload struct { + ID string `json:"ID"` + Filename string `json:"Filename"` + ContentType string `json:"ContentType"` + DownloadURL string `json:"DownloadURL"` + Size int `json:"Size"` +} + +type GenerateChatTitleInput struct { + Query string `json:"Query"` + ModelType string `json:"ModelType"` + ProviderID *string `json:"ProviderId,omitempty"` + Token *string `json:"Token,omitempty"` + Model string `json:"Model"` + Endpoint *string `json:"Endpoint,omitempty"` +} + +type GenerateChatTitleResponse struct { + Title string `json:"Title"` +} + +type GraphUnit struct { + Unit *StorageUnit `json:"Unit"` + Relations []*GraphUnitRelationship `json:"Relations"` +} + +type GraphUnitRelationship struct { + Name string `json:"Name"` + Relationship GraphUnitRelationshipType `json:"Relationship"` + SourceColumn *string `json:"SourceColumn,omitempty"` + TargetColumn *string `json:"TargetColumn,omitempty"` +} + +type HealthStatus struct { + Server string `json:"Server"` + Database string `json:"Database"` +} + +type ImportCollectionFileInput struct { + Schema string `json:"Schema"` + Collection string `json:"Collection"` + Format CollectionImportFormat `json:"Format"` + Mode ImportMode `json:"Mode"` + UpsertKeys []string `json:"UpsertKeys,omitempty"` + SkipColumns []string `json:"SkipColumns,omitempty"` + Delimiter *string `json:"Delimiter,omitempty"` + Sheet *string `json:"Sheet,omitempty"` + File graphql.Upload `json:"File"` +} + +type ImportCollectionPreviewInput struct { + Format CollectionImportFormat `json:"Format"` + Delimiter *string `json:"Delimiter,omitempty"` + Sheet *string `json:"Sheet,omitempty"` + File graphql.Upload `json:"File"` +} + +type ImportColumnMapping struct { + SourceColumn string `json:"SourceColumn"` + TargetColumn *string `json:"TargetColumn,omitempty"` + Skip bool `json:"Skip"` +} + +type ImportColumnMappingPreview struct { + SourceColumn string `json:"SourceColumn"` + TargetColumn string `json:"TargetColumn"` +} + +type ImportExistingTableInput struct { + Schema string `json:"Schema"` + StorageUnit string `json:"StorageUnit"` + Mode ImportMode `json:"Mode"` + Mapping []*ImportColumnMapping `json:"Mapping"` + AllowAutoGenerated *bool `json:"AllowAutoGenerated,omitempty"` +} + +type ImportExistingTablePreviewInput struct { + Schema string `json:"Schema"` + StorageUnit string `json:"StorageUnit"` + UseHeaderMapping bool `json:"UseHeaderMapping"` +} + +type ImportFileInput struct { + TargetMode ImportTargetMode `json:"TargetMode"` + ExistingTable *ImportExistingTableInput `json:"ExistingTable,omitempty"` + NewTable *ImportNewTableInput `json:"NewTable,omitempty"` + Options *ImportFileOptions `json:"Options"` + File graphql.Upload `json:"File"` +} + +type ImportFileOptions struct { + Format ImportFileFormat `json:"Format"` + Delimiter *string `json:"Delimiter,omitempty"` + Sheet *string `json:"Sheet,omitempty"` +} + +type ImportNewTableColumnInput struct { + SourceColumn string `json:"SourceColumn"` + TargetColumn *string `json:"TargetColumn,omitempty"` + Type *string `json:"Type,omitempty"` + Nullable bool `json:"Nullable"` + Primary bool `json:"Primary"` + Skip bool `json:"Skip"` +} + +type ImportNewTableColumnPreview struct { + SourceColumn string `json:"SourceColumn"` + TargetColumn string `json:"TargetColumn"` + Type string `json:"Type"` + Nullable bool `json:"Nullable"` + Primary bool `json:"Primary"` + Skip bool `json:"Skip"` +} + +type ImportNewTableInput struct { + Schema string `json:"Schema"` + TableName string `json:"TableName"` + Columns []*ImportNewTableColumnInput `json:"Columns"` +} + +type ImportNewTablePreview struct { + TableName string `json:"TableName"` + Columns []*ImportNewTableColumnPreview `json:"Columns"` + Issues []*ImportSuggestionIssue `json:"Issues"` +} + +type ImportNewTablePreviewInput struct { + Schema string `json:"Schema"` +} + +type ImportPreview struct { + Sheet *string `json:"Sheet,omitempty"` + Sheets []string `json:"Sheets"` + Columns []string `json:"Columns"` + Rows [][]string `json:"Rows"` + Truncated bool `json:"Truncated"` + ValidationError *string `json:"ValidationError,omitempty"` + Mapping []*ImportColumnMappingPreview `json:"Mapping,omitempty"` + RequiresAllowAutoGenerated bool `json:"RequiresAllowAutoGenerated"` + AutoGeneratedColumns []string `json:"AutoGeneratedColumns"` + NewTable *ImportNewTablePreview `json:"NewTable,omitempty"` +} + +type ImportPreviewInput struct { + TargetMode ImportTargetMode `json:"TargetMode"` + ExistingTable *ImportExistingTablePreviewInput `json:"ExistingTable,omitempty"` + NewTable *ImportNewTablePreviewInput `json:"NewTable,omitempty"` + Options *ImportFileOptions `json:"Options"` + File graphql.Upload `json:"File"` +} + +type ImportResult struct { + Status bool `json:"Status"` + Message string `json:"Message"` + Detail *string `json:"Detail,omitempty"` +} + +type ImportSQLInput struct { + Script *string `json:"Script,omitempty"` + File *graphql.Upload `json:"File,omitempty"` + Filename *string `json:"Filename,omitempty"` +} + +type ImportSuggestionIssue struct { + Key string `json:"Key"` + Field string `json:"Field"` + SourceColumn *string `json:"SourceColumn,omitempty"` +} + +type LayoutInput struct { + WidgetID string `json:"WidgetID"` + Layout string `json:"Layout"` +} + +type LocalAWSProfile struct { + Name string `json:"Name"` + Region *string `json:"Region,omitempty"` + Source string `json:"Source"` + AuthType string `json:"AuthType"` + IsDefault bool `json:"IsDefault"` +} + +type LoginCredentials struct { + ID *string `json:"Id,omitempty"` + Type string `json:"Type"` + Hostname string `json:"Hostname"` + Username string `json:"Username"` + Password string `json:"Password"` + Database string `json:"Database"` + Advanced []*RecordInput `json:"Advanced,omitempty"` +} + +type LoginProfile struct { + Alias *string `json:"Alias,omitempty"` + ID string `json:"Id"` + Type DatabaseType `json:"Type"` + Hostname *string `json:"Hostname,omitempty"` + Database *string `json:"Database,omitempty"` + IsEnvironmentDefined bool `json:"IsEnvironmentDefined"` + Source string `json:"Source"` + SSLConfigured bool `json:"SSLConfigured"` +} + +type LoginProfileInput struct { + ID string `json:"Id"` + Type DatabaseType `json:"Type"` + Database *string `json:"Database,omitempty"` +} + +type MockDataDependencyAnalysis struct { + GenerationOrder []string `json:"GenerationOrder"` + Tables []*MockDataTableInfo `json:"Tables"` + TotalRows int `json:"TotalRows"` + Warnings []string `json:"Warnings"` + Error *string `json:"Error,omitempty"` +} + +type MockDataGenerationInput struct { + Schema string `json:"Schema"` + StorageUnit string `json:"StorageUnit"` + RowCount int `json:"RowCount"` + Method string `json:"Method"` + OverwriteExisting bool `json:"OverwriteExisting"` + FkDensityRatio *int `json:"FkDensityRatio,omitempty"` +} + +type MockDataGenerationStatus struct { + AmountGenerated int `json:"AmountGenerated"` + Details []*MockDataTableDetail `json:"Details,omitempty"` +} + +type MockDataTableDetail struct { + Table string `json:"Table"` + RowsGenerated int `json:"RowsGenerated"` + UsedExistingData bool `json:"UsedExistingData"` +} + +type MockDataTableInfo struct { + Table string `json:"Table"` + RowsToGenerate int `json:"RowsToGenerate"` + IsBlocked bool `json:"IsBlocked"` + UsesExistingData bool `json:"UsesExistingData"` +} + +type Mutation struct { +} + +type OperationWhereCondition struct { + Children []*WhereCondition `json:"Children"` +} + +type Query struct { +} + +type Record struct { + Key string `json:"Key"` + Value string `json:"Value"` +} + +type RecordInput struct { + Key string `json:"Key"` + Value string `json:"Value"` + Extra []*RecordInput `json:"Extra,omitempty"` +} + +type RowsResult struct { + Columns []*Column `json:"Columns"` + Rows [][]string `json:"Rows"` + DisableUpdate bool `json:"DisableUpdate"` + TotalCount int `json:"TotalCount"` +} + +type SQLDataExportInput struct { + Schema string `json:"Schema"` + StorageUnit string `json:"StorageUnit"` + Mode SQLDataExportMode `json:"Mode"` + Where *WhereCondition `json:"Where,omitempty"` + Sort []*SortCondition `json:"Sort,omitempty"` + Limit *int `json:"Limit,omitempty"` +} + +type SSLStatus struct { + IsEnabled bool `json:"IsEnabled"` + Mode string `json:"Mode"` +} + +type SealosBootstrapInput struct { + Kubeconfig string `json:"kubeconfig"` + DbType string `json:"dbType"` + ResourceName string `json:"resourceName"` + DatabaseName *string `json:"databaseName,omitempty"` + Host *string `json:"host,omitempty"` + Port *string `json:"port,omitempty"` + Namespace *string `json:"namespace,omitempty"` +} + +type SealosInstanceIdentity struct { + UID string `json:"uid"` + Namespace string `json:"namespace"` + ResourceName string `json:"resourceName"` +} + +type SealosInstanceIdentityInput struct { + Kubeconfig string `json:"kubeconfig"` + ResourceName string `json:"resourceName"` + Namespace *string `json:"namespace,omitempty"` +} + +type SettingsConfig struct { + MetricsEnabled *bool `json:"MetricsEnabled,omitempty"` + CloudProvidersEnabled bool `json:"CloudProvidersEnabled"` + DisableCredentialForm bool `json:"DisableCredentialForm"` + StandaloneLoginEnabled bool `json:"StandaloneLoginEnabled"` + MaxPageSize int `json:"MaxPageSize"` +} + +type SettingsConfigInput struct { + MetricsEnabled *string `json:"MetricsEnabled,omitempty"` +} + +type SnapshotInput struct { + Config string `json:"Config"` + Data string `json:"Data"` + ExecutedAt string `json:"ExecutedAt"` +} + +type SortCondition struct { + Column string `json:"Column"` + Direction SortDirection `json:"Direction"` +} + +type StatusResponse struct { + Status bool `json:"Status"` +} + +type StorageUnit struct { + Name string `json:"Name"` + Attributes []*Record `json:"Attributes"` + IsMockDataGenerationAllowed bool `json:"IsMockDataGenerationAllowed"` +} + +type StorageUnitColumns struct { + StorageUnit string `json:"StorageUnit"` + Columns []*Column `json:"Columns"` +} + +type TypeDefinition struct { + ID string `json:"id"` + Label string `json:"label"` + HasLength bool `json:"hasLength"` + HasPrecision bool `json:"hasPrecision"` + DefaultLength *int `json:"defaultLength,omitempty"` + DefaultPrecision *int `json:"defaultPrecision,omitempty"` + Category TypeCategory `json:"category"` +} + +type UpdateInfo struct { + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion"` + UpdateAvailable bool `json:"updateAvailable"` + ReleaseURL string `json:"releaseURL"` +} + +type UpdateWidgetInput struct { + Title *string `json:"Title,omitempty"` + Description *string `json:"Description,omitempty"` + Layout *string `json:"Layout,omitempty"` + Query *string `json:"Query,omitempty"` + QueryContext *string `json:"QueryContext,omitempty"` + Visualization *string `json:"Visualization,omitempty"` + Snapshot *string `json:"Snapshot,omitempty"` + SortOrder *int `json:"SortOrder,omitempty"` +} + +type WhereCondition struct { + Type WhereConditionType `json:"Type"` + Atomic *AtomicWhereCondition `json:"Atomic,omitempty"` + And *OperationWhereCondition `json:"And,omitempty"` + Or *OperationWhereCondition `json:"Or,omitempty"` +} + +type WidgetInput struct { + Type string `json:"Type"` + Title string `json:"Title"` + Description *string `json:"Description,omitempty"` + Layout string `json:"Layout"` + Query *string `json:"Query,omitempty"` + QueryContext *string `json:"QueryContext,omitempty"` + Visualization *string `json:"Visualization,omitempty"` + Snapshot *string `json:"Snapshot,omitempty"` + SortOrder *int `json:"SortOrder,omitempty"` +} + +type CloudProviderStatus string + +const ( + CloudProviderStatusConnected CloudProviderStatus = "Connected" + CloudProviderStatusDiscovering CloudProviderStatus = "Discovering" + CloudProviderStatusError CloudProviderStatus = "Error" + CloudProviderStatusDisconnected CloudProviderStatus = "Disconnected" +) + +var AllCloudProviderStatus = []CloudProviderStatus{ + CloudProviderStatusConnected, + CloudProviderStatusDiscovering, + CloudProviderStatusError, + CloudProviderStatusDisconnected, +} + +func (e CloudProviderStatus) IsValid() bool { + switch e { + case CloudProviderStatusConnected, CloudProviderStatusDiscovering, CloudProviderStatusError, CloudProviderStatusDisconnected: + return true + } + return false +} + +func (e CloudProviderStatus) String() string { + return string(e) +} + +func (e *CloudProviderStatus) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = CloudProviderStatus(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid CloudProviderStatus", str) + } + return nil +} + +func (e CloudProviderStatus) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *CloudProviderStatus) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e CloudProviderStatus) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + +type CloudProviderType string + +const ( + CloudProviderTypeAWS CloudProviderType = "AWS" +) + +var AllCloudProviderType = []CloudProviderType{ + CloudProviderTypeAWS, +} + +func (e CloudProviderType) IsValid() bool { + switch e { + case CloudProviderTypeAWS: + return true + } + return false +} + +func (e CloudProviderType) String() string { + return string(e) +} + +func (e *CloudProviderType) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = CloudProviderType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid CloudProviderType", str) + } + return nil +} + +func (e CloudProviderType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *CloudProviderType) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e CloudProviderType) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + +type CollectionImportFormat string + +const ( + CollectionImportFormatJSON CollectionImportFormat = "JSON" + CollectionImportFormatCSV CollectionImportFormat = "CSV" + CollectionImportFormatExcel CollectionImportFormat = "EXCEL" +) + +var AllCollectionImportFormat = []CollectionImportFormat{ + CollectionImportFormatJSON, + CollectionImportFormatCSV, + CollectionImportFormatExcel, +} + +func (e CollectionImportFormat) IsValid() bool { + switch e { + case CollectionImportFormatJSON, CollectionImportFormatCSV, CollectionImportFormatExcel: + return true + } + return false +} + +func (e CollectionImportFormat) String() string { + return string(e) +} + +func (e *CollectionImportFormat) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = CollectionImportFormat(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid CollectionImportFormat", str) + } + return nil +} + +func (e CollectionImportFormat) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *CollectionImportFormat) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e CollectionImportFormat) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + +type ConnectionStatus string + +const ( + ConnectionStatusAvailable ConnectionStatus = "Available" + ConnectionStatusStarting ConnectionStatus = "Starting" + ConnectionStatusStopped ConnectionStatus = "Stopped" + ConnectionStatusDeleting ConnectionStatus = "Deleting" + ConnectionStatusFailed ConnectionStatus = "Failed" + ConnectionStatusUnknown ConnectionStatus = "Unknown" +) + +var AllConnectionStatus = []ConnectionStatus{ + ConnectionStatusAvailable, + ConnectionStatusStarting, + ConnectionStatusStopped, + ConnectionStatusDeleting, + ConnectionStatusFailed, + ConnectionStatusUnknown, +} + +func (e ConnectionStatus) IsValid() bool { + switch e { + case ConnectionStatusAvailable, ConnectionStatusStarting, ConnectionStatusStopped, ConnectionStatusDeleting, ConnectionStatusFailed, ConnectionStatusUnknown: + return true + } + return false +} + +func (e ConnectionStatus) String() string { + return string(e) +} + +func (e *ConnectionStatus) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ConnectionStatus(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ConnectionStatus", str) + } + return nil +} + +func (e ConnectionStatus) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *ConnectionStatus) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e ConnectionStatus) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + +type DatabaseType string + +const ( + DatabaseTypePostgres DatabaseType = "Postgres" + DatabaseTypeMySQL DatabaseType = "MySQL" + DatabaseTypeSqlite3 DatabaseType = "Sqlite3" + DatabaseTypeMongoDb DatabaseType = "MongoDB" + DatabaseTypeRedis DatabaseType = "Redis" + DatabaseTypeElasticSearch DatabaseType = "ElasticSearch" + DatabaseTypeMariaDb DatabaseType = "MariaDB" + DatabaseTypeClickHouse DatabaseType = "ClickHouse" +) + +var AllDatabaseType = []DatabaseType{ + DatabaseTypePostgres, + DatabaseTypeMySQL, + DatabaseTypeSqlite3, + DatabaseTypeMongoDb, + DatabaseTypeRedis, + DatabaseTypeElasticSearch, + DatabaseTypeMariaDb, + DatabaseTypeClickHouse, +} + +func (e DatabaseType) IsValid() bool { + switch e { + case DatabaseTypePostgres, DatabaseTypeMySQL, DatabaseTypeSqlite3, DatabaseTypeMongoDb, DatabaseTypeRedis, DatabaseTypeElasticSearch, DatabaseTypeMariaDb, DatabaseTypeClickHouse: + return true + } + return false +} + +func (e DatabaseType) String() string { + return string(e) +} + +func (e *DatabaseType) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = DatabaseType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid DatabaseType", str) + } + return nil +} + +func (e DatabaseType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *DatabaseType) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e DatabaseType) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + +type GraphUnitRelationshipType string + +const ( + GraphUnitRelationshipTypeOneToOne GraphUnitRelationshipType = "OneToOne" + GraphUnitRelationshipTypeOneToMany GraphUnitRelationshipType = "OneToMany" + GraphUnitRelationshipTypeManyToOne GraphUnitRelationshipType = "ManyToOne" + GraphUnitRelationshipTypeManyToMany GraphUnitRelationshipType = "ManyToMany" + GraphUnitRelationshipTypeUnknown GraphUnitRelationshipType = "Unknown" +) + +var AllGraphUnitRelationshipType = []GraphUnitRelationshipType{ + GraphUnitRelationshipTypeOneToOne, + GraphUnitRelationshipTypeOneToMany, + GraphUnitRelationshipTypeManyToOne, + GraphUnitRelationshipTypeManyToMany, + GraphUnitRelationshipTypeUnknown, +} + +func (e GraphUnitRelationshipType) IsValid() bool { + switch e { + case GraphUnitRelationshipTypeOneToOne, GraphUnitRelationshipTypeOneToMany, GraphUnitRelationshipTypeManyToOne, GraphUnitRelationshipTypeManyToMany, GraphUnitRelationshipTypeUnknown: + return true + } + return false +} + +func (e GraphUnitRelationshipType) String() string { + return string(e) +} + +func (e *GraphUnitRelationshipType) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = GraphUnitRelationshipType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid GraphUnitRelationshipType", str) + } + return nil +} + +func (e GraphUnitRelationshipType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *GraphUnitRelationshipType) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e GraphUnitRelationshipType) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + +type ImportFileFormat string + +const ( + ImportFileFormatCSV ImportFileFormat = "CSV" + ImportFileFormatExcel ImportFileFormat = "EXCEL" +) + +var AllImportFileFormat = []ImportFileFormat{ + ImportFileFormatCSV, + ImportFileFormatExcel, +} + +func (e ImportFileFormat) IsValid() bool { + switch e { + case ImportFileFormatCSV, ImportFileFormatExcel: + return true + } + return false +} + +func (e ImportFileFormat) String() string { + return string(e) +} + +func (e *ImportFileFormat) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ImportFileFormat(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ImportFileFormat", str) + } + return nil +} + +func (e ImportFileFormat) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *ImportFileFormat) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e ImportFileFormat) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + +type ImportMode string + +const ( + ImportModeAppend ImportMode = "APPEND" + ImportModeOverwrite ImportMode = "OVERWRITE" + ImportModeUpsert ImportMode = "UPSERT" +) + +var AllImportMode = []ImportMode{ + ImportModeAppend, + ImportModeOverwrite, + ImportModeUpsert, +} + +func (e ImportMode) IsValid() bool { + switch e { + case ImportModeAppend, ImportModeOverwrite, ImportModeUpsert: + return true + } + return false +} + +func (e ImportMode) String() string { + return string(e) +} + +func (e *ImportMode) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ImportMode(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ImportMode", str) + } + return nil +} + +func (e ImportMode) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *ImportMode) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e ImportMode) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + +type ImportTargetMode string + +const ( + ImportTargetModeExistingTable ImportTargetMode = "EXISTING_TABLE" + ImportTargetModeNewTable ImportTargetMode = "NEW_TABLE" +) + +var AllImportTargetMode = []ImportTargetMode{ + ImportTargetModeExistingTable, + ImportTargetModeNewTable, +} + +func (e ImportTargetMode) IsValid() bool { + switch e { + case ImportTargetModeExistingTable, ImportTargetModeNewTable: + return true + } + return false +} + +func (e ImportTargetMode) String() string { + return string(e) +} + +func (e *ImportTargetMode) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ImportTargetMode(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ImportTargetMode", str) + } + return nil +} + +func (e ImportTargetMode) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *ImportTargetMode) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e ImportTargetMode) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + +type SQLDataExportMode string + +const ( + SQLDataExportModeInsert SQLDataExportMode = "INSERT" + SQLDataExportModeUpdate SQLDataExportMode = "UPDATE" +) + +var AllSQLDataExportMode = []SQLDataExportMode{ + SQLDataExportModeInsert, + SQLDataExportModeUpdate, +} + +func (e SQLDataExportMode) IsValid() bool { + switch e { + case SQLDataExportModeInsert, SQLDataExportModeUpdate: + return true + } + return false +} + +func (e SQLDataExportMode) String() string { + return string(e) +} + +func (e *SQLDataExportMode) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = SQLDataExportMode(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid SQLDataExportMode", str) + } + return nil +} + +func (e SQLDataExportMode) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *SQLDataExportMode) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e SQLDataExportMode) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + +type SortDirection string + +const ( + SortDirectionAsc SortDirection = "ASC" + SortDirectionDesc SortDirection = "DESC" +) + +var AllSortDirection = []SortDirection{ + SortDirectionAsc, + SortDirectionDesc, +} + +func (e SortDirection) IsValid() bool { + switch e { + case SortDirectionAsc, SortDirectionDesc: + return true + } + return false +} + +func (e SortDirection) String() string { + return string(e) +} + +func (e *SortDirection) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = SortDirection(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid SortDirection", str) + } + return nil +} + +func (e SortDirection) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *SortDirection) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e SortDirection) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + +type TypeCategory string + +const ( + TypeCategoryNumeric TypeCategory = "numeric" + TypeCategoryText TypeCategory = "text" + TypeCategoryBinary TypeCategory = "binary" + TypeCategoryDatetime TypeCategory = "datetime" + TypeCategoryBoolean TypeCategory = "boolean" + TypeCategoryJSON TypeCategory = "json" + TypeCategoryOther TypeCategory = "other" +) + +var AllTypeCategory = []TypeCategory{ + TypeCategoryNumeric, + TypeCategoryText, + TypeCategoryBinary, + TypeCategoryDatetime, + TypeCategoryBoolean, + TypeCategoryJSON, + TypeCategoryOther, +} + +func (e TypeCategory) IsValid() bool { + switch e { + case TypeCategoryNumeric, TypeCategoryText, TypeCategoryBinary, TypeCategoryDatetime, TypeCategoryBoolean, TypeCategoryJSON, TypeCategoryOther: + return true + } + return false +} + +func (e TypeCategory) String() string { + return string(e) +} + +func (e *TypeCategory) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = TypeCategory(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid TypeCategory", str) + } + return nil +} + +func (e TypeCategory) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *TypeCategory) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e TypeCategory) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + +type WhereConditionType string + +const ( + WhereConditionTypeAtomic WhereConditionType = "Atomic" + WhereConditionTypeAnd WhereConditionType = "And" + WhereConditionTypeOr WhereConditionType = "Or" +) + +var AllWhereConditionType = []WhereConditionType{ + WhereConditionTypeAtomic, + WhereConditionTypeAnd, + WhereConditionTypeOr, +} + +func (e WhereConditionType) IsValid() bool { + switch e { + case WhereConditionTypeAtomic, WhereConditionTypeAnd, WhereConditionTypeOr: + return true + } + return false +} + +func (e WhereConditionType) String() string { + return string(e) +} + +func (e *WhereConditionType) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = WhereConditionType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid WhereConditionType", str) + } + return nil +} + +func (e WhereConditionType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *WhereConditionType) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e WhereConditionType) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} diff --git a/dataflow/core/graph/resolver.go b/dataflow/core/graph/resolver.go new file mode 100644 index 0000000..7f41142 --- /dev/null +++ b/dataflow/core/graph/resolver.go @@ -0,0 +1,316 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src" + "github.com/clidey/whodb/core/src/auth" + "github.com/clidey/whodb/core/src/dashboard" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/providers" + "github.com/clidey/whodb/core/src/settings" +) + +// This file will not be regenerated automatically. +// +// It serves as dependency injection for your app, add any dependencies you require here. + +// Resolver owns GraphQL resolver dependencies. +type Resolver struct { + DashboardService dashboard.ServiceAPI + dashboardServiceFactory func() (dashboard.ServiceAPI, error) + dashboardServiceMu sync.Mutex +} + +// NewResolver creates the GraphQL dependency resolver. +func NewResolver() *Resolver { + return newResolverWithDashboardFactory(func() (dashboard.ServiceAPI, error) { + return dashboard.NewServiceFromEnv() + }) +} + +func newResolverWithDashboardFactory(factory func() (dashboard.ServiceAPI, error)) *Resolver { + return &Resolver{dashboardServiceFactory: factory} +} + +// GetPluginForContext returns the appropriate database plugin and config for the current session. +func GetPluginForContext(ctx context.Context) (*engine.Plugin, *engine.PluginConfig) { + creds := auth.GetCredentials(ctx) + log.Debugf("[GetPluginForContext] credentials: type=%s, hostname=%s, advanced count=%d", creds.Type, creds.Hostname, len(creds.Advanced)) + for _, rec := range creds.Advanced { + log.Debugf("[GetPluginForContext] advanced: key=%q value=%q", rec.Key, rec.Value) + } + config := engine.NewPluginConfig(creds) + plugin := src.MainEngine.Choose(engine.DatabaseType(config.Credentials.Type)) + return plugin, config +} + +// ValidateStorageUnit checks that a storage unit exists in the given schema. +// This prevents SQL injection by ensuring only existing table names are used. +func ValidateStorageUnit(plugin engine.PluginFunctions, config *engine.PluginConfig, schema string, storageUnit string) error { + exists, err := plugin.StorageUnitExists(config, schema, storageUnit) + if err != nil { + return fmt.Errorf("failed to validate storage unit: %w", err) + } + if !exists { + return fmt.Errorf("storage unit %q not found in schema %q", storageUnit, schema) + } + return nil +} + +// MapColumnsToModel converts engine columns to GraphQL model columns +func MapColumnsToModel(columnsResult []engine.Column) []*model.Column { + var columns []*model.Column + for _, column := range columnsResult { + columns = append(columns, &model.Column{ + Type: column.Type, + Name: column.Name, + IsPrimary: column.IsPrimary, + IsForeignKey: column.IsForeignKey, + ReferencedTable: column.ReferencedTable, + ReferencedColumn: column.ReferencedColumn, + Length: column.Length, + Precision: column.Precision, + Scale: column.Scale, + }) + } + return columns +} + +// FetchColumnsForStorageUnit retrieves column information for a single storage unit. +func FetchColumnsForStorageUnit( + plugin engine.PluginFunctions, + config *engine.PluginConfig, + schema string, + storageUnit string, +) ([]*model.Column, error) { + if err := ValidateStorageUnit(plugin, config, schema, storageUnit); err != nil { + return nil, err + } + + columnsResult, err := plugin.GetColumnsForTable(config, schema, storageUnit) + if err != nil { + return nil, fmt.Errorf("failed to get columns for %s.%s: %w", schema, storageUnit, err) + } + + return MapColumnsToModel(columnsResult), nil +} + +// stateToAWSProvider converts settings.AWSProviderState to the GraphQL model. +func stateToAWSProvider(state *settings.AWSProviderState) *model.AWSProvider { + var profileName *string + if state.Config.ProfileName != "" { + profileName = &state.Config.ProfileName + } + + var lastDiscoveryAt *string + if state.LastDiscoveryAt != nil { + t := state.LastDiscoveryAt.Format("2006-01-02T15:04:05Z") + lastDiscoveryAt = &t + } + + var errorStr *string + if state.Error != "" { + errorStr = &state.Error + } + + return &model.AWSProvider{ + ID: state.Config.ID, + ProviderType: model.CloudProviderTypeAWS, + Name: state.Config.Name, + Region: state.Config.Region, + ProfileName: profileName, + DiscoverRds: state.Config.DiscoverRDS, + DiscoverElastiCache: state.Config.DiscoverElastiCache, + DiscoverDocumentDb: state.Config.DiscoverDocumentDB, + Status: mapCloudProviderStatus(state.Status), + LastDiscoveryAt: lastDiscoveryAt, + DiscoveredCount: state.DiscoveredCount, + Error: errorStr, + } +} + +// mapCloudProviderStatus converts a status string to the GraphQL enum. +func mapCloudProviderStatus(status string) model.CloudProviderStatus { + switch status { + case "Connected": + return model.CloudProviderStatusConnected + case "Discovering": + return model.CloudProviderStatusDiscovering + case "Error": + return model.CloudProviderStatusError + default: + return model.CloudProviderStatusDisconnected + } +} + +// mapProviderTypeToModel converts providers.ProviderType to the GraphQL enum. +func mapProviderTypeToModel(pt providers.ProviderType) model.CloudProviderType { + switch pt { + case providers.ProviderTypeAWS: + return model.CloudProviderTypeAWS + default: + // Default to AWS since only cloud providers appear in DiscoveredConnection + return model.CloudProviderTypeAWS + } +} + +// mapConnectionStatusToModel converts providers.ConnectionStatus to the GraphQL enum. +func mapConnectionStatusToModel(status providers.ConnectionStatus) model.ConnectionStatus { + switch status { + case providers.ConnectionStatusAvailable: + return model.ConnectionStatusAvailable + case providers.ConnectionStatusStarting: + return model.ConnectionStatusStarting + case providers.ConnectionStatusStopped: + return model.ConnectionStatusStopped + case providers.ConnectionStatusDeleting: + return model.ConnectionStatusDeleting + case providers.ConnectionStatusFailed: + return model.ConnectionStatusFailed + default: + return model.ConnectionStatusUnknown + } +} + +// discoveredConnectionToModel converts providers.DiscoveredConnection to the GraphQL model. +// Connection metadata (endpoint, port, TLS settings) is exposed to the frontend for +// prefilling the login form, allowing users to modify values before connecting. +func discoveredConnectionToModel(conn *providers.DiscoveredConnection) *model.DiscoveredConnection { + var region *string + if conn.Region != "" { + region = &conn.Region + } + + // Expose metadata needed for UI prefill and connection decisions. + // - endpoint: database hostname for connection prefill + // - port: database port for connection prefill + // - transitEncryption: TLS setting for ElastiCache/Redis + // - serverless: indicates serverless deployment (affects UI hints) + // - iamAuthEnabled: determines if password is optional for RDS + // - authTokenEnabled: Redis AUTH token hint + allowedMetadataKeys := map[string]bool{ + "endpoint": true, + "port": true, + "transitEncryption": true, + "serverless": true, + "iamAuthEnabled": true, + "authTokenEnabled": true, + "iamAuthSupported": true, + "endpointType": true, + "clusterIdentifier": true, + "proxyName": true, + "requireTLS": true, + "connectivity": true, + "service": true, + "region": true, + } + + var metadata []*model.Record + for k, v := range conn.Metadata { + if allowedMetadataKeys[k] { + metadata = append(metadata, &model.Record{Key: k, Value: v}) + } + } + + return &model.DiscoveredConnection{ + ID: conn.ID, + ProviderType: mapProviderTypeToModel(conn.ProviderType), + ProviderID: conn.ProviderID, + Name: conn.Name, + DatabaseType: string(conn.DatabaseType), + Region: region, + Status: mapConnectionStatusToModel(conn.Status), + Metadata: metadata, + } +} + +func requireDashboardService(resolver *Resolver) (dashboard.ServiceAPI, error) { + if resolver == nil { + return nil, dashboard.ErrMetadataStoreNotConfigured + } + if resolver.DashboardService != nil { + return resolver.DashboardService, nil + } + if resolver.dashboardServiceFactory == nil { + return nil, dashboard.ErrMetadataStoreNotConfigured + } + + resolver.dashboardServiceMu.Lock() + defer resolver.dashboardServiceMu.Unlock() + + if resolver.DashboardService != nil { + return resolver.DashboardService, nil + } + + service, err := resolver.dashboardServiceFactory() + if err != nil { + log.Warnf("dashboard metadata unavailable: %v", err) + return nil, err + } + + resolver.DashboardService = service + return service, nil +} + +func mapDashboardModel(item dashboard.Dashboard) *model.Dashboard { + widgets := make([]*model.DashboardWidget, 0, len(item.Widgets)) + for _, widget := range item.Widgets { + widgets = append(widgets, mapWidgetModel(widget)) + } + + return &model.Dashboard{ + ID: item.ID, + Name: item.Name, + Description: item.Description, + RefreshRule: item.RefreshRule, + Widgets: widgets, + CreatedAt: item.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: item.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +func mapWidgetModel(item dashboard.Widget) *model.DashboardWidget { + return &model.DashboardWidget{ + ID: item.ID, + Type: item.Type, + Title: item.Title, + Description: item.Description, + Layout: string(item.Layout), + Query: item.Query, + QueryContext: rawJSONToStringPtr(item.QueryContext), + Visualization: rawJSONToStringPtr(item.Visualization), + Snapshot: rawJSONToStringPtr(item.Snapshot), + SortOrder: item.SortOrder, + } +} + +func rawJSONToStringPtr(value []byte) *string { + if len(value) == 0 { + return nil + } + + text := string(value) + return &text +} diff --git a/dataflow/core/graph/resolver_mutation_test.go b/dataflow/core/graph/resolver_mutation_test.go new file mode 100644 index 0000000..ce9688d --- /dev/null +++ b/dataflow/core/graph/resolver_mutation_test.go @@ -0,0 +1,264 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import ( + "context" + "errors" + "testing" + + "net/http/httptest" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/internal/testutil" + "github.com/clidey/whodb/core/src" + "github.com/clidey/whodb/core/src/auth" + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/mockdata" +) + +func TestAddRowSuccess(t *testing.T) { + resolver := &Resolver{} + mut := resolver.Mutation() + + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.StorageUnitExistsFunc = func(*engine.PluginConfig, string, string) (bool, error) { return true, nil } + addCalled := 0 + mock.AddRowFunc = func(_ *engine.PluginConfig, _, _ string, _ []engine.Record) (bool, error) { + addCalled++ + return true, nil + } + + setEngineMock(t, mock) + ctx := context.WithValue(context.Background(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"}) + + resp, err := mut.AddRow(ctx, "public", "users", []*model.RecordInput{{Key: "id", Value: "1"}}) + if err != nil { + t.Fatalf("expected add row to succeed, got %v", err) + } + if resp == nil || !resp.Status { + t.Fatalf("expected status true, got %#v", resp) + } + if addCalled != 1 { + t.Fatalf("expected AddRow to be invoked once, got %d", addCalled) + } +} + +func TestAddRowValidationFailure(t *testing.T) { + resolver := &Resolver{} + mut := resolver.Mutation() + + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.StorageUnitExistsFunc = func(*engine.PluginConfig, string, string) (bool, error) { return false, nil } + setEngineMock(t, mock) + ctx := context.WithValue(context.Background(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"}) + + if _, err := mut.AddRow(ctx, "public", "missing", nil); err == nil { + t.Fatalf("expected validation error for missing storage unit") + } +} + +func TestDeleteRowPropagatesPluginError(t *testing.T) { + resolver := &Resolver{} + mut := resolver.Mutation() + + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.StorageUnitExistsFunc = func(*engine.PluginConfig, string, string) (bool, error) { return true, nil } + mock.DeleteRowFunc = func(*engine.PluginConfig, string, string, map[string]string) (bool, error) { + return false, errors.New("delete failed") + } + setEngineMock(t, mock) + ctx := context.WithValue(context.Background(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"}) + + if _, err := mut.DeleteRow(ctx, "public", "users", nil); err == nil { + t.Fatalf("expected delete error to propagate") + } +} + +func TestUpdateStorageUnitCallsPlugin(t *testing.T) { + resolver := &Resolver{} + mut := resolver.Mutation() + + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.StorageUnitExistsFunc = func(*engine.PluginConfig, string, string) (bool, error) { return true, nil } + updateCalled := 0 + mock.UpdateStorageUnitFunc = func(*engine.PluginConfig, string, string, map[string]string, []string) (bool, error) { + updateCalled++ + return true, nil + } + setEngineMock(t, mock) + ctx := context.WithValue(context.Background(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"}) + + resp, err := mut.UpdateStorageUnit(ctx, "public", "users", []*model.RecordInput{{Key: "name", Value: "alice"}}, []string{"name"}) + if err != nil { + t.Fatalf("expected update to succeed, got %v", err) + } + if resp == nil || !resp.Status { + t.Fatalf("expected true status, got %#v", resp) + } + if updateCalled != 1 { + t.Fatalf("expected UpdateStorageUnit to be called once, got %d", updateCalled) + } +} + +func TestReplaceRowCallsPlugin(t *testing.T) { + resolver := &Resolver{} + mut := resolver.Mutation() + + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.StorageUnitExistsFunc = func(*engine.PluginConfig, string, string) (bool, error) { return true, nil } + replaceCalled := 0 + mock.ReplaceRowFunc = func(_ *engine.PluginConfig, schema string, storageUnit string, values map[string]string) (bool, error) { + replaceCalled++ + if schema != "public" || storageUnit != "users" { + t.Fatalf("unexpected target %s.%s", schema, storageUnit) + } + if values["document"] != `{"_id":"1","name":"alice"}` { + t.Fatalf("unexpected document payload %q", values["document"]) + } + return true, nil + } + setEngineMock(t, mock) + ctx := context.WithValue(context.Background(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"}) + + resp, err := mut.ReplaceRow(ctx, "public", "users", []*model.RecordInput{{Key: "document", Value: `{"_id":"1","name":"alice"}`}}) + if err != nil { + t.Fatalf("expected replace to succeed, got %v", err) + } + if resp == nil || !resp.Status { + t.Fatalf("expected true status, got %#v", resp) + } + if replaceCalled != 1 { + t.Fatalf("expected ReplaceRow to be called once, got %d", replaceCalled) + } +} + +func TestQueryMockDataMaxRowCount(t *testing.T) { + resolver := &Resolver{} + query := resolver.Query() + + ctx := context.WithValue(context.Background(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"}) + result, err := query.MockDataMaxRowCount(ctx) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result != mockdata.GetMockDataGenerationMaxRowCount() { + t.Fatalf("expected mock data max row count %d, got %d", mockdata.GetMockDataGenerationMaxRowCount(), result) + } +} + +func TestQueryDatabaseMetadataMapsFields(t *testing.T) { + resolver := &Resolver{} + query := resolver.Query() + + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.GetDatabaseMetadataFunc = func() *engine.DatabaseMetadata { + return &engine.DatabaseMetadata{ + DatabaseType: "Test", + TypeDefinitions: []engine.TypeDefinition{{ + ID: "text", + Label: "Text", + HasLength: true, + HasPrecision: false, + DefaultLength: intPtr(255), + DefaultPrecision: nil, + Category: engine.TypeCategoryText, + }}, + Operators: []string{"=", "LIKE"}, + AliasMap: map[string]string{"varchar": "text"}, + Capabilities: engine.Capabilities{ + SupportsScratchpad: true, + SupportsChat: true, + SupportsGraph: true, + SupportsSchema: false, + SupportsDatabaseSwitch: true, + SupportsModifiers: true, + }, + } + } + setEngineMock(t, mock) + + ctx := context.WithValue(context.Background(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"}) + result, err := query.DatabaseMetadata(ctx) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result == nil || result.DatabaseType != "Test" { + t.Fatalf("expected database metadata to be returned, got %#v", result) + } + if len(result.TypeDefinitions) != 1 || result.TypeDefinitions[0].ID != "text" { + t.Fatalf("expected type definitions to be mapped, got %#v", result.TypeDefinitions) + } + if len(result.AliasMap) != 1 { + t.Fatalf("expected alias map to be converted, got %#v", result.AliasMap) + } + found := false + for _, rec := range result.AliasMap { + if rec.Key == "varchar" && rec.Value == "text" { + found = true + } + } + if !found { + t.Fatalf("expected varchar alias to be present, got %#v", result.AliasMap) + } + if result.Capabilities == nil { + t.Fatalf("expected capabilities to be returned") + } + if !result.Capabilities.SupportsScratchpad { + t.Fatalf("expected SupportsScratchpad to be true") + } + if !result.Capabilities.SupportsChat { + t.Fatalf("expected SupportsChat to be true") + } + if result.Capabilities.SupportsSchema { + t.Fatalf("expected SupportsSchema to be false") + } +} + +func TestLoginFailsWhenPluginUnavailable(t *testing.T) { + resolver := &Resolver{} + mut := resolver.Mutation() + + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.IsAvailableFunc = func(context.Context, *engine.PluginConfig) bool { return false } + setEngineMock(t, mock) + + ctx := context.WithValue(context.Background(), auth.AuthKey_Credentials, &engine.Credentials{Type: "Test"}) + reqCtx := context.WithValue(ctx, common.RouterKey_ResponseWriter, httptest.NewRecorder()) + + _, err := mut.Login(reqCtx, model.LoginCredentials{ + Type: "Test", + Hostname: "h", + Username: "u", + Password: "p", + Database: "d", + }) + if err == nil { + t.Fatalf("expected login to fail when plugin unavailable") + } +} + +func setEngineMock(t *testing.T, mock *testutil.PluginMock) { + t.Helper() + orig := src.MainEngine + src.MainEngine = &engine.Engine{} + src.MainEngine.RegistryPlugin(mock.AsPlugin()) + t.Cleanup(func() { src.MainEngine = orig }) +} + +func intPtr(i int) *int { return &i } diff --git a/dataflow/core/graph/resolver_test.go b/dataflow/core/graph/resolver_test.go new file mode 100644 index 0000000..444b1f9 --- /dev/null +++ b/dataflow/core/graph/resolver_test.go @@ -0,0 +1,189 @@ +// Copyright 2025 Clidey, Inc. +// +// 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. + +package graph + +import ( + "context" + "errors" + "testing" + + "github.com/clidey/whodb/core/internal/testutil" + "github.com/clidey/whodb/core/src" + "github.com/clidey/whodb/core/src/auth" + "github.com/clidey/whodb/core/src/dashboard" + "github.com/clidey/whodb/core/src/engine" +) + +func TestMapColumnsToModelMergesMetadata(t *testing.T) { + refTable := "users" + refColumn := "id" + columns := []engine.Column{ + { + Name: "id", + Type: "INT", + IsPrimary: true, + }, + { + Name: "user_id", + Type: "INT", + IsForeignKey: true, + ReferencedTable: &refTable, + ReferencedColumn: &refColumn, + }, + } + + result := MapColumnsToModel(columns) + + if len(result) != 2 { + t.Fatalf("expected 2 columns, got %d", len(result)) + } + + if !result[0].IsPrimary { + t.Fatalf("expected id to be primary key") + } + + if !result[1].IsForeignKey || result[1].ReferencedTable == nil || *result[1].ReferencedTable != "users" { + t.Fatalf("expected foreign key metadata to be present") + } +} + +func TestFetchColumnsForStorageUnitValidatesAndEnriches(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.StorageUnitExistsFunc = func(_ *engine.PluginConfig, schema string, storageUnit string) (bool, error) { + if schema == "public" && storageUnit == "orders" { + return true, nil + } + return false, nil + } + + refTable := "customers" + refColumn := "id" + mock.GetColumnsForTableFunc = func(_ *engine.PluginConfig, _ string, _ string) ([]engine.Column, error) { + return []engine.Column{ + {Name: "id", Type: "INT", IsPrimary: true}, + {Name: "customer_id", Type: "INT", IsForeignKey: true, ReferencedTable: &refTable, ReferencedColumn: &refColumn}, + }, nil + } + + config := engine.NewPluginConfig(&engine.Credentials{Type: "Test"}) + result, err := FetchColumnsForStorageUnit(mock, config, "public", "orders") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result) != 2 { + t.Fatalf("expected 2 columns, got %d", len(result)) + } + + if !result[0].IsPrimary { + t.Fatalf("expected primary key from engine.Column") + } + + if !result[1].IsForeignKey || result[1].ReferencedTable == nil || *result[1].ReferencedTable != "customers" { + t.Fatalf("expected foreign key metadata from engine.Column") + } +} + +func TestFetchColumnsForStorageUnitFailsWhenMissing(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + mock.StorageUnitExistsFunc = func(_ *engine.PluginConfig, _ string, _ string) (bool, error) { + return false, nil + } + + config := engine.NewPluginConfig(&engine.Credentials{Type: "Test"}) + if _, err := FetchColumnsForStorageUnit(mock, config, "public", "missing"); err == nil { + t.Fatalf("expected validation error when storage unit does not exist") + } +} + +func TestValidateStorageUnit(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Test")) + + mock.StorageUnitExistsFunc = func(_ *engine.PluginConfig, _ string, _ string) (bool, error) { + return true, nil + } + if err := ValidateStorageUnit(mock, engine.NewPluginConfig(&engine.Credentials{Type: "Test"}), "public", "orders"); err != nil { + t.Fatalf("expected validation to pass when storage unit exists, got %v", err) + } + + mock.StorageUnitExistsFunc = func(_ *engine.PluginConfig, _ string, _ string) (bool, error) { + return false, nil + } + if err := ValidateStorageUnit(mock, engine.NewPluginConfig(&engine.Credentials{Type: "Test"}), "public", "missing"); err == nil { + t.Fatalf("expected error when storage unit does not exist") + } + + mock.StorageUnitExistsFunc = func(_ *engine.PluginConfig, _ string, _ string) (bool, error) { + return false, errors.New("validation failed") + } + if err := ValidateStorageUnit(mock, engine.NewPluginConfig(&engine.Credentials{Type: "Test"}), "public", "missing"); err == nil { + t.Fatalf("expected error to bubble up when validation fails") + } +} + +func TestRequireDashboardServiceRetriesAfterInitialFactoryFailure(t *testing.T) { + expected := &stubDashboardService{} + attempts := 0 + resolver := newResolverWithDashboardFactory(func() (dashboard.ServiceAPI, error) { + attempts++ + if attempts == 1 { + return nil, errors.New("metadata database not ready") + } + return expected, nil + }) + + if _, err := requireDashboardService(resolver); err == nil || err.Error() != "metadata database not ready" { + t.Fatalf("expected initial factory error, got %v", err) + } + + got, err := requireDashboardService(resolver) + if err != nil { + t.Fatalf("expected retry to initialize dashboard service, got %v", err) + } + if got != expected { + t.Fatalf("expected initialized dashboard service to be reused") + } + if attempts != 2 { + t.Fatalf("expected two factory attempts, got %d", attempts) + } +} + +type stubDashboardService struct { + dashboard.ServiceAPI +} + +func TestGetPluginForContextUsesEngine(t *testing.T) { + original := src.MainEngine + t.Cleanup(func() { + src.MainEngine = original + }) + + engineInstance := &engine.Engine{} + plugin := &engine.Plugin{Type: "Test"} + engineInstance.RegistryPlugin(plugin) + src.MainEngine = engineInstance + + creds := &engine.Credentials{Type: "Test"} + ctx := context.WithValue(context.Background(), auth.AuthKey_Credentials, creds) + + chosen, config := GetPluginForContext(ctx) + + if chosen != plugin { + t.Fatalf("expected engine to return registered plugin, got %v", chosen) + } + if config.Credentials != creds { + t.Fatalf("expected config to carry credentials from context") + } +} diff --git a/dataflow/core/graph/schema.graphqls b/dataflow/core/graph/schema.graphqls new file mode 100644 index 0000000..d642523 --- /dev/null +++ b/dataflow/core/graph/schema.graphqls @@ -0,0 +1,771 @@ +enum DatabaseType { + Postgres, + MySQL, + Sqlite3, + MongoDB, + Redis, + ElasticSearch, + MariaDB, + ClickHouse, +} + +scalar Upload + +enum ImportFileFormat { + CSV + EXCEL +} + +enum ImportMode { + APPEND + OVERWRITE + UPSERT +} + +enum ImportTargetMode { + EXISTING_TABLE + NEW_TABLE +} + +type Column { + Type: String! + Name: String! + IsPrimary: Boolean! + IsForeignKey: Boolean! + ReferencedTable: String + ReferencedColumn: String + Length: Int + Precision: Int + Scale: Int +} + +type StorageUnitColumns { + StorageUnit: String! + Columns: [Column!]! +} + +enum WhereConditionType { + Atomic + And + Or +} + +input AtomicWhereCondition { + ColumnType: String! + Key: String! + Operator: String! + Value: String! +} + +input OperationWhereCondition { + Children: [WhereCondition!]! +} + +input WhereCondition { + Type: WhereConditionType! + Atomic: AtomicWhereCondition + And: OperationWhereCondition + Or: OperationWhereCondition +} + +enum SortDirection { + ASC + DESC +} + +input SortCondition { + Column: String! + Direction: SortDirection! +} + +enum SQLDataExportMode { + INSERT + UPDATE +} + +input SQLDataExportInput { + Schema: String! + StorageUnit: String! + Mode: SQLDataExportMode! + Where: WhereCondition + Sort: [SortCondition!] + Limit: Int +} + +type ExportDownload { + ID: ID! + Filename: String! + ContentType: String! + DownloadURL: String! + Size: Int! +} + +type RowsResult { + Columns: [Column!]! + Rows: [[String!]!]! + DisableUpdate: Boolean! + TotalCount: Int! +} + +type Record { + Key: String! + Value: String! +} + +input RecordInput { + Key: String! + Value: String! + Extra: [RecordInput!] +} + +input ImportFileOptions { + Format: ImportFileFormat! + Delimiter: String + Sheet: String +} + +input ImportColumnMapping { + SourceColumn: String! + TargetColumn: String + Skip: Boolean! +} + +input ImportExistingTablePreviewInput { + Schema: String! + StorageUnit: String! + UseHeaderMapping: Boolean! +} + +input ImportNewTablePreviewInput { + Schema: String! +} + +input ImportPreviewInput { + TargetMode: ImportTargetMode! + ExistingTable: ImportExistingTablePreviewInput + NewTable: ImportNewTablePreviewInput + Options: ImportFileOptions! + File: Upload! +} + +input ImportExistingTableInput { + Schema: String! + StorageUnit: String! + Mode: ImportMode! + Mapping: [ImportColumnMapping!]! + AllowAutoGenerated: Boolean +} + +input ImportNewTableColumnInput { + SourceColumn: String! + TargetColumn: String + Type: String + Nullable: Boolean! + Primary: Boolean! + Skip: Boolean! +} + +input ImportNewTableInput { + Schema: String! + TableName: String! + Columns: [ImportNewTableColumnInput!]! +} + +input ImportFileInput { + TargetMode: ImportTargetMode! + ExistingTable: ImportExistingTableInput + NewTable: ImportNewTableInput + Options: ImportFileOptions! + File: Upload! +} + +input ImportSQLInput { + Script: String + File: Upload + Filename: String +} + +enum CollectionImportFormat { + JSON + CSV + EXCEL +} + +input ImportCollectionPreviewInput { + Format: CollectionImportFormat! + Delimiter: String + Sheet: String + File: Upload! +} + +type CollectionImportPreview { + Format: CollectionImportFormat! + Sheet: String + Sheets: [String!]! + Columns: [String!]! + Rows: [[String!]!]! + Documents: [String!]! + Count: Int + Truncated: Boolean! + ValidationError: String +} + +input ImportCollectionFileInput { + Schema: String! + Collection: String! + Format: CollectionImportFormat! + Mode: ImportMode! + UpsertKeys: [String!] + SkipColumns: [String!] + Delimiter: String + Sheet: String + File: Upload! +} + +type CollectionImportError { + Index: Int! + Reason: String! +} + +type CollectionImportResult { + Status: Boolean! + ImportedCount: Int! + SkippedCount: Int! + MatchedCount: Int + ModifiedCount: Int + UpsertedCount: Int + Errors: [CollectionImportError!]! + Message: String + Detail: String +} + +type StorageUnit { + Name: String! + Attributes: [Record!]! + IsMockDataGenerationAllowed: Boolean! +} + +enum GraphUnitRelationshipType { + OneToOne, + OneToMany, + ManyToOne, + ManyToMany, + Unknown, +} + +type GraphUnitRelationship { + Name: String! + Relationship: GraphUnitRelationshipType! + SourceColumn: String + TargetColumn: String +} + +type GraphUnit { + Unit: StorageUnit! + Relations: [GraphUnitRelationship!]! +} + +input LoginCredentials { + Id: String + Type: String! + Hostname: String! + Username: String! + Password: String! + Database: String! + Advanced: [RecordInput!] +} + +input SealosBootstrapInput { + kubeconfig: String! + dbType: String! + resourceName: String! + databaseName: String + host: String + port: String + namespace: String +} + +input SealosInstanceIdentityInput { + kubeconfig: String! + resourceName: String! + namespace: String +} + +type SealosInstanceIdentity { + uid: String! + namespace: String! + resourceName: String! +} + +type AuthSessionPayload { + sessionToken: String! + expiresAt: String! + type: String! + hostname: String! + port: String! + database: String! + displayName: String! + instanceUid: String +} + +type SettingsConfig { + MetricsEnabled: Boolean + CloudProvidersEnabled: Boolean! + DisableCredentialForm: Boolean! + StandaloneLoginEnabled: Boolean! + MaxPageSize: Int! +} + +input SettingsConfigInput { + MetricsEnabled: String +} + + +input LoginProfileInput { + Id: String! + Type: DatabaseType! + Database: String +} + +type LoginProfile { + Alias: String + Id: String! + Type: DatabaseType! + Hostname: String + Database: String + IsEnvironmentDefined: Boolean! + Source: String! + SSLConfigured: Boolean! +} + +type StatusResponse { + Status: Boolean! +} + +type DashboardWidget { + ID: ID! + Type: String! + Title: String! + Description: String + Layout: String! + Query: String + QueryContext: String + Visualization: String + Snapshot: String + SortOrder: Int! +} + +type Dashboard { + ID: ID! + Name: String! + Description: String + RefreshRule: String! + Widgets: [DashboardWidget!]! + CreatedAt: String! + UpdatedAt: String! +} + +input WidgetInput { + Type: String! + Title: String! + Description: String + Layout: String! + Query: String + QueryContext: String + Visualization: String + Snapshot: String + SortOrder: Int +} + +input UpdateWidgetInput { + Title: String + Description: String + Layout: String + Query: String + QueryContext: String + Visualization: String + Snapshot: String + SortOrder: Int +} + +input LayoutInput { + WidgetID: ID! + Layout: String! +} + +input SnapshotInput { + Config: String! + Data: String! + ExecutedAt: String! +} + +type ImportPreview { + Sheet: String + Sheets: [String!]! + Columns: [String!]! + Rows: [[String!]!]! + Truncated: Boolean! + ValidationError: String + Mapping: [ImportColumnMappingPreview!] + RequiresAllowAutoGenerated: Boolean! + AutoGeneratedColumns: [String!]! + NewTable: ImportNewTablePreview +} + +type ImportColumnMappingPreview { + SourceColumn: String! + TargetColumn: String! +} + +type ImportNewTablePreview { + TableName: String! + Columns: [ImportNewTableColumnPreview!]! + Issues: [ImportSuggestionIssue!]! +} + +type ImportNewTableColumnPreview { + SourceColumn: String! + TargetColumn: String! + Type: String! + Nullable: Boolean! + Primary: Boolean! + Skip: Boolean! +} + +type ImportSuggestionIssue { + Key: String! + Field: String! + SourceColumn: String +} + +type ImportResult { + Status: Boolean! + Message: String! + Detail: String +} + +input MockDataGenerationInput { + Schema: String! + StorageUnit: String! + RowCount: Int! + Method: String! + OverwriteExisting: Boolean! + FkDensityRatio: Int +} + +type MockDataTableInfo { + Table: String! + RowsToGenerate: Int! + IsBlocked: Boolean! + UsesExistingData: Boolean! +} + +type MockDataTableDetail { + Table: String! + RowsGenerated: Int! + UsedExistingData: Boolean! +} + +type MockDataDependencyAnalysis { + GenerationOrder: [String!]! + Tables: [MockDataTableInfo!]! + TotalRows: Int! + Warnings: [String!]! + Error: String +} + +type MockDataGenerationStatus { + AmountGenerated: Int! + Details: [MockDataTableDetail!] +} + +input ChatInput { + PreviousConversation: String! + Query: String! + Model: String! + Token: String +} + +type AIChatMessage { + Type: String! + Result: RowsResult + Text: String! + RequiresConfirmation: Boolean! +} + +type AIProvider { + Type: String! + Name: String! + ProviderId: String! + IsEnvironmentDefined: Boolean! + IsGeneric: Boolean! +} + +enum TypeCategory { + numeric + text + binary + datetime + boolean + json + other +} + +type TypeDefinition { + id: String! + label: String! + hasLength: Boolean! + hasPrecision: Boolean! + defaultLength: Int + defaultPrecision: Int + category: TypeCategory! +} + +type Capabilities { + supportsScratchpad: Boolean! + supportsChat: Boolean! + supportsGraph: Boolean! + supportsSchema: Boolean! + supportsDatabaseSwitch: Boolean! + supportsModifiers: Boolean! +} + +type DatabaseMetadata { + databaseType: String! + typeDefinitions: [TypeDefinition!]! + operators: [String!]! + aliasMap: [Record!]! + capabilities: Capabilities! + systemSchemas: [String!]! +} + +type DatabaseQuerySuggestion { + description: String! + category: String! +} + +# ============================================================================= +# SSL/TLS Configuration Types +# ============================================================================= + +type SSLStatus { + IsEnabled: Boolean! + Mode: String! +} + +# ============================================================================= +# Cloud Provider Types (Generic Infrastructure) +# ============================================================================= + +# Supported cloud provider types +enum CloudProviderType { + AWS + # GCP - future + # Azure - future +} + +# Provider status (shared across all cloud providers) +enum CloudProviderStatus { + Connected + Discovering + Error + Disconnected +} + +# Connection status for discovered resources +enum ConnectionStatus { + Available + Starting + Stopped + Deleting + Failed + Unknown +} + +# Common fields for all cloud providers +interface CloudProvider { + Id: ID! + ProviderType: CloudProviderType! + Name: String! + Region: String! + Status: CloudProviderStatus! + LastDiscoveryAt: String + DiscoveredCount: Int! + Error: String +} + +# ============================================================================= +# AWS Provider +# ============================================================================= + +type AWSProvider implements CloudProvider { + # Interface fields + Id: ID! + ProviderType: CloudProviderType! + Name: String! + Region: String! + Status: CloudProviderStatus! + LastDiscoveryAt: String + DiscoveredCount: Int! + Error: String + # AWS-specific fields + ProfileName: String + DiscoverRDS: Boolean! + DiscoverElastiCache: Boolean! + DiscoverDocumentDB: Boolean! +} + +input AWSProviderInput { + Name: String! + Region: String! + ProfileName: String + DiscoverRDS: Boolean + DiscoverElastiCache: Boolean + DiscoverDocumentDB: Boolean +} + +# Local AWS profile discovered from ~/.aws/credentials and ~/.aws/config +type LocalAWSProfile { + Name: String! + Region: String + Source: String! # "credentials", "config", or "environment" + AuthType: String! # "static", "sso", "assume-role", or "environment" + IsDefault: Boolean! +} + +# AWS region with partition grouping +type AWSRegion { + Id: String! + Description: String! + Partition: String! +} + +# ============================================================================= +# Discovered Connections (Generic - works with any provider) +# ============================================================================= + +type DiscoveredConnection { + Id: ID! + ProviderType: CloudProviderType! + ProviderID: String! + Name: String! + DatabaseType: String! + Region: String + Status: ConnectionStatus! + Metadata: [Record!]! +} + +# ============================================================================= +# Queries +# ============================================================================= + +type UpdateInfo { + currentVersion: String! + latestVersion: String! + updateAvailable: Boolean! + releaseURL: String! +} + +type HealthStatus { + Server: String! + Database: String! +} + +type Query { + ResolveSealosInstanceIdentity(input: SealosInstanceIdentityInput!): SealosInstanceIdentity! + Version: String! + UpdateInfo: UpdateInfo! + Health: HealthStatus! + GetDashboards: [Dashboard!]! + Profiles: [LoginProfile!]! + Database(type: String!): [String!]! + Schema: [String!]! + StorageUnit(schema: String!): [StorageUnit!]! + Row(schema: String!, storageUnit: String!, where: WhereCondition, sort: [SortCondition!], pageSize: Int!, pageOffset: Int!): RowsResult! + Columns(schema: String!, storageUnit: String!): [Column!]! + ColumnsBatch(schema: String!, storageUnits: [String!]!): [StorageUnitColumns!]! + RawExecute(query: String!): RowsResult! + Graph(schema: String!): [GraphUnit!]! + AIProviders: [AIProvider!]! + AIModel(providerId: String, modelType: String!, token: String): [String!]! + AIChat(providerId: String, modelType: String!, token: String, schema: String!, input: ChatInput!): [AIChatMessage!]! + SettingsConfig: SettingsConfig! + MockDataMaxRowCount: Int! + AnalyzeMockDataDependencies(schema: String!, storageUnit: String!, rowCount: Int!, fkDensityRatio: Int): MockDataDependencyAnalysis! + DatabaseMetadata: DatabaseMetadata + SSLStatus: SSLStatus + DatabaseQuerySuggestions(schema: String!): [DatabaseQuerySuggestion!]! + + # Cloud Provider Queries (Generic) + CloudProviders: [AWSProvider!]! + CloudProvider(id: ID!): AWSProvider + DiscoveredConnections: [DiscoveredConnection!]! + ProviderConnections(providerID: ID!): [DiscoveredConnection!]! + + # AWS-specific helpers + LocalAWSProfiles: [LocalAWSProfile!]! + AWSRegions: [AWSRegion!]! +} + +input GenerateChatTitleInput { + Query: String! + ModelType: String! + ProviderId: String + Token: String + Model: String! + Endpoint: String +} + +type GenerateChatTitleResponse { + Title: String! +} + +type Mutation { + BootstrapSealosSession(input: SealosBootstrapInput!): AuthSessionPayload! + CreateStandaloneSession(credentials: LoginCredentials!): AuthSessionPayload! + Login(credentials: LoginCredentials!): StatusResponse! + LoginWithProfile(profile: LoginProfileInput!): StatusResponse! + Logout: StatusResponse! + UpdateSettings(newSettings: SettingsConfigInput!): StatusResponse! + CreateDashboard(name: String!, description: String, refreshRule: String!): Dashboard! + UpdateDashboard(id: ID!, name: String, description: String, refreshRule: String): Dashboard! + DeleteDashboard(id: ID!): StatusResponse! + + AddWidget(dashboardId: ID!, input: WidgetInput!): DashboardWidget! + UpdateWidget(id: ID!, input: UpdateWidgetInput!): DashboardWidget! + DeleteWidget(id: ID!): StatusResponse! + + UpdateWidgetLayouts(dashboardId: ID!, layouts: [LayoutInput!]!): StatusResponse! + UpdateWidgetSnapshot(id: ID!, snapshot: SnapshotInput!): StatusResponse! + + AddStorageUnit(schema: String!, storageUnit: String!, fields: [RecordInput!]!): StatusResponse! + UpdateStorageUnit(schema: String!, storageUnit: String!, values: [RecordInput!]!, updatedColumns: [String!]!): StatusResponse! + ReplaceRow(schema: String!, storageUnit: String!, values: [RecordInput!]!): StatusResponse! + AddRow(schema: String!, storageUnit: String!, values: [RecordInput!]!): StatusResponse! + DeleteRow(schema: String!, storageUnit: String!, values: [RecordInput!]!): StatusResponse! + CreateSQLDataExport(input: SQLDataExportInput!): ExportDownload! + GenerateMockData(input: MockDataGenerationInput!): MockDataGenerationStatus! + + ImportSQL(input: ImportSQLInput!): ImportResult! + ImportPreview(input: ImportPreviewInput!): ImportPreview! + ImportTableFile(input: ImportFileInput!): ImportResult! + ImportCollectionPreview(input: ImportCollectionPreviewInput!): CollectionImportPreview! + ImportCollectionFile(input: ImportCollectionFileInput!): CollectionImportResult! + + # AI Chat Mutations + ExecuteConfirmedSQL(query: String!, operationType: String!): AIChatMessage! + GenerateChatTitle(input: GenerateChatTitleInput!): GenerateChatTitleResponse! + + # Cloud Provider Mutations + # Provider-specific add/update (type-safe inputs) + AddAWSProvider(input: AWSProviderInput!): AWSProvider! + UpdateAWSProvider(id: ID!, input: AWSProviderInput!): AWSProvider! + # Test AWS credentials without saving a provider + TestAWSCredentials(input: AWSProviderInput!): CloudProviderStatus! + # Generic operations (work with any provider type) + RemoveCloudProvider(id: ID!): StatusResponse! + TestCloudProvider(id: ID!): CloudProviderStatus! + RefreshCloudProvider(id: ID!): AWSProvider! + # Generate a short-lived IAM auth token for RDS database authentication + GenerateRDSAuthToken(providerID: ID!, endpoint: String!, port: Int!, region: String!, username: String!): String! +} diff --git a/dataflow/core/graph/schema.resolvers.go b/dataflow/core/graph/schema.resolvers.go new file mode 100644 index 0000000..e580373 --- /dev/null +++ b/dataflow/core/graph/schema.resolvers.go @@ -0,0 +1,2244 @@ +package graph + +// This file will be automatically regenerated based on the schema, any resolver +// implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.88 + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src" + "github.com/clidey/whodb/core/src/analytics" + "github.com/clidey/whodb/core/src/auth" + "github.com/clidey/whodb/core/src/aws" + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/dashboard" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/envconfig" + "github.com/clidey/whodb/core/src/llm" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/mockdata" + "github.com/clidey/whodb/core/src/plugins/ssl" + "github.com/clidey/whodb/core/src/providers" + awsprovider "github.com/clidey/whodb/core/src/providers/aws" + "github.com/clidey/whodb/core/src/sealos" + "github.com/clidey/whodb/core/src/session" + "github.com/clidey/whodb/core/src/settings" + "github.com/clidey/whodb/core/src/version" + "golang.org/x/sync/errgroup" +) + +// BootstrapSealosSession is the resolver for the BootstrapSealosSession field. +func (r *mutationResolver) BootstrapSealosSession(ctx context.Context, input model.SealosBootstrapInput) (*model.AuthSessionPayload, error) { + if !env.GetSealosBootstrapEnabled() { + return nil, errors.New("sealos bootstrap is disabled") + } + + resolver, err := sealos.DefaultBootstrapResolverFactory(input.Kubeconfig) + if err != nil { + return nil, err + } + + identity, err := resolver.ResolveInstanceIdentity(ctx, sealos.InstanceIdentityInput{ + ResourceName: input.ResourceName, + Namespace: valueOrEmpty(input.Namespace), + }) + if err != nil { + return nil, err + } + + svc, err := session.GetDefaultService() + if err != nil { + return nil, err + } + if err := svc.RevokeStaleSealosSessions(ctx, identity.Namespace, identity.ResourceName, identity.UID); err != nil { + return nil, err + } + + resolved, err := resolver.ResolveBootstrap(ctx, sealos.BootstrapInput{ + Kubeconfig: input.Kubeconfig, + DBType: input.DbType, + ResourceName: identity.ResourceName, + DatabaseName: valueOrEmpty(input.DatabaseName), + Host: valueOrEmpty(input.Host), + Port: valueOrEmpty(input.Port), + Namespace: identity.Namespace, + }) + if err != nil { + return nil, err + } + + plugin := src.MainEngine.Choose(engine.DatabaseType(resolved.Credentials.Type)) + if plugin == nil || !plugin.IsAvailable(ctx, engine.NewPluginConfig(resolved.Credentials)) { + return nil, errors.New("unauthorized") + } + + record, token, err := svc.Create(ctx, session.CreateParams{ + Source: "sealos", + K8sUsername: resolved.K8sUsername, + Namespace: resolved.Namespace, + ResourceName: resolved.ResourceName, + InstanceUID: identity.UID, + DBType: resolved.DBType, + Host: resolved.Host, + Port: resolved.Port, + DatabaseName: resolved.DatabaseName, + Credentials: resolved.Credentials, + }) + if err != nil { + return nil, err + } + + displayName := resolved.ResourceName + if displayName == "" { + displayName = resolved.Host + } + + return &model.AuthSessionPayload{ + SessionToken: token, + ExpiresAt: record.ExpiresAt.Format(time.RFC3339), + Type: resolved.Credentials.Type, + Hostname: resolved.Host, + Port: resolved.Port, + Database: resolved.DatabaseName, + DisplayName: displayName, + InstanceUID: &identity.UID, + }, nil +} + +// CreateStandaloneSession is the resolver for the CreateStandaloneSession field. +func (r *mutationResolver) CreateStandaloneSession(ctx context.Context, credentials model.LoginCredentials) (*model.AuthSessionPayload, error) { + if !env.GetStandaloneLoginEnabled() || env.DisableCredentialForm { + return nil, errors.New("standalone login is disabled") + } + + engineCredentials := loginCredentialsToEngineCredentials(credentials) + plugin := src.MainEngine.Choose(engine.DatabaseType(credentials.Type)) + if plugin == nil || !plugin.IsAvailable(ctx, engine.NewPluginConfig(engineCredentials)) { + log.WithFields(log.Fields{ + "type": credentials.Type, + "hostname": credentials.Hostname, + "username": credentials.Username, + "database": credentials.Database, + }).Error("Database connection failed during standalone login - credentials unauthorized") + return nil, errors.New("unauthorized") + } + + svc, err := session.GetDefaultService() + if err != nil { + return nil, err + } + + port := advancedValue(engineCredentials.Advanced, "Port") + record, token, err := svc.Create(ctx, session.CreateParams{ + Source: "standalone", + DBType: credentials.Type, + Host: credentials.Hostname, + Port: port, + DatabaseName: credentials.Database, + Credentials: engineCredentials, + }) + if err != nil { + return nil, err + } + + return authSessionPayload(token, record.ExpiresAt, credentials.Type, credentials.Hostname, port, credentials.Database), nil +} + +// Login is the resolver for the Login field. +func (r *mutationResolver) Login(ctx context.Context, credentials model.LoginCredentials) (*model.StatusResponse, error) { + engineCredentials := loginCredentialsToEngineCredentials(credentials) + + hasProfileID := credentials.ID != nil && strings.TrimSpace(*credentials.ID) != "" + identity := strings.TrimSpace(analytics.MetadataFromContext(ctx).DistinctID) + hasIdentity := identity != "" && identity != "disabled" + + if hasIdentity { + analytics.CaptureWithDistinctID(ctx, identity, "login.attempt", map[string]any{ + "database_type": credentials.Type, + "profile_id_present": hasProfileID, + }) + } + + if !src.MainEngine.Choose(engine.DatabaseType(credentials.Type)).IsAvailable(ctx, &engine.PluginConfig{ + Credentials: engineCredentials, + }) { + log.WithFields(log.Fields{ + "type": credentials.Type, + "hostname": credentials.Hostname, + "username": credentials.Username, + "database": credentials.Database, + }).Error("Database connection failed during login - credentials unauthorized") + + if hasIdentity { + analytics.CaptureWithDistinctID(ctx, identity, "login.denied", map[string]any{ + "database_type": credentials.Type, + "profile_id_present": hasProfileID, + }) + } + return nil, errors.New("unauthorized") + } + + resp, err := auth.Login(ctx, &credentials) + if err != nil { + if hasIdentity { + analytics.CaptureError(ctx, "login.execute", err, map[string]any{ + "database_type": credentials.Type, + "profile_id_present": hasProfileID, + }) + } + return nil, err + } + + if hasIdentity { + traits := map[string]any{ + "profile_id_present": hasProfileID, + } + if hashedHost := analytics.HashIdentifier(credentials.Hostname); hashedHost != "" { + traits["hostname_hash"] = hashedHost + } + if hashedDatabase := analytics.HashIdentifier(credentials.Database); hashedDatabase != "" { + traits["database_hash"] = hashedDatabase + } + + analytics.IdentifyWithDistinctID(ctx, identity, traits) + analytics.CaptureWithDistinctID(ctx, identity, "login.success", map[string]any{ + "database_type": credentials.Type, + "profile_id_present": hasProfileID, + }) + } + + return resp, nil +} + +// LoginWithProfile is the resolver for the LoginWithProfile field. +func (r *mutationResolver) LoginWithProfile(ctx context.Context, profile model.LoginProfileInput) (*model.StatusResponse, error) { + profiles := src.GetLoginProfiles() + for i, loginProfile := range profiles { + profileId := src.GetLoginProfileId(i, loginProfile) + if profile.ID == profileId { + + resolved := src.GetLoginCredentials(loginProfile) + credentials := &model.LoginCredentials{ + ID: &profile.ID, + Type: resolved.Type, + Hostname: resolved.Hostname, + Username: resolved.Username, + Password: resolved.Password, + Database: resolved.Database, + Advanced: func() []*model.RecordInput { + out := make([]*model.RecordInput, 0, len(resolved.Advanced)) + for _, rec := range resolved.Advanced { + out = append(out, &model.RecordInput{Key: rec.Key, Value: rec.Value}) + } + return out + }(), + } + if profile.Database != nil && *profile.Database != "" { + credentials.Database = *profile.Database + resolved.Database = credentials.Database + } + + identity := strings.TrimSpace(analytics.MetadataFromContext(ctx).DistinctID) + hasIdentity := identity != "" && identity != "disabled" + + if hasIdentity { + analytics.CaptureWithDistinctID(ctx, identity, "login_with_profile.attempt", map[string]any{ + "database_type": loginProfile.Type, + "profile_source": loginProfile.Source, + }) + } + + if !src.MainEngine.Choose(engine.DatabaseType(loginProfile.Type)).IsAvailable(ctx, &engine.PluginConfig{ + Credentials: resolved, + }) { + log.WithFields(log.Fields{ + "profile_id": profile.ID, + "type": loginProfile.Type, + }).Error("Database connection failed for login profile - credentials unauthorized") + + if hasIdentity { + analytics.CaptureWithDistinctID(ctx, identity, "login_with_profile.denied", map[string]any{ + "database_type": loginProfile.Type, + "profile_source": loginProfile.Source, + }) + } + return nil, errors.New("unauthorized") + } + + resp, err := auth.Login(ctx, credentials) + if err != nil { + if hasIdentity { + analytics.CaptureError(ctx, "login_with_profile.execute", err, map[string]any{ + "database_type": loginProfile.Type, + "profile_source": loginProfile.Source, + }) + } + return nil, err + } + + if hasIdentity { + traits := map[string]any{ + "profile_source": loginProfile.Source, + "saved_profile": true, + } + if hashedHost := analytics.HashIdentifier(credentials.Hostname); hashedHost != "" { + traits["hostname_hash"] = hashedHost + } + if hashedDatabase := analytics.HashIdentifier(credentials.Database); hashedDatabase != "" { + traits["database_hash"] = hashedDatabase + } + + analytics.IdentifyWithDistinctID(ctx, identity, traits) + analytics.CaptureWithDistinctID(ctx, identity, "login_with_profile.success", map[string]any{ + "database_type": loginProfile.Type, + "profile_source": loginProfile.Source, + }) + } + + return resp, nil + } + } + log.WithFields(log.Fields{ + "profile_id": profile.ID, + }).Error("Login profile not found or not authorized") + return nil, errors.New("login profile does not exist or is not authorized") +} + +// Logout is the resolver for the Logout field. +func (r *mutationResolver) Logout(ctx context.Context) (*model.StatusResponse, error) { + creds := auth.GetCredentials(ctx) + identity := strings.TrimSpace(analytics.MetadataFromContext(ctx).DistinctID) + hasIdentity := identity != "" && identity != "disabled" + hasProfile := false + dbType := "" + if creds != nil { + hasProfile = creds.Id != nil && strings.TrimSpace(*creds.Id) != "" + dbType = creds.Type + } + + if hasIdentity { + analytics.CaptureWithDistinctID(ctx, identity, "logout.attempt", map[string]any{ + "database_type": dbType, + "profile_id_present": hasProfile, + }) + } + + resp, err := auth.Logout(ctx) + if err != nil { + if hasIdentity { + analytics.CaptureError(ctx, "logout.execute", err, map[string]any{ + "database_type": dbType, + "profile_id_present": hasProfile, + }) + } + return nil, err + } + + if hasIdentity { + analytics.CaptureWithDistinctID(ctx, identity, "logout.success", map[string]any{ + "database_type": dbType, + "profile_id_present": hasProfile, + }) + } + + return resp, nil +} + +// UpdateSettings is the resolver for the UpdateSettings field. +func (r *mutationResolver) UpdateSettings(ctx context.Context, newSettings model.SettingsConfigInput) (*model.StatusResponse, error) { + var fields []settings.ISettingsField + + if newSettings.MetricsEnabled != nil { + metricsEnabled := common.StrPtrToBool(newSettings.MetricsEnabled) + fields = append(fields, settings.MetricsEnabledField(metricsEnabled)) + + analytics.TrackMutation(ctx, "UpdateSettings.metrics", map[string]any{ + "metrics_enabled": metricsEnabled, + }) + } + + updated := settings.UpdateSettings(fields...) + return &model.StatusResponse{ + Status: updated, + }, nil +} + +// CreateDashboard is the resolver for the CreateDashboard field. +func (r *mutationResolver) CreateDashboard(ctx context.Context, name string, description *string, refreshRule string) (*model.Dashboard, error) { + service, err := requireDashboardService(r.Resolver) + if err != nil { + return nil, err + } + + item, err := service.CreateDashboard(ctx, dashboard.CreateDashboardParams{ + Name: name, + Description: description, + RefreshRule: refreshRule, + }) + if err != nil { + return nil, err + } + + return mapDashboardModel(*item), nil +} + +// UpdateDashboard is the resolver for the UpdateDashboard field. +func (r *mutationResolver) UpdateDashboard(ctx context.Context, id string, name *string, description *string, refreshRule *string) (*model.Dashboard, error) { + service, err := requireDashboardService(r.Resolver) + if err != nil { + return nil, err + } + + item, err := service.UpdateDashboard(ctx, id, dashboard.UpdateDashboardParams{ + Name: name, + Description: description, + RefreshRule: refreshRule, + }) + if err != nil { + return nil, err + } + + return mapDashboardModel(*item), nil +} + +// DeleteDashboard is the resolver for the DeleteDashboard field. +func (r *mutationResolver) DeleteDashboard(ctx context.Context, id string) (*model.StatusResponse, error) { + service, err := requireDashboardService(r.Resolver) + if err != nil { + return nil, err + } + + if err := service.DeleteDashboard(ctx, id); err != nil { + return nil, err + } + + return &model.StatusResponse{Status: true}, nil +} + +// AddWidget is the resolver for the AddWidget field. +func (r *mutationResolver) AddWidget(ctx context.Context, dashboardID string, input model.WidgetInput) (*model.DashboardWidget, error) { + service, err := requireDashboardService(r.Resolver) + if err != nil { + return nil, err + } + + item, err := service.AddWidget(ctx, dashboardID, dashboard.WidgetInput{ + Type: input.Type, + Title: input.Title, + Description: input.Description, + Layout: input.Layout, + Query: input.Query, + QueryContext: input.QueryContext, + Visualization: input.Visualization, + Snapshot: input.Snapshot, + SortOrder: input.SortOrder, + }) + if err != nil { + return nil, err + } + + return mapWidgetModel(*item), nil +} + +// UpdateWidget is the resolver for the UpdateWidget field. +func (r *mutationResolver) UpdateWidget(ctx context.Context, id string, input model.UpdateWidgetInput) (*model.DashboardWidget, error) { + service, err := requireDashboardService(r.Resolver) + if err != nil { + return nil, err + } + + item, err := service.UpdateWidget(ctx, id, dashboard.UpdateWidgetInput{ + Title: input.Title, + Description: input.Description, + Layout: input.Layout, + Query: input.Query, + QueryContext: input.QueryContext, + Visualization: input.Visualization, + Snapshot: input.Snapshot, + SortOrder: input.SortOrder, + }) + if err != nil { + return nil, err + } + + return mapWidgetModel(*item), nil +} + +// DeleteWidget is the resolver for the DeleteWidget field. +func (r *mutationResolver) DeleteWidget(ctx context.Context, id string) (*model.StatusResponse, error) { + service, err := requireDashboardService(r.Resolver) + if err != nil { + return nil, err + } + + if err := service.DeleteWidget(ctx, id); err != nil { + return nil, err + } + + return &model.StatusResponse{Status: true}, nil +} + +// UpdateWidgetLayouts is the resolver for the UpdateWidgetLayouts field. +func (r *mutationResolver) UpdateWidgetLayouts(ctx context.Context, dashboardID string, layouts []*model.LayoutInput) (*model.StatusResponse, error) { + service, err := requireDashboardService(r.Resolver) + if err != nil { + return nil, err + } + + inputs := make([]dashboard.LayoutInput, 0, len(layouts)) + for _, layout := range layouts { + inputs = append(inputs, dashboard.LayoutInput{ + WidgetID: layout.WidgetID, + Layout: layout.Layout, + }) + } + + if err := service.UpdateWidgetLayouts(ctx, dashboardID, inputs); err != nil { + return nil, err + } + + return &model.StatusResponse{Status: true}, nil +} + +// UpdateWidgetSnapshot is the resolver for the UpdateWidgetSnapshot field. +func (r *mutationResolver) UpdateWidgetSnapshot(ctx context.Context, id string, snapshot model.SnapshotInput) (*model.StatusResponse, error) { + service, err := requireDashboardService(r.Resolver) + if err != nil { + return nil, err + } + + if err := service.UpdateWidgetSnapshot(ctx, id, dashboard.SnapshotInput{ + Config: snapshot.Config, + Data: snapshot.Data, + ExecutedAt: snapshot.ExecutedAt, + }); err != nil { + return nil, err + } + + return &model.StatusResponse{Status: true}, nil +} + +// AddStorageUnit is the resolver for the AddStorageUnit field. +func (r *mutationResolver) AddStorageUnit(ctx context.Context, schema string, storageUnit string, fields []*model.RecordInput) (*model.StatusResponse, error) { + plugin, config := GetPluginForContext(ctx) + typeArg := config.Credentials.Type + var fieldsMap []engine.Record + for _, field := range fields { + extraFields := map[string]string{} + for _, extraField := range field.Extra { + extraFields[extraField.Key] = extraField.Value + } + fieldsMap = append(fieldsMap, engine.Record{ + Key: field.Key, + Value: field.Value, + Extra: extraFields, + }) + } + status, err := plugin.AddStorageUnit(config, schema, storageUnit, fieldsMap) + if err != nil { + log.WithFields(log.Fields{ + "operation": "AddStorageUnit", + "schema": schema, + "storage_unit": storageUnit, + "database_type": typeArg, + }).WithError(err).Error("Database operation failed") + analytics.CaptureError(ctx, "AddStorageUnit", err, map[string]any{ + "database_type": typeArg, + "schema_hash": analytics.HashIdentifier(schema), + "storage_hash": analytics.HashIdentifier(storageUnit), + }) + return nil, err + } + + analytics.TrackMutation(ctx, "AddStorageUnit", map[string]any{ + "database_type": typeArg, + "schema_hash": analytics.HashIdentifier(schema), + "storage_hash": analytics.HashIdentifier(storageUnit), + "field_count": len(fields), + }) + + return &model.StatusResponse{ + Status: status, + }, nil +} + +// UpdateStorageUnit is the resolver for the UpdateStorageUnit field. +func (r *mutationResolver) UpdateStorageUnit(ctx context.Context, schema string, storageUnit string, values []*model.RecordInput, updatedColumns []string) (*model.StatusResponse, error) { + plugin, config := GetPluginForContext(ctx) + typeArg := config.Credentials.Type + + if err := ValidateStorageUnit(plugin, config, schema, storageUnit); err != nil { + return nil, err + } + + valuesMap := map[string]string{} + for _, value := range values { + valuesMap[value.Key] = value.Value + } + status, err := plugin.UpdateStorageUnit(config, schema, storageUnit, valuesMap, updatedColumns) + if err != nil { + log.WithFields(log.Fields{ + "operation": "UpdateStorageUnit", + "schema": schema, + "storage_unit": storageUnit, + "database_type": typeArg, + "updated_columns": len(updatedColumns), + }).WithError(err).Error("Database operation failed") + analytics.CaptureError(ctx, "UpdateStorageUnit", err, map[string]any{ + "database_type": typeArg, + "schema_hash": analytics.HashIdentifier(schema), + "storage_hash": analytics.HashIdentifier(storageUnit), + "updated_columns": len(updatedColumns), + "values_supplied": len(values), + }) + return nil, err + } + + analytics.TrackMutation(ctx, "UpdateStorageUnit", map[string]any{ + "database_type": typeArg, + "schema_hash": analytics.HashIdentifier(schema), + "storage_hash": analytics.HashIdentifier(storageUnit), + "updated_columns": len(updatedColumns), + "values_supplied": len(values), + }) + + return &model.StatusResponse{ + Status: status, + }, nil +} + +// ReplaceRow is the resolver for the ReplaceRow field. +func (r *mutationResolver) ReplaceRow(ctx context.Context, schema string, storageUnit string, values []*model.RecordInput) (*model.StatusResponse, error) { + plugin, config := GetPluginForContext(ctx) + typeArg := config.Credentials.Type + + if err := ValidateStorageUnit(plugin, config, schema, storageUnit); err != nil { + return nil, err + } + + valuesMap := map[string]string{} + for _, value := range values { + valuesMap[value.Key] = value.Value + } + status, err := plugin.ReplaceRow(config, schema, storageUnit, valuesMap) + if err != nil { + log.WithFields(log.Fields{ + "operation": "ReplaceRow", + "schema": schema, + "storage_unit": storageUnit, + "database_type": typeArg, + "values_count": len(values), + }).WithError(err).Error("Database operation failed") + analytics.CaptureError(ctx, "ReplaceRow", err, map[string]any{ + "database_type": typeArg, + "schema_hash": analytics.HashIdentifier(schema), + "storage_hash": analytics.HashIdentifier(storageUnit), + "values_count": len(values), + }) + return nil, err + } + + analytics.TrackMutation(ctx, "ReplaceRow", map[string]any{ + "database_type": typeArg, + "schema_hash": analytics.HashIdentifier(schema), + "storage_hash": analytics.HashIdentifier(storageUnit), + "values_count": len(values), + }) + + return &model.StatusResponse{ + Status: status, + }, nil +} + +// AddRow is the resolver for the AddRow field. +func (r *mutationResolver) AddRow(ctx context.Context, schema string, storageUnit string, values []*model.RecordInput) (*model.StatusResponse, error) { + plugin, config := GetPluginForContext(ctx) + typeArg := config.Credentials.Type + + if err := ValidateStorageUnit(plugin, config, schema, storageUnit); err != nil { + return nil, err + } + + log.WithFields(log.Fields{ + "operation": "AddRow-Resolver", + "schema": schema, + "storage_unit": storageUnit, + "database_type": typeArg, + "values_count": len(values), + }).Debug("AddRow resolver called") + + valuesRecords := []engine.Record{} + for _, field := range values { + extraFields := map[string]string{} + for _, extraField := range field.Extra { + extraFields[extraField.Key] = extraField.Value + } + valuesRecords = append(valuesRecords, engine.Record{ + Key: field.Key, + Value: field.Value, + Extra: extraFields, + }) + } + + status, err := plugin.AddRow(config, schema, storageUnit, valuesRecords) + if err != nil { + log.WithFields(log.Fields{ + "operation": "AddRow", + "schema": schema, + "storage_unit": storageUnit, + "database_type": typeArg, + }).WithError(err).Error("Database operation failed") + analytics.CaptureError(ctx, "AddRow", err, map[string]any{ + "database_type": typeArg, + "schema_hash": analytics.HashIdentifier(schema), + "storage_hash": analytics.HashIdentifier(storageUnit), + "value_count": len(values), + }) + return nil, err + } + + analytics.TrackMutation(ctx, "AddRow", map[string]any{ + "database_type": typeArg, + "schema_hash": analytics.HashIdentifier(schema), + "storage_hash": analytics.HashIdentifier(storageUnit), + "value_count": len(values), + }) + + return &model.StatusResponse{ + Status: status, + }, nil +} + +// DeleteRow is the resolver for the DeleteRow field. +func (r *mutationResolver) DeleteRow(ctx context.Context, schema string, storageUnit string, values []*model.RecordInput) (*model.StatusResponse, error) { + plugin, config := GetPluginForContext(ctx) + typeArg := config.Credentials.Type + + if err := ValidateStorageUnit(plugin, config, schema, storageUnit); err != nil { + return nil, err + } + + valuesMap := map[string]string{} + for _, value := range values { + valuesMap[value.Key] = value.Value + } + status, err := plugin.DeleteRow(config, schema, storageUnit, valuesMap) + if err != nil { + log.WithFields(log.Fields{ + "operation": "DeleteRow", + "schema": schema, + "storage_unit": storageUnit, + "database_type": typeArg, + }).WithError(err).Error("Database operation failed") + analytics.CaptureError(ctx, "DeleteRow", err, map[string]any{ + "database_type": typeArg, + "schema_hash": analytics.HashIdentifier(schema), + "storage_hash": analytics.HashIdentifier(storageUnit), + "value_count": len(values), + }) + return nil, err + } + + analytics.TrackMutation(ctx, "DeleteRow", map[string]any{ + "database_type": typeArg, + "schema_hash": analytics.HashIdentifier(schema), + "storage_hash": analytics.HashIdentifier(storageUnit), + "value_count": len(values), + }) + + return &model.StatusResponse{ + Status: status, + }, nil +} + +// CreateSQLDataExport is the resolver for the CreateSQLDataExport field. +func (r *mutationResolver) CreateSQLDataExport(ctx context.Context, input model.SQLDataExportInput) (*model.ExportDownload, error) { + plugin, config := GetPluginForContext(ctx) + if err := ValidateStorageUnit(plugin, config, input.Schema, input.StorageUnit); err != nil { + return nil, err + } + if err := validateSQLDataExportTable(plugin, config, input.Schema, input.StorageUnit); err != nil { + return nil, err + } + + req := &engine.SQLDataExportRequest{ + Schema: input.Schema, + StorageUnit: input.StorageUnit, + Mode: engine.SQLDataExportMode(input.Mode.String()), + Where: input.Where, + Sort: input.Sort, + Limit: input.Limit, + } + exportFile, err := createSQLDataExportFile(plugin, config, req) + if err != nil { + log.WithFields(log.Fields{ + "operation": "CreateSQLDataExport", + "schema": input.Schema, + "storage_unit": input.StorageUnit, + "mode": input.Mode.String(), + }).WithError(err).Error("SQL data export failed") + return nil, err + } + + return &model.ExportDownload{ + ID: exportFile.ID, + Filename: exportFile.Filename, + ContentType: exportFile.ContentType, + DownloadURL: "/api/exports/" + exportFile.ID + "/download", + Size: int(exportFile.Size), + }, nil +} + +// GenerateMockData is the resolver for the GenerateMockData field. +func (r *mutationResolver) GenerateMockData(ctx context.Context, input model.MockDataGenerationInput) (*model.MockDataGenerationStatus, error) { + log.WithField("schema", input.Schema).WithField("table", input.StorageUnit).WithField("rowCount", input.RowCount).WithField("overwrite", input.OverwriteExisting).Info("Starting mock data generation") + + maxRowLimit := mockdata.GetMockDataGenerationMaxRowCount() + if input.RowCount > maxRowLimit { + log.WithField("requested", input.RowCount).WithField("max", maxRowLimit).Error("Row count exceeds maximum limit") + return nil, fmt.Errorf("row count exceeds maximum limit of %d", maxRowLimit) + } + + if !mockdata.IsMockDataGenerationAllowed(input.StorageUnit) { + log.WithField("table", input.StorageUnit).Error("Mock data generation not allowed for table") + return nil, errors.New("mock data generation is not allowed for this table") + } + + plugin, config := GetPluginForContext(ctx) + + fkRatio := 0 + if input.FkDensityRatio != nil { + fkRatio = *input.FkDensityRatio + } + generator := src.NewMockDataGenerator(fkRatio) + result, err := generator.Generate(plugin, config, input.Schema, input.StorageUnit, input.RowCount, input.OverwriteExisting) + if err != nil { + log.WithError(err).Error("Mock data generation failed") + return nil, fmt.Errorf("mock data generation failed: %w", err) + } + + var details []*model.MockDataTableDetail + if len(result.Details) > 0 { + details = make([]*model.MockDataTableDetail, len(result.Details)) + for i, d := range result.Details { + details[i] = &model.MockDataTableDetail{ + Table: d.Table, + RowsGenerated: d.RowsGenerated, + UsedExistingData: d.UsedExistingData, + } + } + } + + log.WithField("generatedRows", result.TotalGenerated).Info("Mock data generation completed successfully") + return &model.MockDataGenerationStatus{ + AmountGenerated: result.TotalGenerated, + Details: details, + }, nil +} + +// ImportSQL is the resolver for the ImportSQL field. +func (r *mutationResolver) ImportSQL(ctx context.Context, input model.ImportSQLInput) (*model.ImportResult, error) { + plugin, config := GetPluginForContext(ctx) + + script := "" + if input.Script != nil { + script = *input.Script + } + hasScript := script != "" + hasFile := input.File != nil + + if hasScript && hasFile { + return importResult(false, importErrorSQLSourceBoth), nil + } + if !hasScript && !hasFile { + return importResult(false, importErrorSQLSourceMissing), nil + } + + if hasFile { + data, err := readUploadBytes(*input.File, maxImportFileSizeBytes) + if err != nil { + return importResult(false, importErrorSQLFileFailed), nil + } + script = string(data) + } + + if int64(len([]byte(script))) > maxImportFileSizeBytes { + return importResult(false, importErrorSQLTooLarge), nil + } + + config.MultiStatement = true + _, err := plugin.RawExecute(config, script) + if err != nil { + if errors.Is(err, engine.ErrMultiStatementUnsupported) { + return importResult(false, importErrorSQLMultiStatementUnsupported), nil + } + log.WithError(err).Error("SQL import failed") + return importResultWithMessage(false, importErrorSQLFailed, err.Error()), nil + } + + return importResult(true, ""), nil +} + +// ImportPreview is the resolver for the ImportPreview field. +func (r *mutationResolver) ImportPreview(ctx context.Context, input model.ImportPreviewInput) (*model.ImportPreview, error) { + data, err := readUploadBytes(input.File, maxImportFileSizeBytes) + if err != nil { + return nil, err + } + + result, err := parseImportFile(data, input.Options, importPreviewRowLimit, false) + if err != nil { + return nil, err + } + + preview := &model.ImportPreview{ + Sheets: result.sheets, + Columns: result.columns, + Rows: result.rows, + Truncated: result.truncated, + RequiresAllowAutoGenerated: false, + AutoGeneratedColumns: []string{}, + } + if result.sheet != "" { + preview.Sheet = &result.sheet + } + + plugin, config := GetPluginForContext(ctx) + + switch input.TargetMode { + case model.ImportTargetModeExistingTable: + if input.ExistingTable == nil || input.NewTable != nil { + return nil, newImportValidationError(importValidationTargetModeInvalid) + } + target := input.ExistingTable + columns, err := plugin.GetColumnsForTable(config, target.Schema, target.StorageUnit) + if err != nil { + return nil, err + } + if err := plugin.MarkGeneratedColumns(config, target.Schema, target.StorageUnit, columns); err != nil { + log.WithError(err).Warn("Failed to mark generated columns for import preview") + } + + allowAutoGenerated := target.UseHeaderMapping + mappings, autoGeneratedColumns, err := buildImportMappingInputs( + preview.Columns, + columns, + target.UseHeaderMapping, + allowAutoGenerated, + ) + if err != nil { + key := validationKeyFromError(err) + if key != "" { + preview.ValidationError = &key + } + if len(autoGeneratedColumns) > 0 { + preview.RequiresAllowAutoGenerated = true + preview.AutoGeneratedColumns = autoGeneratedColumns + if len(mappings) > 0 { + preview.Mapping = importMappingPreviewItems(mappings) + } + } + return preview, nil + } + + if len(autoGeneratedColumns) > 0 { + preview.RequiresAllowAutoGenerated = true + preview.AutoGeneratedColumns = autoGeneratedColumns + } + if len(mappings) > 0 { + preview.Mapping = importMappingPreviewItems(mappings) + } + case model.ImportTargetModeNewTable: + if input.NewTable == nil || input.ExistingTable != nil { + return nil, newImportValidationError(importValidationTargetModeInvalid) + } + preview.NewTable = buildNewTablePreview(input.File.Filename, result.columns, plugin.GetDatabaseMetadata()) + default: + return nil, newImportValidationError(importValidationTargetModeInvalid) + } + + return preview, nil +} + +// ImportTableFile is the resolver for the ImportTableFile field. +func (r *mutationResolver) ImportTableFile(ctx context.Context, input model.ImportFileInput) (*model.ImportResult, error) { + plugin, config := GetPluginForContext(ctx) + + data, err := readUploadBytes(input.File, maxImportFileSizeBytes) + if err != nil { + return importResult(false, validationKeyFromError(err)), nil + } + + if input.Options == nil { + return importResult(false, importValidationInvalidOptions), nil + } + + parsed, err := parseImportFile(data, input.Options, maxImportRows, true) + if err != nil { + return importResult(false, validationKeyFromError(err)), nil + } + + if len(parsed.rows) == 0 { + return importResult(false, importErrorNoRows), nil + } + + switch input.TargetMode { + case model.ImportTargetModeExistingTable: + if input.ExistingTable == nil || input.NewTable != nil { + return importResult(false, importValidationTargetModeInvalid), nil + } + return importExistingTableFile(plugin, config, parsed, input.ExistingTable) + case model.ImportTargetModeNewTable: + if input.NewTable == nil || input.ExistingTable != nil { + return importResult(false, importValidationTargetModeInvalid), nil + } + return importNewTableFile(plugin, config, parsed, input.NewTable) + default: + return importResult(false, importValidationTargetModeInvalid), nil + } +} + +// ImportCollectionPreview is the resolver for the ImportCollectionPreview field. +func (r *mutationResolver) ImportCollectionPreview(ctx context.Context, input model.ImportCollectionPreviewInput) (*model.CollectionImportPreview, error) { + data, err := readUploadBytes(input.File, maxImportFileSizeBytes) + if err != nil { + return nil, err + } + return buildCollectionImportPreview(data, input), nil +} + +// ImportCollectionFile is the resolver for the ImportCollectionFile field. +func (r *mutationResolver) ImportCollectionFile(ctx context.Context, input model.ImportCollectionFileInput) (*model.CollectionImportResult, error) { + plugin, config := GetPluginForContext(ctx) + + importer, ok := plugin.PluginFunctions.(engine.CollectionImporter) + if !ok { + return collectionImportFailure(importErrorCollectionUnsupported), nil + } + + data, err := readUploadBytes(input.File, maxImportFileSizeBytes) + if err != nil { + return collectionImportFailure(validationKeyFromError(err)), nil + } + + documents, _, err := parseCollectionDocuments(data, input.Format, input.Delimiter, input.Sheet, input.SkipColumns, maxImportRows, true) + if err != nil { + return collectionImportFailure(validationKeyFromError(err)), nil + } + if len(documents) == 0 { + return collectionImportFailure(importErrorNoRows), nil + } + + return runCollectionImport(plugin, config, importer, input, documents) +} + +// ExecuteConfirmedSQL is the resolver for the ExecuteConfirmedSQL field. +func (r *mutationResolver) ExecuteConfirmedSQL(ctx context.Context, query string, operationType string) (*model.AIChatMessage, error) { + // Get plugin and config from context + plugin, config := GetPluginForContext(ctx) + + // Execute the SQL query + result, execErr := plugin.RawExecute(config, query) + + message := &model.AIChatMessage{ + Type: operationType, + Text: query, + RequiresConfirmation: false, + } + + if execErr != nil { + message.Type = "error" + message.Text = execErr.Error() + } else { + // Convert result + var columns []*model.Column + for _, column := range result.Columns { + columns = append(columns, &model.Column{ + Type: column.Type, + Name: column.Name, + }) + } + message.Result = &model.RowsResult{ + Columns: columns, + Rows: result.Rows, + } + } + + return message, nil +} + +// GenerateChatTitle is the resolver for the GenerateChatTitle field. +func (r *mutationResolver) GenerateChatTitle(ctx context.Context, input model.GenerateChatTitleInput) (*model.GenerateChatTitleResponse, error) { + return generateChatTitleImpl(ctx, input) +} + +// AddAWSProvider is the resolver for the AddAWSProvider field. +func (r *mutationResolver) AddAWSProvider(ctx context.Context, input model.AWSProviderInput) (*model.AWSProvider, error) { + if !env.IsAWSProviderEnabled { + return nil, aws.ErrAWSProviderDisabled + } + + id := settings.GenerateProviderID(input.Name, input.Region) + + // Derive auth method: if a profile name is provided, use "profile"; otherwise "default" + authMethod := "default" + profileName := "" + if input.ProfileName != nil && *input.ProfileName != "" { + authMethod = "profile" + profileName = *input.ProfileName + } + + discoverRDS := true + if input.DiscoverRds != nil { + discoverRDS = *input.DiscoverRds + } + + discoverElastiCache := true + if input.DiscoverElastiCache != nil { + discoverElastiCache = *input.DiscoverElastiCache + } + + discoverDocumentDB := true + if input.DiscoverDocumentDb != nil { + discoverDocumentDB = *input.DiscoverDocumentDb + } + + cfg := &settings.AWSProviderConfig{ + ID: id, + Name: input.Name, + Region: input.Region, + AuthMethod: authMethod, + ProfileName: profileName, + DiscoverRDS: discoverRDS, + DiscoverElastiCache: discoverElastiCache, + DiscoverDocumentDB: discoverDocumentDB, + } + + state, err := settings.AddAWSProvider(cfg) + if err != nil { + return nil, err + } + + return stateToAWSProvider(state), nil +} + +// UpdateAWSProvider is the resolver for the UpdateAWSProvider field. +func (r *mutationResolver) UpdateAWSProvider(ctx context.Context, id string, input model.AWSProviderInput) (*model.AWSProvider, error) { + if !env.IsAWSProviderEnabled { + return nil, aws.ErrAWSProviderDisabled + } + + existing, err := settings.GetAWSProvider(id) + if err != nil { + return nil, err + } + + // Derive auth method from profile name presence + authMethod := "default" + profileName := existing.Config.ProfileName + if input.ProfileName != nil { + profileName = *input.ProfileName + } + if profileName != "" { + authMethod = "profile" + } + + discoverRDS := existing.Config.DiscoverRDS + if input.DiscoverRds != nil { + discoverRDS = *input.DiscoverRds + } + + discoverElastiCache := existing.Config.DiscoverElastiCache + if input.DiscoverElastiCache != nil { + discoverElastiCache = *input.DiscoverElastiCache + } + + discoverDocumentDB := existing.Config.DiscoverDocumentDB + if input.DiscoverDocumentDb != nil { + discoverDocumentDB = *input.DiscoverDocumentDb + } + + cfg := &settings.AWSProviderConfig{ + ID: id, + Name: input.Name, + Region: input.Region, + AuthMethod: authMethod, + ProfileName: profileName, + DiscoverRDS: discoverRDS, + DiscoverElastiCache: discoverElastiCache, + DiscoverDocumentDB: discoverDocumentDB, + } + + state, err := settings.UpdateAWSProvider(id, cfg) + if err != nil { + return nil, err + } + + return stateToAWSProvider(state), nil +} + +// TestAWSCredentials is the resolver for the TestAWSCredentials field. +// Tests AWS credentials without creating/persisting a provider. +func (r *mutationResolver) TestAWSCredentials(ctx context.Context, input model.AWSProviderInput) (model.CloudProviderStatus, error) { + if !env.IsAWSProviderEnabled { + return model.CloudProviderStatusError, aws.ErrAWSProviderDisabled + } + + authMethod := "default" + profileName := "" + if input.ProfileName != nil && *input.ProfileName != "" { + authMethod = "profile" + profileName = *input.ProfileName + } + + log.Infof("TestAWSCredentials: testing region=%s, authMethod=%s, profileName=%s", input.Region, authMethod, profileName) + + cfg := &awsprovider.Config{ + ID: "test-temp", + Name: input.Name, + Region: input.Region, + AuthMethod: aws.AuthMethod(authMethod), + ProfileName: profileName, + } + + provider, err := awsprovider.New(cfg) + if err != nil { + log.Errorf("TestAWSCredentials: failed to create provider: %v", err) + return model.CloudProviderStatusError, err + } + + testCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + if err := provider.TestConnection(testCtx); err != nil { + log.Warnf("TestAWSCredentials: connection test failed: %v", err) + return model.CloudProviderStatusError, err + } + log.Infof("TestAWSCredentials: connection successful for region=%s", input.Region) + return model.CloudProviderStatusConnected, nil +} + +// RemoveCloudProvider is the resolver for the RemoveCloudProvider field. +func (r *mutationResolver) RemoveCloudProvider(ctx context.Context, id string) (*model.StatusResponse, error) { + if !env.IsAWSProviderEnabled { + return &model.StatusResponse{Status: false}, aws.ErrAWSProviderDisabled + } + + // Router pattern: when adding GCP, dispatch based on settings.GetProviderType(id). + // See .claude/docs/cloud-providers.md for full example. + err := settings.RemoveAWSProvider(id) + if err != nil { + return &model.StatusResponse{Status: false}, err + } + return &model.StatusResponse{Status: true}, nil +} + +// TestCloudProvider is the resolver for the TestCloudProvider field. +func (r *mutationResolver) TestCloudProvider(ctx context.Context, id string) (model.CloudProviderStatus, error) { + if !env.IsAWSProviderEnabled { + return model.CloudProviderStatusError, aws.ErrAWSProviderDisabled + } + + status, err := settings.TestAWSProvider(id) + if err != nil { + return model.CloudProviderStatusError, err + } + return mapCloudProviderStatus(status), nil +} + +// RefreshCloudProvider is the resolver for the RefreshCloudProvider field. +func (r *mutationResolver) RefreshCloudProvider(ctx context.Context, id string) (*model.AWSProvider, error) { + if !env.IsAWSProviderEnabled { + return nil, aws.ErrAWSProviderDisabled + } + + state, err := settings.RefreshAWSProvider(id) + if err != nil { + return nil, err + } + return stateToAWSProvider(state), nil +} + +// GenerateRDSAuthToken is the resolver for the GenerateRDSAuthToken field. +// Generates a short-lived IAM auth token for RDS database authentication. +func (r *mutationResolver) GenerateRDSAuthToken(ctx context.Context, providerID string, endpoint string, port int, region string, username string) (string, error) { + if !env.IsAWSProviderEnabled { + return "", aws.ErrAWSProviderDisabled + } + + log.Infof("GenerateRDSAuthToken: providerID=%s, endpoint=%s, port=%d, region=%s, username=%s", providerID, endpoint, port, region, username) + + registry := providers.GetDefaultRegistry() + provider, err := registry.Get(providerID) + if err != nil { + log.Errorf("GenerateRDSAuthToken: provider not found: %v", err) + return "", err + } + + awsProvider, ok := provider.(*awsprovider.Provider) + if !ok { + log.Errorf("GenerateRDSAuthToken: provider %s is not an AWS provider", providerID) + return "", fmt.Errorf("provider %s is not an AWS provider", providerID) + } + + tokenCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + token, err := aws.GenerateRDSAuthToken(tokenCtx, awsProvider.GetAWSConfig(), endpoint, port, region, username) + if err != nil { + log.Errorf("GenerateRDSAuthToken: failed: %v", err) + return "", err + } + log.Infof("GenerateRDSAuthToken: token generated successfully (length=%d)", len(token)) + return token, nil +} + +// ResolveSealosInstanceIdentity is the resolver for the ResolveSealosInstanceIdentity field. +func (r *queryResolver) ResolveSealosInstanceIdentity(ctx context.Context, input model.SealosInstanceIdentityInput) (*model.SealosInstanceIdentity, error) { + if !env.GetSealosBootstrapEnabled() { + return nil, errors.New("sealos bootstrap is disabled") + } + + resolver, err := sealos.DefaultBootstrapResolverFactory(input.Kubeconfig) + if err != nil { + return nil, err + } + + identity, err := resolver.ResolveInstanceIdentity(ctx, sealos.InstanceIdentityInput{ + ResourceName: input.ResourceName, + Namespace: valueOrEmpty(input.Namespace), + }) + if err != nil { + return nil, err + } + + return &model.SealosInstanceIdentity{ + UID: identity.UID, + Namespace: identity.Namespace, + ResourceName: identity.ResourceName, + }, nil +} + +// Version is the resolver for the Version field. +func (r *queryResolver) Version(ctx context.Context) (string, error) { + if env.ApplicationVersion != "" { + return env.ApplicationVersion, nil + } + // Default fallback for development + return "development", nil +} + +// UpdateInfo is the resolver for the UpdateInfo field. +func (r *queryResolver) UpdateInfo(ctx context.Context) (*model.UpdateInfo, error) { + currentVersion := env.ApplicationVersion + if currentVersion == "" { + currentVersion = "development" + } + + disabled := env.GetDisableUpdateCheck() || os.Getenv("SNAP") != "" + + info := version.CheckForUpdate(currentVersion, disabled) + return &model.UpdateInfo{ + CurrentVersion: info.CurrentVersion, + LatestVersion: info.LatestVersion, + UpdateAvailable: info.UpdateAvailable, + ReleaseURL: info.ReleaseURL, + }, nil +} + +// Health is the resolver for the Health field. +func (r *queryResolver) Health(ctx context.Context) (*model.HealthStatus, error) { + status := &model.HealthStatus{ + Server: "healthy", + Database: "unavailable", + } + + // Check if user is authenticated and has credentials + credentials := auth.GetCredentials(ctx) + if credentials != nil && credentials.Type != "" { + config := engine.NewPluginConfig(credentials) + plugin := src.MainEngine.Choose(engine.DatabaseType(config.Credentials.Type)) + + if plugin != nil { + // Create a context with 5 second timeout (Oracle connections can take 3-8s) + healthCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + done := make(chan bool, 1) + + go func() { + defer func() { + if r := recover(); r != nil { + log.Errorf("Panic during health check for database: %v", r) + status.Database = "error" + } + done <- true + }() + + if plugin.IsAvailable(healthCtx, config) { + status.Database = "healthy" + } else { + status.Database = "error" + } + }() + + select { + case <-done: + // Health check completed + case <-healthCtx.Done(): + // Timeout - database is not responding + status.Database = "error" + } + } + } + + return status, nil +} + +// GetDashboards is the resolver for the GetDashboards field. +func (r *queryResolver) GetDashboards(ctx context.Context) ([]*model.Dashboard, error) { + service, err := requireDashboardService(r.Resolver) + if err != nil { + return nil, err + } + + items, err := service.GetDashboards(ctx) + if err != nil { + return nil, err + } + + result := make([]*model.Dashboard, 0, len(items)) + for _, item := range items { + result = append(result, mapDashboardModel(item)) + } + + return result, nil +} + +// Profiles is the resolver for the Profiles field. +func (r *queryResolver) Profiles(ctx context.Context) ([]*model.LoginProfile, error) { + var profiles []*model.LoginProfile + for i, profile := range src.GetLoginProfiles() { + profileName := src.GetLoginProfileId(i, profile) + + // Check if SSL is configured (mode is set and not "disabled") + sslConfigured := false + if mode, ok := profile.Advanced[ssl.KeySSLMode]; ok && mode != "" && mode != string(ssl.SSLModeDisabled) { + sslConfigured = true + } + + loginProfile := &model.LoginProfile{ + ID: profileName, + Type: model.DatabaseType(profile.Type), + Hostname: &profile.Hostname, + Database: &profile.Database, + IsEnvironmentDefined: true, + Source: profile.Source, + SSLConfigured: sslConfigured, + } + if len(profile.Alias) > 0 { + loginProfile.Alias = &profile.Alias + } + if len(profile.CustomId) > 0 { + loginProfile.ID = profile.CustomId + } + profiles = append(profiles, loginProfile) + } + return profiles, nil +} + +// Database is the resolver for the Database field. +// This resolver is used in two scenarios: +// 1. Login page: to get available databases (e.g., SQLite files) before authentication +// 2. Sidebar: to get switchable databases when already logged in +// +// For the sidebar case, we use session credentials. For login page, we fall back +// to a minimal config (works for SQLite which scans filesystem, not for MySQL which needs connection). +func (r *queryResolver) Database(ctx context.Context, typeArg string) ([]string, error) { + plugin := src.MainEngine.Choose(engine.DatabaseType(typeArg)) + if plugin == nil { + return nil, fmt.Errorf("unsupported database type: %s", typeArg) + } + + var config *engine.PluginConfig + + // Try to get credentials from session (for sidebar when logged in) + credentials := auth.GetCredentials(ctx) + if credentials != nil && credentials.Type == typeArg { + config = engine.NewPluginConfig(credentials) + } else { + // No session or type mismatch - use minimal config. works for sqlite + config = &engine.PluginConfig{ + Credentials: &engine.Credentials{ + Type: typeArg, + }, + } + } + + databases, err := plugin.GetDatabases(config) + if err != nil { + log.WithFields(log.Fields{ + "operation": "GetDatabases", + "database_type": typeArg, + }).WithError(err).Error("Database operation failed") + return nil, err + } + return databases, nil +} + +// Schema is the resolver for the Schema field. +func (r *queryResolver) Schema(ctx context.Context) ([]string, error) { + plugin, config := GetPluginForContext(ctx) + typeArg := config.Credentials.Type + schemas, err := plugin.GetAllSchemas(config) + if err != nil { + log.WithFields(log.Fields{ + "operation": "GetAllSchemas", + "database_type": typeArg, + }).WithError(err).Error("Database operation failed") + return nil, err + } + return schemas, nil +} + +// StorageUnit is the resolver for the StorageUnit field. +func (r *queryResolver) StorageUnit(ctx context.Context, schema string) ([]*model.StorageUnit, error) { + plugin, config := GetPluginForContext(ctx) + typeArg := config.Credentials.Type + units, err := plugin.GetStorageUnits(config, schema) + if err != nil { + log.WithFields(log.Fields{ + "operation": "GetStorageUnits", + "schema": schema, + "database_type": typeArg, + }).WithError(err).Error("Database operation failed") + return nil, err + } + var storageUnits []*model.StorageUnit + for _, unit := range units { + storageUnit := engine.GetStorageUnitModel(unit) + storageUnit.IsMockDataGenerationAllowed = mockdata.IsMockDataGenerationAllowed(unit.Name) + storageUnits = append(storageUnits, storageUnit) + } + return storageUnits, nil +} + +// Row is the resolver for the Row field. +func (r *queryResolver) Row(ctx context.Context, schema string, storageUnit string, where *model.WhereCondition, sort []*model.SortCondition, pageSize int, pageOffset int) (*model.RowsResult, error) { + if pageSize <= 0 { + return nil, fmt.Errorf("pageSize must be greater than 0") + } + if pageSize > env.MaxPageSize { + return nil, fmt.Errorf("pageSize must not exceed %d", env.MaxPageSize) + } + if pageOffset < 0 { + return nil, fmt.Errorf("pageOffset must not be negative") + } + + plugin, config := GetPluginForContext(ctx) + typeArg := config.Credentials.Type + + if err := ValidateStorageUnit(plugin, config, schema, storageUnit); err != nil { + return nil, err + } + + // Run GetRows and GetColumnsForTable in parallel + var rowsResult *engine.GetRowsResult + var tableColumns []engine.Column + + g, _ := errgroup.WithContext(ctx) + + g.Go(func() error { + var err error + rowsResult, err = plugin.GetRows(config, &engine.GetRowsRequest{ + Schema: schema, + StorageUnit: storageUnit, + Where: where, + Sort: sort, + PageSize: pageSize, + PageOffset: pageOffset, + }) + if err != nil { + log.WithFields(log.Fields{ + "operation": "GetRows", + "schema": schema, + "storage_unit": storageUnit, + "database_type": typeArg, + "page_size": pageSize, + "page_offset": pageOffset, + }).WithError(err).Error("Database operation failed") + return err + } + return nil + }) + + g.Go(func() error { + var err error + tableColumns, err = plugin.GetColumnsForTable(config, schema, storageUnit) + if err != nil { + log.WithFields(log.Fields{ + "operation": "GetColumnsForTable", + "schema": schema, + "storage_unit": storageUnit, + "error": err.Error(), + }).Warn("Failed to get table columns") + tableColumns = nil + } + return nil + }) + + if err := g.Wait(); err != nil { + return nil, err + } + + columnInfo := make(map[string]engine.Column, len(tableColumns)) + for _, col := range tableColumns { + columnInfo[col.Name] = col + } + + var columns []*model.Column + for _, column := range rowsResult.Columns { + col := columnInfo[column.Name] + columns = append(columns, &model.Column{ + Type: column.Type, + Name: column.Name, + IsPrimary: col.IsPrimary, + IsForeignKey: col.IsForeignKey, + ReferencedTable: col.ReferencedTable, + ReferencedColumn: col.ReferencedColumn, + Length: column.Length, + Precision: column.Precision, + Scale: column.Scale, + }) + } + return &model.RowsResult{ + Columns: columns, + Rows: rowsResult.Rows, + DisableUpdate: rowsResult.DisableUpdate, + TotalCount: int(rowsResult.TotalCount), + }, nil +} + +// Columns is the resolver for the Columns field. +func (r *queryResolver) Columns(ctx context.Context, schema string, storageUnit string) ([]*model.Column, error) { + plugin, config := GetPluginForContext(ctx) + columns, err := FetchColumnsForStorageUnit(plugin, config, schema, storageUnit) + if err != nil { + log.WithFields(log.Fields{ + "operation": "Columns", + "schema": schema, + "storage_unit": storageUnit, + "database_type": config.Credentials.Type, + "error": err.Error(), + }).Error("Failed to fetch columns") + return nil, err + } + return columns, nil +} + +// ColumnsBatch is the resolver for the ColumnsBatch field. +func (r *queryResolver) ColumnsBatch(ctx context.Context, schema string, storageUnits []string) ([]*model.StorageUnitColumns, error) { + plugin, config := GetPluginForContext(ctx) + + results := make([]*model.StorageUnitColumns, len(storageUnits)) + g, _ := errgroup.WithContext(ctx) + g.SetLimit(10) + + for i, storageUnit := range storageUnits { + i, storageUnit := i, storageUnit + g.Go(func() error { + columns, err := FetchColumnsForStorageUnit(plugin, config, schema, storageUnit) + if err != nil { + log.WithFields(log.Fields{ + "operation": "ColumnsBatch", + "schema": schema, + "storage_unit": storageUnit, + "database_type": config.Credentials.Type, + "error": err.Error(), + }).Error("Failed to fetch columns") + // Don't fail the entire batch - just skip this table + results[i] = nil + return nil + } + results[i] = &model.StorageUnitColumns{ + StorageUnit: storageUnit, + Columns: columns, + } + return nil + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + + // Filter out nil results (tables that failed to load) + successfulResults := make([]*model.StorageUnitColumns, 0, len(results)) + for _, result := range results { + if result != nil { + successfulResults = append(successfulResults, result) + } + } + + return successfulResults, nil +} + +// RawExecute is the resolver for the RawExecute field. +func (r *queryResolver) RawExecute(ctx context.Context, query string) (*model.RowsResult, error) { + plugin, config := GetPluginForContext(ctx) + typeArg := config.Credentials.Type + rowsResult, err := plugin.RawExecute(config, query) + if err != nil { + log.WithFields(log.Fields{ + "operation": "RawExecute", + "database_type": typeArg, + "query": query, + }).WithError(err).Error("Database operation failed") + return nil, err + } + var columns []*model.Column + for _, column := range rowsResult.Columns { + columns = append(columns, &model.Column{ + Type: column.Type, + Name: column.Name, + IsPrimary: column.IsPrimary, + IsForeignKey: column.IsForeignKey, + }) + } + return &model.RowsResult{ + Columns: columns, + Rows: rowsResult.Rows, + DisableUpdate: rowsResult.DisableUpdate, + TotalCount: int(rowsResult.TotalCount), + }, nil +} + +// Graph is the resolver for the Graph field. +func (r *queryResolver) Graph(ctx context.Context, schema string) ([]*model.GraphUnit, error) { + plugin, config := GetPluginForContext(ctx) + typeArg := config.Credentials.Type + graphUnits, err := plugin.GetGraph(config, schema) + if err != nil { + log.WithFields(log.Fields{ + "operation": "GetGraph", + "schema": schema, + "database_type": typeArg, + }).WithError(err).Error("Database operation failed") + return nil, err + } + var graphUnitsModel []*model.GraphUnit + for _, graphUnit := range graphUnits { + var relations []*model.GraphUnitRelationship + for _, relation := range graphUnit.Relations { + relations = append(relations, &model.GraphUnitRelationship{ + Name: relation.Name, + Relationship: model.GraphUnitRelationshipType(relation.RelationshipType), + SourceColumn: relation.SourceColumn, + TargetColumn: relation.TargetColumn, + }) + } + graphUnitsModel = append(graphUnitsModel, &model.GraphUnit{ + Unit: engine.GetStorageUnitModel(graphUnit.Unit), + Relations: relations, + }) + } + return graphUnitsModel, nil +} + +// AIProviders is the resolver for the AIProviders field. +func (r *queryResolver) AIProviders(ctx context.Context) ([]*model.AIProvider, error) { + chatProviders := envconfig.GetConfiguredChatProviders() + var aiProviders []*model.AIProvider + for _, provider := range chatProviders { + aiProviders = append(aiProviders, &model.AIProvider{ + Type: provider.Type, + Name: provider.Name, + ProviderID: provider.ProviderId, + IsEnvironmentDefined: true, + IsGeneric: provider.IsGeneric, + }) + } + return aiProviders, nil +} + +// AIModel is the resolver for the AIModel field. +func (r *queryResolver) AIModel(ctx context.Context, providerID *string, modelType string, token *string) ([]string, error) { + config := engine.NewPluginConfig(auth.GetCredentials(ctx)) + + // Initialize ExternalModel to prevent nil pointer dereference + config.ExternalModel = &engine.ExternalModel{ + Type: modelType, + } + + if providerID != nil { + // Try to find provider in environment-defined providers first + chatProviders := envconfig.GetConfiguredChatProviders() + found := false + for _, provider := range chatProviders { + if provider.ProviderId == *providerID { + config.ExternalModel.Token = provider.APIKey + found = true + + // For generic providers, return models from config instead of querying registry + if provider.IsGeneric { + for _, genericProvider := range env.GenericProviders { + if genericProvider.ProviderId == *providerID { + return genericProvider.Models, nil + } + } + } + break + } + } + // If provider not found in environment but token is provided, use the token + // This handles user-added providers that aren't in environment + if !found && token != nil { + config.ExternalModel.Token = *token + } + } else if token != nil { + config.ExternalModel.Token = *token + } + models, err := llm.Instance(config).GetSupportedModels() + if err != nil { + log.WithFields(log.Fields{ + "operation": "GetSupportedModels", + "model_type": modelType, + "provider_id": providerID, + "error": err.Error(), + }).Error("AI operation failed") + return nil, err + } + return models, nil +} + +// AIChat is the resolver for the AIChat field. +func (r *queryResolver) AIChat(ctx context.Context, providerID *string, modelType string, token *string, schema string, input model.ChatInput) ([]*model.AIChatMessage, error) { + plugin, config := GetPluginForContext(ctx) + typeArg := config.Credentials.Type + providerId := "" + if providerID != nil { + providerId = *providerID + } + requestToken := "" + if token != nil { + requestToken = *token + } + creds := envconfig.ResolveProviderCredentials(providerId, requestToken, "", modelType) + config.ExternalModel = &engine.ExternalModel{ + Type: creds.ModelType, + Token: creds.Token, + Model: input.Model, + Endpoint: creds.Endpoint, + } + messages, err := plugin.Chat(config, schema, input.PreviousConversation, input.Query) + + if err != nil { + log.WithFields(log.Fields{ + "operation": "Chat", + "schema": schema, + "database_type": typeArg, + "model": input.Model, + "model_type": modelType, + "provider_id": providerID, + "query": input.Query, + "error": err.Error(), + }).Error("AI chat operation failed") + return nil, err + } + + var chatResponse []*model.AIChatMessage + + for _, message := range messages { + var result *model.RowsResult + if strings.HasPrefix(message.Type, "sql") { + var columns []*model.Column + for _, column := range message.Result.Columns { + columns = append(columns, &model.Column{ + Type: column.Type, + Name: column.Name, + }) + } + result = &model.RowsResult{ + Columns: columns, + Rows: message.Result.Rows, + } + } + chatResponse = append(chatResponse, &model.AIChatMessage{ + Type: message.Type, + Result: result, + Text: message.Text, + RequiresConfirmation: message.RequiresConfirmation, + }) + } + + return chatResponse, nil +} + +// SettingsConfig is the resolver for the SettingsConfig field. +func (r *queryResolver) SettingsConfig(ctx context.Context) (*model.SettingsConfig, error) { + currentSettings := settings.Get() + return &model.SettingsConfig{ + MetricsEnabled: ¤tSettings.MetricsEnabled, + CloudProvidersEnabled: env.IsAWSProviderEnabled, + DisableCredentialForm: env.DisableCredentialForm, + StandaloneLoginEnabled: env.GetStandaloneLoginEnabled(), + MaxPageSize: env.MaxPageSize, + }, nil +} + +// MockDataMaxRowCount is the resolver for the MockDataMaxRowCount field. +func (r *queryResolver) MockDataMaxRowCount(ctx context.Context) (int, error) { + return mockdata.GetMockDataGenerationMaxRowCount(), nil +} + +// AnalyzeMockDataDependencies analyzes FK dependencies for mock data generation. +func (r *queryResolver) AnalyzeMockDataDependencies(ctx context.Context, schema string, storageUnit string, rowCount int, fkDensityRatio *int) (*model.MockDataDependencyAnalysis, error) { + maxRowLimit := mockdata.GetMockDataGenerationMaxRowCount() + if rowCount > maxRowLimit { + errMsg := fmt.Sprintf("row count exceeds maximum limit of %d", maxRowLimit) + return &model.MockDataDependencyAnalysis{ + Error: &errMsg, + }, nil + } + + if !mockdata.IsMockDataGenerationAllowed(storageUnit) { + errMsg := "mock data generation is not allowed for this table" + return &model.MockDataDependencyAnalysis{ + Error: &errMsg, + }, nil + } + + plugin, config := GetPluginForContext(ctx) + if plugin == nil { + return nil, errors.New("no database connection") + } + + fkRatio := 0 + if fkDensityRatio != nil { + fkRatio = *fkDensityRatio + } + generator := src.NewMockDataGenerator(fkRatio) + analysis, err := generator.AnalyzeDependencies(plugin, config, schema, storageUnit, rowCount) + if err != nil { + errMsg := err.Error() + return &model.MockDataDependencyAnalysis{ + Error: &errMsg, + }, nil + } + + tables := make([]*model.MockDataTableInfo, 0, len(analysis.Tables)) + for _, t := range analysis.Tables { + tables = append(tables, &model.MockDataTableInfo{ + Table: t.Table, + RowsToGenerate: t.RowCount, + IsBlocked: t.IsBlocked, + UsesExistingData: t.UsesExistingData, + }) + } + + var errorPtr *string + if analysis.Error != "" { + errorPtr = &analysis.Error + } + + return &model.MockDataDependencyAnalysis{ + GenerationOrder: analysis.GenerationOrder, + Tables: tables, + TotalRows: analysis.TotalRows, + Warnings: analysis.Warnings, + Error: errorPtr, + }, nil +} + +// DatabaseMetadata is the resolver for the DatabaseMetadata field. +func (r *queryResolver) DatabaseMetadata(ctx context.Context) (*model.DatabaseMetadata, error) { + plugin, _ := GetPluginForContext(ctx) + if plugin == nil { + return nil, nil + } + metadata := plugin.GetDatabaseMetadata() + + // Return nil if plugin doesn't implement metadata (default GormPlugin behavior) + if metadata == nil { + return nil, nil + } + + // Convert engine.TypeDefinition to model.TypeDefinition + typeDefinitions := make([]*model.TypeDefinition, 0, len(metadata.TypeDefinitions)) + for _, td := range metadata.TypeDefinitions { + typeDefinitions = append(typeDefinitions, &model.TypeDefinition{ + ID: td.ID, + Label: td.Label, + HasLength: td.HasLength, + HasPrecision: td.HasPrecision, + DefaultLength: td.DefaultLength, + DefaultPrecision: td.DefaultPrecision, + Category: model.TypeCategory(td.Category), + }) + } + + // Convert map[string]string to []*model.Record + aliasMap := make([]*model.Record, 0, len(metadata.AliasMap)) + for key, value := range metadata.AliasMap { + aliasMap = append(aliasMap, &model.Record{ + Key: key, + Value: value, + }) + } + + return &model.DatabaseMetadata{ + DatabaseType: string(metadata.DatabaseType), + TypeDefinitions: typeDefinitions, + Operators: metadata.Operators, + AliasMap: aliasMap, + Capabilities: &model.Capabilities{ + SupportsScratchpad: metadata.Capabilities.SupportsScratchpad, + SupportsChat: metadata.Capabilities.SupportsChat, + SupportsGraph: metadata.Capabilities.SupportsGraph, + SupportsSchema: metadata.Capabilities.SupportsSchema, + SupportsDatabaseSwitch: metadata.Capabilities.SupportsDatabaseSwitch, + SupportsModifiers: metadata.Capabilities.SupportsModifiers, + }, + SystemSchemas: metadata.SystemSchemas, + }, nil +} + +// SSLStatus is the resolver for the SSLStatus field. +func (r *queryResolver) SSLStatus(ctx context.Context) (*model.SSLStatus, error) { + plugin, config := GetPluginForContext(ctx) + if plugin == nil { + log.Debug("[SSL] SSLStatus resolver: no plugin context") + return nil, nil + } + + log.Debugf("[SSL] SSLStatus resolver: querying SSL status for %s", config.Credentials.Type) + status, err := plugin.GetSSLStatus(config) + if err != nil { + log.Warnf("[SSL] SSLStatus resolver: error getting SSL status: %v", err) + return nil, err + } + + // Return nil if SSL status is not applicable (e.g., SQLite) + if status == nil { + log.Debugf("[SSL] SSLStatus resolver: SSL not applicable for %s", config.Credentials.Type) + return nil, nil + } + + log.Infof("[SSL] SSLStatus resolver: %s connection SSL enabled=%t, mode=%s", + config.Credentials.Type, status.IsEnabled, status.Mode) + + return &model.SSLStatus{ + IsEnabled: status.IsEnabled, + Mode: status.Mode, + }, nil +} + +// DatabaseQuerySuggestions is the resolver for the DatabaseQuerySuggestions field. +func (r *queryResolver) DatabaseQuerySuggestions(ctx context.Context, schema string) ([]*model.DatabaseQuerySuggestion, error) { + plugin, config := GetPluginForContext(ctx) + + log.WithFields(log.Fields{ + "operation": "DatabaseQuerySuggestions", + "schema": schema, + }).Info("Fetching database suggestions") + + // Get storage units (tables) from the schema + units, err := plugin.GetStorageUnits(config, schema) + if err != nil { + log.WithFields(log.Fields{ + "operation": "DatabaseQuerySuggestions", + "schema": schema, + }).WithError(err).Error("Failed to get storage units for suggestions") + return nil, err + } + + log.WithFields(log.Fields{ + "operation": "DatabaseQuerySuggestions", + "schema": schema, + "units_count": len(units), + }).Info("Retrieved storage units for suggestions") + + suggestions := []*model.DatabaseQuerySuggestion{} + + // Generate suggestions based on actual tables in the database + // Limit to 3 suggestions + maxSuggestions := 3 + if len(units) > maxSuggestions { + units = units[:maxSuggestions] + } + + for i, unit := range units { + var description string + var category string + + tableName := unit.Name + + // TODO: These hardcoded English strings need localization. There is currently no + // backend localization function available. When one is added, replace these with + // localized equivalents. + // Generate natural, conversational queries that someone would actually ask + switch i % 3 { + case 0: + description = fmt.Sprintf("What are the most recent records in %s?", tableName) + category = "SELECT" + case 1: + description = fmt.Sprintf("How many total entries are in %s?", tableName) + category = "AGGREGATE" + case 2: + description = fmt.Sprintf("Show me all the data in %s", tableName) + category = "SELECT" + } + + suggestions = append(suggestions, &model.DatabaseQuerySuggestion{ + Description: description, + Category: category, + }) + } + + // If no tables found, return empty array + if len(suggestions) == 0 { + log.WithFields(log.Fields{ + "operation": "DatabaseQuerySuggestions", + "schema": schema, + }).Warn("No suggestions generated - no tables found") + return []*model.DatabaseQuerySuggestion{}, nil + } + + log.WithFields(log.Fields{ + "operation": "DatabaseQuerySuggestions", + "schema": schema, + "suggestions_count": len(suggestions), + }).Info("Successfully generated database suggestions") + + return suggestions, nil +} + +// CloudProviders is the resolver for the CloudProviders field. +func (r *queryResolver) CloudProviders(ctx context.Context) ([]*model.AWSProvider, error) { + if !env.IsAWSProviderEnabled { + return []*model.AWSProvider{}, nil + } + + // Router pattern: when adding GCP, append settings.GetGCPProviders() results here. + // See .claude/docs/cloud-providers.md for full example. + states := settings.GetAWSProviders() + result := make([]*model.AWSProvider, 0, len(states)) + for _, state := range states { + result = append(result, stateToAWSProvider(state)) + } + return result, nil +} + +// CloudProvider is the resolver for the CloudProvider field. +func (r *queryResolver) CloudProvider(ctx context.Context, id string) (*model.AWSProvider, error) { + if !env.IsAWSProviderEnabled { + return nil, nil + } + + state, err := settings.GetAWSProvider(id) + if err != nil { + return nil, err + } + return stateToAWSProvider(state), nil +} + +// DiscoveredConnections is the resolver for the DiscoveredConnections field. +func (r *queryResolver) DiscoveredConnections(ctx context.Context) ([]*model.DiscoveredConnection, error) { + if !env.IsAWSProviderEnabled { + return []*model.DiscoveredConnection{}, nil + } + + registry := providers.GetDefaultRegistry() + conns, err := registry.DiscoverAll(ctx) + + result := make([]*model.DiscoveredConnection, 0, len(conns)) + for _, conn := range conns { + result = append(result, discoveredConnectionToModel(&conn)) + } + + // Return partial results with error so UI can show a warning + if err != nil { + log.Warn("Error discovering connections: ", err) + return result, err + } + return result, nil +} + +// ProviderConnections is the resolver for the ProviderConnections field. +func (r *queryResolver) ProviderConnections(ctx context.Context, providerID string) ([]*model.DiscoveredConnection, error) { + if !env.IsAWSProviderEnabled { + return []*model.DiscoveredConnection{}, nil + } + + registry := providers.GetDefaultRegistry() + conns, err := registry.FilterByProvider(ctx, providerID) + if err != nil { + return nil, err + } + + result := make([]*model.DiscoveredConnection, 0, len(conns)) + for _, conn := range conns { + result = append(result, discoveredConnectionToModel(&conn)) + } + return result, nil +} + +// LocalAWSProfiles is the resolver for the LocalAWSProfiles field. +func (r *queryResolver) LocalAWSProfiles(ctx context.Context) ([]*model.LocalAWSProfile, error) { + if !env.IsAWSProviderEnabled { + return []*model.LocalAWSProfile{}, nil + } + + localProfiles, err := aws.DiscoverLocalProfiles() + if err != nil { + return nil, err + } + + result := make([]*model.LocalAWSProfile, len(localProfiles)) + for i, profile := range localProfiles { + var region *string + if profile.Region != "" { + region = &profile.Region + } + result[i] = &model.LocalAWSProfile{ + Name: profile.Name, + Region: region, + Source: profile.Source, + AuthType: profile.AuthType, + IsDefault: profile.IsDefault, + } + } + return result, nil +} + +// AWSRegions is the resolver for the AWSRegions field. +func (r *queryResolver) AWSRegions(ctx context.Context) ([]*model.AWSRegion, error) { + regions := aws.GetRegions() + result := make([]*model.AWSRegion, len(regions)) + for i, region := range regions { + result[i] = &model.AWSRegion{ + ID: region.ID, + Description: region.Description, + Partition: region.Partition, + } + } + return result, nil +} + +// Mutation returns MutationResolver implementation. +func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} } + +// Query returns QueryResolver implementation. +func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } + +type mutationResolver struct{ *Resolver } +type queryResolver struct{ *Resolver } diff --git a/dataflow/core/graph/sealos_bootstrap_test.go b/dataflow/core/graph/sealos_bootstrap_test.go new file mode 100644 index 0000000..0ed3861 --- /dev/null +++ b/dataflow/core/graph/sealos_bootstrap_test.go @@ -0,0 +1,357 @@ +package graph + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/internal/testutil" + "github.com/clidey/whodb/core/src" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/sealos" + "github.com/clidey/whodb/core/src/session" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestBootstrapSealosSessionCreatesServerSession(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Postgres")) + mock.IsAvailableFunc = func(context.Context, *engine.PluginConfig) bool { return true } + setEngineMock(t, mock) + + t.Setenv("WHODB_SEALOS_BOOTSTRAP_ENABLED", "true") + t.Setenv("WHODB_SESSION_ENCRYPTION_KEY", "12345678901234567890123456789012") + t.Setenv("WHODB_SESSION_TTL", "24h") + origFactory := session.DefaultServiceFactory + var service *session.Service + session.DefaultServiceFactory = func() (*session.Service, error) { + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + return nil, err + } + service, err = session.NewService(db, env.GetSessionEncryptionKey(), 24*time.Hour) + return service, err + } + t.Cleanup(func() { + session.DefaultServiceFactory = origFactory + session.ResetDefaultService() + }) + + originalFactory := sealos.DefaultBootstrapResolverFactory + sealos.DefaultBootstrapResolverFactory = func(kubeconfig string) (sealos.BootstrapResolver, error) { + return fakeBootstrapResolver{ + identity: &sealos.ResolvedInstanceIdentity{ + UID: "instance-uid-1", + Namespace: "ns-demo", + ResourceName: "my-db", + }, + result: &sealos.ResolvedBootstrap{ + Namespace: "ns-demo", + ResourceName: "my-db", + DBType: "Postgres", + Host: "my-db.ns-demo.svc", + Port: "5432", + DatabaseName: "postgres", + K8sUsername: "demo-user", + Credentials: &engine.Credentials{ + Type: "Postgres", + Hostname: "my-db.ns-demo.svc", + Username: "postgres", + Password: "secret", + Database: "postgres", + Advanced: []engine.Record{{Key: "Port", Value: "5432"}}, + }, + }, + }, nil + } + t.Cleanup(func() { sealos.DefaultBootstrapResolverFactory = originalFactory }) + + resolver := &Resolver{} + mut := resolver.Mutation() + + payload, err := mut.BootstrapSealosSession(context.Background(), model.SealosBootstrapInput{ + Kubeconfig: "apiVersion: v1", + DbType: "postgresql", + ResourceName: "my-db", + Host: strPtr("my-db.ns-demo.svc"), + Port: strPtr("5432"), + }) + if err != nil { + t.Fatalf("expected bootstrap to succeed, got %v", err) + } + if payload == nil || payload.SessionToken == "" { + t.Fatalf("expected session token payload, got %#v", payload) + } + if payload.Hostname != "my-db.ns-demo.svc" || payload.Port != "5432" { + t.Fatalf("expected connection summary to roundtrip, got %#v", payload) + } + if payload.InstanceUID == nil || *payload.InstanceUID != "instance-uid-1" { + t.Fatalf("expected instance UID in payload, got %#v", payload.InstanceUID) + } + if _, err := time.Parse(time.RFC3339, payload.ExpiresAt); err != nil { + t.Fatalf("expected RFC3339 expiry, got %q (%v)", payload.ExpiresAt, err) + } + _, record, err := service.ResolveToken(context.Background(), payload.SessionToken) + if err != nil { + t.Fatalf("resolve created token: %v", err) + } + if record.InstanceUID != "instance-uid-1" { + t.Fatalf("expected bootstrap identity UID to be stored, got %#v", record) + } +} + +func TestBootstrapSealosSessionResolvesIdentityForEverySupportedSealosEngine(t *testing.T) { + t.Setenv("WHODB_SEALOS_BOOTSTRAP_ENABLED", "true") + + db, err := gorm.Open(sqlite.Open("file:sealos-engine-identity?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open session database: %v", err) + } + service, err := session.NewService(db, "12345678901234567890123456789012", 24*time.Hour) + if err != nil { + t.Fatalf("new session service: %v", err) + } + + originalServiceFactory := session.DefaultServiceFactory + originalResolverFactory := sealos.DefaultBootstrapResolverFactory + originalEngine := src.MainEngine + session.DefaultServiceFactory = func() (*session.Service, error) { return service, nil } + session.ResetDefaultService() + t.Cleanup(func() { + session.DefaultServiceFactory = originalServiceFactory + sealos.DefaultBootstrapResolverFactory = originalResolverFactory + src.MainEngine = originalEngine + session.ResetDefaultService() + }) + + testCases := []struct { + dbType string + engineType engine.DatabaseType + }{ + {dbType: "postgresql", engineType: engine.DatabaseType_Postgres}, + {dbType: "apecloud-mysql", engineType: engine.DatabaseType_MySQL}, + {dbType: "mongodb", engineType: engine.DatabaseType_MongoDB}, + {dbType: "redis", engineType: engine.DatabaseType_Redis}, + {dbType: "clickhouse", engineType: engine.DatabaseType_ClickHouse}, + } + + for _, testCase := range testCases { + t.Run(testCase.dbType, func(t *testing.T) { + _, oldToken, err := service.Create(context.Background(), session.CreateParams{ + Source: "sealos", + Namespace: "ns-demo", + ResourceName: "my-db", + InstanceUID: "replaced-uid", + DBType: string(testCase.engineType), + Host: "my-db.ns-demo.svc", + Port: "1234", + DatabaseName: "default", + Credentials: &engine.Credentials{ + Type: string(testCase.engineType), Hostname: "my-db.ns-demo.svc", Username: "user", Password: "replaced-secret", Database: "default", + }, + }) + if err != nil { + t.Fatalf("create replaced %s session: %v", testCase.dbType, err) + } + + var availabilityCredentials *engine.Credentials + mock := testutil.NewPluginMock(testCase.engineType) + mock.IsAvailableFunc = func(_ context.Context, config *engine.PluginConfig) bool { + availabilityCredentials = config.Credentials + return true + } + src.MainEngine = &engine.Engine{} + src.MainEngine.RegistryPlugin(mock.AsPlugin()) + + identityCalls := 0 + sealos.DefaultBootstrapResolverFactory = func(string) (sealos.BootstrapResolver, error) { + return fakeBootstrapResolver{ + identityCalls: &identityCalls, + identity: &sealos.ResolvedInstanceIdentity{ + UID: "instance-uid-1", + Namespace: "ns-demo", + ResourceName: "my-db", + }, + result: &sealos.ResolvedBootstrap{ + Namespace: "ns-demo", + ResourceName: "my-db", + DBType: string(testCase.engineType), + Host: "my-db.ns-demo.svc", + Port: "1234", + DatabaseName: "default", + Credentials: &engine.Credentials{ + Type: string(testCase.engineType), Hostname: "my-db.ns-demo.svc", Username: "user", Password: "secret", Database: "default", + }, + }, + }, nil + } + + payload, err := (&Resolver{}).Mutation().BootstrapSealosSession(context.Background(), model.SealosBootstrapInput{ + Kubeconfig: "apiVersion: v1", + DbType: testCase.dbType, + ResourceName: "my-db", + }) + if err != nil { + t.Fatalf("bootstrap %s: %v", testCase.dbType, err) + } + if identityCalls != 1 { + t.Fatalf("expected one authoritative identity resolution, got %d", identityCalls) + } + if payload.InstanceUID == nil || *payload.InstanceUID != "instance-uid-1" { + t.Fatalf("expected UID-bound %s session, got %#v", testCase.dbType, payload) + } + if availabilityCredentials == nil || availabilityCredentials.Password != "secret" { + t.Fatalf("expected %s availability check to use replacement credentials, got %#v", testCase.dbType, availabilityCredentials) + } + resolvedCredentials, record, err := service.ResolveToken(context.Background(), payload.SessionToken) + if err != nil { + t.Fatalf("resolve replacement %s session: %v", testCase.dbType, err) + } + if record.InstanceUID != "instance-uid-1" || resolvedCredentials.Password != "secret" { + t.Fatalf("expected replacement %s credentials and UID to persist, got record=%#v credentials=%#v", testCase.dbType, record, resolvedCredentials) + } + if _, _, err := service.ResolveToken(context.Background(), oldToken); !errors.Is(err, session.ErrSessionRevoked) { + t.Fatalf("expected replaced %s token to be revoked, got %v", testCase.dbType, err) + } + }) + } +} + +func TestBootstrapSealosSessionRevokesStaleSessionsBeforeAvailabilityCheck(t *testing.T) { + t.Setenv("WHODB_SEALOS_BOOTSTRAP_ENABLED", "true") + t.Setenv("WHODB_SESSION_ENCRYPTION_KEY", "12345678901234567890123456789012") + t.Setenv("WHODB_SESSION_TTL", "24h") + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open session database: %v", err) + } + service, err := session.NewService(db, env.GetSessionEncryptionKey(), 24*time.Hour) + if err != nil { + t.Fatalf("new session service: %v", err) + } + + originalServiceFactory := session.DefaultServiceFactory + session.DefaultServiceFactory = func() (*session.Service, error) { return service, nil } + session.ResetDefaultService() + t.Cleanup(func() { + session.DefaultServiceFactory = originalServiceFactory + session.ResetDefaultService() + }) + + oldToken := createSealosSessionForGraphTest(t, service, "old-uid") + legacyToken := createSealosSessionForGraphTest(t, service, "") + currentToken := createSealosSessionForGraphTest(t, service, "current-uid") + + checkedBeforeAvailability := false + mock := testutil.NewPluginMock(engine.DatabaseType("Postgres")) + mock.IsAvailableFunc = func(ctx context.Context, _ *engine.PluginConfig) bool { + _, _, oldErr := service.ResolveToken(ctx, oldToken) + _, _, legacyErr := service.ResolveToken(ctx, legacyToken) + _, _, currentErr := service.ResolveToken(ctx, currentToken) + checkedBeforeAvailability = errors.Is(oldErr, session.ErrSessionRevoked) && + errors.Is(legacyErr, session.ErrSessionRevoked) && currentErr == nil + return false + } + setEngineMock(t, mock) + + originalResolverFactory := sealos.DefaultBootstrapResolverFactory + sealos.DefaultBootstrapResolverFactory = func(string) (sealos.BootstrapResolver, error) { + return fakeBootstrapResolver{ + identity: &sealos.ResolvedInstanceIdentity{UID: "current-uid", Namespace: "ns-demo", ResourceName: "my-db"}, + result: &sealos.ResolvedBootstrap{ + Namespace: "ns-demo", + ResourceName: "my-db", + DBType: "Postgres", + Host: "my-db.ns-demo.svc", + Port: "5432", + DatabaseName: "postgres", + Credentials: &engine.Credentials{ + Type: "Postgres", Hostname: "my-db.ns-demo.svc", Username: "postgres", Password: "secret", Database: "postgres", + }, + }, + }, nil + } + t.Cleanup(func() { sealos.DefaultBootstrapResolverFactory = originalResolverFactory }) + + _, err = (&Resolver{}).Mutation().BootstrapSealosSession(context.Background(), model.SealosBootstrapInput{ + Kubeconfig: "apiVersion: v1", + DbType: "postgresql", + ResourceName: "my-db", + }) + if err == nil { + t.Fatal("expected unavailable replacement instance to reject bootstrap") + } + if !checkedBeforeAvailability { + t.Fatal("expected stale sessions to be revoked before database availability check") + } +} + +func TestResolveSealosInstanceIdentityReturnsAuthoritativeUID(t *testing.T) { + t.Setenv("WHODB_SEALOS_BOOTSTRAP_ENABLED", "true") + + originalFactory := sealos.DefaultBootstrapResolverFactory + sealos.DefaultBootstrapResolverFactory = func(string) (sealos.BootstrapResolver, error) { + return fakeBootstrapResolver{ + identity: &sealos.ResolvedInstanceIdentity{UID: "instance-uid-2", Namespace: "ns-demo", ResourceName: "my-db"}, + }, nil + } + t.Cleanup(func() { sealos.DefaultBootstrapResolverFactory = originalFactory }) + + identity, err := (&Resolver{}).Query().ResolveSealosInstanceIdentity(context.Background(), model.SealosInstanceIdentityInput{ + Kubeconfig: "apiVersion: v1", + ResourceName: "my-db", + }) + if err != nil { + t.Fatalf("resolve identity query: %v", err) + } + if identity.UID != "instance-uid-2" || identity.Namespace != "ns-demo" || identity.ResourceName != "my-db" { + t.Fatalf("expected authoritative identity response, got %#v", identity) + } +} + +func createSealosSessionForGraphTest(t *testing.T, service *session.Service, instanceUID string) string { + t.Helper() + + _, token, err := service.Create(context.Background(), session.CreateParams{ + Source: "sealos", + Namespace: "ns-demo", + ResourceName: "my-db", + InstanceUID: instanceUID, + DBType: "Postgres", + Host: "my-db.ns-demo.svc", + Port: "5432", + DatabaseName: "postgres", + Credentials: &engine.Credentials{ + Type: "Postgres", Hostname: "my-db.ns-demo.svc", Username: "postgres", Password: "secret", Database: "postgres", + }, + }) + if err != nil { + t.Fatalf("create Sealos session: %v", err) + } + return token +} + +type fakeBootstrapResolver struct { + identityCalls *int + identity *sealos.ResolvedInstanceIdentity + identityErr error + result *sealos.ResolvedBootstrap + err error +} + +func (f fakeBootstrapResolver) ResolveInstanceIdentity(_ context.Context, _ sealos.InstanceIdentityInput) (*sealos.ResolvedInstanceIdentity, error) { + if f.identityCalls != nil { + (*f.identityCalls)++ + } + return f.identity, f.identityErr +} + +func (f fakeBootstrapResolver) ResolveBootstrap(_ context.Context, _ sealos.BootstrapInput) (*sealos.ResolvedBootstrap, error) { + return f.result, f.err +} + +func strPtr(s string) *string { return &s } diff --git a/dataflow/core/graph/sealos_helpers.go b/dataflow/core/graph/sealos_helpers.go new file mode 100644 index 0000000..2481bc9 --- /dev/null +++ b/dataflow/core/graph/sealos_helpers.go @@ -0,0 +1,8 @@ +package graph + +func valueOrEmpty(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/dataflow/core/graph/sql_data_export_file.go b/dataflow/core/graph/sql_data_export_file.go new file mode 100644 index 0000000..158cdd9 --- /dev/null +++ b/dataflow/core/graph/sql_data_export_file.go @@ -0,0 +1,252 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graph + +import ( + "fmt" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/clidey/whodb/core/src/auth" + "github.com/clidey/whodb/core/src/engine" +) + +const sqlDataExportContentType = "application/sql; charset=utf-8" + +var sqlDataExportFiles = newSQLDataExportFileStore(30 * time.Minute) + +type sqlDataExportFile struct { + ID string + Path string + Filename string + ContentType string + OwnerKey string + Size int64 + CreatedAt time.Time +} + +type sqlDataExportFileStore struct { + mu sync.Mutex + ttl time.Duration + files map[string]*sqlDataExportFile +} + +func newSQLDataExportFileStore(ttl time.Duration) *sqlDataExportFileStore { + return &sqlDataExportFileStore{ + ttl: ttl, + files: make(map[string]*sqlDataExportFile), + } +} + +func (s *sqlDataExportFileStore) put(file *sqlDataExportFile) { + s.mu.Lock() + defer s.mu.Unlock() + s.pruneLocked(time.Now()) + s.files[file.ID] = file +} + +func (s *sqlDataExportFileStore) get(id string) (*sqlDataExportFile, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + file, ok := s.files[id] + if !ok { + return nil, false + } + if time.Since(file.CreatedAt) > s.ttl { + delete(s.files, id) + _ = os.Remove(file.Path) + return nil, false + } + copy := *file + return ©, true +} + +func (s *sqlDataExportFileStore) pruneLocked(now time.Time) { + for id, file := range s.files { + if now.Sub(file.CreatedAt) <= s.ttl { + continue + } + delete(s.files, id) + _ = os.Remove(file.Path) + } +} + +func createSQLDataExportFile(plugin *engine.Plugin, config *engine.PluginConfig, req *engine.SQLDataExportRequest) (*sqlDataExportFile, error) { + if plugin == nil { + return nil, fmt.Errorf("database plugin not found") + } + if config == nil || config.Credentials == nil { + return nil, fmt.Errorf("database credentials not found") + } + + file, err := os.CreateTemp("", "whodb-sql-data-export-*.sql") + if err != nil { + return nil, err + } + path := file.Name() + success := false + defer func() { + if !success { + _ = os.Remove(path) + } + }() + + if err := plugin.ExportSQLData(config, req, file); err != nil { + _ = file.Close() + return nil, err + } + if err := file.Close(); err != nil { + return nil, err + } + + stat, err := os.Stat(path) + if err != nil { + return nil, err + } + + exportFile := &sqlDataExportFile{ + ID: uuid.NewString(), + Path: path, + Filename: sqlDataExportFilename(req), + ContentType: sqlDataExportContentType, + OwnerKey: sqlDataExportOwnerKey(config.Credentials), + Size: stat.Size(), + CreatedAt: time.Now(), + } + sqlDataExportFiles.put(exportFile) + success = true + return exportFile, nil +} + +func handleSQLDataExportDownload(w http.ResponseWriter, r *http.Request) { + credentials := auth.GetCredentials(r.Context()) + if credentials == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + id := chi.URLParam(r, "id") + file, ok := sqlDataExportFiles.get(id) + if !ok { + http.Error(w, "Export not found", http.StatusNotFound) + return + } + if file.OwnerKey != sqlDataExportOwnerKey(credentials) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + content, err := os.Open(file.Path) + if err != nil { + if os.IsNotExist(err) { + http.Error(w, "Export not found", http.StatusNotFound) + return + } + http.Error(w, "Failed to open export", http.StatusInternalServerError) + return + } + defer content.Close() + + w.Header().Set("Content-Type", file.ContentType) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", escapeHeaderFilename(file.Filename))) + w.Header().Set("Content-Length", strconv.FormatInt(file.Size, 10)) + http.ServeContent(w, r, file.Filename, file.CreatedAt, content) +} + +func validateSQLDataExportTable(plugin *engine.Plugin, config *engine.PluginConfig, schema string, storageUnit string) error { + units, err := plugin.GetStorageUnits(config, schema) + if err != nil { + return err + } + for _, unit := range units { + if unit.Name != storageUnit { + continue + } + if storageUnitIsView(unit) { + return fmt.Errorf("SQL Data Export supports SQL Tables, not SQL Views") + } + return nil + } + return fmt.Errorf("storage unit %s not found", storageUnit) +} + +func storageUnitIsView(unit engine.StorageUnit) bool { + for _, attribute := range unit.Attributes { + if !strings.EqualFold(attribute.Key, "Type") { + continue + } + return strings.Contains(strings.ToUpper(attribute.Value), "VIEW") + } + return false +} + +func sqlDataExportFilename(req *engine.SQLDataExportRequest) string { + mode := strings.ToLower(string(req.Mode)) + if mode == "" { + mode = "export" + } + return sanitizeExportFilename(req.StorageUnit) + "_" + mode + ".sql" +} + +func sanitizeExportFilename(value string) string { + var builder strings.Builder + for _, r := range value { + switch { + case r >= 'a' && r <= 'z': + builder.WriteRune(r) + case r >= 'A' && r <= 'Z': + builder.WriteRune(r) + case r >= '0' && r <= '9': + builder.WriteRune(r) + case r == '.', r == '-', r == '_': + builder.WriteRune(r) + default: + builder.WriteRune('_') + } + } + name := strings.Trim(builder.String(), "._-") + if name == "" { + return "export" + } + return name +} + +func escapeHeaderFilename(value string) string { + return strings.ReplaceAll(value, `"`, "'") +} + +func sqlDataExportOwnerKey(credentials *engine.Credentials) string { + id := "" + if credentials.Id != nil { + id = *credentials.Id + } + return strings.Join([]string{ + id, + credentials.Type, + credentials.Hostname, + credentials.Username, + credentials.Database, + }, "\x00") +} diff --git a/dataflow/core/graph/standalone_session_test.go b/dataflow/core/graph/standalone_session_test.go new file mode 100644 index 0000000..87144ef --- /dev/null +++ b/dataflow/core/graph/standalone_session_test.go @@ -0,0 +1,167 @@ +package graph + +import ( + "context" + "testing" + "time" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/internal/testutil" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/session" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestCreateStandaloneSessionCreatesServerSession(t *testing.T) { + var captured *engine.PluginConfig + mock := testutil.NewPluginMock(engine.DatabaseType("Postgres")) + mock.IsAvailableFunc = func(_ context.Context, config *engine.PluginConfig) bool { + captured = config + return true + } + setEngineMock(t, mock) + + t.Setenv("WHODB_SESSION_ENCRYPTION_KEY", "12345678901234567890123456789012") + var service *session.Service + origFactory := session.DefaultServiceFactory + session.DefaultServiceFactory = func() (*session.Service, error) { + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + return nil, err + } + service, err = session.NewService(db, env.GetSessionEncryptionKey(), 24*time.Hour) + return service, err + } + t.Cleanup(func() { + session.DefaultServiceFactory = origFactory + session.ResetDefaultService() + }) + + resolver := &Resolver{} + payload, err := resolver.Mutation().CreateStandaloneSession(context.Background(), model.LoginCredentials{ + Type: "Postgres", + Hostname: "localhost", + Username: "postgres", + Password: "secret", + Database: "postgres", + Advanced: []*model.RecordInput{{Key: "Port", Value: "5432"}}, + }) + if err != nil { + t.Fatalf("expected standalone session to succeed, got %v", err) + } + if payload == nil || payload.SessionToken == "" { + t.Fatalf("expected session token payload, got %#v", payload) + } + if payload.DisplayName != "Postgres @ localhost/postgres" { + t.Fatalf("expected generated display name, got %q", payload.DisplayName) + } + if payload.Type != "Postgres" || payload.Hostname != "localhost" || payload.Port != "5432" || payload.Database != "postgres" { + t.Fatalf("expected connection summary to roundtrip, got %#v", payload) + } + if captured == nil || captured.Credentials == nil || captured.Credentials.Password != "secret" { + t.Fatalf("expected plugin connectivity check to receive credentials, got %#v", captured) + } + + credentials, record, err := service.ResolveToken(context.Background(), payload.SessionToken) + if err != nil { + t.Fatalf("expected returned token to resolve, got %v", err) + } + if record.Source != "standalone" { + t.Fatalf("expected standalone source, got %q", record.Source) + } + if credentials.Password != "secret" || credentials.Database != "postgres" { + t.Fatalf("expected encrypted credentials to roundtrip, got %#v", credentials) + } +} + +func TestCreateStandaloneSessionDisplayNameOmitsEmptyDatabase(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Redis")) + mock.IsAvailableFunc = func(context.Context, *engine.PluginConfig) bool { return true } + setEngineMock(t, mock) + getService := newStandaloneSessionServiceForTest(t) + + resolver := &Resolver{} + payload, err := resolver.Mutation().CreateStandaloneSession(context.Background(), model.LoginCredentials{ + Type: "Redis", + Hostname: "localhost", + Username: "", + Password: "secret", + Database: "", + Advanced: []*model.RecordInput{{Key: "Port", Value: "6379"}}, + }) + if err != nil { + t.Fatalf("expected standalone session to succeed, got %v", err) + } + if payload.DisplayName != "Redis @ localhost" { + t.Fatalf("expected display name without database, got %q", payload.DisplayName) + } + + _, record, err := getService().ResolveToken(context.Background(), payload.SessionToken) + if err != nil { + t.Fatalf("expected returned token to resolve, got %v", err) + } + if record.Source != "standalone" || record.DatabaseName != "" { + t.Fatalf("expected standalone Redis session without database, got %#v", record) + } +} + +func TestCreateStandaloneSessionRejectsFailedConnectivity(t *testing.T) { + mock := testutil.NewPluginMock(engine.DatabaseType("Postgres")) + mock.IsAvailableFunc = func(context.Context, *engine.PluginConfig) bool { return false } + setEngineMock(t, mock) + + origFactory := session.DefaultServiceFactory + serviceRequested := false + session.DefaultServiceFactory = func() (*session.Service, error) { + serviceRequested = true + return nil, nil + } + t.Cleanup(func() { + session.DefaultServiceFactory = origFactory + session.ResetDefaultService() + }) + + resolver := &Resolver{} + payload, err := resolver.Mutation().CreateStandaloneSession(context.Background(), model.LoginCredentials{ + Type: "Postgres", + Hostname: "localhost", + Username: "postgres", + Password: "wrong", + Database: "postgres", + Advanced: []*model.RecordInput{{Key: "Port", Value: "5432"}}, + }) + if err == nil { + t.Fatalf("expected standalone session to fail when connectivity fails") + } + if payload != nil { + t.Fatalf("expected no payload on failed connectivity, got %#v", payload) + } + if serviceRequested { + t.Fatalf("expected failed connectivity to avoid session creation") + } +} + +func newStandaloneSessionServiceForTest(t *testing.T) func() *session.Service { + t.Helper() + + t.Setenv("WHODB_SESSION_ENCRYPTION_KEY", "12345678901234567890123456789012") + var service *session.Service + origFactory := session.DefaultServiceFactory + session.DefaultServiceFactory = func() (*session.Service, error) { + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + return nil, err + } + service, err = session.NewService(db, env.GetSessionEncryptionKey(), 24*time.Hour) + return service, err + } + t.Cleanup(func() { + session.DefaultServiceFactory = origFactory + session.ResetDefaultService() + }) + return func() *session.Service { + return service + } +} diff --git a/dataflow/core/internal/testutil/mock_plugin.go b/dataflow/core/internal/testutil/mock_plugin.go new file mode 100644 index 0000000..af890da --- /dev/null +++ b/dataflow/core/internal/testutil/mock_plugin.go @@ -0,0 +1,300 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package testutil + +import ( + "context" + "fmt" + "io" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/engine" +) + +// PluginMock implements engine.PluginFunctions with configurable function hooks. +// It allows tests to override only the behaviors they care about without +// spinning up real database connections. +type PluginMock struct { + Type engine.DatabaseType + + GetDatabasesFunc func(*engine.PluginConfig) ([]string, error) + IsAvailableFunc func(context.Context, *engine.PluginConfig) bool + GetAllSchemasFunc func(*engine.PluginConfig) ([]string, error) + GetStorageUnitsFunc func(*engine.PluginConfig, string) ([]engine.StorageUnit, error) + StorageUnitExistsFunc func(*engine.PluginConfig, string, string) (bool, error) + AddStorageUnitFunc func(*engine.PluginConfig, string, string, []engine.Record) (bool, error) + AddStorageUnitOptionsFunc func(*engine.PluginConfig, string, string, []engine.Record, engine.CreateStorageUnitOptions) (bool, error) + DropStorageUnitFunc func(*engine.PluginConfig, string, string) (bool, error) + UpdateStorageUnitFunc func(*engine.PluginConfig, string, string, map[string]string, []string) (bool, error) + ReplaceRowFunc func(*engine.PluginConfig, string, string, map[string]string) (bool, error) + AddRowFunc func(*engine.PluginConfig, string, string, []engine.Record) (bool, error) + AddRowReturningIDFunc func(*engine.PluginConfig, string, string, []engine.Record) (int64, error) + BulkAddRowsFunc func(*engine.PluginConfig, string, string, [][]engine.Record) (bool, error) + DeleteRowFunc func(*engine.PluginConfig, string, string, map[string]string) (bool, error) + GetRowsFunc func(*engine.PluginConfig, *engine.GetRowsRequest) (*engine.GetRowsResult, error) + GetRowCountFunc func(*engine.PluginConfig, string, string, *model.WhereCondition) (int64, error) + GetGraphFunc func(*engine.PluginConfig, string) ([]engine.GraphUnit, error) + RawExecuteFunc func(*engine.PluginConfig, string, ...any) (*engine.GetRowsResult, error) + ChatFunc func(*engine.PluginConfig, string, string, string) ([]*engine.ChatMessage, error) + ExportDataFunc func(*engine.PluginConfig, string, string, func([]string) error, []map[string]any) error + ExportSQLDataFunc func(*engine.PluginConfig, *engine.SQLDataExportRequest, io.Writer) error + FormatValueFunc func(any) string + GetColumnsForTableFunc func(*engine.PluginConfig, string, string) ([]engine.Column, error) + MarkGeneratedColumnsFunc func(*engine.PluginConfig, string, string, []engine.Column) error + GetColumnConstraintsFunc func(*engine.PluginConfig, string, string) (map[string]map[string]any, error) + ClearTableDataFunc func(*engine.PluginConfig, string, string) (bool, error) + GetForeignKeysFunc func(*engine.PluginConfig, string, string) (map[string]*engine.ForeignKeyRelationship, error) + WithTransactionFunc func(*engine.PluginConfig, func(tx any) error) error + GetDatabaseMetadataFunc func() *engine.DatabaseMetadata + GetSSLStatusFunc func(*engine.PluginConfig) (*engine.SSLStatus, error) +} + +// NewPluginMock creates a PluginMock with the provided database type. +func NewPluginMock(dbType engine.DatabaseType) *PluginMock { + return &PluginMock{Type: dbType} +} + +// AsPlugin wraps the mock in an engine.Plugin for code paths that expect it. +func (m *PluginMock) AsPlugin() *engine.Plugin { + return &engine.Plugin{ + PluginFunctions: m, + Type: m.Type, + } +} + +func (m *PluginMock) GetDatabases(config *engine.PluginConfig) ([]string, error) { + if m.GetDatabasesFunc != nil { + return m.GetDatabasesFunc(config) + } + return nil, nil +} + +func (m *PluginMock) IsAvailable(ctx context.Context, config *engine.PluginConfig) bool { + if m.IsAvailableFunc != nil { + return m.IsAvailableFunc(ctx, config) + } + return true +} + +func (m *PluginMock) GetAllSchemas(config *engine.PluginConfig) ([]string, error) { + if m.GetAllSchemasFunc != nil { + return m.GetAllSchemasFunc(config) + } + return nil, nil +} + +func (m *PluginMock) GetStorageUnits(config *engine.PluginConfig, schema string) ([]engine.StorageUnit, error) { + if m.GetStorageUnitsFunc != nil { + return m.GetStorageUnitsFunc(config, schema) + } + return nil, nil +} + +func (m *PluginMock) StorageUnitExists(config *engine.PluginConfig, schema string, storageUnit string) (bool, error) { + if m.StorageUnitExistsFunc != nil { + return m.StorageUnitExistsFunc(config, schema, storageUnit) + } + return false, nil +} + +func (m *PluginMock) AddStorageUnit(config *engine.PluginConfig, schema string, storageUnit string, fields []engine.Record) (bool, error) { + if m.AddStorageUnitFunc != nil { + return m.AddStorageUnitFunc(config, schema, storageUnit, fields) + } + return false, nil +} + +func (m *PluginMock) AddStorageUnitWithOptions(config *engine.PluginConfig, schema string, storageUnit string, fields []engine.Record, options engine.CreateStorageUnitOptions) (bool, error) { + if m.AddStorageUnitOptionsFunc != nil { + return m.AddStorageUnitOptionsFunc(config, schema, storageUnit, fields, options) + } + return m.AddStorageUnit(config, schema, storageUnit, fields) +} + +func (m *PluginMock) DropStorageUnit(config *engine.PluginConfig, schema string, storageUnit string) (bool, error) { + if m.DropStorageUnitFunc != nil { + return m.DropStorageUnitFunc(config, schema, storageUnit) + } + return false, nil +} + +func (m *PluginMock) UpdateStorageUnit(config *engine.PluginConfig, schema string, storageUnit string, values map[string]string, updatedColumns []string) (bool, error) { + if m.UpdateStorageUnitFunc != nil { + return m.UpdateStorageUnitFunc(config, schema, storageUnit, values, updatedColumns) + } + return false, nil +} + +// ReplaceRow invokes the configured replacement hook. +func (m *PluginMock) ReplaceRow(config *engine.PluginConfig, schema string, storageUnit string, values map[string]string) (bool, error) { + if m.ReplaceRowFunc != nil { + return m.ReplaceRowFunc(config, schema, storageUnit, values) + } + return false, nil +} + +func (m *PluginMock) AddRow(config *engine.PluginConfig, schema string, storageUnit string, values []engine.Record) (bool, error) { + if m.AddRowFunc != nil { + return m.AddRowFunc(config, schema, storageUnit, values) + } + return false, nil +} + +func (m *PluginMock) AddRowReturningID(config *engine.PluginConfig, schema string, storageUnit string, values []engine.Record) (int64, error) { + if m.AddRowReturningIDFunc != nil { + return m.AddRowReturningIDFunc(config, schema, storageUnit, values) + } + return 0, nil +} + +func (m *PluginMock) BulkAddRows(config *engine.PluginConfig, schema string, storageUnit string, rows [][]engine.Record) (bool, error) { + if m.BulkAddRowsFunc != nil { + return m.BulkAddRowsFunc(config, schema, storageUnit, rows) + } + // Default: call AddRow for each row + for _, row := range rows { + if _, err := m.AddRow(config, schema, storageUnit, row); err != nil { + return false, err + } + } + return true, nil +} + +func (m *PluginMock) DeleteRow(config *engine.PluginConfig, schema string, storageUnit string, values map[string]string) (bool, error) { + if m.DeleteRowFunc != nil { + return m.DeleteRowFunc(config, schema, storageUnit, values) + } + return false, nil +} + +func (m *PluginMock) GetRows(config *engine.PluginConfig, req *engine.GetRowsRequest) (*engine.GetRowsResult, error) { + if m.GetRowsFunc != nil { + return m.GetRowsFunc(config, req) + } + return nil, nil +} + +func (m *PluginMock) GetRowCount(config *engine.PluginConfig, schema string, storageUnit string, where *model.WhereCondition) (int64, error) { + if m.GetRowCountFunc != nil { + return m.GetRowCountFunc(config, schema, storageUnit, where) + } + return 0, nil +} + +func (m *PluginMock) GetGraph(config *engine.PluginConfig, schema string) ([]engine.GraphUnit, error) { + if m.GetGraphFunc != nil { + return m.GetGraphFunc(config, schema) + } + return nil, nil +} + +func (m *PluginMock) RawExecute(config *engine.PluginConfig, query string, params ...any) (*engine.GetRowsResult, error) { + if m.RawExecuteFunc != nil { + return m.RawExecuteFunc(config, query, params...) + } + return nil, nil +} + +func (m *PluginMock) Chat(config *engine.PluginConfig, schema string, previousConversation string, query string) ([]*engine.ChatMessage, error) { + if m.ChatFunc != nil { + return m.ChatFunc(config, schema, previousConversation, query) + } + return nil, nil +} + +func (m *PluginMock) ExportData(config *engine.PluginConfig, schema string, storageUnit string, writer func([]string) error, selectedRows []map[string]any) error { + if m.ExportDataFunc != nil { + return m.ExportDataFunc(config, schema, storageUnit, writer, selectedRows) + } + return nil +} + +// ExportSQLData invokes the configured SQL data export hook. +func (m *PluginMock) ExportSQLData(config *engine.PluginConfig, req *engine.SQLDataExportRequest, writer io.Writer) error { + if m.ExportSQLDataFunc != nil { + return m.ExportSQLDataFunc(config, req, writer) + } + return nil +} + +func (m *PluginMock) FormatValue(val any) string { + if m.FormatValueFunc != nil { + return m.FormatValueFunc(val) + } + return fmt.Sprint(val) +} + +func (m *PluginMock) GetColumnsForTable(config *engine.PluginConfig, schema string, storageUnit string) ([]engine.Column, error) { + if m.GetColumnsForTableFunc != nil { + return m.GetColumnsForTableFunc(config, schema, storageUnit) + } + return nil, nil +} + +func (m *PluginMock) MarkGeneratedColumns(config *engine.PluginConfig, schema string, storageUnit string, columns []engine.Column) error { + if m.MarkGeneratedColumnsFunc != nil { + return m.MarkGeneratedColumnsFunc(config, schema, storageUnit, columns) + } + return nil +} + +func (m *PluginMock) GetColumnConstraints(config *engine.PluginConfig, schema string, storageUnit string) (map[string]map[string]any, error) { + if m.GetColumnConstraintsFunc != nil { + return m.GetColumnConstraintsFunc(config, schema, storageUnit) + } + return nil, nil +} + +func (m *PluginMock) ClearTableData(config *engine.PluginConfig, schema string, storageUnit string) (bool, error) { + if m.ClearTableDataFunc != nil { + return m.ClearTableDataFunc(config, schema, storageUnit) + } + return false, nil +} + +func (m *PluginMock) NullifyFKColumn(_ *engine.PluginConfig, _, _, _ string) error { return nil } + +func (m *PluginMock) GetForeignKeyRelationships(config *engine.PluginConfig, schema string, storageUnit string) (map[string]*engine.ForeignKeyRelationship, error) { + if m.GetForeignKeysFunc != nil { + return m.GetForeignKeysFunc(config, schema, storageUnit) + } + return nil, nil +} + +func (m *PluginMock) WithTransaction(config *engine.PluginConfig, operation func(tx any) error) error { + if m.WithTransactionFunc != nil { + return m.WithTransactionFunc(config, operation) + } + if operation != nil { + return operation(nil) + } + return nil +} + +func (m *PluginMock) GetDatabaseMetadata() *engine.DatabaseMetadata { + if m.GetDatabaseMetadataFunc != nil { + return m.GetDatabaseMetadataFunc() + } + return nil +} + +func (m *PluginMock) GetSSLStatus(config *engine.PluginConfig) (*engine.SSLStatus, error) { + if m.GetSSLStatusFunc != nil { + return m.GetSSLStatusFunc(config) + } + return nil, nil +} diff --git a/dataflow/core/server.go b/dataflow/core/server.go new file mode 100644 index 0000000..7fc88aa --- /dev/null +++ b/dataflow/core/server.go @@ -0,0 +1,139 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package main + +//go:generate go run github.com/99designs/gqlgen generate + +import ( + _ "github.com/clidey/whodb/core/src/bamlconfig" // Must be first - sets BAML_LOG before native library loads + + "context" + "fmt" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/clidey/whodb/core/src" + "github.com/clidey/whodb/core/src/analytics" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + "github.com/clidey/whodb/core/src/router" + "github.com/clidey/whodb/core/src/settings" + "errors" +) + +const defaultPort = "8080" + +func main() { + defer log.CloseLogFile() + log.Alwaysf("Starting WhoDB... (log level: %s, set WHODB_LOG_LEVEL=warn or WHODB_LOG_LEVEL=error for quieter output)", log.GetLevel()) + + settingsCfg := settings.Get() + + if err := analytics.Initialize(analytics.Config{ + APIKey: env.PosthogAPIKey, + Host: env.PosthogHost, + Environment: env.ApplicationEnvironment, + AppVersion: env.ApplicationVersion, + }); err != nil { + // analytics init failure is non-fatal + } else { + defer analytics.Shutdown() + } + analytics.SetEnabled(settingsCfg.MetricsEnabled) + + src.InitializeEngine() + + // Load persisted AWS providers from disk (if any) + if err := settings.LoadProvidersFromFile(); err != nil { + log.Warnf("Failed to load persisted AWS providers: %v", err) + } + + // Initialize AWS providers from environment variables (may add or override persisted) + if err := settings.InitAWSProvidersFromEnv(); err != nil { + log.Warnf("Failed to initialize AWS providers from environment: %v", err) + } + + r := router.InitializeRouter(staticFiles) + + port := os.Getenv("PORT") + if port == "" { + port = defaultPort + } + + srv := &http.Server{ + Addr: fmt.Sprintf(":%s", port), + Handler: r, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 1 * time.Minute, + IdleTimeout: 30 * time.Second, + } + + go func() { + log.Info("Almost there...") + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server error: %s\n", err) + } + }() + + // Brief pause to let ListenAndServe bind the port before printing the welcome banner + time.Sleep(2 * time.Second) + if env.IsEnterpriseEdition { + log.Always("🎉 Welcome to WhoDB Enterprise! 🎉") + } else { + log.Always("🎉 Welcome to WhoDB! 🎉") + } + log.Always("Get started by visiting:") + log.Alwaysf("http://0.0.0.0:%s", port) + log.Always("Explore and enjoy working with your databases!") + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Info("Shutting down server...") + + // Create a deadline for graceful shutdown + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Shutdown HTTP server and close DB connections in parallel + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + if err := srv.Shutdown(ctx); err != nil { + log.Errorf("HTTP server shutdown error: %v", err) + } + }() + + go func() { + defer wg.Done() + plugins.CloseAllConnections(ctx) + }() + + wg.Wait() + + close(quit) + + log.Info("Server exiting") +} diff --git a/dataflow/core/server_test.go b/dataflow/core/server_test.go new file mode 100644 index 0000000..4e90ece --- /dev/null +++ b/dataflow/core/server_test.go @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package main + +import ( + "context" + "embed" + "errors" + "net/http" + "os" + "testing" + "time" + + "github.com/clidey/whodb/core/src" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/router" +) + +//go:embed build/* +var staticFilesTest embed.FS + +func TestMain(m *testing.M) { + log.Info("Starting WhoDB in test mode...") + + src.InitializeEngine() + r := router.InitializeRouter(staticFilesTest) + + srv := &http.Server{ + Addr: ":0", + Handler: r, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 1 * time.Minute, + IdleTimeout: 30 * time.Second, + } + + go func() { + log.Info("Server starting...") + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("listen: %s", err) + } + }() + + log.Info("WhoDB test server running") + + code := m.Run() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Errorf("Graceful shutdown failed: %v", err) + } + + log.Info("Test server shut down. Exiting and writing coverage.") + os.Exit(code) +} diff --git a/dataflow/core/src/analytics/posthog.go b/dataflow/core/src/analytics/posthog.go new file mode 100644 index 0000000..b4f4039 --- /dev/null +++ b/dataflow/core/src/analytics/posthog.go @@ -0,0 +1,397 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package analytics + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/posthog/posthog-go" + + "github.com/clidey/whodb/core/src/log" +) + +type contextKey string + +const ( + contextKeyMetadata contextKey = "analytics.metadata" + + libraryName = "whodb-backend" + anonymousID = "anonymous" + headerKeyID = "X-Whodb-Analytics-Id" +) + +// Config contains runtime configuration for the PostHog client. +type Config struct { + APIKey string + Host string + Environment string + AppVersion string +} + +// Metadata stores request-scoped attributes that are useful for analytics enrichment. +type Metadata struct { + DistinctID string + Domain string + Path string + Method string + UserAgent string + Referer string + RequestID string +} + +var ( + clientMu sync.RWMutex + client posthog.Client + cfg Config + + initOnce sync.Once + enabled atomic.Bool + fallbackID atomic.Value +) + +// Initialize prepares the global PostHog client. It is safe to invoke multiple times. +func Initialize(config Config) error { + var initErr error + initOnce.Do(func() { + cfg = config + fallbackID.Store(hashIdentifier(fmt.Sprintf("fallback:%d", time.Now().UnixNano()))) + enabled.Store(false) + + c, err := posthog.NewWithConfig(config.APIKey, posthog.Config{ + Endpoint: config.Host, + }) + if err != nil { + initErr = err + return + } + storeClient(c) + }) + + if initErr != nil { + enabled.Store(false) + } + + return initErr +} + +// Shutdown flushes events and disposes the PostHog client. +func Shutdown() { + old := storeClient(nil) + if old == nil { + return + } + + if err := old.Close(); err != nil { + log.WithError(err).Warn("Analytics: unable to flush PostHog queue during shutdown") + } + + enabled.Store(false) +} + +// SetEnabled toggles whether analytics events should be emitted. +func SetEnabled(state bool) { + if state && !Configured() { + enabled.Store(false) + return + } + enabled.Store(state) +} + +// Enabled reports whether analytics are currently active. +func Enabled() bool { + return enabled.Load() && Configured() +} + +// Configured reports whether a PostHog client has been configured successfully. +func Configured() bool { + return loadClient() != nil +} + +// WithMetadata stores metadata inside the supplied context. +func WithMetadata(ctx context.Context, metadata Metadata) context.Context { + return context.WithValue(ctx, contextKeyMetadata, metadata) +} + +// MetadataFromContext extracts analytics metadata from the context. +func MetadataFromContext(ctx context.Context) Metadata { + value := ctx.Value(contextKeyMetadata) + if value == nil { + return Metadata{} + } + + metadata, ok := value.(Metadata) + if !ok { + return Metadata{} + } + return metadata +} + +// Capture emits a generic analytics event using the distinct id derived from the context. +func Capture(ctx context.Context, event string, properties map[string]any) { + CaptureWithDistinctID(ctx, distinctIDFromContext(ctx), event, properties) +} + +// CaptureWithDistinctID emits an analytics event using an explicit distinct id. +func CaptureWithDistinctID(ctx context.Context, distinctID string, event string, properties map[string]any) { + distinctID = strings.TrimSpace(distinctID) + if !Enabled() || distinctID == "" { + return + } + + message := posthog.Capture{ + Event: event, + DistinctId: distinctID, + Timestamp: time.Now().UTC(), + Properties: buildProperties(ctx, properties), + } + + enqueue(message) +} + +// CaptureError emits a structured error event. +func CaptureError(ctx context.Context, operation string, err error, properties map[string]any) { + if err == nil { + return + } + + props := make(map[string]any, len(properties)+2) + for k, v := range properties { + props[k] = v + } + props["operation"] = operation + props["error_message"] = err.Error() + + Capture(ctx, "$exception", props) +} + +// IdentifyWithDistinctID associates user traits with a specific distinct id. +func IdentifyWithDistinctID(ctx context.Context, distinctID string, traits map[string]any) { + distinctID = strings.TrimSpace(distinctID) + if !Enabled() || distinctID == "" { + return + } + + message := posthog.Identify{ + DistinctId: distinctID, + Timestamp: time.Now().UTC(), + Properties: buildIdentifyTraits(ctx, traits), + } + + enqueue(message) +} + +// TrackMutation is a helper for recording GraphQL mutations executed by the user. +func TrackMutation(ctx context.Context, name string, props map[string]any) { + Capture(ctx, fmt.Sprintf("graphql.mutation.%s", name), props) +} + +// HashIdentifier hashes a potentially sensitive identifier before emission. +func HashIdentifier(value string) string { + return hashIdentifier(value) +} + +// BuildMetadata constructs request metadata from an HTTP request. +func BuildMetadata(r *http.Request) Metadata { + domain := deriveDomain(r) + + metadata := Metadata{ + DistinctID: strings.TrimSpace(r.Header.Get(headerKeyID)), + Domain: domain, + Path: "", + Method: r.Method, + UserAgent: r.UserAgent(), + Referer: r.Referer(), + RequestID: strings.TrimSpace(r.Header.Get("X-Request-Id")), + } + + if r.URL != nil { + metadata.Path = r.URL.Path + } + + return metadata +} + +func deriveDomain(r *http.Request) string { + if origin := strings.TrimSpace(r.Header.Get("Origin")); origin != "" { + if parsed, err := url.Parse(origin); err == nil && parsed.Hostname() != "" { + return strings.ToLower(parsed.Hostname()) + } + } + + host := strings.TrimSpace(r.Host) + if host == "" && r.URL != nil { + host = strings.TrimSpace(r.URL.Host) + } + if host == "" { + return "" + } + + if parsedHost, _, err := net.SplitHostPort(host); err == nil { + return strings.ToLower(parsedHost) + } + + return strings.ToLower(host) +} + +func buildProperties(ctx context.Context, properties map[string]any) posthog.Properties { + props := posthog.NewProperties() + for key, value := range properties { + props.Set(key, value) + } + + metadata := MetadataFromContext(ctx) + + if metadata.Domain != "" { + if _, exists := props["domain"]; !exists { + props.Set("domain", metadata.Domain) + } + } + if metadata.Path != "" { + if _, exists := props["path"]; !exists { + props.Set("path", metadata.Path) + } + } + if metadata.Method != "" { + if _, exists := props["method"]; !exists { + props.Set("method", metadata.Method) + } + } + if metadata.UserAgent != "" { + if _, exists := props["user_agent"]; !exists { + props.Set("user_agent", metadata.UserAgent) + } + } + if metadata.Referer != "" { + if _, exists := props["referer"]; !exists { + props.Set("referer", metadata.Referer) + } + } + if metadata.RequestID != "" { + if _, exists := props["request_id"]; !exists { + props.Set("request_id", metadata.RequestID) + } + } + + if cfg.Environment != "" { + if _, exists := props["environment"]; !exists { + props.Set("environment", cfg.Environment) + } + } + if cfg.AppVersion != "" { + if _, exists := props["app_version"]; !exists { + props.Set("app_version", cfg.AppVersion) + } + } + + props.Set("$lib", libraryName) + if cfg.AppVersion != "" { + props.Set("$lib_version", cfg.AppVersion) + } + + return props +} + +func buildIdentifyTraits(ctx context.Context, traits map[string]any) posthog.Properties { + properties := posthog.NewProperties() + for key, value := range traits { + properties.Set(key, value) + } + + metadata := MetadataFromContext(ctx) + if metadata.Domain != "" { + if _, exists := properties["last_seen_domain"]; !exists { + properties.Set("last_seen_domain", metadata.Domain) + } + } + if cfg.Environment != "" { + if _, exists := properties["environment"]; !exists { + properties.Set("environment", cfg.Environment) + } + } + if cfg.AppVersion != "" { + if _, exists := properties["app_version"]; !exists { + properties.Set("app_version", cfg.AppVersion) + } + } + + return properties +} + +func enqueue(message posthog.Message) { + if !Enabled() { + return + } + + c := loadClient() + if c == nil { + return + } + + if err := c.Enqueue(message); err != nil && !errors.Is(err, posthog.ErrTooManyRequests) { + log.WithError(err).Warn("Analytics: failed to enqueue message") + } +} + +func loadClient() posthog.Client { + clientMu.RLock() + defer clientMu.RUnlock() + return client +} + +func storeClient(c posthog.Client) posthog.Client { + clientMu.Lock() + old := client + client = c + clientMu.Unlock() + return old +} + +func distinctIDFromContext(ctx context.Context) string { + metadata := MetadataFromContext(ctx) + if metadata.DistinctID != "" { + return metadata.DistinctID + } + + if value, ok := fallbackID.Load().(string); ok && value != "" { + return value + } + + return anonymousID +} + +func hashIdentifier(value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + + hasher := sha256.New() + if _, err := hasher.Write([]byte(value)); err != nil { + return "" + } + return hex.EncodeToString(hasher.Sum(nil)) +} diff --git a/dataflow/core/src/analytics/posthog_test.go b/dataflow/core/src/analytics/posthog_test.go new file mode 100644 index 0000000..4369d2c --- /dev/null +++ b/dataflow/core/src/analytics/posthog_test.go @@ -0,0 +1,206 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package analytics + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + + "github.com/posthog/posthog-go" +) + +type fakePosthogClient struct { + messages []posthog.Message +} + +func (f *fakePosthogClient) Enqueue(message posthog.Message) error { + f.messages = append(f.messages, message) + return nil +} + +func (f *fakePosthogClient) Close() error { return nil } +func (f *fakePosthogClient) CloseWithContext(context.Context) error { return nil } + +func (f *fakePosthogClient) IsFeatureEnabled(posthog.FeatureFlagPayload) (any, error) { + return nil, nil +} + +func (f *fakePosthogClient) GetFeatureFlag(posthog.FeatureFlagPayload) (any, error) { + return nil, nil +} + +func (f *fakePosthogClient) GetFeatureFlagResult(posthog.FeatureFlagPayload) (*posthog.FeatureFlagResult, error) { + return nil, nil +} + +func (f *fakePosthogClient) GetFeatureFlagPayload(posthog.FeatureFlagPayload) (string, error) { + return "", nil +} + +func (f *fakePosthogClient) GetRemoteConfigPayload(string) (string, error) { + return "", nil +} + +func (f *fakePosthogClient) GetAllFlags(posthog.FeatureFlagPayloadNoKey) (map[string]any, error) { + return nil, nil +} + +func (f *fakePosthogClient) ReloadFeatureFlags() error { + return nil +} + +func (f *fakePosthogClient) GetFeatureFlags() ([]posthog.FeatureFlag, error) { + return nil, nil +} + +func (f *fakePosthogClient) GetLastCapturedEvent() *posthog.Capture { + return nil +} + +func resetAnalyticsState() { + storeClient(nil) + enabled.Store(false) + initOnce = sync.Once{} + fallbackID = atomic.Value{} + cfg = Config{} +} + +func TestBuildMetadataPrefersOriginAndStripsPort(t *testing.T) { + t.Cleanup(resetAnalyticsState) + + req := httptest.NewRequest(http.MethodPost, "http://api.example.com:8080/api/query", nil) + req.Header.Set("Origin", "https://frontend.local:3000") + req.Header.Set("User-Agent", "test-agent") + req.Header.Set("Referer", "https://frontend.local/dashboard") + req.Header.Set("X-Request-Id", "req-123") + + metadata := BuildMetadata(req) + + if metadata.Domain != "frontend.local" { + t.Fatalf("expected origin host to be used as domain, got %s", metadata.Domain) + } + if metadata.Path != "/api/query" { + t.Fatalf("expected path to be set, got %s", metadata.Path) + } + if metadata.Method != http.MethodPost { + t.Fatalf("expected method %s, got %s", http.MethodPost, metadata.Method) + } + if metadata.UserAgent != "test-agent" || metadata.Referer != "https://frontend.local/dashboard" { + t.Fatalf("expected user agent and referer to be preserved") + } + if metadata.RequestID != "req-123" { + t.Fatalf("expected request id to be derived from header, got %s", metadata.RequestID) + } +} + +func TestHashIdentifierHandlesEmptyInput(t *testing.T) { + if got := HashIdentifier(" "); got != "" { + t.Fatalf("expected empty string to return empty hash, got %s", got) + } + value := "example" + if HashIdentifier(value) != HashIdentifier(value) { + t.Fatalf("expected hash function to be deterministic") + } +} + +func TestCaptureDoesNothingWhenDisabled(t *testing.T) { + t.Cleanup(resetAnalyticsState) + client := &fakePosthogClient{} + storeClient(client) + enabled.Store(false) + + Capture(context.Background(), "event", map[string]any{"a": 1}) + if len(client.messages) != 0 { + t.Fatalf("expected no messages when analytics disabled") + } +} + +func TestBuildPropertiesMergesMetadataAndConfig(t *testing.T) { + t.Cleanup(resetAnalyticsState) + + cfg = Config{ + Environment: "prod", + AppVersion: "1.2.3", + } + ctx := WithMetadata(context.Background(), Metadata{ + Domain: "frontend.local", + Path: "/api", + Method: http.MethodPost, + UserAgent: "agent", + Referer: "ref", + RequestID: "req-1", + }) + + props := buildProperties(ctx, map[string]any{ + "custom": "value", + "path": "override", + }) + + if props["domain"] != "frontend.local" { + t.Fatalf("expected domain to be added from metadata, got %v", props["domain"]) + } + if props["path"] != "override" { + t.Fatalf("expected explicit path property to be preserved, got %v", props["path"]) + } + if props["environment"] != "prod" || props["app_version"] != "1.2.3" { + t.Fatalf("expected config metadata to be included") + } + if props["$lib"] != libraryName { + t.Fatalf("expected $lib to be set to %s", libraryName) + } + if props["custom"] != "value" { + t.Fatalf("expected custom properties to be retained") + } +} + +func TestCaptureWithDistinctIDRequiresEnabledAndConfigured(t *testing.T) { + t.Cleanup(resetAnalyticsState) + + client := &fakePosthogClient{} + storeClient(client) + + // Disabled: should drop events + enabled.Store(false) + CaptureWithDistinctID(context.Background(), "abc123", "event-disabled", map[string]any{"a": 1}) + if len(client.messages) != 0 { + t.Fatalf("expected no events to be enqueued when disabled") + } + + // Enabled and configured: should enqueue + enabled.Store(true) + CaptureWithDistinctID(context.Background(), "abc123", "event-enabled", map[string]any{"a": 1}) + if len(client.messages) != 1 { + t.Fatalf("expected one event to be enqueued, got %d", len(client.messages)) + } + + var capture posthog.Capture + switch msg := client.messages[0].(type) { + case posthog.Capture: + capture = msg + case *posthog.Capture: + capture = *msg + default: + t.Fatalf("expected capture message, got %T", client.messages[0]) + } + if capture.Event != "event-enabled" || capture.DistinctId != "abc123" { + t.Fatalf("capture payload did not match expectations: %#v", capture) + } +} diff --git a/dataflow/core/src/auth/auth.go b/dataflow/core/src/auth/auth.go new file mode 100644 index 0000000..351c61d --- /dev/null +++ b/dataflow/core/src/auth/auth.go @@ -0,0 +1,289 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package auth + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "sync" + + "github.com/clidey/whodb/core/src" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/session" +) + +type AuthKey string + +const ( + AuthKey_Token AuthKey = "Token" + AuthKey_Credentials AuthKey = "Credentials" +) + +const maxRequestBodySize = 1024 * 1024 // Limit request body size to 1MB + +func GetCredentials(ctx context.Context) *engine.Credentials { + credentials := ctx.Value(AuthKey_Credentials) + if credentials == nil { + return nil + } + return credentials.(*engine.Credentials) +} + +func isPublicRoute(r *http.Request) bool { + if env.IsDevelopment { + body, err := io.ReadAll(r.Body) + if err != nil { + return false + } + + r.Body = io.NopCloser(bytes.NewReader(body)) + if r.Method != http.MethodPost { + return false + } + + var query map[string]any + if err := json.Unmarshal(body, &query); err != nil { + return false + } + + if q, ok := query["query"].(string); ok && strings.Contains(q, "IntrospectionQuery") { + return true + } + } + + return !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/api" +} + +func AuthMiddleware(next http.Handler) http.Handler { + var onceHeader sync.Once + var onceCookie sync.Once + var onceKeyring sync.Once + var onceInline sync.Once + var onceProfile sync.Once + var onceSession sync.Once + const maxAuthHeaderLen = 16 * 1024 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isPublicRoute(r) { + next.ServeHTTP(w, r) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) + body, err := readRequestBody(r) + if err != nil { + if err.Error() == "http: request body too large" { + http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge) + } else { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + return + } + + // this is to ensure that it can be re-read by the GraphQL layer + r.Body = io.NopCloser(bytes.NewBuffer(body)) + if isAllowed(r, body) { + next.ServeHTTP(w, r) + return + } + + var token string + // Prefer Authorization header if present to support desktop/webview environments + if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") { + token = strings.TrimPrefix(authHeader, "Bearer ") + if len(token) > maxAuthHeaderLen { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + onceHeader.Do(func() { log.Info("Auth: using Authorization header") }) + } + // Fallback to cookie when header is not provided + if token == "" { + if dbCookie, err := r.Cookie(string(AuthKey_Token)); err == nil { + token = dbCookie.Value + onceCookie.Do(func() { log.Info("Auth: using cookie-based auth") }) + } else { + log.Debugf("[Auth] Cookie not found: %v", err) + } + } + + if token == "" { + log.Debug("[Auth] No token found (no cookie or header), returning 401") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if strings.HasPrefix(token, "session:") { + rawToken := strings.TrimPrefix(token, "session:") + svc, err := session.GetDefaultService() + if err != nil { + log.Debugf("[Auth] Session service unavailable: %v", err) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + credentials, _, err := svc.ResolveToken(r.Context(), rawToken) + if err != nil { + if errors.Is(err, session.ErrSessionNotFound) || + errors.Is(err, session.ErrSessionExpired) || + errors.Is(err, session.ErrSessionRevoked) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + log.Debugf("[Auth] Failed to resolve session token: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if databaseOverride := strings.TrimSpace(r.Header.Get("X-WhoDB-Database")); databaseOverride != "" { + credentials.Database = databaseOverride + } + + onceSession.Do(func() { log.Info("Auth: credentials resolved via server session") }) + ctx := context.WithValue(r.Context(), AuthKey_Credentials, credentials) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + decodedValue, err := base64.StdEncoding.DecodeString(token) + if err != nil { + log.Debugf("[Auth] Failed to decode base64 token: %v (token len=%d)", err, len(token)) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + credentials := &engine.Credentials{} + err = json.Unmarshal(decodedValue, credentials) + if err != nil { + log.Debugf("[Auth] Failed to unmarshal credentials JSON: %v", err) + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + if env.IsAPIGatewayEnabled && (credentials.AccessToken == nil || (credentials.AccessToken != nil && !isTokenValid(*credentials.AccessToken))) { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + inline := true + isIdOnly := credentials.Id != nil && credentials.Type == "" && credentials.Hostname == "" + + if credentials.Id != nil && isIdOnly { + // Client sent only ID - must match a saved profile or keyring entry + matched := false + profiles := src.GetLoginProfiles() + for i, loginProfile := range profiles { + profileId := src.GetLoginProfileId(i, loginProfile) + if *credentials.Id == profileId { + profile := *src.GetLoginCredentials(loginProfile) + profile.Id = credentials.Id + if credentials.Database != "" { + profile.Database = credentials.Database + } + credentials = &profile + matched = true + inline = false + onceProfile.Do(func() { log.Info("Auth: credentials resolved via saved profile") }) + break + } + } + if !matched { + if stored, err := LoadCredentials(*credentials.Id); err == nil && stored != nil { + if credentials.Database != "" { + stored.Database = credentials.Database + } + stored.Id = credentials.Id + credentials = stored + inline = false + onceKeyring.Do(func() { log.Info("Auth: credentials resolved via OS keyring") }) + } else { + // ID-only request but no stored credentials found + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + } + } else if credentials.Id != nil && !isIdOnly { + // Client sent full credentials with ID - validate or store for future use + // This is the initial login case for desktop apps + onceInline.Do(func() { log.Info("Auth: credentials supplied inline with ID") }) + } + + if inline { + onceInline.Do(func() { log.Info("Auth: credentials supplied inline") }) + } + + ctx := r.Context() + ctx = context.WithValue(ctx, AuthKey_Credentials, credentials) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func readRequestBody(r *http.Request) ([]byte, error) { + buf := &strings.Builder{} + _, err := io.Copy(buf, r.Body) + if err != nil { + return nil, err + } + return []byte(buf.String()), nil +} + +type GraphQLRequest struct { + OperationName string `json:"operationName"` + Variables map[string]any `json:"variables"` +} + +func isAllowed(r *http.Request, body []byte) bool { + if r.Method != http.MethodPost { + return false + } + + query := GraphQLRequest{} + if err := json.Unmarshal(body, &query); err != nil { + return false + } + + if query.OperationName == "GetDatabase" { + return query.Variables["type"] == engine.DatabaseType_Sqlite3 + } + + switch query.OperationName { + case "Login", "LoginWithProfile", "BootstrapSealosSession", "ResolveSealosInstanceIdentity", "CreateStandaloneSession", "GetProfiles", "UpdateSettings", "SettingsConfig", "GetVersion", + "GetAWSProviders", "GetDiscoveredConnections", "GetProviderConnections", + "GetLocalAWSProfiles", "GetAWSRegions", + "AddAWSProvider", "TestAWSCredentials", "TestCloudProvider", + "RefreshCloudProvider", "RemoveCloudProvider", "UpdateAWSProvider", + "GenerateRDSAuthToken": + return true + } + return false +} + +func isTokenValid(token string) bool { + for _, t := range env.Tokens { + if t == token { + return true + } + } + return false +} diff --git a/dataflow/core/src/auth/auth_middleware_test.go b/dataflow/core/src/auth/auth_middleware_test.go new file mode 100644 index 0000000..0222e7b --- /dev/null +++ b/dataflow/core/src/auth/auth_middleware_test.go @@ -0,0 +1,298 @@ +package auth + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/clidey/whodb/core/src" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/types" + "github.com/zalando/go-keyring" +) + +func TestAuthMiddlewarePrefersAuthorizationHeader(t *testing.T) { + creds := engine.Credentials{ + Type: "Postgres", + Hostname: "db.local", + Username: "alice", + Password: "pw", + Database: "app", + } + payload, _ := json.Marshal(&creds) + token := base64.StdEncoding.EncodeToString(payload) + + body := `{"operationName":"Other"}` + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + + var got *engine.Credentials + handler := AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = GetCredentials(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected request to pass, got status %d", rr.Code) + } + if got == nil || got.Username != "alice" || got.Database != "app" { + t.Fatalf("expected credentials from Authorization header, got %+v", got) + } +} + +func TestAuthMiddlewareRejectsOversizeHeader(t *testing.T) { + huge := bytes.Repeat([]byte("a"), 20000) + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBufferString(`{"operationName":"Other"}`)) + req.Header.Set("Authorization", "Bearer "+base64.StdEncoding.EncodeToString(huge)) + rr := httptest.NewRecorder() + + handler := AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for oversized auth header, got %d", rr.Code) + } +} + +func TestAuthMiddlewareAllowsPublicRouteInDev(t *testing.T) { + origDev := env.IsDevelopment + env.IsDevelopment = true + t.Cleanup(func() { env.IsDevelopment = origDev }) + + req := httptest.NewRequest(http.MethodPost, "/graphql", bytes.NewBufferString(`{"query":"IntrospectionQuery"}`)) + rr := httptest.NewRecorder() + + handler := AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected public route to bypass auth, got %d", rr.Code) + } +} + +func TestAuthMiddlewareFallsBackToCookieWhenHeaderMissing(t *testing.T) { + creds := engine.Credentials{ + Type: "Postgres", + Hostname: "db.local", + Username: "alice", + Password: "pw", + Database: "app", + } + payload, _ := json.Marshal(&creds) + token := base64.StdEncoding.EncodeToString(payload) + + body := `{"operationName":"Other"}` + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBufferString(body)) + req.AddCookie(&http.Cookie{Name: string(AuthKey_Token), Value: token}) + rr := httptest.NewRecorder() + + var got *engine.Credentials + handler := AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = GetCredentials(r.Context()) + w.WriteHeader(http.StatusOK) + })) + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected request to pass, got status %d", rr.Code) + } + if got == nil || got.Username != "alice" { + t.Fatalf("expected credentials from cookie, got %+v", got) + } +} + +func TestAuthMiddlewareRejectsInvalidTokenEncodings(t *testing.T) { + t.Run("bad base64", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBufferString(`{"operationName":"Other"}`)) + req.Header.Set("Authorization", "Bearer not-base64") + rr := httptest.NewRecorder() + + AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rr.Code) + } + }) + + t.Run("bad json", func(t *testing.T) { + token := base64.StdEncoding.EncodeToString([]byte("{not-json")) + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBufferString(`{"operationName":"Other"}`)) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + + AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } + }) +} + +func TestAuthMiddlewareEnforcesAPIGatewayTokenValidation(t *testing.T) { + origGateway := env.IsAPIGatewayEnabled + origTokens := env.Tokens + env.IsAPIGatewayEnabled = true + env.Tokens = []string{"good"} + t.Cleanup(func() { + env.IsAPIGatewayEnabled = origGateway + env.Tokens = origTokens + }) + + t.Run("missing access token", func(t *testing.T) { + creds := engine.Credentials{Type: "Postgres", Hostname: "h", Username: "u", Password: "p", Database: "d"} + payload, _ := json.Marshal(&creds) + token := base64.StdEncoding.EncodeToString(payload) + + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBufferString(`{"operationName":"Other"}`)) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + + AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } + }) + + t.Run("invalid access token", func(t *testing.T) { + bad := "bad" + creds := engine.Credentials{Type: "Postgres", Hostname: "h", Username: "u", Password: "p", Database: "d", AccessToken: &bad} + payload, _ := json.Marshal(&creds) + token := base64.StdEncoding.EncodeToString(payload) + + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBufferString(`{"operationName":"Other"}`)) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + + AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rr.Code) + } + }) + + t.Run("valid access token", func(t *testing.T) { + good := "good" + creds := engine.Credentials{Type: "Postgres", Hostname: "h", Username: "u", Password: "p", Database: "d", AccessToken: &good} + payload, _ := json.Marshal(&creds) + token := base64.StdEncoding.EncodeToString(payload) + + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBufferString(`{"operationName":"Other"}`)) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + + var got *engine.Credentials + AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = GetCredentials(r.Context()) + w.WriteHeader(http.StatusOK) + })).ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + if got == nil || got.AccessToken == nil || *got.AccessToken != "good" { + t.Fatalf("expected credentials to be set on context, got %+v", got) + } + }) +} + +func TestAuthMiddlewareResolvesIDOnlyCredentialsFromProfiles(t *testing.T) { + origEngine := src.MainEngine + src.MainEngine = &engine.Engine{} + t.Cleanup(func() { src.MainEngine = origEngine }) + + src.MainEngine.AddLoginProfile(types.DatabaseCredentials{ + CustomId: "profile-1", + Type: "Postgres", + Hostname: "db.local", + Username: "alice", + Password: "pw", + Database: "app", + Port: "5432", + Advanced: map[string]string{}, + }) + + id := "profile-1" + creds := engine.Credentials{ + Id: &id, + Database: "override", + } + payload, _ := json.Marshal(&creds) + token := base64.StdEncoding.EncodeToString(payload) + + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBufferString(`{"operationName":"Other"}`)) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + + var got *engine.Credentials + AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = GetCredentials(r.Context()) + w.WriteHeader(http.StatusOK) + })).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + if got == nil || got.Type != "Postgres" || got.Username != "alice" || got.Database != "override" { + t.Fatalf("expected resolved credentials with overridden database, got %+v", got) + } +} + +func TestAuthMiddlewareResolvesIDOnlyCredentialsFromKeyring(t *testing.T) { + keyring.MockInit() + t.Setenv("WHODB_DESKTOP", "true") + + origEngine := src.MainEngine + src.MainEngine = &engine.Engine{} + t.Cleanup(func() { src.MainEngine = origEngine }) + + id := "keyring-1" + stored := &engine.Credentials{ + Type: "Postgres", + Hostname: "db.local", + Username: "alice", + Password: "pw", + Database: "app", + } + if err := SaveCredentials(id, stored); err != nil { + t.Fatalf("failed to seed keyring: %v", err) + } + + requested := engine.Credentials{ + Id: &id, + Database: "override", + } + payload, _ := json.Marshal(&requested) + token := base64.StdEncoding.EncodeToString(payload) + + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBufferString(`{"operationName":"Other"}`)) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + + var got *engine.Credentials + AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = GetCredentials(r.Context()) + w.WriteHeader(http.StatusOK) + })).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + if got == nil || got.Type != "Postgres" || got.Username != "alice" || got.Database != "override" { + t.Fatalf("expected resolved credentials with overridden database, got %+v", got) + } +} + +func TestAuthMiddlewareRejectsOversizeBody(t *testing.T) { + body := bytes.Repeat([]byte("a"), maxRequestBodySize+1) + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewReader(body)) + rr := httptest.NewRecorder() + + AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).ServeHTTP(rr, req) + if rr.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413, got %d", rr.Code) + } +} diff --git a/dataflow/core/src/auth/auth_test.go b/dataflow/core/src/auth/auth_test.go new file mode 100644 index 0000000..29fc7e9 --- /dev/null +++ b/dataflow/core/src/auth/auth_test.go @@ -0,0 +1,399 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package auth + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/session" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestIsPublicRouteAllowsIntrospectionInDev(t *testing.T) { + originalDev := env.IsDevelopment + env.IsDevelopment = true + t.Cleanup(func() { + env.IsDevelopment = originalDev + }) + + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBufferString(`{"query":"IntrospectionQuery"}`)) + if !isPublicRoute(req) { + t.Fatalf("expected introspection query to be treated as public route in development") + } +} + +func TestIsPublicRouteBlocksWhenNotDev(t *testing.T) { + originalDev := env.IsDevelopment + env.IsDevelopment = false + t.Cleanup(func() { + env.IsDevelopment = originalDev + }) + + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBufferString(`{"query":"IntrospectionQuery"}`)) + if isPublicRoute(req) { + t.Fatalf("expected introspection query to require auth outside development") + } +} + +func TestAuthMiddlewareExtractsCredentialsFromBearer(t *testing.T) { + originalDev := env.IsDevelopment + originalGateway := env.IsAPIGatewayEnabled + env.IsDevelopment = false + env.IsAPIGatewayEnabled = false + t.Cleanup(func() { + env.IsDevelopment = originalDev + env.IsAPIGatewayEnabled = originalGateway + }) + + creds := engine.Credentials{ + Type: "Postgres", + Hostname: "db.local", + Username: "alice", + Password: "pw", + Database: "app", + } + payload, err := json.Marshal(&creds) + if err != nil { + t.Fatalf("failed to marshal credentials: %v", err) + } + token := base64.StdEncoding.EncodeToString(payload) + + req := httptest.NewRequest(http.MethodPost, "/api/query", strings.NewReader(`{"operationName":"Other"}`)) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + + var captured *engine.Credentials + handler := AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = GetCredentials(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected request to pass through middleware, got status %d", rr.Code) + } + + if captured == nil || captured.Username != "alice" || captured.Database != "app" { + t.Fatalf("expected credentials to be populated from bearer token, got %+v", captured) + } +} + +func TestAuthMiddlewareRejectsMissingToken(t *testing.T) { + originalDev := env.IsDevelopment + env.IsDevelopment = false + t.Cleanup(func() { + env.IsDevelopment = originalDev + }) + + req := httptest.NewRequest(http.MethodPost, "/api/query", strings.NewReader(`{"operationName":"Other"}`)) + rr := httptest.NewRecorder() + + handler := AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected unauthorized status for missing token, got %d", rr.Code) + } +} + +func TestAuthMiddlewarePreservesAdvancedOptions(t *testing.T) { + originalDev := env.IsDevelopment + originalGateway := env.IsAPIGatewayEnabled + env.IsDevelopment = false + env.IsAPIGatewayEnabled = false + t.Cleanup(func() { + env.IsDevelopment = originalDev + env.IsAPIGatewayEnabled = originalGateway + }) + + creds := engine.Credentials{ + Type: "Postgres", + Hostname: "db.local", + Username: "alice", + Password: "pw", + Database: "app", + Advanced: []engine.Record{ + {Key: "SSL Mode", Value: "verify-ca"}, + {Key: "SSL CA Certificate Path", Value: "/path/to/ca.crt"}, + }, + } + payload, err := json.Marshal(&creds) + if err != nil { + t.Fatalf("failed to marshal credentials: %v", err) + } + token := base64.StdEncoding.EncodeToString(payload) + + req := httptest.NewRequest(http.MethodPost, "/api/query", strings.NewReader(`{"operationName":"Other"}`)) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + + var captured *engine.Credentials + handler := AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = GetCredentials(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected request to pass through middleware, got status %d", rr.Code) + } + + if captured == nil { + t.Fatal("expected credentials to be populated") + } + + if len(captured.Advanced) != 2 { + t.Fatalf("expected 2 advanced options, got %d", len(captured.Advanced)) + } + + if captured.Advanced[0].Key != "SSL Mode" || captured.Advanced[0].Value != "verify-ca" { + t.Fatalf("expected SSL Mode=verify-ca, got %s=%s", captured.Advanced[0].Key, captured.Advanced[0].Value) + } + + if captured.Advanced[1].Key != "SSL CA Certificate Path" { + t.Fatalf("expected SSL CA Certificate Path, got %s", captured.Advanced[1].Key) + } +} + +func TestAuthMiddlewareDecodesLoginCredentialsFormat(t *testing.T) { + // This test verifies that credentials marshaled from model.LoginCredentials format + // (with capitalized JSON field names) are correctly unmarshaled into engine.Credentials + originalDev := env.IsDevelopment + originalGateway := env.IsAPIGatewayEnabled + env.IsDevelopment = false + env.IsAPIGatewayEnabled = false + t.Cleanup(func() { + env.IsDevelopment = originalDev + env.IsAPIGatewayEnabled = originalGateway + }) + + // This JSON matches the format produced by model.LoginCredentials (GraphQL input) + loginPayload := `{ + "Type": "Postgres", + "Hostname": "db.local", + "Username": "alice", + "Password": "pw", + "Database": "app", + "Advanced": [ + {"Key": "SSL Mode", "Value": "verify-ca"}, + {"Key": "SSL CA Certificate Path", "Value": "/path/to/ca.crt"} + ] + }` + token := base64.StdEncoding.EncodeToString([]byte(loginPayload)) + + req := httptest.NewRequest(http.MethodPost, "/api/query", strings.NewReader(`{"operationName":"Other"}`)) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + + var captured *engine.Credentials + handler := AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = GetCredentials(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected request to pass through middleware, got status %d", rr.Code) + } + + if captured == nil { + t.Fatal("expected credentials to be populated") + } + + if len(captured.Advanced) != 2 { + t.Fatalf("expected 2 advanced options, got %d: %+v", len(captured.Advanced), captured.Advanced) + } + + if captured.Advanced[0].Key != "SSL Mode" || captured.Advanced[0].Value != "verify-ca" { + t.Fatalf("expected SSL Mode=verify-ca, got %s=%s", captured.Advanced[0].Key, captured.Advanced[0].Value) + } +} + +func TestIsAllowedPermitsWhitelistedOperations(t *testing.T) { + body := `{"operationName":"Login","variables":{}}` + req := httptest.NewRequest(http.MethodPost, "/api/query", strings.NewReader(body)) + if !isAllowed(req, []byte(body)) { + t.Fatalf("expected Login operation to be allowed without auth") + } + + getDb := `{"operationName":"GetDatabase","variables":{"type":"Sqlite3"}}` + req = httptest.NewRequest(http.MethodPost, "/api/query", strings.NewReader(getDb)) + if !isAllowed(req, []byte(getDb)) { + t.Fatalf("expected GetDatabase for Sqlite3 to be allowed") + } + + denied := `{"operationName":"GetDatabase","variables":{"type":"Postgres"}}` + req = httptest.NewRequest(http.MethodPost, "/api/query", strings.NewReader(denied)) + if isAllowed(req, []byte(denied)) { + t.Fatalf("expected GetDatabase for non-sqlite to be rejected") + } + + bootstrap := `{"operationName":"BootstrapSealosSession","variables":{}}` + req = httptest.NewRequest(http.MethodPost, "/api/query", strings.NewReader(bootstrap)) + if !isAllowed(req, []byte(bootstrap)) { + t.Fatalf("expected BootstrapSealosSession to be allowed without auth") + } + + identity := `{"operationName":"ResolveSealosInstanceIdentity","variables":{}}` + req = httptest.NewRequest(http.MethodPost, "/api/query", strings.NewReader(identity)) + if !isAllowed(req, []byte(identity)) { + t.Fatalf("expected ResolveSealosInstanceIdentity to be allowed without auth") + } + + standalone := `{"operationName":"CreateStandaloneSession","variables":{}}` + req = httptest.NewRequest(http.MethodPost, "/api/query", strings.NewReader(standalone)) + if !isAllowed(req, []byte(standalone)) { + t.Fatalf("expected CreateStandaloneSession to be allowed without auth") + } +} + +func TestAuthMiddlewareRejectsSessionRevokedForReplacedInstance(t *testing.T) { + service := newAuthSessionServiceForTest(t) + _, token, err := service.Create(context.Background(), session.CreateParams{ + Source: "sealos", + Namespace: "ns-demo", + ResourceName: "my-db", + InstanceUID: "old-uid", + DBType: "Postgres", + Host: "db.ns.svc", + Port: "5432", + DatabaseName: "postgres", + Credentials: &engine.Credentials{ + Type: "Postgres", Hostname: "db.ns.svc", Username: "alice", Password: "pw", Database: "postgres", + }, + }) + if err != nil { + t.Fatalf("create old instance session: %v", err) + } + if err := service.RevokeStaleSealosSessions(context.Background(), "ns-demo", "my-db", "current-uid"); err != nil { + t.Fatalf("revoke replaced instance session: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/query", strings.NewReader(`{"operationName":"Other"}`)) + req.Header.Set("Authorization", "Bearer session:"+token) + rr := httptest.NewRecorder() + handlerCalled := false + + AuthMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + handlerCalled = true + })).ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected revoked replacement token to return 401, got %d", rr.Code) + } + if handlerCalled { + t.Fatal("expected revoked replacement token not to reach the GraphQL handler") + } +} + +func TestAuthMiddlewareExtractsCredentialsFromSessionToken(t *testing.T) { + service := newAuthSessionServiceForTest(t) + record, token, err := service.Create(context.Background(), session.CreateParams{ + Source: "sealos", + Namespace: "ns-demo", + ResourceName: "my-db", + DBType: "Postgres", + Host: "db.ns.svc", + Port: "5432", + DatabaseName: "postgres", + Credentials: &engine.Credentials{ + Type: "Postgres", + Hostname: "db.ns.svc", + Username: "alice", + Password: "pw", + Database: "postgres", + Advanced: []engine.Record{{Key: "Port", Value: "5432"}}, + }, + }) + if err != nil { + t.Fatalf("create session: %v", err) + } + if record == nil { + t.Fatalf("expected session metadata to be returned") + } + + req := httptest.NewRequest(http.MethodPost, "/api/query", strings.NewReader(`{"operationName":"Other"}`)) + req.Header.Set("Authorization", "Bearer session:"+token) + req.Header.Set("X-WhoDB-Database", "analytics") + rr := httptest.NewRecorder() + + var captured *engine.Credentials + handler := AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = GetCredentials(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected request to pass through middleware, got status %d", rr.Code) + } + if captured == nil { + t.Fatal("expected credentials to be set") + } + if captured.Username != "alice" || captured.Database != "analytics" { + t.Fatalf("expected session credentials with database override, got %+v", captured) + } +} + +func newAuthSessionServiceForTest(t *testing.T) *session.Service { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + + service, err := session.NewService(db, "12345678901234567890123456789012", 24*time.Hour) + if err != nil { + t.Fatalf("new session service: %v", err) + } + + t.Setenv("WHODB_SESSION_ENCRYPTION_KEY", "12345678901234567890123456789012") + t.Setenv("WHODB_SESSION_TTL", "24h") + t.Setenv("WHODB_METADATA_DSN", "") + t.Setenv("WHODB_SESSION_DSN", "") + t.Setenv("WHODB_SEALOS_BOOTSTRAP_ENABLED", "true") + + originalFactory := session.DefaultServiceFactory + session.DefaultServiceFactory = func() (*session.Service, error) { return service, nil } + t.Cleanup(func() { + session.DefaultServiceFactory = originalFactory + session.ResetDefaultService() + os.Unsetenv("WHODB_SESSION_ENCRYPTION_KEY") + }) + + return service +} diff --git a/dataflow/core/src/auth/keyring_warn.go b/dataflow/core/src/auth/keyring_warn.go new file mode 100644 index 0000000..90b1d3f --- /dev/null +++ b/dataflow/core/src/auth/keyring_warn.go @@ -0,0 +1,31 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package auth + +import ( + "sync" + + "github.com/clidey/whodb/core/src/log" +) + +var keyringWarnOnce sync.Once + +func warnKeyringUnavailableOnce(err error) { + keyringWarnOnce.Do(func() { + log.Warn("Keyring not available or failed; continuing without secure storage. Error: ", err) + }) +} diff --git a/dataflow/core/src/auth/login.go b/dataflow/core/src/auth/login.go new file mode 100644 index 0000000..ab8ccfe --- /dev/null +++ b/dataflow/core/src/auth/login.go @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package auth + +import ( + "context" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" +) + +func Login(ctx context.Context, input *model.LoginCredentials) (*model.StatusResponse, error) { + log.Debugf("[Login] type=%s, hostname=%s, username=%s, database=%s, advanced=%d", + input.Type, input.Hostname, input.Username, input.Database, len(input.Advanced)) + + // Note: We no longer set cookies for authentication. + // Credentials are sent via Authorization header on each request. This avoids the ~4KB cookie size + // limit which can be exceeded when SSL certificates are included. + log.Debugf("[Login] Login successful for %s at %s", input.Type, input.Hostname) + + // Persist credentials in OS keychain when an Id is provided (for future use) + if input.ID != nil && *input.ID != "" { + advanced := make([]engine.Record, 0, len(input.Advanced)) + for _, rec := range input.Advanced { + advanced = append(advanced, engine.Record{Key: rec.Key, Value: rec.Value}) + } + if err := SaveCredentials(*input.ID, &engine.Credentials{ + Id: input.ID, + Type: input.Type, + Hostname: input.Hostname, + Username: input.Username, + Password: input.Password, + Database: input.Database, + Advanced: advanced, + IsProfile: false, + }); err != nil { + warnKeyringUnavailableOnce(err) + } + } + + return &model.StatusResponse{ + Status: true, + }, nil +} diff --git a/dataflow/core/src/auth/logout.go b/dataflow/core/src/auth/logout.go new file mode 100644 index 0000000..71dad5d --- /dev/null +++ b/dataflow/core/src/auth/logout.go @@ -0,0 +1,45 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package auth + +import ( + "context" + "net/http" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/plugins" +) + +func Logout(ctx context.Context) (*model.StatusResponse, error) { + if creds := GetCredentials(ctx); creds != nil { + // Close any cached database connection for this profile + plugins.RemoveConnection(&engine.PluginConfig{Credentials: creds}) + + // Best-effort: remove stored credentials from keyring + if creds.Id != nil { + if err := DeleteCredentials(*creds.Id); err != nil { + warnKeyringUnavailableOnce(err) + } + } + } + http.SetCookie(ctx.Value(common.RouterKey_ResponseWriter).(http.ResponseWriter), nil) + return &model.StatusResponse{ + Status: true, + }, nil +} diff --git a/dataflow/core/src/auth/secure_store.go b/dataflow/core/src/auth/secure_store.go new file mode 100644 index 0000000..912af91 --- /dev/null +++ b/dataflow/core/src/auth/secure_store.go @@ -0,0 +1,87 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package auth + +import ( + "encoding/json" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/env" + "github.com/zalando/go-keyring" +) + +var keyringService = getKeyringService() + +func getKeyringService() string { + base := "WhoDB" + if env.IsEnterpriseEdition { + base = "WhoDB-EE" + } + if env.IsDevelopment { + return base + "-Dev" + } + return base +} + +// GetKeyringServiceName exposes the resolved keyring service label for logging. +func GetKeyringServiceName() string { return keyringService } + +func keyForProfile(id string) string { + return "profile:" + id +} + +func SaveCredentials(id string, creds *engine.Credentials) error { + if !env.GetIsDesktopMode() { + return nil + } + if id == "" || creds == nil { + return nil + } + data, err := json.Marshal(creds) + if err != nil { + return err + } + return keyring.Set(keyringService, keyForProfile(id), string(data)) +} + +func LoadCredentials(id string) (*engine.Credentials, error) { + if !env.GetIsDesktopMode() { + return nil, keyring.ErrNotFound + } + if id == "" { + return nil, keyring.ErrNotFound + } + val, err := keyring.Get(keyringService, keyForProfile(id)) + if err != nil { + return nil, err + } + var creds engine.Credentials + if err := json.Unmarshal([]byte(val), &creds); err != nil { + return nil, err + } + return &creds, nil +} + +func DeleteCredentials(id string) error { + if !env.GetIsDesktopMode() { + return nil + } + if id == "" { + return nil + } + return keyring.Delete(keyringService, keyForProfile(id)) +} diff --git a/dataflow/core/src/auth/secure_store_test.go b/dataflow/core/src/auth/secure_store_test.go new file mode 100644 index 0000000..b79e894 --- /dev/null +++ b/dataflow/core/src/auth/secure_store_test.go @@ -0,0 +1,50 @@ +package auth + +import ( + "testing" + + "github.com/clidey/whodb/core/src/engine" + "github.com/zalando/go-keyring" +) + +func TestSaveLoadDeleteCredentialsUsesKeyringWhenDesktopMode(t *testing.T) { + keyring.MockInit() + t.Setenv("WHODB_DESKTOP", "true") + + creds := &engine.Credentials{ + Id: strPtr("profile1"), + Type: "Postgres", + Hostname: "db.local", + Username: "alice", + Password: "pw", + Database: "app", + } + + if err := SaveCredentials(*creds.Id, creds); err != nil { + t.Fatalf("expected save to succeed with mocked keyring, got %v", err) + } + + loaded, err := LoadCredentials(*creds.Id) + if err != nil { + t.Fatalf("expected load to succeed, got %v", err) + } + if loaded == nil || loaded.Username != "alice" || loaded.Password != "pw" { + t.Fatalf("loaded credentials mismatch: %#v", loaded) + } + + if err := DeleteCredentials(*creds.Id); err != nil { + t.Fatalf("expected delete to succeed, got %v", err) + } + if _, err := LoadCredentials(*creds.Id); err == nil { + t.Fatalf("expected subsequent load to fail after deletion") + } +} + +func TestSaveCredentialsNoopWhenNotDesktop(t *testing.T) { + t.Setenv("WHODB_DESKTOP", "false") + if err := SaveCredentials("id", &engine.Credentials{}); err != nil { + t.Fatalf("expected noop save to succeed without error") + } +} + +func strPtr(s string) *string { return &s } diff --git a/dataflow/core/src/aws/config.go b/dataflow/core/src/aws/config.go new file mode 100644 index 0000000..76e51b6 --- /dev/null +++ b/dataflow/core/src/aws/config.go @@ -0,0 +1,75 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" +) + +// LoadAWSConfig creates an AWS SDK configuration from WhoDB credentials. +// This is the primary entry point for AWS plugins. +// +// The function parses WhoDB credentials, builds the appropriate credential +// provider based on the auth method, and returns a configured aws.Config +// that can be used to create service clients. +// +// Example: +// +// cfg, err := aws.LoadAWSConfig(ctx, pluginConfig.Credentials) +// if err != nil { +// return nil, err +// } +func LoadAWSConfig(ctx context.Context, creds *engine.Credentials) (aws.Config, error) { + awsCreds, err := ParseFromWhoDB(creds) + if err != nil { + return aws.Config{}, err + } + + return loadConfigFromAWSCredentials(ctx, awsCreds) +} + +func loadConfigFromAWSCredentials(ctx context.Context, awsCreds *AWSCredentialConfig) (aws.Config, error) { + options := []func(*awsconfig.LoadOptions) error{ + awsconfig.WithRegion(awsCreds.Region), + } + + if awsCreds.IsProfileAuth() && awsCreds.ProfileName != "" { + options = append(options, awsconfig.WithSharedConfigProfile(awsCreds.ProfileName)) + } + + cfg, err := awsconfig.LoadDefaultConfig(ctx, options...) + if err != nil { + log.WithFields(map[string]any{ + "region": awsCreds.Region, + "authMethod": awsCreds.AuthMethod, + }).WithError(err).Error("Failed to load AWS configuration") + return aws.Config{}, HandleAWSError(err) + } + + log.WithFields(map[string]any{ + "region": awsCreds.Region, + "authMethod": awsCreds.AuthMethod, + "hasProfileName": awsCreds.ProfileName != "", + }).Debug("AWS configuration loaded successfully") + + return cfg, nil +} diff --git a/dataflow/core/src/aws/credentials.go b/dataflow/core/src/aws/credentials.go new file mode 100644 index 0000000..788fd23 --- /dev/null +++ b/dataflow/core/src/aws/credentials.go @@ -0,0 +1,100 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "errors" + "strings" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" +) + +type AuthMethod string + +const ( + // AuthMethodProfile uses a named profile from the AWS shared credentials file. + AuthMethodProfile AuthMethod = "profile" + + // AuthMethodDefault uses the AWS SDK's default credential chain. + // This is the recommended method as it automatically handles: + // environment variables, shared credentials file, IAM roles, etc. + AuthMethodDefault AuthMethod = "default" +) + +const ( + AdvancedKeyAuthMethod = "Auth Method" + AdvancedKeyProfileName = "Profile Name" +) + +// AWSCredentialConfig holds parsed AWS configuration extracted from WhoDB credentials. +type AWSCredentialConfig struct { + Region string + AuthMethod AuthMethod + ProfileName string +} + +// ParseFromWhoDB extracts AWS configuration from WhoDB credentials. +// Returns an error if required fields are missing or invalid. +func ParseFromWhoDB(creds *engine.Credentials) (*AWSCredentialConfig, error) { + if creds == nil { + return nil, errors.New("credentials cannot be nil") + } + + region := strings.TrimSpace(creds.Hostname) + if region == "" { + region = common.GetRecordValueOrDefault(creds.Advanced, "Region", "") + } + + authMethodStr := common.GetRecordValueOrDefault(creds.Advanced, AdvancedKeyAuthMethod, string(AuthMethodDefault)) + profileName := common.GetRecordValueOrDefault(creds.Advanced, AdvancedKeyProfileName, "") + + config := &AWSCredentialConfig{ + Region: region, + AuthMethod: AuthMethod(strings.ToLower(strings.TrimSpace(authMethodStr))), + ProfileName: profileName, + } + + if err := config.Validate(); err != nil { + return nil, err + } + + return config, nil +} + +// Validate checks that the configuration is valid for the selected auth method. +func (c *AWSCredentialConfig) Validate() error { + if c.Region == "" { + return ErrRegionRequired + } + + switch c.AuthMethod { + case AuthMethodProfile: + if c.ProfileName == "" { + return ErrProfileNameRequired + } + case AuthMethodDefault: + default: + return ErrInvalidAuthMethod + } + + return nil +} + +func (c *AWSCredentialConfig) IsProfileAuth() bool { + return c.AuthMethod == AuthMethodProfile +} diff --git a/dataflow/core/src/aws/credentials_test.go b/dataflow/core/src/aws/credentials_test.go new file mode 100644 index 0000000..fe57320 --- /dev/null +++ b/dataflow/core/src/aws/credentials_test.go @@ -0,0 +1,155 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "testing" + + "github.com/clidey/whodb/core/src/engine" +) + +func TestParseFromWhoDB_ProfileAuth(t *testing.T) { + creds := &engine.Credentials{ + Hostname: "eu-west-1", + Advanced: []engine.Record{ + {Key: AdvancedKeyAuthMethod, Value: "profile"}, + {Key: AdvancedKeyProfileName, Value: "production"}, + }, + } + + config, err := ParseFromWhoDB(creds) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if config.AuthMethod != AuthMethodProfile { + t.Errorf("expected auth method profile, got %s", config.AuthMethod) + } + if config.ProfileName != "production" { + t.Errorf("expected profile name production, got %s", config.ProfileName) + } +} + +func TestParseFromWhoDB_DefaultAuth(t *testing.T) { + creds := &engine.Credentials{ + Hostname: "ap-southeast-1", + } + + config, err := ParseFromWhoDB(creds) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if config.AuthMethod != AuthMethodDefault { + t.Errorf("expected auth method default, got %s", config.AuthMethod) + } +} + +func TestParseFromWhoDB_NilCredentials(t *testing.T) { + _, err := ParseFromWhoDB(nil) + if err == nil { + t.Error("expected error for nil credentials") + } +} + +func TestParseFromWhoDB_MissingRegion(t *testing.T) { + creds := &engine.Credentials{ + Advanced: []engine.Record{ + {Key: AdvancedKeyAuthMethod, Value: "default"}, + }, + } + + _, err := ParseFromWhoDB(creds) + if err != ErrRegionRequired { + t.Errorf("expected ErrRegionRequired, got %v", err) + } +} + +func TestParseFromWhoDB_ProfileAuthMissingName(t *testing.T) { + creds := &engine.Credentials{ + Hostname: "us-west-2", + Advanced: []engine.Record{ + {Key: AdvancedKeyAuthMethod, Value: "profile"}, + }, + } + + _, err := ParseFromWhoDB(creds) + if err != ErrProfileNameRequired { + t.Errorf("expected ErrProfileNameRequired, got %v", err) + } +} + +func TestParseFromWhoDB_InvalidAuthMethod(t *testing.T) { + creds := &engine.Credentials{ + Hostname: "us-west-2", + Advanced: []engine.Record{ + {Key: AdvancedKeyAuthMethod, Value: "invalid"}, + }, + } + + _, err := ParseFromWhoDB(creds) + if err != ErrInvalidAuthMethod { + t.Errorf("expected ErrInvalidAuthMethod, got %v", err) + } +} + +func TestParseFromWhoDB_AuthMethodCaseInsensitive(t *testing.T) { + testCases := []struct { + input string + expected AuthMethod + }{ + {"PROFILE", AuthMethodProfile}, + {"Profile", AuthMethodProfile}, + {"DEFAULT", AuthMethodDefault}, + {"Default", AuthMethodDefault}, + } + + for _, tc := range testCases { + creds := &engine.Credentials{ + Hostname: "us-west-2", + Advanced: []engine.Record{ + {Key: AdvancedKeyAuthMethod, Value: tc.input}, + {Key: AdvancedKeyProfileName, Value: "test"}, // For profile auth + }, + } + + config, err := ParseFromWhoDB(creds) + if err != nil { + t.Errorf("unexpected error for auth method %s: %v", tc.input, err) + continue + } + if config.AuthMethod != tc.expected { + t.Errorf("expected auth method %s for input %s, got %s", tc.expected, tc.input, config.AuthMethod) + } + } +} + +func TestAWSCredentialConfig_IsProfileAuth(t *testing.T) { + config := &AWSCredentialConfig{ + Region: "us-west-2", + AuthMethod: AuthMethodDefault, + } + + if config.IsProfileAuth() { + t.Error("expected IsProfileAuth to return false for default auth") + } + + config.AuthMethod = AuthMethodProfile + if !config.IsProfileAuth() { + t.Error("expected IsProfileAuth to return true for profile auth") + } +} diff --git a/dataflow/core/src/aws/errors.go b/dataflow/core/src/aws/errors.go new file mode 100644 index 0000000..1a3aed1 --- /dev/null +++ b/dataflow/core/src/aws/errors.go @@ -0,0 +1,164 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "errors" + "fmt" + "strings" + + "github.com/aws/smithy-go" +) + +var ( + ErrAccessDenied = errors.New("access denied: check IAM permissions for this operation") + ErrInvalidCredentials = errors.New("invalid AWS credentials: check access key and secret key") + ErrExpiredCredentials = errors.New("AWS credentials have expired: refresh session token or re-authenticate") + ErrResourceNotFound = errors.New("resource not found: check the resource name and region") + ErrServiceUnavailable = errors.New("AWS service temporarily unavailable: try again later") + ErrThrottling = errors.New("request throttled: too many requests, try again later") + ErrConnectionFailed = errors.New("connection failed: check network connectivity and endpoint") + ErrInvalidRegion = errors.New("invalid or inaccessible region: check the region name") + ErrRegionRequired = errors.New("AWS region is required (set via Hostname field)") + ErrProfileNameRequired = errors.New("profile auth requires a profile name (set via 'Profile Name' advanced option)") + ErrInvalidAuthMethod = errors.New("invalid auth method: must be one of: default, profile") + ErrAWSProviderDisabled = errors.New("AWS provider is disabled") +) + +func HandleAWSError(err error) error { + if err == nil { + return nil + } + + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + return handleAPIError(apiErr) + } + + var opErr *smithy.OperationError + if errors.As(err, &opErr) { + if errors.As(opErr.Err, &apiErr) { + return handleAPIError(apiErr) + } + if isConnectionError(opErr.Err) { + return fmt.Errorf("%w: %s", ErrConnectionFailed, opErr.Err.Error()) + } + } + + errMsg := err.Error() + if strings.Contains(errMsg, "no credentials") || + strings.Contains(errMsg, "NoCredentialProviders") { + return ErrInvalidCredentials + } + if (strings.Contains(errMsg, "SSO") || strings.Contains(errMsg, "sso")) && + (strings.Contains(errMsg, "expired") || strings.Contains(errMsg, "token")) { + return fmt.Errorf("AWS SSO session expired: run 'aws sso login' to refresh your session") + } + if strings.Contains(errMsg, "connection refused") || + strings.Contains(errMsg, "no such host") || + strings.Contains(errMsg, "dial tcp") { + return fmt.Errorf("%w: %s", ErrConnectionFailed, errMsg) + } + + return err +} + +func handleAPIError(apiErr smithy.APIError) error { + code := apiErr.ErrorCode() + + switch code { + // Authentication and authorization errors + case "AccessDeniedException", "AccessDenied", "UnauthorizedAccess": + return ErrAccessDenied + case "InvalidSignatureException", "SignatureDoesNotMatch": + return ErrInvalidCredentials + case "UnrecognizedClientException", "InvalidClientTokenId": + return ErrInvalidCredentials + case "ExpiredTokenException", "TokenRefreshRequired": + return ErrExpiredCredentials + case "IncompleteSignature": + return ErrInvalidCredentials + + // Resource errors + case "ResourceNotFoundException", "TableNotFoundException", "ItemNotFoundException": + return ErrResourceNotFound + case "ResourceInUseException": + return fmt.Errorf("resource is in use: %s", apiErr.ErrorMessage()) + + // Throttling and rate limiting + case "ThrottlingException", "Throttling", "ProvisionedThroughputExceededException": + return ErrThrottling + case "RequestLimitExceeded", "TooManyRequestsException", "LimitExceededException": + return ErrThrottling + + // Connection/timeout + case "RequestTimeoutException": + return ErrConnectionFailed + + // Service availability + case "ServiceUnavailable", "InternalServerError", "InternalError": + return ErrServiceUnavailable + case "ServiceException": + return ErrServiceUnavailable + + // Validation errors + case "ValidationException", "ValidationError": + return fmt.Errorf("validation error: %s", apiErr.ErrorMessage()) + case "InvalidParameterValue", "InvalidParameterException": + return fmt.Errorf("invalid parameter: %s", apiErr.ErrorMessage()) + case "MissingRequiredParameterException": + return fmt.Errorf("missing required parameter: %s", apiErr.ErrorMessage()) + + // Region errors + case "InvalidRegion", "RegionDisabledException": + return ErrInvalidRegion + + // Conditional check failures (DynamoDB) + case "ConditionalCheckFailedException": + return fmt.Errorf("conditional check failed: %s", apiErr.ErrorMessage()) + case "TransactionConflictException": + return fmt.Errorf("transaction conflict: %s", apiErr.ErrorMessage()) + + default: + // Return a formatted error with the AWS error code and message + return fmt.Errorf("AWS error [%s]: %s", code, apiErr.ErrorMessage()) + } +} + +func isConnectionError(err error) bool { + if err == nil { + return false + } + errMsg := strings.ToLower(err.Error()) + connectionPatterns := []string{ + "connection refused", + "no such host", + "dial tcp", + "i/o timeout", + "network is unreachable", + "connection reset", + "eof", + "tls handshake timeout", + "context deadline exceeded", + } + for _, pattern := range connectionPatterns { + if strings.Contains(errMsg, pattern) { + return true + } + } + return false +} diff --git a/dataflow/core/src/aws/errors_test.go b/dataflow/core/src/aws/errors_test.go new file mode 100644 index 0000000..8d81135 --- /dev/null +++ b/dataflow/core/src/aws/errors_test.go @@ -0,0 +1,279 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "errors" + "strings" + "testing" + + "github.com/aws/smithy-go" +) + +// mockAPIError implements smithy.APIError for testing. +type mockAPIError struct { + code string + message string + fault smithy.ErrorFault +} + +func (e *mockAPIError) ErrorCode() string { + return e.code +} + +func (e *mockAPIError) ErrorMessage() string { + return e.message +} + +func (e *mockAPIError) ErrorFault() smithy.ErrorFault { + return e.fault +} + +func (e *mockAPIError) Error() string { + return e.code + ": " + e.message +} + +// Ensure mockAPIError implements smithy.APIError +var _ smithy.APIError = (*mockAPIError)(nil) + +func TestHandleAWSError_Nil(t *testing.T) { + if HandleAWSError(nil) != nil { + t.Error("expected nil for nil error") + } +} + +func TestHandleAWSError_AccessDenied(t *testing.T) { + testCases := []string{ + "AccessDeniedException", + "AccessDenied", + "UnauthorizedAccess", + } + + for _, code := range testCases { + err := &mockAPIError{code: code, message: "test message"} + result := HandleAWSError(err) + if !errors.Is(result, ErrAccessDenied) { + t.Errorf("expected ErrAccessDenied for %s, got %v", code, result) + } + } +} + +func TestHandleAWSError_InvalidCredentials(t *testing.T) { + testCases := []string{ + "InvalidSignatureException", + "SignatureDoesNotMatch", + "UnrecognizedClientException", + "InvalidClientTokenId", + "IncompleteSignature", + } + + for _, code := range testCases { + err := &mockAPIError{code: code, message: "test message"} + result := HandleAWSError(err) + if !errors.Is(result, ErrInvalidCredentials) { + t.Errorf("expected ErrInvalidCredentials for %s, got %v", code, result) + } + } +} + +func TestHandleAWSError_ExpiredCredentials(t *testing.T) { + testCases := []string{ + "ExpiredTokenException", + "TokenRefreshRequired", + } + + for _, code := range testCases { + err := &mockAPIError{code: code, message: "test message"} + result := HandleAWSError(err) + if !errors.Is(result, ErrExpiredCredentials) { + t.Errorf("expected ErrExpiredCredentials for %s, got %v", code, result) + } + } +} + +func TestHandleAWSError_ResourceNotFound(t *testing.T) { + testCases := []string{ + "ResourceNotFoundException", + "TableNotFoundException", + "ItemNotFoundException", + } + + for _, code := range testCases { + err := &mockAPIError{code: code, message: "test message"} + result := HandleAWSError(err) + if !errors.Is(result, ErrResourceNotFound) { + t.Errorf("expected ErrResourceNotFound for %s, got %v", code, result) + } + } +} + +func TestHandleAWSError_Throttling(t *testing.T) { + testCases := []string{ + "ThrottlingException", + "Throttling", + "ProvisionedThroughputExceededException", + "RequestLimitExceeded", + "TooManyRequestsException", + } + + for _, code := range testCases { + err := &mockAPIError{code: code, message: "test message"} + result := HandleAWSError(err) + if !errors.Is(result, ErrThrottling) { + t.Errorf("expected ErrThrottling for %s, got %v", code, result) + } + } +} + +func TestHandleAWSError_ServiceUnavailable(t *testing.T) { + testCases := []string{ + "ServiceUnavailable", + "InternalServerError", + "InternalError", + "ServiceException", + } + + for _, code := range testCases { + err := &mockAPIError{code: code, message: "test message"} + result := HandleAWSError(err) + if !errors.Is(result, ErrServiceUnavailable) { + t.Errorf("expected ErrServiceUnavailable for %s, got %v", code, result) + } + } +} + +func TestHandleAWSError_InvalidRegion(t *testing.T) { + testCases := []string{ + "InvalidRegion", + "RegionDisabledException", + } + + for _, code := range testCases { + err := &mockAPIError{code: code, message: "test message"} + result := HandleAWSError(err) + if !errors.Is(result, ErrInvalidRegion) { + t.Errorf("expected ErrInvalidRegion for %s, got %v", code, result) + } + } +} + +func TestHandleAWSError_ValidationError(t *testing.T) { + err := &mockAPIError{code: "ValidationException", message: "field X is invalid"} + result := HandleAWSError(err) + if result == nil || result.Error() != "validation error: field X is invalid" { + t.Errorf("unexpected error message: %v", result) + } +} + +func TestHandleAWSError_UnknownCode(t *testing.T) { + err := &mockAPIError{code: "SomeUnknownError", message: "unknown error occurred"} + result := HandleAWSError(err) + if result == nil { + t.Error("expected non-nil error for unknown code") + } + expected := "AWS error [SomeUnknownError]: unknown error occurred" + if result.Error() != expected { + t.Errorf("expected %q, got %q", expected, result.Error()) + } +} + +func TestHandleAWSError_SSOExpired(t *testing.T) { + testCases := []string{ + "SSO session token has expired", + "the SSO token is expired", + "sso: token is expired, please run aws sso login", + } + + for _, msg := range testCases { + err := errors.New(msg) + result := HandleAWSError(err) + if result == nil { + t.Errorf("expected non-nil error for %q", msg) + continue + } + if !strings.Contains(result.Error(), "SSO session expired") { + t.Errorf("expected SSO expiry message for %q, got %q", msg, result.Error()) + } + } +} + +func TestHandleAWSError_ConnectionPatterns(t *testing.T) { + testCases := []string{ + "no credentials found", + "NoCredentialProviders: no valid providers", + "connection refused", + "no such host", + "dial tcp: failed", + } + + for _, msg := range testCases { + err := errors.New(msg) + result := HandleAWSError(err) + if result == nil { + t.Errorf("expected non-nil error for %s", msg) + } + } +} + +func TestHandleAWSError_RequestTimeout(t *testing.T) { + err := &mockAPIError{code: "RequestTimeoutException", message: "timed out"} + result := HandleAWSError(err) + if !errors.Is(result, ErrConnectionFailed) { + t.Errorf("expected ErrConnectionFailed for RequestTimeoutException, got %v", result) + } +} + +func TestHandleAWSError_LimitExceeded(t *testing.T) { + err := &mockAPIError{code: "LimitExceededException", message: "limit exceeded"} + result := HandleAWSError(err) + if !errors.Is(result, ErrThrottling) { + t.Errorf("expected ErrThrottling for LimitExceededException, got %v", result) + } +} + +func TestIsConnectionError(t *testing.T) { + testCases := []struct { + msg string + expected bool + }{ + {"connection refused", true}, + {"no such host", true}, + {"dial tcp 1.2.3.4:443", true}, + {"i/o timeout", true}, + {"network is unreachable", true}, + {"connection reset by peer", true}, + {"EOF", true}, + {"tls handshake timeout", true}, + {"context deadline exceeded", true}, + {"TLS Handshake Timeout", true}, // case-insensitive + {"Context Deadline Exceeded", true}, + {"something else entirely", false}, + {"", false}, + } + + for _, tc := range testCases { + result := isConnectionError(errors.New(tc.msg)) + if result != tc.expected { + t.Errorf("isConnectionError(%q): expected %v, got %v", tc.msg, tc.expected, result) + } + } + + // nil error + if isConnectionError(nil) { + t.Error("isConnectionError(nil): expected false") + } +} diff --git a/dataflow/core/src/aws/profiles.go b/dataflow/core/src/aws/profiles.go new file mode 100644 index 0000000..402ec18 --- /dev/null +++ b/dataflow/core/src/aws/profiles.go @@ -0,0 +1,192 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "bufio" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/clidey/whodb/core/src/log" +) + +// LocalProfile represents an AWS profile discovered from local configuration. +type LocalProfile struct { + Name string + Region string + Source string + AuthType string // "static", "sso", "assume-role", "environment" + IsDefault bool +} + +// DiscoverLocalProfiles scans ~/.aws/credentials and ~/.aws/config for available profiles. +// It also checks environment variables for AWS credentials. +func DiscoverLocalProfiles() ([]LocalProfile, error) { + profiles := make(map[string]*LocalProfile) + + if hasEnvCredentials() { + envProfile := &LocalProfile{ + Name: "environment", + Source: "environment", + AuthType: "environment", + IsDefault: os.Getenv("AWS_PROFILE") == "", + } + if region := os.Getenv("AWS_DEFAULT_REGION"); region != "" { + envProfile.Region = region + } else if region := os.Getenv("AWS_REGION"); region != "" { + envProfile.Region = region + } + profiles["environment"] = envProfile + } + + awsDir := getAWSConfigDir() + + credentialsPath := filepath.Join(awsDir, "credentials") + credProfiles, err := parseINIFile(credentialsPath, "credentials", false) + if err != nil { + if !os.IsNotExist(err) { + log.Warnf("Failed to parse AWS credentials file %s: %v", credentialsPath, err) + } + } else { + for name, profile := range credProfiles { + profiles[name] = profile + } + } + + configPath := filepath.Join(awsDir, "config") + configProfiles, err := parseINIFile(configPath, "config", true) + if err != nil { + if !os.IsNotExist(err) { + log.Warnf("Failed to parse AWS config file %s: %v", configPath, err) + } + } else { + for name, profile := range configProfiles { + if existing, ok := profiles[name]; ok { + if existing.Region == "" && profile.Region != "" { + existing.Region = profile.Region + } + } else { + profiles[name] = profile + } + } + } + + result := make([]LocalProfile, 0, len(profiles)) + for _, profile := range profiles { + result = append(result, *profile) + } + + return result, nil +} + +func hasEnvCredentials() bool { + accessKey := os.Getenv("AWS_ACCESS_KEY_ID") + secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY") + return accessKey != "" && secretKey != "" +} + +func getAWSConfigDir() string { + if dir := os.Getenv("AWS_CONFIG_FILE"); dir != "" { + return filepath.Dir(dir) + } + if dir := os.Getenv("AWS_SHARED_CREDENTIALS_FILE"); dir != "" { + return filepath.Dir(dir) + } + homeDir, err := os.UserHomeDir() + if err != nil { + log.Warnf("Unable to determine home directory for AWS config: %v", err) + return "" + } + return filepath.Join(homeDir, ".aws") +} + +func parseINIFile(path, source string, isConfigFile bool) (map[string]*LocalProfile, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + profiles := make(map[string]*LocalProfile) + var currentProfile *LocalProfile + // Track keys per profile to determine auth type after parsing + profileKeys := make(map[string]map[string]bool) + + sectionRegex := regexp.MustCompile(`^\s*\[([^\]]+)\]\s*$`) + keyValueRegex := regexp.MustCompile(`^\s*([^=]+?)\s*=\s*(.+?)\s*$`) + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ";") { + continue + } + + if matches := sectionRegex.FindStringSubmatch(line); matches != nil { + sectionName := matches[1] + + profileName := sectionName + if isConfigFile && strings.HasPrefix(sectionName, "profile ") { + profileName = strings.TrimPrefix(sectionName, "profile ") + } + + currentProfile = &LocalProfile{ + Name: profileName, + Source: source, + AuthType: "static", + IsDefault: profileName == "default", + } + profiles[profileName] = currentProfile + profileKeys[profileName] = make(map[string]bool) + continue + } + + if currentProfile != nil { + if matches := keyValueRegex.FindStringSubmatch(line); matches != nil { + key := strings.ToLower(matches[1]) + value := matches[2] + + profileKeys[currentProfile.Name][key] = true + + if key == "region" { + currentProfile.Region = value + } + } + } + } + + // Determine auth type based on observed keys + for name, keys := range profileKeys { + profile, ok := profiles[name] + if !ok { + continue + } + if keys["sso_start_url"] || keys["sso_session"] { + profile.AuthType = "sso" + log.Debugf("AWS profiles: %s detected as SSO profile", name) + } else if keys["role_arn"] { + profile.AuthType = "assume-role" + log.Debugf("AWS profiles: %s detected as assume-role profile", name) + } + } + + return profiles, scanner.Err() +} diff --git a/dataflow/core/src/aws/profiles_test.go b/dataflow/core/src/aws/profiles_test.go new file mode 100644 index 0000000..33ce4c0 --- /dev/null +++ b/dataflow/core/src/aws/profiles_test.go @@ -0,0 +1,312 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseINIFile_Credentials(t *testing.T) { + // Create a temporary credentials file + tempDir := t.TempDir() + credPath := filepath.Join(tempDir, "credentials") + + content := `[default] +aws_access_key_id = AKIAIOSFODNN7EXAMPLE +aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + +[production] +aws_access_key_id = AKIAI44QH8DHBEXAMPLE +aws_secret_access_key = je7MtGbClwBF/2Zp9Utk/h3yCo8nvbEXAMPLEKEY + +[staging] +aws_access_key_id = AKIAISTAGING +aws_secret_access_key = stagingsecret +` + if err := os.WriteFile(credPath, []byte(content), 0600); err != nil { + t.Fatalf("Failed to write test credentials file: %v", err) + } + + profiles, err := parseINIFile(credPath, "credentials", false) + if err != nil { + t.Fatalf("parseINIFile failed: %v", err) + } + + if len(profiles) != 3 { + t.Errorf("Expected 3 profiles, got %d", len(profiles)) + } + + // Check default profile + if def, ok := profiles["default"]; !ok { + t.Error("Expected 'default' profile") + } else { + if !def.IsDefault { + t.Error("Expected default profile to have IsDefault=true") + } + if def.Source != "credentials" { + t.Errorf("Expected source 'credentials', got '%s'", def.Source) + } + } + + // Check production profile + if prod, ok := profiles["production"]; !ok { + t.Error("Expected 'production' profile") + } else { + if prod.IsDefault { + t.Error("Expected production profile to have IsDefault=false") + } + } +} + +func TestParseINIFile_Config(t *testing.T) { + // Create a temporary config file + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config") + + content := `[default] +region = us-east-1 +output = json + +[profile production] +region = us-west-2 +output = json + +[profile staging] +region = eu-west-1 +` + if err := os.WriteFile(configPath, []byte(content), 0600); err != nil { + t.Fatalf("Failed to write test config file: %v", err) + } + + profiles, err := parseINIFile(configPath, "config", true) + if err != nil { + t.Fatalf("parseINIFile failed: %v", err) + } + + if len(profiles) != 3 { + t.Errorf("Expected 3 profiles, got %d", len(profiles)) + } + + // Check default profile region + if def, ok := profiles["default"]; !ok { + t.Error("Expected 'default' profile") + } else { + if def.Region != "us-east-1" { + t.Errorf("Expected region 'us-east-1', got '%s'", def.Region) + } + } + + // Check production profile region + if prod, ok := profiles["production"]; !ok { + t.Error("Expected 'production' profile") + } else { + if prod.Region != "us-west-2" { + t.Errorf("Expected region 'us-west-2', got '%s'", prod.Region) + } + } +} + +func TestParseINIFile_WithComments(t *testing.T) { + tempDir := t.TempDir() + credPath := filepath.Join(tempDir, "credentials") + + content := `# This is a comment +[default] +aws_access_key_id = AKIAIOSFODNN7EXAMPLE +; This is also a comment +aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +` + if err := os.WriteFile(credPath, []byte(content), 0600); err != nil { + t.Fatalf("Failed to write test credentials file: %v", err) + } + + profiles, err := parseINIFile(credPath, "credentials", false) + if err != nil { + t.Fatalf("parseINIFile failed: %v", err) + } + + if len(profiles) != 1 { + t.Errorf("Expected 1 profile, got %d", len(profiles)) + } +} + +func TestHasEnvCredentials(t *testing.T) { + // Save current env + oldAccessKey := os.Getenv("AWS_ACCESS_KEY_ID") + oldSecretKey := os.Getenv("AWS_SECRET_ACCESS_KEY") + defer func() { + os.Setenv("AWS_ACCESS_KEY_ID", oldAccessKey) + os.Setenv("AWS_SECRET_ACCESS_KEY", oldSecretKey) + }() + + // Test with no credentials + os.Unsetenv("AWS_ACCESS_KEY_ID") + os.Unsetenv("AWS_SECRET_ACCESS_KEY") + if hasEnvCredentials() { + t.Error("Expected hasEnvCredentials to return false when no env vars set") + } + + // Test with only access key + os.Setenv("AWS_ACCESS_KEY_ID", "test") + os.Unsetenv("AWS_SECRET_ACCESS_KEY") + if hasEnvCredentials() { + t.Error("Expected hasEnvCredentials to return false with only access key") + } + + // Test with both credentials + os.Setenv("AWS_ACCESS_KEY_ID", "test") + os.Setenv("AWS_SECRET_ACCESS_KEY", "secret") + if !hasEnvCredentials() { + t.Error("Expected hasEnvCredentials to return true when both env vars set") + } +} + +func TestParseINIFile_SSOProfile(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config") + + content := `[default] +region = us-east-1 + +[profile sso-user] +sso_start_url = https://my-sso.awsapps.com/start +sso_account_id = 123456789012 +sso_role_name = ReadOnly +region = us-west-2 + +[profile role-user] +role_arn = arn:aws:iam::123456789012:role/MyRole +source_profile = default +region = eu-west-1 + +[profile static-user] +aws_access_key_id = AKIAEXAMPLE +aws_secret_access_key = secretkey +` + if err := os.WriteFile(configPath, []byte(content), 0600); err != nil { + t.Fatalf("Failed to write test config file: %v", err) + } + + profiles, err := parseINIFile(configPath, "config", true) + if err != nil { + t.Fatalf("parseINIFile failed: %v", err) + } + + if len(profiles) != 4 { + t.Errorf("Expected 4 profiles, got %d", len(profiles)) + } + + // SSO profile + if sso, ok := profiles["sso-user"]; !ok { + t.Error("Expected 'sso-user' profile") + } else { + if sso.AuthType != "sso" { + t.Errorf("Expected auth type 'sso', got '%s'", sso.AuthType) + } + if sso.Region != "us-west-2" { + t.Errorf("Expected region 'us-west-2', got '%s'", sso.Region) + } + } + + // Assume-role profile + if role, ok := profiles["role-user"]; !ok { + t.Error("Expected 'role-user' profile") + } else { + if role.AuthType != "assume-role" { + t.Errorf("Expected auth type 'assume-role', got '%s'", role.AuthType) + } + } + + // Static profile + if static, ok := profiles["static-user"]; !ok { + t.Error("Expected 'static-user' profile") + } else { + if static.AuthType != "static" { + t.Errorf("Expected auth type 'static', got '%s'", static.AuthType) + } + } + + // Default profile (no SSO/role keys) + if def, ok := profiles["default"]; !ok { + t.Error("Expected 'default' profile") + } else { + if def.AuthType != "static" { + t.Errorf("Expected auth type 'static' for default, got '%s'", def.AuthType) + } + } +} + +func TestParseINIFile_SSOSessionProfile(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config") + + content := `[profile sso-session-user] +sso_session = my-session +sso_account_id = 123456789012 +sso_role_name = Admin +region = us-east-1 +` + if err := os.WriteFile(configPath, []byte(content), 0600); err != nil { + t.Fatalf("Failed to write test config file: %v", err) + } + + profiles, err := parseINIFile(configPath, "config", true) + if err != nil { + t.Fatalf("parseINIFile failed: %v", err) + } + + if sso, ok := profiles["sso-session-user"]; !ok { + t.Error("Expected 'sso-session-user' profile") + } else { + if sso.AuthType != "sso" { + t.Errorf("Expected auth type 'sso' for sso_session profile, got '%s'", sso.AuthType) + } + } +} + +func TestDiscoverLocalProfiles_NoFiles(t *testing.T) { + // Save and clear relevant env vars + oldConfigFile := os.Getenv("AWS_CONFIG_FILE") + oldCredsFile := os.Getenv("AWS_SHARED_CREDENTIALS_FILE") + oldAccessKey := os.Getenv("AWS_ACCESS_KEY_ID") + oldSecretKey := os.Getenv("AWS_SECRET_ACCESS_KEY") + defer func() { + os.Setenv("AWS_CONFIG_FILE", oldConfigFile) + os.Setenv("AWS_SHARED_CREDENTIALS_FILE", oldCredsFile) + os.Setenv("AWS_ACCESS_KEY_ID", oldAccessKey) + os.Setenv("AWS_SECRET_ACCESS_KEY", oldSecretKey) + }() + + // Point to non-existent directory + tempDir := t.TempDir() + os.Setenv("AWS_CONFIG_FILE", filepath.Join(tempDir, "nonexistent", "config")) + os.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(tempDir, "nonexistent", "credentials")) + os.Unsetenv("AWS_ACCESS_KEY_ID") + os.Unsetenv("AWS_SECRET_ACCESS_KEY") + + profiles, err := DiscoverLocalProfiles() + if err != nil { + t.Fatalf("DiscoverLocalProfiles failed: %v", err) + } + + // Should return empty list, not error + if len(profiles) != 0 { + t.Errorf("Expected 0 profiles, got %d", len(profiles)) + } +} diff --git a/dataflow/core/src/aws/rds_auth.go b/dataflow/core/src/aws/rds_auth.go new file mode 100644 index 0000000..53b06e9 --- /dev/null +++ b/dataflow/core/src/aws/rds_auth.go @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "context" + "fmt" + + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/feature/rds/auth" + "github.com/clidey/whodb/core/src/log" +) + +// GenerateRDSAuthToken builds a short-lived IAM authentication token for RDS. +// The token acts as a password and is valid for ~15 minutes. +func GenerateRDSAuthToken(ctx context.Context, cfg awssdk.Config, endpoint string, port int, region, username string) (string, error) { + addr := fmt.Sprintf("%s:%d", endpoint, port) + log.Infof("RDS IAM Auth: generating token for %s (region=%s, user=%s)", addr, region, username) + token, err := auth.BuildAuthToken(ctx, addr, region, username, cfg.Credentials) + if err != nil { + log.Errorf("RDS IAM Auth: token generation failed for %s: %v", addr, err) + return "", HandleAWSError(err) + } + log.Infof("RDS IAM Auth: token generated successfully for %s (token length=%d)", addr, len(token)) + return token, nil +} diff --git a/dataflow/core/src/aws/regions.go b/dataflow/core/src/aws/regions.go new file mode 100644 index 0000000..6181d43 --- /dev/null +++ b/dataflow/core/src/aws/regions.go @@ -0,0 +1,97 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +// Partition constants for AWS region grouping. +const ( + PartitionStandard = "aws" + PartitionGovCloud = "aws-us-gov" + PartitionChina = "aws-cn" +) + +// Region represents an AWS region with its partition. +type Region struct { + ID string + Description string + Partition string +} + +// GetRegions returns the list of AWS regions across all supported partitions. +// Based on https://www.aws-services.info/regions.html +func GetRegions() []Region { + return []Region{ + // US regions + {ID: "us-east-1", Description: "US East (N. Virginia)", Partition: PartitionStandard}, + {ID: "us-east-2", Description: "US East (Ohio)", Partition: PartitionStandard}, + {ID: "us-west-1", Description: "US West (N. California)", Partition: PartitionStandard}, + {ID: "us-west-2", Description: "US West (Oregon)", Partition: PartitionStandard}, + + // Canada + {ID: "ca-central-1", Description: "Canada (Central)", Partition: PartitionStandard}, + {ID: "ca-west-1", Description: "Canada West (Calgary)", Partition: PartitionStandard}, + + // Mexico + {ID: "mx-central-1", Description: "Mexico (Central)", Partition: PartitionStandard}, + + // Europe + {ID: "eu-west-1", Description: "Europe (Ireland)", Partition: PartitionStandard}, + {ID: "eu-west-2", Description: "Europe (London)", Partition: PartitionStandard}, + {ID: "eu-west-3", Description: "Europe (Paris)", Partition: PartitionStandard}, + {ID: "eu-central-1", Description: "Europe (Frankfurt)", Partition: PartitionStandard}, + {ID: "eu-central-2", Description: "Europe (Zurich)", Partition: PartitionStandard}, + {ID: "eu-north-1", Description: "Europe (Stockholm)", Partition: PartitionStandard}, + {ID: "eu-south-1", Description: "Europe (Milan)", Partition: PartitionStandard}, + {ID: "eu-south-2", Description: "Europe (Spain)", Partition: PartitionStandard}, + + // Asia Pacific + {ID: "ap-east-1", Description: "Asia Pacific (Hong Kong)", Partition: PartitionStandard}, + {ID: "ap-east-2", Description: "Asia Pacific (Taipei)", Partition: PartitionStandard}, + {ID: "ap-northeast-1", Description: "Asia Pacific (Tokyo)", Partition: PartitionStandard}, + {ID: "ap-northeast-2", Description: "Asia Pacific (Seoul)", Partition: PartitionStandard}, + {ID: "ap-northeast-3", Description: "Asia Pacific (Osaka)", Partition: PartitionStandard}, + {ID: "ap-south-1", Description: "Asia Pacific (Mumbai)", Partition: PartitionStandard}, + {ID: "ap-south-2", Description: "Asia Pacific (Hyderabad)", Partition: PartitionStandard}, + {ID: "ap-southeast-1", Description: "Asia Pacific (Singapore)", Partition: PartitionStandard}, + {ID: "ap-southeast-2", Description: "Asia Pacific (Sydney)", Partition: PartitionStandard}, + {ID: "ap-southeast-3", Description: "Asia Pacific (Jakarta)", Partition: PartitionStandard}, + {ID: "ap-southeast-4", Description: "Asia Pacific (Melbourne)", Partition: PartitionStandard}, + {ID: "ap-southeast-5", Description: "Asia Pacific (Malaysia)", Partition: PartitionStandard}, + {ID: "ap-southeast-6", Description: "Asia Pacific (New Zealand)", Partition: PartitionStandard}, + {ID: "ap-southeast-7", Description: "Asia Pacific (Thailand)", Partition: PartitionStandard}, + + // South America + {ID: "sa-east-1", Description: "South America (São Paulo)", Partition: PartitionStandard}, + + // Middle East + {ID: "me-south-1", Description: "Middle East (Bahrain)", Partition: PartitionStandard}, + {ID: "me-central-1", Description: "Middle East (UAE)", Partition: PartitionStandard}, + + // Africa + {ID: "af-south-1", Description: "Africa (Cape Town)", Partition: PartitionStandard}, + + // Israel + {ID: "il-central-1", Description: "Israel (Tel Aviv)", Partition: PartitionStandard}, + + // GovCloud + {ID: "us-gov-west-1", Description: "AWS GovCloud (US-West)", Partition: PartitionGovCloud}, + {ID: "us-gov-east-1", Description: "AWS GovCloud (US-East)", Partition: PartitionGovCloud}, + + // China + {ID: "cn-north-1", Description: "China (Beijing)", Partition: PartitionChina}, + {ID: "cn-northwest-1", Description: "China (Ningxia)", Partition: PartitionChina}, + } +} diff --git a/dataflow/core/src/aws/regions_test.go b/dataflow/core/src/aws/regions_test.go new file mode 100644 index 0000000..bbe3caa --- /dev/null +++ b/dataflow/core/src/aws/regions_test.go @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "testing" +) + +func TestGetRegions_HasPartitions(t *testing.T) { + regions := GetRegions() + + partitions := make(map[string]int) + for _, r := range regions { + if r.Partition == "" { + t.Errorf("region %s has empty partition", r.ID) + } + partitions[r.Partition]++ + } + + if partitions[PartitionStandard] == 0 { + t.Error("expected standard partition regions") + } + if partitions[PartitionGovCloud] != 2 { + t.Errorf("expected 2 GovCloud regions, got %d", partitions[PartitionGovCloud]) + } + if partitions[PartitionChina] != 2 { + t.Errorf("expected 2 China regions, got %d", partitions[PartitionChina]) + } +} + +func TestGetRegions_GovCloudRegions(t *testing.T) { + regions := GetRegions() + + govRegions := make(map[string]bool) + for _, r := range regions { + if r.Partition == PartitionGovCloud { + govRegions[r.ID] = true + } + } + + if !govRegions["us-gov-west-1"] { + t.Error("expected us-gov-west-1 in GovCloud regions") + } + if !govRegions["us-gov-east-1"] { + t.Error("expected us-gov-east-1 in GovCloud regions") + } +} + +func TestGetRegions_ChinaRegions(t *testing.T) { + regions := GetRegions() + + chinaRegions := make(map[string]bool) + for _, r := range regions { + if r.Partition == PartitionChina { + chinaRegions[r.ID] = true + } + } + + if !chinaRegions["cn-north-1"] { + t.Error("expected cn-north-1 in China regions") + } + if !chinaRegions["cn-northwest-1"] { + t.Error("expected cn-northwest-1 in China regions") + } +} + +func TestGetRegions_NoDuplicateIDs(t *testing.T) { + regions := GetRegions() + seen := make(map[string]bool) + for _, r := range regions { + if seen[r.ID] { + t.Errorf("duplicate region ID: %s", r.ID) + } + seen[r.ID] = true + } +} diff --git a/dataflow/core/src/bamlconfig/bamlconfig.go b/dataflow/core/src/bamlconfig/bamlconfig.go new file mode 100644 index 0000000..38a05ad --- /dev/null +++ b/dataflow/core/src/bamlconfig/bamlconfig.go @@ -0,0 +1,118 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +// Package bamlconfig sets BAML environment defaults before the BAML native library loads. +// This package MUST be imported first (before any other imports) in main packages +// to ensure the environment variable is set before baml_client is imported. +// +// Usage in main.go: +// +// import ( +// _ "github.com/clidey/whodb/core/src/bamlconfig" // Must be first! +// // ... other imports +// ) +package bamlconfig + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +func init() { + // Set BAML_LIBRARY_PATH for macOS desktop builds + // This must happen before BAML's init() loads the native library + configureBamlLibraryPath() + + // Don't override if user explicitly set BAML_LOG + if os.Getenv("BAML_LOG") != "" { + return + } + + // Map WHODB_LOG_LEVEL to BAML_LOG + level := strings.ToLower(os.Getenv("WHODB_LOG_LEVEL")) + + var bamlLevel string + switch level { + case "debug": + bamlLevel = "debug" + case "none", "off", "disabled": + bamlLevel = "off" + default: + // BAML's info/warn output is library startup chatter (download progress, + // cache paths) that isn't useful to WhoDB users. Only show BAML errors + // unless the user explicitly requests debug. + bamlLevel = "error" + } + + os.Setenv("BAML_LOG", bamlLevel) +} + +// configureBamlLibraryPath sets BAML_LIBRARY_PATH for macOS desktop builds. +// On macOS, when running as a .app bundle, we bundle a signed copy of the BAML +// native library in Contents/Frameworks/ to satisfy Gatekeeper requirements. +func configureBamlLibraryPath() { + // Skip if already set + if existing := os.Getenv("BAML_LIBRARY_PATH"); existing != "" { + return + } + + // Only applies to macOS + if runtime.GOOS != "darwin" { + return + } + + // Get executable path to find the bundle + execPath, err := os.Executable() + if err != nil { + return + } + + // Resolve symlinks to get real path + execPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + return + } + + // Check if we're running from a .app bundle + // Expected structure: WhoDB.app/Contents/MacOS/whodb + macosDir := filepath.Dir(execPath) + contentsDir := filepath.Dir(macosDir) + + if filepath.Base(macosDir) != "MacOS" || filepath.Base(contentsDir) != "Contents" { + return // Not running from a bundle + } + + // Determine architecture-specific dylib name + var dylibName string + switch runtime.GOARCH { + case "arm64": + dylibName = "libbaml_cffi-aarch64-apple-darwin.dylib" + case "amd64": + dylibName = "libbaml_cffi-x86_64-apple-darwin.dylib" + default: + return // Unsupported architecture + } + + // Check if bundled dylib exists + frameworksDir := filepath.Join(contentsDir, "Frameworks") + dylibPath := filepath.Join(frameworksDir, dylibName) + + if _, err := os.Stat(dylibPath); err == nil { + os.Setenv("BAML_LIBRARY_PATH", dylibPath) + } +} diff --git a/dataflow/core/src/common/canonical_types.go b/dataflow/core/src/common/canonical_types.go new file mode 100644 index 0000000..8a4afe8 --- /dev/null +++ b/dataflow/core/src/common/canonical_types.go @@ -0,0 +1,116 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package common + +import ( + "regexp" + "strconv" + "strings" +) + +// TypeSpec represents a parsed type specification with its components. +type TypeSpec struct { + // BaseType is the type name without length/precision (UPPERCASE). + BaseType string + + // Length is the length parameter for types like VARCHAR(255). + // Zero if not specified or not applicable. In the example above, the length is 255. + Length int + + // Precision is the precision for types like DECIMAL(10,2). + // Zero if not specified or not applicable. In the example above, the precision is 10. + Precision int + + // Scale is the scale for types like DECIMAL(10,2). + // Zero if not specified or not applicable. In the example above, the scale is 2. + Scale int +} + +// typeSpecRegex matches type specifications like "VARCHAR(255)" or "DECIMAL(10,2)" +var typeSpecRegex = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9_ ]*?)(?:\((\d+)(?:,\s*(\d+))?\))?$`) + +// ParseTypeSpec parses a full type string into its components. +// Examples: +// - "VARCHAR(255)" -> BaseType="VARCHAR", Length=255 +// - "DECIMAL(10,2)" -> BaseType="DECIMAL", Precision=10, Scale=2 +// - "INTEGER" -> BaseType="INTEGER" +func ParseTypeSpec(fullType string) TypeSpec { + fullType = strings.TrimSpace(fullType) + + matches := typeSpecRegex.FindStringSubmatch(fullType) + if matches == nil { + return TypeSpec{BaseType: strings.ToUpper(fullType)} + } + + spec := TypeSpec{ + BaseType: strings.ToUpper(strings.TrimSpace(matches[1])), + } + + // Parse first number (length or precision) + if matches[2] != "" { + if n, err := strconv.Atoi(matches[2]); err == nil { + if matches[3] != "" { + spec.Precision = n + } else { + spec.Length = n + } + } + } + + // Parse second number (scale) + if matches[3] != "" { + if n, err := strconv.Atoi(matches[3]); err == nil { + spec.Scale = n + if spec.Length > 0 { + spec.Precision = spec.Length + spec.Length = 0 + } + } + } + + return spec +} + +// FormatTypeSpec formats a TypeSpec back into a type string. +func FormatTypeSpec(spec TypeSpec) string { + if spec.Precision > 0 { + return spec.BaseType + "(" + + strconv.Itoa(spec.Precision) + "," + + strconv.Itoa(spec.Scale) + ")" + } + + if spec.Length > 0 { + return spec.BaseType + "(" + strconv.Itoa(spec.Length) + ")" + } + + return spec.BaseType +} + +// NormalizeTypeWithMap normalizes a type name using the provided alias map. +// The alias map should map uppercase aliases to uppercase canonical names. +func NormalizeTypeWithMap(typeName string, aliasMap map[string]string) string { + spec := ParseTypeSpec(typeName) + upperBase := strings.ToUpper(spec.BaseType) + + if canonical, ok := aliasMap[upperBase]; ok { + spec.BaseType = canonical + } else { + spec.BaseType = upperBase + } + + return FormatTypeSpec(spec) +} diff --git a/dataflow/core/src/common/canonical_types_test.go b/dataflow/core/src/common/canonical_types_test.go new file mode 100644 index 0000000..1b96269 --- /dev/null +++ b/dataflow/core/src/common/canonical_types_test.go @@ -0,0 +1,54 @@ +package common + +import "testing" + +func TestParseAndFormatTypeSpec(t *testing.T) { + t.Run("varchar length", func(t *testing.T) { + spec := ParseTypeSpec("VARCHAR(255)") + if spec.BaseType != "VARCHAR" || spec.Length != 255 || spec.Precision != 0 || spec.Scale != 0 { + t.Fatalf("unexpected spec: %#v", spec) + } + if got := FormatTypeSpec(spec); got != "VARCHAR(255)" { + t.Fatalf("expected VARCHAR(255), got %q", got) + } + }) + + t.Run("decimal precision scale", func(t *testing.T) { + spec := ParseTypeSpec("DECIMAL(10,2)") + if spec.BaseType != "DECIMAL" || spec.Precision != 10 || spec.Scale != 2 || spec.Length != 0 { + t.Fatalf("unexpected spec: %#v", spec) + } + if got := FormatTypeSpec(spec); got != "DECIMAL(10,2)" { + t.Fatalf("expected DECIMAL(10,2), got %q", got) + } + }) + + t.Run("bare type", func(t *testing.T) { + spec := ParseTypeSpec("integer") + if spec.BaseType != "INTEGER" || spec.Length != 0 || spec.Precision != 0 || spec.Scale != 0 { + t.Fatalf("unexpected spec: %#v", spec) + } + if got := FormatTypeSpec(spec); got != "INTEGER" { + t.Fatalf("expected INTEGER, got %q", got) + } + }) +} + +func TestNormalizeTypeWithMapPreservesParams(t *testing.T) { + aliasMap := map[string]string{ + "INT": "INTEGER", + "VARCHAR": "CHARACTER VARYING", + } + + if got := NormalizeTypeWithMap("int", aliasMap); got != "INTEGER" { + t.Fatalf("expected INTEGER, got %q", got) + } + + if got := NormalizeTypeWithMap("varchar(100)", aliasMap); got != "CHARACTER VARYING(100)" { + t.Fatalf("expected CHARACTER VARYING(100), got %q", got) + } + + if got := NormalizeTypeWithMap("unknown(3)", aliasMap); got != "UNKNOWN(3)" { + t.Fatalf("expected UNKNOWN(3), got %q", got) + } +} diff --git a/dataflow/core/src/common/chat_baml.go b/dataflow/core/src/common/chat_baml.go new file mode 100644 index 0000000..6f23c59 --- /dev/null +++ b/dataflow/core/src/common/chat_baml.go @@ -0,0 +1,216 @@ +//go:build !arm && !riscv64 + +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package common + +import ( + "context" + + baml "github.com/boundaryml/baml/engine/language_client_go/pkg" + "github.com/clidey/whodb/core/baml_client" + "github.com/clidey/whodb/core/baml_client/types" + "github.com/clidey/whodb/core/src/engine" +) + +// RawExecutePlugin defines the interface for executing raw SQL queries +type RawExecutePlugin interface { + RawExecute(config *engine.PluginConfig, query string, params ...any) (*engine.GetRowsResult, error) +} + +// SQLChatBAML generates SQL queries using BAML for structured prompt engineering +// This replaces the old string-based prompt and JSON parsing approach +func SQLChatBAML( + ctx context.Context, + databaseType string, + schema string, + tableDetails string, + previousConversation string, + userQuery string, + config *engine.PluginConfig, + plugin RawExecutePlugin, +) ([]*engine.ChatMessage, error) { + + // Build BAML context + dbContext := types.DatabaseContext{ + Database_type: databaseType, + Schema: schema, + Tables_and_fields: tableDetails, + Previous_conversation: previousConversation, + } + + // Create dynamic BAML client and log request + callOpts := SetupAIClient(config.ExternalModel) + + // Call BAML function to generate SQL + responses, err := baml_client.GenerateSQLQuery(ctx, dbContext, userQuery, callOpts...) + if err != nil { + return nil, err + } + + // Convert BAML responses to WhoDB ChatMessage format + var chatMessages []*engine.ChatMessage + for _, bamlResp := range responses { + message := &engine.ChatMessage{ + Type: string(bamlResp.Type), + Text: bamlResp.Text, + Result: &engine.GetRowsResult{}, + RequiresConfirmation: false, + } + + // Convert BAML type to WhoDB type format + message.Type = convertBAMLTypeToWhoDB(bamlResp.Type) + + // Execute SQL if it's a query + if bamlResp.Type == types.ChatMessageTypeSQL && bamlResp.Operation != nil { + // Check if operation is a mutation that requires confirmation + isMutation := *bamlResp.Operation == types.OperationTypeINSERT || + *bamlResp.Operation == types.OperationTypeUPDATE || + *bamlResp.Operation == types.OperationTypeDELETE || + *bamlResp.Operation == types.OperationTypeCREATE || + *bamlResp.Operation == types.OperationTypeALTER || + *bamlResp.Operation == types.OperationTypeDROP + + if isMutation { + // Don't execute mutations immediately - require user confirmation + message.Type = convertOperationType(*bamlResp.Operation) + message.RequiresConfirmation = true + message.Result = nil + } else { + // Execute non-mutation queries (SELECT, etc.) immediately + result, execErr := plugin.RawExecute(config, bamlResp.Text) + if execErr != nil { + message.Type = "error" + message.Text = execErr.Error() + } else { + // Set operation-specific type + message.Type = convertOperationType(*bamlResp.Operation) + } + message.Result = result + } + } + + chatMessages = append(chatMessages, message) + } + + return chatMessages, nil +} + +// convertBAMLTypeToWhoDB converts BAML ChatMessageType to WhoDB message type string +func convertBAMLTypeToWhoDB(bamlType types.ChatMessageType) string { + switch bamlType { + case types.ChatMessageTypeSQL: + return "sql" + case types.ChatMessageTypeMESSAGE: + return "message" + case types.ChatMessageTypeERROR: + return "error" + default: + return "message" + } +} + +// convertOperationType converts BAML OperationType to WhoDB operation string +func convertOperationType(operation types.OperationType) string { + switch operation { + case types.OperationTypeGET: + return "sql:get" + case types.OperationTypeINSERT: + return "sql:insert" + case types.OperationTypeUPDATE: + return "sql:update" + case types.OperationTypeDELETE: + return "sql:delete" + case types.OperationTypeCREATE: + return "sql:create" + case types.OperationTypeALTER: + return "sql:alter" + case types.OperationTypeDROP: + return "sql:drop" + case types.OperationTypeTEXT: + return "text" + default: + return "sql" + } +} + +// BAMLConfigResolver resolves BAML provider string + options for a given provider type. +// Injected by the llm package at init time to use the provider registry. +type BAMLConfigResolver func(providerType, apiKey, endpoint, model string) (string, map[string]any, error) + +var bamlConfigResolver BAMLConfigResolver + +// RegisterBAMLConfigResolver sets the function used to resolve BAML config from the provider registry. +func RegisterBAMLConfigResolver(resolver BAMLConfigResolver) { + bamlConfigResolver = resolver +} + +// SetupAIClient creates the BAML client options for the given external model. +func SetupAIClient(externalModel *engine.ExternalModel) []baml_client.CallOptionFunc { + var callOpts []baml_client.CallOptionFunc + if externalModel == nil { + return callOpts + } + if externalModel.Model == "" { + return callOpts + } + + if registry := CreateDynamicBAMLClient(externalModel); registry != nil { + callOpts = append(callOpts, baml_client.WithClientRegistry(registry)) + } + + return callOpts +} + +// CreateDynamicBAMLClient creates a BAML ClientRegistry with a dynamically configured client +// based on the user's selected provider, model, API key, and endpoint. +// We register as "DefaultClient" to override the BAML function's explicit client reference. +func CreateDynamicBAMLClient(externalModel *engine.ExternalModel) *baml.ClientRegistry { + if externalModel == nil { + return nil + } + + registry := baml.NewClientRegistry() + + provider, opts := getBAMLProviderAndOptions(externalModel) + + // Register as "DefaultClient" to override the static client reference in BAML functions + registry.AddLlmClient("DefaultClient", provider, opts) + registry.SetPrimaryClient("DefaultClient") + + return registry +} + +// getBAMLProviderAndOptions maps WhoDB ExternalModel to BAML provider string and options. +// Delegates to the provider registry when available, falling back to openai-generic for unknown types. +func getBAMLProviderAndOptions(m *engine.ExternalModel) (string, map[string]any) { + if bamlConfigResolver != nil { + provider, opts, err := bamlConfigResolver(m.Type, m.Token, m.Endpoint, m.Model) + if err == nil { + return provider, opts + } + } + // Fallback for unregistered provider types + opts := map[string]any{"model": m.Model} + if m.Endpoint != "" { + opts["base_url"] = m.Endpoint + } + if m.Token != "" { + opts["api_key"] = m.Token + } + return "openai-generic", opts +} diff --git a/dataflow/core/src/common/chat_baml_unsupported.go b/dataflow/core/src/common/chat_baml_unsupported.go new file mode 100644 index 0000000..e59f314 --- /dev/null +++ b/dataflow/core/src/common/chat_baml_unsupported.go @@ -0,0 +1,68 @@ +//go:build arm || riscv64 + +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +// chat_baml_unsupported.go provides stub implementations for armv7 platforms +// where BAML is not supported. AI features will return user-friendly errors +// instead of crashing. + +package common + +import ( + "context" + "errors" + + "github.com/clidey/whodb/core/src/engine" +) + +// RawExecutePlugin defines the interface for executing raw SQL queries +type RawExecutePlugin interface { + RawExecute(config *engine.PluginConfig, query string, params ...any) (*engine.GetRowsResult, error) +} + +// ErrBAMLNotSupported is returned when AI features are used on unsupported platforms +var ErrBAMLNotSupported = errors.New("AI features are not supported on this platform (arm/riscv64). BAML requires amd64 or arm64 architecture") + +// SQLChatBAML returns an error on unsupported platforms +func SQLChatBAML( + ctx context.Context, + databaseType string, + schema string, + tableDetails string, + previousConversation string, + userQuery string, + config *engine.PluginConfig, + plugin RawExecutePlugin, +) ([]*engine.ChatMessage, error) { + return nil, ErrBAMLNotSupported +} + +// SetupAIClient returns nil on unsupported platforms +func SetupAIClient(externalModel *engine.ExternalModel) []any { + return nil +} + +// CreateDynamicBAMLClient returns nil on unsupported platforms +func CreateDynamicBAMLClient(externalModel *engine.ExternalModel) any { + return nil +} + +// BAMLConfigResolver resolves BAML provider string + options for a given provider type. +type BAMLConfigResolver func(providerType, apiKey, endpoint, model string) (string, map[string]any, error) + +// RegisterBAMLConfigResolver is a no-op on unsupported platforms. +func RegisterBAMLConfigResolver(resolver BAMLConfigResolver) {} diff --git a/dataflow/core/src/common/config/config.go b/dataflow/core/src/common/config/config.go new file mode 100644 index 0000000..02ef6ca --- /dev/null +++ b/dataflow/core/src/common/config/config.go @@ -0,0 +1,176 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +// Package config provides a unified configuration file for all WhoDB components. +// Each component (CLI, server, desktop) owns its own section of the config. +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + + "github.com/clidey/whodb/core/src/common/datadir" +) + +const configFileName = "config.json" + +// Section names for each component +const ( + SectionCLI = "cli" + SectionAWS = "aws" + SectionDesktop = "desktop" +) + +var ( + configPath string + configPathOnce sync.Once + configMu sync.RWMutex +) + +// RawConfig represents the entire config file as a map of sections. +// Each section is owned by a specific component. +type RawConfig map[string]json.RawMessage + +// getConfigPath returns the path to the unified config file. +func getConfigPath(opts datadir.Options) (string, error) { + var err error + configPathOnce.Do(func() { + var dir string + dir, err = datadir.Get(opts) + if err != nil { + return + } + configPath = filepath.Join(dir, configFileName) + }) + return configPath, err +} + +// GetPath returns the config file path for the given options. +// This is useful for displaying to users or debugging. +func GetPath(opts datadir.Options) (string, error) { + return getConfigPath(opts) +} + +// ReadSection reads a specific section from the config file into the provided value. +// If the section doesn't exist, the value is left unchanged. +// Returns error only for file read/parse errors, not for missing sections. +func ReadSection(section string, value any, opts datadir.Options) error { + configMu.RLock() + defer configMu.RUnlock() + + path, err := getConfigPath(opts) + if err != nil { + return err + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil // No config file yet, use defaults + } + return err + } + + var raw RawConfig + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + sectionData, exists := raw[section] + if !exists { + return nil // Section doesn't exist, use defaults + } + + return json.Unmarshal(sectionData, value) +} + +// WriteSection writes a specific section to the config file, preserving other sections. +// Uses atomic write (temp file + rename) to prevent corruption. +func WriteSection(section string, value any, opts datadir.Options) error { + configMu.Lock() + defer configMu.Unlock() + + path, err := getConfigPath(opts) + if err != nil { + return err + } + + // Read existing config (or start fresh) + var raw RawConfig + data, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) { + return err + } + raw = make(RawConfig) + } else { + if err := json.Unmarshal(data, &raw); err != nil { + // If existing file is corrupted, start fresh + raw = make(RawConfig) + } + } + + // Marshal the section value + sectionData, err := json.Marshal(value) + if err != nil { + return err + } + + // Update the section + raw[section] = sectionData + + // Marshal the entire config with pretty printing + newData, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return err + } + + // Atomic write: write to temp file, then rename + dir := filepath.Dir(path) + tempFile, err := os.CreateTemp(dir, ".config-*.tmp") + if err != nil { + return err + } + tempPath := tempFile.Name() + + // Write data and close + if _, err := tempFile.Write(newData); err != nil { + tempFile.Close() + os.Remove(tempPath) + return err + } + if err := tempFile.Close(); err != nil { + os.Remove(tempPath) + return err + } + + // Atomic rename + if err := os.Rename(tempPath, path); err != nil { + os.Remove(tempPath) + return err + } + + return nil +} + +// ResetConfigPath resets the cached config path. +// This is only for testing - do not use in production code. +func ResetConfigPath() { + configPathOnce = sync.Once{} + configPath = "" +} diff --git a/dataflow/core/src/common/config/config_test.go b/dataflow/core/src/common/config/config_test.go new file mode 100644 index 0000000..c5697eb --- /dev/null +++ b/dataflow/core/src/common/config/config_test.go @@ -0,0 +1,282 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/clidey/whodb/core/src/common/datadir" +) + +// testOptions returns datadir options pointing to a temp directory. +func setupTestDir(t *testing.T) (datadir.Options, func()) { + t.Helper() + + tempDir := t.TempDir() + oldXDG := os.Getenv("XDG_DATA_HOME") + os.Setenv("XDG_DATA_HOME", tempDir) + ResetConfigPath() + + cleanup := func() { + os.Setenv("XDG_DATA_HOME", oldXDG) + ResetConfigPath() + } + + return datadir.Options{AppName: "whodb-test"}, cleanup +} + +func TestWriteSection_CreatesConfigFile(t *testing.T) { + opts, cleanup := setupTestDir(t) + defer cleanup() + + type TestSection struct { + Name string `json:"name"` + Value int `json:"value"` + } + + section := TestSection{Name: "test", Value: 42} + err := WriteSection("test", section, opts) + if err != nil { + t.Fatalf("WriteSection failed: %v", err) + } + + // Verify the file was created + path, err := GetPath(opts) + if err != nil { + t.Fatalf("GetPath failed: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read config file: %v", err) + } + + var raw RawConfig + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("failed to unmarshal config: %v", err) + } + + if _, exists := raw["test"]; !exists { + t.Error("test section should exist in config") + } +} + +func TestWriteSection_PreservesOtherSections(t *testing.T) { + opts, cleanup := setupTestDir(t) + defer cleanup() + + type Section struct { + Data string `json:"data"` + } + + // Write first section + section1 := Section{Data: "first"} + if err := WriteSection("section1", section1, opts); err != nil { + t.Fatalf("WriteSection (1) failed: %v", err) + } + + // Write second section + section2 := Section{Data: "second"} + if err := WriteSection("section2", section2, opts); err != nil { + t.Fatalf("WriteSection (2) failed: %v", err) + } + + // Verify both sections exist + var read1, read2 Section + if err := ReadSection("section1", &read1, opts); err != nil { + t.Fatalf("ReadSection (1) failed: %v", err) + } + if err := ReadSection("section2", &read2, opts); err != nil { + t.Fatalf("ReadSection (2) failed: %v", err) + } + + if read1.Data != "first" { + t.Errorf("expected section1 data 'first', got '%s'", read1.Data) + } + if read2.Data != "second" { + t.Errorf("expected section2 data 'second', got '%s'", read2.Data) + } +} + +func TestReadSection_MissingFile(t *testing.T) { + opts, cleanup := setupTestDir(t) + defer cleanup() + + type Section struct { + Data string `json:"data"` + } + + var section Section + section.Data = "default" // Set a default + + // Reading from non-existent file should not error and leave default + err := ReadSection("test", §ion, opts) + if err != nil { + t.Fatalf("ReadSection should not error for missing file: %v", err) + } + + if section.Data != "default" { + t.Errorf("expected default value to be preserved, got '%s'", section.Data) + } +} + +func TestReadSection_MissingSection(t *testing.T) { + opts, cleanup := setupTestDir(t) + defer cleanup() + + type Section struct { + Data string `json:"data"` + } + + // Create a config with one section + section1 := Section{Data: "exists"} + if err := WriteSection("section1", section1, opts); err != nil { + t.Fatalf("WriteSection failed: %v", err) + } + + // Try to read a different section + var section2 Section + section2.Data = "default" + + err := ReadSection("nonexistent", §ion2, opts) + if err != nil { + t.Fatalf("ReadSection should not error for missing section: %v", err) + } + + if section2.Data != "default" { + t.Errorf("expected default value to be preserved, got '%s'", section2.Data) + } +} + +func TestReadSection_CorruptedJSON(t *testing.T) { + opts, cleanup := setupTestDir(t) + defer cleanup() + + // Get the config path and create a corrupted file + path, err := GetPath(opts) + if err != nil { + t.Fatalf("GetPath failed: %v", err) + } + + // Create the directory + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("failed to create dir: %v", err) + } + + // Write corrupted JSON + if err := os.WriteFile(path, []byte("{invalid json"), 0644); err != nil { + t.Fatalf("failed to write corrupted config: %v", err) + } + + type Section struct { + Data string `json:"data"` + } + + var section Section + err = ReadSection("test", §ion, opts) + if err == nil { + t.Error("ReadSection should error for corrupted JSON") + } +} + +func TestWriteSection_AtomicWrite(t *testing.T) { + opts, cleanup := setupTestDir(t) + defer cleanup() + + type Section struct { + Data string `json:"data"` + } + + // Write initial data + section := Section{Data: "initial"} + if err := WriteSection("test", section, opts); err != nil { + t.Fatalf("WriteSection failed: %v", err) + } + + // Update the data + section.Data = "updated" + if err := WriteSection("test", section, opts); err != nil { + t.Fatalf("WriteSection (update) failed: %v", err) + } + + // Read back + var read Section + if err := ReadSection("test", &read, opts); err != nil { + t.Fatalf("ReadSection failed: %v", err) + } + + if read.Data != "updated" { + t.Errorf("expected 'updated', got '%s'", read.Data) + } + + // Verify no temp files left behind + path, _ := GetPath(opts) + dir := filepath.Dir(path) + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("failed to read dir: %v", err) + } + + for _, entry := range entries { + if filepath.Ext(entry.Name()) == ".tmp" { + t.Errorf("temp file should not exist: %s", entry.Name()) + } + } +} + +func TestRawConfig(t *testing.T) { + // Test that RawConfig is a map of json.RawMessage + raw := make(RawConfig) + raw["test"] = json.RawMessage(`{"key":"value"}`) + + data, err := json.Marshal(raw) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var parsed RawConfig + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + // Verify the key exists and can be parsed + var inner struct { + Key string `json:"key"` + } + if err := json.Unmarshal(parsed["test"], &inner); err != nil { + t.Fatalf("failed to unmarshal inner: %v", err) + } + if inner.Key != "value" { + t.Errorf("expected key='value', got key='%s'", inner.Key) + } +} + +func TestSectionConstants(t *testing.T) { + // Verify section constants are defined + if SectionCLI != "cli" { + t.Errorf("expected SectionCLI to be 'cli', got '%s'", SectionCLI) + } + if SectionAWS != "aws" { + t.Errorf("expected SectionAWS to be 'aws', got '%s'", SectionAWS) + } + if SectionDesktop != "desktop" { + t.Errorf("expected SectionDesktop to be 'desktop', got '%s'", SectionDesktop) + } +} diff --git a/dataflow/core/src/common/constraint_validation.go b/dataflow/core/src/common/constraint_validation.go new file mode 100644 index 0000000..eb43c43 --- /dev/null +++ b/dataflow/core/src/common/constraint_validation.go @@ -0,0 +1,90 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package common + +import ( + "regexp" + "strings" +) + +// ValidateColumnName validates that a column name is safe and doesn't contain SQL injection attempts +func ValidateColumnName(columnName string) bool { + // Column names should only contain alphanumeric characters, underscores, and not start with a number + validColumnPattern := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + + // Check for basic pattern + if !validColumnPattern.MatchString(columnName) { + return false + } + + // Check for SQL keywords that should never appear in column names + sqlKeywords := []string{ + "SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", + "EXEC", "EXECUTE", "UNION", "GRANT", "REVOKE", "TRUNCATE", + "MERGE", "CALL", "EXPLAIN", "LOCK", "COMMENT", "COMMIT", "ROLLBACK", + "SAVEPOINT", "SET", "SHOW", "--", "/*", "*/", ";", + } + + upperColumn := strings.ToUpper(columnName) + for _, keyword := range sqlKeywords { + if strings.Contains(upperColumn, keyword) { + return false + } + } + + // Reasonable length limit for column names + if len(columnName) > 128 { + return false + } + + return true +} + +// SanitizeConstraintValue sanitizes a single constraint value to prevent SQL injection +func SanitizeConstraintValue(value string) (string, bool) { + // Remove any SQL comment indicators + if strings.Contains(value, "--") || strings.Contains(value, "/*") || strings.Contains(value, "*/") { + return "", false + } + + // Remove any semicolons which could be used to terminate statements + if strings.Contains(value, ";") { + return "", false + } + + // Check for dangerous SQL keywords + dangerousKeywords := []string{ + "DROP", "DELETE", "TRUNCATE", "EXEC", "EXECUTE", + "CREATE", "ALTER", "GRANT", "REVOKE", + "UNION", "INSERT", "UPDATE", + } + + upperValue := strings.ToUpper(value) + for _, keyword := range dangerousKeywords { + // Check for whole word matches to avoid false positives + if regexp.MustCompile(`\b` + keyword + `\b`).MatchString(upperValue) { + return "", false + } + } + + // Limit value length to prevent buffer overflow attacks + if len(value) > 1000 { + return "", false + } + + return value, true +} diff --git a/dataflow/core/src/common/context.go b/dataflow/core/src/common/context.go new file mode 100644 index 0000000..4e922bb --- /dev/null +++ b/dataflow/core/src/common/context.go @@ -0,0 +1,21 @@ +// Copyright 2025 Clidey, Inc. +// +// 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. + +package common + +type RouterKey string + +const ( + RouterKey_ResponseWriter RouterKey = "ResponseWriter" +) diff --git a/dataflow/core/src/common/csv.go b/dataflow/core/src/common/csv.go new file mode 100644 index 0000000..cdbe0d8 --- /dev/null +++ b/dataflow/core/src/common/csv.go @@ -0,0 +1,38 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package common + +// EscapeFormula escapes values that could be interpreted as formulas in spreadsheet applications +func EscapeFormula(value string) string { + if len(value) == 0 { + return value + } + + // Check if the first character is a formula indicator + firstChar := value[0] + if firstChar == '=' || firstChar == '+' || firstChar == '-' || firstChar == '@' || firstChar == '\t' || firstChar == '\r' { + // Prefix with single quote to prevent formula execution + return "'" + value + } + + return value +} + +// FormatCSVHeader creates a header with the column name. +func FormatCSVHeader(columnName, dataType string) string { + return columnName +} diff --git a/dataflow/core/src/common/datadir/datadir.go b/dataflow/core/src/common/datadir/datadir.go new file mode 100644 index 0000000..ccef924 --- /dev/null +++ b/dataflow/core/src/common/datadir/datadir.go @@ -0,0 +1,106 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package datadir + +import ( + "os" + "path/filepath" + "runtime" +) + +// Options configures the data directory path. +type Options struct { + // AppName is the base application name (e.g., "whodb"). + AppName string + // EnterpriseEdition appends "-ee" to distinguish EE data. + EnterpriseEdition bool + // Development appends "-dev" to distinguish dev data. + Development bool +} + +// Get returns the data directory path for the given options. +// The directory is created if it doesn't exist. +// +// Paths by OS: +// - Linux: $XDG_DATA_HOME// or ~/.local/share// +// - macOS: ~/Library/Application Support// +// - Windows: %APPDATA%\\ +func Get(opts Options) (string, error) { + if opts.AppName == "" { + opts.AppName = "whodb" + } + + base, err := getBaseDir() + if err != nil { + return "", err + } + + // Build app name with suffixes + appName := opts.AppName + if opts.EnterpriseEdition { + appName += "-ee" + } + if opts.Development { + appName += "-dev" + } + + dataDir := filepath.Join(base, appName) + + // Create directory if it doesn't exist + if err := os.MkdirAll(dataDir, 0700); err != nil { + return "", err + } + + return dataDir, nil +} + +// getBaseDir returns the base data directory for the current OS. +func getBaseDir() (string, error) { + switch runtime.GOOS { + case "windows": + // %APPDATA% (e.g., C:\Users\\AppData\Roaming) + if appData := os.Getenv("APPDATA"); appData != "" { + return appData, nil + } + // Fallback + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "AppData", "Roaming"), nil + + case "darwin": + // ~/Library/Application Support + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "Application Support"), nil + + default: + // Linux and others: XDG Base Directory spec + if xdgData := os.Getenv("XDG_DATA_HOME"); xdgData != "" { + return xdgData, nil + } + // Default: ~/.local/share + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "share"), nil + } +} diff --git a/dataflow/core/src/common/datadir/datadir_test.go b/dataflow/core/src/common/datadir/datadir_test.go new file mode 100644 index 0000000..297dea2 --- /dev/null +++ b/dataflow/core/src/common/datadir/datadir_test.go @@ -0,0 +1,74 @@ +package datadir + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestGetCreatesDirectoryWithExpectedBase(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + t.Setenv("USERPROFILE", tempHome) + + // Prefer an explicit base directory when supported by the platform. + // - Linux/other: uses XDG_DATA_HOME if set. + // - Windows: uses APPDATA if set. + // - macOS: always uses ~/Library/Application Support (so HOME override is used). + tempBase := t.TempDir() + t.Setenv("XDG_DATA_HOME", tempBase) + t.Setenv("APPDATA", tempBase) + + opts := Options{AppName: "whodb-test"} + dir, err := Get(opts) + if err != nil { + t.Fatalf("Get returned error: %v", err) + } + + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("expected directory to exist: %v", err) + } + if !info.IsDir() { + t.Fatalf("expected %q to be a directory", dir) + } + + switch runtime.GOOS { + case "darwin": + expected := filepath.Join(tempHome, "Library", "Application Support", "whodb-test") + if dir != expected { + t.Fatalf("expected %q, got %q", expected, dir) + } + case "windows": + expected := filepath.Join(tempBase, "whodb-test") + if dir != expected { + t.Fatalf("expected %q, got %q", expected, dir) + } + default: + expected := filepath.Join(tempBase, "whodb-test") + if dir != expected { + t.Fatalf("expected %q, got %q", expected, dir) + } + } +} + +func TestGetAppliesSuffixes(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + t.Setenv("USERPROFILE", tempHome) + + dir, err := Get(Options{ + AppName: "whodb-test", + EnterpriseEdition: true, + Development: true, + }) + if err != nil { + t.Fatalf("Get returned error: %v", err) + } + + if !strings.HasSuffix(dir, "whodb-test-ee-dev") { + t.Fatalf("expected suffix whodb-test-ee-dev, got %q", dir) + } +} diff --git a/dataflow/core/src/common/dbtypes.go b/dataflow/core/src/common/dbtypes.go new file mode 100644 index 0000000..e34b205 --- /dev/null +++ b/dataflow/core/src/common/dbtypes.go @@ -0,0 +1,120 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package common + +import ( + mapset "github.com/deckarep/golang-set/v2" +) + +// Shared database data type classifications across the backend. +// These group engine-visible SQL types into coarse categories. +// +// IMPORTANT: These sets contain CANONICAL types only - no aliases. +// Types are normalized via per-plugin NormalizeType() before classification. +// Each canonical type listed here is the official form for at least one database. +var ( + IntTypes = mapset.NewSet( + "INTEGER", "SMALLINT", "BIGINT", "INT", "TINYINT", "MEDIUMINT", + "INT8", "INT16", "INT32", "INT64", + "SERIAL", "BIGSERIAL", "SMALLSERIAL", + ) + + UintTypes = mapset.NewSet( + "TINYINT UNSIGNED", "SMALLINT UNSIGNED", "MEDIUMINT UNSIGNED", + "INT UNSIGNED", "BIGINT UNSIGNED", + "UINT8", "UINT16", "UINT32", "UINT64", + ) + + BigIntTypes = mapset.NewSet( + "INT128", "INT256", + "UINT128", "UINT256", + ) + + DecimalTypes = mapset.NewSet( + "NUMERIC", "DECIMAL", "NUMBER", "MONEY", "SMALLMONEY", + "DECIMAL32", "DECIMAL64", "DECIMAL128", "DECIMAL256", + ) + + FloatTypes = mapset.NewSet( + "REAL", "DOUBLE PRECISION", + "FLOAT", "DOUBLE", + "FLOAT32", "FLOAT64", + "BINARY_FLOAT", "BINARY_DOUBLE", + ) + + BoolTypes = mapset.NewSet( + "BOOLEAN", + "BOOL", + "BIT", + ) + + DateTypes = mapset.NewSet( + "DATE", + "DATE32", + ) + + DateTimeTypes = mapset.NewSet( + "TIMESTAMP", "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITH LOCAL TIME ZONE", + "TIME", "TIME WITH TIME ZONE", + "DATETIME", "YEAR", + "DATETIME2", "SMALLDATETIME", "DATETIMEOFFSET", + "INTERVAL", "INTERVAL YEAR TO MONTH", "INTERVAL DAY TO SECOND", + "DATETIME64", + ) + + UuidTypes = mapset.NewSet( + "UUID", + "UNIQUEIDENTIFIER", + ) + + TextTypes = mapset.NewSet( + "CHARACTER VARYING", "CHARACTER", "TEXT", + "VARCHAR", "CHAR", + "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", + "STRING", "FIXEDSTRING", + "NVARCHAR", "NCHAR", "NTEXT", + "VARCHAR2", "NVARCHAR2", "CLOB", "NCLOB", "LONG", + ) + + JsonTypes = mapset.NewSet( + "JSON", + "JSONB", + ) + + BinaryTypes = mapset.NewSet( + "BYTEA", + "BINARY", "VARBINARY", + "TINYBLOB", "BLOB", "MEDIUMBLOB", "LONGBLOB", + "IMAGE", + "RAW", "LONG RAW", "BFILE", + ) + + GeometryTypes = mapset.NewSet( + "POINT", "LINE", "LSEG", "BOX", "PATH", "POLYGON", "CIRCLE", + "GEOMETRY", "GEOGRAPHY", + ) + + NetworkTypes = mapset.NewSet( + "CIDR", "INET", "MACADDR", "MACADDR8", + "IPV4", "IPV6", + ) + + XMLTypes = mapset.NewSet( + "XML", + "XMLTYPE", + ) +) diff --git a/dataflow/core/src/common/utils.go b/dataflow/core/src/common/utils.go new file mode 100644 index 0000000..e7607ff --- /dev/null +++ b/dataflow/core/src/common/utils.go @@ -0,0 +1,133 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package common + +import ( + "fmt" + "net/url" + "os" + "strconv" + "strings" + + "github.com/clidey/whodb/core/src/engine" +) + +// GetRecordValueOrDefault searches for a key in a slice of Records and returns its value, +// or the provided default if the key is not found or has an empty value. +func GetRecordValueOrDefault(records []engine.Record, key string, defaultValue string) string { + for _, record := range records { + if record.Key == key && len(record.Value) > 0 { + return record.Value + } + } + return defaultValue +} + +// IsRunningInsideDocker detects if the current process is running inside a Docker container +// by checking for the presence of the /.dockerenv file. +func IsRunningInsideDocker() bool { + _, err := os.Stat("/.dockerenv") + return !os.IsNotExist(err) +} + +// IsRunningInsideWSL2 detects if the current process is running inside WSL2 +// by checking for "microsoft" or "WSL" in /proc/version. +func IsRunningInsideWSL2() bool { + data, err := os.ReadFile("/proc/version") + if err != nil { + return false + } + version := strings.ToLower(string(data)) + return strings.Contains(version, "microsoft") || strings.Contains(version, "wsl") +} + +// GetWSL2WindowsHost returns the Windows host IP from inside WSL2 +// by reading the default gateway from /proc/net/route. In WSL2, the +// default gateway is the Windows host. This is a file read only — +// no command execution. +func GetWSL2WindowsHost() string { + data, err := os.ReadFile("/proc/net/route") + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 3 || fields[1] != "00000000" { + continue + } + // Default route found — parse gateway from hex to IP + gw, err := strconv.ParseUint(fields[2], 16, 32) + if err != nil { + return "" + } + return fmt.Sprintf("%d.%d.%d.%d", gw&0xFF, (gw>>8)&0xFF, (gw>>16)&0xFF, (gw>>24)&0xFF) + } + return "" +} + +// replaceHost replaces the hostname portion of a host string (which may include a port) +// while preserving the port. For example, replaceHost("localhost:1234", "localhost", "172.1.2.3") +// returns "172.1.2.3:1234". +func replaceHost(hostWithPort, oldHost, newHost string) string { + return strings.Replace(hostWithPort, oldHost, newHost, 1) +} + +// ResolveLocalURL rewrites a URL targeting localhost or 127.0.0.1 so it reaches +// the host machine when running inside Docker or WSL2. Non-localhost URLs are +// returned unchanged. +func ResolveLocalURL(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + hostname := parsed.Hostname() + if hostname != "localhost" && hostname != "127.0.0.1" { + return rawURL + } + if IsRunningInsideDocker() { + parsed.Host = replaceHost(parsed.Host, hostname, "host.docker.internal") + return parsed.String() + } + if IsRunningInsideWSL2() { + if wslHost := GetWSL2WindowsHost(); wslHost != "" { + parsed.Host = replaceHost(parsed.Host, hostname, wslHost) + return parsed.String() + } + } + return rawURL +} + +// FilterList returns a new slice containing only the elements for which the predicate returns true. +func FilterList[T any](items []T, by func(input T) bool) []T { + filteredItems := []T{} + for _, item := range items { + if by(item) { + filteredItems = append(filteredItems, item) + } + } + return filteredItems +} + +// StrPtrToBool converts a string pointer to a boolean. +// Returns true if the string value is "true" (case-insensitive), false otherwise or if nil. +func StrPtrToBool(s *string) bool { + if s == nil { + return false + } + value := strings.ToLower(*s) + return value == "true" +} diff --git a/dataflow/core/src/common/utils_test.go b/dataflow/core/src/common/utils_test.go new file mode 100644 index 0000000..500c28f --- /dev/null +++ b/dataflow/core/src/common/utils_test.go @@ -0,0 +1,170 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package common + +import ( + "strings" + "testing" + + "github.com/clidey/whodb/core/src/engine" +) + +func TestValidateColumnName(t *testing.T) { + cases := []struct { + name string + input string + expected bool + }{ + {name: "simple", input: "user_id", expected: true}, + {name: "starts with number", input: "1field", expected: false}, + {name: "contains keyword", input: "drop_table", expected: false}, + {name: "contains dash", input: "first-name", expected: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ValidateColumnName(tc.input); got != tc.expected { + t.Fatalf("ValidateColumnName(%s) = %v, expected %v", tc.input, got, tc.expected) + } + }) + } +} + +func TestSanitizeConstraintValue(t *testing.T) { + cases := []struct { + name string + input string + expectedOK bool + expectedText string + }{ + {name: "safe value", input: "active", expectedOK: true, expectedText: "active"}, + {name: "contains drop", input: "DROP TABLE users", expectedOK: false}, + {name: "contains comment", input: "value -- comment", expectedOK: false}, + {name: "contains semicolon", input: "abc;", expectedOK: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := SanitizeConstraintValue(tc.input) + if ok != tc.expectedOK { + t.Fatalf("SanitizeConstraintValue(%s) ok=%v expected %v", tc.input, ok, tc.expectedOK) + } + if ok && got != tc.expectedText { + t.Fatalf("expected sanitized value %s, got %s", tc.expectedText, got) + } + }) + } +} + +func TestUtilityHelpers(t *testing.T) { + if got := EscapeFormula("=SUM(A1)"); got != "'=SUM(A1)" { + t.Fatalf("expected formula to be escaped, got %s", got) + } + + if header := FormatCSVHeader("col", "text"); header != "col" { + t.Fatalf("unexpected csv header: %s", header) + } + + records := []engine.Record{ + {Key: "mode", Value: "readonly"}, + } + if val := GetRecordValueOrDefault(records, "mode", "rw"); val != "readonly" { + t.Fatalf("expected existing record value to be returned") + } + if val := GetRecordValueOrDefault(records, "missing", "fallback"); val != "fallback" { + t.Fatalf("expected fallback to be returned when key missing") + } + + filtered := FilterList([]int{1, 2, 3, 4}, func(v int) bool { return v%2 == 0 }) + if len(filtered) != 2 || filtered[0] != 2 || filtered[1] != 4 { + t.Fatalf("FilterList did not filter even numbers correctly: %#v", filtered) + } + + trueStr := "true" + falseStr := "False" + if !StrPtrToBool(&trueStr) { + t.Fatalf("true string should convert to true") + } + if StrPtrToBool(&falseStr) { + t.Fatalf("false string should convert to false") + } + if StrPtrToBool(nil) { + t.Fatalf("nil pointer should convert to false") + } +} + +func TestResolveLocalURL(t *testing.T) { + // Non-localhost URLs should always pass through unchanged + nonLocal := []string{ + "https://api.openai.com/v1", + "http://my-server:8080/api", + "http://192.168.1.100:1234/v1", + "https://example.com", + } + for _, u := range nonLocal { + if got := ResolveLocalURL(u); got != u { + t.Fatalf("ResolveLocalURL(%q) = %q, want unchanged", u, got) + } + } + + // Invalid URLs should pass through unchanged + if got := ResolveLocalURL("://bad"); got != "://bad" { + t.Fatalf("ResolveLocalURL with invalid URL should return unchanged, got %q", got) + } + + // Localhost URLs: verify that the host is rewritten in Docker/WSL2, + // or unchanged when running natively. + localhostCases := []struct { + name string + input string + }{ + {"with port and path", "http://localhost:1234/v1"}, + {"without port", "http://localhost/api"}, + {"127.0.0.1 with port", "http://127.0.0.1:8080/v1/chat"}, + {"127.0.0.1 without port", "http://127.0.0.1/test"}, + } + + inDocker := IsRunningInsideDocker() + inWSL2 := IsRunningInsideWSL2() + + for _, tc := range localhostCases { + t.Run(tc.name, func(t *testing.T) { + got := ResolveLocalURL(tc.input) + if inDocker { + if !strings.Contains(got, "host.docker.internal") { + t.Fatalf("expected Docker rewrite, got %q", got) + } + } else if inWSL2 { + if wslHost := GetWSL2WindowsHost(); wslHost != "" { + if !strings.Contains(got, wslHost) { + t.Fatalf("expected WSL2 rewrite to %s, got %q", wslHost, got) + } + } + } else { + if got != tc.input { + t.Fatalf("outside Docker/WSL2, expected unchanged, got %q", got) + } + } + }) + } + + // Port preservation: the path and port must survive the rewrite + got := ResolveLocalURL("http://localhost:1234/v1/chat/completions") + if !strings.Contains(got, ":1234/v1/chat/completions") { + t.Fatalf("port and path not preserved: %q", got) + } +} diff --git a/dataflow/core/src/dashboard/models.go b/dataflow/core/src/dashboard/models.go new file mode 100644 index 0000000..bc21340 --- /dev/null +++ b/dataflow/core/src/dashboard/models.go @@ -0,0 +1,42 @@ +package dashboard + +import ( + "encoding/json" + "time" +) + +type Dashboard struct { + ID string `gorm:"primaryKey"` + ScopeKey string `gorm:"column:scope_key;index;not null"` + Name string `gorm:"not null"` + Description *string + RefreshRule string `gorm:"column:refresh_rule;not null;default:on-demand"` + Widgets []Widget `gorm:"foreignKey:DashboardID;constraint:OnDelete:CASCADE"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` +} + +type Widget struct { + ID string `gorm:"primaryKey"` + DashboardID string `gorm:"column:dashboard_id;index;not null"` + Dashboard Dashboard `gorm:"foreignKey:DashboardID"` + Type string `gorm:"not null"` + Title string `gorm:"not null"` + Description *string + Layout json.RawMessage `gorm:"type:jsonb;not null"` + Query *string + QueryContext json.RawMessage `gorm:"column:query_context;type:jsonb"` + Visualization json.RawMessage `gorm:"type:jsonb"` + Snapshot json.RawMessage `gorm:"type:jsonb"` + SortOrder int `gorm:"column:sort_order;not null;default:0"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` +} + +func (*Dashboard) TableName() string { + return "dashboards" +} + +func (*Widget) TableName() string { + return "widgets" +} diff --git a/dataflow/core/src/dashboard/repository.go b/dataflow/core/src/dashboard/repository.go new file mode 100644 index 0000000..f4430ec --- /dev/null +++ b/dataflow/core/src/dashboard/repository.go @@ -0,0 +1,111 @@ +package dashboard + +import ( + "context" + "errors" + "fmt" + "time" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) (*Repository, error) { + if db == nil { + return nil, errors.New("missing metadata database") + } + + repo := &Repository{db: db} + if err := repo.db.AutoMigrate(&Dashboard{}, &Widget{}); err != nil { + return nil, fmt.Errorf("auto-migrate dashboard metadata: %w", err) + } + + return repo, nil +} + +func (r *Repository) ListDashboardsByScope(ctx context.Context, scopeKey string) ([]Dashboard, error) { + var dashboards []Dashboard + err := r.db.WithContext(ctx). + Preload("Widgets", func(tx *gorm.DB) *gorm.DB { + return tx.Order("sort_order ASC").Order("created_at ASC") + }). + Where("scope_key = ?", scopeKey). + Order("created_at DESC"). + Find(&dashboards).Error + if err != nil { + return nil, err + } + + return dashboards, nil +} + +func (r *Repository) CreateDashboard(ctx context.Context, dashboard *Dashboard) error { + return r.db.WithContext(ctx).Create(dashboard).Error +} + +func (r *Repository) GetDashboard(ctx context.Context, id string) (*Dashboard, error) { + var dashboard Dashboard + err := r.db.WithContext(ctx). + Preload("Widgets", func(tx *gorm.DB) *gorm.DB { + return tx.Order("sort_order ASC").Order("created_at ASC") + }). + First(&dashboard, "id = ?", id).Error + if err != nil { + return nil, err + } + return &dashboard, nil +} + +func (r *Repository) SaveDashboard(ctx context.Context, dashboard *Dashboard) error { + return r.db.WithContext(ctx).Save(dashboard).Error +} + +func (r *Repository) DeleteDashboard(ctx context.Context, id string) error { + return r.db.WithContext(ctx).Delete(&Dashboard{}, "id = ?", id).Error +} + +func (r *Repository) CreateWidget(ctx context.Context, widget *Widget) error { + return r.db.WithContext(ctx).Create(widget).Error +} + +func (r *Repository) GetWidget(ctx context.Context, id string) (*Widget, error) { + var widget Widget + err := r.db.WithContext(ctx). + Preload("Dashboard"). + First(&widget, "id = ?", id).Error + if err != nil { + return nil, err + } + return &widget, nil +} + +func (r *Repository) SaveWidget(ctx context.Context, widget *Widget) error { + return r.db.WithContext(ctx).Save(widget).Error +} + +func (r *Repository) DeleteWidget(ctx context.Context, id string) error { + return r.db.WithContext(ctx).Delete(&Widget{}, "id = ?", id).Error +} + +func (r *Repository) UpdateWidgetLayouts(ctx context.Context, dashboardID string, layouts []LayoutInput) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for index, layout := range layouts { + if err := tx.Model(&Widget{}). + Where("id = ? AND dashboard_id = ?", layout.WidgetID, dashboardID). + Updates(map[string]any{ + "layout": []byte(layout.Layout), + "sort_order": index, + "updated_at": time.Now().UTC(), + }).Error; err != nil { + return err + } + } + + return tx.Model(&Dashboard{}). + Where("id = ?", dashboardID). + Update("updated_at", time.Now().UTC()).Error + }) +} diff --git a/dataflow/core/src/dashboard/scope.go b/dataflow/core/src/dashboard/scope.go new file mode 100644 index 0000000..a1e34ec --- /dev/null +++ b/dataflow/core/src/dashboard/scope.go @@ -0,0 +1,30 @@ +package dashboard + +import ( + "context" + "errors" + "strings" + + "github.com/clidey/whodb/core/src/auth" + "github.com/clidey/whodb/core/src/engine" +) + +func ScopeKeyFromContext(ctx context.Context) (string, error) { + return ScopeKeyFromCredentials(auth.GetCredentials(ctx)) +} + +func ScopeKeyFromCredentials(creds *engine.Credentials) (string, error) { + if creds == nil { + return "", errors.New("missing credentials") + } + + port := "" + for _, record := range creds.Advanced { + if record.Key == "Port" { + port = record.Value + break + } + } + + return strings.Join([]string{creds.Type, creds.Hostname, port, creds.Database}, ":"), nil +} diff --git a/dataflow/core/src/dashboard/scope_test.go b/dataflow/core/src/dashboard/scope_test.go new file mode 100644 index 0000000..0dc543d --- /dev/null +++ b/dataflow/core/src/dashboard/scope_test.go @@ -0,0 +1,25 @@ +package dashboard + +import ( + "testing" + + "github.com/clidey/whodb/core/src/engine" +) + +func TestScopeKeyFromCredentials(t *testing.T) { + creds := &engine.Credentials{ + Type: "Postgres", + Hostname: "db.internal", + Database: "analytics", + Advanced: []engine.Record{{Key: "Port", Value: "5432"}}, + } + + got, err := ScopeKeyFromCredentials(creds) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if got != "Postgres:db.internal:5432:analytics" { + t.Fatalf("unexpected scope key %q", got) + } +} diff --git a/dataflow/core/src/dashboard/service.go b/dataflow/core/src/dashboard/service.go new file mode 100644 index 0000000..f0d5e70 --- /dev/null +++ b/dataflow/core/src/dashboard/service.go @@ -0,0 +1,443 @@ +package dashboard + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "github.com/clidey/whodb/core/src/plugins" + "github.com/google/uuid" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +const ( + RefreshRuleOnDemand = "on-demand" + RefreshRuleByMinute = "by-minute" + WidgetTypeChart = "chart" +) + +var ( + ErrMetadataStoreNotConfigured = errors.New("dashboard metadata store is not configured; set WHODB_METADATA_DSN") + ErrDashboardNotFound = errors.New("dashboard not found") + ErrWidgetNotFound = errors.New("widget not found") + ErrInvalidRefreshRule = errors.New("invalid refresh rule") + ErrInvalidWidgetType = errors.New("invalid widget type") + ErrInvalidJSONPayload = errors.New("invalid json payload") + ErrScopeMismatch = errors.New("dashboard or widget is outside the current scope") +) + +type ServiceAPI interface { + GetDashboards(ctx context.Context) ([]Dashboard, error) + CreateDashboard(ctx context.Context, params CreateDashboardParams) (*Dashboard, error) + UpdateDashboard(ctx context.Context, id string, params UpdateDashboardParams) (*Dashboard, error) + DeleteDashboard(ctx context.Context, id string) error + AddWidget(ctx context.Context, dashboardID string, input WidgetInput) (*Widget, error) + UpdateWidget(ctx context.Context, id string, input UpdateWidgetInput) (*Widget, error) + DeleteWidget(ctx context.Context, id string) error + UpdateWidgetLayouts(ctx context.Context, dashboardID string, layouts []LayoutInput) error + UpdateWidgetSnapshot(ctx context.Context, id string, snapshot SnapshotInput) error +} + +type Service struct { + repo *Repository +} + +type CreateDashboardParams struct { + Name string + Description *string + RefreshRule string +} + +type UpdateDashboardParams struct { + Name *string + Description *string + RefreshRule *string +} + +type WidgetInput struct { + Type string + Title string + Description *string + Layout string + Query *string + QueryContext *string + Visualization *string + Snapshot *string + SortOrder *int +} + +type UpdateWidgetInput struct { + Title *string + Description *string + Layout *string + Query *string + QueryContext *string + Visualization *string + Snapshot *string + SortOrder *int +} + +type LayoutInput struct { + WidgetID string + Layout string +} + +type SnapshotInput struct { + Config string + Data string + ExecutedAt string +} + +func NewService(db *gorm.DB) (*Service, error) { + repo, err := NewRepository(db) + if err != nil { + return nil, err + } + + return &Service{repo: repo}, nil +} + +func NewServiceFromEnv() (*Service, error) { + dsn := strings.TrimSpace(os.Getenv("WHODB_METADATA_DSN")) + if dsn == "" { + return nil, ErrMetadataStoreNotConfigured + } + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(plugins.GetGormLogConfig()), + }) + if err != nil { + return nil, fmt.Errorf("open metadata database: %w", err) + } + + if err := plugins.ConfigureConnectionPool(db); err != nil { + return nil, fmt.Errorf("configure metadata connection pool: %w", err) + } + + return NewService(db) +} + +func (s *Service) GetDashboards(ctx context.Context) ([]Dashboard, error) { + scopeKey, err := ScopeKeyFromContext(ctx) + if err != nil { + return nil, err + } + + return s.repo.ListDashboardsByScope(ctx, scopeKey) +} + +func (s *Service) CreateDashboard(ctx context.Context, params CreateDashboardParams) (*Dashboard, error) { + scopeKey, err := ScopeKeyFromContext(ctx) + if err != nil { + return nil, err + } + + refreshRule := params.RefreshRule + if refreshRule == "" { + refreshRule = RefreshRuleOnDemand + } + if !isValidRefreshRule(refreshRule) { + return nil, ErrInvalidRefreshRule + } + + name := strings.TrimSpace(params.Name) + if name == "" { + return nil, errors.New("dashboard name is required") + } + + dashboard := &Dashboard{ + ID: uuid.NewString(), + ScopeKey: scopeKey, + Name: name, + Description: normalizeNullableString(params.Description), + RefreshRule: refreshRule, + } + if err := s.repo.CreateDashboard(ctx, dashboard); err != nil { + return nil, err + } + + return s.repo.GetDashboard(ctx, dashboard.ID) +} + +func (s *Service) UpdateDashboard(ctx context.Context, id string, params UpdateDashboardParams) (*Dashboard, error) { + dashboard, err := s.getOwnedDashboard(ctx, id) + if err != nil { + return nil, err + } + + if params.Name != nil { + name := strings.TrimSpace(*params.Name) + if name == "" { + return nil, errors.New("dashboard name is required") + } + dashboard.Name = name + } + if params.Description != nil { + dashboard.Description = normalizeNullableString(params.Description) + } + if params.RefreshRule != nil { + if !isValidRefreshRule(*params.RefreshRule) { + return nil, ErrInvalidRefreshRule + } + dashboard.RefreshRule = *params.RefreshRule + } + + if err := s.repo.SaveDashboard(ctx, dashboard); err != nil { + return nil, err + } + return s.repo.GetDashboard(ctx, dashboard.ID) +} + +func (s *Service) DeleteDashboard(ctx context.Context, id string) error { + if _, err := s.getOwnedDashboard(ctx, id); err != nil { + return err + } + return s.repo.DeleteDashboard(ctx, id) +} + +func (s *Service) AddWidget(ctx context.Context, dashboardID string, input WidgetInput) (*Widget, error) { + dashboard, err := s.getOwnedDashboard(ctx, dashboardID) + if err != nil { + return nil, err + } + + widget, err := newWidgetFromInput(dashboard.ID, input) + if err != nil { + return nil, err + } + + if err := s.repo.CreateWidget(ctx, widget); err != nil { + return nil, err + } + return s.repo.GetWidget(ctx, widget.ID) +} + +func (s *Service) UpdateWidget(ctx context.Context, id string, input UpdateWidgetInput) (*Widget, error) { + widget, err := s.getOwnedWidget(ctx, id) + if err != nil { + return nil, err + } + + if input.Title != nil { + title := strings.TrimSpace(*input.Title) + if title == "" { + return nil, errors.New("widget title is required") + } + widget.Title = title + } + if input.Description != nil { + widget.Description = normalizeNullableString(input.Description) + } + if input.Layout != nil { + widget.Layout, err = validateJSON("layout", *input.Layout, true) + if err != nil { + return nil, err + } + } + if input.Query != nil { + widget.Query = normalizeNullableString(input.Query) + } + if input.QueryContext != nil { + widget.QueryContext, err = validateJSON("query_context", *input.QueryContext, false) + if err != nil { + return nil, err + } + } + if input.Visualization != nil { + widget.Visualization, err = validateJSON("visualization", *input.Visualization, false) + if err != nil { + return nil, err + } + } + if input.Snapshot != nil { + widget.Snapshot, err = validateJSON("snapshot", *input.Snapshot, false) + if err != nil { + return nil, err + } + } + if input.SortOrder != nil { + widget.SortOrder = *input.SortOrder + } + + if err := s.repo.SaveWidget(ctx, widget); err != nil { + return nil, err + } + return s.repo.GetWidget(ctx, widget.ID) +} + +func (s *Service) DeleteWidget(ctx context.Context, id string) error { + if _, err := s.getOwnedWidget(ctx, id); err != nil { + return err + } + return s.repo.DeleteWidget(ctx, id) +} + +func (s *Service) UpdateWidgetLayouts(ctx context.Context, dashboardID string, layouts []LayoutInput) error { + if _, err := s.getOwnedDashboard(ctx, dashboardID); err != nil { + return err + } + for _, layout := range layouts { + if _, err := validateJSON("layout", layout.Layout, true); err != nil { + return err + } + } + + return s.repo.UpdateWidgetLayouts(ctx, dashboardID, layouts) +} + +func (s *Service) UpdateWidgetSnapshot(ctx context.Context, id string, snapshot SnapshotInput) error { + widget, err := s.getOwnedWidget(ctx, id) + if err != nil { + return err + } + + configJSON, err := validateJSON("snapshot.config", snapshot.Config, true) + if err != nil { + return err + } + dataJSON, err := validateJSON("snapshot.data", snapshot.Data, true) + if err != nil { + return err + } + + combined, err := json.Marshal(map[string]any{ + "config": json.RawMessage(configJSON), + "data": json.RawMessage(dataJSON), + "executedAt": snapshot.ExecutedAt, + }) + if err != nil { + return fmt.Errorf("marshal snapshot: %w", err) + } + + widget.Snapshot = combined + if err := s.repo.SaveWidget(ctx, widget); err != nil { + return err + } + return nil +} + +func newWidgetFromInput(dashboardID string, input WidgetInput) (*Widget, error) { + if input.Type != WidgetTypeChart { + return nil, fmt.Errorf("%w: expected chart", ErrInvalidWidgetType) + } + + title := strings.TrimSpace(input.Title) + if title == "" { + return nil, errors.New("widget title is required") + } + + layout, err := validateJSON("layout", input.Layout, true) + if err != nil { + return nil, err + } + queryContext, err := validateJSONPointer("query_context", input.QueryContext) + if err != nil { + return nil, err + } + visualization, err := validateJSONPointer("visualization", input.Visualization) + if err != nil { + return nil, err + } + snapshot, err := validateJSONPointer("snapshot", input.Snapshot) + if err != nil { + return nil, err + } + + sortOrder := 0 + if input.SortOrder != nil { + sortOrder = *input.SortOrder + } + + return &Widget{ + ID: uuid.NewString(), + DashboardID: dashboardID, + Type: input.Type, + Title: title, + Description: normalizeNullableString(input.Description), + Layout: layout, + Query: normalizeNullableString(input.Query), + QueryContext: queryContext, + Visualization: visualization, + Snapshot: snapshot, + SortOrder: sortOrder, + }, nil +} + +func validateJSONPointer(name string, value *string) (json.RawMessage, error) { + if value == nil { + return nil, nil + } + return validateJSON(name, *value, false) +} + +func validateJSON(name string, value string, required bool) (json.RawMessage, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + if required { + return nil, fmt.Errorf("%w: %s is required", ErrInvalidJSONPayload, name) + } + return nil, nil + } + if !json.Valid([]byte(trimmed)) { + return nil, fmt.Errorf("%w: %s", ErrInvalidJSONPayload, name) + } + return json.RawMessage(trimmed), nil +} + +func normalizeNullableString(value *string) *string { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func isValidRefreshRule(value string) bool { + return value == RefreshRuleOnDemand || value == RefreshRuleByMinute +} + +func (s *Service) getOwnedDashboard(ctx context.Context, id string) (*Dashboard, error) { + scopeKey, err := ScopeKeyFromContext(ctx) + if err != nil { + return nil, err + } + + dashboard, err := s.repo.GetDashboard(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrDashboardNotFound + } + return nil, err + } + if dashboard.ScopeKey != scopeKey { + return nil, ErrScopeMismatch + } + return dashboard, nil +} + +func (s *Service) getOwnedWidget(ctx context.Context, id string) (*Widget, error) { + scopeKey, err := ScopeKeyFromContext(ctx) + if err != nil { + return nil, err + } + + widget, err := s.repo.GetWidget(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrWidgetNotFound + } + return nil, err + } + if widget.Dashboard.ScopeKey != scopeKey { + return nil, ErrScopeMismatch + } + return widget, nil +} diff --git a/dataflow/core/src/dashboard/service_test.go b/dataflow/core/src/dashboard/service_test.go new file mode 100644 index 0000000..0d8fbfb --- /dev/null +++ b/dataflow/core/src/dashboard/service_test.go @@ -0,0 +1,75 @@ +package dashboard + +import ( + "context" + "strings" + "testing" + + "github.com/clidey/whodb/core/src/auth" + "github.com/clidey/whodb/core/src/engine" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestCreateDashboardUsesDerivedScopeKey(t *testing.T) { + service := newDashboardServiceForTest(t) + ctx := context.WithValue(context.Background(), auth.AuthKey_Credentials, &engine.Credentials{ + Type: "Postgres", + Hostname: "db.internal", + Database: "analytics", + }) + + dashboard, err := service.CreateDashboard(ctx, CreateDashboardParams{ + Name: "Revenue", + RefreshRule: "on-demand", + }) + if err != nil { + t.Fatalf("expected create to succeed, got %v", err) + } + + if dashboard.ScopeKey != "Postgres:db.internal::analytics" { + t.Fatalf("expected derived scope key, got %q", dashboard.ScopeKey) + } +} + +func TestAddWidgetRejectsNonChartType(t *testing.T) { + service := newDashboardServiceForTest(t) + ctx := context.WithValue(context.Background(), auth.AuthKey_Credentials, &engine.Credentials{ + Type: "Postgres", + Hostname: "db.internal", + Database: "analytics", + }) + + dashboard, err := service.CreateDashboard(ctx, CreateDashboardParams{ + Name: "Revenue", + RefreshRule: "on-demand", + }) + if err != nil { + t.Fatalf("expected create to succeed, got %v", err) + } + + _, err = service.AddWidget(ctx, dashboard.ID, WidgetInput{ + Type: "table", + Title: "Top rows", + Layout: `{"i":"widget-1","x":0,"y":0,"w":4,"h":6}`, + }) + if err == nil || !strings.Contains(err.Error(), "chart") { + t.Fatalf("expected non-chart widget rejection, got %v", err) + } +} + +func newDashboardServiceForTest(t *testing.T) *Service { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + + service, err := NewService(db) + if err != nil { + t.Fatalf("new service: %v", err) + } + + return service +} diff --git a/dataflow/core/src/ee_stub.go b/dataflow/core/src/ee_stub.go new file mode 100644 index 0000000..bd7d189 --- /dev/null +++ b/dataflow/core/src/ee_stub.go @@ -0,0 +1,12 @@ +package src + +import "github.com/clidey/whodb/core/src/engine" + +// Stub for CE builds - this file is used during code generation +// when EE modules are not available +func init() { + // CE version - no EE initialization + SetEEInitializer(func(e *engine.Engine) { + // No-op for CE + }) +} diff --git a/dataflow/core/src/engine/base_plugin.go b/dataflow/core/src/engine/base_plugin.go new file mode 100644 index 0000000..b6ebb34 --- /dev/null +++ b/dataflow/core/src/engine/base_plugin.go @@ -0,0 +1,162 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package engine + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/clidey/whodb/core/graph/model" +) + +// BasePlugin provides default implementations for all PluginFunctions methods. +// Non-SQL plugins embed this and override only the methods they support. +// User-facing operations return errors.ErrUnsupported; internal operations return empty results. +type BasePlugin struct{} + +// Compile-time check that BasePlugin satisfies PluginFunctions. +var _ PluginFunctions = (*BasePlugin)(nil) + +func (b *BasePlugin) GetDatabases(_ *PluginConfig) ([]string, error) { + return nil, errors.ErrUnsupported +} + +func (b *BasePlugin) IsAvailable(_ context.Context, _ *PluginConfig) bool { + return false +} + +func (b *BasePlugin) GetAllSchemas(_ *PluginConfig) ([]string, error) { + return nil, errors.ErrUnsupported +} + +func (b *BasePlugin) GetStorageUnits(_ *PluginConfig, _ string) ([]StorageUnit, error) { + return nil, errors.ErrUnsupported +} + +func (b *BasePlugin) StorageUnitExists(_ *PluginConfig, _ string, _ string) (bool, error) { + return false, errors.ErrUnsupported +} + +func (b *BasePlugin) AddStorageUnit(_ *PluginConfig, _ string, _ string, _ []Record) (bool, error) { + return false, errors.ErrUnsupported +} + +// AddStorageUnitWithOptions returns unsupported for plugins that do not create storage units with options. +func (b *BasePlugin) AddStorageUnitWithOptions(_ *PluginConfig, _ string, _ string, _ []Record, _ CreateStorageUnitOptions) (bool, error) { + return false, errors.ErrUnsupported +} + +// DropStorageUnit returns unsupported for plugins that do not drop storage units. +func (b *BasePlugin) DropStorageUnit(_ *PluginConfig, _ string, _ string) (bool, error) { + return false, errors.ErrUnsupported +} + +func (b *BasePlugin) UpdateStorageUnit(_ *PluginConfig, _ string, _ string, _ map[string]string, _ []string) (bool, error) { + return false, errors.ErrUnsupported +} + +// ReplaceRow replaces one existing row or document using plugin-specific identity fields. +func (b *BasePlugin) ReplaceRow(_ *PluginConfig, _ string, _ string, _ map[string]string) (bool, error) { + return false, errors.ErrUnsupported +} + +func (b *BasePlugin) AddRow(_ *PluginConfig, _ string, _ string, _ []Record) (bool, error) { + return false, errors.ErrUnsupported +} + +func (b *BasePlugin) AddRowReturningID(_ *PluginConfig, _ string, _ string, _ []Record) (int64, error) { + return 0, errors.ErrUnsupported +} + +func (b *BasePlugin) BulkAddRows(_ *PluginConfig, _ string, _ string, _ [][]Record) (bool, error) { + return false, errors.ErrUnsupported +} + +func (b *BasePlugin) DeleteRow(_ *PluginConfig, _ string, _ string, _ map[string]string) (bool, error) { + return false, errors.ErrUnsupported +} + +func (b *BasePlugin) GetRows(_ *PluginConfig, _ *GetRowsRequest) (*GetRowsResult, error) { + return nil, errors.ErrUnsupported +} + +func (b *BasePlugin) GetRowCount(_ *PluginConfig, _ string, _ string, _ *model.WhereCondition) (int64, error) { + return 0, errors.ErrUnsupported +} + +func (b *BasePlugin) GetGraph(_ *PluginConfig, _ string) ([]GraphUnit, error) { + return nil, errors.ErrUnsupported +} + +func (b *BasePlugin) RawExecute(_ *PluginConfig, _ string, _ ...any) (*GetRowsResult, error) { + return nil, errors.ErrUnsupported +} + +func (b *BasePlugin) Chat(_ *PluginConfig, _ string, _ string, _ string) ([]*ChatMessage, error) { + return nil, errors.ErrUnsupported +} + +func (b *BasePlugin) ExportData(_ *PluginConfig, _ string, _ string, _ func([]string) error, _ []map[string]any) error { + return errors.ErrUnsupported +} + +// ExportSQLData returns unsupported for plugins that do not provide SQL Data Export. +func (b *BasePlugin) ExportSQLData(_ *PluginConfig, _ *SQLDataExportRequest, _ io.Writer) error { + return errors.ErrUnsupported +} + +func (b *BasePlugin) FormatValue(val any) string { + return fmt.Sprintf("%v", val) +} + +func (b *BasePlugin) GetColumnsForTable(_ *PluginConfig, _ string, _ string) ([]Column, error) { + return nil, errors.ErrUnsupported +} + +func (b *BasePlugin) MarkGeneratedColumns(_ *PluginConfig, _ string, _ string, _ []Column) error { + return nil +} + +func (b *BasePlugin) GetColumnConstraints(_ *PluginConfig, _ string, _ string) (map[string]map[string]any, error) { + return map[string]map[string]any{}, nil +} + +func (b *BasePlugin) ClearTableData(_ *PluginConfig, _ string, _ string) (bool, error) { + return false, errors.ErrUnsupported +} + +func (b *BasePlugin) NullifyFKColumn(_ *PluginConfig, _ string, _ string, _ string) error { + return nil +} + +func (b *BasePlugin) GetForeignKeyRelationships(_ *PluginConfig, _ string, _ string) (map[string]*ForeignKeyRelationship, error) { + return map[string]*ForeignKeyRelationship{}, nil +} + +func (b *BasePlugin) WithTransaction(_ *PluginConfig, operation func(tx any) error) error { + return operation(nil) +} + +func (b *BasePlugin) GetDatabaseMetadata() *DatabaseMetadata { + return nil +} + +func (b *BasePlugin) GetSSLStatus(_ *PluginConfig) (*SSLStatus, error) { + return nil, nil +} diff --git a/dataflow/core/src/engine/base_plugin_test.go b/dataflow/core/src/engine/base_plugin_test.go new file mode 100644 index 0000000..6533f11 --- /dev/null +++ b/dataflow/core/src/engine/base_plugin_test.go @@ -0,0 +1,139 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package engine + +import ( + "context" + "errors" + "testing" +) + +func TestBasePluginSatisfiesInterface(t *testing.T) { + // Compile-time check is in base_plugin.go; this confirms it at runtime too + var _ PluginFunctions = (*BasePlugin)(nil) +} + +func TestBasePluginUserFacingMethodsReturnUnsupported(t *testing.T) { + bp := &BasePlugin{} + config := &PluginConfig{} + + if _, err := bp.RawExecute(config, "SELECT 1"); !errors.Is(err, errors.ErrUnsupported) { + t.Fatalf("RawExecute should return ErrUnsupported, got %v", err) + } + if _, err := bp.Chat(config, "", "", ""); !errors.Is(err, errors.ErrUnsupported) { + t.Fatalf("Chat should return ErrUnsupported, got %v", err) + } + if _, err := bp.GetGraph(config, ""); !errors.Is(err, errors.ErrUnsupported) { + t.Fatalf("GetGraph should return ErrUnsupported, got %v", err) + } + if _, err := bp.GetAllSchemas(config); !errors.Is(err, errors.ErrUnsupported) { + t.Fatalf("GetAllSchemas should return ErrUnsupported, got %v", err) + } +} + +func TestBasePluginInternalMethodsReturnEmpty(t *testing.T) { + bp := &BasePlugin{} + config := &PluginConfig{} + + constraints, err := bp.GetColumnConstraints(config, "", "") + if err != nil { + t.Fatalf("GetColumnConstraints should return nil error, got %v", err) + } + if len(constraints) != 0 { + t.Fatalf("GetColumnConstraints should return empty map, got %v", constraints) + } + + fks, err := bp.GetForeignKeyRelationships(config, "", "") + if err != nil { + t.Fatalf("GetForeignKeyRelationships should return nil error, got %v", err) + } + if len(fks) != 0 { + t.Fatalf("GetForeignKeyRelationships should return empty map, got %v", fks) + } + + if err := bp.NullifyFKColumn(config, "", "", ""); err != nil { + t.Fatalf("NullifyFKColumn should return nil, got %v", err) + } + + if err := bp.MarkGeneratedColumns(config, "", "", nil); err != nil { + t.Fatalf("MarkGeneratedColumns should return nil, got %v", err) + } +} + +func TestBasePluginWithTransactionCallsOperationDirectly(t *testing.T) { + bp := &BasePlugin{} + called := false + err := bp.WithTransaction(nil, func(tx any) error { + called = true + if tx != nil { + t.Fatalf("WithTransaction should pass nil tx, got %v", tx) + } + return nil + }) + if err != nil { + t.Fatalf("WithTransaction should return nil, got %v", err) + } + if !called { + t.Fatalf("WithTransaction should call the operation") + } +} + +func TestBasePluginFormatValue(t *testing.T) { + bp := &BasePlugin{} + if bp.FormatValue(42) != "42" { + t.Fatalf("FormatValue(42) should return \"42\", got %q", bp.FormatValue(42)) + } + if bp.FormatValue("hello") != "hello" { + t.Fatalf("FormatValue(\"hello\") should return \"hello\", got %q", bp.FormatValue("hello")) + } +} + +func TestBasePluginIsAvailableReturnsFalse(t *testing.T) { + bp := &BasePlugin{} + if bp.IsAvailable(context.Background(), nil) { + t.Fatalf("IsAvailable should return false by default") + } +} + +func TestBasePluginMetadataReturnsNil(t *testing.T) { + bp := &BasePlugin{} + if bp.GetDatabaseMetadata() != nil { + t.Fatalf("GetDatabaseMetadata should return nil by default") + } + status, err := bp.GetSSLStatus(nil) + if err != nil || status != nil { + t.Fatalf("GetSSLStatus should return nil, nil; got %v, %v", status, err) + } +} + +// TestBasePluginEmbedding verifies that a plugin can embed BasePlugin +// and override only the methods it needs. +func TestBasePluginEmbedding(t *testing.T) { + type TestPlugin struct { + BasePlugin + } + + tp := &TestPlugin{} + + // Should inherit BasePlugin defaults + if _, err := tp.RawExecute(nil, ""); !errors.Is(err, errors.ErrUnsupported) { + t.Fatalf("embedded BasePlugin should return ErrUnsupported for RawExecute") + } + + // Should satisfy the interface + var _ PluginFunctions = tp +} diff --git a/dataflow/core/src/engine/collection_importer.go b/dataflow/core/src/engine/collection_importer.go new file mode 100644 index 0000000..7e44877 --- /dev/null +++ b/dataflow/core/src/engine/collection_importer.go @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package engine + +// DocumentImportFailure describes a single source record that could not be +// written to a collection. Index is the zero-based position of the record in +// the parsed source (file order). +type DocumentImportFailure struct { + Index int + Reason string +} + +// DocumentUpsertResult reports the outcome counts of an upsert import. +type DocumentUpsertResult struct { + Matched int + Modified int + Upserted int +} + +// CollectionImporter is an optional plugin capability for importing documents +// into a document collection. Only document databases (for example MongoDB) +// implement it; resolvers type-assert PluginFunctions to this interface and +// report an unsupported error when the assertion fails. +// +// Both methods continue past per-document write errors rather than aborting, +// returning the records they skipped so the caller can report them. +type CollectionImporter interface { + // InsertDocuments inserts the given documents into the collection, skipping + // (not aborting on) individual documents that fail. It returns the number + // inserted and the failures encountered. + InsertDocuments(config *PluginConfig, schema string, collection string, documents []map[string]any) (int, []DocumentImportFailure, error) + + // UpsertDocuments replaces or inserts each document, matching existing + // documents by keys. When keys is empty it defaults to ["_id"]. It skips + // individual documents that fail and returns the outcome counts plus failures. + UpsertDocuments(config *PluginConfig, schema string, collection string, keys []string, documents []map[string]any) (DocumentUpsertResult, []DocumentImportFailure, error) +} diff --git a/dataflow/core/src/engine/engine.go b/dataflow/core/src/engine/engine.go new file mode 100644 index 0000000..4f3e52d --- /dev/null +++ b/dataflow/core/src/engine/engine.go @@ -0,0 +1,115 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package engine + +import ( + "strings" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/types" +) + +// DatabaseType identifies a supported database system. +type DatabaseType string + +const ( + DatabaseType_Postgres = "Postgres" + DatabaseType_MySQL = "MySQL" + DatabaseType_MariaDB = "MariaDB" + DatabaseType_Sqlite3 = "Sqlite3" + DatabaseType_MongoDB = "MongoDB" + DatabaseType_Redis = "Redis" + DatabaseType_ElasticSearch = "ElasticSearch" + DatabaseType_ClickHouse = "ClickHouse" + DatabaseType_ElastiCache = "ElastiCache" // Uses Redis plugin for now + DatabaseType_DocumentDB = "DocumentDB" // Uses MongoDB plugin for now +) + +// LoginProfileRetriever is a function that retrieves stored database credentials. +type LoginProfileRetriever func() ([]types.DatabaseCredentials, error) + +// Engine manages database plugins and login profiles. +type Engine struct { + Plugins []*Plugin + LoginProfiles []types.DatabaseCredentials + ProfileRetrievers []LoginProfileRetriever +} + +// RegistryPlugin adds a database plugin to the engine. +func (e *Engine) RegistryPlugin(plugin *Plugin) { + if e.Plugins == nil { + e.Plugins = []*Plugin{} + } + e.Plugins = append(e.Plugins, plugin) +} + +// Choose returns the plugin that matches the given database type, or nil if not found. +func (e *Engine) Choose(databaseType DatabaseType) *Plugin { + // resolve databases from their name to their underlying type + resolvedType := resolvePluginType(databaseType) + + for _, plugin := range e.Plugins { + if strings.EqualFold(string(plugin.Type), string(resolvedType)) { + return plugin + } + } + return nil +} + +// resolvePluginType maps display database types to their underlying plugin types. +func resolvePluginType(dbType DatabaseType) DatabaseType { + switch dbType { + case DatabaseType_ElastiCache: + return DatabaseType_Redis + case DatabaseType_DocumentDB: + return DatabaseType_MongoDB + default: + return dbType + } +} + +// AddLoginProfile adds database credentials to the engine's profile list. +func (e *Engine) AddLoginProfile(profile types.DatabaseCredentials) { + if e.LoginProfiles == nil { + e.LoginProfiles = []types.DatabaseCredentials{} + } + e.LoginProfiles = append(e.LoginProfiles, profile) +} + +// RegisterProfileRetriever adds a function that retrieves database credentials on demand. +func (e *Engine) RegisterProfileRetriever(retriever LoginProfileRetriever) { + if e.ProfileRetrievers == nil { + e.ProfileRetrievers = []LoginProfileRetriever{} + } + e.ProfileRetrievers = append(e.ProfileRetrievers, retriever) +} + +// GetStorageUnitModel converts an engine StorageUnit to a GraphQL model StorageUnit. +func GetStorageUnitModel(unit StorageUnit) *model.StorageUnit { + var attributes []*model.Record + for _, attribute := range unit.Attributes { + attributes = append(attributes, &model.Record{ + Key: attribute.Key, + Value: attribute.Value, + }) + } + return &model.StorageUnit{ + Name: unit.Name, + Attributes: attributes, + IsMockDataGenerationAllowed: false, // Will be set in resolver + } +} diff --git a/dataflow/core/src/engine/engine_test.go b/dataflow/core/src/engine/engine_test.go new file mode 100644 index 0000000..28d0598 --- /dev/null +++ b/dataflow/core/src/engine/engine_test.go @@ -0,0 +1,92 @@ +// Copyright 2025 Clidey, Inc. +// +// 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. + +package engine + +import ( + "testing" + + "github.com/clidey/whodb/core/src/types" +) + +func TestRegistryPluginAndChoose(t *testing.T) { + engine := &Engine{} + postgres := &Plugin{Type: DatabaseType_Postgres} + engine.RegistryPlugin(postgres) + + if got := engine.Choose(DatabaseType("postgres")); got != postgres { + t.Fatalf("Choose should be case-insensitive and return the registered plugin") + } + + if got := engine.Choose(DatabaseType("unknown")); got != nil { + t.Fatalf("Choose should return nil for an unregistered database type") + } +} + +func TestAddLoginProfileAndRetriever(t *testing.T) { + engine := &Engine{} + + engine.AddLoginProfile(typesDatabaseCredentials("first")) + engine.RegisterProfileRetriever(func() ([]types.DatabaseCredentials, error) { + return []types.DatabaseCredentials{typesDatabaseCredentials("retrieved")}, nil + }) + + if len(engine.LoginProfiles) != 1 { + t.Fatalf("expected 1 stored profile, got %d", len(engine.LoginProfiles)) + } + + if len(engine.ProfileRetrievers) != 1 { + t.Fatalf("expected 1 profile retriever, got %d", len(engine.ProfileRetrievers)) + } +} + +func TestGetStorageUnitModel(t *testing.T) { + unit := StorageUnit{ + Name: "users", + Attributes: []Record{ + {Key: "owner", Value: "admin"}, + {Key: "ttl", Value: "3600"}, + }, + } + + model := GetStorageUnitModel(unit) + if model.Name != "users" { + t.Fatalf("expected name to be carried over") + } + if len(model.Attributes) != 2 { + t.Fatalf("expected attributes to be copied, got %d", len(model.Attributes)) + } + if model.IsMockDataGenerationAllowed { + t.Fatalf("IsMockDataGenerationAllowed is set by resolver and should default to false") + } +} + +func TestChooseResolvesDisplayTypesToUnderlyingPlugins(t *testing.T) { + engine := &Engine{} + mongo := &Plugin{Type: DatabaseType_MongoDB} + redis := &Plugin{Type: DatabaseType_Redis} + engine.RegistryPlugin(mongo) + engine.RegistryPlugin(redis) + + if got := engine.Choose(DatabaseType_DocumentDB); got != mongo { + t.Fatalf("expected DocumentDB to resolve to MongoDB plugin") + } + if got := engine.Choose(DatabaseType_ElastiCache); got != redis { + t.Fatalf("expected ElastiCache to resolve to Redis plugin") + } +} + +func typesDatabaseCredentials(username string) types.DatabaseCredentials { + return types.DatabaseCredentials{Username: username} +} diff --git a/dataflow/core/src/engine/metadata.go b/dataflow/core/src/engine/metadata.go new file mode 100644 index 0000000..26ce784 --- /dev/null +++ b/dataflow/core/src/engine/metadata.go @@ -0,0 +1,131 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package engine + +import "strings" + +// TypeCategory represents the category of a database type for UI grouping +type TypeCategory string + +const ( + TypeCategoryNumeric TypeCategory = "numeric" + TypeCategoryText TypeCategory = "text" + TypeCategoryBinary TypeCategory = "binary" + TypeCategoryDatetime TypeCategory = "datetime" + TypeCategoryBoolean TypeCategory = "boolean" + TypeCategoryJSON TypeCategory = "json" + TypeCategoryOther TypeCategory = "other" +) + +// TypeDefinition describes a database column type with its metadata +type TypeDefinition struct { + ID string // Canonical type name (e.g., "VARCHAR", "INTEGER") + Label string // Display label for UI (e.g., "varchar", "integer") + HasLength bool // Shows length input when selected (VARCHAR, CHAR) + HasPrecision bool // Shows precision/scale inputs (DECIMAL, NUMERIC) + DefaultLength *int // Default length for types with HasLength + DefaultPrecision *int // Default precision for types with HasPrecision + Category TypeCategory // Type category for grouping +} + +// Capabilities declares which optional features a plugin supports. +// The frontend reads these to determine which UI elements to show. +type Capabilities struct { + SupportsScratchpad bool + SupportsChat bool + SupportsGraph bool + SupportsSchema bool + SupportsDatabaseSwitch bool + SupportsModifiers bool +} + +// DatabaseMetadata contains all metadata for a database type +type DatabaseMetadata struct { + DatabaseType DatabaseType + TypeDefinitions []TypeDefinition + Operators []string + AliasMap map[string]string + Capabilities Capabilities + SystemSchemas []string +} + +// Helper function to create a pointer to an int +func IntPtr(i int) *int { + return &i +} + +// ValidateColumnType checks if a column type string is valid against the TypeDefinitions. +// It parses the type string (e.g., "VARCHAR(255)") and validates: +// - The base type exists in TypeDefinitions (or AliasMap) +// - Length/precision parameters match the type's requirements +// Returns nil if valid, or an error describing the issue. +func ValidateColumnType(typeName string, metadata *DatabaseMetadata) error { + if metadata == nil || len(metadata.TypeDefinitions) == 0 { + // No metadata available - allow any type (backward compatibility) + return nil + } + + // Parse the type specification + baseType, hasParams := parseTypeForValidation(typeName) + baseTypeUpper := strings.ToUpper(baseType) + + // Check if it's an alias and get the canonical type + if canonical, ok := metadata.AliasMap[baseTypeUpper]; ok { + baseTypeUpper = strings.ToUpper(canonical) + } + + // Find the type definition + var typeDef *TypeDefinition + for i := range metadata.TypeDefinitions { + if strings.ToUpper(metadata.TypeDefinitions[i].ID) == baseTypeUpper { + typeDef = &metadata.TypeDefinitions[i] + break + } + } + + if typeDef == nil { + return &UnsupportedTypeError{TypeName: typeName, DatabaseType: string(metadata.DatabaseType)} + } + + // Validate parameters + if (typeDef.HasLength || typeDef.HasPrecision) && !hasParams { + // Type supports parameters but none provided - this is OK, defaults will be used + return nil + } + + return nil +} + +// UnsupportedTypeError is returned when a column type is not supported by the database +type UnsupportedTypeError struct { + TypeName string + DatabaseType string +} + +func (e *UnsupportedTypeError) Error() string { + return "data type: " + e.TypeName + " not supported by: " + e.DatabaseType +} + +// parseTypeForValidation extracts the base type and whether it has parameters +func parseTypeForValidation(typeName string) (baseType string, hasParams bool) { + typeName = strings.TrimSpace(typeName) + parenIdx := strings.IndexByte(typeName, '(') + if parenIdx == -1 { + return typeName, false + } + return typeName[:parenIdx], true +} diff --git a/dataflow/core/src/engine/metadata_test.go b/dataflow/core/src/engine/metadata_test.go new file mode 100644 index 0000000..0ad0aef --- /dev/null +++ b/dataflow/core/src/engine/metadata_test.go @@ -0,0 +1,59 @@ +package engine + +import ( + "errors" + "testing" +) + +func TestValidateColumnTypeAllowsWhenMetadataMissing(t *testing.T) { + if err := ValidateColumnType("NOT_A_REAL_TYPE", nil); err != nil { + t.Fatalf("expected nil error when metadata is nil, got %v", err) + } + + if err := ValidateColumnType("NOT_A_REAL_TYPE", &DatabaseMetadata{}); err != nil { + t.Fatalf("expected nil error when metadata has no type definitions, got %v", err) + } +} + +func TestValidateColumnTypeRejectsUnsupported(t *testing.T) { + meta := &DatabaseMetadata{ + DatabaseType: "TestDB", + TypeDefinitions: []TypeDefinition{ + {ID: "INTEGER"}, + }, + AliasMap: map[string]string{}, + } + + err := ValidateColumnType("NOT_A_REAL_TYPE", meta) + var unsupported *UnsupportedTypeError + if err == nil || !errors.As(err, &unsupported) { + t.Fatalf("expected UnsupportedTypeError, got %v", err) + } + if unsupported.TypeName != "NOT_A_REAL_TYPE" { + t.Fatalf("expected TypeName to be preserved, got %q", unsupported.TypeName) + } + if unsupported.DatabaseType != "TestDB" { + t.Fatalf("expected DatabaseType to be preserved, got %q", unsupported.DatabaseType) + } +} + +func TestValidateColumnTypeResolvesAliases(t *testing.T) { + meta := &DatabaseMetadata{ + DatabaseType: "TestDB", + TypeDefinitions: []TypeDefinition{ + {ID: "INTEGER"}, + {ID: "VARCHAR", HasLength: true}, + }, + AliasMap: map[string]string{ + "INT": "INTEGER", + }, + } + + if err := ValidateColumnType("int", meta); err != nil { + t.Fatalf("expected alias INT -> INTEGER to validate, got %v", err) + } + + if err := ValidateColumnType("VARCHAR(255)", meta); err != nil { + t.Fatalf("expected parametrized type to validate, got %v", err) + } +} diff --git a/dataflow/core/src/engine/plugin.go b/dataflow/core/src/engine/plugin.go new file mode 100644 index 0000000..5486e2a --- /dev/null +++ b/dataflow/core/src/engine/plugin.go @@ -0,0 +1,236 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package engine + +import ( + "context" + "errors" + "io" + + "github.com/clidey/whodb/core/graph/model" +) + +// ErrMultiStatementUnsupported is returned by plugins that do not support +// executing multiple SQL statements in a single request. +var ErrMultiStatementUnsupported = errors.New("multi-statement SQL is not supported by this database") + +// Credentials holds authentication and connection details for a database. +type Credentials struct { + Id *string `json:"Id,omitempty"` + Type string `json:"Type"` + Hostname string `json:"Hostname"` + Username string `json:"Username"` + Password string `json:"Password"` + Database string `json:"Database"` + Advanced []Record `json:"Advanced,omitempty"` + AccessToken *string `json:"AccessToken,omitempty"` + IsProfile bool `json:"IsProfile,omitempty"` +} + +// ExternalModel represents an external AI model configuration for chat functionality. +type ExternalModel struct { + Type string // Provider type: "OpenAI", "Anthropic", "Ollama", etc. + Token string // API key + Model string // User-selected model: "gpt-4o", "claude-sonnet-4", etc. + Endpoint string // Base URL (for Ollama/generic providers) +} + +// PluginConfig contains all configuration needed to connect to and operate on a database. +type PluginConfig struct { + Credentials *Credentials + ExternalModel *ExternalModel + Transaction any // Optional transaction for transactional operations (e.g., *gorm.DB for SQL plugins) + MultiStatement bool // Hint for plugins that need special handling for multi-statement scripts (e.g., MySQL) + UpsertPKColumns []string // PK columns for ON CONFLICT DO UPDATE; non-nil = upsert mode + SkipConflictPKColumns []string // PK columns for ON CONFLICT DO NOTHING (append mode — skip duplicate rows) +} + +// Record represents a key-value pair with optional extra metadata, +// used for column attributes, configuration, and data transfer. +type Record struct { + Key string `json:"Key"` + Value string `json:"Value"` + Extra map[string]string `json:"Extra,omitempty"` +} + +// StorageUnit represents a database table, collection, or equivalent storage structure. +type StorageUnit struct { + Name string + Attributes []Record +} + +// Column describes a database column including its type, name, and relationship metadata. +type Column struct { + Type string + Name string + IsNullable bool + IsPrimary bool + IsAutoIncrement bool + IsComputed bool // Database-managed, generated, etc + IsForeignKey bool + ReferencedTable *string + ReferencedColumn *string + Length *int // For VARCHAR(n), CHAR(n) types + Precision *int // For DECIMAL(p,s) types + Scale *int // For DECIMAL(p,s) types +} + +// GetRowsResult contains the result of a row query including columns, data, and pagination info. +type GetRowsResult struct { + Columns []Column + Rows [][]string + DisableUpdate bool + TotalCount int64 +} + +// GraphUnitRelationshipType defines the cardinality of a relationship between tables. +type GraphUnitRelationshipType string + +// GraphUnitRelationship describes a foreign key relationship between two tables. +type GraphUnitRelationship struct { + Name string + RelationshipType GraphUnitRelationshipType + SourceColumn *string + TargetColumn *string +} + +// GraphUnit represents a table and its relationships for graph visualization. +type GraphUnit struct { + Unit StorageUnit + Relations []GraphUnitRelationship +} + +// ChatMessage represents a message in an AI chat conversation with optional query results. +type ChatMessage struct { + Type string + Result *GetRowsResult + Text string + RequiresConfirmation bool +} + +// ForeignKeyRelationship describes a foreign key constraint on a column. +type ForeignKeyRelationship struct { + ColumnName string + ReferencedTable string + ReferencedColumn string +} + +// SSLStatus contains verified SSL/TLS connection status from the database. +type SSLStatus struct { + IsEnabled bool // Whether SSL/TLS is active on the current connection + Mode string // SSL mode: disabled, required, verify-ca, verify-identity, etc. +} + +// GetRowsRequest bundles the parameters for a GetRows query. +type GetRowsRequest struct { + Schema string + StorageUnit string + Where *model.WhereCondition + Sort []*model.SortCondition + PageSize int + PageOffset int +} + +// SQLDataExportMode identifies which row write statements a SQL Data Export emits. +type SQLDataExportMode string + +const ( + // SQLDataExportModeInsert emits INSERT statements. + SQLDataExportModeInsert SQLDataExportMode = "INSERT" + // SQLDataExportModeUpdate emits UPDATE statements. + SQLDataExportModeUpdate SQLDataExportMode = "UPDATE" +) + +// SQLDataExportRequest describes the SQL Table rows and statement mode to export. +type SQLDataExportRequest struct { + Schema string + StorageUnit string + Mode SQLDataExportMode + Where *model.WhereCondition + Sort []*model.SortCondition + Limit *int +} + +// CreateStorageUnitOptions configures plugin-specific table creation behavior. +type CreateStorageUnitOptions struct { + // PrimaryKeysUseSourceValues disables plugin defaults that generate primary + // key values automatically when rows will provide those values directly. + PrimaryKeysUseSourceValues bool +} + +// PluginFunctions defines the interface that all database plugins must implement. +// Each method provides a specific database operation capability. +type PluginFunctions interface { + GetDatabases(config *PluginConfig) ([]string, error) + IsAvailable(ctx context.Context, config *PluginConfig) bool + GetAllSchemas(config *PluginConfig) ([]string, error) + GetStorageUnits(config *PluginConfig, schema string) ([]StorageUnit, error) + StorageUnitExists(config *PluginConfig, schema string, storageUnit string) (bool, error) + AddStorageUnit(config *PluginConfig, schema string, storageUnit string, fields []Record) (bool, error) + AddStorageUnitWithOptions(config *PluginConfig, schema string, storageUnit string, fields []Record, options CreateStorageUnitOptions) (bool, error) + DropStorageUnit(config *PluginConfig, schema string, storageUnit string) (bool, error) + UpdateStorageUnit(config *PluginConfig, schema string, storageUnit string, values map[string]string, updatedColumns []string) (bool, error) + ReplaceRow(config *PluginConfig, schema string, storageUnit string, values map[string]string) (bool, error) + AddRow(config *PluginConfig, schema string, storageUnit string, values []Record) (bool, error) + AddRowReturningID(config *PluginConfig, schema string, storageUnit string, values []Record) (int64, error) + BulkAddRows(config *PluginConfig, schema string, storageUnit string, rows [][]Record) (bool, error) + DeleteRow(config *PluginConfig, schema string, storageUnit string, values map[string]string) (bool, error) + GetRows(config *PluginConfig, req *GetRowsRequest) (*GetRowsResult, error) + GetRowCount(config *PluginConfig, schema string, storageUnit string, where *model.WhereCondition) (int64, error) + GetGraph(config *PluginConfig, schema string) ([]GraphUnit, error) + RawExecute(config *PluginConfig, query string, params ...any) (*GetRowsResult, error) + Chat(config *PluginConfig, schema string, previousConversation string, query string) ([]*ChatMessage, error) + ExportData(config *PluginConfig, schema string, storageUnit string, writer func([]string) error, selectedRows []map[string]any) error + ExportSQLData(config *PluginConfig, req *SQLDataExportRequest, writer io.Writer) error + FormatValue(val any) string + GetColumnsForTable(config *PluginConfig, schema string, storageUnit string) ([]Column, error) + + // MarkGeneratedColumns enriches columns with auto-increment and computed column flags + // by querying database-specific system catalogs + MarkGeneratedColumns(config *PluginConfig, schema string, storageUnit string, columns []Column) error + + // Mock data generation methods + GetColumnConstraints(config *PluginConfig, schema string, storageUnit string) (map[string]map[string]any, error) + ClearTableData(config *PluginConfig, schema string, storageUnit string) (bool, error) + NullifyFKColumn(config *PluginConfig, schema string, storageUnit string, column string) error + + // Foreign key detection + GetForeignKeyRelationships(config *PluginConfig, schema string, storageUnit string) (map[string]*ForeignKeyRelationship, error) + + // Transaction support + WithTransaction(config *PluginConfig, operation func(tx any) error) error + + // Database metadata for frontend type/operator configuration + GetDatabaseMetadata() *DatabaseMetadata + + // GetSSLStatus returns the verified SSL/TLS status of the current connection. + // Returns nil if SSL status cannot be determined (e.g., SQLite) or is not applicable. + GetSSLStatus(config *PluginConfig) (*SSLStatus, error) +} + +// Plugin wraps PluginFunctions with a database type identifier. +type Plugin struct { + PluginFunctions + Type DatabaseType +} + +// NewPluginConfig creates a new PluginConfig with the given credentials. +func NewPluginConfig(credentials *Credentials) *PluginConfig { + return &PluginConfig{ + Credentials: credentials, + } +} diff --git a/dataflow/core/src/env/aws.go b/dataflow/core/src/env/aws.go new file mode 100644 index 0000000..8611ced --- /dev/null +++ b/dataflow/core/src/env/aws.go @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package env + +// AWSProviderEnvConfig represents AWS provider configuration from environment variables. +// Authentication is handled by the AWS SDK default credential chain or named profiles. +// Example: +// +// WHODB_AWS_PROVIDER='[{ +// "name": "Production AWS", +// "region": "us-west-2", +// "profileName": "production" +// }]' +type AWSProviderEnvConfig struct { + // Name is a human-readable name for this provider. + Name string `json:"name"` + + // Region is the AWS region to discover resources in. + Region string `json:"region"` + + // ProfileName for profile auth. If set, uses the named AWS profile. + // If empty, uses the default credential chain. + ProfileName string `json:"profileName,omitempty"` + + // DiscoverRDS enables RDS database discovery (defaults to true if omitted). + DiscoverRDS *bool `json:"discoverRDS,omitempty"` + + // DiscoverElastiCache enables ElastiCache discovery (defaults to true if omitted). + DiscoverElastiCache *bool `json:"discoverElastiCache,omitempty"` + + // DiscoverDocumentDB enables DocumentDB discovery (defaults to true if omitted). + DiscoverDocumentDB *bool `json:"discoverDocumentDB,omitempty"` +} diff --git a/dataflow/core/src/env/env.go b/dataflow/core/src/env/env.go new file mode 100644 index 0000000..b8cc36d --- /dev/null +++ b/dataflow/core/src/env/env.go @@ -0,0 +1,226 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package env + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/clidey/whodb/core/src/common" +) + +var IsDevelopment = os.Getenv("ENVIRONMENT") == "dev" +var IsEnterpriseEdition = false // Set to true by EE build + +// GetIsDesktopMode returns true if running in desktop mode. +// This is a function (not a variable) so it reads the env var each time, +// allowing the desktop app to set WHODB_DESKTOP after package initialization. +func GetIsDesktopMode() bool { + return os.Getenv("WHODB_DESKTOP") == "true" +} + +// GetIsCLIMode returns true if running as the CLI/TUI application. +func GetIsCLIMode() bool { + return os.Getenv("WHODB_CLI") == "true" +} + +// GetIsLocalMode returns true if running locally (desktop or CLI) where +// full filesystem access is expected, as opposed to server mode. +func GetIsLocalMode() bool { + return GetIsDesktopMode() || GetIsCLIMode() +} + +var Tokens = common.FilterList(strings.Split(os.Getenv("WHODB_TOKENS"), ","), func(item string) bool { + return item != "" +}) +var IsAPIGatewayEnabled = len(Tokens) > 0 +var OllamaHost = os.Getenv("WHODB_OLLAMA_HOST") +var OllamaPort = os.Getenv("WHODB_OLLAMA_PORT") + +var AnthropicAPIKey = os.Getenv("WHODB_ANTHROPIC_API_KEY") +var AnthropicEndpoint = os.Getenv("WHODB_ANTHROPIC_ENDPOINT") +var AnthropicName = os.Getenv("WHODB_ANTHROPIC_NAME") + +var OpenAIAPIKey = os.Getenv("WHODB_OPENAI_API_KEY") +var OpenAIEndpoint = os.Getenv("WHODB_OPENAI_ENDPOINT") +var OpenAIName = os.Getenv("WHODB_OPENAI_NAME") + +var OllamaName = os.Getenv("WHODB_OLLAMA_NAME") + +var AllowedOrigins = common.FilterList(strings.Split(os.Getenv("WHODB_ALLOWED_ORIGINS"), ","), func(item string) bool { + return item != "" +}) + +var LogLevel = os.Getenv("WHODB_LOG_LEVEL") + +var AccessLogFile = os.Getenv("WHODB_ACCESS_LOG_FILE") // where to store the http access logs +var LogFile = os.Getenv("WHODB_LOG_FILE") // where to store all other non-http logs +var LogFormat = os.Getenv("WHODB_LOG_FORMAT") // only option right now is "json". leave blank for default format + +// Default log paths used when the AccessLogFile and LogFile vars are set to "default". +const DefaultLogDir = "/var/log/whodb" +const DefaultLogFile = DefaultLogDir + "/whodb.log" +const DefaultAccessLogFile = DefaultLogDir + "/whodb.access.log" + +// GetDisableUpdateCheck returns true if update checking is disabled. +func GetDisableUpdateCheck() bool { + return os.Getenv("WHODB_DISABLE_UPDATE_CHECK") == "true" +} + +var DisableMockDataGeneration = os.Getenv("WHODB_DISABLE_MOCK_DATA_GENERATION") + +var ApplicationEnvironment = os.Getenv("WHODB_APPLICATION_ENVIRONMENT") + +var ApplicationVersion string + +var PosthogAPIKey = "phc_hbXcCoPTdxm5ADL8PmLSYTIUvS6oRWFM2JAK8SMbfnH" +var PosthogHost = "https://us.i.posthog.com" + +// IsAWSProviderEnabled controls whether AWS provider functionality is available. +// disabled by default for now until official release +var IsAWSProviderEnabled = os.Getenv("WHODB_ENABLE_AWS_PROVIDER") == "true" + +// DisableCredentialForm controls whether the credential form is disabled. +var DisableCredentialForm = os.Getenv("WHODB_DISABLE_CREDENTIAL_FORM") == "true" + +// MaxPageSize is the maximum number of rows that can be requested in a single +// page via the Row resolver. Configurable via WHODB_MAX_PAGE_SIZE (default 10000). +var MaxPageSize = getMaxPageSize() + +type ChatProvider struct { + Type string + Name string // Display name/alias for the provider + APIKey string + Endpoint string + ProviderId string + ClientType string // BAML client type (openai-generic, anthropic, aws-bedrock) - only for generic providers + IsGeneric bool // True for generic/custom providers, false for built-in providers +} + +// GenericProviderConfig holds configuration for a generic AI provider. +type GenericProviderConfig struct { + ProviderId string + Name string + ClientType string // "openai-generic", "anthropic", etc. + BaseURL string + APIKey string + Models []string +} + +var GenericProviders []GenericProviderConfig + +// AddGenericProvider adds a generic provider to the GenericProviders list. +// This is used by EE providers and other dynamic provider registration systems. +func AddGenericProvider(config GenericProviderConfig) { + GenericProviders = append(GenericProviders, config) +} + +// ResolvedCredentials holds provider credentials resolved from environment config. +type ResolvedCredentials struct { + ModelType string // Provider type as sent by the frontend (ProviderId) + Token string // API key + Endpoint string // Base URL +} + +func GetOllamaEndpoint() string { + host, port := GetOllamaHost() + return fmt.Sprintf("http://%s:%s/api", host, port) +} + +// GetOllamaHost returns the resolved Ollama host and port, accounting for +// Docker/WSL2 environments and WHODB_OLLAMA_HOST/PORT overrides. +func GetOllamaHost() (string, string) { + host := "localhost" + port := "11434" + + if common.IsRunningInsideDocker() { + host = "host.docker.internal" + } else if common.IsRunningInsideWSL2() { + if wslHost := common.GetWSL2WindowsHost(); wslHost != "" { + host = wslHost + } + } + + if OllamaHost != "" { + host = OllamaHost + } + if OllamaPort != "" { + port = OllamaPort + } + + return host, port +} + +func GetAnthropicEndpoint() string { + if AnthropicEndpoint != "" { + return AnthropicEndpoint + } + return "https://api.anthropic.com/v1" +} + +func GetOpenAIEndpoint() string { + if OpenAIEndpoint != "" { + return OpenAIEndpoint + } + return "https://api.openai.com/v1" +} + +// GetSessionDSN returns the configured auth session DSN, falling back to the +// metadata DSN when a dedicated session DSN is not configured. +func GetSessionDSN() string { + if dsn := strings.TrimSpace(os.Getenv("WHODB_SESSION_DSN")); dsn != "" { + return dsn + } + return strings.TrimSpace(os.Getenv("WHODB_METADATA_DSN")) +} + +// GetSessionEncryptionKey returns the server-side auth session encryption key. +func GetSessionEncryptionKey() string { + return strings.TrimSpace(os.Getenv("WHODB_SESSION_ENCRYPTION_KEY")) +} + +// GetSessionTTL returns the configured auth session lifetime, defaulting to 24h. +func GetSessionTTL() string { + if ttl := strings.TrimSpace(os.Getenv("WHODB_SESSION_TTL")); ttl != "" { + return ttl + } + return "24h" +} + +// GetSealosBootstrapEnabled returns true when the Sealos bootstrap flow is enabled. +func GetSealosBootstrapEnabled() bool { + return strings.TrimSpace(os.Getenv("WHODB_SEALOS_BOOTSTRAP_ENABLED")) != "false" +} + +// GetStandaloneLoginEnabled returns true when manual standalone login is enabled. +func GetStandaloneLoginEnabled() bool { + return strings.TrimSpace(os.Getenv("WHODB_STANDALONE_LOGIN_ENABLED")) != "false" +} + +func getMaxPageSize() int { + val := os.Getenv("WHODB_MAX_PAGE_SIZE") + if val == "" { + return 10000 + } + n, err := strconv.Atoi(val) + if err != nil || n <= 0 { + return 10000 + } + return n +} diff --git a/dataflow/core/src/env/env_test.go b/dataflow/core/src/env/env_test.go new file mode 100644 index 0000000..4e913c8 --- /dev/null +++ b/dataflow/core/src/env/env_test.go @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package env + +import "testing" + +func TestGetOllamaEndpointRespectsOverrides(t *testing.T) { + origHost, origPort := OllamaHost, OllamaPort + t.Cleanup(func() { OllamaHost, OllamaPort = origHost, origPort }) + + OllamaHost = "ollama.example.com" + OllamaPort = "9999" + + endpoint := GetOllamaEndpoint() + if endpoint != "http://ollama.example.com:9999/api" { + t.Fatalf("expected custom ollama endpoint to be honored, got %s", endpoint) + } +} + +func TestGetSealosBootstrapEnabledDefaultsToTrue(t *testing.T) { + t.Run("default enabled", func(t *testing.T) { + t.Setenv("WHODB_SEALOS_BOOTSTRAP_ENABLED", "") + if !GetSealosBootstrapEnabled() { + t.Fatalf("expected sealos bootstrap to default to enabled") + } + }) + + t.Run("explicit false disables", func(t *testing.T) { + t.Setenv("WHODB_SEALOS_BOOTSTRAP_ENABLED", "false") + if GetSealosBootstrapEnabled() { + t.Fatalf("expected explicit false to disable sealos bootstrap") + } + }) + + t.Run("explicit true enables", func(t *testing.T) { + t.Setenv("WHODB_SEALOS_BOOTSTRAP_ENABLED", "true") + if !GetSealosBootstrapEnabled() { + t.Fatalf("expected explicit true to enable sealos bootstrap") + } + }) +} + +func TestGetStandaloneLoginEnabledDefaultsToTrue(t *testing.T) { + t.Run("default enabled", func(t *testing.T) { + t.Setenv("WHODB_STANDALONE_LOGIN_ENABLED", "") + if !GetStandaloneLoginEnabled() { + t.Fatalf("expected standalone login to default to enabled") + } + }) + + t.Run("explicit false disables", func(t *testing.T) { + t.Setenv("WHODB_STANDALONE_LOGIN_ENABLED", "false") + if GetStandaloneLoginEnabled() { + t.Fatalf("expected explicit false to disable standalone login") + } + }) +} diff --git a/dataflow/core/src/envconfig/envconfig.go b/dataflow/core/src/envconfig/envconfig.go new file mode 100644 index 0000000..195c064 --- /dev/null +++ b/dataflow/core/src/envconfig/envconfig.go @@ -0,0 +1,296 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +// Package envconfig provides configuration-loading functions that depend on +// both the env and log packages. It exists to break the circular dependency +// that would occur if env imported log directly. +package envconfig + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/migrate" + "github.com/clidey/whodb/core/src/types" +) + +// GetDefaultDatabaseCredentials reads database credentials from environment +// variables. It first checks WHODB_ for a JSON array, then falls back +// to numbered WHODB__1, WHODB__2, etc. +func GetDefaultDatabaseCredentials(databaseType string) []types.DatabaseCredentials { + uppercaseDatabaseType := strings.ToUpper(databaseType) + credEnvVar := fmt.Sprintf("WHODB_%s", uppercaseDatabaseType) + credEnvValue := os.Getenv(credEnvVar) + + if credEnvValue == "" { + return findAllDatabaseCredentials(databaseType) + } + + var creds []types.DatabaseCredentials + err := json.Unmarshal([]byte(credEnvValue), &creds) + if err != nil { + log.Error("🔴 [Database Error] Failed to parse database credentials from environment variable! Error: ", err) + return nil + } + + return creds +} + +func findAllDatabaseCredentials(databaseType string) []types.DatabaseCredentials { + uppercaseDatabaseType := strings.ToUpper(databaseType) + i := 1 + var profiles []types.DatabaseCredentials + + for { + databaseProfile := os.Getenv(fmt.Sprintf("WHODB_%s_%d", uppercaseDatabaseType, i)) + if databaseProfile == "" { + break + } + + var creds types.DatabaseCredentials + err := json.Unmarshal([]byte(databaseProfile), &creds) + if err != nil { + log.Error("Unable to parse database credential: ", err) + break + } + + profiles = append(profiles, creds) + i++ + } + + return profiles +} + +// GetAWSProvidersFromEnv parses the WHODB_AWS_PROVIDER environment variable +// into a slice of AWS provider configurations, applying defaults. +func GetAWSProvidersFromEnv() ([]env.AWSProviderEnvConfig, error) { + val := os.Getenv("WHODB_AWS_PROVIDER") + if val == "" { + return nil, nil + } + + var configs []env.AWSProviderEnvConfig + if err := json.Unmarshal([]byte(val), &configs); err != nil { + log.Error("[AWS Provider] Failed to parse WHODB_AWS_PROVIDER: ", err) + return nil, err + } + + // Apply defaults + for i := range configs { + if configs[i].DiscoverRDS == nil { + t := true + configs[i].DiscoverRDS = &t + } + if configs[i].DiscoverElastiCache == nil { + t := true + configs[i].DiscoverElastiCache = &t + } + if configs[i].DiscoverDocumentDB == nil { + t := true + configs[i].DiscoverDocumentDB = &t + } + } + + return configs, nil +} + +// ParseGenericProviders reads WHODB_AI_GENERIC_* environment variables to +// discover generic AI provider configurations. +// +// Environment variable format: +// +// WHODB_AI_GENERIC__NAME="Provider Display Name" +// WHODB_AI_GENERIC__TYPE="openai-generic" +// WHODB_AI_GENERIC__BASE_URL="https://api.example.com/v1" +// WHODB_AI_GENERIC__API_KEY="sk-..." +// WHODB_AI_GENERIC__MODELS="model-1,model-2,model-3" +func ParseGenericProviders() []env.GenericProviderConfig { + var providers []env.GenericProviderConfig + processed := make(map[string]bool) + + // Iterate through all environment variables to find WHODB_AI_GENERIC_* patterns + for _, envVar := range os.Environ() { + if !strings.HasPrefix(envVar, "WHODB_AI_GENERIC_") { + continue + } + + parts := strings.SplitN(envVar, "=", 2) + key := parts[0] + + // Extract provider ID from key (e.g., "MISTRAL" from "WHODB_AI_GENERIC_MISTRAL_NAME") + keyParts := strings.Split(key, "_") + if len(keyParts) < 5 { + continue + } + + // ID is everything between WHODB_AI_GENERIC_ and the final field + idParts := keyParts[3 : len(keyParts)-1] + providerID := strings.Join(idParts, "_") + + if processed[providerID] { + continue + } + + // Read all fields for this provider + prefix := fmt.Sprintf("WHODB_AI_GENERIC_%s_", providerID) + name := os.Getenv(prefix + "NAME") + clientType := os.Getenv(prefix + "TYPE") + baseURL := os.Getenv(prefix + "BASE_URL") + apiKey := os.Getenv(prefix + "API_KEY") + modelsStr := os.Getenv(prefix + "MODELS") + + // Validate required fields + if baseURL == "" || modelsStr == "" { + log.Warnf("Incomplete generic provider config for %s, skipping (missing base_url or models)", providerID) + continue + } + + // Parse models + models := common.FilterList(strings.Split(modelsStr, ","), func(item string) bool { + return strings.TrimSpace(item) != "" + }) + + if len(models) == 0 { + log.Warnf("No models specified for generic provider %s, skipping", providerID) + continue + } + + // Default values + if name == "" { + name = providerID + } + if clientType == "" { + clientType = "openai-generic" // Default to OpenAI-compatible + } + + providers = append(providers, env.GenericProviderConfig{ + ProviderId: strings.ToLower(providerID), + Name: name, + ClientType: clientType, + BaseURL: baseURL, + APIKey: apiKey, + Models: models, + }) + + processed[providerID] = true + } + + if len(providers) > 0 { + log.Infof("Discovered %d generic AI provider(s)", len(providers)) + for _, provider := range providers { + log.Infof(" - %s (%s) with %d model(s)", provider.Name, provider.ProviderId, len(provider.Models)) + } + } + + return providers +} + +// GetConfiguredChatProviders returns all configured AI chat providers from +// environment variables and dynamically registered generic providers. +func GetConfiguredChatProviders() []env.ChatProvider { + var providers []env.ChatProvider + + if len(env.OpenAIAPIKey) > 0 { + name := env.OpenAIName + if name == "" { + name = "OpenAI" + } + providers = append(providers, env.ChatProvider{ + Type: "OpenAI", + Name: name, + APIKey: env.OpenAIAPIKey, + Endpoint: env.GetOpenAIEndpoint(), + ProviderId: "openai-1", + }) + } + + if len(env.AnthropicAPIKey) > 0 { + name := env.AnthropicName + if name == "" { + name = "Anthropic" + } + providers = append(providers, env.ChatProvider{ + Type: "Anthropic", + Name: name, + APIKey: env.AnthropicAPIKey, + Endpoint: env.GetAnthropicEndpoint(), + ProviderId: "anthropic-1", + }) + } + + // Flag if legacy OpenAI-Compatible env vars are still set + if os.Getenv("WHODB_OPENAI_COMPATIBLE_ENDPOINT") != "" || os.Getenv("WHODB_OPENAI_COMPATIBLE_API_KEY") != "" || os.Getenv("WHODB_CUSTOM_MODELS") != "" { + migrate.DeprecatedOpenAICompatibleEnv = true + } + + // Add all generic providers + for _, genericProvider := range env.GenericProviders { + providers = append(providers, env.ChatProvider{ + Type: genericProvider.ProviderId, + Name: genericProvider.Name, + APIKey: genericProvider.APIKey, + Endpoint: common.ResolveLocalURL(genericProvider.BaseURL), + ProviderId: genericProvider.ProviderId, + ClientType: genericProvider.ClientType, + IsGeneric: true, + }) + } + + name := env.OllamaName + if name == "" { + name = "Ollama" + } + providers = append(providers, env.ChatProvider{ + Type: "Ollama", + Name: name, + APIKey: "", + Endpoint: env.GetOllamaEndpoint(), + ProviderId: "ollama-1", + }) + + return providers +} + +// ResolveProviderCredentials looks up a provider by ID and resolves credentials. +// Request-level values take precedence over environment-configured values. +func ResolveProviderCredentials(providerId, requestToken, requestEndpoint, requestModelType string) env.ResolvedCredentials { + result := env.ResolvedCredentials{ + ModelType: requestModelType, + Token: requestToken, + Endpoint: requestEndpoint, + } + if providerId == "" { + return result + } + for _, provider := range GetConfiguredChatProviders() { + if provider.ProviderId != providerId { + continue + } + if result.Token == "" { + result.Token = provider.APIKey + } + if result.Endpoint == "" { + result.Endpoint = provider.Endpoint + } + break + } + return result +} diff --git a/dataflow/core/src/envconfig/envconfig_providers_test.go b/dataflow/core/src/envconfig/envconfig_providers_test.go new file mode 100644 index 0000000..766c117 --- /dev/null +++ b/dataflow/core/src/envconfig/envconfig_providers_test.go @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package envconfig + +import ( + "testing" + + "github.com/clidey/whodb/core/src/env" +) + +func TestResolveProviderCredentials_PreservesModelType(t *testing.T) { + origProviders := env.GenericProviders + t.Cleanup(func() { env.GenericProviders = origProviders }) + + env.GenericProviders = []env.GenericProviderConfig{ + { + ProviderId: "lmstudio", + Name: "LM Studio", + ClientType: "openai-generic", + BaseURL: "http://localhost:1234/v1", + APIKey: "lms-key", + }, + } + + result := ResolveProviderCredentials("lmstudio", "", "", "lmstudio") + + if result.ModelType != "lmstudio" { + t.Fatalf("expected ModelType to stay 'lmstudio' (ProviderId), got %q — ClientType override regression", result.ModelType) + } + if result.Token != "lms-key" { + t.Fatalf("expected Token to be filled from provider config, got %q", result.Token) + } + if result.Endpoint == "" { + t.Fatalf("expected Endpoint to be filled from provider config, got empty string") + } +} + +func TestResolveProviderCredentials_RequestValuesOverrideConfig(t *testing.T) { + origProviders := env.GenericProviders + t.Cleanup(func() { env.GenericProviders = origProviders }) + + env.GenericProviders = []env.GenericProviderConfig{ + { + ProviderId: "test-provider", + Name: "Test", + ClientType: "openai-generic", + BaseURL: "http://config-url", + APIKey: "config-key", + }, + } + + result := ResolveProviderCredentials("test-provider", "request-key", "http://request-url", "test-provider") + + if result.Token != "request-key" { + t.Fatalf("expected request-level token to take precedence, got %q", result.Token) + } + if result.Endpoint != "http://request-url" { + t.Fatalf("expected request-level endpoint to take precedence, got %q", result.Endpoint) + } +} + +func TestGetConfiguredChatProviders(t *testing.T) { + originalOpenAI := env.OpenAIAPIKey + originalOpenAIEndpoint := env.OpenAIEndpoint + originalAnthropic := env.AnthropicAPIKey + origHost, origPort := env.OllamaHost, env.OllamaPort + + t.Cleanup(func() { + env.OpenAIAPIKey = originalOpenAI + env.OpenAIEndpoint = originalOpenAIEndpoint + env.AnthropicAPIKey = originalAnthropic + env.OllamaHost, env.OllamaPort = origHost, origPort + }) + + env.OpenAIAPIKey = "openai-key" + env.OpenAIEndpoint = "https://custom.openai/api" + env.AnthropicAPIKey = "anthropic-key" + env.OllamaHost = "ollama.local" + env.OllamaPort = "1234" + + providers := GetConfiguredChatProviders() + if len(providers) != 3 { + t.Fatalf("expected three providers (OpenAI, Anthropic, Ollama), got %d", len(providers)) + } + + if providers[0].Type != "OpenAI" || providers[0].Endpoint != env.OpenAIEndpoint { + t.Fatalf("expected OpenAI provider to use custom endpoint, got %+v", providers[0]) + } + if providers[1].Type != "Anthropic" { + t.Fatalf("expected Anthropic provider present, got %+v", providers[1]) + } + if providers[2].Type != "Ollama" || providers[2].Endpoint != "http://ollama.local:1234/api" { + t.Fatalf("expected Ollama provider to use overridden host/port, got %+v", providers[2]) + } +} diff --git a/dataflow/core/src/envconfig/envconfig_test.go b/dataflow/core/src/envconfig/envconfig_test.go new file mode 100644 index 0000000..91b2bfe --- /dev/null +++ b/dataflow/core/src/envconfig/envconfig_test.go @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package envconfig + +import "testing" + +func TestGetDefaultDatabaseCredentialsParsesEnv(t *testing.T) { + t.Setenv("WHODB_POSTGRES", `[{"host":"db.local","user":"alice","password":"secret","database":"app"}]`) + + creds := GetDefaultDatabaseCredentials("postgres") + if len(creds) != 1 { + t.Fatalf("expected one credential parsed from env, got %d", len(creds)) + } + + if creds[0].Hostname != "db.local" || creds[0].Username != "alice" || creds[0].Database != "app" { + t.Fatalf("unexpected credentials parsed: %+v", creds[0]) + } +} + +func TestFindAllDatabaseCredentialsFallback(t *testing.T) { + t.Setenv("WHODB_MYSQL", "") + t.Setenv("WHODB_MYSQL_1", `{"host":"mysql.local","user":"bob","password":"pw","database":"northwind"}`) + + creds := GetDefaultDatabaseCredentials("mysql") + if len(creds) != 1 { + t.Fatalf("expected fallback credentials to be discovered, got %d", len(creds)) + } + if creds[0].Hostname != "mysql.local" || creds[0].Username != "bob" { + t.Fatalf("unexpected fallback credential: %+v", creds[0]) + } +} diff --git a/dataflow/core/src/llm/llm_client.go b/dataflow/core/src/llm/llm_client.go new file mode 100644 index 0000000..bce9c45 --- /dev/null +++ b/dataflow/core/src/llm/llm_client.go @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package llm + +import ( + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/llm/providers" + "github.com/clidey/whodb/core/src/log" +) + +func init() { + // Register built-in providers + providers.RegisterProvider(providers.NewOpenAIProvider()) + providers.RegisterProvider(providers.NewAnthropicProvider()) + providers.RegisterProvider(providers.NewOllamaProvider()) + + // Wire BAML config resolution to use the provider registry + common.RegisterBAMLConfigResolver(providers.GetBAMLConfig) +} + +// RegisterGenericProviders registers generic AI providers from environment configuration. +// This is called by the env package after parsing generic provider configs and by EE providers. +// It registers the provider with both the LLM provider system (for backend operations) +// and the env.GenericProviders list (for frontend display). +func RegisterGenericProviders(name string, providerId string, models []string, clientType string, baseURL string, apiKey string) { + provider := providers.NewGenericProvider(providerId, name, models, clientType) + providers.RegisterProvider(provider) + + // Also add to env.GenericProviders so it shows up in the frontend + env.AddGenericProvider(env.GenericProviderConfig{ + ProviderId: providerId, + Name: name, + ClientType: clientType, + BaseURL: baseURL, + APIKey: apiKey, + Models: models, + }) +} + +// Type aliases for backward compatibility with llm package +type LLMType = providers.LLMType +type LLMModel = providers.LLMModel + +const ( + Ollama_LLMType = providers.Ollama_LLMType + OpenAI_LLMType = providers.OpenAI_LLMType + Anthropic_LLMType = providers.Anthropic_LLMType +) + +type LLMClient struct { + Type LLMType + APIKey string + ProfileId string +} + +// getEndpointForProvider returns the appropriate endpoint for a provider type +func getEndpointForProvider(providerType LLMType) string { + switch providerType { + case OpenAI_LLMType: + return env.GetOpenAIEndpoint() + case Anthropic_LLMType: + return env.GetAnthropicEndpoint() + case Ollama_LLMType: + return env.GetOllamaEndpoint() + default: + // For generic providers, look up endpoint from environment configuration + for _, provider := range env.GenericProviders { + if providers.LLMType(provider.ProviderId) == providerType { + return provider.BaseURL + } + } + log.Warnf("No endpoint found for provider type: %s", providerType) + return "" + } +} + +func (c *LLMClient) GetSupportedModels() ([]string, error) { + // Get provider from registry + provider, err := providers.GetProvider(c.Type) + if err != nil { + log.WithError(err).Errorf("Provider not found for type: %s", c.Type) + return nil, err + } + + // Build provider config with endpoint from environment + config := &providers.ProviderConfig{ + Type: c.Type, + APIKey: c.APIKey, + Endpoint: getEndpointForProvider(c.Type), + } + + // Use provider to get supported models + return provider.GetSupportedModels(config) +} diff --git a/dataflow/core/src/llm/llm_instance.go b/dataflow/core/src/llm/llm_instance.go new file mode 100644 index 0000000..f08aa11 --- /dev/null +++ b/dataflow/core/src/llm/llm_instance.go @@ -0,0 +1,27 @@ +// Copyright 2025 Clidey, Inc. +// +// 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. + +package llm + +import ( + "github.com/clidey/whodb/core/src/engine" +) + +// Instance returns an LLMClient configured from the given plugin config. +func Instance(config *engine.PluginConfig) *LLMClient { + return &LLMClient{ + Type: LLMType(config.ExternalModel.Type), + APIKey: config.ExternalModel.Token, + } +} diff --git a/dataflow/core/src/llm/llm_test.go b/dataflow/core/src/llm/llm_test.go new file mode 100644 index 0000000..925199a --- /dev/null +++ b/dataflow/core/src/llm/llm_test.go @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package llm + +import ( + "testing" + + "github.com/clidey/whodb/core/src/engine" +) + +func TestInstanceReturnsCorrectClient(t *testing.T) { + cfg := &engine.PluginConfig{ExternalModel: &engine.ExternalModel{Type: string(OpenAI_LLMType), Token: "key1"}} + client := Instance(cfg) + if client.Type != OpenAI_LLMType { + t.Fatalf("expected type %s, got %s", OpenAI_LLMType, client.Type) + } + if client.APIKey != "key1" { + t.Fatalf("expected API key 'key1', got %s", client.APIKey) + } + + // Different config produces a client with the new values + cfg2 := &engine.PluginConfig{ExternalModel: &engine.ExternalModel{Type: string(Ollama_LLMType), Token: "key2"}} + client2 := Instance(cfg2) + if client2.Type != Ollama_LLMType { + t.Fatalf("expected type %s, got %s", Ollama_LLMType, client2.Type) + } + if client2.APIKey != "key2" { + t.Fatalf("expected API key 'key2', got %s", client2.APIKey) + } +} + +func TestGetSupportedModelsReturnsErrorForUnsupportedType(t *testing.T) { + client := LLMClient{Type: LLMType("Unknown")} + if _, err := client.GetSupportedModels(); err == nil { + t.Fatalf("expected error for unsupported type") + } +} diff --git a/dataflow/core/src/llm/providers/anthropic_provider.go b/dataflow/core/src/llm/providers/anthropic_provider.go new file mode 100644 index 0000000..da3bd51 --- /dev/null +++ b/dataflow/core/src/llm/providers/anthropic_provider.go @@ -0,0 +1,122 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package providers + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/clidey/whodb/core/src/log" +) + +const ( + Anthropic_LLMType LLMType = "Anthropic" +) + +// AnthropicProvider implements the AIProvider interface for Anthropic's Claude API. +type AnthropicProvider struct{} + +// NewAnthropicProvider creates a new Anthropic provider instance. +func NewAnthropicProvider() *AnthropicProvider { + return &AnthropicProvider{} +} + +// GetType returns the provider type. +func (p *AnthropicProvider) GetType() LLMType { + return Anthropic_LLMType +} + +// GetProtocol returns "anthropic" — the streaming protocol family. +func (p *AnthropicProvider) GetProtocol() string { return "anthropic" } + +// GetDefaultEndpoint returns the default Anthropic API endpoint. +func (p *AnthropicProvider) GetDefaultEndpoint() string { + return "https://api.anthropic.com/v1" +} + +// ValidateConfig validates the provider configuration. +func (p *AnthropicProvider) ValidateConfig(config *ProviderConfig) error { + if config.APIKey == "" { + return errors.New("API key is required for Anthropic") + } + if config.Endpoint == "" { + config.Endpoint = p.GetDefaultEndpoint() + } + return nil +} + +// GetSupportedModels fetches the list of available models from Anthropic. +func (p *AnthropicProvider) GetSupportedModels(config *ProviderConfig) ([]string, error) { + if err := p.ValidateConfig(config); err != nil { + return nil, err + } + + url := fmt.Sprintf("%s/models", config.Endpoint) + headers := map[string]string{ + "x-api-key": config.APIKey, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + } + + resp, err := sendHTTPRequest("GET", url, nil, headers) + if err != nil { + log.WithError(err).Errorf("Failed to fetch models from Anthropic at %s", url) + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Errorf("Anthropic models endpoint returned non-OK status: %d, body: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("failed to fetch models: %s", string(body)) + } + + var modelsResp struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&modelsResp); err != nil { + log.WithError(err).Error("Failed to decode Anthropic models response") + return nil, err + } + + // Filter to only include claude-* models (all Claude models are chat-compatible) + var models []string + for _, model := range modelsResp.Data { + if strings.HasPrefix(model.ID, "claude-") { + models = append(models, model.ID) + } + } + return models, nil +} + +// CreateBAMLClient creates BAML client type and options for Anthropic. +func (p *AnthropicProvider) CreateBAMLClient(config *ProviderConfig, model string) (string, map[string]any, error) { + opts := map[string]any{"model": model} + if config.APIKey != "" { + opts["api_key"] = config.APIKey + } + if config.Endpoint != "" && config.Endpoint != p.GetDefaultEndpoint() { + opts["base_url"] = config.Endpoint + } + return "anthropic", opts, nil +} diff --git a/dataflow/core/src/llm/providers/generic_provider.go b/dataflow/core/src/llm/providers/generic_provider.go new file mode 100644 index 0000000..35db221 --- /dev/null +++ b/dataflow/core/src/llm/providers/generic_provider.go @@ -0,0 +1,102 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package providers + +import ( + "fmt" +) + +// GenericProvider implements the AIProvider interface for any OpenAI-compatible API. +// This enables support for Mistral, Cohere, Google Gemini, and other providers +// that implement the OpenAI API specification. +type GenericProvider struct { + providerId string + name string + models []string + clientType string +} + +// NewGenericProvider creates a new generic provider instance. +func NewGenericProvider(providerId, name string, models []string, clientType string) *GenericProvider { + if clientType == "" { + clientType = "openai-generic" // Default to OpenAI-compatible + } + return &GenericProvider{ + providerId: providerId, + name: name, + models: models, + clientType: clientType, + } +} + +// GetType returns the provider type (unique per generic provider instance). +func (p *GenericProvider) GetType() LLMType { + return LLMType(p.providerId) +} + +// GetProtocol returns the streaming protocol family based on the configured client type. +// Maps "anthropic" → "anthropic", "aws-bedrock" → "aws-bedrock", everything else → "openai". +func (p *GenericProvider) GetProtocol() string { + switch p.clientType { + case "anthropic": + return "anthropic" + case "aws-bedrock": + return "aws-bedrock" + default: + return "openai" + } +} + +// GetDefaultEndpoint returns an empty string as generic providers must specify their endpoint. +func (p *GenericProvider) GetDefaultEndpoint() string { + return "" // No default - must be configured +} + +// ValidateConfig validates the provider configuration. +func (p *GenericProvider) ValidateConfig(config *ProviderConfig) error { + if config.Endpoint == "" { + return fmt.Errorf("endpoint is required for generic provider %s", p.name) + } + return nil +} + +// GetSupportedModels returns the pre-configured list of models. +// Generic providers don't support dynamic model discovery - models must be specified in config. +func (p *GenericProvider) GetSupportedModels(config *ProviderConfig) ([]string, error) { + return p.models, nil +} + +// CreateBAMLClient creates BAML client type and options for the generic provider. +func (p *GenericProvider) CreateBAMLClient(config *ProviderConfig, model string) (string, map[string]any, error) { + if err := p.ValidateConfig(config); err != nil { + return "", nil, err + } + + opts := map[string]any{ + "base_url": config.Endpoint, + "model": model, + "default_role": "user", + "request_timeout_ms": 60000, + } + + // Only include api_key if provided + if config.APIKey != "" { + opts["api_key"] = config.APIKey + } + + return p.clientType, opts, nil +} diff --git a/dataflow/core/src/llm/providers/ollama_provider.go b/dataflow/core/src/llm/providers/ollama_provider.go new file mode 100644 index 0000000..8a226b6 --- /dev/null +++ b/dataflow/core/src/llm/providers/ollama_provider.go @@ -0,0 +1,119 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package providers + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/log" +) + +const ( + Ollama_LLMType LLMType = "Ollama" +) + +// OllamaProvider implements the AIProvider interface for local Ollama instances. +type OllamaProvider struct{} + +// NewOllamaProvider creates a new Ollama provider instance. +func NewOllamaProvider() *OllamaProvider { + return &OllamaProvider{} +} + +// GetType returns the provider type. +func (p *OllamaProvider) GetType() LLMType { + return Ollama_LLMType +} + +// GetProtocol returns "openai" — Ollama uses the OpenAI-compatible streaming protocol. +func (p *OllamaProvider) GetProtocol() string { return "openai" } + +// GetDefaultEndpoint returns the default Ollama API endpoint, resolved for the current environment +// (Docker, WSL2, or custom WHODB_OLLAMA_HOST/PORT). +func (p *OllamaProvider) GetDefaultEndpoint() string { + host, port := env.GetOllamaHost() + return fmt.Sprintf("http://%s:%s/api", host, port) +} + +// ValidateConfig validates the provider configuration. +func (p *OllamaProvider) ValidateConfig(config *ProviderConfig) error { + if config.Endpoint == "" { + config.Endpoint = p.GetDefaultEndpoint() + } + return nil +} + +// GetSupportedModels fetches the list of available models from Ollama. +func (p *OllamaProvider) GetSupportedModels(config *ProviderConfig) ([]string, error) { + if err := p.ValidateConfig(config); err != nil { + return nil, err + } + + url := fmt.Sprintf("%s/tags", config.Endpoint) + + resp, err := sendHTTPRequest("GET", url, nil, nil) + if err != nil { + log.WithError(err).Errorf("Failed to fetch models from Ollama at %s", url) + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Errorf("Ollama models endpoint returned non-OK status: %d, body: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("failed to fetch models: %s", string(body)) + } + + var modelsResp struct { + Models []struct { + Name string `json:"model"` + } `json:"models"` + } + if err := json.NewDecoder(resp.Body).Decode(&modelsResp); err != nil { + log.WithError(err).Error("Failed to decode Ollama models response") + return nil, err + } + + models := []string{} + for _, model := range modelsResp.Models { + models = append(models, model.Name) + } + return models, nil +} + +// CreateBAMLClient creates BAML client type and options for Ollama. +// Ollama uses the openai-generic client type as it's OpenAI-compatible. +func (p *OllamaProvider) CreateBAMLClient(config *ProviderConfig, model string) (string, map[string]any, error) { + if err := p.ValidateConfig(config); err != nil { + return "", nil, err + } + + // Ollama expects base_url without /api suffix for OpenAI-compatible endpoint + baseURL := strings.TrimSuffix(config.Endpoint, "/api") + "/v1" + + return "openai-generic", map[string]any{ + "base_url": baseURL, + "model": model, + "default_role": "user", + "request_timeout_ms": 60000, + }, nil +} diff --git a/dataflow/core/src/llm/providers/openai_provider.go b/dataflow/core/src/llm/providers/openai_provider.go new file mode 100644 index 0000000..233a6c6 --- /dev/null +++ b/dataflow/core/src/llm/providers/openai_provider.go @@ -0,0 +1,234 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package providers + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + + "github.com/clidey/whodb/core/src/log" +) + +// responsesAPICache caches whether a given endpoint supports the Responses API. +// Keyed by endpoint URL, value is bool (true = supports Responses API). +var responsesAPICache sync.Map + +const ( + OpenAI_LLMType LLMType = "OpenAI" +) + +// OpenAIProvider implements the AIProvider interface for OpenAI's API. +type OpenAIProvider struct{} + +// NewOpenAIProvider creates a new OpenAI provider instance. +func NewOpenAIProvider() *OpenAIProvider { + return &OpenAIProvider{} +} + +// GetType returns the provider type. +func (p *OpenAIProvider) GetType() LLMType { + return OpenAI_LLMType +} + +// GetProtocol returns "openai" — the streaming protocol family. +func (p *OpenAIProvider) GetProtocol() string { return "openai" } + +// GetDefaultEndpoint returns the default OpenAI API endpoint. +func (p *OpenAIProvider) GetDefaultEndpoint() string { + return "https://api.openai.com/v1" +} + +// ValidateConfig validates the provider configuration. +func (p *OpenAIProvider) ValidateConfig(config *ProviderConfig) error { + if config.APIKey == "" { + return errors.New("API key is required for OpenAI") + } + if config.Endpoint == "" { + config.Endpoint = p.GetDefaultEndpoint() + } + return nil +} + +// GetSupportedModels fetches the list of available models from OpenAI. +func (p *OpenAIProvider) GetSupportedModels(config *ProviderConfig) ([]string, error) { + if err := p.ValidateConfig(config); err != nil { + return nil, err + } + + url := fmt.Sprintf("%s/models", config.Endpoint) + headers := map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", config.APIKey), + "Content-Type": "application/json", + } + + resp, err := sendHTTPRequest("GET", url, nil, headers) + if err != nil { + log.WithError(err).Errorf("Failed to fetch models from OpenAI at %s", url) + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Errorf("OpenAI models endpoint returned non-OK status: %d, body: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("failed to fetch models: %s", string(body)) + } + + var modelsResp struct { + Models []struct { + Name string `json:"id"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&modelsResp); err != nil { + log.WithError(err).Error("Failed to decode OpenAI models response") + return nil, err + } + + // Filter out non-chat models using a blocklist approach. + // New model families pass through automatically. + var models []string + for _, model := range modelsResp.Models { + name := model.Name + if isNonChatModel(name) { + continue + } + models = append(models, name) + } + return models, nil +} + +// CreateBAMLClient creates BAML client type and options for OpenAI. +// It probes the endpoint to determine whether the Responses API is available, +// falling back to Chat Completions if not. The probe result is cached per endpoint. +func (p *OpenAIProvider) CreateBAMLClient(config *ProviderConfig, model string) (string, map[string]any, error) { + endpoint := config.Endpoint + if endpoint == "" { + endpoint = p.GetDefaultEndpoint() + } + + clientType := "openai" + if p.supportsResponsesAPI(endpoint, config.APIKey) { + clientType = "openai-responses" + } + + opts := map[string]any{"model": model} + if config.APIKey != "" { + opts["api_key"] = config.APIKey + } + if config.Endpoint != "" && config.Endpoint != p.GetDefaultEndpoint() { + opts["base_url"] = config.Endpoint + } + return clientType, opts, nil +} + +// supportsResponsesAPI probes the endpoint to check for Responses API support. +// Results are cached in responsesAPICache (keyed by endpoint URL). +// +// Logic: POST to {endpoint}/responses with empty JSON body. +// - 404/405 → endpoint not found → Chat Completions only (cached) +// - Any other status (400, 401, 422, etc.) → endpoint exists → Responses API available (cached) +// - Network error → safe fallback to Chat Completions (not cached, will retry next call) +func (p *OpenAIProvider) supportsResponsesAPI(endpoint, apiKey string) bool { + if cached, ok := responsesAPICache.Load(endpoint); ok { + return cached.(bool) + } + + probeURL := endpoint + "/responses" + headers := map[string]string{ + "Content-Type": "application/json", + } + if apiKey != "" { + headers["Authorization"] = "Bearer " + apiKey + } + + resp, err := sendHTTPRequest("POST", probeURL, []byte("{}"), headers) + if err != nil { + log.Debugf("Responses API probe failed for %s (network error), falling back to Chat Completions", endpoint) + return false + } + defer resp.Body.Close() + io.ReadAll(resp.Body) + + supports := resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusMethodNotAllowed + if supports { + log.Debugf("Responses API available at %s (probe status: %d)", endpoint, resp.StatusCode) + } else { + log.Debugf("Responses API not available at %s (probe status: %d), using Chat Completions", endpoint, resp.StatusCode) + } + + responsesAPICache.Store(endpoint, supports) + return supports +} + +// isNonChatModel returns true for model IDs that are not chat-compatible. +// Uses a blocklist so new chat model families pass through automatically. +func isNonChatModel(name string) bool { + // Non-chat model families (prefix match) + nonChatPrefixes := []string{ + "dall-e-", // image generation + "whisper-", // speech-to-text + "tts-", // text-to-speech + "text-embedding-", // embeddings + "text-moderation-", // moderation + "gpt-image-", // image generation + "babbage-", // legacy completions + "davinci-", // legacy completions + } + for _, prefix := range nonChatPrefixes { + if strings.HasPrefix(name, prefix) { + return true + } + } + + // Non-chat model suffixes/infixes + nonChatPatterns := []string{"-instruct", "-base", "-codex"} + for _, pattern := range nonChatPatterns { + if strings.Contains(name, pattern) { + return true + } + } + + // Non-chat model infixes (partial name matches) + if strings.Contains(name, "-transcribe") || + strings.Contains(name, "-realtime") { + return true + } + + return false +} + +// sendHTTPRequest is a helper function to send HTTP requests. +// This is duplicated from http_client.go to avoid circular dependencies. +// TODO: Consider refactoring to a shared utility package. +func sendHTTPRequest(method, url string, body []byte, headers map[string]string) (*http.Response, error) { + req, err := http.NewRequest(method, url, strings.NewReader(string(body))) + if err != nil { + return nil, err + } + + for key, value := range headers { + req.Header.Set(key, value) + } + + client := &http.Client{} + return client.Do(req) +} diff --git a/dataflow/core/src/llm/providers/provider.go b/dataflow/core/src/llm/providers/provider.go new file mode 100644 index 0000000..0787ac4 --- /dev/null +++ b/dataflow/core/src/llm/providers/provider.go @@ -0,0 +1,160 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package providers + +import ( + "errors" + "fmt" +) + +// LLMType represents the type of LLM provider. +type LLMType string + +// LLMModel represents a specific model within a provider. +type LLMModel string + +// AIProvider defines the interface that all LLM providers must implement. +// This follows WhoDB's plugin architecture pattern (similar to database plugins). +type AIProvider interface { + // Metadata methods + GetType() LLMType + + // GetProtocol returns the API protocol family this provider uses for streaming. + // Values: "openai", "anthropic", "aws-bedrock". + // This is a static property — no network calls. Used for routing to the correct + // streaming client implementation (e.g. in the agent package). + GetProtocol() string + + // Configuration methods + GetDefaultEndpoint() string + ValidateConfig(config *ProviderConfig) error + + // Core operations + GetSupportedModels(config *ProviderConfig) ([]string, error) + + // BAML integration methods + CreateBAMLClient(config *ProviderConfig, model string) (clientType string, opts map[string]any, err error) +} + +// ProviderConfig holds configuration for a specific provider instance. +type ProviderConfig struct { + Type LLMType + APIKey string + Endpoint string + ProviderId string // e.g., "openai-1", "generic-mistral" + Metadata map[string]string +} + +// ProviderRegistry manages all registered AI providers. +// Uses a map for O(1) lookup by provider type. +type ProviderRegistry struct { + providers map[LLMType]AIProvider +} + +// NewProviderRegistry creates a new provider registry. +func NewProviderRegistry() *ProviderRegistry { + return &ProviderRegistry{ + providers: make(map[LLMType]AIProvider), + } +} + +// Register adds a provider to the registry. +// If a provider with the same type already exists, it will be replaced. +func (r *ProviderRegistry) Register(provider AIProvider) { + r.providers[provider.GetType()] = provider +} + +// Get retrieves a provider by type. +// Returns an error if the provider type is not registered. +func (r *ProviderRegistry) Get(providerType LLMType) (AIProvider, error) { + if provider, ok := r.providers[providerType]; ok { + return provider, nil + } + return nil, fmt.Errorf("unsupported provider type: %s", providerType) +} + +// Has checks if a provider type is registered. +func (r *ProviderRegistry) Has(providerType LLMType) bool { + _, ok := r.providers[providerType] + return ok +} + +// List returns all registered provider types. +func (r *ProviderRegistry) List() []LLMType { + types := make([]LLMType, 0, len(r.providers)) + for providerType := range r.providers { + types = append(types, providerType) + } + return types +} + +// Module-level registry instance +var providerRegistry *ProviderRegistry + +// init initializes the provider registry and registers built-in providers. +// This is called automatically when the package is imported. +func init() { + providerRegistry = NewProviderRegistry() +} + +// GetProvider retrieves a provider from the global registry. +// Returns an error if the provider type is not registered. +func GetProvider(providerType LLMType) (AIProvider, error) { + if providerRegistry == nil { + return nil, errors.New("provider registry not initialized") + } + return providerRegistry.Get(providerType) +} + +// RegisterProvider registers a provider in the global registry. +func RegisterProvider(provider AIProvider) { + if providerRegistry == nil { + providerRegistry = NewProviderRegistry() + } + providerRegistry.Register(provider) +} + +// HasProvider checks if a provider type is registered in the global registry. +func HasProvider(providerType LLMType) bool { + if providerRegistry == nil { + return false + } + return providerRegistry.Has(providerType) +} + +// ListProviders returns all registered provider types from the global registry. +func ListProviders() []LLMType { + if providerRegistry == nil { + return []LLMType{} + } + return providerRegistry.List() +} + +// GetBAMLConfig resolves BAML provider string + options for the given provider type. +// This is the single source of truth for mapping a provider type to BAML configuration. +func GetBAMLConfig(providerType string, apiKey, endpoint, model string) (string, map[string]any, error) { + provider, err := GetProvider(LLMType(providerType)) + if err != nil { + return "", nil, err + } + config := &ProviderConfig{ + Type: LLMType(providerType), + APIKey: apiKey, + Endpoint: endpoint, + } + return provider.CreateBAMLClient(config, model) +} diff --git a/dataflow/core/src/llm/providers/providers_test.go b/dataflow/core/src/llm/providers/providers_test.go new file mode 100644 index 0000000..7ea10c1 --- /dev/null +++ b/dataflow/core/src/llm/providers/providers_test.go @@ -0,0 +1,119 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package providers + +import ( + "testing" +) + +// --- GetBAMLConfig routing tests --- + +func TestGetBAMLConfig_RoutesToBuiltInProviders(t *testing.T) { + RegisterProvider(NewOpenAIProvider()) + RegisterProvider(NewAnthropicProvider()) + RegisterProvider(NewOllamaProvider()) + + tests := []struct { + providerType string + expectedBAMLType string + }{ + // OpenAI probes https://example.com/responses — likely 404 in tests, + // so falls back to "openai" (Chat Completions). This tests the fallback path. + {"OpenAI", "openai"}, + {"Anthropic", "anthropic"}, + {"Ollama", "openai-generic"}, // Ollama uses openai-generic BAML client + } + + for _, tc := range tests { + t.Run(tc.providerType, func(t *testing.T) { + bamlType, opts, err := GetBAMLConfig(tc.providerType, "test-key", "https://example.com", "test-model") + if err != nil { + t.Fatalf("unexpected error for %s: %v", tc.providerType, err) + } + if bamlType != tc.expectedBAMLType { + t.Fatalf("expected BAML type %q for %s, got %q", tc.expectedBAMLType, tc.providerType, bamlType) + } + if opts["model"] != "test-model" { + t.Fatalf("expected model 'test-model' in opts, got %v", opts["model"]) + } + }) + } +} + +func TestGetBAMLConfig_RoutesToGenericProvider(t *testing.T) { + provider := NewGenericProvider("lmstudio", "LM Studio", []string{"model-1"}, "openai-generic") + RegisterProvider(provider) + + bamlType, opts, err := GetBAMLConfig("lmstudio", "key", "http://localhost:1234/v1", "model-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if bamlType != "openai-generic" { + t.Fatalf("expected BAML type 'openai-generic', got %q", bamlType) + } + if opts["base_url"] != "http://localhost:1234/v1" { + t.Fatalf("expected base_url in opts, got %v", opts["base_url"]) + } + if opts["default_role"] != "user" { + t.Fatalf("expected default_role 'user' for generic provider, got %v", opts["default_role"]) + } + if opts["request_timeout_ms"] != 60000 { + t.Fatalf("expected request_timeout_ms 60000 for generic provider, got %v", opts["request_timeout_ms"]) + } +} + +func TestGetBAMLConfig_ErrorsForUnregisteredProvider(t *testing.T) { + _, _, err := GetBAMLConfig("nonexistent-provider", "", "", "model") + if err == nil { + t.Fatalf("expected error for unregistered provider type") + } +} + +// --- GenericProvider tests (proving default_role/request_timeout_ms are set) --- + +func TestGenericProvider_CreateBAMLClient_IncludesDefaults(t *testing.T) { + p := NewGenericProvider("custom", "Custom Provider", nil, "openai-generic") + config := &ProviderConfig{ + Endpoint: "http://custom.api/v1", + APIKey: "custom-key", + } + + clientType, opts, err := p.CreateBAMLClient(config, "custom-model") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if clientType != "openai-generic" { + t.Fatalf("expected client type 'openai-generic', got %q", clientType) + } + + // These are critical for OpenAI-compatible providers — the regression + // caused them to be missing because the lookup fell through to a bare + // openai-generic fallback without these fields. + if opts["default_role"] != "user" { + t.Fatalf("expected default_role 'user', got %v", opts["default_role"]) + } + if opts["request_timeout_ms"] != 60000 { + t.Fatalf("expected request_timeout_ms 60000, got %v", opts["request_timeout_ms"]) + } + if opts["base_url"] != "http://custom.api/v1" { + t.Fatalf("expected base_url, got %v", opts["base_url"]) + } + if opts["api_key"] != "custom-key" { + t.Fatalf("expected api_key, got %v", opts["api_key"]) + } +} diff --git a/dataflow/core/src/log/log.go b/dataflow/core/src/log/log.go new file mode 100644 index 0000000..c243ac5 --- /dev/null +++ b/dataflow/core/src/log/log.go @@ -0,0 +1,289 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package log + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/clidey/whodb/core/src/env" + "github.com/sirupsen/logrus" +) + +const ( + ServiceName = "whodb" + ISO8601Format = time.RFC3339 +) + +// Fields is an alias for logrus.Fields used in structured logging. +type Fields logrus.Fields + +var logger *logrus.Logger + +// logFile holds the opened log file when WHODB_LOG_FILE is set. +var logFile *os.File + +// accessLogger is a separate logrus instance for HTTP access logs. +// nil unless WHODB_ACCESS_LOG_FILE is set. +var accessLogger *logrus.Logger +var accessLogFile *os.File + +// ConditionalEntry wraps logrus.Entry to downgrade unsupported-operation errors +// from Error to Debug level. All other methods are promoted from *logrus.Entry. +type ConditionalEntry struct { + *logrus.Entry +} + +func Debugf(format string, args ...any) { logger.Debugf(format, args...) } +func Debug(args ...any) { logger.Debug(args...) } +func Infof(format string, args ...any) { logger.Infof(format, args...) } +func Info(args ...any) { logger.Info(args...) } +func Warnf(format string, args ...any) { logger.Warnf(format, args...) } +func Warn(args ...any) { logger.Warn(args...) } +func Errorf(format string, args ...any) { logger.Errorf(format, args...) } +func Error(args ...any) { logger.Error(args...) } +func Fatalf(format string, args ...any) { logger.Fatalf(format, args...) } +func Fatal(args ...any) { logger.Fatal(args...) } + +// Alwaysf logs a formatted message at Info level regardless of the configured log level. +func Alwaysf(format string, args ...any) { + prev := logger.GetLevel() + logger.SetLevel(logrus.InfoLevel) + logger.Infof(format, args...) + logger.SetLevel(prev) +} + +// Always logs a message at Info level regardless of the configured log level. +func Always(args ...any) { + prev := logger.GetLevel() + logger.SetLevel(logrus.InfoLevel) + logger.Info(args...) + logger.SetLevel(prev) +} + +// WithError creates a ConditionalEntry with an error field. +func WithError(err error) *ConditionalEntry { + return &ConditionalEntry{Entry: logger.WithError(err)} +} + +// WithField creates a ConditionalEntry with a single field. +func WithField(key string, value any) *ConditionalEntry { + return &ConditionalEntry{Entry: logger.WithField(key, value)} +} + +// WithFields creates a ConditionalEntry with multiple fields. +func WithFields(fields Fields) *ConditionalEntry { + return &ConditionalEntry{Entry: logger.WithFields(logrus.Fields(fields))} +} + +// Error downgrades to Debug when the attached error is errors.ErrUnsupported. +func (e *ConditionalEntry) Error(args ...any) { + if isUnsupportedOperation(e.Entry.Data["error"]) { + e.Entry.Debug(args...) + return + } + e.Entry.Error(args...) +} + +// Errorf downgrades to Debug when the attached error is errors.ErrUnsupported. +func (e *ConditionalEntry) Errorf(format string, args ...any) { + if isUnsupportedOperation(e.Entry.Data["error"]) { + e.Entry.Debug(args...) + return + } + e.Entry.Errorf(format, args...) +} + +// Chaining methods return *ConditionalEntry to preserve the Error override. +func (e *ConditionalEntry) WithField(key string, value any) *ConditionalEntry { + return &ConditionalEntry{Entry: e.Entry.WithField(key, value)} +} + +func (e *ConditionalEntry) WithFields(fields map[string]any) *ConditionalEntry { + return &ConditionalEntry{Entry: e.Entry.WithFields(fields)} +} + +func (e *ConditionalEntry) WithError(err error) *ConditionalEntry { + return &ConditionalEntry{Entry: e.Entry.WithError(err)} +} + +func isUnsupportedOperation(err any) bool { + if err == nil { + return false + } + if e, ok := err.(error); ok { + return errors.Is(e, errors.ErrUnsupported) + } + return false +} + +// toLogrusLevel converts a log level string to a logrus.Level. +// Accepts everything logrus accepts (debug, info, warn/warning, error, fatal, panic) +// plus "none"/"off"/"disabled" to silence all output. +func toLogrusLevel(level string) logrus.Level { + switch strings.ToLower(level) { + case "", "info": + return logrus.InfoLevel + case "none", "off", "disabled": + return logrus.PanicLevel + default: + parsed, err := logrus.ParseLevel(level) + if err != nil { + return logrus.InfoLevel + } + return parsed + } +} + +type serviceHook struct{} + +func (h *serviceHook) Levels() []logrus.Level { return logrus.AllLevels } + +func (h *serviceHook) Fire(entry *logrus.Entry) error { + entry.Data["service"] = ServiceName + return nil +} + +func init() { + logger = logrus.New() + logger.SetLevel(toLogrusLevel(env.LogLevel)) + configureFormatter() + logger.AddHook(&serviceHook{}) + + if logFilePath := resolveLogPath(env.LogFile, env.DefaultLogFile); logFilePath != "" { + logFile = openLogFile(logFilePath) + logger.SetOutput(logFile) + } + + if accessLogPath := resolveLogPath(env.AccessLogFile, env.DefaultAccessLogFile); accessLogPath != "" { + accessLogFile = openLogFile(accessLogPath) + accessLogger = logrus.New() + accessLogger.SetOutput(accessLogFile) + accessLogger.SetFormatter(&logrus.TextFormatter{ + TimestampFormat: ISO8601Format, + FullTimestamp: true, + }) + } +} + +// resolveLogPath maps the env var value to an actual file path. +// "default" (case-insensitive) returns the provided default path; +// empty stays empty (no file logging); anything else is used as-is. +func resolveLogPath(value string, defaultPath string) string { + if strings.EqualFold(value, "default") { + return defaultPath + } + return value +} + +// openLogFile creates the parent directory and opens the file for appending. +// Exits the process if the directory or file cannot be opened — if file logging +// was explicitly configured, running without it is a misconfiguration. +func openLogFile(path string) *os.File { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0750); err != nil { + fmt.Fprintf(os.Stderr, "whodb: failed to create log directory %s: %v\n", dir, err) + os.Exit(1) + } + if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { + fmt.Fprintf(os.Stderr, "whodb: refusing to open log file %s: path is a symlink\n", path) + os.Exit(1) + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + fmt.Fprintf(os.Stderr, "whodb: failed to open log file %s: %v\n", path, err) + os.Exit(1) + } + return f +} + +func configureFormatter() { + if getLogFormat() == "json" { + logger.SetFormatter(&logrus.JSONFormatter{ + TimestampFormat: ISO8601Format, + FieldMap: logrus.FieldMap{ + logrus.FieldKeyTime: "timestamp", + logrus.FieldKeyLevel: "level", + logrus.FieldKeyMsg: "message", + }, + }) + } else { + logger.SetFormatter(&logrus.TextFormatter{ + TimestampFormat: ISO8601Format, + FullTimestamp: true, + }) + } +} + +func getLogFormat() string { + if strings.EqualFold(env.LogFormat, "json") { + return "json" + } + return "text" +} + +// GetLevel returns the current log level as a string. +func GetLevel() string { + return logger.GetLevel().String() +} + +// SetLogLevel changes the active log level at runtime. +func SetLogLevel(level string) { + logger.SetLevel(toLogrusLevel(level)) +} + +// DisableOutput redirects all log output to io.Discard. +// Used by the TUI where stdout is reserved for terminal rendering. +func DisableOutput() { + if logger != nil { + logger.SetOutput(io.Discard) + } +} + +// CloseLogFile closes the log file and access log file if they were opened. +func CloseLogFile() { + if logFile != nil { + logFile.Close() + logFile = nil + } + if accessLogFile != nil { + accessLogFile.Close() + accessLogFile = nil + accessLogger = nil + } +} + +// LogAccess writes an HTTP access log entry to the dedicated access log file. +// This is a no-op unless WHODB_ACCESS_LOG_FILE is set. +func LogAccess(method, path string, status int, duration time.Duration, host, remoteAddr string) { + if accessLogger == nil { + return + } + accessLogger.WithFields(logrus.Fields{ + "method": method, + "path": path, + "status": status, + "duration": duration, + "host": host, + "remote_addr": remoteAddr, + }).Info("access") +} diff --git a/dataflow/core/src/log/log_test.go b/dataflow/core/src/log/log_test.go new file mode 100644 index 0000000..a9ae68d --- /dev/null +++ b/dataflow/core/src/log/log_test.go @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package log + +import ( + "testing" + + "github.com/sirupsen/logrus" +) + +func TestResolveLogPath(t *testing.T) { + const fallback = "/var/log/whodb/whodb.log" + + cases := []struct { + name string + input string + expected string + }{ + {name: "empty returns empty", input: "", expected: ""}, + {name: "default returns default path", input: "default", expected: fallback}, + {name: "Default case-insensitive", input: "Default", expected: fallback}, + {name: "DEFAULT all caps", input: "DEFAULT", expected: fallback}, + {name: "custom path returned as-is", input: "/tmp/my.log", expected: "/tmp/my.log"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := resolveLogPath(tc.input, fallback); got != tc.expected { + t.Fatalf("resolveLogPath(%q, %q) = %q, expected %q", tc.input, fallback, got, tc.expected) + } + }) + } +} + +func TestToLogrusLevel(t *testing.T) { + cases := []struct { + name string + input string + expected logrus.Level + }{ + {name: "info uppercase", input: "INFO", expected: logrus.InfoLevel}, + {name: "warn", input: "warn", expected: logrus.WarnLevel}, + {name: "warning", input: "warning", expected: logrus.WarnLevel}, + {name: "error mixed case", input: "Error", expected: logrus.ErrorLevel}, + {name: "debug", input: "debug", expected: logrus.DebugLevel}, + {name: "empty defaults to info", input: "", expected: logrus.InfoLevel}, + {name: "none silences output", input: "none", expected: logrus.PanicLevel}, + {name: "off silences output", input: "OFF", expected: logrus.PanicLevel}, + {name: "disabled silences output", input: "Disabled", expected: logrus.PanicLevel}, + {name: "invalid defaults to info", input: "garbage", expected: logrus.InfoLevel}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if level := toLogrusLevel(tc.input); level != tc.expected { + t.Fatalf("toLogrusLevel(%q) = %v, expected %v", tc.input, level, tc.expected) + } + }) + } +} diff --git a/dataflow/core/src/migrate/migrate.go b/dataflow/core/src/migrate/migrate.go new file mode 100644 index 0000000..f0114aa --- /dev/null +++ b/dataflow/core/src/migrate/migrate.go @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +// Package migrate tracks deprecated configuration patterns and collects +// migration warnings at startup. When a deprecation period ends, remove +// the corresponding flag and warning from this file. +// +// This package intentionally has no dependencies to avoid import cycles. +// Flags are set by other packages during parsing; warnings are collected +// and logged by the caller (typically src.InitializeEngine). +package migrate + +// DeprecatedConfigKey is set when a database credential env var uses +// the legacy "config" JSON key instead of "advanced". +var DeprecatedConfigKey bool + +// DeprecatedOpenAICompatibleEnv is set when legacy WHODB_OPENAI_COMPATIBLE_* +// or WHODB_CUSTOM_MODELS env vars are detected. +var DeprecatedOpenAICompatibleEnv bool + +// Warnings returns migration messages for any active deprecations. +func Warnings() []string { + var warnings []string + if DeprecatedConfigKey { + warnings = append(warnings, `Deprecated: database connection profile uses "config" key — please rename to "advanced". The "config" key will be removed in a future release.`) + } + if DeprecatedOpenAICompatibleEnv { + warnings = append(warnings, `Deprecated: WHODB_OPENAI_COMPATIBLE_ENDPOINT, WHODB_OPENAI_COMPATIBLE_API_KEY, and WHODB_CUSTOM_MODELS are deprecated and no longer have any effect. Use WHODB_AI_GENERIC__* environment variables instead.`) + } + return warnings +} diff --git a/dataflow/core/src/migrate/migrate_test.go b/dataflow/core/src/migrate/migrate_test.go new file mode 100644 index 0000000..69f5117 --- /dev/null +++ b/dataflow/core/src/migrate/migrate_test.go @@ -0,0 +1,56 @@ +package migrate + +import "testing" + +func resetFlags() { + DeprecatedConfigKey = false + DeprecatedOpenAICompatibleEnv = false +} + +func TestWarnings_NoDeprecations(t *testing.T) { + resetFlags() + warnings := Warnings() + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %d", len(warnings)) + } +} + +func TestWarnings_DeprecatedConfigKey(t *testing.T) { + resetFlags() + defer resetFlags() + DeprecatedConfigKey = true + + warnings := Warnings() + if len(warnings) != 1 { + t.Fatalf("expected 1 warning, got %d", len(warnings)) + } + if warnings[0] == "" { + t.Error("expected non-empty warning message") + } +} + +func TestWarnings_DeprecatedOpenAICompatibleEnv(t *testing.T) { + resetFlags() + defer resetFlags() + DeprecatedOpenAICompatibleEnv = true + + warnings := Warnings() + if len(warnings) != 1 { + t.Fatalf("expected 1 warning, got %d", len(warnings)) + } + if warnings[0] == "" { + t.Error("expected non-empty warning message") + } +} + +func TestWarnings_MultipleDeprecations(t *testing.T) { + resetFlags() + defer resetFlags() + DeprecatedConfigKey = true + DeprecatedOpenAICompatibleEnv = true + + warnings := Warnings() + if len(warnings) != 2 { + t.Fatalf("expected 2 warnings, got %d", len(warnings)) + } +} diff --git a/dataflow/core/src/mockdata/column_patterns.go b/dataflow/core/src/mockdata/column_patterns.go new file mode 100644 index 0000000..caf26f4 --- /dev/null +++ b/dataflow/core/src/mockdata/column_patterns.go @@ -0,0 +1,245 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mockdata + +import ( + "regexp" + "strings" + + "github.com/brianvoe/gofakeit/v7" +) + +// ColumnPattern defines a mapping from column name patterns to value generators. +// Patterns are matched using word-boundary regex for accurate semantic matching. +type ColumnPattern struct { + Pattern *regexp.Regexp + Name string + Generate func(f *gofakeit.Faker, maxLen int) any +} + +// columnPatterns is ordered from most specific to least specific - first match wins. +var columnPatterns = []ColumnPattern{ + // Email - matches "email", "e_mail", "user_email", "email_address" etc. + {regexp.MustCompile(`(?i)(^|_)e[-_]?mail(_|$)`), "email", genEmail}, + + // Username (before generic name patterns) + {regexp.MustCompile(`(?i)(^|_)(user[-_]?name|uname|login)(_|$)`), "username", genUsername}, + + // First/Last name (before generic "name") + {regexp.MustCompile(`(?i)(^|_)(first[-_]?name|fname|given[-_]?name)(_|$)`), "first_name", genFirstName}, + {regexp.MustCompile(`(?i)(^|_)(last[-_]?name|lname|surname|family[-_]?name)(_|$)`), "last_name", genLastName}, + + // Full name (generic, after specific names) + {regexp.MustCompile(`(?i)^name$|(^|_)(full[-_]?name|display[-_]?name)(_|$)`), "full_name", genFullName}, + + // Phone - matches "phone", "phone_number", "mobile", "cell", "telephone", "tel" + {regexp.MustCompile(`(?i)(^|_)(phone|mobile|cell|telephone|tel)(_|$)`), "phone", genPhone}, + + // IPs - MUST come before address to avoid "ip_address" matching "address" + {regexp.MustCompile(`(?i)(^|_)(ip|ip[-_]?addr(ess)?)(_|$)`), "ip", genIP}, + + // URLs + {regexp.MustCompile(`(?i)^(url|website|link|homepage)$`), "url", genURL}, + + // Address components + {regexp.MustCompile(`(?i)(^|_)(street[-_]?address|address[-_]?line|address|street)(_|$)`), "address", genStreet}, + {regexp.MustCompile(`(?i)^city$`), "city", genCity}, + {regexp.MustCompile(`(?i)^(state|province|region)$`), "state", genState}, + {regexp.MustCompile(`(?i)^country$`), "country", genCountry}, + {regexp.MustCompile(`(?i)(^|_)(zip|postal|postcode)(_|$)`), "zip", genZip}, + + // Business - matches "company", "organization", "org", "company_name" + {regexp.MustCompile(`(?i)(^|_)(company|organization|org)(_|$)`), "company", genCompany}, + {regexp.MustCompile(`(?i)(^|_)(job[-_]?title|title|position|role)(_|$)`), "job_title", genJobTitle}, + + // Content + {regexp.MustCompile(`(?i)(^|_)(description|bio|about|summary)(_|$)`), "description", genDescription}, + + // Geo + {regexp.MustCompile(`(?i)^(latitude|lat)$`), "latitude", genLatitude}, + {regexp.MustCompile(`(?i)^(longitude|lng|lon)$`), "longitude", genLongitude}, + + // Auth - matches "password", "passwd", "pwd", "secret", "api_key", "token" + {regexp.MustCompile(`(?i)(^|_)(password|passwd|pwd|secret|api[-_]?key|token)(_|$)`), "password", genPassword}, +} + +// MatchColumnName returns a generated value if the column name matches a pattern. +func MatchColumnName(colName string, maxLen int, faker *gofakeit.Faker) (any, bool) { + for _, p := range columnPatterns { + if p.Pattern.MatchString(colName) { + return p.Generate(faker, maxLen), true + } + } + return nil, false +} + +func genEmail(f *gofakeit.Faker, maxLen int) any { + email := f.Email() + if maxLen > 0 && len(email) > maxLen { + // Generate shorter email if needed + email = f.LetterN(uint(min(5, maxLen-10))) + "@" + f.DomainName() + if len(email) > maxLen { + email = email[:maxLen] + } + } + return email +} + +func genUsername(f *gofakeit.Faker, maxLen int) any { + username := f.Username() + if maxLen > 0 && len(username) > maxLen { + username = username[:maxLen] + } + return username +} + +func genFirstName(f *gofakeit.Faker, maxLen int) any { + name := f.FirstName() + if maxLen > 0 && len(name) > maxLen { + name = name[:maxLen] + } + return name +} + +func genLastName(f *gofakeit.Faker, maxLen int) any { + name := f.LastName() + if maxLen > 0 && len(name) > maxLen { + name = name[:maxLen] + } + return name +} + +func genFullName(f *gofakeit.Faker, maxLen int) any { + name := f.Name() + if maxLen > 0 && len(name) > maxLen { + name = name[:maxLen] + } + return name +} + +func genPhone(f *gofakeit.Faker, maxLen int) any { + phone := f.Phone() + if maxLen > 0 && len(phone) > maxLen { + phone = f.Numerify(strings.Repeat("#", min(10, maxLen))) + } + return phone +} + +func genStreet(f *gofakeit.Faker, maxLen int) any { + street := f.Street() + if maxLen > 0 && len(street) > maxLen { + street = street[:maxLen] + } + return street +} + +func genCity(f *gofakeit.Faker, maxLen int) any { + city := f.City() + if maxLen > 0 && len(city) > maxLen { + city = city[:maxLen] + } + return city +} + +func genState(f *gofakeit.Faker, maxLen int) any { + if maxLen > 0 && maxLen <= 3 { + return f.StateAbr() + } + state := f.State() + if maxLen > 0 && len(state) > maxLen { + return f.StateAbr() + } + return state +} + +func genCountry(f *gofakeit.Faker, maxLen int) any { + if maxLen > 0 && maxLen <= 3 { + return f.CountryAbr() + } + country := f.Country() + if maxLen > 0 && len(country) > maxLen { + return f.CountryAbr() + } + return country +} + +func genZip(f *gofakeit.Faker, maxLen int) any { + zip := f.Zip() + if maxLen > 0 && len(zip) > maxLen { + zip = zip[:maxLen] + } + return zip +} + +func genURL(f *gofakeit.Faker, maxLen int) any { + url := f.URL() + if maxLen > 0 && len(url) > maxLen { + url = "https://" + f.DomainName() + if len(url) > maxLen { + url = url[:maxLen] + } + } + return url +} + +func genIP(f *gofakeit.Faker, _ int) any { + return f.IPv4Address() +} + +func genCompany(f *gofakeit.Faker, maxLen int) any { + company := f.Company() + if maxLen > 0 && len(company) > maxLen { + company = company[:maxLen] + } + return company +} + +func genJobTitle(f *gofakeit.Faker, maxLen int) any { + title := f.JobTitle() + if maxLen > 0 && len(title) > maxLen { + title = title[:maxLen] + } + return title +} + +func genDescription(f *gofakeit.Faker, maxLen int) any { + wordCount := 10 + if maxLen > 0 && maxLen < 50 { + wordCount = 3 + } + desc := f.LoremIpsumSentence(wordCount) + if maxLen > 0 && len(desc) > maxLen { + desc = desc[:maxLen] + } + return desc +} + +func genLatitude(f *gofakeit.Faker, _ int) any { + return f.Latitude() +} + +func genLongitude(f *gofakeit.Faker, _ int) any { + return f.Longitude() +} + +func genPassword(f *gofakeit.Faker, maxLen int) any { + length := 16 + if maxLen > 0 && maxLen < length { + length = maxLen + } + return f.Password(true, true, true, true, false, length) +} diff --git a/dataflow/core/src/mockdata/generator.go b/dataflow/core/src/mockdata/generator.go new file mode 100644 index 0000000..7039eaf --- /dev/null +++ b/dataflow/core/src/mockdata/generator.go @@ -0,0 +1,465 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mockdata + +import ( + "fmt" + "strings" + "time" + + "github.com/brianvoe/gofakeit/v7" + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" +) + +const ( + // DefaultForeignKeyDensityRatio determines parent row count: requestedRows / ratio = parent rows + // E.g., 20 child rows with ratio 20 → 1 parent row + // This can be overridden via the FKDensityRatio parameter in Generate() + DefaultForeignKeyDensityRatio = 20 + + // NullableFKProbability is the chance a nullable FK column gets NULL + NullableFKProbability = 0.2 + + // RegularNullProbability is the chance a regular nullable column gets NULL + RegularNullProbability = 0.1 + + // MaxPKRetries is the maximum number of attempts to generate a unique PK value + MaxPKRetries = 100 + + // MaxExistingPKsToLoad limits how many existing rows to load for PK uniqueness checks. + // Beyond this limit, uniqueness violations may occur for tables with many rows. + MaxExistingPKsToLoad = 10000 +) + +// Use shared database type sets +var ( + intTypes = common.IntTypes + uintTypes = common.UintTypes + floatTypes = common.FloatTypes + boolTypes = common.BoolTypes + dateTypes = common.DateTypes + dateTimeTypes = common.DateTimeTypes + uuidTypes = common.UuidTypes + textTypes = common.TextTypes + jsonTypes = common.JsonTypes +) + +// TableDependency represents a table in the dependency chain with generation info. +type TableDependency struct { + Table string + DependsOn []string + RowCount int + IsBlocked bool + UsesExistingData bool +} + +// GenerationResult contains the result of mock data generation. +type GenerationResult struct { + TotalGenerated int + Details []TableDetail + Warnings []string +} + +// TableDetail contains per-table generation info. +type TableDetail struct { + Table string + RowsGenerated int + UsedExistingData bool +} + +// DependencyAnalysis contains the result of analyzing table dependencies. +type DependencyAnalysis struct { + GenerationOrder []string + Tables []TableDependency + TotalRows int + Warnings []string + Error string +} + +// Generator handles mock data generation with FK support. +type Generator struct { + faker *gofakeit.Faker + fkDensityRatio int // Parent rows = child rows / ratio (default: 20) + generatedPKs map[string][]map[string]any // Cache of generated PK rows per table (supports composite PKs) + existingPKs map[string][]map[string]any // Cache of existing PK rows for blocked tables + usedPKValues map[string]map[string]bool // Track used PK values: table -> pkValueString -> true + databaseType string // Database type for type-specific generation (e.g., "PostgreSQL") +} + +// NewGenerator creates a new mock data generator with the specified FK density ratio. +// The ratio determines how many parent rows are created per N child rows. +// E.g., ratio=20 means 1 parent row per 20 child rows. +// Pass 0 or negative to use the default ratio. +func NewGenerator(fkDensityRatio int) *Generator { + if fkDensityRatio <= 0 { + fkDensityRatio = DefaultForeignKeyDensityRatio + } + return &Generator{ + faker: gofakeit.New(uint64(time.Now().UnixNano())), + fkDensityRatio: fkDensityRatio, + generatedPKs: make(map[string][]map[string]any), + existingPKs: make(map[string][]map[string]any), + usedPKValues: make(map[string]map[string]bool), + } +} + +// AnalyzeDependencies collects all tables in dependency order with cycle detection. +// Returns an analysis result with ordered tables, row counts, and any errors. +func (g *Generator) AnalyzeDependencies( + plugin engine.PluginFunctions, + config *engine.PluginConfig, + schema, table string, + rowCount int, +) (*DependencyAnalysis, error) { + analysis := &DependencyAnalysis{ + Warnings: []string{}, + } + + // Collect all FK relationships recursively + visited := make(map[string]bool) + tableInfo := make(map[string]*TableDependency) + adjacency := make(map[string][]string) + + if err := g.collectDependencies(plugin, config, schema, table, rowCount, visited, tableInfo, adjacency); err != nil { + analysis.Error = err.Error() + return analysis, nil + } + + // Topological sort with cycle detection + order, err := topoSort(adjacency) + if err != nil { + analysis.Error = err.Error() + return analysis, nil + } + + // Build result in generation order + analysis.GenerationOrder = order + for _, tbl := range order { + if info, ok := tableInfo[tbl]; ok { + analysis.Tables = append(analysis.Tables, *info) + analysis.TotalRows += info.RowCount + if info.UsesExistingData { + analysis.Warnings = append(analysis.Warnings, + fmt.Sprintf("Table '%s' has mock data disabled; will use existing data", tbl)) + } + } + } + + return analysis, nil +} + +// collectDependencies recursively collects all table dependencies. +func (g *Generator) collectDependencies( + plugin engine.PluginFunctions, + config *engine.PluginConfig, + schema, table string, + rowCount int, + visited map[string]bool, + tableInfo map[string]*TableDependency, + adjacency map[string][]string, +) error { + if visited[table] { + log.WithField("table", table).Debug("Table already visited, skipping") + return nil + } + visited[table] = true + + log.WithFields(map[string]any{ + "table": table, + "rowCount": rowCount, + }).Debug("Collecting dependencies for table") + + // Get FK relationships for this table + fks, err := plugin.GetForeignKeyRelationships(config, schema, table) + if err != nil { + log.WithError(err).WithField("table", table).Warn("Failed to get FK relationships") + fks = make(map[string]*engine.ForeignKeyRelationship) + } + + if len(fks) > 0 { + fkColumns := make([]string, 0, len(fks)) + for col := range fks { + fkColumns = append(fkColumns, col) + } + log.WithFields(map[string]any{ + "table": table, + "fkColumns": fkColumns, + "fkCount": len(fks), + }).Debug("Found foreign key relationships") + } + + // Check if mock data generation is allowed using env function + isBlocked := !IsMockDataGenerationAllowed(table) + if isBlocked { + log.WithField("table", table).Debug("Mock data generation blocked for table") + } + + // Create table info + info := &TableDependency{ + Table: table, + RowCount: rowCount, + IsBlocked: isBlocked, + UsesExistingData: isBlocked, + DependsOn: []string{}, + } + + // Ensure table is in adjacency map even if it has no FKs + // This is needed for topoSort to include tables with no dependencies + if _, exists := adjacency[table]; !exists { + adjacency[table] = []string{} + } + + // Process each FK + for colName, fk := range fks { + // Skip self-references + if fk.ReferencedTable == table { + log.WithFields(map[string]any{ + "table": table, + "column": colName, + }).Debug("Skipping self-referencing FK") + continue + } + + info.DependsOn = append(info.DependsOn, fk.ReferencedTable) + + // Track adjacency for topo sort (child depends on parent) + adjacency[table] = append(adjacency[table], fk.ReferencedTable) + + // Calculate parent row count based on density ratio + // Ensure at least 1 parent row exists + parentRowCount := max(1, rowCount/g.fkDensityRatio) + + log.WithFields(map[string]any{ + "childTable": table, + "parentTable": fk.ReferencedTable, + "column": colName, + "parentRowCount": parentRowCount, + }).Debug("Following FK to parent table") + + // Recursively collect parent dependencies + if err := g.collectDependencies(plugin, config, schema, fk.ReferencedTable, parentRowCount, visited, tableInfo, adjacency); err != nil { + return err + } + } + + tableInfo[table] = info + return nil +} + +// topoSort performs topological sort using Kahn's algorithm with cycle detection. +// Returns tables in order they should be populated (parents before children). +// adjacency maps: child -> [parents it depends on] +func topoSort(adjacency map[string][]string) ([]string, error) { + // inDegree[node] = number of dependencies (parents) that node has + inDegree := make(map[string]int) + // reverse maps: parent -> [children that depend on it] + reverse := make(map[string][]string) + + // Build in-degree and reverse adjacency + for node, deps := range adjacency { + // node's in-degree is the count of its dependencies + inDegree[node] = len(deps) + + for _, dep := range deps { + // Ensure parent is in inDegree map (may have 0 deps itself) + if _, ok := inDegree[dep]; !ok { + inDegree[dep] = 0 + } + // Track that node depends on dep (for when dep is processed) + reverse[dep] = append(reverse[dep], node) + } + } + + log.WithFields(map[string]any{ + "inDegree": inDegree, + }).Debug("Topological sort in-degree calculated") + + // Queue nodes with no dependencies (parents/root tables) + var queue []string + for node, degree := range inDegree { + if degree == 0 { + queue = append(queue, node) + } + } + + log.WithField("initialQueue", queue).Debug("Topological sort starting with root tables") + + var result []string + for len(queue) > 0 { + node := queue[0] + queue = queue[1:] + result = append(result, node) + + // When a parent is processed, reduce in-degree of its children + for _, child := range reverse[node] { + inDegree[child]-- + if inDegree[child] == 0 { + queue = append(queue, child) + } + } + } + + // Check for cycle - if not all nodes processed, there's a cycle + if len(result) != len(inDegree) { + var cycleNodes []string + for node, degree := range inDegree { + if degree > 0 { + cycleNodes = append(cycleNodes, node) + } + } + return nil, fmt.Errorf("circular dependency detected involving tables: %s", strings.Join(cycleNodes, " → ")) + } + + log.WithField("order", result).Debug("Topological sort complete") + return result, nil +} + +// Generate creates mock data for the target table and its FK dependencies. +// All inserts are wrapped in a transaction for atomicity - either all tables +// are populated successfully, or all changes are rolled back on any error. +func (g *Generator) Generate( + plugin engine.PluginFunctions, + config *engine.PluginConfig, + schema, table string, + rowCount int, + overwrite bool, +) (*GenerationResult, error) { + log.WithFields(map[string]any{ + "schema": schema, + "table": table, + "rowCount": rowCount, + "overwrite": overwrite, + }).Info("Starting mock data generation") + + // Reset caches for fresh generation + g.generatedPKs = make(map[string][]map[string]any) + g.existingPKs = make(map[string][]map[string]any) + g.usedPKValues = make(map[string]map[string]bool) + + // Set database type for type-specific generation + if metadata := plugin.GetDatabaseMetadata(); metadata != nil { + g.databaseType = string(metadata.DatabaseType) + } + + result := &GenerationResult{ + Details: []TableDetail{}, + Warnings: []string{}, + } + + // Analyze dependencies (read-only, outside transaction) + log.Debug("Analyzing table dependencies") + analysis, err := g.AnalyzeDependencies(plugin, config, schema, table, rowCount) + if err != nil { + return nil, err + } + + if analysis.Error != "" { + log.WithField("error", analysis.Error).Error("Dependency analysis failed") + return nil, fmt.Errorf("%s", analysis.Error) + } + + log.WithFields(map[string]any{ + "generationOrder": analysis.GenerationOrder, + "totalTables": len(analysis.Tables), + "totalRows": analysis.TotalRows, + }).Info("Dependency analysis complete") + + result.Warnings = analysis.Warnings + + // If overwrite mode, clear target table and all child tables first (in FK-safe order) + if overwrite { + log.Debug("Overwrite mode: clearing target table and child tables") + graph, err := plugin.GetGraph(config, schema) + if err != nil { + log.WithError(err).Warn("Failed to get graph for FK-safe clearing, attempting direct clear") + // Fall back to direct clear (may fail with FK constraints) + if _, err := plugin.ClearTableData(config, schema, table); err != nil { + return nil, fmt.Errorf("failed to clear table %s: %w", table, err) + } + } else { + if err := plugins.ClearTableWithDependencies(plugin, config, schema, table, graph); err != nil { + return nil, fmt.Errorf("failed to clear tables for overwrite: %w", err) + } + } + } + + // Wrap all inserts in a transaction for atomicity + // If any insert fails, all changes are rolled back + err = plugin.WithTransaction(config, func(tx any) error { + // Create a transactional config that passes the transaction to all operations + txConfig := &engine.PluginConfig{ + Credentials: config.Credentials, + ExternalModel: config.ExternalModel, + Transaction: tx, + } + + // Generate data for each table in order + for i, tblDep := range analysis.Tables { + log.WithFields(map[string]any{ + "step": fmt.Sprintf("%d/%d", i+1, len(analysis.Tables)), + "table": tblDep.Table, + "rowCount": tblDep.RowCount, + "dependsOn": tblDep.DependsOn, + }).Info("Processing table") + + // Load existing PKs for blocked tables + if tblDep.UsesExistingData { + log.WithField("table", tblDep.Table).Debug("Table uses existing data, loading PKs") + if err := g.loadExistingPKs(plugin, txConfig, schema, tblDep.Table); err != nil { + return fmt.Errorf("failed to load existing PKs for %s: %w", tblDep.Table, err) + } + result.Details = append(result.Details, TableDetail{ + Table: tblDep.Table, + RowsGenerated: 0, + UsedExistingData: true, + }) + continue + } + + // Generate rows for this table + isTargetTable := tblDep.Table == table + generated, err := g.generateTableRows(plugin, txConfig, schema, tblDep.Table, tblDep.RowCount, overwrite && isTargetTable, isTargetTable) + if err != nil { + return fmt.Errorf("failed to generate rows for %s: %w", tblDep.Table, err) + } + + result.TotalGenerated += generated + result.Details = append(result.Details, TableDetail{ + Table: tblDep.Table, + RowsGenerated: generated, + UsedExistingData: false, + }) + } + + return nil + }) + + if err != nil { + log.WithError(err).Error("Mock data generation failed, transaction rolled back") + return nil, err + } + + log.WithFields(map[string]any{ + "totalGenerated": result.TotalGenerated, + "tablesCount": len(result.Details), + }).Info("Mock data generation complete") + + return result, nil +} diff --git a/dataflow/core/src/mockdata/generator_helpers.go b/dataflow/core/src/mockdata/generator_helpers.go new file mode 100644 index 0000000..5b3888f --- /dev/null +++ b/dataflow/core/src/mockdata/generator_helpers.go @@ -0,0 +1,126 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mockdata + +import ( + "fmt" + "strconv" + "strings" + + "github.com/clidey/whodb/core/src/engine" +) + +// valueToRecord converts a value to an engine.Record. +// If constraintType is provided (non-empty), it overrides the column type for type hints. +// This is important for MongoDB where schema validation types are more authoritative +// than types inferred from documents. +func valueToRecord(col engine.Column, value any, constraintType string) engine.Record { + typeHint := col.Type + if constraintType != "" { + typeHint = constraintType + } + + extra := map[string]string{ + "Type": typeHint, + } + + var valueStr string + if value == nil { + valueStr = "" + extra["IsNull"] = "true" + } else if f, ok := value.(float64); ok { + // Use fixed-point notation to avoid scientific notation (e.g., "1e+07") + // which can cause numeric overflow errors in databases like PostgreSQL + valueStr = strconv.FormatFloat(f, 'f', -1, 64) + } else { + valueStr = fmt.Sprintf("%v", value) + } + + return engine.Record{ + Key: col.Name, + Value: valueStr, + Extra: extra, + } +} + +// detectDatabaseType returns the simplified database type for a column. +func detectDatabaseType(columnType string) string { + // Unwrap ClickHouse wrapper types (Nullable, LowCardinality) before classification + columnType = unwrapTypeModifiers(columnType) + upperType := strings.ToUpper(columnType) + + // Handle PostgreSQL arrays first + if strings.Contains(upperType, "[]") { + return "array" + } + + // Handle timestamp/time types with timezone suffixes before other checks + // PostgreSQL uses "TIMESTAMP WITH TIME ZONE", "TIME WITH TIME ZONE", etc. + if strings.HasPrefix(upperType, "TIMESTAMP") { + return "datetime" + } + if strings.HasPrefix(upperType, "TIME") && !strings.HasPrefix(upperType, "TINYINT") { + return "datetime" + } + + // Remove size specifiers like VARCHAR(255) -> VARCHAR + if idx := strings.Index(upperType, "("); idx > 0 { + upperType = upperType[:idx] + } + upperType = strings.TrimSpace(upperType) + + switch { + case intTypes.Contains(upperType): + return "int" + case uintTypes.Contains(upperType): + return "uint" + case floatTypes.Contains(upperType): + return "float" + case boolTypes.Contains(upperType): + return "bool" + case dateTypes.Contains(upperType): + return "date" + case dateTimeTypes.Contains(upperType): + return "datetime" + case uuidTypes.Contains(upperType): + return "uuid" + case jsonTypes.Contains(upperType): + return "json" + case textTypes.Contains(upperType): + return "text" + default: + return "text" + } +} + +// getConstraintsForColumn performs case-insensitive lookup for column constraints. +// This is necessary because SQLite (and some other databases) treat column names +// as case-insensitive, but the constraint map keys might not match the column case. +func getConstraintsForColumn(constraints map[string]map[string]any, columnName string) map[string]any { + // Try exact match first + if c, ok := constraints[columnName]; ok { + return c + } + // Try case-insensitive match + lowerName := strings.ToLower(columnName) + for key, value := range constraints { + if strings.ToLower(key) == lowerName { + return value + } + } + return nil +} diff --git a/dataflow/core/src/mockdata/generator_pk.go b/dataflow/core/src/mockdata/generator_pk.go new file mode 100644 index 0000000..e8e6b10 --- /dev/null +++ b/dataflow/core/src/mockdata/generator_pk.go @@ -0,0 +1,271 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mockdata + +import ( + "fmt" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" +) + +// loadExistingPKs loads primary key values from an existing table. +// Supports composite PKs by storing maps of column->value. +func (g *Generator) loadExistingPKs( + plugin engine.PluginFunctions, + config *engine.PluginConfig, + schema, table string, +) error { + log.WithField("table", table).Debug("Loading existing PKs from table") + + // Get columns to find all PK columns + columns, err := plugin.GetColumnsForTable(config, schema, table) + if err != nil { + return err + } + + // Collect all PK column names + var pkColNames []string + for _, col := range columns { + if col.IsPrimary { + pkColNames = append(pkColNames, col.Name) + } + } + + if len(pkColNames) == 0 { + // No PK found, try to use first column + if len(columns) > 0 { + pkColNames = []string{columns[0].Name} + log.WithFields(map[string]any{ + "table": table, + "column": columns[0].Name, + }).Debug("No PK column found, using first column") + } else { + return fmt.Errorf("table %s has no columns", table) + } + } else { + log.WithFields(map[string]any{ + "table": table, + "pkColumns": pkColNames, + }).Debug("Found PK columns") + } + + // Get existing rows + rows, err := plugin.GetRows(config, &engine.GetRowsRequest{Schema: schema, StorageUnit: table, PageSize: 50}) + if err != nil { + return err + } + + // Build column name -> index map + colIdxMap := make(map[string]int) + for i, col := range rows.Columns { + colIdxMap[col.Name] = i + } + + // Extract PK values for each row + pks := make([]map[string]any, 0, len(rows.Rows)) + for _, row := range rows.Rows { + pkRow := make(map[string]any) + for _, pkColName := range pkColNames { + if idx, exists := colIdxMap[pkColName]; exists && idx < len(row) { + pkRow[pkColName] = row[idx] + } + } + if len(pkRow) > 0 { + pks = append(pks, pkRow) + } + } + + g.existingPKs[table] = pks + log.WithFields(map[string]any{ + "table": table, + "pkCount": len(pks), + "pkColumns": pkColNames, + }).Debug("Loaded existing PKs") + + return nil +} + +// getPKRowsForTable returns available PK rows for a table. +// Each row is a map of column name -> value to support composite PKs. +func (g *Generator) getPKRowsForTable(table string) []map[string]any { + // Check generated PKs first + if pks, ok := g.generatedPKs[table]; ok && len(pks) > 0 { + return pks + } + // Fall back to existing PKs + if pks, ok := g.existingPKs[table]; ok && len(pks) > 0 { + return pks + } + return nil +} + +// trackAutoGeneratedPK stores an auto-generated PK value from the database. +// Used when the PK is auto-increment and the database generates the value. +func (g *Generator) trackAutoGeneratedPK(table string, pkColumnName string, generatedID int64) { + pkRow := map[string]any{ + pkColumnName: generatedID, + } + g.generatedPKs[table] = append(g.generatedPKs[table], pkRow) + log.WithFields(map[string]any{ + "table": table, + "pkColumn": pkColumnName, + "pkValue": generatedID, + "totalRows": len(g.generatedPKs[table]), + }).Debug("Tracked auto-generated PK") +} + +// trackGeneratedPK stores all PK column values from a generated row. +// Supports composite PKs by storing a map of column->value. +func (g *Generator) trackGeneratedPK(table string, columns []engine.Column, row []engine.Record) { + // Build a map of record key -> value for quick lookup + rowValues := make(map[string]string) + for _, rec := range row { + rowValues[rec.Key] = rec.Value + } + + // Collect all PK column values + pkRow := make(map[string]any) + for _, col := range columns { + if col.IsPrimary { + if val, exists := rowValues[col.Name]; exists { + pkRow[col.Name] = val + } + } + } + + if len(pkRow) > 0 { + g.generatedPKs[table] = append(g.generatedPKs[table], pkRow) + log.WithFields(map[string]any{ + "table": table, + "pkColumns": pkRow, + "totalRows": len(g.generatedPKs[table]), + }).Debug("Tracked generated PK row") + } +} + +// pkValueKey creates a string key for a PK value to use in the usedPKValues set. +// For composite PKs, concatenates all column values with a separator. +func pkValueKey(pkColumns []engine.Column, row []engine.Record) string { + // Build a map of record key -> value for quick lookup + rowValues := make(map[string]string) + for _, rec := range row { + rowValues[rec.Key] = rec.Value + } + + // Concatenate PK column values + var parts []string + for _, col := range pkColumns { + if col.IsPrimary && !col.IsAutoIncrement { + if val, exists := rowValues[col.Name]; exists { + parts = append(parts, fmt.Sprintf("%s=%s", col.Name, val)) + } + } + } + return strings.Join(parts, "|") +} + +// loadExistingPKsForUniqueness loads existing PK values from a table into the usedPKValues set. +// This prevents generating PK values that already exist in the database. +func (g *Generator) loadExistingPKsForUniqueness( + plugin engine.PluginFunctions, + config *engine.PluginConfig, + schema, table string, + columns []engine.Column, +) error { + // Find non-auto-increment PK columns + var pkColumns []engine.Column + for _, col := range columns { + if col.IsPrimary && !col.IsAutoIncrement { + pkColumns = append(pkColumns, col) + } + } + + // No non-auto-increment PKs to check + if len(pkColumns) == 0 { + return nil + } + + log.WithFields(map[string]any{ + "table": table, + "pkColumns": len(pkColumns), + }).Debug("Loading existing PK values for uniqueness check") + + // Initialize the used PK set for this table + if g.usedPKValues[table] == nil { + g.usedPKValues[table] = make(map[string]bool) + } + + // Get existing rows (limit to avoid memory issues) + rows, err := plugin.GetRows(config, &engine.GetRowsRequest{Schema: schema, StorageUnit: table, PageSize: MaxExistingPKsToLoad}) + if err != nil { + return fmt.Errorf("failed to load existing rows for PK uniqueness check: %w", err) + } + + // Build column name -> index map + colIdxMap := make(map[string]int) + for i, col := range rows.Columns { + colIdxMap[col.Name] = i + } + + // Extract and store PK values + for _, row := range rows.Rows { + // Build a pseudo-record for pkValueKey + records := make([]engine.Record, 0, len(pkColumns)) + for _, pkCol := range pkColumns { + if idx, exists := colIdxMap[pkCol.Name]; exists && idx < len(row) { + records = append(records, engine.Record{Key: pkCol.Name, Value: row[idx]}) + } + } + if len(records) > 0 { + key := pkValueKey(pkColumns, records) + g.usedPKValues[table][key] = true + } + } + + log.WithFields(map[string]any{ + "table": table, + "existingPKs": len(g.usedPKValues[table]), + "totalRowsFound": len(rows.Rows), + }).Debug("Loaded existing PK values") + + return nil +} + +// isPKValueUsed checks if a PK value combination is already used. +func (g *Generator) isPKValueUsed(table string, columns []engine.Column, row []engine.Record) bool { + if g.usedPKValues[table] == nil { + return false + } + key := pkValueKey(columns, row) + if key == "" { + return false // No non-auto-increment PKs + } + return g.usedPKValues[table][key] +} + +// markPKValueUsed marks a PK value combination as used. +func (g *Generator) markPKValueUsed(table string, columns []engine.Column, row []engine.Record) { + if g.usedPKValues[table] == nil { + g.usedPKValues[table] = make(map[string]bool) + } + key := pkValueKey(columns, row) + if key != "" { + g.usedPKValues[table][key] = true + } +} diff --git a/dataflow/core/src/mockdata/generator_rows.go b/dataflow/core/src/mockdata/generator_rows.go new file mode 100644 index 0000000..1986def --- /dev/null +++ b/dataflow/core/src/mockdata/generator_rows.go @@ -0,0 +1,597 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mockdata + +import ( + "fmt" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + gorm_plugin "github.com/clidey/whodb/core/src/plugins/gorm" +) + +// generateTableRows generates rows for a single table. +// If isTargetTable is true, uses bulk insert for better performance. +func (g *Generator) generateTableRows( + plugin engine.PluginFunctions, + config *engine.PluginConfig, + schema, table string, + rowCount int, + overwrite bool, + isTargetTable bool, +) (int, error) { + insertMode := "single" + if isTargetTable { + insertMode = "bulk" + } + log.WithFields(map[string]any{ + "table": table, + "rowCount": rowCount, + "overwrite": overwrite, + "insertMode": insertMode, + }).Info("Generating mock data for table") + + // Get columns and constraints + columns, err := plugin.GetColumnsForTable(config, schema, table) + if err != nil { + return 0, fmt.Errorf("failed to get columns: %w", err) + } + + // Enrich columns with auto-increment and computed column flags + if err := plugin.MarkGeneratedColumns(config, schema, table, columns); err != nil { + log.WithError(err).Warn("Failed to mark generated columns for " + table) + } + + // Log column details for debugging PK/auto-increment/computed issues + columnDetails := make([]map[string]any, 0, len(columns)) + for _, col := range columns { + if col.IsPrimary || col.IsAutoIncrement || col.IsComputed { + columnDetails = append(columnDetails, map[string]any{ + "name": col.Name, + "type": col.Type, + "isPrimary": col.IsPrimary, + "isAutoIncr": col.IsAutoIncrement, + "isComputed": col.IsComputed, + }) + } + } + log.WithFields(map[string]any{ + "table": table, + "columnCount": len(columns), + "specialColumns": columnDetails, + }).Debug("Retrieved column definitions") + + constraints, err := plugin.GetColumnConstraints(config, schema, table) + if err != nil { + log.WithError(err).WithField("table", table).Warn("Failed to get constraints, using defaults") + constraints = make(map[string]map[string]any) + } else { + // Log constraint details for debugging + constraintDetails := make(map[string][]string) + for col, c := range constraints { + var details []string + if checkVals, ok := c["check_values"]; ok { + details = append(details, fmt.Sprintf("check_values=%v", checkVals)) + } + if checkMin, ok := c["check_min"]; ok { + details = append(details, fmt.Sprintf("check_min=%v", checkMin)) + } + if checkMax, ok := c["check_max"]; ok { + details = append(details, fmt.Sprintf("check_max=%v", checkMax)) + } + if len(details) > 0 { + constraintDetails[col] = details + } + } + log.WithFields(map[string]any{ + "table": table, + "constraintCount": len(constraints), + "checkConstraints": constraintDetails, + }).Debug("Retrieved column constraints") + } + + // Get FK relationships + fks, err := plugin.GetForeignKeyRelationships(config, schema, table) + if err != nil { + log.WithError(err).WithField("table", table).Warn("Failed to get FK relationships") + fks = make(map[string]*engine.ForeignKeyRelationship) + } else if len(fks) > 0 { + log.WithFields(map[string]any{ + "table": table, + "fkCount": len(fks), + }).Debug("Retrieved FK relationships") + } + + // Load existing PKs to prevent uniqueness violations (only when not overwriting) + // When overwriting, tables are already cleared at the top level in Generate() + if !overwrite { + if err := g.loadExistingPKsForUniqueness(plugin, config, schema, table, columns); err != nil { + log.WithError(err).WithField("table", table).Warn("Failed to load existing PKs for uniqueness check") + // Continue anyway - worst case we get unique constraint errors + } + } + + // Target table: use bulk insert (no PK tracking needed) + // Parent tables: use single inserts (need to track PKs for FK references) + if isTargetTable { + return g.generateRowsBulk(plugin, config, schema, table, columns, constraints, fks, rowCount) + } + return g.generateRowsSingle(plugin, config, schema, table, columns, constraints, fks, rowCount) +} + +// generateRowsBulk generates all rows first, then bulk inserts them. +// Used for target table where PK tracking isn't needed. +func (g *Generator) generateRowsBulk( + plugin engine.PluginFunctions, + config *engine.PluginConfig, + schema, table string, + columns []engine.Column, + constraints map[string]map[string]any, + fks map[string]*engine.ForeignKeyRelationship, + rowCount int, +) (int, error) { + log.WithFields(map[string]any{ + "table": table, + "rowCount": rowCount, + }).Debug("Starting bulk row generation") + + // Generate all rows first + rows := make([][]engine.Record, 0, rowCount) + failedCount := 0 + pkCollisions := 0 + for i := 0; i < rowCount; i++ { + var row []engine.Record + var err error + + // Try to generate a row with unique PK (retry on collision) + for attempt := 0; attempt < MaxPKRetries; attempt++ { + row, err = g.generateRow(columns, constraints, fks, table) + if err != nil { + break // Generation error, not a collision + } + + // Check if PK is unique + if !g.isPKValueUsed(table, columns, row) { + // Mark PK as used and proceed + g.markPKValueUsed(table, columns, row) + break + } + + // PK collision - retry + if attempt == MaxPKRetries-1 { + err = fmt.Errorf("failed to generate unique PK after %d attempts", MaxPKRetries) + pkCollisions++ + } + } + + if err != nil { + log.WithError(err).WithField("table", table).WithField("row", i).Error("Failed to generate row") + failedCount++ + continue + } + rows = append(rows, row) + } + + if failedCount > 0 { + log.WithFields(map[string]any{ + "table": table, + "failedCount": failedCount, + "pkCollisions": pkCollisions, + }).Warn("Some rows failed to generate") + } + + if len(rows) == 0 { + log.WithField("table", table).Warn("No rows generated") + return 0, nil + } + + log.WithFields(map[string]any{ + "table": table, + "rowCount": len(rows), + }).Debug("Generated rows, starting bulk insert") + + // Bulk insert all rows + if _, err := plugin.BulkAddRows(config, schema, table, rows); err != nil { + log.WithError(err).WithField("table", table).WithField("rowCount", len(rows)).Error("Failed to bulk insert rows") + return 0, fmt.Errorf("failed to bulk insert rows: %w", err) + } + + log.WithFields(map[string]any{ + "table": table, + "generated": len(rows), + }).Info("Completed bulk mock data generation") + return len(rows), nil +} + +// generateRowsSingle generates and inserts rows one at a time. +// Used for parent tables where PK tracking is needed for FK references. +func (g *Generator) generateRowsSingle( + plugin engine.PluginFunctions, + config *engine.PluginConfig, + schema, table string, + columns []engine.Column, + constraints map[string]map[string]any, + fks map[string]*engine.ForeignKeyRelationship, + rowCount int, +) (int, error) { + log.WithFields(map[string]any{ + "table": table, + "rowCount": rowCount, + }).Debug("Starting single-row generation (PK tracking enabled)") + + // Find auto-increment PK column (if any) for tracking database-generated IDs + var autoIncrementPKCol *engine.Column + for i := range columns { + if columns[i].IsPrimary && columns[i].IsAutoIncrement { + autoIncrementPKCol = &columns[i] + log.WithFields(map[string]any{ + "table": table, + "column": columns[i].Name, + }).Debug("Found auto-increment PK column for tracking") + break + } + } + + generated := 0 + failedGenerate := 0 + failedInsert := 0 + pkCollisions := 0 + + for i := 0; i < rowCount; i++ { + var row []engine.Record + var err error + + // Try to generate a row with unique PK (retry on collision) + for attempt := 0; attempt < MaxPKRetries; attempt++ { + row, err = g.generateRow(columns, constraints, fks, table) + if err != nil { + break // Generation error, not a collision + } + + // Check if PK is unique (skip for auto-increment as DB handles it) + if autoIncrementPKCol == nil && g.isPKValueUsed(table, columns, row) { + // PK collision - retry + if attempt == MaxPKRetries-1 { + err = fmt.Errorf("failed to generate unique PK after %d attempts", MaxPKRetries) + pkCollisions++ + } + continue + } + + // Mark PK as used (for non-auto-increment PKs) + if autoIncrementPKCol == nil { + g.markPKValueUsed(table, columns, row) + } + break + } + + if err != nil { + log.WithError(err).WithField("table", table).WithField("row", i).Error("Failed to generate row") + failedGenerate++ + continue + } + + // Insert row and get auto-generated ID (if any) + generatedID, err := plugin.AddRowReturningID(config, schema, table, row) + if err != nil { + log.WithError(err).WithField("table", table).WithField("row", i).Error("Failed to insert row") + // Return error immediately to trigger transaction rollback + // This ensures atomicity - either all tables are populated or none are + return 0, fmt.Errorf("failed to insert row %d into %s: %w", i, table, err) + } + + // Track generated PK for FK references + if autoIncrementPKCol != nil { + // Auto-increment PK: track the database-generated ID + if generatedID > 0 { + g.trackAutoGeneratedPK(table, autoIncrementPKCol.Name, generatedID) + } else { + // Failed to get the auto-generated ID - this is a problem + // because FK references won't work + log.WithFields(map[string]any{ + "table": table, + "column": autoIncrementPKCol.Name, + "row": i, + }).Warn("Failed to get auto-generated PK - FK references may fail") + } + } else { + // Non-auto-increment PK: track from the generated row values + g.trackGeneratedPK(table, columns, row) + } + generated++ + + // Log progress every 100 rows for larger batches + if rowCount >= 100 && generated%100 == 0 { + log.WithFields(map[string]any{ + "table": table, + "progress": fmt.Sprintf("%d/%d", generated, rowCount), + }).Debug("Row generation progress") + } + } + + if failedGenerate > 0 || failedInsert > 0 { + log.WithFields(map[string]any{ + "table": table, + "failedGenerate": failedGenerate, + "failedInsert": failedInsert, + "pkCollisions": pkCollisions, + }).Warn("Some rows failed during generation") + } + + log.WithFields(map[string]any{ + "table": table, + "generated": generated, + "trackedPKs": len(g.generatedPKs[table]), + }).Info("Completed single-row mock data generation") + return generated, nil +} + +// generateRow creates a single row with proper FK handling. +// Uses a per-row cache to ensure composite FKs use values from the same parent row. +func (g *Generator) generateRow( + columns []engine.Column, + constraints map[string]map[string]any, + fks map[string]*engine.ForeignKeyRelationship, + table string, +) ([]engine.Record, error) { + records := make([]engine.Record, 0, len(columns)) + + // Cache for selected parent rows - ensures composite FKs are consistent + // Maps parent table name -> selected row (or nil for NULL) + selectedParentRows := make(map[string]map[string]any) + + // Track skip reasons for debugging + skippedAutoIncr := 0 + skippedComputed := 0 + skippedNil := 0 + + for _, col := range columns { + // Skip auto-increment columns - database will generate these values + if col.IsAutoIncrement { + log.WithFields(map[string]any{ + "table": table, + "column": col.Name, + }).Debug("Skipping auto-increment column") + skippedAutoIncr++ + continue + } + + // Skip computed/database-managed columns + if col.IsComputed { + log.WithFields(map[string]any{ + "table": table, + "column": col.Name, + "type": col.Type, + }).Debug("Skipping computed column") + skippedComputed++ + continue + } + + // Use case-insensitive lookup for constraints (needed for SQLite) + colConstraints := getConstraintsForColumn(constraints, col.Name) + var value any + var err error + + // Check if this is a FK column + if fk, isFk := fks[col.Name]; isFk { + value, err = g.generateFKValue(col, colConstraints, fk, table, selectedParentRows) + if err != nil { + return nil, fmt.Errorf("failed to generate FK value for column %s: %w", col.Name, err) + } + } else { + value = g.generateColumnValue(col, colConstraints) + } + + // Skip nil values - let the database use default values instead of explicit NULLs. + if value == nil { + skippedNil++ + continue + } + + // Get constraint type if available (important for MongoDB schema validation) + constraintType := "" + if colConstraints != nil { + if t, ok := colConstraints["type"].(string); ok { + constraintType = t + } + } + + // Convert value to record + record := valueToRecord(col, value, constraintType) + records = append(records, record) + } + + // Log summary if no records were generated (indicates a bug) + if len(records) == 0 { + log.WithFields(map[string]any{ + "table": table, + "totalColumns": len(columns), + "skippedAutoIncr": skippedAutoIncr, + "skippedComputed": skippedComputed, + "skippedNil": skippedNil, + }).Warn("No records generated - all columns were skipped") + } + + return records, nil +} + +// generateFKValue generates a value for a FK column. +// selectedParentRows caches which parent row was selected for each table, +// ensuring composite FKs use consistent values from the same parent row. +// Returns an error if no parent PKs are available for a non-nullable FK. +func (g *Generator) generateFKValue( + col engine.Column, + constraints map[string]any, + fk *engine.ForeignKeyRelationship, + currentTable string, + selectedParentRows map[string]map[string]any, +) (any, error) { + isNullable := gorm_plugin.IsNullable(constraints) + + log.WithFields(map[string]any{ + "table": currentTable, + "column": col.Name, + "isNullable": isNullable, + "fkTarget": fk.ReferencedTable, + }).Debug("Generating FK value") + + // Self-reference: return NULL if nullable, otherwise error + if fk.ReferencedTable == currentTable { + if isNullable { + log.WithFields(map[string]any{ + "table": currentTable, + "column": col.Name, + }).Debug("Self-referencing FK set to NULL") + return nil, nil + } + // Non-nullable self-reference - cannot generate valid data + return nil, fmt.Errorf("cannot generate data for non-nullable self-referencing FK column %s", col.Name) + } + + // Check if we've already selected a row for this parent table + // (to ensure composite FK columns are consistently NULL or not) + if selectedRow, exists := selectedParentRows[fk.ReferencedTable]; exists { + // Use the already-selected row for composite FK consistency + if selectedRow == nil { + // Previously decided to use NULL + return nil, nil + } + value := selectedRow[fk.ReferencedColumn] + log.WithFields(map[string]any{ + "table": currentTable, + "column": col.Name, + "parentTable": fk.ReferencedTable, + "referencedColumn": fk.ReferencedColumn, + "value": value, + }).Debug("Using cached parent row for composite FK") + return value, nil + } + + // First FK column for this parent table - decide NULL or select a row + if isNullable && g.faker.Float64() < NullableFKProbability { + log.WithFields(map[string]any{ + "table": currentTable, + "column": col.Name, + }).Debug("Nullable FK set to NULL (random)") + selectedParentRows[fk.ReferencedTable] = nil // Cache the NULL decision + return nil, nil + } + + // Get parent PK rows + parentRows := g.getPKRowsForTable(fk.ReferencedTable) + log.WithFields(map[string]any{ + "column": col.Name, + "referencedTable": fk.ReferencedTable, + "parentRowCount": len(parentRows), + }).Debug("Looking up parent PKs for FK") + + if len(parentRows) == 0 { + // No parent PKs available - this is an error for non-nullable FK columns + if isNullable { + log.WithFields(map[string]any{ + "column": col.Name, + "referencedTable": fk.ReferencedTable, + }).Debug("No parent PKs available, using NULL for nullable FK") + selectedParentRows[fk.ReferencedTable] = nil + return nil, nil + } + return nil, fmt.Errorf("no parent rows available for non-nullable FK column %s (references %s)", col.Name, fk.ReferencedTable) + } + + // Pick a random parent row and cache it + idx := g.faker.Number(0, len(parentRows)-1) + selectedRow := parentRows[idx] + selectedParentRows[fk.ReferencedTable] = selectedRow + + // Get the specific column value + value := selectedRow[fk.ReferencedColumn] + + log.WithFields(map[string]any{ + "table": currentTable, + "column": col.Name, + "parentTable": fk.ReferencedTable, + "referencedColumn": fk.ReferencedColumn, + "value": value, + "availableRows": len(parentRows), + }).Debug("Selected FK value from parent table") + + return value, nil +} + +// generateColumnValue generates a value for a regular column. +func (g *Generator) generateColumnValue(col engine.Column, constraints map[string]any) any { + // Check for nullable with random NULL + if gorm_plugin.IsNullable(constraints) { + if g.faker.Float64() < RegularNullProbability { + return nil + } + } + + // Check for is_json constraint (MariaDB stores JSON as LONGTEXT with JSON_VALID check) + if constraints != nil { + if isJSON, ok := constraints["is_json"].(bool); ok && isJSON { + return GenerateByType("json", g.databaseType, nil, g.faker) + } + } + + // Merge column metadata (length, precision, scale) into constraints if available + effectiveConstraints := constraints + needsCopy := false + if col.Length != nil && *col.Length > 0 && (constraints == nil || constraints["length"] == nil) { + needsCopy = true + } + if col.Precision != nil && *col.Precision > 0 && (constraints == nil || constraints["precision"] == nil) { + needsCopy = true + } + if needsCopy { + effectiveConstraints = make(map[string]any) + for k, v := range constraints { // no-op if constraints is nil + effectiveConstraints[k] = v + } + if col.Length != nil && *col.Length > 0 { + effectiveConstraints["length"] = *col.Length + } + if col.Precision != nil && *col.Precision > 0 { + effectiveConstraints["precision"] = *col.Precision + } + if col.Scale != nil && *col.Scale >= 0 { + effectiveConstraints["scale"] = *col.Scale + } + } + + // Try column name pattern matching first (for text types) + dbType := detectDatabaseType(col.Type) + if dbType == "text" { + // Skip pattern matching if we have check_values (ENUM) + if effectiveConstraints != nil { + if _, hasCheckValues := effectiveConstraints["check_values"]; hasCheckValues { + return GenerateByType(col.Type, g.databaseType, effectiveConstraints, g.faker) + } + } + + maxLen := 0 + if col.Length != nil { + maxLen = *col.Length + } + if val, matched := MatchColumnName(col.Name, maxLen, g.faker); matched { + return val + } + } + + // Fall back to type-based generation + return GenerateByType(col.Type, g.databaseType, effectiveConstraints, g.faker) +} diff --git a/dataflow/core/src/mockdata/generator_test.go b/dataflow/core/src/mockdata/generator_test.go new file mode 100644 index 0000000..98e755a --- /dev/null +++ b/dataflow/core/src/mockdata/generator_test.go @@ -0,0 +1,1515 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mockdata + +import ( + "fmt" + "math" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v7" + "github.com/clidey/whodb/core/src/engine" +) + +// GenerateValue generates a mock value +func (g *Generator) GenerateValue(columnName string, columnType string, constraints map[string]any) (any, error) { + col := engine.Column{ + Name: columnName, + Type: columnType, + } + return g.generateColumnValue(col, constraints), nil +} + +// GenerateRowDataWithConstraints generates mock data for a complete row +func (g *Generator) GenerateRowDataWithConstraints(columns []engine.Column, colConstraints map[string]map[string]any) ([]engine.Record, error) { + return g.generateRow(columns, colConstraints, nil, "") +} + +// GenerateRowData generates mock data without constraints +func (g *Generator) GenerateRowData(columns []engine.Column) ([]engine.Record, error) { + return g.GenerateRowDataWithConstraints(columns, nil) +} + +// GenerateRowWithDefaults generates a row using safe default values +func (g *Generator) GenerateRowWithDefaults(columns []engine.Column) []engine.Record { + records, _ := g.GenerateRowData(columns) + return records +} + +func TestGenerateRowDataWithConstraintsSkipsSerialAndRespectsCheckValues(t *testing.T) { + g := NewGenerator(0) + g.faker = gofakeit.New(1) // deterministic output + + columns := []engine.Column{ + {Name: "id", Type: "serial", IsAutoIncrement: true}, + {Name: "status", Type: "varchar(10)"}, + {Name: "created_at", Type: "timestamp"}, + } + constraints := map[string]map[string]any{ + "status": {"check_values": []string{"open", "closed"}}, + } + + records, err := g.GenerateRowDataWithConstraints(columns, constraints) + if err != nil { + t.Fatalf("unexpected error generating row data: %v", err) + } + + if len(records) != 2 { + t.Fatalf("expected serial column to be skipped, got %d records", len(records)) + } + + status := findRecord(records, "status") + if status == nil { + t.Fatalf("expected status column to be present") + } + if status.Value != "open" && status.Value != "closed" { + t.Fatalf("expected status to respect check_values constraint, got %s", status.Value) + } + if status.Extra["Type"] != "varchar(10)" { + t.Fatalf("expected type to be stored in Extra metadata") + } + + created := findRecord(records, "created_at") + if created == nil { + t.Fatalf("expected created_at column to be present") + } + if _, err := time.Parse("2006-01-02 15:04:05", created.Value); err != nil { + t.Fatalf("expected timestamp to be formatted without timezone, got %s", created.Value) + } +} + +func TestGenerateRowWithDefaultsCoversCommonTypes(t *testing.T) { + g := NewGenerator(0) + g.faker = gofakeit.New(1) + + // Set up length pointer for varchar test + noteLen := 2 + columns := []engine.Column{ + {Name: "id", Type: "serial", IsAutoIncrement: true}, + {Name: "price", Type: "decimal(10,2)"}, + {Name: "active", Type: "boolean"}, + {Name: "created", Type: "date"}, + {Name: "note", Type: "varchar(2)", Length: ¬eLen}, + {Name: "payload", Type: "jsonb"}, + } + + records := g.GenerateRowWithDefaults(columns) + if len(records) != 5 { // serial skipped + t.Fatalf("expected serial column to be skipped, got %d records", len(records)) + } + + price := findRecord(records, "price") + if price == nil { + t.Fatalf("expected price column to be present") + } + // Decimal values are randomly generated within range [0, 10000] + // Just check it's not empty and has proper format + if price.Value == "" { + t.Fatalf("expected decimal value to be generated, got empty string") + } + + active := findRecord(records, "active") + if active == nil || (active.Value != "false" && active.Value != "true") { + t.Fatalf("expected boolean default to be present, got %#v", active) + } + + note := findRecord(records, "note") + if note == nil || len(note.Value) != 2 { + t.Fatalf("expected varchar(2) default to respect length, got %#v", note) + } + + payload := findRecord(records, "payload") + if payload == nil || !strings.HasPrefix(payload.Value, "{") { + t.Fatalf("expected json default to be object, got %#v", payload) + } +} + +func TestGenerateRowDataWithConstraintsHandlesArrays(t *testing.T) { + g := NewGenerator(0) + g.faker = gofakeit.New(2) + + columns := []engine.Column{ + {Name: "tags", Type: "text[]"}, + } + constraints := map[string]map[string]any{ + "tags": {"nullable": false}, + } + + records, err := g.GenerateRowDataWithConstraints(columns, constraints) + if err != nil { + t.Fatalf("unexpected error generating array data: %v", err) + } + if len(records) != 1 { + t.Fatalf("expected one record, got %d", len(records)) + } + if !strings.HasPrefix(records[0].Value, "{") || !strings.HasSuffix(records[0].Value, "}") { + t.Fatalf("expected array value to be wrapped in braces, got %s", records[0].Value) + } +} + +func findRecord(records []engine.Record, key string) *engine.Record { + for _, r := range records { + if r.Key == key { + return &r + } + } + return nil +} + +func TestDetectDatabaseTypeHandlesArrayAndDefaults(t *testing.T) { + if got := detectDatabaseType("text[]"); got != "array" { + t.Fatalf("expected array type for text[], got %s", got) + } + if got := detectDatabaseType("unknown_type"); got != "text" { + t.Fatalf("expected unknown types to default to text, got %s", got) + } +} + +func TestDetectDatabaseTypeHandlesTimestampVariants(t *testing.T) { + // PostgreSQL timestamp types with timezone + timestampTypes := []string{ + "TIMESTAMP", + "TIMESTAMP WITH TIME ZONE", + "TIMESTAMP WITHOUT TIME ZONE", + "timestamp with time zone", + "timestamptz", + } + for _, typeName := range timestampTypes { + if got := detectDatabaseType(typeName); got != "datetime" { + t.Errorf("expected datetime for %q, got %s", typeName, got) + } + } + + // Time types + timeTypes := []string{ + "TIME", + "TIME WITH TIME ZONE", + "TIME WITHOUT TIME ZONE", + "time with time zone", + "timetz", + } + for _, typeName := range timeTypes { + if got := detectDatabaseType(typeName); got != "datetime" { + t.Errorf("expected datetime for %q, got %s", typeName, got) + } + } + + // TINYINT should NOT be detected as time + if got := detectDatabaseType("TINYINT"); got == "datetime" { + t.Errorf("TINYINT should not be detected as datetime, got %s", got) + } +} + +// TestDetectDatabaseTypeHandlesCommonTypes tests the type detection function +// Note: parseMaxLen was removed in favor of constraint-based length handling + +func TestMatchColumnNameEmail(t *testing.T) { + faker := gofakeit.New(1) + + testCases := []string{"email", "user_email", "e_mail", "Email", "EMAIL"} + for _, colName := range testCases { + value, matched := MatchColumnName(colName, 0, faker) + if !matched { + t.Fatalf("expected column name %q to match email pattern", colName) + } + email, ok := value.(string) + if !ok { + t.Fatalf("expected string value for %q, got %T", colName, value) + } + if !strings.Contains(email, "@") { + t.Fatalf("expected email format for %q, got %q", colName, email) + } + } +} + +func TestMatchColumnNameUsername(t *testing.T) { + faker := gofakeit.New(1) + + testCases := []string{"username", "user_name", "uname", "login"} + for _, colName := range testCases { + value, matched := MatchColumnName(colName, 0, faker) + if !matched { + t.Fatalf("expected column name %q to match username pattern", colName) + } + if _, ok := value.(string); !ok { + t.Fatalf("expected string value for %q, got %T", colName, value) + } + } +} + +func TestMatchColumnNamePhone(t *testing.T) { + faker := gofakeit.New(1) + + testCases := []string{"phone", "phone_number", "mobile", "cell", "telephone", "tel"} + for _, colName := range testCases { + value, matched := MatchColumnName(colName, 0, faker) + if !matched { + t.Fatalf("expected column name %q to match phone pattern", colName) + } + if _, ok := value.(string); !ok { + t.Fatalf("expected string value for %q, got %T", colName, value) + } + } +} + +func TestMatchColumnNameAddress(t *testing.T) { + faker := gofakeit.New(1) + + testCases := []struct { + colName string + pattern string + expected bool + }{ + {"address", "address", true}, + {"street", "street", true}, + {"city", "city", true}, + {"state", "state", true}, + {"country", "country", true}, + {"zip", "zip", true}, + {"postal_code", "postal", true}, + } + + for _, tc := range testCases { + value, matched := MatchColumnName(tc.colName, 0, faker) + if matched != tc.expected { + t.Fatalf("expected column name %q match=%v, got match=%v", tc.colName, tc.expected, matched) + } + if matched { + if _, ok := value.(string); !ok { + t.Fatalf("expected string value for %q, got %T", tc.colName, value) + } + } + } +} + +func TestMatchColumnNameUrl(t *testing.T) { + faker := gofakeit.New(1) + + testCases := []string{"url", "website", "link", "homepage"} + for _, colName := range testCases { + value, matched := MatchColumnName(colName, 0, faker) + if !matched { + t.Fatalf("expected column name %q to match URL pattern", colName) + } + url, ok := value.(string) + if !ok { + t.Fatalf("expected string value for %q, got %T", colName, value) + } + if !strings.HasPrefix(url, "http") { + t.Fatalf("expected URL format for %q, got %q", colName, url) + } + } +} + +func TestMatchColumnNameIPAddress(t *testing.T) { + faker := gofakeit.New(1) + + testCases := []string{"ip", "ip_address", "ipaddress", "ip_addr"} + for _, colName := range testCases { + value, matched := MatchColumnName(colName, 0, faker) + if !matched { + t.Fatalf("expected column name %q to match IP pattern", colName) + } + ip, ok := value.(string) + if !ok { + t.Fatalf("expected string value for %q, got %T", colName, value) + } + // IPv4 should have dots + if !strings.Contains(ip, ".") { + t.Fatalf("expected IPv4 format for %q, got %q", colName, ip) + } + } +} + +func TestMatchColumnNameNames(t *testing.T) { + faker := gofakeit.New(1) + + testCases := []struct { + colName string + pattern string + }{ + {"first_name", "first_name"}, + {"firstname", "firstname"}, + {"fname", "fname"}, + {"last_name", "last_name"}, + {"lastname", "lastname"}, + {"lname", "lname"}, + {"surname", "surname"}, + {"name", "name"}, + {"full_name", "full_name"}, + } + + for _, tc := range testCases { + value, matched := MatchColumnName(tc.colName, 0, faker) + if !matched { + t.Fatalf("expected column name %q to match name pattern", tc.colName) + } + if _, ok := value.(string); !ok { + t.Fatalf("expected string value for %q, got %T", tc.colName, value) + } + } +} + +func TestMatchColumnNameCompany(t *testing.T) { + faker := gofakeit.New(1) + + testCases := []string{"company", "organization", "org", "company_name"} + for _, colName := range testCases { + value, matched := MatchColumnName(colName, 0, faker) + if !matched { + t.Fatalf("expected column name %q to match company pattern", colName) + } + if _, ok := value.(string); !ok { + t.Fatalf("expected string value for %q, got %T", colName, value) + } + } +} + +func TestMatchColumnNameNoMatch(t *testing.T) { + faker := gofakeit.New(1) + + testCases := []string{"created_at", "updated_at", "id", "status", "amount", "count"} + for _, colName := range testCases { + _, matched := MatchColumnName(colName, 0, faker) + if matched { + t.Fatalf("expected column name %q to NOT match any pattern", colName) + } + } +} + +func TestMatchColumnNameRespectsMaxLen(t *testing.T) { + faker := gofakeit.New(1) + + // Test with a short max length + value, matched := MatchColumnName("email", 10, faker) + if !matched { + t.Fatal("expected email to match") + } + email, ok := value.(string) + if !ok { + t.Fatalf("expected string, got %T", value) + } + if len(email) > 10 { + t.Fatalf("expected email to be truncated to 10 chars, got %d: %q", len(email), email) + } +} + +func TestGenerateValueUsesColumnNameContext(t *testing.T) { + g := NewGenerator(0) + g.faker = gofakeit.New(1) + + columns := []engine.Column{ + {Name: "user_email", Type: "varchar(255)"}, + {Name: "phone_number", Type: "varchar(20)"}, + {Name: "website", Type: "text"}, + } + + for _, col := range columns { + value, err := g.GenerateValue(col.Name, col.Type, nil) + if err != nil { + t.Fatalf("unexpected error for %q: %v", col.Name, err) + } + + str, ok := value.(string) + if !ok { + t.Fatalf("expected string for %q, got %T", col.Name, value) + } + + switch col.Name { + case "user_email": + if !strings.Contains(str, "@") { + t.Fatalf("expected email format for user_email, got %q", str) + } + case "phone_number": + // Phone should have some digits + hasDigit := false + for _, r := range str { + if r >= '0' && r <= '9' { + hasDigit = true + break + } + } + if !hasDigit { + t.Fatalf("expected phone format for phone_number, got %q", str) + } + case "website": + if !strings.HasPrefix(str, "http") { + t.Fatalf("expected URL format for website, got %q", str) + } + } + } +} + +func TestGenerateValueCheckValuesHasPriority(t *testing.T) { + g := NewGenerator(0) + g.faker = gofakeit.New(1) + + // Even though column is named "email", check_values should take priority + constraints := map[string]any{ + "check_values": []string{"active", "inactive"}, + } + + value, err := g.GenerateValue("email", "varchar(50)", constraints) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + str, ok := value.(string) + if !ok { + t.Fatalf("expected string, got %T", value) + } + + if str != "active" && str != "inactive" { + t.Fatalf("expected check_values to take priority, got %q", str) + } +} + +func TestMatchColumnNameLatLong(t *testing.T) { + faker := gofakeit.New(1) + + latCases := []string{"latitude", "lat"} + for _, colName := range latCases { + value, matched := MatchColumnName(colName, 0, faker) + if !matched { + t.Fatalf("expected column name %q to match latitude pattern", colName) + } + lat, ok := value.(float64) + if !ok { + t.Fatalf("expected float64 for latitude, got %T", value) + } + if lat < -90 || lat > 90 { + t.Fatalf("expected latitude in range [-90, 90], got %f", lat) + } + } + + longCases := []string{"longitude", "lng", "lon"} + for _, colName := range longCases { + value, matched := MatchColumnName(colName, 0, faker) + if !matched { + t.Fatalf("expected column name %q to match longitude pattern", colName) + } + lng, ok := value.(float64) + if !ok { + t.Fatalf("expected float64 for longitude, got %T", value) + } + if lng < -180 || lng > 180 { + t.Fatalf("expected longitude in range [-180, 180], got %f", lng) + } + } +} + +func TestGenDecimalRespectsPrecisionAndScale(t *testing.T) { + faker := gofakeit.New(1) + + tests := []struct { + name string + precision int + scale int + maxVal float64 + }{ + {"numeric(5,2)", 5, 2, 999.99}, + {"numeric(3,2)", 3, 2, 9.99}, + {"numeric(10,2)", 10, 2, 99999999.99}, + {"numeric(2,2)", 2, 2, 0.99}, + {"numeric(4,0)", 4, 0, 9999}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + constraints := map[string]any{ + "precision": tt.precision, + "scale": tt.scale, + } + + for i := 0; i < 100; i++ { + val := genDecimal(constraints, faker) + f, ok := val.(float64) + if !ok { + t.Fatalf("expected float64, got %T", val) + } + if f < 0 || f > tt.maxVal { + t.Fatalf("iteration %d: value %f exceeds max %f for %s", i, f, tt.maxVal, tt.name) + } + } + }) + } +} + +func TestGenDecimalAcceptsPrecisionAsInt64(t *testing.T) { + faker := gofakeit.New(1) + + // precision as int64 (the original code path) + constraints := map[string]any{ + "precision": int64(5), + "scale": 2, + } + + for i := 0; i < 50; i++ { + val := genDecimal(constraints, faker) + f, ok := val.(float64) + if !ok { + t.Fatalf("expected float64, got %T", val) + } + if f > 999.99 { + t.Fatalf("value %f exceeds max 999.99 for numeric(5,2) with int64 precision", f) + } + } +} + +func TestGenDecimalDefaultsWithoutPrecision(t *testing.T) { + faker := gofakeit.New(1) + + // No precision → default max 1000.0, scale 2 + for i := 0; i < 50; i++ { + val := genDecimal(nil, faker) + f, ok := val.(float64) + if !ok { + t.Fatalf("expected float64, got %T", val) + } + if f < 0 || f > 1000.0 { + t.Fatalf("value %f outside default range [0, 1000]", f) + } + // Verify scale=2: at most 2 decimal places + str := strconv.FormatFloat(f, 'f', -1, 64) + if idx := strings.Index(str, "."); idx >= 0 { + decimals := len(str) - idx - 1 + if decimals > 2 { + t.Fatalf("expected at most 2 decimal places, got %d in %s", decimals, str) + } + } + } +} + +func TestGenDecimalRespectsCheckMinMax(t *testing.T) { + faker := gofakeit.New(1) + + constraints := map[string]any{ + "check_min": 10.0, + "check_max": 20.0, + } + + for i := 0; i < 100; i++ { + val := genDecimal(constraints, faker) + f, ok := val.(float64) + if !ok { + t.Fatalf("expected float64, got %T", val) + } + if f < 10.0 || f > 20.0 { + t.Fatalf("value %f outside constrained range [10, 20]", f) + } + } +} + +func TestColumnPrecisionScaleFlowsToGenDecimal(t *testing.T) { + g := NewGenerator(0) + g.faker = gofakeit.New(1) + + precision := 5 + scale := 2 + columns := []engine.Column{ + {Name: "amount", Type: "numeric", Precision: &precision, Scale: &scale}, + } + constraints := map[string]map[string]any{ + "amount": {"nullable": false}, + } + + maxAllowed := math.Pow(10, float64(precision-scale)) - math.Pow(10, -float64(scale)) + + for i := 0; i < 100; i++ { + records, err := g.GenerateRowDataWithConstraints(columns, constraints) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + rec := findRecord(records, "amount") + if rec == nil { + t.Fatalf("iteration %d: expected amount record", i) + } + f, err := strconv.ParseFloat(rec.Value, 64) + if err != nil { + t.Fatalf("iteration %d: failed to parse %q as float: %v", i, rec.Value, err) + } + if f > maxAllowed { + t.Fatalf("iteration %d: value %f exceeds max %f for numeric(%d,%d)", i, f, maxAllowed, precision, scale) + } + } +} + +func TestGenIntRespectsConstraints(t *testing.T) { + faker := gofakeit.New(1) + + tests := []struct { + name string + typ string + min float64 + max float64 + wantMin int + wantMax int + }{ + {"check_min and check_max", "integer", 5, 10, 5, 10}, + {"smallint default range", "smallint", 0, 0, 1, 32767}, + {"tinyint default range", "tinyint", 0, 0, 1, 127}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var constraints map[string]any + if tt.min != 0 || tt.max != 0 { + constraints = map[string]any{} + if tt.min != 0 { + constraints["check_min"] = tt.min + } + if tt.max != 0 { + constraints["check_max"] = tt.max + } + } + + wantMin := tt.wantMin + wantMax := tt.wantMax + if tt.min != 0 { + wantMin = int(tt.min) + } + if tt.max != 0 { + wantMax = int(tt.max) + } + + for i := 0; i < 100; i++ { + val := genInt(tt.typ, constraints, faker) + n, ok := val.(int) + if !ok { + t.Fatalf("expected int, got %T", val) + } + if n < wantMin || n > wantMax { + t.Fatalf("iteration %d: value %d outside range [%d, %d]", i, n, wantMin, wantMax) + } + } + }) + } +} + +func TestComputedColumnsAreSkipped(t *testing.T) { + g := NewGenerator(0) + g.faker = gofakeit.New(1) + + columns := []engine.Column{ + {Name: "id", Type: "integer", IsPrimary: true, IsAutoIncrement: true}, + {Name: "price", Type: "numeric"}, + {Name: "tax", Type: "numeric", IsComputed: true}, + {Name: "total", Type: "numeric", IsComputed: true}, + } + constraints := map[string]map[string]any{ + "price": {"nullable": false}, + } + + records, err := g.GenerateRowDataWithConstraints(columns, constraints) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, r := range records { + if r.Key == "id" || r.Key == "tax" || r.Key == "total" { + t.Fatalf("expected column %q to be skipped (auto-increment or computed)", r.Key) + } + } + + if findRecord(records, "price") == nil { + t.Fatal("expected price column to be present") + } +} + +func TestGenTextRespectsLength(t *testing.T) { + faker := gofakeit.New(1) + + lengths := []int{3, 10, 50, 255} + for _, maxLen := range lengths { + constraints := map[string]any{"length": maxLen} + for i := 0; i < 20; i++ { + val := genText(constraints, faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if len(str) > maxLen { + t.Fatalf("length %d: text %q exceeds max length %d", len(str), str, maxLen) + } + } + } +} + +func TestGenUintRespectsConstraints(t *testing.T) { + faker := gofakeit.New(1) + + constraints := map[string]any{ + "check_min": float64(100), + "check_max": float64(200), + } + + for i := 0; i < 100; i++ { + val := genUint("uint32", constraints, faker) + n, ok := val.(uint) + if !ok { + t.Fatalf("expected uint, got %T", val) + } + if n < 100 || n > 200 { + t.Fatalf("iteration %d: value %d outside range [100, 200]", i, n) + } + } +} + +func TestGenDateFormat(t *testing.T) { + faker := gofakeit.New(1) + + for i := 0; i < 20; i++ { + val := genDate(faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + parsed, err := time.Parse("2006-01-02", str) + if err != nil { + t.Fatalf("iteration %d: failed to parse date %q: %v", i, str, err) + } + if parsed.After(time.Now()) || parsed.Before(time.Now().AddDate(-10, 0, 0)) { + t.Fatalf("iteration %d: date %s outside expected 10-year range", i, str) + } + } +} + +func TestGenDateTimeFormat(t *testing.T) { + faker := gofakeit.New(1) + + for i := 0; i < 20; i++ { + val := genDateTime(faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + parsed, err := time.Parse("2006-01-02 15:04:05", str) + if err != nil { + t.Fatalf("iteration %d: failed to parse datetime %q: %v", i, str, err) + } + if parsed.After(time.Now()) || parsed.Before(time.Now().AddDate(-10, 0, 0)) { + t.Fatalf("iteration %d: datetime %s outside expected 10-year range", i, str) + } + } +} + +func TestGenTimeFormat(t *testing.T) { + faker := gofakeit.New(1) + + for i := 0; i < 20; i++ { + val := genTime(faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if _, err := time.Parse("15:04:05", str); err != nil { + t.Fatalf("iteration %d: failed to parse time %q: %v", i, str, err) + } + } +} + +func TestGenYearRange(t *testing.T) { + faker := gofakeit.New(1) + + for i := 0; i < 20; i++ { + val := genYear(faker) + n, ok := val.(int) + if !ok { + t.Fatalf("expected int, got %T", val) + } + if n < 1970 || n > time.Now().Year() { + t.Fatalf("iteration %d: year %d outside range [1970, %d]", i, n, time.Now().Year()) + } + } +} + +func TestGenIntervalFormat(t *testing.T) { + faker := gofakeit.New(1) + validUnits := map[string]bool{ + "seconds": true, "minutes": true, "hours": true, + "days": true, "weeks": true, "months": true, "years": true, + } + + pattern := regexp.MustCompile(`^(\d+)\s+(\w+)$`) + for i := 0; i < 20; i++ { + val := genInterval(faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + matches := pattern.FindStringSubmatch(str) + if matches == nil { + t.Fatalf("iteration %d: interval %q doesn't match expected format", i, str) + } + num, _ := strconv.Atoi(matches[1]) + if num < 1 || num > 30 { + t.Fatalf("iteration %d: interval value %d outside range [1, 30]", i, num) + } + if !validUnits[matches[2]] { + t.Fatalf("iteration %d: invalid interval unit %q", i, matches[2]) + } + } +} + +func TestGenIPv4Format(t *testing.T) { + faker := gofakeit.New(1) + ipv4Pattern := regexp.MustCompile(`^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$`) + + for i := 0; i < 20; i++ { + val := genIPv4(faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !ipv4Pattern.MatchString(str) { + t.Fatalf("iteration %d: %q is not a valid IPv4 format", i, str) + } + } +} + +func TestGenIPv6Format(t *testing.T) { + faker := gofakeit.New(1) + + for i := 0; i < 20; i++ { + val := genIPv6(faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.Contains(str, ":") { + t.Fatalf("iteration %d: %q doesn't look like an IPv6 address", i, str) + } + } +} + +func TestGenMACAddrFormat(t *testing.T) { + faker := gofakeit.New(1) + macPattern := regexp.MustCompile(`^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$`) + + for i := 0; i < 20; i++ { + val := genMACAddr(faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !macPattern.MatchString(str) { + t.Fatalf("iteration %d: %q is not a valid MAC address format", i, str) + } + } +} + +func TestGenPointFormat(t *testing.T) { + faker := gofakeit.New(1) + + for i := 0; i < 20; i++ { + val := genPoint(faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.HasPrefix(str, "(") || !strings.HasSuffix(str, ")") { + t.Fatalf("iteration %d: point %q not wrapped in parens", i, str) + } + if !strings.Contains(str, ",") { + t.Fatalf("iteration %d: point %q missing comma separator", i, str) + } + } +} + +func TestGenGeometryFormats(t *testing.T) { + faker := gofakeit.New(1) + + tests := []struct { + typeName string + contains string + }{ + {"line", "{"}, + {"lseg", "[("}, + {"box", "("}, + {"path", "[("}, + {"polygon", "(("}, + {"circle", "<("}, + } + + for _, tt := range tests { + t.Run(tt.typeName, func(t *testing.T) { + val := genGeometry(tt.typeName, faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.Contains(str, tt.contains) { + t.Fatalf("%s: expected output to contain %q, got %q", tt.typeName, tt.contains, str) + } + }) + } +} + +func TestGenSpatialFormats(t *testing.T) { + faker := gofakeit.New(1) + + tests := []struct { + typeName string + prefix string + }{ + {"geometry", "POINT("}, + {"geography", "POINT("}, + {"linestring", "LINESTRING("}, + {"multipoint", "MULTIPOINT("}, + {"multilinestring", "MULTILINESTRING("}, + {"multipolygon", "MULTIPOLYGON("}, + {"geometrycollection", "GEOMETRYCOLLECTION("}, + } + + for _, tt := range tests { + t.Run(tt.typeName, func(t *testing.T) { + val := genSpatial(tt.typeName, faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.HasPrefix(str, tt.prefix) { + t.Fatalf("%s: expected prefix %q, got %q", tt.typeName, tt.prefix, str) + } + }) + } +} + +func TestGenClickHouseDecimalFormats(t *testing.T) { + faker := gofakeit.New(1) + + types := []string{"decimal32", "decimal64", "decimal128", "decimal256"} + for _, typeName := range types { + t.Run(typeName, func(t *testing.T) { + for i := 0; i < 20; i++ { + val := genClickHouseDecimal(typeName, nil, faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + f, err := strconv.ParseFloat(str, 64) + if err != nil { + t.Fatalf("iteration %d: failed to parse %q as float: %v", i, str, err) + } + if f < 0 { + t.Fatalf("iteration %d: value %f should be non-negative", i, f) + } + if !strings.Contains(str, ".") { + t.Fatalf("iteration %d: expected decimal point in %q", i, str) + } + } + }) + } +} + +func TestGenXMLFormat(t *testing.T) { + faker := gofakeit.New(1) + + for i := 0; i < 20; i++ { + val := genXML(faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.HasPrefix(str, "") || !strings.HasSuffix(str, "") { + t.Fatalf("iteration %d: XML %q not wrapped in tags", i, str) + } + if !strings.Contains(str, "") || !strings.Contains(str, "") { + t.Fatalf("iteration %d: XML %q missing expected child elements", i, str) + } + } +} + +func TestGenJSONFormat(t *testing.T) { + faker := gofakeit.New(1) + + for i := 0; i < 20; i++ { + val := genJSON(faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.HasPrefix(str, "{") || !strings.HasSuffix(str, "}") { + t.Fatalf("iteration %d: JSON %q not a valid object", i, str) + } + for _, key := range []string{"id", "name", "email", "active"} { + if !strings.Contains(str, fmt.Sprintf("%q", key)) { + t.Fatalf("iteration %d: JSON missing key %q: %s", i, key, str) + } + } + } +} + +func TestGenHstoreFormat(t *testing.T) { + faker := gofakeit.New(1) + + for i := 0; i < 20; i++ { + val := genHstore(faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.Contains(str, "=>") { + t.Fatalf("iteration %d: hstore %q missing => separator", i, str) + } + } +} + +func TestGenBinaryFormat(t *testing.T) { + faker := gofakeit.New(1) + + t.Run("default length", func(t *testing.T) { + for i := 0; i < 20; i++ { + val := genBinary(nil, faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.HasPrefix(str, "0x") { + t.Fatalf("iteration %d: binary %q missing 0x prefix", i, str) + } + } + }) + + t.Run("custom length", func(t *testing.T) { + constraints := map[string]any{"length": 4} + for i := 0; i < 20; i++ { + val := genBinary(constraints, faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.HasPrefix(str, "0x") { + t.Fatalf("iteration %d: binary %q missing 0x prefix", i, str) + } + // 0x prefix + 2 hex chars per byte = 2 + 4*2 = 10 + hexPart := str[2:] + if len(hexPart) != 8 { + t.Fatalf("iteration %d: expected 8 hex chars for 4 bytes, got %d in %q", i, len(hexPart), str) + } + } + }) +} + +func TestGenArrayFormats(t *testing.T) { + faker := gofakeit.New(1) + + types := []string{"text[]", "int[]", "integer[]", "varchar[]"} + for _, dbType := range types { + t.Run(dbType, func(t *testing.T) { + for i := 0; i < 20; i++ { + val := genArray(dbType, faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.HasPrefix(str, "{") || !strings.HasSuffix(str, "}") { + t.Fatalf("iteration %d: array %q not wrapped in braces", i, str) + } + } + }) + } +} + +func TestGenerateByTypeRoutesToCorrectGenerator(t *testing.T) { + faker := gofakeit.New(1) + + tests := []struct { + dbType string + validate func(t *testing.T, val any) + }{ + {"integer", func(t *testing.T, val any) { + if _, ok := val.(int); !ok { + t.Fatalf("expected int, got %T", val) + } + }}, + {"boolean", func(t *testing.T, val any) { + if _, ok := val.(bool); !ok { + t.Fatalf("expected bool, got %T", val) + } + }}, + {"uuid", func(t *testing.T, val any) { + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if len(strings.Split(str, "-")) != 5 { + t.Fatalf("expected UUID format, got %q", str) + } + }}, + {"date", func(t *testing.T, val any) { + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if _, err := time.Parse("2006-01-02", str); err != nil { + t.Fatalf("expected date format, got %q", str) + } + }}, + {"json", func(t *testing.T, val any) { + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.HasPrefix(str, "{") { + t.Fatalf("expected JSON object, got %q", str) + } + }}, + {"inet", func(t *testing.T, val any) { + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.Contains(str, ".") { + t.Fatalf("expected IPv4 format, got %q", str) + } + }}, + {"macaddr", func(t *testing.T, val any) { + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.Contains(str, ":") { + t.Fatalf("expected MAC address format, got %q", str) + } + }}, + {"numeric", func(t *testing.T, val any) { + f, ok := val.(float64) + if !ok { + t.Fatalf("expected float64, got %T", val) + } + if f < 0 || f > 1000 { + t.Fatalf("expected value in default range, got %f", f) + } + }}, + {"money", func(t *testing.T, val any) { + if _, ok := val.(float64); !ok { + t.Fatalf("expected float64 for money, got %T", val) + } + }}, + {"bytea", func(t *testing.T, val any) { + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.HasPrefix(str, "0x") { + t.Fatalf("expected hex prefix, got %q", str) + } + }}, + {"xml", func(t *testing.T, val any) { + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.HasPrefix(str, "<") { + t.Fatalf("expected XML, got %q", str) + } + }}, + {"hstore", func(t *testing.T, val any) { + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.Contains(str, "=>") { + t.Fatalf("expected hstore format, got %q", str) + } + }}, + {"point", func(t *testing.T, val any) { + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.HasPrefix(str, "(") { + t.Fatalf("expected point format, got %q", str) + } + }}, + {"text[]", func(t *testing.T, val any) { + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if !strings.HasPrefix(str, "{") { + t.Fatalf("expected array format, got %q", str) + } + }}, + } + + for _, tt := range tests { + t.Run(tt.dbType, func(t *testing.T) { + val := GenerateByType(tt.dbType, "", nil, faker) + tt.validate(t, val) + }) + } +} + +func TestUnwrapTypeModifiers(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"Nullable(Int32)", "Int32"}, + {"LowCardinality(String)", "String"}, + {"LowCardinality(Nullable(String))", "String"}, + {"Nullable(LowCardinality(FixedString(10)))", "FixedString(10)"}, + {"Int32", "Int32"}, + {"varchar(255)", "varchar(255)"}, + {"NULLABLE(FLOAT64)", "FLOAT64"}, + {"nullable(datetime64(3))", "datetime64(3)"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := unwrapTypeModifiers(tt.input) + if result != tt.expected { + t.Errorf("unwrapTypeModifiers(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestDetectDatabaseTypeHandlesWrappedTypes(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"Nullable(Int32)", "int"}, + {"LowCardinality(String)", "text"}, + {"Nullable(Float64)", "float"}, + {"LowCardinality(Nullable(String))", "text"}, + {"Nullable(DateTime)", "datetime"}, + {"Nullable(UUID)", "uuid"}, + {"Nullable(Bool)", "bool"}, + {"Nullable(Date)", "date"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := detectDatabaseType(tt.input) + if result != tt.expected { + t.Errorf("detectDatabaseType(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestGenerateByTypeClickHouseEnum(t *testing.T) { + faker := gofakeit.New(1) + + // Enum with check_values constraint (as set by the ClickHouse plugin) + constraints := map[string]any{ + "check_values": []string{"active", "inactive", "pending"}, + } + + for i := 0; i < 50; i++ { + val := GenerateByType("Enum8", "", constraints, faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + if str != "active" && str != "inactive" && str != "pending" { + t.Fatalf("iteration %d: expected enum value, got %q", i, str) + } + } +} + +func TestGenerateByTypeNullableInt(t *testing.T) { + faker := gofakeit.New(1) + + // Nullable(Int32) should generate an integer, not text + val := GenerateByType("Nullable(Int32)", "", nil, faker) + if _, ok := val.(int); !ok { + t.Fatalf("expected int for Nullable(Int32), got %T: %v", val, val) + } +} + +func TestGenerateByTypeLowCardinalityString(t *testing.T) { + faker := gofakeit.New(1) + + // LowCardinality(String) should generate text + val := GenerateByType("LowCardinality(String)", "", nil, faker) + if _, ok := val.(string); !ok { + t.Fatalf("expected string for LowCardinality(String), got %T: %v", val, val) + } +} + +func TestGenerateByTypeClickHouseArray(t *testing.T) { + faker := gofakeit.New(1) + + val := GenerateByType("Array(Int32)", "", nil, faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string for Array(Int32), got %T", val) + } + if !strings.HasPrefix(str, "[") || !strings.HasSuffix(str, "]") { + t.Fatalf("expected array literal [..], got %q", str) + } +} + +func TestGenerateByTypeClickHouseMap(t *testing.T) { + faker := gofakeit.New(1) + + val := GenerateByType("Map(String, Int32)", "", nil, faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string for Map(String, Int32), got %T", val) + } + if !strings.HasPrefix(str, "{") || !strings.HasSuffix(str, "}") { + t.Fatalf("expected map literal {..}, got %q", str) + } +} + +func TestGenerateByTypeClickHouseTuple(t *testing.T) { + faker := gofakeit.New(1) + + val := GenerateByType("Tuple(String, Int32, Float64)", "", nil, faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string for Tuple(...), got %T", val) + } + if !strings.HasPrefix(str, "(") || !strings.HasSuffix(str, ")") { + t.Fatalf("expected tuple literal (..), got %q", str) + } + // Should have 3 elements separated by commas + inner := str[1 : len(str)-1] + parts := strings.Split(inner, ", ") + if len(parts) != 3 { + t.Fatalf("expected 3 tuple elements, got %d in %q", len(parts), str) + } +} + +func TestGenClickHouseDecimalRespectsScale(t *testing.T) { + faker := gofakeit.New(1) + + tests := []struct { + name string + scale int + precision int + }{ + {"Decimal32(5)", 5, 9}, + {"Decimal64(3)", 3, 18}, + {"Decimal32(0)", 0, 9}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + constraints := map[string]any{ + "scale": tt.scale, + "precision": tt.precision, + } + for i := 0; i < 50; i++ { + val := genClickHouseDecimal("decimal32", constraints, faker) + str, ok := val.(string) + if !ok { + t.Fatalf("expected string, got %T", val) + } + // Check decimal places + if dotIdx := strings.Index(str, "."); dotIdx >= 0 { + decimals := len(str) - dotIdx - 1 + if decimals != tt.scale { + t.Fatalf("iteration %d: expected %d decimal places, got %d in %q", i, tt.scale, decimals, str) + } + } else if tt.scale > 0 { + t.Fatalf("iteration %d: expected decimal point for scale %d, got %q", i, tt.scale, str) + } + } + }) + } +} + +func TestExtractInnerType(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"Array(Int32)", "Int32"}, + {"Array(String)", "String"}, + {"Array(Nullable(String))", "Nullable(String)"}, + {"Map(String, Int32)", "String, Int32"}, + {"Tuple(String, Int32, Float64)", "String, Int32, Float64"}, + {"NoParens", "String"}, // fallback + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := extractInnerType(tt.input) + if result != tt.expected { + t.Errorf("extractInnerType(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestExtractMapTypes(t *testing.T) { + tests := []struct { + input string + expectedK string + expectedV string + }{ + {"Map(String, Int32)", "String", "Int32"}, + {"Map(String, Array(Int32))", "String", "Array(Int32)"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + k, v := extractMapTypes(tt.input) + if k != tt.expectedK || v != tt.expectedV { + t.Errorf("extractMapTypes(%q) = (%q, %q), want (%q, %q)", tt.input, k, v, tt.expectedK, tt.expectedV) + } + }) + } +} + +func TestExtractTupleTypes(t *testing.T) { + types := extractTupleTypes("Tuple(String, Int32, Float64)") + if len(types) != 3 { + t.Fatalf("expected 3 types, got %d: %v", len(types), types) + } + expected := []string{"String", "Int32", "Float64"} + for i, e := range expected { + if types[i] != e { + t.Errorf("type[%d] = %q, want %q", i, types[i], e) + } + } +} + +func TestGenDecimalScaleRounding(t *testing.T) { + faker := gofakeit.New(1) + + scales := []int{0, 1, 3, 4} + for _, scale := range scales { + t.Run(fmt.Sprintf("scale_%d", scale), func(t *testing.T) { + constraints := map[string]any{"scale": scale} + for i := 0; i < 50; i++ { + val := genDecimal(constraints, faker) + f, ok := val.(float64) + if !ok { + t.Fatalf("expected float64, got %T", val) + } + // Verify rounding: multiply by 10^scale, result should be integer + multiplier := math.Pow(10, float64(scale)) + rounded := math.Round(f * multiplier) + if math.Abs(f*multiplier-rounded) > 1e-9 { + t.Fatalf("iteration %d: value %f has more than %d decimal places", i, f, scale) + } + } + }) + } +} diff --git a/dataflow/core/src/mockdata/policy.go b/dataflow/core/src/mockdata/policy.go new file mode 100644 index 0000000..afdd36f --- /dev/null +++ b/dataflow/core/src/mockdata/policy.go @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mockdata + +import ( + "strings" + + "github.com/clidey/whodb/core/src/env" +) + +// IsMockDataGenerationAllowed checks if mock data generation is allowed for the given table. +// Respects the WHODB_DISABLE_MOCK_DATA_GENERATION environment variable: +// - Empty: all tables allowed +// - "*": all tables disabled +// - Comma-separated list: specific tables disabled +func IsMockDataGenerationAllowed(tableName string) bool { + if env.DisableMockDataGeneration == "" { + return true + } + + if env.DisableMockDataGeneration == "*" { + return false + } + + disabledTables := strings.Split(env.DisableMockDataGeneration, ",") + for _, disabled := range disabledTables { + if strings.TrimSpace(disabled) == tableName { + return false + } + } + + return true +} + +// GetMockDataGenerationMaxRowCount returns the maximum number of rows that can be +// generated in a single mock data generation request. +func GetMockDataGenerationMaxRowCount() int { + return 200 +} diff --git a/dataflow/core/src/mockdata/policy_test.go b/dataflow/core/src/mockdata/policy_test.go new file mode 100644 index 0000000..5735c28 --- /dev/null +++ b/dataflow/core/src/mockdata/policy_test.go @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mockdata + +import ( + "testing" + + "github.com/clidey/whodb/core/src/env" +) + +func TestIsMockDataGenerationAllowed(t *testing.T) { + original := env.DisableMockDataGeneration + t.Cleanup(func() { + env.DisableMockDataGeneration = original + }) + + env.DisableMockDataGeneration = "" + if !IsMockDataGenerationAllowed("users") { + t.Fatalf("mock data generation should be allowed when unset") + } + + env.DisableMockDataGeneration = "*" + if IsMockDataGenerationAllowed("anything") { + t.Fatalf("mock data generation should be disabled when wildcard is used") + } + + env.DisableMockDataGeneration = "logs, metrics" + if IsMockDataGenerationAllowed("logs") { + t.Fatalf("logs should be disabled when listed") + } + if !IsMockDataGenerationAllowed("orders") { + t.Fatalf("orders should remain enabled when not listed") + } +} diff --git a/dataflow/core/src/mockdata/type_mappings.go b/dataflow/core/src/mockdata/type_mappings.go new file mode 100644 index 0000000..99dcaac --- /dev/null +++ b/dataflow/core/src/mockdata/type_mappings.go @@ -0,0 +1,661 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mockdata + +import ( + "encoding/json" + "fmt" + "math" + "strings" + "time" + + "github.com/brianvoe/gofakeit/v7" +) + +// TypeGenerator is a function that generates mock data for a database type. +// Returns (value, handled). If handled is false, the caller falls back to default handling. +type TypeGenerator func(dbType string, databaseType string, constraints map[string]any, faker *gofakeit.Faker) (any, bool) + +var eeTypeGenerator TypeGenerator + +// RegisterEETypeGenerator allows EE to register a handler for EE-specific types +func RegisterEETypeGenerator(gen TypeGenerator) { + eeTypeGenerator = gen +} + +// unwrapTypeModifiers strips ClickHouse wrapper types like Nullable(...) and +// LowCardinality(...) to expose the inner type. Handles nested wrappers like +// LowCardinality(Nullable(String)). The stripping is case-insensitive. +func unwrapTypeModifiers(dbType string) string { + for { + lower := strings.ToLower(dbType) + trimmed := strings.TrimSpace(lower) + unwrapped := false + for _, prefix := range []string{"nullable(", "lowcardinality("} { + if strings.HasPrefix(trimmed, prefix) && strings.HasSuffix(trimmed, ")") { + // Extract inner type preserving original case from dbType + inner := strings.TrimSpace(dbType[len(prefix) : len(dbType)-1]) + dbType = inner + unwrapped = true + break + } + } + if !unwrapped { + return dbType + } + } +} + +// GenerateByType generates a value for a database type, respecting constraints. +// The dbType parameter is the raw column type (e.g., "int", "text[]", "varchar(255)"). +// The constraints map contains check_min, check_max, length, scale, check_values, etc. +func GenerateByType(dbType string, databaseType string, constraints map[string]any, faker *gofakeit.Faker) any { + // Try ee first + if eeTypeGenerator != nil { + if value, handled := eeTypeGenerator(dbType, databaseType, constraints, faker); handled { + return value + } + } + + // Unwrap ClickHouse wrapper types (Nullable, LowCardinality) before normalization + dbType = unwrapTypeModifiers(dbType) + + normalizedType := strings.ToLower(dbType) + + if strings.HasSuffix(normalizedType, "[]") { + return genArray(dbType, faker) + } + + // Handle ClickHouse compound types before stripping parentheses + if strings.HasPrefix(normalizedType, "array(") { + return genClickHouseArray(dbType, databaseType, constraints, faker) + } + if strings.HasPrefix(normalizedType, "map(") { + return genClickHouseMap(dbType, databaseType, faker) + } + if strings.HasPrefix(normalizedType, "tuple(") { + return genClickHouseTuple(dbType, databaseType, faker) + } + + if idx := strings.Index(normalizedType, "("); idx > 0 { + normalizedType = normalizedType[:idx] + } + + // Handle timestamp/time types with timezone suffixes (e.g., "timestamp with time zone") + // These need prefix matching because PostgreSQL includes timezone info in the type name + if strings.HasPrefix(normalizedType, "timestamp") { + return genDateTime(faker) + } + if strings.HasPrefix(normalizedType, "time") && !strings.HasPrefix(normalizedType, "tinyint") { + return genTime(faker) + } + + switch normalizedType { + case "int", "integer", "int2", "int4", "int8", "smallint", "bigint", "tinyint", "mediumint", "serial", "bigserial", "smallserial", + "int16", "int32", "int64", "int128", "int256": + return genInt(normalizedType, constraints, faker) + + case "uint", "uint8", "uint16", "uint32", "uint64", "uint128", "uint256", + "tinyint unsigned", "smallint unsigned", "mediumint unsigned", "int unsigned", "bigint unsigned": + return genUint(normalizedType, constraints, faker) + + case "float", "float4", "float8", "real", "double", "double precision", "decimal", "numeric", "number", "money", + "float32", "float64": + return genDecimal(constraints, faker) + + case "bool", "boolean", "bit": + return faker.Bool() + + case "date", "date32": + return genDate(faker) + case "datetime", "datetime64": + return genDateTime(faker) + case "interval": + return genInterval(faker) + case "year": + return genYear(faker) + + case "uuid": + return faker.UUID() + + case "json", "jsonb": + return genJSON(faker) + + case "bytea", "blob", "binary", "varbinary", "image", "tinyblob", "mediumblob", "longblob": + return genBinary(constraints, faker) + + case "text", "string", "varchar", "char", "character", "character varying", "nvarchar", "nchar", "ntext", "fixedstring", "clob", "long", + "tinytext", "mediumtext", "longtext": + return genText(constraints, faker) + + case "hstore": + return genHstore(faker) + + case "enum", "enum8", "enum16": + return genText(constraints, faker) + + case "set": + return genText(constraints, faker) + + case "inet", "cidr", "ipv4": + return genIPv4(faker) + case "ipv6": + return genIPv6(faker) + case "macaddr", "macaddr8": + return genMACAddr(faker) + + case "point": + return genPoint(faker) + + case "xml": + return genXML(faker) + + case "line", "lseg", "box", "path", "polygon", "circle": + return genGeometry(normalizedType, faker) + + case "geometry", "geography", "linestring", "multipoint", "multilinestring", "multipolygon", "geometrycollection": + return genSpatial(normalizedType, faker) + + case "decimal32", "decimal64", "decimal128", "decimal256": + return genClickHouseDecimal(normalizedType, constraints, faker) + + default: + return genText(constraints, faker) + } +} + +// genInt generates an integer respecting type limits and check_min/check_max constraints. +// Type-specific limits are applied as defaults when no constraints are provided. +func genInt(typeName string, c map[string]any, f *gofakeit.Faker) any { + // Type-specific default limits (using safe positive ranges for mock data) + var minVal, maxVal int64 + switch typeName { + case "tinyint": + minVal, maxVal = 1, 127 + case "smallint", "int2", "int16", "smallserial": + minVal, maxVal = 1, 32767 + case "mediumint": + minVal, maxVal = 1, 8388607 + default: + // int, integer, int4, int8, bigint, serial, bigserial, int32, int64, int128, int256 + // Conservative max to avoid overflows in triggers/computed columns + // (e.g., SUM(price * quantity) stored in a NUMERIC(10,2) column) + minVal, maxVal = 1, 1000 + } + + // Override with explicit constraints if provided + if c != nil { + if v, ok := c["check_min"].(float64); ok { + minVal = int64(v) + } + if v, ok := c["check_max"].(float64); ok { + maxVal = int64(v) + } + } + + // Ensure min <= max + if minVal > maxVal { + minVal, maxVal = maxVal, minVal + } + + return f.IntRange(int(minVal), int(maxVal)) +} + +// genUint generates an unsigned integer respecting type limits and check_min/check_max constraints. +func genUint(typeName string, c map[string]any, f *gofakeit.Faker) any { + // Type-specific default limits + var minVal, maxVal uint64 + switch typeName { + case "uint8", "tinyint unsigned": + minVal, maxVal = 0, 255 + case "uint16", "smallint unsigned": + minVal, maxVal = 0, 65535 + case "mediumint unsigned": + minVal, maxVal = 0, 16777215 + default: + // uint, uint32, uint64, uint128, uint256, int unsigned, bigint unsigned + minVal, maxVal = 0, 1000 + } + + // Override with explicit constraints if provided + if c != nil { + if v, ok := c["check_min"].(float64); ok && v >= 0 { + minVal = uint64(v) + } + if v, ok := c["check_max"].(float64); ok && v >= 0 { + maxVal = uint64(v) + } + } + + if minVal > maxVal { + minVal, maxVal = maxVal, minVal + } + + return f.UintRange(uint(minVal), uint(maxVal)) +} + +// genDecimal generates a decimal number respecting precision, scale, and check_min/check_max constraints. +// Uses a conservative default max (1000) rather than precision-derived max to avoid overflows +// from triggers, computed columns, or expressions like SUM(price * quantity) that can exceed +// individual column limits. +func genDecimal(c map[string]any, f *gofakeit.Faker) any { + minVal := 0.0 + maxVal := 1000.0 + scale := 2 + + if c != nil { + if s, ok := c["scale"].(int); ok && s >= 0 { + scale = s + } + // Use precision to TIGHTEN max when the column can't hold the default range. + // e.g., decimal(5,2) max is 999.99, which is below the default 1000 → cap to 999.99. + // But decimal(10,2) max is 99999999.99 — DON'T inflate to that, because triggers + // or computed columns (e.g., SUM(price * quantity)) can overflow other columns. + var precision int + if p, ok := c["precision"].(int); ok && p > 0 { + precision = p + } else if p, ok := c["precision"].(int64); ok && p > 0 { + precision = int(p) + } + if precision > 0 { + intDigits := precision - scale + var precisionMax float64 + if intDigits > 0 { + precisionMax = math.Pow(10, float64(intDigits)) - math.Pow(10, -float64(scale)) + } else { + precisionMax = 1 - math.Pow(10, -float64(scale)) + } + // Only tighten, never inflate + if precisionMax < maxVal { + maxVal = precisionMax + } + } + // Explicit constraints override + if v, ok := c["check_min"].(float64); ok { + minVal = v + } + if v, ok := c["check_max"].(float64); ok { + maxVal = v + } + } + + if minVal > maxVal { + minVal, maxVal = maxVal, minVal + } + + val := f.Float64Range(minVal, maxVal) + multiplier := math.Pow(10, float64(scale)) + rounded := math.Round(val*multiplier) / multiplier + // Clamp after rounding — rounding can push values just above the boundary + // (e.g., 99999999.995 rounds to 100000000.00 which overflows NUMERIC(10,2)) + if rounded > maxVal { + rounded = maxVal + } + if rounded < minVal { + rounded = minVal + } + return rounded +} + +// genDate generates a date within the last 10 years +func genDate(f *gofakeit.Faker) any { + start := time.Now().AddDate(-10, 0, 0) + end := time.Now() + return f.DateRange(start, end).Format("2006-01-02") +} + +// genDateTime generates a datetime within the last 10 years +func genDateTime(f *gofakeit.Faker) any { + start := time.Now().AddDate(-10, 0, 0) + end := time.Now() + return f.DateRange(start, end).Format("2006-01-02 15:04:05") +} + +// genTime generates a time value +func genTime(f *gofakeit.Faker) any { + return f.Date().Format("15:04:05") +} + +// genYear generates a year value (MySQL YEAR type: 1901-2155) +func genYear(f *gofakeit.Faker) any { + return f.IntRange(1970, time.Now().Year()) +} + +// genInterval generates a PostgreSQL-compatible interval string +func genInterval(f *gofakeit.Faker) any { + units := []string{"seconds", "minutes", "hours", "days", "weeks", "months", "years"} + unit := units[f.Number(0, len(units)-1)] + value := f.Number(1, 30) + return fmt.Sprintf("%d %s", value, unit) +} + +// genIPv4 generates an IPv4 address string +func genIPv4(f *gofakeit.Faker) any { + return f.IPv4Address() +} + +// genIPv6 generates an IPv6 address string +func genIPv6(f *gofakeit.Faker) any { + return f.IPv6Address() +} + +// genMACAddr generates a MAC address string +func genMACAddr(f *gofakeit.Faker) any { + return f.MacAddress() +} + +// genPoint generates a PostgreSQL POINT value as "(x,y)" +func genPoint(f *gofakeit.Faker) any { + x := f.Float64Range(-180, 180) + y := f.Float64Range(-90, 90) + return fmt.Sprintf("(%f,%f)", x, y) +} + +// genGeometry generates PostgreSQL native geometry types +func genGeometry(typeName string, f *gofakeit.Faker) any { + switch typeName { + case "line": + return "{1,2,3}" + case "lseg": + return "[(0,0),(1,1)]" + case "box": + return "(1,1),(0,0)" + case "path": + return "[(0,0),(1,1),(2,0)]" + case "polygon": + return "((0,0),(1,0),(1,1),(0,1),(0,0))" + case "circle": + return "<(0,0),1>" + default: + return "(0,0)" + } +} + +// genSpatial generates PostGIS/spatial types in WKT format +func genSpatial(typeName string, f *gofakeit.Faker) any { + switch typeName { + case "geometry", "geography": + return "POINT(0 0)" + case "linestring": + return "LINESTRING(0 0, 1 1, 2 2)" + case "multipoint": + return "MULTIPOINT((0 0), (1 1))" + case "multilinestring": + return "MULTILINESTRING((0 0, 1 1), (2 2, 3 3))" + case "multipolygon": + return "MULTIPOLYGON(((0 0, 1 0, 1 1, 0 0)))" + case "geometrycollection": + return "GEOMETRYCOLLECTION(POINT(0 0), LINESTRING(0 0, 1 1))" + default: + return "POINT(0 0)" + } +} + +// genClickHouseDecimal generates ClickHouse decimal values as strings. +// Uses scale/precision from constraints when available (set by the ClickHouse plugin). +// Decimal32(S) has max precision 9, Decimal64(S) has 18, Decimal128(S) has 38, Decimal256(S) has 76. +func genClickHouseDecimal(typeName string, c map[string]any, f *gofakeit.Faker) any { + // Default max precisions per type + maxPrecision := 9 + switch typeName { + case "decimal64": + maxPrecision = 18 + case "decimal128": + maxPrecision = 38 + case "decimal256": + maxPrecision = 76 + } + + scale := 2 // default + if c != nil { + if s, ok := c["scale"].(int); ok && s >= 0 { + scale = s + } + if p, ok := c["precision"].(int); ok && p > 0 { + maxPrecision = p + } + } + + intDigits := maxPrecision - scale + if intDigits < 0 { + intDigits = 0 + } + // Cap integer digits to avoid float64 overflow + if intDigits > 15 { + intDigits = 15 + } + + maxVal := math.Pow(10, float64(intDigits)) - math.Pow(10, -float64(scale)) + if maxVal <= 0 { + maxVal = 1 - math.Pow(10, -float64(scale)) + } + // Keep values conservative for mock data + if maxVal > 1000 { + maxVal = 1000 + } + + val := f.Float64Range(0, maxVal) + return fmt.Sprintf("%.*f", scale, val) +} + +// genXML generates a simple XML element +func genXML(f *gofakeit.Faker) any { + return fmt.Sprintf("%d%s", f.Number(1, 1000), f.Word()) +} + +// genJSON generates a simple JSON object +func genJSON(f *gofakeit.Faker) any { + data := map[string]any{ + "id": f.Number(1, 1000), + "name": f.Name(), + "email": f.Email(), + "active": f.Bool(), + } + jsonBytes, _ := json.Marshal(data) + return string(jsonBytes) +} + +// genHstore generates a PostgreSQL hstore value as "key1"=>"value1","key2"=>"value2" +func genHstore(f *gofakeit.Faker) any { + pairs := make([]string, f.Number(1, 3)) + for i := range pairs { + key := f.Word() + value := f.Word() + pairs[i] = fmt.Sprintf("\"%s\"=>\"%s\"", key, value) + } + return strings.Join(pairs, ",") +} + +// genBinary generates binary data as hex string (0x prefixed) +// The hex format survives string serialization and can be decoded by ConvertStringValue +func genBinary(c map[string]any, f *gofakeit.Faker) any { + length := 16 + if c != nil { + if l, ok := c["length"].(int); ok && l > 0 { + length = min(l, 256) + } + } + + bytes := make([]byte, length) + for i := range bytes { + bytes[i] = byte(f.Number(0, 255)) + } + // Return as hex string with 0x prefix for proper round-trip through string serialization + return fmt.Sprintf("0x%x", bytes) +} + +// genArray generates a PostgreSQL-style array literal like {val1,val2,val3} +func genArray(dbType string, f *gofakeit.Faker) any { + count := f.Number(1, 5) + elements := make([]string, count) + + elementType := strings.TrimSuffix(strings.ToLower(dbType), "[]") + + for i := range count { + if elementType == "int" || elementType == "integer" || elementType == "int4" { + elements[i] = f.Numerify("###") + } else { + elements[i] = f.Word() + } + } + + return "{" + strings.Join(elements, ",") + "}" +} + +// genClickHouseArray generates a ClickHouse Array literal like [val1, val2, val3] +func genClickHouseArray(dbType string, databaseType string, constraints map[string]any, faker *gofakeit.Faker) any { + innerType := extractInnerType(dbType) + count := faker.Number(1, 5) + elements := make([]string, count) + for i := range count { + val := GenerateByType(innerType, databaseType, constraints, faker) + elements[i] = fmt.Sprintf("%v", val) + } + return "[" + strings.Join(elements, ", ") + "]" +} + +// genClickHouseMap generates a ClickHouse Map literal like {'k1': v1, 'k2': v2} +func genClickHouseMap(dbType string, databaseType string, faker *gofakeit.Faker) any { + keyType, valueType := extractMapTypes(dbType) + count := faker.Number(1, 3) + pairs := make([]string, count) + for i := range count { + key := GenerateByType(keyType, databaseType, nil, faker) + val := GenerateByType(valueType, databaseType, nil, faker) + keyStr := strings.ReplaceAll(fmt.Sprintf("%v", key), "'", "''") + valStr := strings.ReplaceAll(fmt.Sprintf("%v", val), "'", "''") + pairs[i] = "'" + keyStr + "': '" + valStr + "'" + } + return "{" + strings.Join(pairs, ", ") + "}" +} + +// genClickHouseTuple generates a ClickHouse Tuple literal like ('text', 42, 3.14). +// String-typed values are single-quoted as required by ClickHouse SQL literal syntax. +func genClickHouseTuple(dbType string, databaseType string, faker *gofakeit.Faker) any { + innerTypes := extractTupleTypes(dbType) + elements := make([]string, len(innerTypes)) + for i, t := range innerTypes { + val := GenerateByType(t, databaseType, nil, faker) + // Quote string values per ClickHouse tuple literal syntax + if s, isStr := val.(string); isStr { + elements[i] = "'" + strings.ReplaceAll(s, "'", "''") + "'" + } else { + elements[i] = fmt.Sprintf("%v", val) + } + } + return "(" + strings.Join(elements, ", ") + ")" +} + +// extractInnerType extracts the inner type from a wrapper like "Array(Int32)" -> "Int32" +func extractInnerType(dbType string) string { + start := strings.Index(dbType, "(") + if start == -1 { + return "String" + } + end := strings.LastIndex(dbType, ")") + if end == -1 || end <= start { + return "String" + } + return strings.TrimSpace(dbType[start+1 : end]) +} + +// extractMapTypes extracts key and value types from "Map(String, Int32)" -> ("String", "Int32") +func extractMapTypes(dbType string) (string, string) { + inner := extractInnerType(dbType) + // Split on first comma that's not inside nested parentheses + depth := 0 + for i, ch := range inner { + switch ch { + case '(': + depth++ + case ')': + depth-- + case ',': + if depth == 0 { + return strings.TrimSpace(inner[:i]), strings.TrimSpace(inner[i+1:]) + } + } + } + return "String", "String" +} + +// extractTupleTypes extracts element types from "Tuple(String, Int32, Float64)" +func extractTupleTypes(dbType string) []string { + inner := extractInnerType(dbType) + var types []string + depth := 0 + start := 0 + for i, ch := range inner { + switch ch { + case '(': + depth++ + case ')': + depth-- + case ',': + if depth == 0 { + types = append(types, strings.TrimSpace(inner[start:i])) + start = i + 1 + } + } + } + // Add the last element + if remaining := strings.TrimSpace(inner[start:]); remaining != "" { + types = append(types, remaining) + } + if len(types) == 0 { + return []string{"String"} + } + return types +} + +// genText generates text respecting length and check_values (ENUM) constraints +func genText(c map[string]any, f *gofakeit.Faker) any { + if c != nil { + if values, ok := c["check_values"].([]string); ok && len(values) > 0 { + return values[f.Number(0, len(values)-1)] + } + if values, ok := c["check_values"].([]any); ok && len(values) > 0 { + strValues := make([]string, len(values)) + for i, v := range values { + strValues[i], _ = v.(string) + } + return strValues[f.Number(0, len(strValues)-1)] + } + } + + maxLen := 255 + if c != nil { + if l, ok := c["length"].(int); ok && l > 0 { + maxLen = l + } + } + + var text string + if maxLen <= 10 { + text = f.LetterN(uint(maxLen)) + } else if maxLen <= 50 { + text = f.LoremIpsumSentence(3) + } else { + text = f.LoremIpsumSentence(f.Number(3, 10)) + } + + if len(text) > maxLen { + text = text[:maxLen] + } + + return text +} diff --git a/dataflow/core/src/plugins/clickhouse/add.go b/dataflow/core/src/plugins/clickhouse/add.go new file mode 100644 index 0000000..64e0a4c --- /dev/null +++ b/dataflow/core/src/plugins/clickhouse/add.go @@ -0,0 +1,70 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package clickhouse + +import ( + "errors" + "fmt" + "strings" + + "github.com/clidey/whodb/core/src/engine" + gorm_plugin "github.com/clidey/whodb/core/src/plugins/gorm" + "gorm.io/gorm" +) + +// ErrUpsertNotSupported is returned when upsert mode is attempted on ClickHouse. +var ErrUpsertNotSupported = errors.New("import.error.upsert_not_supported") + +func (p *ClickHousePlugin) GetCreateTableQuery(db *gorm.DB, schema string, storageUnit string, columns []engine.Record) string { + builder := gorm_plugin.NewSQLBuilder(db, p) + + // ClickHouse doesn't decorate primary columns in the column definition; + // instead it uses them for the ORDER BY clause. + var primaryKeys []string + columnDefs := gorm_plugin.RecordsToColumnDefs(columns, func(def gorm_plugin.ColumnDef, column engine.Record) gorm_plugin.ColumnDef { + primaryKeys = append(primaryKeys, column.Key) + // ClickHouse primary keys still respect nullable constraints + if nullable, ok := column.Extra["nullable"]; ok && nullable == "false" { + def.NotNull = true + } + return def + }) + + // Determine ORDER BY clause + orderByClause := "" + if len(primaryKeys) > 0 { + quotedKeys := make([]string, len(primaryKeys)) + for i, key := range primaryKeys { + quotedKeys[i] = builder.QuoteIdentifier(key) + } + orderByClause = strings.Join(quotedKeys, ", ") + } else if len(columns) > 0 { + orderByClause = builder.QuoteIdentifier(columns[0].Key) + } + + suffix := fmt.Sprintf("ENGINE = MergeTree() ORDER BY (%s)", orderByClause) + return builder.CreateTableQueryWithSuffix(schema, storageUnit, columnDefs, suffix) +} + +// BulkAddRows rejects upsert mode (ClickHouse has no ON CONFLICT support) and +// delegates all other modes to the base GormPlugin implementation. +func (p *ClickHousePlugin) BulkAddRows(config *engine.PluginConfig, schema string, storageUnit string, rows [][]engine.Record) (bool, error) { + if len(config.UpsertPKColumns) > 0 { + return false, ErrUpsertNotSupported + } + return p.GormPlugin.BulkAddRows(config, schema, storageUnit, rows) +} diff --git a/dataflow/core/src/plugins/clickhouse/clickhouse.go b/dataflow/core/src/plugins/clickhouse/clickhouse.go new file mode 100644 index 0000000..fac84b2 --- /dev/null +++ b/dataflow/core/src/plugins/clickhouse/clickhouse.go @@ -0,0 +1,567 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package clickhouse + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "math/big" + "net" + "reflect" + "slices" + "strconv" + "strings" + "time" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + gorm_plugin "github.com/clidey/whodb/core/src/plugins/gorm" + "github.com/google/uuid" + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +var ( + supportedOperators = map[string]string{ + "=": "=", ">=": ">=", ">": ">", "<=": "<=", "<": "<", "!=": "!=", "<>": "<>", "==": "==", + "LIKE": "LIKE", "NOT LIKE": "NOT LIKE", "ILIKE": "ILIKE", + "IN": "IN", "NOT IN": "NOT IN", "GLOBAL IN": "GLOBAL IN", "GLOBAL NOT IN": "GLOBAL NOT IN", + "BETWEEN": "BETWEEN", "NOT BETWEEN": "NOT BETWEEN", + "IS NULL": "IS NULL", "IS NOT NULL": "IS NOT NULL", + "AND": "AND", "OR": "OR", "NOT": "NOT", + } +) + +type ClickHousePlugin struct { + gorm_plugin.GormPlugin +} + +func (p *ClickHousePlugin) GetDatabases(config *engine.PluginConfig) ([]string, error) { + return plugins.WithConnection[[]string](config, p.DB, func(db *gorm.DB) ([]string, error) { + var databases []struct { + Datname string `gorm:"column:datname"` + } + if err := db.Table("system.databases"). + Select("name AS datname"). + Scan(&databases).Error; err != nil { + return nil, err + } + databaseNames := []string{} + for _, database := range databases { + databaseNames = append(databaseNames, database.Datname) + } + return databaseNames, nil + }) +} + +func (p *ClickHousePlugin) GetSupportedOperators() map[string]string { + return supportedOperators +} + +// HandleCustomDataType handles ClickHouse compound types (Array, Map, Tuple). +func (p *ClickHousePlugin) HandleCustomDataType(value string, columnType string, isNullable bool) (any, bool, error) { + upper := strings.ToUpper(columnType) + + var convert func(string, string) (any, error) + switch { + case strings.HasPrefix(upper, "ARRAY("): + convert = convertArrayLiteral + case strings.HasPrefix(upper, "MAP("): + convert = convertMapLiteral + case strings.HasPrefix(upper, "TUPLE("): + convert = convertTupleLiteral + default: + return nil, false, nil + } + + if isNullable && (value == "" || strings.EqualFold(value, "NULL")) { + return nil, true, nil + } + result, err := convert(value, columnType) + return result, true, err +} + +func (p *ClickHousePlugin) ConvertStringValue(value, columnType string, isNullable bool) (any, error) { + // Unwrap Nullable() wrapper — ClickHouse encodes nullability in the type string + upperCheck := strings.ToUpper(columnType) + if strings.HasPrefix(upperCheck, "NULLABLE(") { + isNullable = true + columnType = columnType[9:] // strip "Nullable(" (9 chars) + if strings.HasSuffix(columnType, ")") { + columnType = columnType[:len(columnType)-1] + } + } + + // Unwrap LowCardinality() wrapper + if strings.HasPrefix(strings.ToUpper(columnType), "LOWCARDINALITY(") { + columnType = columnType[15:] // strip "LowCardinality(" (15 chars) + if strings.HasSuffix(columnType, ")") { + columnType = columnType[:len(columnType)-1] + } + } + + normalized := strings.ToUpper(p.NormalizeType(columnType)) + + // Handle Array types + if strings.HasPrefix(normalized, "ARRAY") { + if !strings.Contains(columnType, "(") { + return []any{value}, nil + } + return p.ConvertArrayValue(value, columnType) + } + + if strings.Contains(normalized, "JSON") { + if !json.Valid([]byte(value)) { + return nil, fmt.Errorf("invalid JSON format") + } + return value, nil + } + return p.GormPlugin.ConvertStringValue(value, columnType, isNullable) +} + +func (p *ClickHousePlugin) GetAllSchemas(config *engine.PluginConfig) ([]string, error) { + // technically a table is considered a schema in clickhouse + return nil, errors.ErrUnsupported +} + +func (p *ClickHousePlugin) GetTableInfoQuery() string { + return ` + SELECT + name, + engine, + total_rows, + formatReadableSize(total_bytes) as total_size + FROM system.tables + WHERE database = ? + AND name NOT LIKE '.inner%' + ` +} + +func (p *ClickHousePlugin) GetStorageUnitExistsQuery() string { + return `SELECT EXISTS(SELECT 1 FROM system.tables WHERE database = ? AND name = ?)` +} + +func (p *ClickHousePlugin) GetTableNameAndAttributes(rows *sql.Rows) (string, []engine.Record) { + var tableName, tableType string + var totalRows *uint64 + var totalSize *string + + if err := rows.Scan(&tableName, &tableType, &totalRows, &totalSize); err != nil { + log.WithError(err).Error("Failed to scan table name and attributes from ClickHouse system.tables query") + return "", nil + } + + rowCount := "0" + if totalRows != nil { + rowCount = strconv.FormatUint(*totalRows, 10) + } + + size := "unknown" + if totalSize != nil { + size = *totalSize + } + + attributes := []engine.Record{ + {Key: "Type", Value: tableType}, + {Key: "Total Size", Value: size}, + {Key: "Count", Value: rowCount}, + } + + return tableName, attributes +} + +func (p *ClickHousePlugin) RawExecute(config *engine.PluginConfig, query string, params ...any) (*engine.GetRowsResult, error) { + return p.executeRawSQL(config, query, params...) +} + +// ClearTableData handles ClickHouse-specific DELETE semantics. +// ClickHouse DELETE is an async mutation (ALTER TABLE DELETE) that doesn't return results +// in the same way as traditional SQL DELETE. GORM's Delete method doesn't handle this properly. +func (p *ClickHousePlugin) ClearTableData(config *engine.PluginConfig, schema string, storageUnit string) (bool, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + builder := p.CreateSQLBuilder(db) + tableName := builder.BuildFullTableName(schema, storageUnit) + + query := fmt.Sprintf("ALTER TABLE %s DELETE WHERE 1=1", tableName) + + // Execute the DELETE mutation + err := db.Exec(query).Error + if err != nil { + // ClickHouse mutations may return "driver: bad connection" when trying to read + // the non-existent result set. Verify the connection is still healthy. + if err.Error() == "driver: bad connection" { + var result int + if db.Raw("SELECT 1").Scan(&result).Error == nil { + // Connection is healthy, mutation was accepted + log.WithField("table", tableName).Debug("ClickHouse DELETE mutation accepted") + return true, nil + } + } + return false, err + } + + log.WithField("table", tableName).Debug("ClickHouse DELETE executed") + return true, nil + }) +} + +// UpdateStorageUnit handles two ClickHouse-specific issues with ALTER TABLE UPDATE: +// the driver hangs when binding typed slices (Array columns) as parameters, and +// mutations may return "driver: bad connection" on success. +func (p *ClickHousePlugin) UpdateStorageUnit(config *engine.PluginConfig, schema string, storageUnit string, values map[string]string, updatedColumns []string) (bool, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + columnTypeInfos, err := p.GetColumnTypes(db, schema, storageUnit) + if err != nil { + return false, err + } + + arrayColumns := map[string]bool{} + for _, col := range updatedColumns { + if ct, ok := columnTypeInfos[col]; ok && strings.HasPrefix(strings.ToUpper(ct.Type), "ARRAY(") { + arrayColumns[col] = true + } + } + + if len(arrayColumns) == 0 { + result, err := p.GormPlugin.UpdateStorageUnit(config, schema, storageUnit, values, updatedColumns) + if err != nil && err.Error() == "driver: bad connection" { + var check int + if db.Raw("SELECT 1").Scan(&check).Error == nil { + return true, nil + } + } + return result, err + } + + pkColumns, err := p.GetPrimaryKeyColumns(db, schema, storageUnit) + if err != nil { + pkColumns = []string{} + } + + conditions := make(map[string]any) + convertedValues := make(map[string]any) + + for column, strValue := range values { + isPK := slices.Contains(pkColumns, column) + isUpdated := slices.Contains(updatedColumns, column) + if !isPK && !isUpdated && len(pkColumns) > 0 { + continue + } + + colInfo := columnTypeInfos[column] + converted, convErr := p.ConvertStringValue(strValue, colInfo.Type, colInfo.IsNullable) + if convErr != nil { + return false, fmt.Errorf("failed to convert value for column '%s' in table %s.%s: %w", column, schema, storageUnit, convErr) + } + + if isUpdated && arrayColumns[column] && reflect.ValueOf(converted).Kind() == reflect.Slice { + converted = arrayToExpr(converted) + } + + if isPK { + conditions[column] = converted + } else if isUpdated { + convertedValues[column] = converted + } else if len(pkColumns) == 0 { + conditions[column] = converted + } + } + + if len(convertedValues) == 0 { + return true, nil + } + + result := p.CreateSQLBuilder(db).UpdateQuery(schema, storageUnit, convertedValues, conditions) + if result.Error != nil { + if result.Error.Error() == "driver: bad connection" { + var check int + if db.Raw("SELECT 1").Scan(&check).Error == nil { + return true, nil + } + } + return false, result.Error + } + + return true, nil + }) +} + +func (p *ClickHousePlugin) executeRawSQL(config *engine.PluginConfig, query string, params ...any) (*engine.GetRowsResult, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (*engine.GetRowsResult, error) { + // ClickHouse's native TCP protocol only supports one statement per request. + // Multi-statement scripts silently execute only the first statement. + if config != nil && config.MultiStatement && isMultiStatement(query) { + return nil, engine.ErrMultiStatementUnsupported + } + + rows, err := db.Raw(query, params...).Rows() + if err != nil { + // ClickHouse mutations (ALTER TABLE UPDATE/DELETE, INSERT, etc.) execute successfully + // but the driver returns "driver: bad connection" when trying to read the non-existent result set. + // Verify the connection is still healthy with a simple query - if so, the mutation succeeded. + if err.Error() == "driver: bad connection" { + var result int + if db.Raw("SELECT 1").Scan(&result).Error == nil { + return &engine.GetRowsResult{ + Columns: []engine.Column{}, + Rows: [][]string{}, + }, nil + } + } + return nil, err + } + defer rows.Close() + + return p.ConvertRawToRows(rows) + }) +} + +// ShouldHandleColumnType returns true for ClickHouse special types +func (p *ClickHousePlugin) ShouldHandleColumnType(typeName string) bool { + upper := strings.ToUpper(typeName) + // Handle all ClickHouse array and special types + return strings.HasPrefix(upper, "NULLABLE(") || + strings.HasPrefix(upper, "LOWCARDINALITY(") || + strings.HasPrefix(upper, "ARRAY(") || + strings.HasPrefix(upper, "TUPLE(") || + strings.HasPrefix(upper, "MAP(") || + strings.HasPrefix(upper, "NESTED(") || + strings.HasPrefix(upper, "INT128") || + strings.HasPrefix(upper, "INT256") || + strings.HasPrefix(upper, "UINT128") || + strings.HasPrefix(upper, "UINT256") || + strings.HasPrefix(upper, "DECIMAL") || // Decimal32, Decimal64, Decimal128, Decimal256 + strings.HasPrefix(upper, "FIXEDSTRING") || + strings.HasPrefix(upper, "ENUM") || // Enum8, Enum16 + strings.Contains(upper, "DATETIME64") || + upper == "IPV4" || + upper == "IPV6" || + upper == "UUID" || + upper == "DATE32" || + upper == "JSON" || + upper == "POINT" || + upper == "RING" || + upper == "POLYGON" || + upper == "MULTIPOLYGON" +} + +// GetColumnScanner returns appropriate scanner for ClickHouse column types +func (p *ClickHousePlugin) GetColumnScanner(typeName string) any { + upper := strings.ToUpper(typeName) + if strings.HasPrefix(upper, "INT128") || strings.HasPrefix(upper, "INT256") || strings.HasPrefix(upper, "UINT128") || strings.HasPrefix(upper, "UINT256") { + return new(big.Int) + } + // For special ClickHouse types, use any to handle any type + var value any + return &value +} + +// FormatColumnValue formats the value for display +func (p *ClickHousePlugin) FormatColumnValue(typeName string, value any) (string, error) { + // Handle the any pointer we created in GetColumnScanner + if ptr, ok := value.(*any); ok && ptr != nil { + actualValue := *ptr + if actualValue == nil { + return "", nil + } + + upperType := strings.ToUpper(typeName) + + // Handle different ClickHouse types + switch v := actualValue.(type) { + case *big.Int: + if v == nil { + return "", nil + } + return v.String(), nil + case []string: + // Array of strings + quoted := make([]string, len(v)) + for i, s := range v { + quoted[i] = "'" + s + "'" + } + return "[" + strings.Join(quoted, ", ") + "]", nil + case []any: + // Tuple or mixed array + if strings.HasPrefix(upperType, "TUPLE") { + return formatTuple(v), nil + } + return formatSlice(v), nil + case map[string]any: + return formatMap(v), nil + case net.IP: + // IPv4 or IPv6 address + return v.String(), nil + case *net.IP: + // Pointer to IP address + if v != nil { + return v.String(), nil + } + return "", nil + case uuid.UUID: + // UUID type + return v.String(), nil + case time.Time: + // DateTime, DateTime64, Date, Date32 + if strings.Contains(upperType, "DATE") && !strings.Contains(upperType, "DATETIME") { + // Date types - show only date part + return v.Format("2006-01-02"), nil + } + // DateTime types - show full timestamp + if strings.Contains(upperType, "DATETIME64") { + // High precision datetime + return v.Format("2006-01-02 15:04:05.999999999"), nil + } + return v.Format("2006-01-02 15:04:05"), nil + case decimal.Decimal: + // Decimal32, Decimal64, Decimal128, Decimal256 + return v.String(), nil + case []byte: + // FixedString or binary data + if strings.HasPrefix(upperType, "FIXEDSTRING") { + // Trim null bytes for FixedString + trimmed := strings.TrimRight(string(v), "\x00") + return trimmed, nil + } + // Other binary data + return fmt.Sprintf("0x%x", v), nil + case string: + if strings.HasPrefix(upperType, "FIXEDSTRING") { + return strings.TrimRight(v, "\x00"), nil + } + return v, nil + default: + // Handle typed maps/slices from the driver (e.g., map[string]int32, []int32) + // using reflection so the display matches ClickHouse literal syntax + rv := reflect.ValueOf(actualValue) + if rv.Kind() == reflect.Map { + return formatReflectMap(rv, upperType), nil + } + if rv.Kind() == reflect.Slice { + if strings.HasPrefix(upperType, "TUPLE") { + return formatReflectTuple(rv), nil + } + return formatReflectSlice(rv, upperType), nil + } + if stringer, ok := actualValue.(fmt.Stringer); ok { + return stringer.String(), nil + } + if marshaled, err := json.Marshal(actualValue); err == nil && json.Valid(marshaled) { + return string(marshaled), nil + } + return fmt.Sprintf("%v", actualValue), nil + } + } + + // Fallback to string representation + return fmt.Sprintf("%v", value), nil +} + +// NormalizeType converts ClickHouse type aliases to their canonical form. +func (p *ClickHousePlugin) NormalizeType(typeName string) string { + return NormalizeType(typeName) +} + +// GetColumnTypes overrides the base implementation because GORM ClickHouse's +// migrator.ColumnTypes() doesn't support "database.table" format - it uses +// m.CurrentDatabase() internally and expects just the table name. +func (p *ClickHousePlugin) GetColumnTypes(db *gorm.DB, schema, tableName string) (map[string]gorm_plugin.ColumnTypeInfo, error) { + migrator := gorm_plugin.NewMigratorHelper(db, p) + // Pass just table name - ClickHouse GORM driver handles database context + return migrator.GetColumnTypes(tableName) +} + +// GetColumnsForTable overrides the base implementation for the same reason as GetColumnTypes. +func (p *ClickHousePlugin) GetColumnsForTable(config *engine.PluginConfig, schema string, storageUnit string) ([]engine.Column, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) ([]engine.Column, error) { + migrator := gorm_plugin.NewMigratorHelper(db, p) + + // Pass just table name - ClickHouse GORM driver handles database context + columns, err := migrator.GetOrderedColumns(storageUnit) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to get columns for table %s.%s", schema, storageUnit)) + return nil, err + } + + // Get primary keys + primaryKeys, err := p.GetPrimaryKeyColumns(db, schema, storageUnit) + if err != nil { + log.WithError(err).Warn(fmt.Sprintf("Failed to get primary keys for table %s.%s", schema, storageUnit)) + primaryKeys = []string{} + } + + // Enrich columns with primary key information + edge case fix regarding auto-increment fields + for i := range columns { + // ClickHouse doesn't have traditional auto-increment columns. ClickHouse 25.01+ + // introduced generateSerialID() which provides auto-increment behavior via DEFAULT + // expressions (e.g., DEFAULT generateSerialID('counter')), but this is not a column + // attribute - it's a default value. GORM may incorrectly detect columns as auto-increment, + // so we explicitly disable it for all columns. + columns[i].IsAutoIncrement = false + + // ClickHouse GORM driver embeds length in type name (e.g., "FixedString(10)") + // but doesn't expose it via Length(). Parse it from the type string. + if columns[i].Length == nil { + typeSpec := common.ParseTypeSpec(columns[i].Type) + if typeSpec.Length > 0 { + columns[i].Length = &typeSpec.Length + } + } + + // Set primary key flag + for _, pk := range primaryKeys { + if columns[i].Name == pk { + columns[i].IsPrimary = true + break + } + } + } + + return columns, nil + }) +} + +// WithTransaction executes the operation directly since ClickHouse doesn't support traditional ACID transactions. +// The ClickHouse GORM driver's Begin() produces a connection where metadata queries like ColumnTypes() return +// empty results, which breaks mock data generation and other operations that need column information. +func (p *ClickHousePlugin) WithTransaction(config *engine.PluginConfig, operation func(tx any) error) error { + return operation(nil) +} + +// ResolveGraphSchema returns the database name for ClickHouse graph queries, +// since ClickHouse uses database name as the schema parameter. +func (p *ClickHousePlugin) ResolveGraphSchema(config *engine.PluginConfig, schema string) string { + return config.Credentials.Database +} + +// ShouldCheckRowsAffected returns false because ClickHouse's GORM driver +// doesn't report affected rows for UPDATE/DELETE mutations. +func (p *ClickHousePlugin) ShouldCheckRowsAffected() bool { + return false +} + +func NewClickHousePlugin() *engine.Plugin { + clickhousePlugin := &ClickHousePlugin{} + clickhousePlugin.Type = engine.DatabaseType_ClickHouse + clickhousePlugin.PluginFunctions = clickhousePlugin + clickhousePlugin.GormPluginFunctions = clickhousePlugin + return &clickhousePlugin.Plugin +} diff --git a/dataflow/core/src/plugins/clickhouse/compound_convert.go b/dataflow/core/src/plugins/clickhouse/compound_convert.go new file mode 100644 index 0000000..6d0b060 --- /dev/null +++ b/dataflow/core/src/plugins/clickhouse/compound_convert.go @@ -0,0 +1,318 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package clickhouse + +import ( + "fmt" + "reflect" + "sort" + "strconv" + "strings" + + "gorm.io/gorm" +) + +// formatLiteral formats a single value in ClickHouse literal syntax. +func formatLiteral(v any) string { + switch v := v.(type) { + case string: + return "'" + v + "'" + default: + return fmt.Sprintf("%v", v) + } +} + +// formatMap formats map[string]any in ClickHouse literal syntax: {'k1': v1, 'k2': v2} +func formatMap(m map[string]any) string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(m)) + for _, k := range keys { + parts = append(parts, "'"+k+"': "+formatLiteral(m[k])) + } + return "{" + strings.Join(parts, ", ") + "}" +} + +// formatTuple formats []any in ClickHouse tuple syntax: ('text', 42, 3.14) +func formatTuple(items []any) string { + parts := make([]string, len(items)) + for i, v := range items { + parts[i] = formatLiteral(v) + } + return "(" + strings.Join(parts, ", ") + ")" +} + +// formatSlice formats []any in ClickHouse array syntax: [1, 2, 3] +func formatSlice(items []any) string { + parts := make([]string, len(items)) + for i, v := range items { + parts[i] = formatLiteral(v) + } + return "[" + strings.Join(parts, ", ") + "]" +} + +// formatReflectMap formats a typed map (e.g., map[string]int32) using reflection. +func formatReflectMap(rv reflect.Value, upperType string) string { + keys := rv.MapKeys() + sort.Slice(keys, func(i, j int) bool { + return fmt.Sprint(keys[i].Interface()) < fmt.Sprint(keys[j].Interface()) + }) + parts := make([]string, 0, rv.Len()) + for _, key := range keys { + k := formatLiteral(key.Interface()) + v := formatLiteral(rv.MapIndex(key).Interface()) + parts = append(parts, k+": "+v) + } + return "{" + strings.Join(parts, ", ") + "}" +} + +// formatReflectTuple formats a typed slice as a ClickHouse tuple using reflection. +func formatReflectTuple(rv reflect.Value) string { + parts := make([]string, rv.Len()) + for i := range rv.Len() { + parts[i] = formatLiteral(rv.Index(i).Interface()) + } + return "(" + strings.Join(parts, ", ") + ")" +} + +// formatReflectSlice formats a typed slice as a ClickHouse array using reflection. +func formatReflectSlice(rv reflect.Value, upperType string) string { + parts := make([]string, rv.Len()) + for i := range rv.Len() { + parts[i] = formatLiteral(rv.Index(i).Interface()) + } + return "[" + strings.Join(parts, ", ") + "]" +} + +// convertArrayLiteral parses [50, 60] into a strongly-typed Go slice (e.g., []int32). +func convertArrayLiteral(value string, columnType string) (any, error) { + elemType := extractInner(columnType) + if elemType == "" { + elemType = "String" + } + goType := chTypeToReflect(elemType) + sliceType := reflect.SliceOf(goType) + result := reflect.MakeSlice(sliceType, 0, 0) + + inner := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(value), "["), "]")) + if inner == "" { + return result.Interface(), nil + } + + for _, elem := range splitTopLevel(inner, ',') { + elem = strings.Trim(strings.TrimSpace(elem), "'") + v, err := parseValue(elem, elemType) + if err != nil { + return nil, fmt.Errorf("array element: %w", err) + } + result = reflect.Append(result, reflect.ValueOf(v)) + } + + return result.Interface(), nil +} + +// arrayToExpr wraps a typed array slice as a gorm.Expr SQL literal for UPDATE statements. +func arrayToExpr(v any) any { + rv := reflect.ValueOf(v) + parts := make([]string, rv.Len()) + for i := range rv.Len() { + parts[i] = formatLiteral(rv.Index(i).Interface()) + } + return gorm.Expr("[" + strings.Join(parts, ", ") + "]") +} + +// convertMapLiteral parses {'key1': 10, 'key2': 20} into a strongly-typed Go map. +func convertMapLiteral(value string, columnType string) (any, error) { + keyType, valType := extractTypeParams2(columnType) + keyGoType := chTypeToReflect(keyType) + valGoType := chTypeToReflect(valType) + + mapType := reflect.MapOf(keyGoType, valGoType) + result := reflect.MakeMap(mapType) + + inner := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(value), "{"), "}")) + if inner == "" { + return result.Interface(), nil + } + + for _, entry := range splitTopLevel(inner, ',') { + parts := splitTopLevel(strings.TrimSpace(entry), ':') + if len(parts) != 2 { + continue + } + + k, err := parseValue(strings.Trim(strings.TrimSpace(parts[0]), "'"), keyType) + if err != nil { + return nil, fmt.Errorf("map key: %w", err) + } + v, err := parseValue(strings.Trim(strings.TrimSpace(parts[1]), "'"), valType) + if err != nil { + return nil, fmt.Errorf("map value: %w", err) + } + result.SetMapIndex(reflect.ValueOf(k), reflect.ValueOf(v)) + } + + return result.Interface(), nil +} + +// convertTupleLiteral parses ('hello', 42, 3.14) into []any. +func convertTupleLiteral(value string, columnType string) (any, error) { + elemTypes := splitTopLevel(extractInner(columnType), ',') + + inner := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(value), "("), ")")) + if inner == "" { + return []any{}, nil + } + + elements := splitTopLevel(inner, ',') + result := make([]any, 0, len(elements)) + for i, elem := range elements { + chType := "String" + if i < len(elemTypes) { + chType = strings.TrimSpace(elemTypes[i]) + } + v, err := parseValue(strings.Trim(strings.TrimSpace(elem), "'"), chType) + if err != nil { + return nil, fmt.Errorf("tuple element %d: %w", i, err) + } + result = append(result, v) + } + return result, nil +} + +// parseValue converts a string to the Go type matching a ClickHouse type name. +func parseValue(s string, chType string) (any, error) { + switch strings.ToUpper(strings.TrimSpace(chType)) { + case "INT8": + v, err := strconv.ParseInt(s, 10, 8) + return int8(v), err + case "INT16": + v, err := strconv.ParseInt(s, 10, 16) + return int16(v), err + case "INT32": + v, err := strconv.ParseInt(s, 10, 32) + return int32(v), err + case "INT64": + v, err := strconv.ParseInt(s, 10, 64) + return v, err + case "UINT8": + v, err := strconv.ParseUint(s, 10, 8) + return uint8(v), err + case "UINT16": + v, err := strconv.ParseUint(s, 10, 16) + return uint16(v), err + case "UINT32": + v, err := strconv.ParseUint(s, 10, 32) + return uint32(v), err + case "UINT64": + v, err := strconv.ParseUint(s, 10, 64) + return v, err + case "FLOAT32": + v, err := strconv.ParseFloat(s, 32) + return float32(v), err + case "FLOAT64": + v, err := strconv.ParseFloat(s, 64) + return v, err + case "BOOL", "BOOLEAN": + v, err := strconv.ParseBool(s) + return v, err + default: + return s, nil + } +} + +// chTypeToReflect maps a ClickHouse type name to its Go reflect.Type. +func chTypeToReflect(chType string) reflect.Type { + switch strings.ToUpper(strings.TrimSpace(chType)) { + case "INT8": + return reflect.TypeOf(int8(0)) + case "INT16": + return reflect.TypeOf(int16(0)) + case "INT32": + return reflect.TypeOf(int32(0)) + case "INT64": + return reflect.TypeOf(int64(0)) + case "UINT8": + return reflect.TypeOf(uint8(0)) + case "UINT16": + return reflect.TypeOf(uint16(0)) + case "UINT32": + return reflect.TypeOf(uint32(0)) + case "UINT64": + return reflect.TypeOf(uint64(0)) + case "FLOAT32": + return reflect.TypeOf(float32(0)) + case "FLOAT64": + return reflect.TypeOf(float64(0)) + case "BOOL", "BOOLEAN": + return reflect.TypeOf(false) + default: + return reflect.TypeOf("") + } +} + +// splitTopLevel splits by separator, respecting nested ()[]{} and quotes. +func splitTopLevel(s string, sep byte) []string { + var result []string + depth := 0 + inQ := false // single-quote state + start := 0 + + for i := 0; i < len(s); i++ { + switch ch := s[i]; { + case ch == '\'': + inQ = !inQ + case !inQ: + switch ch { + case '(', '[', '{': + depth++ + case ')', ']', '}': + depth-- + default: + if ch == sep && depth == 0 { + result = append(result, s[start:i]) + start = i + 1 + } + } + } + } + if start <= len(s) { + result = append(result, s[start:]) + } + return result +} + +func extractInner(s string) string { + i := strings.Index(s, "(") + j := strings.LastIndex(s, ")") + if i == -1 || j <= i { + return "" + } + return s[i+1 : j] +} + +// extractTypeParams2 extracts key and value types from "Map(String, Int32)". +func extractTypeParams2(columnType string) (string, string) { + parts := splitTopLevel(extractInner(columnType), ',') + if len(parts) == 2 { + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + } + return "String", "String" +} diff --git a/dataflow/core/src/plugins/clickhouse/compound_convert_unit_test.go b/dataflow/core/src/plugins/clickhouse/compound_convert_unit_test.go new file mode 100644 index 0000000..b40f26b --- /dev/null +++ b/dataflow/core/src/plugins/clickhouse/compound_convert_unit_test.go @@ -0,0 +1,65 @@ +package clickhouse + +import ( + "reflect" + "testing" +) + +func TestSplitTopLevelRespectsQuotesAndNesting(t *testing.T) { + input := "'a,b', {'k1': 1, 'k2': 2}, (1, 2, 3)" + parts := splitTopLevel(input, ',') + if len(parts) != 3 { + t.Fatalf("expected 3 parts, got %d: %#v", len(parts), parts) + } +} + +func TestConvertArrayLiteralTypedSlice(t *testing.T) { + val, err := convertArrayLiteral("[1, 2, 3]", "Array(Int32)") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + arr, ok := val.([]int32) + if !ok { + t.Fatalf("expected []int32, got %T", val) + } + if !reflect.DeepEqual(arr, []int32{1, 2, 3}) { + t.Fatalf("unexpected array: %#v", arr) + } +} + +func TestConvertMapLiteralTypedMap(t *testing.T) { + val, err := convertMapLiteral("{'key1': 10, 'key2': 20}", "Map(String, Int32)") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + m, ok := val.(map[string]int32) + if !ok { + t.Fatalf("expected map[string]int32, got %T", val) + } + if m["key1"] != 10 || m["key2"] != 20 { + t.Fatalf("unexpected map: %#v", m) + } +} + +func TestConvertTupleLiteralReturnsTypedValues(t *testing.T) { + val, err := convertTupleLiteral("('hello', 42, 3.14)", "Tuple(String, Int32, Float64)") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + items, ok := val.([]any) + if !ok { + t.Fatalf("expected []any, got %T", val) + } + if len(items) != 3 { + t.Fatalf("expected 3 items, got %d", len(items)) + } + if items[0] != "hello" { + t.Fatalf("expected first element to be hello, got %#v", items[0]) + } + if _, ok := items[1].(int32); !ok { + t.Fatalf("expected second element to be int32, got %T", items[1]) + } + if _, ok := items[2].(float64); !ok { + t.Fatalf("expected third element to be float64, got %T", items[2]) + } +} diff --git a/dataflow/core/src/plugins/clickhouse/compound_types_test.go b/dataflow/core/src/plugins/clickhouse/compound_types_test.go new file mode 100644 index 0000000..257b734 --- /dev/null +++ b/dataflow/core/src/plugins/clickhouse/compound_types_test.go @@ -0,0 +1,160 @@ +//go:build integration + +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package clickhouse + +import ( + "context" + "strings" + "testing" + + "github.com/ClickHouse/clickhouse-go/v2" + gorm_clickhouse "gorm.io/driver/clickhouse" + "gorm.io/gorm" +) + +// TestGormExprCompoundTypes verifies that gorm.Expr() works for ClickHouse +// Map, Tuple, and Array literals through GORM's Create() path. +// Run with: go test -tags integration -run TestGormExprCompoundTypes ./src/plugins/clickhouse/ +func TestGormExprCompoundTypes(t *testing.T) { + db := connectTestDB(t) + + // Create test table with compound types + db.Exec("DROP TABLE IF EXISTS test_db.test_compound_types") + err := db.Exec(` + CREATE TABLE test_db.test_compound_types ( + id UInt32, + col_map Map(String, Int32), + col_tuple Tuple(String, Int32, Float64), + col_array Array(Int32), + col_enum Enum8('active' = 1, 'inactive' = 2) + ) ENGINE = MergeTree() ORDER BY id + `).Error + if err != nil { + t.Fatalf("Failed to create test table: %v", err) + } + defer db.Exec("DROP TABLE IF EXISTS test_db.test_compound_types") + + // Use our converters to go from string literals to typed Go values + mapVal, err := convertMapLiteral("{'key1': 10, 'key2': 20}", "Map(String, Int32)") + if err != nil { + t.Fatalf("Failed to convert map: %v", err) + } + tupleVal, err := convertTupleLiteral("('hello', 42, 3.14)", "Tuple(String, Int32, Float64)") + if err != nil { + t.Fatalf("Failed to convert tuple: %v", err) + } + + data := map[string]any{ + "id": uint32(1), + "col_map": mapVal, + "col_tuple": tupleVal, + "col_array": []int32{1, 2, 3}, + "col_enum": "active", + } + + result := db.Table("test_db.test_compound_types").Create(data) + if result.Error != nil { + t.Fatalf("Failed to insert with gorm.Expr: %v", result.Error) + } + + // Verify the data was inserted correctly + var count int64 + db.Raw("SELECT count() FROM test_db.test_compound_types WHERE id = 1").Scan(&count) + if count != 1 { + t.Fatalf("Expected 1 row, got %d", count) + } + + // Read back and verify values + var readMap, readTuple, readArray, readEnum string + db.Raw("SELECT toString(col_map), toString(col_tuple), toString(col_array), col_enum FROM test_db.test_compound_types WHERE id = 1"). + Row().Scan(&readMap, &readTuple, &readArray, &readEnum) + + t.Logf("Map: %s", readMap) + t.Logf("Tuple: %s", readTuple) + t.Logf("Array: %s", readArray) + t.Logf("Enum: %s", readEnum) + + if readEnum != "active" { + t.Errorf("Expected enum 'active', got %q", readEnum) + } + + // Test FormatColumnValue produces ClickHouse literal syntax + plugin := &ClickHousePlugin{} + var scannedMap, scannedTuple, scannedArray any + row := db.Raw("SELECT col_map, col_tuple, col_array FROM test_db.test_compound_types WHERE id = 1").Row() + if err := row.Scan(&scannedMap, &scannedTuple, &scannedArray); err != nil { + t.Fatalf("Failed to scan compound types: %v", err) + } + + fmtMap, _ := plugin.FormatColumnValue("Map(String, Int32)", &scannedMap) + fmtTuple, _ := plugin.FormatColumnValue("Tuple(String, Int32, Float64)", &scannedTuple) + fmtArray, _ := plugin.FormatColumnValue("Array(Int32)", &scannedArray) + + t.Logf("Map display: %s", fmtMap) + t.Logf("Tuple display: %s", fmtTuple) + t.Logf("Array display: %s", fmtArray) + + // Map should use ClickHouse literal syntax with single quotes + if !strings.HasPrefix(fmtMap, "{") || !strings.HasSuffix(fmtMap, "}") { + t.Errorf("Map display should use {}, got %q", fmtMap) + } + if !strings.Contains(fmtMap, "'key1'") { + t.Errorf("Map display should single-quote keys, got %q", fmtMap) + } + + // Tuple should use ClickHouse tuple syntax + if !strings.HasPrefix(fmtTuple, "(") || !strings.HasSuffix(fmtTuple, ")") { + t.Errorf("Tuple display should use (), got %q", fmtTuple) + } + if !strings.Contains(fmtTuple, "'hello'") { + t.Errorf("Tuple display should single-quote strings, got %q", fmtTuple) + } + + // Array should use bracket syntax + if !strings.HasPrefix(fmtArray, "[") || !strings.HasSuffix(fmtArray, "]") { + t.Errorf("Array display should use [], got %q", fmtArray) + } +} + +// connectTestDB connects to the local ClickHouse test instance. +func connectTestDB(t *testing.T) *gorm.DB { + t.Helper() + + conn := clickhouse.OpenDB(&clickhouse.Options{ + Addr: []string{"localhost:9000"}, + Auth: clickhouse.Auth{ + Database: "test_db", + Username: "user", + Password: "password", + }, + }) + + if err := conn.PingContext(context.Background()); err != nil { + t.Skipf("ClickHouse not available: %v", err) + } + + db, err := gorm.Open(gorm_clickhouse.New(gorm_clickhouse.Config{ + Conn: conn, + }), &gorm.Config{}) + if err != nil { + t.Fatalf("Failed to open GORM connection: %v", err) + } + + return db +} diff --git a/dataflow/core/src/plugins/clickhouse/constraints.go b/dataflow/core/src/plugins/clickhouse/constraints.go new file mode 100644 index 0000000..8407125 --- /dev/null +++ b/dataflow/core/src/plugins/clickhouse/constraints.go @@ -0,0 +1,205 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package clickhouse + +import ( + "regexp" + "strconv" + "strings" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/plugins" + gorm_plugin "github.com/clidey/whodb/core/src/plugins/gorm" + "gorm.io/gorm" +) + +// enumValuePattern matches 'value' = N inside ClickHouse Enum definitions. +// Captures the quoted value (group 1). +var enumValuePattern = regexp.MustCompile(`'([^']*)'`) + +// GetColumnConstraints overrides the base implementation to extract ClickHouse-specific +// constraints from system.columns, including Enum values and Decimal scale/precision. +func (p *ClickHousePlugin) GetColumnConstraints(config *engine.PluginConfig, schema string, storageUnit string) (map[string]map[string]any, error) { + // Start with base GORM constraints + constraints, err := p.GormPlugin.GetColumnConstraints(config, schema, storageUnit) + if err != nil { + constraints = make(map[string]map[string]any) + } + + // Query system.columns for original type strings (case-preserved) + _, err = plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + var columns []struct { + Name string `gorm:"column:name"` + Type string `gorm:"column:type"` + } + + if err := db.Table("system.columns"). + Select("name, type"). + Where("database = ? AND table = ?", schema, storageUnit). + Scan(&columns).Error; err != nil { + return false, err + } + + for _, col := range columns { + enrichConstraintsFromType(constraints, col.Name, col.Type) + } + + return true, nil + }) + + if err != nil { + // Return base constraints if system.columns query fails + return constraints, nil + } + + return constraints, nil +} + +// enrichConstraintsFromType parses a ClickHouse type string and injects relevant +// constraints (check_values for enums, scale/precision for decimals). +func enrichConstraintsFromType(constraints map[string]map[string]any, columnName string, fullType string) { + // ClickHouse's system.columns always wraps nullable types in Nullable(...), + // so its absence is authoritative. Without this, complex types (Tuple, Array, Map) + // default to nullable and mock data generation may produce nil for non-nullable columns. + upperFull := strings.ToUpper(fullType) + if !strings.Contains(upperFull, "NULLABLE(") { + colConstraints := gorm_plugin.EnsureConstraintEntry(constraints, columnName) + colConstraints["nullable"] = false + } + + // Unwrap Nullable/LowCardinality to find the inner type + innerType := unwrapClickHouseModifiers(fullType) + upperInner := strings.ToUpper(innerType) + + // Parse Enum8/Enum16 values + if strings.HasPrefix(upperInner, "ENUM8(") || strings.HasPrefix(upperInner, "ENUM16(") { + values := parseClickHouseEnumValues(innerType) + if len(values) > 0 { + colConstraints := gorm_plugin.EnsureConstraintEntry(constraints, columnName) + colConstraints["check_values"] = values + } + return + } + + // Parse Decimal scale/precision + // Decimal32(S), Decimal64(S), Decimal128(S), Decimal256(S) — single param is scale + // Decimal(P, S) — two params + if strings.HasPrefix(upperInner, "DECIMAL") { + scale, precision := parseClickHouseDecimalParams(innerType) + if scale >= 0 || precision > 0 { + colConstraints := gorm_plugin.EnsureConstraintEntry(constraints, columnName) + if scale >= 0 { + colConstraints["scale"] = scale + } + if precision > 0 { + colConstraints["precision"] = precision + } + } + } +} + +// unwrapClickHouseModifiers strips Nullable(...) and LowCardinality(...) wrappers, +// preserving the original case of the inner type. +func unwrapClickHouseModifiers(typeName string) string { + for { + lower := strings.ToLower(strings.TrimSpace(typeName)) + unwrapped := false + for _, prefix := range []string{"nullable(", "lowcardinality("} { + if strings.HasPrefix(lower, prefix) && strings.HasSuffix(lower, ")") { + typeName = strings.TrimSpace(typeName[len(prefix) : len(typeName)-1]) + unwrapped = true + break + } + } + if !unwrapped { + return typeName + } + } +} + +// parseClickHouseEnumValues extracts the string values from ClickHouse Enum definitions. +// Input: "Enum8('active' = 1, 'inactive' = 2)" -> ["active", "inactive"] +func parseClickHouseEnumValues(enumType string) []string { + // Find content between the outer parentheses + start := strings.Index(enumType, "(") + end := strings.LastIndex(enumType, ")") + if start == -1 || end == -1 || end <= start { + return nil + } + + content := enumType[start+1 : end] + matches := enumValuePattern.FindAllStringSubmatch(content, -1) + + var values []string + for _, match := range matches { + if len(match) > 1 { + if sanitized, ok := common.SanitizeConstraintValue(match[1]); ok { + values = append(values, sanitized) + } + } + } + return values +} + +// parseClickHouseDecimalParams extracts scale and precision from ClickHouse Decimal types. +// Decimal32(S) -> scale=S, precision=9 +// Decimal64(S) -> scale=S, precision=18 +// Decimal128(S) -> scale=S, precision=38 +// Decimal256(S) -> scale=S, precision=76 +// Decimal(P, S) -> scale=S, precision=P +func parseClickHouseDecimalParams(decimalType string) (scale int, precision int) { + scale = -1 // -1 means not found + + start := strings.Index(decimalType, "(") + end := strings.LastIndex(decimalType, ")") + if start == -1 || end == -1 || end <= start { + return + } + + baseName := strings.ToUpper(strings.TrimSpace(decimalType[:start])) + content := decimalType[start+1 : end] + parts := strings.Split(content, ",") + + if len(parts) == 1 { + // Single param: Decimal32(S) — the param is scale + if s, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil { + scale = s + } + // Set max precision based on type width + switch baseName { + case "DECIMAL32": + precision = 9 + case "DECIMAL64": + precision = 18 + case "DECIMAL128": + precision = 38 + case "DECIMAL256": + precision = 76 + } + } else if len(parts) == 2 { + // Two params: Decimal(P, S) + if p, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil { + precision = p + } + if s, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil { + scale = s + } + } + + return +} diff --git a/dataflow/core/src/plugins/clickhouse/db.go b/dataflow/core/src/plugins/clickhouse/db.go new file mode 100644 index 0000000..373a632 --- /dev/null +++ b/dataflow/core/src/plugins/clickhouse/db.go @@ -0,0 +1,132 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package clickhouse + +import ( + "context" + "net" + "strconv" + "time" + + "github.com/ClickHouse/clickhouse-go/v2" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + "github.com/clidey/whodb/core/src/plugins/ssl" + gorm_clickhouse "gorm.io/driver/clickhouse" +) + +func (p *ClickHousePlugin) DB(config *engine.PluginConfig) (*gorm.DB, error) { + connectionInput, err := p.ParseConnectionConfig(config) + if err != nil { + return nil, err + } + + auth := clickhouse.Auth{ + Database: connectionInput.Database, + Username: connectionInput.Username, + Password: connectionInput.Password, + } + + address := []string{net.JoinHostPort(connectionInput.Hostname, strconv.Itoa(connectionInput.Port))} + options := &clickhouse.Options{ + Addr: address, + Auth: auth, + DialTimeout: time.Duration(connectionInput.ConnectionTimeout) * time.Second, + ConnOpenStrategy: clickhouse.ConnOpenInOrder, + Compression: &clickhouse.Compression{ + Method: clickhouse.CompressionLZ4, + }, + } + + if connectionInput.HTTPProtocol != "disable" { + options.Protocol = clickhouse.HTTP + options.Compression = &clickhouse.Compression{ + Method: clickhouse.CompressionGZIP, + } + } + + if connectionInput.Debug != "disable" { + options.Debug = true + } + + switch connectionInput.ReadOnly { + case "disable": + options.Settings = clickhouse.Settings{ + "readonly": 0, + } + case "enable": + options.Settings = clickhouse.Settings{ + "readonly": 1, + } + } + + // Configure SSL/TLS + sslMode := "disabled" + if connectionInput.SSLConfig != nil && connectionInput.SSLConfig.IsEnabled() { + sslMode = string(connectionInput.SSLConfig.Mode) + tlsConfig, err := ssl.BuildTLSConfig(connectionInput.SSLConfig, connectionInput.Hostname) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": connectionInput.Hostname, + "sslMode": connectionInput.SSLConfig.Mode, + }).Error("Failed to build TLS configuration for ClickHouse") + return nil, err + } + options.TLS = tlsConfig + } + + conn := clickhouse.OpenDB(options) + + l := log.WithFields(map[string]any{ + "hostname": connectionInput.Hostname, + "port": connectionInput.Port, + "database": connectionInput.Database, + "username": connectionInput.Username, + "sslMode": sslMode, + "protocol": func() string { + if connectionInput.HTTPProtocol != "disable" { + return "HTTP" + } + return "Native" + }(), + }) + + err = conn.PingContext(context.Background()) + if err != nil { + l.WithError(err).Error("Failed to ping ClickHouse server") + return nil, err + } + + db, err := gorm.Open(gorm_clickhouse.New(gorm_clickhouse.Config{ + Conn: conn, + }), &gorm.Config{Logger: logger.Default.LogMode(plugins.GetGormLogConfig())}) + if err != nil { + l.WithError(err).Error("Failed to open ClickHouse GORM connection") + return nil, err + } + + // Configure connection pool for better reconnection behavior + if err := plugins.ConfigureConnectionPool(db); err != nil { + l.WithError(err).Warn("Failed to configure connection pool") + } + + return db, nil +} diff --git a/dataflow/core/src/plugins/clickhouse/export.go b/dataflow/core/src/plugins/clickhouse/export.go new file mode 100644 index 0000000..80d93f6 --- /dev/null +++ b/dataflow/core/src/plugins/clickhouse/export.go @@ -0,0 +1,130 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package clickhouse + +import ( + "fmt" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/plugins/gorm" +) + +// ExportData exports ClickHouse table data to tabular format +func (p *ClickHousePlugin) ExportData(config *engine.PluginConfig, schema string, storageUnit string, writer func([]string) error, selectedRows []map[string]any) error { + // If selected rows are provided, delegate to parent GORM implementation + if len(selectedRows) > 0 { + return p.GormPlugin.ExportData(config, schema, storageUnit, writer, selectedRows) + } + db, err := p.DB(config) + if err != nil { + return err + } + + // Get column information using GORM's query builder + rows, err := db.Table("system.columns"). + Select("name, type"). + Where("database = ? AND table = ?", schema, storageUnit). + Order("position"). + Rows() + if err != nil { + return fmt.Errorf("failed to get columns: %v", err) + } + defer rows.Close() + + var columns []string + var types []string + for rows.Next() { + var col, typ string + if err := rows.Scan(&col, &typ); err != nil { + return err + } + columns = append(columns, col) + types = append(types, typ) + } + + // Write headers + headers := make([]string, len(columns)) + for i, col := range columns { + headers[i] = common.FormatCSVHeader(col, types[i]) + } + if err := writer(headers); err != nil { + return fmt.Errorf("failed to write headers: %v", err) + } + + // Use GORM query builder for export + builder := gorm_plugin.NewSQLBuilder(db, p) + fullTable := builder.BuildFullTableName(schema, storageUnit) + + exportQuery := db.Table(fullTable) + if len(columns) > 0 { + exportQuery = exportQuery.Select(columns) + } + + dataRows, err := exportQuery.Rows() + if err != nil { + return fmt.Errorf("failed to query data: %v", err) + } + defer dataRows.Close() + + rowCount := 0 + values := make([]any, len(columns)) + valuePtrs := make([]any, len(columns)) + for i := range values { + valuePtrs[i] = &values[i] + } + + for dataRows.Next() { + if err := dataRows.Scan(valuePtrs...); err != nil { + return fmt.Errorf("failed to scan row: %v", err) + } + + row := make([]string, len(columns)) + for i, val := range values { + row[i] = p.formatValue(val) + } + + if err := writer(row); err != nil { + return fmt.Errorf("failed to write row: %v", err) + } + + rowCount++ + } + + return dataRows.Err() +} + +// Helper functions + +func (p *ClickHousePlugin) formatValue(val any) string { + if val == nil { + return "" + } + + var strVal string + switch v := val.(type) { + case []byte: + strVal = string(v) + case string: + strVal = v + default: + strVal = fmt.Sprintf("%v", v) + } + + // Apply formula injection protection + return common.EscapeFormula(strVal) +} diff --git a/dataflow/core/src/plugins/clickhouse/export_sql.go b/dataflow/core/src/plugins/clickhouse/export_sql.go new file mode 100644 index 0000000..5a83f5e --- /dev/null +++ b/dataflow/core/src/plugins/clickhouse/export_sql.go @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package clickhouse + +import ( + "fmt" + "io" + + "github.com/clidey/whodb/core/src/engine" +) + +// ExportSQLData streams ClickHouse SQL Data Export statements. +func (p *ClickHousePlugin) ExportSQLData(config *engine.PluginConfig, req *engine.SQLDataExportRequest, writer io.Writer) error { + if req != nil && req.Mode == engine.SQLDataExportModeUpdate { + return fmt.Errorf("SQL Update Export is not supported for ClickHouse") + } + return p.GormPlugin.ExportSQLData(config, req, writer) +} diff --git a/dataflow/core/src/plugins/clickhouse/graph.go b/dataflow/core/src/plugins/clickhouse/graph.go new file mode 100644 index 0000000..ae5d1ab --- /dev/null +++ b/dataflow/core/src/plugins/clickhouse/graph.go @@ -0,0 +1,76 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package clickhouse + +import "gorm.io/gorm" + +// TODO: clickhouse doesn't have relations. need to revisit this query. +// todo: right now it's meant to just recognize based on key names but that's for debug purposes. +const graphQuery = ` +WITH candidate_keys AS ( + SELECT + database, + table, + name AS column_name + FROM system.columns + WHERE database = ? + AND (name LIKE '%_id' OR name = 'id') +), +relations AS ( + -- Many-to-One + One-to-One candidates + SELECT + fk.table AS table1, + pk.table AS table2, + fk.column_name AS fk_column, + pk.column_name AS pk_column, + CASE + WHEN fk.column_name LIKE pk.table || '_id' THEN 'ManyToOne' + ELSE 'PossibleRelation' + END AS relation + FROM candidate_keys fk + JOIN candidate_keys pk + ON fk.column_name LIKE pk.table || '_id' + AND pk.column_name = 'id' + AND fk.table != pk.table +), +join_tables AS ( + -- Many-to-Many candidates (tables with 2+ *_id columns and no "id" column) + SELECT + table AS table1, + arrayJoin(arrayFilter(x -> x != 'id', groupArray(column_name))) AS fk_column, + 'JoinTable' AS relation + FROM candidate_keys + GROUP BY database, table + HAVING countIf(column_name LIKE '%_id') >= 2 + AND countIf(column_name = 'id') = 0 +) +SELECT * +FROM relations +UNION ALL +SELECT + jt.table1, + splitByString('_id', jt.fk_column)[1] AS table2, -- infer target table from column name + jt.fk_column AS fk_column, + 'id' AS pk_column, + 'ManyToMany' AS relation +FROM join_tables jt +ORDER BY relation, table1, table2; +` + +func (p *ClickHousePlugin) GetGraphQueryDB(db *gorm.DB, schema string) *gorm.DB { + return db.Raw(graphQuery, schema) +} diff --git a/dataflow/core/src/plugins/clickhouse/ssl_status.go b/dataflow/core/src/plugins/clickhouse/ssl_status.go new file mode 100644 index 0000000..4d00f31 --- /dev/null +++ b/dataflow/core/src/plugins/clickhouse/ssl_status.go @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package clickhouse + +import ( + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/plugins" + "github.com/clidey/whodb/core/src/plugins/ssl" +) + +// GetSSLStatus returns the SSL status based on the configured SSL mode for ClickHouse. +// ClickHouse native protocol doesn't expose connection-level SSL state via queries, +// so we use config-based detection like Redis and Elasticsearch. +func (p *ClickHousePlugin) GetSSLStatus(config *engine.PluginConfig) (*engine.SSLStatus, error) { + if cached := plugins.GetCachedSSLStatus(config); cached != nil { + return cached, nil + } + + sslConfig := ssl.ParseSSLConfig(engine.DatabaseType_ClickHouse, config.Credentials.Advanced, config.Credentials.Hostname, config.Credentials.IsProfile) + + var status *engine.SSLStatus + if sslConfig == nil || !sslConfig.IsEnabled() { + status = &engine.SSLStatus{ + IsEnabled: false, + Mode: string(ssl.SSLModeDisabled), + } + } else { + status = &engine.SSLStatus{ + IsEnabled: true, + Mode: string(sslConfig.Mode), + } + } + + plugins.SetCachedSSLStatus(config, status) + return status, nil +} diff --git a/dataflow/core/src/plugins/clickhouse/types.go b/dataflow/core/src/plugins/clickhouse/types.go new file mode 100644 index 0000000..7c2c6cc --- /dev/null +++ b/dataflow/core/src/plugins/clickhouse/types.go @@ -0,0 +1,101 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package clickhouse + +import ( + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" +) + +// AliasMap maps ClickHouse type aliases to their canonical names. +// Note: ClickHouse uses mixed-case type names (Int8, String, etc.) +var AliasMap = map[string]string{ + "TINYINT": "Int8", + "SMALLINT": "Int16", + "INT": "Int32", + "INTEGER": "Int32", + "BIGINT": "Int64", + "FLOAT": "Float32", + "DOUBLE": "Float64", + "BOOLEAN": "Bool", + "TEXT": "String", + "VARCHAR": "String", + "CHAR": "FixedString", + "TIMESTAMP": "DateTime", +} + +// TypeDefinitions contains the canonical ClickHouse types with metadata for UI. +var TypeDefinitions = []engine.TypeDefinition{ + {ID: "Int8", Label: "Int8", Category: engine.TypeCategoryNumeric}, + {ID: "Int16", Label: "Int16", Category: engine.TypeCategoryNumeric}, + {ID: "Int32", Label: "Int32", Category: engine.TypeCategoryNumeric}, + {ID: "Int64", Label: "Int64", Category: engine.TypeCategoryNumeric}, + {ID: "Int128", Label: "Int128", Category: engine.TypeCategoryNumeric}, + {ID: "Int256", Label: "Int256", Category: engine.TypeCategoryNumeric}, + {ID: "UInt8", Label: "UInt8", Category: engine.TypeCategoryNumeric}, + {ID: "UInt16", Label: "UInt16", Category: engine.TypeCategoryNumeric}, + {ID: "UInt32", Label: "UInt32", Category: engine.TypeCategoryNumeric}, + {ID: "UInt64", Label: "UInt64", Category: engine.TypeCategoryNumeric}, + {ID: "UInt128", Label: "UInt128", Category: engine.TypeCategoryNumeric}, + {ID: "UInt256", Label: "UInt256", Category: engine.TypeCategoryNumeric}, + {ID: "Float32", Label: "Float32", Category: engine.TypeCategoryNumeric}, + {ID: "Float64", Label: "Float64", Category: engine.TypeCategoryNumeric}, + {ID: "Decimal", Label: "Decimal", HasPrecision: true, DefaultPrecision: engine.IntPtr(10), Category: engine.TypeCategoryNumeric}, + {ID: "Decimal32", Label: "Decimal32", HasPrecision: true, DefaultPrecision: engine.IntPtr(9), Category: engine.TypeCategoryNumeric}, + {ID: "Decimal64", Label: "Decimal64", HasPrecision: true, DefaultPrecision: engine.IntPtr(18), Category: engine.TypeCategoryNumeric}, + {ID: "Decimal128", Label: "Decimal128", HasPrecision: true, DefaultPrecision: engine.IntPtr(38), Category: engine.TypeCategoryNumeric}, + {ID: "String", Label: "String", Category: engine.TypeCategoryText}, + {ID: "FixedString", Label: "FixedString", HasLength: true, DefaultLength: engine.IntPtr(16), Category: engine.TypeCategoryText}, + {ID: "Date", Label: "Date", Category: engine.TypeCategoryDatetime}, + {ID: "Date32", Label: "Date32", Category: engine.TypeCategoryDatetime}, + {ID: "DateTime", Label: "DateTime", Category: engine.TypeCategoryDatetime}, + {ID: "DateTime64", Label: "DateTime64", Category: engine.TypeCategoryDatetime}, + {ID: "Bool", Label: "Bool", Category: engine.TypeCategoryBoolean}, + {ID: "UUID", Label: "UUID", Category: engine.TypeCategoryOther}, + {ID: "JSON", Label: "JSON", Category: engine.TypeCategoryJSON}, + {ID: "IPv4", Label: "IPv4", Category: engine.TypeCategoryOther}, + {ID: "IPv6", Label: "IPv6", Category: engine.TypeCategoryOther}, + {ID: "Enum8", Label: "Enum8", Category: engine.TypeCategoryOther}, + {ID: "Enum16", Label: "Enum16", Category: engine.TypeCategoryOther}, +} + +// NormalizeType converts a ClickHouse type alias to its canonical form. +func NormalizeType(typeName string) string { + return common.NormalizeTypeWithMap(typeName, AliasMap) +} + +// GetDatabaseMetadata returns ClickHouse metadata for frontend configuration. +func (p *ClickHousePlugin) GetDatabaseMetadata() *engine.DatabaseMetadata { + operators := make([]string, 0, len(supportedOperators)) + for op := range supportedOperators { + operators = append(operators, op) + } + return &engine.DatabaseMetadata{ + DatabaseType: engine.DatabaseType_ClickHouse, + TypeDefinitions: TypeDefinitions, + Operators: operators, + AliasMap: AliasMap, + Capabilities: engine.Capabilities{ + SupportsScratchpad: true, + SupportsChat: true, + SupportsGraph: true, + SupportsDatabaseSwitch: true, + SupportsModifiers: true, + }, + SystemSchemas: []string{"system", "INFORMATION_SCHEMA", "information_schema"}, + } +} diff --git a/dataflow/core/src/plugins/clickhouse/utils.go b/dataflow/core/src/plugins/clickhouse/utils.go new file mode 100644 index 0000000..1002500 --- /dev/null +++ b/dataflow/core/src/plugins/clickhouse/utils.go @@ -0,0 +1,65 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package clickhouse + +import "strings" + +// Identifier quoting handled by GORM Dialector + +func (p *ClickHousePlugin) GetPrimaryKeyColQuery() string { + return ` + SELECT name + FROM system.columns + WHERE database = ? AND table = ? AND is_in_primary_key = 1 + ` +} + +// isMultiStatement checks if a SQL string contains multiple statements by +// stripping ClickHouse comment syntax and counting semicolons. +// (https://clickhouse.com/docs/sql-reference/syntax#comments): +func isMultiStatement(query string) bool { + var b strings.Builder + inBlock := false + for i := 0; i < len(query); i++ { + if inBlock { + if i+1 < len(query) && query[i] == '*' && query[i+1] == '/' { + inBlock = false + i++ + } + continue + } + if i+1 < len(query) && query[i] == '/' && query[i+1] == '*' { + inBlock = true + i++ + continue + } + if i+1 < len(query) && query[i] == '-' && query[i+1] == '-' { + for i < len(query) && query[i] != '\n' { + i++ + } + continue + } + if query[i] == '#' { + for i < len(query) && query[i] != '\n' { + i++ + } + continue + } + b.WriteByte(query[i]) + } + return strings.Count(strings.TrimSpace(b.String()), ";") > 1 +} diff --git a/dataflow/core/src/plugins/clickhouse/utils_unit_test.go b/dataflow/core/src/plugins/clickhouse/utils_unit_test.go new file mode 100644 index 0000000..14f7a15 --- /dev/null +++ b/dataflow/core/src/plugins/clickhouse/utils_unit_test.go @@ -0,0 +1,26 @@ +package clickhouse + +import "testing" + +func TestIsMultiStatement(t *testing.T) { + cases := []struct { + name string + query string + want bool + }{ + {name: "no semicolons", query: "SELECT 1", want: false}, + {name: "single statement", query: "SELECT 1;", want: false}, + {name: "two statements", query: "SELECT 1; SELECT 2;", want: true}, + {name: "line comment ignored", query: "SELECT 1; -- comment;\n", want: false}, + {name: "hash comment ignored", query: "SELECT 1; # comment;\n", want: false}, + {name: "block comment ignored", query: "SELECT 1; /* ; */ SELECT 2;", want: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isMultiStatement(tc.query); got != tc.want { + t.Fatalf("isMultiStatement(%q) = %v, want %v", tc.query, got, tc.want) + } + }) + } +} diff --git a/dataflow/core/src/plugins/common_test.go b/dataflow/core/src/plugins/common_test.go new file mode 100644 index 0000000..74dbde3 --- /dev/null +++ b/dataflow/core/src/plugins/common_test.go @@ -0,0 +1,110 @@ +// Copyright 2025 Clidey, Inc. +// +// 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. + +package plugins + +import ( + "fmt" + "testing" + + "github.com/clidey/whodb/core/src/engine" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestWithConnectionCachesConnections(t *testing.T) { + resetConnectionCache(t) + t.Cleanup(func() { + resetConnectionCache(t) + }) + + creations := 0 + cfg := &engine.PluginConfig{ + Credentials: &engine.Credentials{ + Type: "Sqlite3", + Hostname: "localhost", + Username: "user", + Password: "pass", + Database: "file::memory:?cache=shared", + Advanced: []engine.Record{ + {Key: "mode", Value: "memory"}, + }, + }, + } + + createDB := func(*engine.PluginConfig) (*gorm.DB, error) { + creations++ + return gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + } + + var pointers []string + operation := func(db *gorm.DB) (string, error) { + pointers = append(pointers, fmt.Sprintf("%p", db)) + return "ok", nil + } + + for i := 0; i < 2; i++ { + if _, err := WithConnection(cfg, createDB, operation); err != nil { + t.Fatalf("WithConnection returned error: %v", err) + } + } + + if creations != 1 { + t.Fatalf("expected connection to be created once, got %d", creations) + } + + if len(pointers) != 2 || pointers[0] != pointers[1] { + t.Fatalf("expected cached connection to be reused between calls") + } +} + +func TestGetConnectionCacheKeyChangesOnSecrets(t *testing.T) { + cfg := &engine.PluginConfig{ + Credentials: &engine.Credentials{ + Type: "Postgres", + Hostname: "localhost", + Username: "alice", + Password: "secret1", + Database: "db1", + }, + } + + key1 := getConnectionCacheKey(cfg) + + cfg.Credentials.Password = "secret2" + key2 := getConnectionCacheKey(cfg) + if key1 == key2 { + t.Fatalf("changing password should alter cache key") + } + + cfg.Credentials.Advanced = []engine.Record{{Key: "sslmode", Value: "require"}} + key3 := getConnectionCacheKey(cfg) + if key2 == key3 { + t.Fatalf("changing advanced config should alter cache key") + } +} + +func resetConnectionCache(t *testing.T) { + t.Helper() + connectionCacheMu.Lock() + for key, cached := range connectionCache { + if cached != nil && cached.db != nil { + if sqlDB, err := cached.db.DB(); err == nil { + sqlDB.Close() + } + } + delete(connectionCache, key) + } + connectionCacheMu.Unlock() +} diff --git a/dataflow/core/src/plugins/connection_cache.go b/dataflow/core/src/plugins/connection_cache.go new file mode 100644 index 0000000..cd2e081 --- /dev/null +++ b/dataflow/core/src/plugins/connection_cache.go @@ -0,0 +1,280 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package plugins + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "gorm.io/gorm" +) + +// cachedConnection holds a cached GORM database instance. +type cachedConnection struct { + db *gorm.DB + lastUsed time.Time + sslStatus *engine.SSLStatus +} + +// connectionCacheTTL is how long unused connections stay in cache before cleanup. +const connectionCacheTTL = 5 * time.Minute + +// maxCachedConnections limits cache size to prevent memory exhaustion. +const maxCachedConnections = 50 + +var ( + // connectionCache stores cached GORM instances keyed by config hash. + connectionCache = make(map[string]*cachedConnection) + connectionCacheMu sync.Mutex + stopCleanup = make(chan struct{}) +) + +func init() { + startConnectionCleanup() +} + +// startConnectionCleanup starts a background goroutine that periodically removes stale connections. +func startConnectionCleanup() { + go func() { + ticker := time.NewTicker(connectionCacheTTL / 5) + defer ticker.Stop() + for { + select { + case <-ticker.C: + cleanupStaleConnections() + case <-stopCleanup: + return + } + } + }() +} + +// cleanupStaleConnections removes connections that haven't been used within the TTL. +func cleanupStaleConnections() { + log.Debug("cleaning up stale connections") + staleThreshold := time.Now().Add(-connectionCacheTTL) + + connectionCacheMu.Lock() + defer connectionCacheMu.Unlock() + + for key, cached := range connectionCache { + if cached.lastUsed.Before(staleThreshold) { + delete(connectionCache, key) + closeGormDB(cached.db) + log.Debug("Closed stale database connection") + } + } +} + +// closeGormDB closes the underlying database connection of a GORM instance. +func closeGormDB(db *gorm.DB) { + if db == nil { + return + } + if sqlDB, err := db.DB(); err == nil { + if err := sqlDB.Close(); err != nil { + log.WithError(err).Error("failed to close db connection") + } + } +} + +// connIdentifier returns a short identifier for logging (type:host:db) +func connIdentifier(config *engine.PluginConfig) string { + return fmt.Sprintf("%s:%s:%s", config.Credentials.Type, config.Credentials.Hostname, config.Credentials.Database) +} + +// shortKey returns first 8 chars of cache key for logging +func shortKey(key string) string { + if len(key) > 8 { + return key[:8] + } + return key +} + +// getConnectionCacheKey generates a unique hash key for a connection config. +// Uses SHA256 to avoid exposing raw credentials in memory. +// codeql[go/weak-crypto-algorithm]: SHA256 is intentional for cache key generation, not used for password storage +func getConnectionCacheKey(config *engine.PluginConfig) string { + parts := []string{ + config.Credentials.Type, + config.Credentials.Hostname, + config.Credentials.Username, + config.Credentials.Password, + config.Credentials.Database, + strconv.FormatBool(config.Credentials.IsProfile), + } + if config.Credentials.Id != nil { + parts = append(parts, *config.Credentials.Id) + } + for _, adv := range config.Credentials.Advanced { + parts = append(parts, adv.Key, adv.Value) + } + data := strings.Join(parts, "\x00") + hash := sha256.Sum256([]byte(data)) + return hex.EncodeToString(hash[:]) +} + +// RemoveConnection removes a specific connection from cache and closes it (call on logout). +func RemoveConnection(config *engine.PluginConfig) { + connID := connIdentifier(config) + key := getConnectionCacheKey(config) + l := log.WithFields(map[string]any{"conn_id": connID, "cache_key": shortKey(key)}) + l.Debug("RemoveConnection called") + + connectionCacheMu.Lock() + cached, found := connectionCache[key] + if found { + delete(connectionCache, key) + connectionCacheMu.Unlock() + closeGormDB(cached.db) + l.Debug("Connection removed and closed") + return + } + connectionCacheMu.Unlock() + l.Debug("Connection not found in cache") +} + +// CloseAllConnections closes all cached connections (call on shutdown). +func CloseAllConnections(_ context.Context) { + l := log.WithField("phase", "shutdown") + l.Info("CloseAllConnections called, stopping cleanup goroutine") + + // Stop the background cleanup goroutine + close(stopCleanup) + + // Close all cached connections + connectionCacheMu.Lock() + connCount := len(connectionCache) + l.WithField("conn_count", connCount).Info("Closing cached connections") + for key, cached := range connectionCache { + l.WithField("cache_key", shortKey(key)).Debug("Closing connection") + closeGormDB(cached.db) + delete(connectionCache, key) + } + connectionCacheMu.Unlock() + l.Info("All connections closed") +} + +// getOrCreateConnection retrieves a cached connection or creates a new one. +// Validates cached connections with a ping before returning. +// Connection creation happens WITHOUT holding the lock to avoid blocking other operations. +func getOrCreateConnection(config *engine.PluginConfig, createDB DBCreationFunc) (*gorm.DB, error) { + connID := connIdentifier(config) + key := getConnectionCacheKey(config) + l := log.WithFields(map[string]any{"conn_id": connID, "cache_key": shortKey(key)}) + + // First, check cache (with lock) + connectionCacheMu.Lock() + if cached, found := connectionCache[key]; found && cached != nil && cached.db != nil { + cached.lastUsed = time.Now() + db := cached.db + connectionCacheMu.Unlock() + + // Validate connection with ping (outside lock to avoid blocking) + if sqlDB, err := db.DB(); err == nil && sqlDB != nil { + if err := sqlDB.Ping(); err == nil { + l.Debug("Cache HIT - connection alive") + return db, nil + } + l.WithError(err).Debug("Ping failed, will create new connection") + } + + // Connection is stale - remove from cache + connectionCacheMu.Lock() + if existingCached, stillExists := connectionCache[key]; stillExists && existingCached.db == db { + delete(connectionCache, key) + } + connectionCacheMu.Unlock() + } else { + connectionCacheMu.Unlock() + } + + // Create new connection WITHOUT holding the lock + // This is critical - connection creation can take 30+ seconds for slow/failing DBs + l.Debug("Creating NEW database connection") + createStart := time.Now() + db, err := createDB(config) + if err != nil { + l.WithFields(map[string]any{"duration_ms": time.Since(createStart).Milliseconds(), "error": err.Error()}).Error("Failed to create connection") + return nil, err + } + l.WithField("duration_ms", time.Since(createStart).Milliseconds()).Info("Connection created successfully") + + // Store in cache (with lock) + connectionCacheMu.Lock() + defer connectionCacheMu.Unlock() + + // Check if another goroutine created a connection while we were creating ours + if cached, found := connectionCache[key]; found && cached != nil && cached.db != nil { + // Another goroutine won the race - use their connection, close ours + if sqlDB, err := cached.db.DB(); err == nil && sqlDB != nil { + if err := sqlDB.Ping(); err == nil { + cached.lastUsed = time.Now() + l.Debug("using connection created by another goroutine") + // Close the connection we just created since we won't use it + closeGormDB(db) + return cached.db, nil + } + } + // Their connection is stale - remove it, use ours + delete(connectionCache, key) + } + + connectionCache[key] = &cachedConnection{ + db: db, + lastUsed: time.Now(), + } + + // Evict oldest connection if we exceed the limit + if len(connectionCache) > maxCachedConnections { + evictOldestConnection(key) + } + + return db, nil +} + +// evictOldestConnection removes the oldest connection to stay under maxCachedConnections. +// Must be called while holding connectionCacheMu. +func evictOldestConnection(excludeKey string) { + var oldestKey string + var oldestTime = time.Now().Add(time.Hour) // future time as initial value + + for key, cached := range connectionCache { + if key == excludeKey { + continue + } + if cached.lastUsed.Before(oldestTime) { + oldestTime = cached.lastUsed + oldestKey = key + } + } + + if oldestKey != "" { + cached := connectionCache[oldestKey] + delete(connectionCache, oldestKey) + closeGormDB(cached.db) + log.WithField("cache_key", shortKey(oldestKey)).Debug("Evicted oldest connection to stay under limit") + } +} diff --git a/dataflow/core/src/plugins/connection_cache_test.go b/dataflow/core/src/plugins/connection_cache_test.go new file mode 100644 index 0000000..0e61522 --- /dev/null +++ b/dataflow/core/src/plugins/connection_cache_test.go @@ -0,0 +1,179 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package plugins + +import ( + "testing" + "time" + + "github.com/clidey/whodb/core/src/engine" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func resetCacheState(t *testing.T) { + t.Helper() + connectionCacheMu.Lock() + for key, cached := range connectionCache { + if cached != nil && cached.db != nil { + if sqlDB, err := cached.db.DB(); err == nil { + sqlDB.Close() + } + } + delete(connectionCache, key) + } + connectionCacheMu.Unlock() +} + +func newTestConfig() *engine.PluginConfig { + return &engine.PluginConfig{ + Credentials: &engine.Credentials{ + Type: "Sqlite3", + Hostname: "localhost", + Username: "user", + Password: "pw", + Database: "file::memory:?cache=shared", + }, + } +} + +func TestRemoveConnectionRemovesFromCache(t *testing.T) { + resetCacheState(t) + t.Cleanup(func() { resetCacheState(t) }) + + cfg := newTestConfig() + createDB := func(*engine.PluginConfig) (*gorm.DB, error) { + return gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + } + + _, err := getOrCreateConnection(cfg, createDB) + if err != nil { + t.Fatalf("failed to create connection: %v", err) + } + + // Verify it's in the cache + key := getConnectionCacheKey(cfg) + connectionCacheMu.Lock() + _, exists := connectionCache[key] + connectionCacheMu.Unlock() + if !exists { + t.Fatalf("expected connection to be in cache") + } + + // Remove it + RemoveConnection(cfg) + + connectionCacheMu.Lock() + _, exists = connectionCache[key] + connectionCacheMu.Unlock() + if exists { + t.Fatalf("expected connection to be removed from cache") + } +} + +func TestCleanupStaleConnectionsRemovesOldEntries(t *testing.T) { + resetCacheState(t) + t.Cleanup(func() { resetCacheState(t) }) + + cfg := newTestConfig() + createDB := func(*engine.PluginConfig) (*gorm.DB, error) { + return gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + } + + _, err := getOrCreateConnection(cfg, createDB) + if err != nil { + t.Fatalf("failed to create connection: %v", err) + } + + // Mark as stale by setting lastUsed in the past + key := getConnectionCacheKey(cfg) + connectionCacheMu.Lock() + if cached, ok := connectionCache[key]; ok { + cached.lastUsed = time.Now().Add(-connectionCacheTTL * 2) + } + connectionCacheMu.Unlock() + + cleanupStaleConnections() + + connectionCacheMu.Lock() + _, exists := connectionCache[key] + connectionCacheMu.Unlock() + if exists { + t.Fatalf("expected stale connection to be cleaned up") + } +} + +func TestEvictOldestConnectionPrefersOldest(t *testing.T) { + resetCacheState(t) + t.Cleanup(func() { resetCacheState(t) }) + + // Inject two connections with different lastUsed timestamps + connectionCacheMu.Lock() + connectionCache["new"] = &cachedConnection{lastUsed: time.Now()} + connectionCache["old"] = &cachedConnection{lastUsed: time.Now().Add(-10 * time.Minute)} + connectionCacheMu.Unlock() + + connectionCacheMu.Lock() + evictOldestConnection("") + connectionCacheMu.Unlock() + + connectionCacheMu.Lock() + _, oldExists := connectionCache["old"] + _, newExists := connectionCache["new"] + connectionCacheMu.Unlock() + + if oldExists { + t.Fatalf("expected oldest connection to be evicted") + } + if !newExists { + t.Fatalf("expected newer connection to remain") + } +} + +func TestGetOrCreateConnectionReusesCache(t *testing.T) { + resetCacheState(t) + t.Cleanup(func() { resetCacheState(t) }) + + cfg := newTestConfig() + callCount := 0 + createDB := func(*engine.PluginConfig) (*gorm.DB, error) { + callCount++ + return gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + } + + // First call - should create + db1, err := getOrCreateConnection(cfg, createDB) + if err != nil { + t.Fatalf("first call failed: %v", err) + } + if callCount != 1 { + t.Fatalf("expected createDB to be called once, got %d", callCount) + } + + // Second call - should reuse cache + db2, err := getOrCreateConnection(cfg, createDB) + if err != nil { + t.Fatalf("second call failed: %v", err) + } + if callCount != 1 { + t.Fatalf("expected createDB to still be 1 (cache hit), got %d", callCount) + } + + if db1 != db2 { + t.Fatalf("expected same db instance from cache") + } +} diff --git a/dataflow/core/src/plugins/connection_pool.go b/dataflow/core/src/plugins/connection_pool.go new file mode 100644 index 0000000..8689486 --- /dev/null +++ b/dataflow/core/src/plugins/connection_pool.go @@ -0,0 +1,166 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package plugins + +import ( + "fmt" + "time" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// SortDirection indicates ascending or descending sort order. +type SortDirection int + +const ( + Up SortDirection = iota // ASC + Down // DESC +) + +// Sort represents a column sort condition with direction. +type Sort struct { + Column string + Direction SortDirection +} + +// AtomicCondition represents a single comparison condition (e.g., column = value). +type AtomicCondition struct { + Key string + Operator string + Value any + ColumnType string +} + +// DBOperation is a function that performs database operations with a GORM connection. +type DBOperation[T any] func(*gorm.DB) (T, error) + +// DBCreationFunc is a function that creates a new GORM database connection. +type DBCreationFunc func(pluginConfig *engine.PluginConfig) (*gorm.DB, error) + +// ConfigureConnectionPool sets recommended connection pool settings for database connections. +// This should be called after opening a GORM connection to ensure proper pool management. +// Settings are tuned for long-running server applications with connection caching. +func ConfigureConnectionPool(db *gorm.DB) error { + sqlDB, err := db.DB() + if err != nil { + return err + } + + // MaxOpenConns: Limit concurrent connections to prevent overwhelming the DB server. + // 10 is reasonable for most use cases - adjust based on DB server capacity. + sqlDB.SetMaxOpenConns(10) + + // MaxIdleConns: Keep idle connections ready for reuse. + // Should be <= MaxOpenConns. Higher values reduce connection creation overhead. + sqlDB.SetMaxIdleConns(5) + + // ConnMaxLifetime: Force connection refresh to handle server-side timeouts. + // Most DBs have idle timeouts (MySQL: wait_timeout=8h, PostgreSQL: idle_session_timeout). + // 30 minutes is conservative and works for most databases. + sqlDB.SetConnMaxLifetime(30 * time.Minute) + + // ConnMaxIdleTime: Close idle connections faster than lifetime. + // Helps detect and remove half-open connections that the server closed. + // 5 minutes matches our cache cleanup TTL. + sqlDB.SetConnMaxIdleTime(5 * time.Minute) + + return nil +} + +// GetGormLogConfig returns the GORM logger level based on the environment log level setting. +func GetGormLogConfig() logger.LogLevel { + switch log.GetLevel() { + case "warning": + return logger.Warn + case "error": + return logger.Error + default: + return logger.Silent + } +} + +// WithConnection manages the database connection lifecycle for an operation. +// Connections are cached and reused across operations to prevent connection exhaustion. +// The underlying sql.DB handles connection pooling internally. +// If config.Transaction is set (as a *gorm.DB), it will be used instead of creating a new connection. +// Multi-statement connections bypass the cache and are closed immediately after use. +func WithConnection[T any](config *engine.PluginConfig, DB DBCreationFunc, operation DBOperation[T]) (T, error) { + // Check if we're operating within a transaction + if config != nil && config.Transaction != nil { + if tx, ok := config.Transaction.(*gorm.DB); ok { + return operation(tx) + } + } + + // Multi-statement connections are one-off (e.g., SQL imports). Create a fresh + // connection, run the operation, and close it — no caching. + if config != nil && config.MultiStatement { + db, err := DB(config) + if err != nil { + var zero T + return zero, err + } + defer closeGormDB(db) + return operation(db) + } + + db, err := getOrCreateConnection(config, DB) + if err != nil { + log.WithFields(map[string]any{ + "conn_id": connIdentifier(config), + "error": err.Error(), + }).Error("WithConnection FAILED to get connection") + var zero T + return zero, err + } + + if db == nil { + var zero T + return zero, fmt.Errorf("internal error: nil database connection") + } + + return operation(db) +} + +// GetCachedSSLStatus retrieves the SSL status from the connection cache. +// Returns nil if not cached or connection doesn't exist. +func GetCachedSSLStatus(config *engine.PluginConfig) *engine.SSLStatus { + key := getConnectionCacheKey(config) + + connectionCacheMu.Lock() + defer connectionCacheMu.Unlock() + + if cached, found := connectionCache[key]; found { + return cached.sslStatus + } + return nil +} + +// SetCachedSSLStatus stores SSL status in the connection cache. +func SetCachedSSLStatus(config *engine.PluginConfig, status *engine.SSLStatus) { + key := getConnectionCacheKey(config) + + connectionCacheMu.Lock() + defer connectionCacheMu.Unlock() + + if cached, found := connectionCache[key]; found { + cached.sslStatus = status + } +} diff --git a/dataflow/core/src/plugins/elasticsearch/add.go b/dataflow/core/src/plugins/elasticsearch/add.go new file mode 100644 index 0000000..9c1d007 --- /dev/null +++ b/dataflow/core/src/plugins/elasticsearch/add.go @@ -0,0 +1,182 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package elasticsearch + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/elastic/go-elasticsearch/v8/esapi" +) + +func (p *ElasticSearchPlugin) AddStorageUnit(config *engine.PluginConfig, schema string, storageUnit string, fields []engine.Record) (bool, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to connect to ElasticSearch while adding storage unit") + return false, err + } + + // Build mapping from provided fields, best-effort. If none provided, keep default. + var body *bytes.Buffer + if len(fields) > 0 { + props := buildElasticMappings(fields) + // Only include mapping if there are valid (non-empty) fields + if len(props) > 0 { + mapping := map[string]any{ + "mappings": map[string]any{ + "properties": props, + }, + } + body = new(bytes.Buffer) + if err := json.NewEncoder(body).Encode(mapping); err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to encode ElasticSearch mapping for index creation") + return false, err + } + } + } + + req := client.Indices.Create + var res *esapi.Response + if body != nil { + res, err = req(storageUnit, client.Indices.Create.WithBody(body)) + } else { + res, err = req(storageUnit) + } + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to create ElasticSearch index") + return false, err + } + + defer res.Body.Close() + + if res.IsError() { + err := fmt.Errorf("failed to create index: %s", formatElasticError(res)) + log.WithError(err).WithField("storageUnit", storageUnit).Error("ElasticSearch index creation API returned error") + return false, err + } + + return true, nil +} + +func (p *ElasticSearchPlugin) AddRow(config *engine.PluginConfig, schema string, storageUnit string, values []engine.Record) (bool, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to connect to ElasticSearch while adding row") + return false, err + } + + jsonValue := map[string]string{} + for _, value := range values { + jsonValue[value.Key] = value.Value + } + + docID := "" + if id, ok := jsonValue["_id"]; ok { + docID = id + delete(jsonValue, "_id") + } + + documentBytes, err := json.Marshal(jsonValue) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to marshal ElasticSearch document to JSON") + return false, fmt.Errorf("error marshaling document to JSON: %v", err) + } + + documentReader := bytes.NewReader(documentBytes) + + indexOpts := []func(*esapi.IndexRequest){ + client.Index.WithRefresh("true"), + } + if strings.TrimSpace(docID) != "" { + indexOpts = append(indexOpts, client.Index.WithDocumentID(docID)) + } + + res, err := client.Index( + storageUnit, + documentReader, + indexOpts..., + ) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to index document in ElasticSearch") + return false, fmt.Errorf("error indexing document: %v", err) + } + defer res.Body.Close() + + if res.IsError() { + err := fmt.Errorf("failed to index document: %s", formatElasticError(res)) + log.WithError(err).WithField("storageUnit", storageUnit).Error("ElasticSearch document indexing API returned error") + return false, err + } + + return true, nil +} + +// AddRowReturningID is not supported for Elasticsearch (document IDs are strings). +// Returns 0 as Elasticsearch doesn't use auto-increment integer IDs. +func (p *ElasticSearchPlugin) AddRowReturningID(config *engine.PluginConfig, schema string, storageUnit string, values []engine.Record) (int64, error) { + return 0, errors.ErrUnsupported +} + +func (p *ElasticSearchPlugin) BulkAddRows(config *engine.PluginConfig, schema string, storageUnit string, rows [][]engine.Record) (bool, error) { + return false, errors.ErrUnsupported +} + +// buildElasticMappings converts field definitions into an Elasticsearch properties map. +// Empty field names are filtered out since Elasticsearch rejects them. +func buildElasticMappings(fields []engine.Record) map[string]any { + props := make(map[string]any, len(fields)) + for _, f := range fields { + // Skip empty field names - Elasticsearch rejects them + if strings.TrimSpace(f.Key) == "" { + continue + } + props[f.Key] = map[string]any{ + "type": mapElasticFieldType(f.Value), + } + } + return props +} + +// mapElasticFieldType maps an arbitrary type string into a reasonable ES field type. +func mapElasticFieldType(typeStr string) string { + lower := strings.ToLower(typeStr) + switch { + case strings.Contains(lower, "keyword"): + return "keyword" + case strings.Contains(lower, "text"): + return "text" + case strings.Contains(lower, "bool"): + return "boolean" + case strings.Contains(lower, "date"), strings.Contains(lower, "time"): + return "date" + case strings.Contains(lower, "int"), strings.Contains(lower, "long"): + return "long" + case strings.Contains(lower, "float"), strings.Contains(lower, "double"), strings.Contains(lower, "decimal"): + return "double" + case strings.Contains(lower, "geo"): + return "geo_point" + case strings.Contains(lower, "object"), strings.Contains(lower, "json"): + return "object" + default: + return "text" + } +} diff --git a/dataflow/core/src/plugins/elasticsearch/db.go b/dataflow/core/src/plugins/elasticsearch/db.go new file mode 100644 index 0000000..38ca057 --- /dev/null +++ b/dataflow/core/src/plugins/elasticsearch/db.go @@ -0,0 +1,160 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package elasticsearch + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins/ssl" + "github.com/elastic/go-elasticsearch/v8" +) + +func DB(config *engine.PluginConfig) (*elasticsearch.Client, error) { + log.Debug("[ES DB] Creating Elasticsearch client") + var addresses []string + port, err := strconv.Atoi(common.GetRecordValueOrDefault(config.Credentials.Advanced, "Port", "9200")) + if err != nil { + log.WithError(err).WithField("hostname", config.Credentials.Hostname).Error("Invalid port number for ElasticSearch connection") + return nil, err + } + log.Debugf("[ES DB] Port: %d", port) + + hostName := url.QueryEscape(config.Credentials.Hostname) + + // Configure SSL/TLS + sslMode := "disabled" + log.Debugf("[ES DB] Parsing SSL config for %s", hostName) + sslConfig := ssl.ParseSSLConfig(engine.DatabaseType_ElasticSearch, config.Credentials.Advanced, config.Credentials.Hostname, config.Credentials.IsProfile) + + // Determine scheme based on SSL mode + scheme := "http" + if sslConfig != nil && sslConfig.IsEnabled() { + scheme = "https" + sslMode = string(sslConfig.Mode) + log.Debugf("[ES DB] SSL enabled, mode=%s, scheme=%s", sslMode, scheme) + } else { + log.Debug("[ES DB] SSL disabled or not configured") + } + + addressUrl := url.URL{ + Scheme: scheme, + Host: net.JoinHostPort(hostName, strconv.Itoa(port)), + } + + addresses = []string{ + addressUrl.String(), + } + log.Debugf("[ES DB] Connecting to: %s", addresses[0]) + + cfg := elasticsearch.Config{ + Addresses: addresses, + Username: config.Credentials.Username, + Password: config.Credentials.Password, + } + + // Configure TLS if enabled + if sslConfig != nil && sslConfig.IsEnabled() { + // For insecure mode, skip certificate verification + if sslConfig.Mode == ssl.SSLModeInsecure { + log.Debug("[ES DB] Insecure mode: skipping certificate verification") + cfg.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + } else { + // For enabled mode, use the native CACert option if CA is provided + caCertPEM, err := sslConfig.CACert.Load() + if err != nil { + log.WithError(err).Error("[ES DB] Failed to load CA certificate") + return nil, err + } + + if caCertPEM != nil { + log.Debugf("[ES DB] Using CA certificate (%d bytes)", len(caCertPEM)) + cfg.CACert = caCertPEM + } + + // Handle client certificates for mutual TLS + if !sslConfig.ClientCert.IsEmpty() && !sslConfig.ClientKey.IsEmpty() { + log.Debug("[ES DB] Loading client certificate for mutual TLS") + tlsConfig, err := ssl.BuildTLSConfig(sslConfig, config.Credentials.Hostname) + if err != nil { + log.WithError(err).Error("[ES DB] Failed to build TLS config for client certs") + return nil, err + } + if tlsConfig != nil && len(tlsConfig.Certificates) > 0 { + // If we have client certs, we need custom transport + // But also include CA verification + if caCertPEM != nil { + rootCAs := x509.NewCertPool() + rootCAs.AppendCertsFromPEM(caCertPEM) + tlsConfig.RootCAs = rootCAs + } + cfg.Transport = &http.Transport{ + TLSClientConfig: tlsConfig, + } + cfg.CACert = nil // Clear this since we're using transport + } + } + } + } + + log.Debug("[ES DB] Creating Elasticsearch client instance") + client, err := elasticsearch.NewClient(cfg) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "port": port, + "sslMode": sslMode, + }).Error("Failed to create ElasticSearch client") + return nil, err + } + + log.Debug("[ES DB] Pinging Elasticsearch server") + res, err := client.Info() + if err != nil || res.IsError() { + errMsg := "no error" + if err != nil { + errMsg = err.Error() + } + resStatus := "N/A" + if res != nil { + resStatus = res.Status() + } + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "port": port, + "sslMode": sslMode, + "error": errMsg, + "resStatus": resStatus, + }).Error("Failed to ping ElasticSearch server") + return nil, fmt.Errorf("error pinging Elasticsearch: %v", err) + } + + log.Debugf("[ES DB] Successfully connected to Elasticsearch at %s", addresses[0]) + return client, nil +} diff --git a/dataflow/core/src/plugins/elasticsearch/delete.go b/dataflow/core/src/plugins/elasticsearch/delete.go new file mode 100644 index 0000000..791a1a3 --- /dev/null +++ b/dataflow/core/src/plugins/elasticsearch/delete.go @@ -0,0 +1,99 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package elasticsearch + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" +) + +func (p *ElasticSearchPlugin) DeleteRow(config *engine.PluginConfig, database string, storageUnit string, values map[string]string) (bool, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to connect to ElasticSearch while deleting row") + return false, err + } + + // Extract the document JSON + documentJSON, ok := values["document"] + if !ok { + err := errors.New("missing 'document' key in values map") + log.WithError(err).WithField("storageUnit", storageUnit).Error("Missing document key in delete request values") + return false, err + } + + // Unmarshal the JSON to extract the _id field + var jsonValues map[string]any + if err := json.Unmarshal([]byte(documentJSON), &jsonValues); err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to unmarshal document JSON for deletion") + return false, err + } + + id, ok := jsonValues["_id"] + if !ok { + err := errors.New("missing '_id' field in the document") + log.WithError(err).WithField("storageUnit", storageUnit).Error("Missing _id field in document for deletion") + return false, err + } + + idStr, ok := id.(string) + if !ok || strings.TrimSpace(idStr) == "" { + return false, fmt.Errorf("invalid '_id' field; expected non-empty string") + } + + // Delete the document by ID + res, err := client.Delete( + storageUnit, + idStr, + client.Delete.WithContext(context.Background()), + client.Delete.WithRefresh("true"), // Ensure the deletion is immediately visible + ) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).WithField("documentId", idStr).Error("Failed to execute ElasticSearch delete operation") + return false, fmt.Errorf("failed to execute delete: %w", err) + } + defer res.Body.Close() + + // Check if the response indicates an error + if res.IsError() { + err := fmt.Errorf("error deleting document: %s", formatElasticError(res)) + log.WithError(err).WithField("storageUnit", storageUnit).WithField("documentId", idStr).Error("ElasticSearch delete API returned error") + return false, err + } + + // Decode the response to check the result + var deleteResponse map[string]any + if err := json.NewDecoder(res.Body).Decode(&deleteResponse); err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).WithField("documentId", id).Error("Failed to decode ElasticSearch delete response") + return false, err + } + + // Check if the deletion was successful + if result, ok := deleteResponse["result"].(string); ok && result != "deleted" { + err := errors.New("no documents were deleted") + log.WithError(err).WithField("storageUnit", storageUnit).WithField("documentId", id).WithField("result", result).Error("ElasticSearch delete operation did not delete any documents") + return false, err + } + + return true, nil +} diff --git a/dataflow/core/src/plugins/elasticsearch/elasticsearch.go b/dataflow/core/src/plugins/elasticsearch/elasticsearch.go new file mode 100644 index 0000000..a9ae6dc --- /dev/null +++ b/dataflow/core/src/plugins/elasticsearch/elasticsearch.go @@ -0,0 +1,235 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package elasticsearch + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" +) + +var ( + supportedOperators = map[string]string{ + "match": "match", "match_phrase": "match_phrase", "match_phrase_prefix": "match_phrase_prefix", "multi_match": "multi_match", + "bool": "bool", "term": "term", "terms": "terms", "range": "range", "exists": "exists", "prefix": "prefix", "wildcard": "wildcard", + "regexp": "regexp", "fuzzy": "fuzzy", "ids": "ids", "constant_score": "constant_score", "function_score": "function_score", + "dis_max": "dis_max", "nested": "nested", "has_child": "has_child", "has_parent": "has_parent", + } +) + +type ElasticSearchPlugin struct { + engine.BasePlugin +} + +func (p *ElasticSearchPlugin) IsAvailable(ctx context.Context, config *engine.PluginConfig) bool { + client, err := DB(config) + if err != nil { + return false + } + res, err := client.Info() + if err != nil || res.IsError() { + return false + } + return true +} + +// GetDatabases lists all Elasticsearch indices (equivalent to databases). +// System indices (those starting with .) are filtered out. +func (p *ElasticSearchPlugin) GetDatabases(config *engine.PluginConfig) ([]string, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).Error("Failed to connect to ElasticSearch while listing indices") + return nil, err + } + + // Use Cat Indices API for lightweight index listing + res, err := client.Cat.Indices( + client.Cat.Indices.WithFormat("json"), + ) + if err != nil { + log.WithError(err).Error("Failed to get ElasticSearch indices") + return nil, err + } + defer res.Body.Close() + + if res.IsError() { + err := fmt.Errorf("error getting indices: %s", res.String()) + log.WithError(err).Error("ElasticSearch Cat Indices API returned error") + return nil, err + } + + var indices []map[string]any + if err := json.NewDecoder(res.Body).Decode(&indices); err != nil { + log.WithError(err).Error("Failed to decode ElasticSearch indices response") + return nil, err + } + + databases := make([]string, 0, len(indices)) + for _, idx := range indices { + indexName, ok := idx["index"].(string) + if !ok { + continue + } + + // Skip hidden/system indices (those starting with .) + if strings.HasPrefix(indexName, ".") { + continue + } + + databases = append(databases, indexName) + } + + return databases, nil +} + +func (p *ElasticSearchPlugin) GetStorageUnits(config *engine.PluginConfig, database string) ([]engine.StorageUnit, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).Error("Failed to connect to ElasticSearch while getting storage units") + return nil, err + } + + res, err := client.Indices.Stats() + if err != nil { + log.WithError(err).Error("Failed to get ElasticSearch indices stats") + return nil, err + } + defer res.Body.Close() + + if res.IsError() { + err := fmt.Errorf("error getting stats for indices: %s", res.String()) + log.WithError(err).Error("ElasticSearch indices stats API returned error") + return nil, err + } + + var stats map[string]any + if err := json.NewDecoder(res.Body).Decode(&stats); err != nil { + log.WithError(err).Error("Failed to decode ElasticSearch indices stats response") + return nil, err + } + + indicesStats, ok := stats["indices"].(map[string]any) + if !ok { + log.WithField("stats", stats).Error("Unexpected indices stats format from ElasticSearch") + return nil, fmt.Errorf("unexpected indices stats format") + } + + storageUnits := make([]engine.StorageUnit, 0, len(indicesStats)) + + for indexName, indexStatsInterface := range indicesStats { + // Skip hidden/system indices (those starting with .) + if strings.HasPrefix(indexName, ".") { + continue + } + + indexStats, ok := indexStatsInterface.(map[string]any) + if !ok { + log.Warnf("Skipping index %s: unexpected stats format", indexName) + continue + } + + primaries, ok := indexStats["primaries"].(map[string]any) + if !ok { + log.Warnf("Skipping index %s: missing primaries data", indexName) + continue + } + + docs, ok := primaries["docs"].(map[string]any) + if !ok { + log.Warnf("Skipping index %s: missing docs data", indexName) + continue + } + + store, ok := primaries["store"].(map[string]any) + if !ok { + log.Warnf("Skipping index %s: missing store data", indexName) + continue + } + + storageUnit := engine.StorageUnit{ + Name: indexName, + Attributes: []engine.Record{ + {Key: "Type", Value: "Index"}, + {Key: "Storage Size", Value: fmt.Sprintf("%v", store["size_in_bytes"])}, + {Key: "Count", Value: fmt.Sprintf("%v", docs["count"])}, + }, + } + storageUnits = append(storageUnits, storageUnit) + } + + return storageUnits, nil +} + +func (p *ElasticSearchPlugin) StorageUnitExists(config *engine.PluginConfig, database string, index string) (bool, error) { + client, err := DB(config) + if err != nil { + return false, err + } + + res, err := client.Indices.Exists([]string{index}) + if err != nil { + return false, err + } + defer res.Body.Close() + + return res.StatusCode == 200, nil +} + +func (p *ElasticSearchPlugin) FormatValue(val any) string { + if val == nil { + return "" + } + return fmt.Sprintf("%v", val) +} + +// GetDatabaseMetadata returns ElasticSearch metadata for frontend configuration. +// ElasticSearch is a search engine without traditional type definitions. +func (p *ElasticSearchPlugin) GetDatabaseMetadata() *engine.DatabaseMetadata { + operators := make([]string, 0, len(supportedOperators)) + for op := range supportedOperators { + operators = append(operators, op) + } + return &engine.DatabaseMetadata{ + DatabaseType: engine.DatabaseType_ElasticSearch, + TypeDefinitions: []engine.TypeDefinition{ + {ID: "text", Label: "text", Category: engine.TypeCategoryText}, + {ID: "keyword", Label: "keyword", Category: engine.TypeCategoryText}, + {ID: "boolean", Label: "boolean", Category: engine.TypeCategoryBoolean}, + {ID: "long", Label: "long", Category: engine.TypeCategoryNumeric}, + {ID: "double", Label: "double", Category: engine.TypeCategoryNumeric}, + {ID: "date", Label: "date", Category: engine.TypeCategoryDatetime}, + {ID: "object", Label: "object", Category: engine.TypeCategoryOther}, + {ID: "array", Label: "array", Category: engine.TypeCategoryOther}, + {ID: "geo_point", Label: "geo_point", Category: engine.TypeCategoryOther}, + {ID: "nested", Label: "nested", Category: engine.TypeCategoryOther}, + {ID: "mixed", Label: "mixed", Category: engine.TypeCategoryOther}, + }, + Operators: operators, + AliasMap: map[string]string{}, + } +} + +func NewElasticSearchPlugin() *engine.Plugin { + return &engine.Plugin{ + Type: engine.DatabaseType_ElasticSearch, + PluginFunctions: &ElasticSearchPlugin{}, + } +} diff --git a/dataflow/core/src/plugins/elasticsearch/errors.go b/dataflow/core/src/plugins/elasticsearch/errors.go new file mode 100644 index 0000000..793feb2 --- /dev/null +++ b/dataflow/core/src/plugins/elasticsearch/errors.go @@ -0,0 +1,43 @@ +/* + * // Copyright 2025 Clidey, Inc. + * // + * // 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. + */ + +package elasticsearch + +import ( + "bytes" + "io" + "strings" + + "github.com/elastic/go-elasticsearch/v8/esapi" +) + +// formatElasticError extracts a readable error message from an Elasticsearch response. +func formatElasticError(res *esapi.Response) string { + if res == nil { + return "unknown error" + } + body, err := io.ReadAll(res.Body) + if err != nil { + return res.String() + } + // Restore body for potential callers who still read it + res.Body = io.NopCloser(bytes.NewBuffer(body)) + msg := strings.TrimSpace(string(body)) + if msg == "" { + return res.String() + } + return msg +} diff --git a/dataflow/core/src/plugins/elasticsearch/errors_test.go b/dataflow/core/src/plugins/elasticsearch/errors_test.go new file mode 100644 index 0000000..b6d1994 --- /dev/null +++ b/dataflow/core/src/plugins/elasticsearch/errors_test.go @@ -0,0 +1,38 @@ +package elasticsearch + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/elastic/go-elasticsearch/v8/esapi" +) + +func TestFormatElasticErrorReadsAndRestoresBody(t *testing.T) { + original := []byte(" something went wrong \n") + res := &esapi.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(bytes.NewReader(original)), + } + + msg := formatElasticError(res) + if msg != "something went wrong" { + t.Fatalf("expected trimmed body message, got %q", msg) + } + + // Ensure callers can still read the body after formatting. + restored, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("failed to read restored body: %v", err) + } + if !bytes.Equal(restored, original) { + t.Fatalf("expected restored body %q, got %q", string(original), string(restored)) + } +} + +func TestFormatElasticErrorHandlesNil(t *testing.T) { + if got := formatElasticError(nil); got != "unknown error" { + t.Fatalf("expected unknown error, got %q", got) + } +} diff --git a/dataflow/core/src/plugins/elasticsearch/export.go b/dataflow/core/src/plugins/elasticsearch/export.go new file mode 100644 index 0000000..4c6e948 --- /dev/null +++ b/dataflow/core/src/plugins/elasticsearch/export.go @@ -0,0 +1,349 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package elasticsearch + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" +) + +// ExportData exports ElasticSearch index data to tabular format +func (p *ElasticSearchPlugin) ExportData(config *engine.PluginConfig, schema string, storageUnit string, writer func([]string) error, selectedRows []map[string]any) error { + // If selected rows provided, export only those + if len(selectedRows) > 0 { + fieldSet := make(map[string]bool) + for _, row := range selectedRows { + for k := range row { + fieldSet[k] = true + } + } + + fieldNames := make([]string, 0, len(fieldSet)) + for field := range fieldSet { + fieldNames = append(fieldNames, field) + } + sort.Strings(fieldNames) + + // Write headers + headers := make([]string, len(fieldNames)) + for i, field := range fieldNames { + headers[i] = common.FormatCSVHeader(field, "JSON") + } + if err := writer(headers); err != nil { + return fmt.Errorf("failed to write headers: %v", err) + } + + // Write rows + for _, row := range selectedRows { + rowData := make([]string, len(fieldNames)) + for i, field := range fieldNames { + if val, exists := row[field]; exists { + rowData[i] = p.formatElasticValue(val) + } else { + rowData[i] = "" + } + } + if err := writer(rowData); err != nil { + return fmt.Errorf("failed to write row: %v", err) + } + } + return nil + } + db, err := DB(config) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to connect to ElasticSearch while exporting data") + return err + } + + // Get mapping to understand fields + mapping, err := db.Indices.GetMapping( + db.Indices.GetMapping.WithIndex(storageUnit), + ) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to get ElasticSearch index mapping for export") + return fmt.Errorf("failed to get index mapping: %v", err) + } + defer mapping.Body.Close() + + var mappingResponse map[string]any + if err := json.NewDecoder(mapping.Body).Decode(&mappingResponse); err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to decode ElasticSearch mapping response for export") + return fmt.Errorf("failed to decode mapping: %v", err) + } + + // Extract field names from mapping + fieldNames := p.extractFieldNames(mappingResponse, storageUnit) + + // Write headers + headers := make([]string, len(fieldNames)) + for i, field := range fieldNames { + headers[i] = common.FormatCSVHeader(field, "JSON") + } + if err := writer(headers); err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to write CSV headers during ElasticSearch export") + return fmt.Errorf("failed to write headers: %v", err) + } + + // Scroll through all documents + res, err := db.Search( + db.Search.WithContext(context.Background()), + db.Search.WithIndex(storageUnit), + db.Search.WithScroll(5*60*1000), // 5 minutes + db.Search.WithSize(1000), + ) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to execute ElasticSearch scroll search for export") + return fmt.Errorf("failed to search index: %v", err) + } + + var searchResult map[string]any + if err := json.NewDecoder(res.Body).Decode(&searchResult); err != nil { + res.Body.Close() + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to decode ElasticSearch search result during export") + return fmt.Errorf("failed to decode search result: %v", err) + } + res.Body.Close() + + scrollID, _ := searchResult["_scroll_id"].(string) + rowCount := 0 + + for { + hits := searchResult["hits"].(map[string]any)["hits"].([]any) + if len(hits) == 0 { + break + } + + for _, hit := range hits { + doc := hit.(map[string]any)["_source"].(map[string]any) + + row := make([]string, len(fieldNames)) + for i, field := range fieldNames { + if val, exists := p.getNestedValue(doc, field); exists { + row[i] = p.formatElasticValue(val) + } else { + row[i] = "" + } + } + if err := writer(row); err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).WithField("rowCount", rowCount).Error("Failed to write row during ElasticSearch export") + return fmt.Errorf("failed to write row: %v", err) + } + + rowCount++ + } + + if scrollID == "" { + break + } + + // Get next batch + res, err = db.Scroll( + db.Scroll.WithScrollID(scrollID), + db.Scroll.WithScroll(5*60*1000), + ) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Error during ElasticSearch scroll operation, breaking export loop") + break + } + + searchResult = make(map[string]any) + if err := json.NewDecoder(res.Body).Decode(&searchResult); err != nil { + res.Body.Close() + log.WithError(err).WithField("storageUnit", storageUnit).Error("Error decoding ElasticSearch scroll response, breaking export loop") + break + } + res.Body.Close() + + if nextScrollID, ok := searchResult["_scroll_id"].(string); ok && nextScrollID != "" { + scrollID = nextScrollID + } + } + + return nil +} + +// ExportDataNDJSON streams Elasticsearch data as NDJSON. +func (p *ElasticSearchPlugin) ExportDataNDJSON(config *engine.PluginConfig, schema string, storageUnit string, writer func(string) error, selectedRows []map[string]any) error { + if len(selectedRows) > 0 { + for _, row := range selectedRows { + line, err := json.Marshal(row) + if err != nil { + return err + } + if err := writer(string(line)); err != nil { + return err + } + } + return nil + } + + db, err := DB(config) + if err != nil { + return err + } + + res, err := db.Search( + db.Search.WithContext(context.Background()), + db.Search.WithIndex(storageUnit), + db.Search.WithScroll(5*60*1000), // 5 minutes + db.Search.WithSize(1000), + ) + if err != nil { + return fmt.Errorf("failed to search index: %v", err) + } + + var searchResult map[string]any + if err := json.NewDecoder(res.Body).Decode(&searchResult); err != nil { + res.Body.Close() + return fmt.Errorf("failed to decode search result: %v", err) + } + res.Body.Close() + + scrollID, _ := searchResult["_scroll_id"].(string) + + for { + hits := searchResult["hits"].(map[string]any)["hits"].([]any) + if len(hits) == 0 { + break + } + + for _, hit := range hits { + doc := hit.(map[string]any)["_source"].(map[string]any) + line, err := json.Marshal(doc) + if err != nil { + return err + } + if err := writer(string(line)); err != nil { + return err + } + } + + if scrollID == "" { + break + } + + res, err = db.Scroll( + db.Scroll.WithScrollID(scrollID), + db.Scroll.WithScroll(5*60*1000), + ) + if err != nil { + break + } + + searchResult = make(map[string]any) + if err := json.NewDecoder(res.Body).Decode(&searchResult); err != nil { + res.Body.Close() + break + } + res.Body.Close() + + if nextScrollID, ok := searchResult["_scroll_id"].(string); ok && nextScrollID != "" { + scrollID = nextScrollID + } + } + + return nil +} + +// Helper functions + +func (p *ElasticSearchPlugin) extractFieldNames(mapping map[string]any, indexName string) []string { + fields := make(map[string]bool) + + if indexData, ok := mapping[indexName].(map[string]any); ok { + if mappings, ok := indexData["mappings"].(map[string]any); ok { + if properties, ok := mappings["properties"].(map[string]any); ok { + p.extractFieldsRecursive(properties, "", fields) + } + } + } + + // Convert to slice + result := make([]string, 0, len(fields)) + for field := range fields { + result = append(result, field) + } + return result +} + +func (p *ElasticSearchPlugin) extractFieldsRecursive(properties map[string]any, prefix string, fields map[string]bool) { + for name, prop := range properties { + fullName := name + if prefix != "" { + fullName = prefix + "." + name + } + + fields[fullName] = true + + if propMap, ok := prop.(map[string]any); ok { + if subProps, ok := propMap["properties"].(map[string]any); ok { + p.extractFieldsRecursive(subProps, fullName, fields) + } + } + } +} + +func (p *ElasticSearchPlugin) getNestedValue(doc map[string]any, field string) (any, bool) { + parts := strings.Split(field, ".") + current := doc + + for i, part := range parts { + if i == len(parts)-1 { + val, exists := current[part] + return val, exists + } + + if next, ok := current[part].(map[string]any); ok { + current = next + } else { + return nil, false + } + } + + return nil, false +} + +func (p *ElasticSearchPlugin) formatElasticValue(val any) string { + if val == nil { + return "" + } + + var strVal string + switch v := val.(type) { + case string: + strVal = v + case []any, map[string]any: + data, err := json.Marshal(v) + if err != nil { + log.WithError(err).Error("Failed to marshal ElasticSearch value to JSON during export formatting") + strVal = fmt.Sprintf("%v", v) + } else { + strVal = string(data) + } + default: + strVal = fmt.Sprintf("%v", v) + } + + return common.EscapeFormula(strVal) +} diff --git a/dataflow/core/src/plugins/elasticsearch/graph.go b/dataflow/core/src/plugins/elasticsearch/graph.go new file mode 100644 index 0000000..9b5a0ec --- /dev/null +++ b/dataflow/core/src/plugins/elasticsearch/graph.go @@ -0,0 +1,221 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package elasticsearch + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" +) + +type tableRelation struct { + Table1 string + Table2 string + Relation string + SourceColumn string + TargetColumn string +} + +func (p *ElasticSearchPlugin) GetGraph(config *engine.PluginConfig, database string) ([]engine.GraphUnit, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).Error("Failed to connect to ElasticSearch while getting graph") + return nil, err + } + + res, err := client.Indices.Stats() + if err != nil { + log.WithError(err).Error("Failed to get ElasticSearch indices stats for graph generation") + return nil, err + } + defer res.Body.Close() + + if res.IsError() { + err := fmt.Errorf("error getting indices: %s", res.String()) + log.WithError(err).Error("ElasticSearch indices stats API returned error for graph generation") + return nil, err + } + + var stats map[string]any + if err := json.NewDecoder(res.Body).Decode(&stats); err != nil { + log.WithError(err).Error("Failed to decode ElasticSearch indices stats for graph generation") + return nil, err + } + + indicesStats := stats["indices"].(map[string]any) + + indices := []string{} + for indexName := range indicesStats { + if strings.HasPrefix(indexName, ".") { + continue + } + indices = append(indices, indexName) + } + + relations := []tableRelation{} + uniqueRelations := make(map[string]bool) + + for indexName := range indicesStats { + if strings.HasPrefix(indexName, ".") { + continue + } + var buf bytes.Buffer + query := map[string]any{ + "size": 100, + "query": map[string]any{ + "match_all": map[string]any{}, + }, + } + if err := json.NewEncoder(&buf).Encode(query); err != nil { + log.WithError(err).WithField("indexName", indexName).Error("Failed to encode ElasticSearch query for graph generation") + return nil, err + } + + res, err := client.Search( + client.Search.WithContext(context.Background()), + client.Search.WithIndex(indexName), + client.Search.WithBody(&buf), + ) + if err != nil { + log.WithError(err).WithField("indexName", indexName).Error("Failed to execute ElasticSearch search query for graph generation") + return nil, err + } + + if res.IsError() { + err := fmt.Errorf("error searching documents: %s", formatElasticError(res)) + res.Body.Close() + log.WithError(err).WithField("indexName", indexName).Error("ElasticSearch search API returned error for graph generation") + return nil, err + } + + var searchResult map[string]any + if err := json.NewDecoder(res.Body).Decode(&searchResult); err != nil { + res.Body.Close() + log.WithError(err).WithField("indexName", indexName).Error("Failed to decode ElasticSearch search result for graph generation") + return nil, err + } + res.Body.Close() + + hits := searchResult["hits"].(map[string]any)["hits"].([]any) + if len(hits) > 0 { + foreignKeys := make(map[string]string) + + for _, h := range hits { + doc, ok := h.(map[string]any)["_source"].(map[string]any) + if !ok { + continue + } + + for fieldName := range doc { + if fieldName == "_id" { + continue + } + + // Check for explicit relation hints + if strings.Contains(strings.ToLower(fieldName), ".id") { + for _, otherIndex := range indices { + if otherIndex == indexName { + continue + } + if strings.Contains(strings.ToLower(fieldName), strings.ToLower(otherIndex)) { + foreignKeys[otherIndex] = fieldName + break + } + } + } + + for _, otherIndex := range indices { + if otherIndex == indexName { + continue + } + + singularName := strings.TrimSuffix(otherIndex, "s") + pluralName := otherIndex + if !strings.HasSuffix(otherIndex, "s") { + pluralName = otherIndex + "s" + } + + lowerField := strings.ToLower(fieldName) + if lowerField == strings.ToLower(singularName)+"_id" || + lowerField == strings.ToLower(singularName)+"id" || + lowerField == strings.ToLower(otherIndex)+"_id" || + lowerField == strings.ToLower(otherIndex)+"id" || + lowerField == strings.ToLower(pluralName)+"_id" || + lowerField == strings.ToLower(pluralName)+"id" { + foreignKeys[otherIndex] = fieldName + break + } + } + } + } + + for fk, fieldName := range foreignKeys { + relKey := indexName + ":" + fk + if !uniqueRelations[relKey+":ManyToOne"] { + uniqueRelations[relKey+":ManyToOne"] = true + relations = append(relations, tableRelation{ + Table1: indexName, + Table2: fk, + Relation: "ManyToOne", + SourceColumn: fieldName, + TargetColumn: "_id", + }) + } + } + } + } + + tableMap := make(map[string][]engine.GraphUnitRelationship) + for _, tr := range relations { + sourceCol := tr.SourceColumn + targetCol := tr.TargetColumn + tableMap[tr.Table1] = append(tableMap[tr.Table1], engine.GraphUnitRelationship{ + Name: tr.Table2, + RelationshipType: engine.GraphUnitRelationshipType(tr.Relation), + SourceColumn: &sourceCol, + TargetColumn: &targetCol, + }) + } + + storageUnits, err := p.GetStorageUnits(config, database) + if err != nil { + log.WithError(err).Error("Failed to get storage units for ElasticSearch graph generation") + return nil, err + } + + storageUnitsMap := map[string]engine.StorageUnit{} + for _, storageUnit := range storageUnits { + storageUnitsMap[storageUnit.Name] = storageUnit + } + + tables := []engine.GraphUnit{} + for _, storageUnit := range storageUnits { + foundTable, ok := tableMap[storageUnit.Name] + var relations []engine.GraphUnitRelationship + if ok { + relations = foundTable + } + tables = append(tables, engine.GraphUnit{Unit: storageUnit, Relations: relations}) + } + + return tables, nil +} diff --git a/dataflow/core/src/plugins/elasticsearch/rows.go b/dataflow/core/src/plugins/elasticsearch/rows.go new file mode 100644 index 0000000..bb2cdcc --- /dev/null +++ b/dataflow/core/src/plugins/elasticsearch/rows.go @@ -0,0 +1,378 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package elasticsearch + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" +) + +func (p *ElasticSearchPlugin) GetRows(config *engine.PluginConfig, req *engine.GetRowsRequest) (*engine.GetRowsResult, error) { + collection := req.StorageUnit + where, sortConds, pageSize, pageOffset := req.Where, req.Sort, req.PageSize, req.PageOffset + client, err := DB(config) + if err != nil { + log.WithError(err).WithField("collection", collection).Error("Failed to connect to ElasticSearch while getting rows") + return nil, err + } + + // Convert the where condition to an Elasticsearch filter + elasticSearchConditions, err := convertWhereConditionToES(where) + if err != nil { + log.WithError(err).WithField("collection", collection).Error("Failed to convert where condition to ElasticSearch query") + return nil, fmt.Errorf("error converting where condition: %v", err) + } + + query := map[string]any{ + "from": pageOffset, + "size": pageSize, + "query": map[string]any{ + "bool": elasticSearchConditions, + }, + } + + // Apply sorting if provided + // Skip "document" column as it's a virtual column representing the entire JSON document + if len(sortConds) > 0 { + sortArray := []map[string]any{} + for _, s := range sortConds { + if s.Column == "document" { + continue + } + order := "asc" + if s.Direction == model.SortDirectionDesc { + order = "desc" + } + sortArray = append(sortArray, map[string]any{ + s.Column: map[string]any{ + "order": order, + }, + }) + } + if len(sortArray) > 0 { + query["sort"] = sortArray + } + } + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(query); err != nil { + log.WithError(err).WithField("collection", collection).Error("Failed to encode ElasticSearch query to JSON") + return nil, err + } + + res, err := client.Search( + client.Search.WithContext(context.Background()), + client.Search.WithIndex(collection), + client.Search.WithBody(&buf), + client.Search.WithTrackTotalHits(true), + ) + if err != nil { + log.WithError(err).WithField("collection", collection).Error("Failed to execute ElasticSearch search query") + return nil, err + } + defer res.Body.Close() + + if res.IsError() { + err := fmt.Errorf("error searching documents: %s", res.String()) + log.WithError(err).WithField("collection", collection).Error("ElasticSearch search API returned error") + return nil, err + } + + var searchResult map[string]any + if err := json.NewDecoder(res.Body).Decode(&searchResult); err != nil { + log.WithError(err).WithField("collection", collection).Error("Failed to decode ElasticSearch search response") + return nil, err + } + + hits, ok := searchResult["hits"].(map[string]any)["hits"].([]any) + if !ok { + err := fmt.Errorf("invalid response structure") + log.WithError(err).WithField("collection", collection).Error("ElasticSearch search response has invalid structure") + return nil, err + } + + result := &engine.GetRowsResult{ + Columns: []engine.Column{ + {Name: "document", Type: "Document"}, + }, + Rows: [][]string{}, + } + + // Extract total count from the response (already tracked via WithTrackTotalHits) + if hitsMap, ok := searchResult["hits"].(map[string]any); ok { + if total, ok := hitsMap["total"].(map[string]any); ok { + if value, ok := total["value"].(float64); ok { + result.TotalCount = int64(value) + } + } + } + + for _, hit := range hits { + hitMap := hit.(map[string]any) + source := hitMap["_source"].(map[string]any) + id := hitMap["_id"] + source["_id"] = id + jsonBytes, err := json.Marshal(source) + if err != nil { + log.WithError(err).WithField("collection", collection).Error("Failed to marshal ElasticSearch document source to JSON") + return nil, err + } + result.Rows = append(result.Rows, []string{string(jsonBytes)}) + } + + return result, nil +} + +func (p *ElasticSearchPlugin) GetRowCount(config *engine.PluginConfig, database, index string, where *model.WhereCondition) (int64, error) { + client, err := DB(config) + if err != nil { + return 0, err + } + + elasticSearchConditions, err := convertWhereConditionToES(where) + if err != nil { + return 0, fmt.Errorf("error converting where condition: %v", err) + } + + query := map[string]any{ + "query": map[string]any{ + "bool": elasticSearchConditions, + }, + } + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(query); err != nil { + return 0, err + } + + res, err := client.Count( + client.Count.WithContext(context.Background()), + client.Count.WithIndex(index), + client.Count.WithBody(&buf), + ) + if err != nil { + return 0, err + } + defer res.Body.Close() + + if res.IsError() { + return 0, fmt.Errorf("error counting documents: %s", res.String()) + } + + var countResult map[string]any + if err := json.NewDecoder(res.Body).Decode(&countResult); err != nil { + return 0, err + } + + count, ok := countResult["count"].(float64) + if !ok { + return 0, fmt.Errorf("unexpected count response format") + } + + return int64(count), nil +} + +func (p *ElasticSearchPlugin) GetColumnsForTable(config *engine.PluginConfig, schema string, storageUnit string) ([]engine.Column, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "index": storageUnit, + }).Error("Failed to connect to ElasticSearch for column inference") + return nil, err + } + + var buf bytes.Buffer + query := map[string]any{ + "size": 100, + "query": map[string]any{ + "match_all": map[string]any{}, + }, + } + if err := json.NewEncoder(&buf).Encode(query); err != nil { + log.WithError(err).WithField("index", storageUnit).Error("Failed to encode query for column inference") + return nil, err + } + + res, err := client.Search( + client.Search.WithContext(context.Background()), + client.Search.WithIndex(storageUnit), + client.Search.WithBody(&buf), + ) + if err != nil { + log.WithError(err).WithField("index", storageUnit).Error("Failed to search for sample document") + return nil, err + } + defer res.Body.Close() + + if res.IsError() { + log.WithField("index", storageUnit).Warn("No documents found, returning empty schema") + return []engine.Column{}, nil + } + + var searchResult map[string]any + if err := json.NewDecoder(res.Body).Decode(&searchResult); err != nil { + log.WithError(err).WithField("index", storageUnit).Error("Failed to decode search result") + return nil, err + } + + hits := searchResult["hits"].(map[string]any)["hits"].([]any) + if len(hits) == 0 { + return []engine.Column{}, nil + } + + fieldTypes := make(map[string]string) + for _, h := range hits { + hitMap, ok := h.(map[string]any) + if !ok { + continue + } + source, ok := hitMap["_source"].(map[string]any) + if !ok { + continue + } + for fieldName, fieldValue := range source { + fieldType := inferElasticSearchType(fieldValue) + fieldTypes[fieldName] = mergeElasticTypes(fieldTypes[fieldName], fieldType) + } + } + + indicesRes, err := client.Indices.Stats() + if err != nil { + log.WithError(err).Error("Failed to get ElasticSearch indices for FK detection") + return nil, err + } + defer indicesRes.Body.Close() + + if indicesRes.IsError() { + log.Error("ElasticSearch indices stats API returned error for FK detection") + return nil, fmt.Errorf("error getting indices: %s", indicesRes.String()) + } + + var stats map[string]any + if err := json.NewDecoder(indicesRes.Body).Decode(&stats); err != nil { + log.WithError(err).Error("Failed to decode ElasticSearch indices stats") + return nil, err + } + + indicesStats := stats["indices"].(map[string]any) + var indices []string + for indexName := range indicesStats { + indices = append(indices, indexName) + } + + if len(fieldTypes) == 0 { + return []engine.Column{}, nil + } + + fieldNames := make([]string, 0, len(fieldTypes)) + for name := range fieldTypes { + fieldNames = append(fieldNames, name) + } + sort.Strings(fieldNames) + + columns := []engine.Column{ + { + Name: "_id", + Type: "keyword", + IsPrimary: true, + IsForeignKey: false, + }, + } + + for _, fieldName := range fieldNames { + fieldType := fieldTypes[fieldName] + + var isForeignKey bool + var referencedTable *string + + lowerField := strings.ToLower(fieldName) + for _, otherIndex := range indices { + if otherIndex == storageUnit { + continue + } + + singularName := strings.TrimSuffix(otherIndex, "s") + pluralName := otherIndex + if !strings.HasSuffix(otherIndex, "s") { + pluralName = otherIndex + "s" + } + + if lowerField == strings.ToLower(singularName)+"_id" || + lowerField == strings.ToLower(singularName)+"id" || + lowerField == strings.ToLower(otherIndex)+"_id" || + lowerField == strings.ToLower(otherIndex)+"id" || + lowerField == strings.ToLower(pluralName)+"_id" || + lowerField == strings.ToLower(pluralName)+"id" { + isForeignKey = true + referencedTable = &otherIndex + break + } + } + + columns = append(columns, engine.Column{ + Name: fieldName, + Type: fieldType, + IsPrimary: false, + IsForeignKey: isForeignKey, + ReferencedTable: referencedTable, + }) + } + + return columns, nil +} + +func inferElasticSearchType(value any) string { + if value == nil { + return "null" + } + + switch value.(type) { + case string: + return "text" + case float64, float32: + return "float" + case int, int32, int64: + return "long" + case bool: + return "boolean" + case []any: + return "array" + case map[string]any: + return "object" + default: + return "keyword" + } +} + +// mergeElasticTypes combines inferred types; conflicting types become "mixed". +func mergeElasticTypes(current, next string) string { + if current == "" { + return next + } + if current == next { + return current + } + return "mixed" +} diff --git a/dataflow/core/src/plugins/elasticsearch/ssl_status.go b/dataflow/core/src/plugins/elasticsearch/ssl_status.go new file mode 100644 index 0000000..484c186 --- /dev/null +++ b/dataflow/core/src/plugins/elasticsearch/ssl_status.go @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package elasticsearch + +import ( + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins/ssl" +) + +// GetSSLStatus returns the SSL status based on the configured SSL mode for Elasticsearch. +// Elasticsearch SSL is determined by whether the connection uses HTTPS scheme. +func (p *ElasticSearchPlugin) GetSSLStatus(config *engine.PluginConfig) (*engine.SSLStatus, error) { + log.Debug("[SSL] ElasticSearchPlugin.GetSSLStatus: checking configured SSL mode") + sslConfig := ssl.ParseSSLConfig(engine.DatabaseType_ElasticSearch, config.Credentials.Advanced, config.Credentials.Hostname, config.Credentials.IsProfile) + + if sslConfig == nil || !sslConfig.IsEnabled() { + log.Debug("[SSL] ElasticSearchPlugin.GetSSLStatus: SSL not configured or disabled") + return &engine.SSLStatus{ + IsEnabled: false, + Mode: string(ssl.SSLModeDisabled), + }, nil + } + + log.Debugf("[SSL] ElasticSearchPlugin.GetSSLStatus: SSL enabled, mode=%s", sslConfig.Mode) + return &engine.SSLStatus{ + IsEnabled: true, + Mode: string(sslConfig.Mode), + }, nil +} diff --git a/dataflow/core/src/plugins/elasticsearch/update.go b/dataflow/core/src/plugins/elasticsearch/update.go new file mode 100644 index 0000000..331e8e8 --- /dev/null +++ b/dataflow/core/src/plugins/elasticsearch/update.go @@ -0,0 +1,121 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package elasticsearch + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" +) + +var script = ` +for (entry in params.entrySet()) { + ctx._source[entry.getKey()] = entry.getValue(); +} +for (key in ctx._source.keySet().toArray()) { + if (!params.containsKey(key)) { + ctx._source.remove(key); + } +} +` + +func (p *ElasticSearchPlugin) UpdateStorageUnit(config *engine.PluginConfig, database string, storageUnit string, values map[string]string, updatedColumns []string) (bool, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to connect to ElasticSearch while updating storage unit") + return false, err + } + + documentJSON, ok := values["document"] + if !ok { + err := errors.New("missing 'document' key in values map") + log.WithError(err).WithField("storageUnit", storageUnit).Error("Missing document key in update request values") + return false, err + } + + var jsonValues map[string]any + if err := json.Unmarshal([]byte(documentJSON), &jsonValues); err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to unmarshal document JSON for update") + return false, err + } + + id, ok := jsonValues["_id"] + if !ok { + err := errors.New("missing '_id' field in the document") + log.WithError(err).WithField("storageUnit", storageUnit).Error("Missing _id field in document for update") + return false, err + } + + delete(jsonValues, "_id") + + idStr, ok := id.(string) + if !ok || strings.TrimSpace(idStr) == "" { + return false, fmt.Errorf("invalid '_id' field; expected non-empty string") + } + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(map[string]any{ + "script": map[string]any{ + "source": script, + "lang": "painless", + "params": jsonValues, + }, + "upsert": jsonValues, + }); err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).WithField("documentId", id).Error("Failed to encode ElasticSearch update request") + return false, err + } + + res, err := client.Update( + storageUnit, + idStr, + &buf, + client.Update.WithContext(context.Background()), + client.Update.WithRefresh("true"), + ) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).WithField("documentId", idStr).Error("Failed to execute ElasticSearch update operation") + return false, fmt.Errorf("failed to execute update: %w", err) + } + defer res.Body.Close() + + if res.IsError() { + err := fmt.Errorf("error updating document: %s", formatElasticError(res)) + log.WithError(err).WithField("storageUnit", storageUnit).WithField("documentId", idStr).Error("ElasticSearch update API returned error") + return false, err + } + + var updateResponse map[string]any + if err := json.NewDecoder(res.Body).Decode(&updateResponse); err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).WithField("documentId", id).Error("Failed to decode ElasticSearch update response") + return false, err + } + + if result, ok := updateResponse["result"].(string); ok && result == "noop" { + err := errors.New("no documents were updated") + log.WithError(err).WithField("storageUnit", storageUnit).WithField("documentId", id).WithField("result", result).Error("ElasticSearch update operation did not update any documents") + return false, err + } + + return true, nil +} diff --git a/dataflow/core/src/plugins/elasticsearch/where_conditions.go b/dataflow/core/src/plugins/elasticsearch/where_conditions.go new file mode 100644 index 0000000..56f1677 --- /dev/null +++ b/dataflow/core/src/plugins/elasticsearch/where_conditions.go @@ -0,0 +1,351 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package elasticsearch + +import ( + "fmt" + "strings" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/log" +) + +// convertAtomicConditionToES converts an atomic where condition to an Elasticsearch query clause +func convertAtomicConditionToES(atomic *model.AtomicWhereCondition) (map[string]any, error) { + if atomic == nil { + return nil, fmt.Errorf("atomic condition is nil") + } + + // Handle different operators + switch atomic.Operator { + case "match", "MATCH": + // Full-text search + return map[string]any{ + "match": map[string]any{ + atomic.Key: atomic.Value, + }, + }, nil + + case "match_phrase_prefix", "MATCH_PHRASE_PREFIX": + return map[string]any{ + "match_phrase_prefix": map[string]any{ + atomic.Key: atomic.Value, + }, + }, nil + + case "=", "eq", "EQ", "equals", "EQUALS", "term", "TERM": + // Special handling for _id field - use ids query + if atomic.Key == "_id" { + return map[string]any{ + "ids": map[string]any{ + "values": []any{atomic.Value}, + }, + }, nil + } + // Exact match for other fields + return map[string]any{ + "term": map[string]any{ + atomic.Key: atomic.Value, + }, + }, nil + + case "!=", "ne", "NE", "not equals", "NOT EQUALS": + // Not equal + return map[string]any{ + "bool": map[string]any{ + "must_not": []map[string]any{ + { + "term": map[string]any{ + atomic.Key: atomic.Value, + }, + }, + }, + }, + }, nil + + case "exists", "EXISTS": + // Field exists + return map[string]any{ + "exists": map[string]any{ + "field": atomic.Key, + }, + }, nil + + case "not exists", "NOT EXISTS": + // Field does not exist + return map[string]any{ + "bool": map[string]any{ + "must_not": []map[string]any{ + { + "exists": map[string]any{ + "field": atomic.Key, + }, + }, + }, + }, + }, nil + + case ">", "gt", "GT": + // Greater than + return map[string]any{ + "range": map[string]any{ + atomic.Key: map[string]any{ + "gt": atomic.Value, + }, + }, + }, nil + + case ">=", "gte", "GTE": + // Greater than or equal + return map[string]any{ + "range": map[string]any{ + atomic.Key: map[string]any{ + "gte": atomic.Value, + }, + }, + }, nil + + case "<", "lt", "LT": + // Less than + return map[string]any{ + "range": map[string]any{ + atomic.Key: map[string]any{ + "lt": atomic.Value, + }, + }, + }, nil + + case "<=", "lte", "LTE": + // Less than or equal + return map[string]any{ + "range": map[string]any{ + atomic.Key: map[string]any{ + "lte": atomic.Value, + }, + }, + }, nil + + case "like", "LIKE", "contains", "CONTAINS": + // Wildcard search + return map[string]any{ + "wildcard": map[string]any{ + atomic.Key: map[string]any{ + "value": fmt.Sprintf("*%v*", atomic.Value), + }, + }, + }, nil + + case "prefix", "PREFIX", "starts with", "STARTS WITH": + // Prefix search + return map[string]any{ + "prefix": map[string]any{ + atomic.Key: atomic.Value, + }, + }, nil + + case "terms", "TERMS": + // Multiple exact matches + values := parseCSVToSlice(atomic.Value) + return map[string]any{ + "terms": map[string]any{ + atomic.Key: values, + }, + }, nil + + case "ids", "IDS": + var ids []any + ids = parseCSVToSlice(atomic.Value) + return map[string]any{ + "ids": map[string]any{ + "values": ids, + }, + }, nil + + case "range", "RANGE": + // Expect "min,max" (empty allowed) + minBound, maxBound := parseRangeBounds(fmt.Sprintf("%v", atomic.Value)) + rangeClause := map[string]any{} + if minBound != "" { + rangeClause["gte"] = minBound + } + if maxBound != "" { + rangeClause["lte"] = maxBound + } + return map[string]any{ + "range": map[string]any{ + atomic.Key: rangeClause, + }, + }, nil + + case "match_phrase", "MATCH_PHRASE": + // Phrase match + return map[string]any{ + "match_phrase": map[string]any{ + atomic.Key: atomic.Value, + }, + }, nil + + case "wildcard", "WILDCARD": + // Wildcard pattern match + return map[string]any{ + "wildcard": map[string]any{ + atomic.Key: map[string]any{ + "value": atomic.Value, + }, + }, + }, nil + + case "regexp", "REGEXP": + // Regular expression match + return map[string]any{ + "regexp": map[string]any{ + atomic.Key: atomic.Value, + }, + }, nil + + case "fuzzy", "FUZZY": + // Fuzzy match + return map[string]any{ + "fuzzy": map[string]any{ + atomic.Key: atomic.Value, + }, + }, nil + + default: + // Default to match query for unknown operators + log.WithField("operator", atomic.Operator).Warn("Unknown operator, defaulting to match query") + return map[string]any{ + "match": map[string]any{ + atomic.Key: atomic.Value, + }, + }, nil + } +} + +func convertWhereConditionToES(where *model.WhereCondition) (map[string]any, error) { + if where == nil { + return map[string]any{}, nil + } + + switch where.Type { + case model.WhereConditionTypeAtomic: + if where.Atomic == nil { + err := fmt.Errorf("atomic condition must have an atomicwherecondition") + log.WithError(err).Error("Invalid atomic where condition: missing atomic condition") + return nil, err + } + + // Convert atomic condition based on operator + clause, err := convertAtomicConditionToES(where.Atomic) + if err != nil { + return nil, err + } + + return map[string]any{ + "must": []map[string]any{clause}, + }, nil + + case model.WhereConditionTypeAnd: + if where.And == nil || len(where.And.Children) == 0 { + return map[string]any{}, nil + } + mustClauses := []map[string]any{} + for _, child := range where.And.Children { + // Handle child conditions based on their type + if child.Type == model.WhereConditionTypeAtomic && child.Atomic != nil { + // For atomic children, convert based on operator + clause, err := convertAtomicConditionToES(child.Atomic) + if err != nil { + log.WithError(err).Error("Failed to convert atomic condition in AND clause to ElasticSearch query") + return nil, err + } + mustClauses = append(mustClauses, clause) + } else { + // For nested AND/OR, we need to wrap them in a bool query + childCondition, err := convertWhereConditionToES(child) + if err != nil { + log.WithError(err).Error("Failed to convert child condition in AND clause to ElasticSearch query") + return nil, err + } + // Wrap the child condition in a bool query + mustClauses = append(mustClauses, map[string]any{ + "bool": childCondition, + }) + } + } + return map[string]any{"must": mustClauses}, nil + + case model.WhereConditionTypeOr: + if where.Or == nil || len(where.Or.Children) == 0 { + return map[string]any{}, nil + } + shouldClauses := []map[string]any{} + for _, child := range where.Or.Children { + // Handle child conditions based on their type + if child.Type == model.WhereConditionTypeAtomic && child.Atomic != nil { + // For atomic children, convert based on operator + clause, err := convertAtomicConditionToES(child.Atomic) + if err != nil { + log.WithError(err).Error("Failed to convert atomic condition in OR clause to ElasticSearch query") + return nil, err + } + shouldClauses = append(shouldClauses, clause) + } else { + // For nested AND/OR, we need to wrap them in a bool query + childCondition, err := convertWhereConditionToES(child) + if err != nil { + log.WithError(err).Error("Failed to convert child condition in OR clause to ElasticSearch query") + return nil, err + } + // Wrap the child condition in a bool query + shouldClauses = append(shouldClauses, map[string]any{ + "bool": childCondition, + }) + } + } + return map[string]any{ + "should": shouldClauses, + "minimum_should_match": 1, // Ensures at least one condition matches + }, nil + + default: + err := fmt.Errorf("unknown whereconditiontype: %v", where.Type) + return nil, err + } +} + +func parseCSVToSlice(raw string) []any { + if strings.TrimSpace(raw) == "" { + return []any{} + } + parts := strings.Split(raw, ",") + values := make([]any, 0, len(parts)) + for _, p := range parts { + values = append(values, strings.TrimSpace(p)) + } + return values +} + +func parseRangeBounds(raw string) (string, string) { + parts := strings.Split(raw, ",") + if len(parts) == 1 { + return strings.TrimSpace(parts[0]), "" + } + minBound := strings.TrimSpace(parts[0]) + maxBound := strings.TrimSpace(parts[1]) + return minBound, maxBound +} diff --git a/dataflow/core/src/plugins/gorm/add.go b/dataflow/core/src/plugins/gorm/add.go new file mode 100644 index 0000000..5fb5304 --- /dev/null +++ b/dataflow/core/src/plugins/gorm/add.go @@ -0,0 +1,217 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "errors" + "fmt" + "strconv" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + "gorm.io/gorm" +) + +// PrimaryKeysUseSourceValuesExtraKey marks primary key columns that should use imported source values. +const PrimaryKeysUseSourceValuesExtraKey = "primaryKeysUseSourceValues" + +func (p *GormPlugin) AddStorageUnit(config *engine.PluginConfig, schema string, storageUnit string, fields []engine.Record) (bool, error) { + return p.AddStorageUnitWithOptions(config, schema, storageUnit, fields, engine.CreateStorageUnitOptions{}) +} + +// AddStorageUnitWithOptions creates a SQL table using database-specific DDL options. +func (p *GormPlugin) AddStorageUnitWithOptions(config *engine.PluginConfig, schema string, storageUnit string, fields []engine.Record, options engine.CreateStorageUnitOptions) (bool, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + if len(fields) == 0 { + return false, errors.New("no fields provided for table creation") + } + + migrator := NewMigratorHelper(db, p.GormPluginFunctions) + fullTableName := p.FormTableName(schema, storageUnit) + if migrator.TableExists(fullTableName) { + return false, fmt.Errorf("table %s already exists", fullTableName) + } + + var columns []engine.Record + metadata := p.GetDatabaseMetadata() + for _, fieldType := range fields { + if err := engine.ValidateColumnType(fieldType.Value, metadata); err != nil { + return false, err + } + + // Keep original field name without quoting for column definition + fieldName := fieldType.Key + primaryKey, err := strconv.ParseBool(fieldType.Extra["Primary"]) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to parse Primary key flag for field %s in table %s.%s", fieldType.Key, schema, storageUnit)) + primaryKey = false + } + nullable, err := strconv.ParseBool(fieldType.Extra["Nullable"]) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to parse Nullable flag for field %s in table %s.%s", fieldType.Key, schema, storageUnit)) + nullable = false + } + + // Validate: Primary keys cannot be nullable + if primaryKey && nullable { + return false, fmt.Errorf("column %s cannot be both primary key and nullable", fieldType.Key) + } + + columns = append(columns, engine.Record{ + Key: fieldName, + Value: fieldType.Value, + Extra: map[string]string{ + "primary": strconv.FormatBool(primaryKey), + "nullable": strconv.FormatBool(nullable), + }, + }) + if primaryKey && options.PrimaryKeysUseSourceValues { + columns[len(columns)-1].Extra[PrimaryKeysUseSourceValuesExtraKey] = "true" + } + } + + createTableQuery := p.GetCreateTableQuery(db, schema, storageUnit, columns) + + if err := db.Exec(createTableQuery).Error; err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to create table %s.%s with query: %s", schema, storageUnit, createTableQuery)) + return false, err + } + return true, nil + }) +} + +// DropStorageUnit drops a SQL table using database-specific identifier quoting. +func (p *GormPlugin) DropStorageUnit(config *engine.PluginConfig, schema string, storageUnit string) (bool, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + builder := p.GormPluginFunctions.CreateSQLBuilder(db) + if err := db.Exec(builder.DropTableQuery(schema, storageUnit)).Error; err != nil { + return false, err + } + return true, nil + }) +} + +// addRowWithDB performs the actual row insertion using the provided database connection +func (p *GormPlugin) addRowWithDB(db *gorm.DB, schema string, storageUnit string, values []engine.Record) error { + if len(values) == 0 { + return errors.New("no values provided to insert into the table") + } + + if storageUnit == "" { + return fmt.Errorf("storage unit name cannot be empty") + } + + // Fetch column types to ensure proper type conversion + columnTypeInfos, err := p.GormPluginFunctions.GetColumnTypes(db, schema, storageUnit) + if err != nil { + log.WithError(err).WithField("schema", schema).WithField("storageUnit", storageUnit). + Warn("Failed to fetch column types, continuing without type information") + columnTypeInfos = make(map[string]ColumnTypeInfo) + } + + for i, value := range values { + if values[i].Extra == nil { + values[i].Extra = make(map[string]string) + } + if values[i].Extra["Type"] == "" { + if colInfo, ok := columnTypeInfos[value.Key]; ok { + values[i].Extra["Type"] = colInfo.Type + if colInfo.IsNullable { + values[i].Extra["IsNullable"] = "true" + } + } + } + } + + builder := p.GormPluginFunctions.CreateSQLBuilder(db) + + valuesToAdd, err := p.ConvertRecordValuesToMap(values) + if err != nil { + return err + } + + return builder.InsertRow(schema, storageUnit, valuesToAdd) +} + +func (p *GormPlugin) AddRow(config *engine.PluginConfig, schema string, storageUnit string, values []engine.Record) (bool, error) { + if p.errorHandler == nil { + p.InitPlugin() + } + + if storageUnit == "" { + log.Error("AddRow called with empty storageUnit name") + return false, fmt.Errorf("storage unit name cannot be empty") + } + + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + err := p.addRowWithDB(db, schema, storageUnit, values) + if err != nil { + err = p.errorHandler.HandleError(err, "AddRow", map[string]any{ + "schema": schema, + "storageUnit": storageUnit, + "valueCount": len(values), + }) + } + return err == nil, err + }) +} + +// AddRowReturningID inserts a row and returns the auto-generated ID. +// Returns 0 if the table has no auto-increment column or the database doesn't support it. +func (p *GormPlugin) AddRowReturningID(config *engine.PluginConfig, schema string, storageUnit string, values []engine.Record) (int64, error) { + if p.errorHandler == nil { + p.InitPlugin() + } + + if storageUnit == "" { + log.Error("AddRowReturningID called with empty storageUnit name") + return 0, fmt.Errorf("storage unit name cannot be empty") + } + + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (int64, error) { + var lastID int64 + + // Use a transaction to ensure INSERT and lastval() use the same connection. + // This is critical for PostgreSQL where lastval() is session-specific. + err := db.Transaction(func(tx *gorm.DB) error { + if err := p.addRowWithDB(tx, schema, storageUnit, values); err != nil { + return err + } + + var err error + lastID, err = p.GormPluginFunctions.GetLastInsertID(tx) + if err != nil { + log.WithError(err).Warn("Failed to get last insert ID") + return nil + } + + return nil + }) + + if err != nil { + err = p.errorHandler.HandleError(err, "AddRowReturningID", map[string]any{ + "schema": schema, + "storageUnit": storageUnit, + "valueCount": len(values), + }) + return 0, err + } + + return lastID, nil + }) +} diff --git a/dataflow/core/src/plugins/gorm/batch.go b/dataflow/core/src/plugins/gorm/batch.go new file mode 100644 index 0000000..c29c448 --- /dev/null +++ b/dataflow/core/src/plugins/gorm/batch.go @@ -0,0 +1,317 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "fmt" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// BatchConfig holds configuration for batch operations +type BatchConfig struct { + BatchSize int // Number of records per batch + UseBulkInsert bool // Use database-specific bulk insert when available + FailOnError bool // Stop on first error or continue + LogProgress bool // Log progress during batch operations + UpsertPKColumns []string // PK columns for ON CONFLICT DO UPDATE; non-nil = upsert mode + SkipConflictPKColumns []string // PK columns for ON CONFLICT with identity update (append mode — skip duplicates) +} + +// DefaultBatchConfig returns default batch configuration +func DefaultBatchConfig() *BatchConfig { + return &BatchConfig{ + BatchSize: 1000, + UseBulkInsert: true, + FailOnError: true, + LogProgress: false, + } +} + +// BatchProcessor handles batch operations for the plugin +type BatchProcessor struct { + plugin GormPluginFunctions + config *BatchConfig + dbType engine.DatabaseType +} + +// NewBatchProcessor creates a new batch processor +func NewBatchProcessor(plugin GormPluginFunctions, dbType engine.DatabaseType, config *BatchConfig) *BatchProcessor { + if config == nil { + config = DefaultBatchConfig() + } + return &BatchProcessor{ + plugin: plugin, + config: config, + dbType: dbType, + } +} + +// getMaxParametersForDB returns the maximum number of parameters supported by the database. +func (b *BatchProcessor) getMaxParametersForDB() int { + return b.plugin.GetMaxBulkInsertParameters() +} + +// calculateBatchSize returns an appropriate batch size based on column count and DB limits. +func (b *BatchProcessor) calculateBatchSize(columnCount int) int { + if columnCount == 0 { + return b.config.BatchSize + } + + maxParams := b.getMaxParametersForDB() + // Leave some margin (10%) for safety + safeMaxParams := int(float64(maxParams) * 0.9) + maxRowsForDB := safeMaxParams / columnCount + + if maxRowsForDB < 1 { + maxRowsForDB = 1 + } + + // Use the smaller of configured batch size and calculated max + if maxRowsForDB < b.config.BatchSize { + log.WithFields(map[string]any{ + "configuredBatchSize": b.config.BatchSize, + "calculatedMaxRows": maxRowsForDB, + "columnCount": columnCount, + "maxParameters": maxParams, + "dbType": b.dbType, + }).Debug("Reducing batch size due to database parameter limit") + return maxRowsForDB + } + + return b.config.BatchSize +} + +// buildSkipConflictClause delegates to the plugin's dialect-specific implementation. +func (b *BatchProcessor) buildSkipConflictClause() clause.OnConflict { + return b.plugin.BuildSkipConflictClause(b.config.SkipConflictPKColumns) +} + +// buildUpsertClause creates an OnConflict clause for upsert operations. +// It uses UpsertPKColumns as the conflict target and updates all non-PK columns. +func (b *BatchProcessor) buildUpsertClause(record map[string]any) clause.OnConflict { + conflictCols := make([]clause.Column, len(b.config.UpsertPKColumns)) + for i, col := range b.config.UpsertPKColumns { + conflictCols[i] = clause.Column{Name: col} + } + + pkSet := make(map[string]bool, len(b.config.UpsertPKColumns)) + for _, pk := range b.config.UpsertPKColumns { + pkSet[pk] = true + } + + var updateCols []string + for col := range record { + if !pkSet[col] { + updateCols = append(updateCols, col) + } + } + + // All-PK table (e.g., junction table): no columns to update, so DO NOTHING + if len(updateCols) == 0 { + return clause.OnConflict{ + Columns: conflictCols, + DoNothing: true, + } + } + + return clause.OnConflict{ + Columns: conflictCols, + DoUpdates: clause.AssignmentColumns(updateCols), + } +} + +// InsertBatch inserts multiple rows in batches +func (b *BatchProcessor) InsertBatch(db *gorm.DB, schema, tableName string, records []map[string]any) error { + if len(records) == 0 { + return nil + } + + columnCount := 0 + if len(records) > 0 { + columnCount = len(records[0]) + } + + if columnCount == 0 { + log.WithFields(map[string]any{ + "table": tableName, + "recordCount": len(records), + }).Error("All records are empty - no columns to insert") + return fmt.Errorf("cannot insert empty records into %s: all columns were skipped (check for incorrect auto-increment/computed detection)", tableName) + } + + effectiveBatchSize := b.calculateBatchSize(columnCount) + + fullTableName := b.plugin.FormTableName(schema, tableName) + + // Use GORM's CreateInBatches for efficient bulk insert + if b.config.UseBulkInsert { + query := db.Table(fullTableName) + if len(b.config.SkipConflictPKColumns) > 0 { + query = query.Clauses(b.buildSkipConflictClause()) + } else if len(b.config.UpsertPKColumns) > 0 { + query = query.Clauses(b.buildUpsertClause(records[0])) + } + result := query.CreateInBatches(records, effectiveBatchSize) + if result.Error != nil { + log.WithError(result.Error). + WithField("table", fullTableName). + WithField("recordCount", len(records)). + Error("Batch insert failed") + return result.Error + } + + if b.config.LogProgress { + log.WithField("table", fullTableName). + WithField("recordsInserted", result.RowsAffected). + Info("Batch insert completed") + } + + return nil + } + + // Fall back to individual inserts if bulk insert is disabled + totalRecords := len(records) + for i := 0; i < totalRecords; i += effectiveBatchSize { + end := i + effectiveBatchSize + if end > totalRecords { + end = totalRecords + } + + batch := records[i:end] + + err := db.Transaction(func(tx *gorm.DB) error { + for _, record := range batch { + query := tx.Table(fullTableName) + if len(b.config.SkipConflictPKColumns) > 0 { + query = query.Clauses(b.buildSkipConflictClause()) + } else if len(b.config.UpsertPKColumns) > 0 { + query = query.Clauses(b.buildUpsertClause(record)) + } + if err := query.Create(record).Error; err != nil { + if b.config.FailOnError { + return err + } + log.WithError(err). + WithField("table", fullTableName). + Warn("Failed to insert record, continuing") + } + } + return nil + }) + + if err != nil && b.config.FailOnError { + return fmt.Errorf("batch insert failed at batch %d: %w", i/effectiveBatchSize, err) + } + + if b.config.LogProgress { + log.WithField("progress", fmt.Sprintf("%d/%d", end, totalRecords)). + WithField("table", fullTableName). + Debug("Batch progress") + } + } + + return nil +} + +// ExportInBatches exports data in batches to avoid memory issues +func (b *BatchProcessor) ExportInBatches(db *gorm.DB, schema, tableName string, columns []string, writer func([]map[string]any) error) error { + fullTableName := b.plugin.FormTableName(schema, tableName) + query := db.Table(fullTableName) + if len(columns) > 0 { + query = query.Select(columns) + } + + offset := 0 + totalExported := 0 + + for { + var batch []map[string]any + + // Fetch batch + result := query.Limit(b.config.BatchSize).Offset(offset).Find(&batch) + if result.Error != nil { + return fmt.Errorf("failed to fetch batch at offset %d: %w", offset, result.Error) + } + + // No more records + if len(batch) == 0 { + break + } + + // Write batch + if err := writer(batch); err != nil { + return fmt.Errorf("failed to write batch at offset %d: %w", offset, err) + } + + totalExported += len(batch) + offset += b.config.BatchSize + + if b.config.LogProgress { + log.WithField("recordsExported", totalExported). + WithField("table", fullTableName). + Debug("Export progress") + } + + // If we got less than BatchSize, we're done + if len(batch) < b.config.BatchSize { + break + } + } + + if b.config.LogProgress { + log.WithField("table", fullTableName). + WithField("totalRecordsExported", totalExported). + Info("Export completed") + } + + return nil +} + +// BulkAddRows adds multiple rows using batch processing +func (p *GormPlugin) BulkAddRows(config *engine.PluginConfig, schema string, storageUnit string, rows [][]engine.Record) (bool, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + // Convert Records to maps + var records []map[string]any + for _, row := range rows { + record, err := p.ConvertRecordValuesToMap(row) + if err != nil { + return false, fmt.Errorf("failed to convert row: %w", err) + } + records = append(records, record) + } + + batchCfg := &BatchConfig{ + BatchSize: 1000, + UseBulkInsert: true, + FailOnError: true, + LogProgress: len(records) > 10000, // Log progress for large datasets + } + if config != nil { + batchCfg.UpsertPKColumns = config.UpsertPKColumns + batchCfg.SkipConflictPKColumns = config.SkipConflictPKColumns + } + processor := NewBatchProcessor(p.GormPluginFunctions, p.Type, batchCfg) + + err := processor.InsertBatch(db, schema, storageUnit, records) + return err == nil, err + }) +} diff --git a/dataflow/core/src/plugins/gorm/chat.go b/dataflow/core/src/plugins/gorm/chat.go new file mode 100644 index 0000000..09d8143 --- /dev/null +++ b/dataflow/core/src/plugins/gorm/chat.go @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + ctx "context" + "fmt" + "strings" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + "gorm.io/gorm" +) + +func (p *GormPlugin) Chat(config *engine.PluginConfig, schema string, previousConversation string, query string) ([]*engine.ChatMessage, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) ([]*engine.ChatMessage, error) { + // Get table names from table info query + rows, err := db.Raw(p.GetTableInfoQuery(), schema).Rows() + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to get tables for chat operation in schema: %s", schema)) + return nil, err + } + defer rows.Close() + + helper := NewMigratorHelper(db, p.GormPluginFunctions) + tableDetails := strings.Builder{} + + for rows.Next() { + tableName, _ := p.GetTableNameAndAttributes(rows) + if tableName == "" { + continue + } + + tableDetails.WriteString(fmt.Sprintf("table: %v\n", tableName)) + + // Use GORM migrator to get column types with length info (preserves column order) + fullTableName := p.FormTableName(schema, tableName) + orderedColumns, err := helper.GetOrderedColumns(fullTableName) + if err != nil { + log.WithError(err).Warnf("Failed to get column types for table %s in chat", fullTableName) + continue + } + + for _, col := range orderedColumns { + tableDetails.WriteString(fmt.Sprintf("- %v (%v)\n", col.Name, col.Type)) + } + } + + tableContext := tableDetails.String() + + // Use BAML for structured SQL query generation + callCtx := ctx.Background() + + return common.SQLChatBAML(callCtx, string(p.Type), schema, tableContext, previousConversation, query, config, p) + }) +} diff --git a/dataflow/core/src/plugins/gorm/constraint_helpers.go b/dataflow/core/src/plugins/gorm/constraint_helpers.go new file mode 100644 index 0000000..17a4f83 --- /dev/null +++ b/dataflow/core/src/plugins/gorm/constraint_helpers.go @@ -0,0 +1,422 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "regexp" + "strconv" + "strings" +) + +var ( + // Patterns for numeric comparison constraints. + // The \(? and \)? allow optional parentheses around numbers for MSSQL format like ([col]>=(0)) + gtePattern = regexp.MustCompile(`>=\s*\(?(-?\d+(?:\.\d+)?)\)?`) + gtPattern = regexp.MustCompile(`>[^=]\s*\(?(-?\d+(?:\.\d+)?)\)?|>\s*\(?(-?\d+(?:\.\d+)?)\)?$`) + ltePattern = regexp.MustCompile(`<=\s*\(?(-?\d+(?:\.\d+)?)\)?`) + ltPattern = regexp.MustCompile(`<[^=]\s*\(?(-?\d+(?:\.\d+)?)\)?|<\s*\(?(-?\d+(?:\.\d+)?)\)?$`) + betweenPattern = regexp.MustCompile(`(?i)between\s+\(?(-?\d+(?:\.\d+)?)\)?\s+and\s+\(?(-?\d+(?:\.\d+)?)\)?`) + typeCastPattern = regexp.MustCompile(`::\w+(\s+\w+)?(\[\])?`) + columnNamePattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*`) + // Pattern for OR-style equality constraints: [col]='value' OR [col]='value2' + // Matches: [column]='value' or column='value' (with single or double quotes) + orEqualityPattern = regexp.MustCompile(`(?:\[?\w+\]?)\s*=\s*(?:N)?['"]([^'"]+)['"]`) +) + +// EnsureConstraintEntry initializes a constraint map entry for a column if it doesn't exist. +func EnsureConstraintEntry(constraints map[string]map[string]any, columnName string) map[string]any { + if constraints[columnName] == nil { + constraints[columnName] = map[string]any{} + } + return constraints[columnName] +} + +// ParseINClauseValues extracts values from a SQL IN clause. +// Handles formats like: +// - column IN ('val1', 'val2', 'val3') +// - [column] IN (N'val1', N'val2') (MSSQL Unicode) +// - column IN ("val1", "val2") +// - Multi-line CHECK constraints with whitespace/newlines +func ParseINClauseValues(clause string) []string { + // Normalize whitespace (newlines, tabs, multiple spaces) to single spaces + normalizedClause := strings.Join(strings.Fields(clause), " ") + clauseLower := strings.ToLower(normalizedClause) + + inIdx := strings.Index(clauseLower, " in ") + if inIdx == -1 { + inIdx = strings.Index(clauseLower, " in(") + } + if inIdx == -1 { + return nil + } + + // Use the normalized clause for parsing + clause = normalizedClause + + startIdx := strings.Index(clause[inIdx:], "(") + if startIdx == -1 { + return nil + } + startIdx += inIdx + 1 + + depth := 1 + endIdx := -1 + for i := startIdx; i < len(clause); i++ { + switch clause[i] { + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + endIdx = i + } + } + if endIdx != -1 { + break + } + } + if endIdx == -1 || endIdx <= startIdx { + return nil + } + + content := clause[startIdx:endIdx] + + return ParseValueList(content) +} + +// ParseORClauseValues extracts values from OR equality constraints. +// Handles formats like: +// - ([status]='Pending' OR [status]='Shipped' OR [status]='Delivered') +// - (col='a' OR col='b') +// - ([col]=N'value1' OR [col]=N'value2') (MSSQL Unicode) +func ParseORClauseValues(clause string) []string { + // Check if this looks like an OR-style constraint + clauseLower := strings.ToLower(clause) + if !strings.Contains(clauseLower, " or ") { + return nil + } + + // Find all [col]='value' or col='value' patterns + matches := orEqualityPattern.FindAllStringSubmatch(clause, -1) + if len(matches) == 0 { + return nil + } + + values := make([]string, 0, len(matches)) + seen := make(map[string]bool) + for _, match := range matches { + if len(match) > 1 { + val := match[1] + if !seen[val] { + values = append(values, val) + seen[val] = true + } + } + } + + if len(values) == 0 { + return nil + } + return values +} + +// ParseValueList parses a comma-separated list of quoted values. +// Handles: +// - Single quotes: 'value' +// - Double quotes: "value" +// - MySQL charset prefix: _utf8mb4'value' +// - PostgreSQL type casts: 'value'::text +func ParseValueList(content string) []string { + var values []string + parts := strings.Split(content, ",") + + for _, part := range parts { + part = strings.TrimSpace(part) + + part = strings.TrimPrefix(part, "N") + part = strings.TrimPrefix(part, "n") + + if idx := strings.Index(part, "'"); idx > 0 && part[0] == '_' { + part = part[idx:] + } + + // Remove PostgreSQL type casts like ::text, ::varchar, ::character varying + part = typeCastPattern.ReplaceAllString(part, "") + + // Remove quotes and add to values + if len(part) >= 2 { + if (part[0] == '\'' && part[len(part)-1] == '\'') || + (part[0] == '"' && part[len(part)-1] == '"') { + values = append(values, part[1:len(part)-1]) + } + } + } + + return values +} + +// MinMaxResult holds the result of parsing min/max constraints +type MinMaxResult struct { + Min float64 + Max float64 + HasMin bool + HasMax bool +} + +// ParseMinMaxConstraints extracts min/max values from CHECK constraint clauses. +// Handles patterns like: +// - column >= 0 +// - column > 0 +// - column <= 100 +// - column < 100 +// - column BETWEEN 0 AND 100 +// - ([column]>=(0)), ([column]>(0)) +func ParseMinMaxConstraints(clause string) MinMaxResult { + result := MinMaxResult{} + clauseLower := strings.ToLower(clause) + + // Pattern for >= value + if matches := gtePattern.FindStringSubmatch(clause); len(matches) > 1 { + if val, err := strconv.ParseFloat(matches[1], 64); err == nil { + result.Min = val + result.HasMin = true + } + } else if matches := gtPattern.FindStringSubmatch(clause); len(matches) > 0 { + // Pattern for > value (exclusive, so add 1 for integers) + // GT pattern has two capture groups due to alternation; use first non-empty one + numStr := getFirstNonEmptyMatch(matches[1:]) + if numStr != "" { + if val, err := strconv.ParseFloat(numStr, 64); err == nil { + // For integers, > 0 means >= 1 + if val == float64(int64(val)) { + result.Min = val + 1 + } else { + result.Min = val + } + result.HasMin = true + } + } + } + + // Pattern for <= value + if matches := ltePattern.FindStringSubmatch(clause); len(matches) > 1 { + if val, err := strconv.ParseFloat(matches[1], 64); err == nil { + result.Max = val + result.HasMax = true + } + } else if matches := ltPattern.FindStringSubmatch(clause); len(matches) > 0 { + // Pattern for < value (exclusive, so subtract 1 for integers) + // LT pattern has two capture groups due to alternation; use first non-empty one + numStr := getFirstNonEmptyMatch(matches[1:]) + if numStr != "" && !strings.Contains(clause[strings.Index(clauseLower, "<"):], "=") { + if val, err := strconv.ParseFloat(numStr, 64); err == nil { + // For integers, < 100 means <= 99 + if val == float64(int64(val)) { + result.Max = val - 1 + } else { + result.Max = val + } + result.HasMax = true + } + } + } + + // Pattern for BETWEEN min AND max + if strings.Contains(clauseLower, "between") { + if matches := betweenPattern.FindStringSubmatch(clause); len(matches) > 2 { + if minVal, err := strconv.ParseFloat(matches[1], 64); err == nil { + result.Min = minVal + result.HasMin = true + } + if maxVal, err := strconv.ParseFloat(matches[2], 64); err == nil { + result.Max = maxVal + result.HasMax = true + } + } + } + + return result +} + +// getFirstNonEmptyMatch returns the first non-empty string from the slice. +// Used for regex patterns with alternation that have multiple capture groups. +func getFirstNonEmptyMatch(matches []string) string { + for _, m := range matches { + if m != "" { + return m + } + } + return "" +} + +// ApplyMinMaxToConstraints applies MinMaxResult values to a constraint map +func ApplyMinMaxToConstraints(constraints map[string]any, result MinMaxResult) { + if result.HasMin { + constraints["check_min"] = result.Min + } + if result.HasMax { + constraints["check_max"] = result.Max + } +} + +// SanitizeConstraintValue removes type casts and normalizes a constraint value. +// Handles PostgreSQL type casts like ::text, ::integer, etc. +func SanitizeConstraintValue(value string) string { + // Remove PostgreSQL type casts (uses pre-compiled regex) + value = typeCastPattern.ReplaceAllString(value, "") + value = strings.TrimSpace(value) + value = strings.Trim(value, "'\"") + return value +} + +// ExtractColumnNameFromClause extracts the column name from a CHECK constraint clause. +// Handles various database-specific formats: +// - PostgreSQL: (column)::text = ... or column >= 0 +// - PostgreSQL with functions: length((password)::text) >= 8 +// - MySQL: `column` IN (...) or column >= 0 +// - SQLite: column IN (...) or column >= 0 +func ExtractColumnNameFromClause(clause string) string { + // Remove backticks (MySQL) + clause = strings.ReplaceAll(clause, "`", "") + // Remove square brackets (MSSQL) + clause = strings.ReplaceAll(clause, "[", "") + clause = strings.ReplaceAll(clause, "]", "") + + // Remove outer parentheses + clause = strings.TrimSpace(clause) + clause = strings.TrimPrefix(clause, "(") + + // Check if this starts with a function call like length(...), upper(...), etc. + // Pattern: word followed by ( + firstWord := columnNamePattern.FindString(clause) + if firstWord != "" { + afterWord := strings.TrimPrefix(clause, firstWord) + afterWord = strings.TrimSpace(afterWord) + if strings.HasPrefix(afterWord, "(") { + // This is a function call - extract column from inside + col := extractColumnFromFunction(afterWord) + if col != "" { + return col + } + } + } + + // Remove type casts like ::text + if idx := strings.Index(clause, "::"); idx > 0 { + clause = clause[:idx] + } + + // Remove any trailing parenthesis + clause = strings.TrimSuffix(clause, ")") + clause = strings.TrimSpace(clause) + + // Extract the first word (column name) + match := columnNamePattern.FindString(clause) + return match +} + +// extractColumnFromFunction extracts a column name from inside a function call. +// Input like "((password)::text)" returns "password" +func extractColumnFromFunction(funcArgs string) string { + // Remove leading parentheses + funcArgs = strings.TrimLeft(funcArgs, "(") + + // Remove type casts + if idx := strings.Index(funcArgs, "::"); idx > 0 { + funcArgs = funcArgs[:idx] + } + + // Remove trailing parentheses + funcArgs = strings.TrimRight(funcArgs, ")") + funcArgs = strings.TrimSpace(funcArgs) + + // Extract column name + return columnNamePattern.FindString(funcArgs) +} + +// ============================================================================= +// Constraint Map Reading Helpers +// ============================================================================= +// These functions read values from the constraint map structure that plugins produce. +// The constraint map uses string keys like "nullable", "primary", "auto_increment", etc. + +// IsNullable returns whether the column allows NULL values. +// Defaults to true if the constraint is not set. +func IsNullable(constraint map[string]any) bool { + if constraint == nil { + return true + } + if value, ok := constraint["nullable"].(bool); ok { + return value + } + return true +} + +// IsPrimary returns whether the column is a primary key based on constraint data. +func IsPrimary(constraint map[string]any) bool { + if constraint == nil { + return false + } + if value, ok := constraint["primary"].(bool); ok { + return value + } + return false +} + +// IsAutoIncrement returns whether the column is auto-incremented. +func IsAutoIncrement(constraint map[string]any) bool { + if constraint == nil { + return false + } + if value, ok := constraint["auto_increment"].(bool); ok && value { + return true + } + return false +} + +// IsGenerated returns whether the column is a generated/computed column. +func IsGenerated(constraint map[string]any) bool { + if constraint == nil { + return false + } + if value, ok := constraint["generated"].(bool); ok && value { + return true + } + return false +} + +// HasDefault returns whether the column has a default value. +func HasDefault(constraint map[string]any) bool { + if constraint == nil { + return false + } + if value, ok := constraint["default"].(string); ok && value != "" { + return true + } + return false +} + +// GetDefault returns the default value for the column, or empty string if none. +func GetDefault(constraint map[string]any) string { + if constraint == nil { + return "" + } + if value, ok := constraint["default"].(string); ok { + return value + } + return "" +} diff --git a/dataflow/core/src/plugins/gorm/constraint_helpers_test.go b/dataflow/core/src/plugins/gorm/constraint_helpers_test.go new file mode 100644 index 0000000..ed30f1e --- /dev/null +++ b/dataflow/core/src/plugins/gorm/constraint_helpers_test.go @@ -0,0 +1,452 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "reflect" + "testing" +) + +func TestEnsureConstraintEntry(t *testing.T) { + constraints := make(map[string]map[string]any) + + // First call should create the entry + entry := EnsureConstraintEntry(constraints, "column1") + if entry == nil { + t.Fatal("expected non-nil entry") + } + entry["test"] = true + + // Second call should return existing entry + entry2 := EnsureConstraintEntry(constraints, "column1") + if entry2["test"] != true { + t.Fatal("expected to get same entry back") + } + + // Different column should get different entry + entry3 := EnsureConstraintEntry(constraints, "column2") + if entry3["test"] != nil { + t.Fatal("expected new entry for different column") + } +} + +func TestParseINClauseValues(t *testing.T) { + tests := []struct { + name string + clause string + expected []string + }{ + { + name: "simple IN clause", + clause: "status IN ('active', 'inactive', 'pending')", + expected: []string{"active", "inactive", "pending"}, + }, + { + name: "MSSQL bracketed column", + clause: "([status] IN ('active', 'inactive', 'pending'))", + expected: []string{"active", "inactive", "pending"}, + }, + { + name: "MSSQL N prefix", + clause: "([status] IN (N'active', N'inactive'))", + expected: []string{"active", "inactive"}, + }, + { + name: "double quotes", + clause: `status IN ("open", "closed")`, + expected: []string{"open", "closed"}, + }, + { + name: "no spaces after IN", + clause: "status IN('a','b','c')", + expected: []string{"a", "b", "c"}, + }, + { + name: "MySQL charset prefix", + clause: "status IN (_utf8mb4'active', _utf8mb4'inactive')", + expected: []string{"active", "inactive"}, + }, + { + name: "no IN clause", + clause: "status >= 0", + expected: nil, + }, + { + name: "empty IN clause", + clause: "status IN ()", + expected: nil, + }, + { + name: "multi-line CHECK constraint", + clause: "status\n IN\n(\n 'pending',\n 'completed',\n 'canceled'\n)", + expected: []string{"pending", "completed", "canceled"}, + }, + { + name: "tabs and spaces", + clause: "status\t IN \t('active',\t'inactive')", + expected: []string{"active", "inactive"}, + }, + { + name: "PostgreSQL type casts in IN clause", + clause: "status IN ('pending'::text, 'completed'::text, 'canceled'::text)", + expected: []string{"pending", "completed", "canceled"}, + }, + { + name: "PostgreSQL type casts with character varying", + clause: "status IN ('active'::character varying, 'inactive'::character varying)", + expected: []string{"active", "inactive"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseINClauseValues(tt.clause) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("ParseINClauseValues(%q) = %v, want %v", tt.clause, result, tt.expected) + } + }) + } +} + +func TestParseValueList(t *testing.T) { + tests := []struct { + name string + content string + expected []string + }{ + { + name: "single quotes", + content: "'a', 'b', 'c'", + expected: []string{"a", "b", "c"}, + }, + { + name: "double quotes", + content: `"x", "y"`, + expected: []string{"x", "y"}, + }, + { + name: "mixed spacing", + content: "'a','b' , 'c'", + expected: []string{"a", "b", "c"}, + }, + { + name: "empty", + content: "", + expected: nil, + }, + { + name: "PostgreSQL type casts", + content: "'pending'::text, 'completed'::text", + expected: []string{"pending", "completed"}, + }, + { + name: "PostgreSQL array type cast", + content: "'values'::text[]", + expected: []string{"values"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseValueList(tt.content) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("ParseValueList(%q) = %v, want %v", tt.content, result, tt.expected) + } + }) + } +} + +func TestParseORClauseValues(t *testing.T) { + tests := []struct { + name string + clause string + expected []string + }{ + { + name: "MSSQL OR chain with brackets", + clause: "([status]='Cancelled' OR [status]='Delivered' OR [status]='Shipped' OR [status]='Pending')", + expected: []string{"Cancelled", "Delivered", "Shipped", "Pending"}, + }, + { + name: "MSSQL OR chain with Unicode prefix", + clause: "([col]=N'value1' OR [col]=N'value2')", + expected: []string{"value1", "value2"}, + }, + { + name: "OR chain without brackets", + clause: "(status='active' OR status='inactive')", + expected: []string{"active", "inactive"}, + }, + { + name: "no OR clause", + clause: "status IN ('active', 'inactive')", + expected: nil, + }, + { + name: "empty", + clause: "", + expected: nil, + }, + { + name: "min/max constraint (not OR equality)", + clause: "([age]>=(18))", + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseORClauseValues(tt.clause) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("ParseORClauseValues(%q) = %v, want %v", tt.clause, result, tt.expected) + } + }) + } +} + +func TestParseMinMaxConstraints(t *testing.T) { + tests := []struct { + name string + clause string + expectedMin float64 + expectedMax float64 + expectedHasMin bool + expectedHasMax bool + }{ + { + name: "greater than or equal", + clause: "amount >= 0", + expectedMin: 0, + expectedHasMin: true, + }, + { + name: "greater than", + clause: "quantity > 0", + expectedMin: 1, + expectedHasMin: true, + }, + { + name: "less than or equal", + clause: "score <= 100", + expectedMax: 100, + expectedHasMax: true, + }, + { + name: "less than", + clause: "age < 120", + expectedMax: 119, + expectedHasMax: true, + }, + { + name: "between", + clause: "rating BETWEEN 1 AND 5", + expectedMin: 1, + expectedMax: 5, + expectedHasMin: true, + expectedHasMax: true, + }, + { + name: "negative numbers", + clause: "temp >= -40", + expectedMin: -40, + expectedHasMin: true, + }, + { + name: "decimal numbers", + clause: "price >= 0.01", + expectedMin: 0.01, + expectedHasMin: true, + }, + { + name: "no constraints", + clause: "status IN ('a', 'b')", + }, + // MSSQL parenthesized format tests + { + name: "MSSQL greater than or equal parenthesized", + clause: "([price]>=(0))", + expectedMin: 0, + expectedHasMin: true, + }, + { + name: "MSSQL greater than parenthesized", + clause: "([quantity]>(0))", + expectedMin: 1, + expectedHasMin: true, + }, + { + name: "MSSQL less than or equal parenthesized", + clause: "([score]<=(100))", + expectedMax: 100, + expectedHasMax: true, + }, + { + name: "MSSQL less than parenthesized", + clause: "([age]<(120))", + expectedMax: 119, + expectedHasMax: true, + }, + { + name: "MSSQL decimal in parentheses", + clause: "([price]>=(0.01))", + expectedMin: 0.01, + expectedHasMin: true, + }, + { + name: "MSSQL between parenthesized", + clause: "([rating] BETWEEN (1) AND (5))", + expectedMin: 1, + expectedMax: 5, + expectedHasMin: true, + expectedHasMax: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseMinMaxConstraints(tt.clause) + if result.HasMin != tt.expectedHasMin { + t.Errorf("HasMin = %v, want %v", result.HasMin, tt.expectedHasMin) + } + if result.HasMax != tt.expectedHasMax { + t.Errorf("HasMax = %v, want %v", result.HasMax, tt.expectedHasMax) + } + if result.HasMin && result.Min != tt.expectedMin { + t.Errorf("Min = %v, want %v", result.Min, tt.expectedMin) + } + if result.HasMax && result.Max != tt.expectedMax { + t.Errorf("Max = %v, want %v", result.Max, tt.expectedMax) + } + }) + } +} + +func TestSanitizeConstraintValue(t *testing.T) { + tests := []struct { + name string + value string + expected string + }{ + { + name: "postgres type cast", + value: "'active'::text", + expected: "active", + }, + { + name: "postgres array type cast", + value: "'values'::text[]", + expected: "values", + }, + { + name: "simple quoted", + value: "'value'", + expected: "value", + }, + { + name: "double quoted", + value: `"value"`, + expected: "value", + }, + { + name: "whitespace", + value: " 'value' ", + expected: "value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeConstraintValue(tt.value) + if result != tt.expected { + t.Errorf("SanitizeConstraintValue(%q) = %q, want %q", tt.value, result, tt.expected) + } + }) + } +} + +func TestExtractColumnNameFromClause(t *testing.T) { + tests := []struct { + name string + clause string + expected string + }{ + { + name: "simple column", + clause: "status IN ('a', 'b')", + expected: "status", + }, + { + name: "postgresql with type cast", + clause: "(status)::text = ANY (ARRAY['a'::text])", + expected: "status", + }, + { + name: "mysql backticks", + clause: "`status` IN ('a', 'b')", + expected: "status", + }, + { + name: "mssql brackets", + clause: "[status] IN ('a', 'b')", + expected: "status", + }, + { + name: "min constraint", + clause: "price >= 0", + expected: "price", + }, + { + name: "between constraint", + clause: "age BETWEEN 18 AND 120", + expected: "age", + }, + { + name: "underscore column", + clause: "order_status IN ('pending')", + expected: "order_status", + }, + { + name: "empty clause", + clause: "", + expected: "", + }, + { + name: "length function", + clause: "length((password)::text) >= 8", + expected: "password", + }, + { + name: "char_length function", + clause: "char_length(username) >= 3", + expected: "username", + }, + { + name: "upper function", + clause: "upper(status) IN ('ACTIVE', 'INACTIVE')", + expected: "status", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExtractColumnNameFromClause(tt.clause) + if result != tt.expected { + t.Errorf("ExtractColumnNameFromClause(%q) = %q, want %q", tt.clause, result, tt.expected) + } + }) + } +} diff --git a/dataflow/core/src/plugins/gorm/constraints.go b/dataflow/core/src/plugins/gorm/constraints.go new file mode 100644 index 0000000..b803cb3 --- /dev/null +++ b/dataflow/core/src/plugins/gorm/constraints.go @@ -0,0 +1,128 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/plugins" + "gorm.io/gorm" +) + +// GetColumnConstraints gets column constraints using GORM's Migrator +func (p *GormPlugin) GetColumnConstraints(config *engine.PluginConfig, schema string, storageUnit string) (map[string]map[string]any, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (map[string]map[string]any, error) { + fullTableName := p.FormTableName(schema, storageUnit) + migrator := NewMigratorHelper(db, p.GormPluginFunctions) + migratorConstraints, err := migrator.GetConstraints(fullTableName) + if err != nil { + // Fall back to empty constraints if Migrator fails + // This maintains backward compatibility + return make(map[string]map[string]any), nil + } + + constraints := make(map[string]map[string]any) + for constraintType, columns := range migratorConstraints { + for _, col := range columns { + columnName := col.Name() + if constraints[columnName] == nil { + constraints[columnName] = make(map[string]any) + } + + switch constraintType { + case "PRIMARY": + constraints[columnName]["primary"] = true + case "UNIQUE": + constraints[columnName]["unique"] = true + case "NOT_NULL": + constraints[columnName]["nullable"] = false + } + + constraints[columnName]["type"] = col.DatabaseTypeName() + if nullable, ok := col.Nullable(); ok { + constraints[columnName]["nullable"] = nullable + } + if unique, ok := col.Unique(); ok { + constraints[columnName]["unique"] = unique + } + if autoIncrement, ok := col.AutoIncrement(); ok && autoIncrement { + constraints[columnName]["auto_increment"] = true + } + if defaultValue, ok := col.DefaultValue(); ok && defaultValue != "" { + constraints[columnName]["default"] = defaultValue + } + if comment, ok := col.Comment(); ok && comment != "" { + constraints[columnName]["comment"] = comment + } + if length, ok := col.Length(); ok && length > 0 { + constraints[columnName]["length"] = length + } + if precision, scale, ok := col.DecimalSize(); ok { + constraints[columnName]["precision"] = precision + if scale > 0 { + constraints[columnName]["scale"] = scale + } + } + } + } + + // If no constraints found via Migrator, try database-specific implementation + if len(constraints) == 0 { + // Each database plugin can still override this method for custom logic + return p.getColumnConstraintsRaw(db, schema, storageUnit) + } + + return constraints, nil + }) +} + +// getColumnConstraintsRaw is a fallback for when Migrator doesn't provide constraints +func (p *GormPlugin) getColumnConstraintsRaw(db *gorm.DB, schema string, storageUnit string) (map[string]map[string]any, error) { + // Default implementation - return empty constraints + // Database-specific plugins should override this method + return make(map[string]map[string]any), nil +} + +// clearTableDataWithDB performs the actual table data clearing using the provided database connection +func (p *GormPlugin) clearTableDataWithDB(db *gorm.DB, schema string, storageUnit string) error { + builder := p.GormPluginFunctions.CreateSQLBuilder(db) + tableName := builder.BuildFullTableName(schema, storageUnit) + + // Use raw SQL with "WHERE 1=1" to delete all rows + // This works across all SQL databases and bypasses GORM's safety check + result := db.Table(tableName).Where("1=1").Delete(nil) + return result.Error +} + +// NullifyFKColumn sets a column to NULL for all rows where it is not already NULL. +// Used to break circular FK constraints during mock data overwrite. +func (p *GormPlugin) NullifyFKColumn(config *engine.PluginConfig, schema string, storageUnit string, column string) error { + _, err := plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + builder := p.GormPluginFunctions.CreateSQLBuilder(db) + tableName := builder.BuildFullTableName(schema, storageUnit) + result := db.Table(tableName).Where(column + " IS NOT NULL").Update(column, gorm.Expr("NULL")) + return result.Error == nil, result.Error + }) + return err +} + +// ClearTableData clears all data from a table +func (p *GormPlugin) ClearTableData(config *engine.PluginConfig, schema string, storageUnit string) (bool, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + err := p.clearTableDataWithDB(db, schema, storageUnit) + return err == nil, err + }) +} diff --git a/dataflow/core/src/plugins/gorm/convert_rows.go b/dataflow/core/src/plugins/gorm/convert_rows.go new file mode 100644 index 0000000..d3f88fc --- /dev/null +++ b/dataflow/core/src/plugins/gorm/convert_rows.go @@ -0,0 +1,284 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "database/sql" + "encoding/hex" + "fmt" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/dromara/carbon/v2" + "gorm.io/gorm" +) + +// ConvertRawToRows converts raw SQL rows into the engine's GetRowsResult format. +// Handles column metadata extraction (type, length, precision) and per-row value formatting +// including binary, geometry, time, and plugin-specific custom types. +func (p *GormPlugin) ConvertRawToRows(rows *sql.Rows) (*engine.GetRowsResult, error) { + columns, err := rows.Columns() + if err != nil { + log.WithError(err).Error("Failed to get column names from result set") + return nil, err + } + + columnTypes, err := rows.ColumnTypes() + if err != nil { + log.WithError(err).Error("Failed to get column types from result set") + return nil, err + } + + // Create a map for faster column type lookup + typeMap := make(map[string]*sql.ColumnType, len(columnTypes)) + for _, colType := range columnTypes { + typeMap[colType.Name()] = colType + } + + result := &engine.GetRowsResult{ + Columns: make([]engine.Column, 0, len(columns)), + Rows: make([][]string, 0, 100), + } + + for _, col := range columns { + if colType, exists := typeMap[col]; exists { + colTypeName := colType.DatabaseTypeName() + // Let plugins handle array type display (e.g., PostgreSQL _int4 -> []int4) + if p.GormPluginFunctions.IsArrayType(colTypeName) { + colTypeName = strings.Replace(colTypeName, "_", "[]", 1) + } + if customName := p.GormPluginFunctions.GetCustomColumnTypeName(col, colTypeName); customName != "" { + colTypeName = customName + } + + column := engine.Column{Name: col, Type: colTypeName} + baseTypeName := strings.ToUpper(colType.DatabaseTypeName()) + + // Only extract length for types where it's user-specifiable + if typesWithLength[baseTypeName] { + if length, ok := colType.Length(); ok && length > 0 { + l := int(length) + column.Length = &l + // Include length in type name for display + colTypeName = fmt.Sprintf("%s(%d)", colTypeName, length) + column.Type = colTypeName + } + } + + // Only extract precision/scale for decimal-like types + if typesWithPrecision[baseTypeName] { + if precision, scale, ok := colType.DecimalSize(); ok && precision > 0 { + prec := int(precision) + column.Precision = &prec + if scale > 0 { + s := int(scale) + column.Scale = &s + colTypeName = fmt.Sprintf("%s(%d,%d)", colType.DatabaseTypeName(), precision, scale) + } else { + colTypeName = fmt.Sprintf("%s(%d)", colType.DatabaseTypeName(), precision) + } + column.Type = colTypeName + } + } + + result.Columns = append(result.Columns, column) + } + } + + for rows.Next() { + columnPointers := make([]any, len(columns)) + row := make([]string, len(columns)) + + for i, col := range columns { + colType := typeMap[col] + typeName := colType.DatabaseTypeName() + + if p.GormPluginFunctions.ShouldHandleColumnType(typeName) { + columnPointers[i] = p.GormPluginFunctions.GetColumnScanner(typeName) + } else { + switch typeName { + case "VARBINARY", "BINARY", "IMAGE", "BYTEA", "BLOB", "HIERARCHYID", + "GEOMETRY", "POINT", "LINESTRING", "POLYGON", "GEOGRAPHY", + "MULTIPOINT", "MULTILINESTRING", "MULTIPOLYGON": + columnPointers[i] = new(sql.RawBytes) + default: + columnPointers[i] = new(sql.NullString) + } + } + } + + if err := rows.Scan(columnPointers...); err != nil { + log.WithError(err).Error("Failed to scan row data") + return nil, err + } + + for i, colPtr := range columnPointers { + colType := typeMap[columns[i]] + typeName := colType.DatabaseTypeName() + + if p.GormPluginFunctions.ShouldHandleColumnType(typeName) { + value, err := p.GormPluginFunctions.FormatColumnValue(typeName, colPtr) + if err != nil { + row[i] = "ERROR: " + err.Error() + } else { + row[i] = value + } + } else { + switch typeName { + case "VARBINARY", "BINARY", "IMAGE", "BYTEA", "BLOB": + rawBytes := colPtr.(*sql.RawBytes) + if rawBytes == nil || len(*rawBytes) == 0 { + row[i] = "" + } else { + row[i] = "0x" + hex.EncodeToString(*rawBytes) + } + case "GEOMETRY", "POINT", "LINESTRING", "POLYGON", "GEOGRAPHY", + "MULTIPOINT", "MULTILINESTRING", "MULTIPOLYGON": + rawBytes := colPtr.(*sql.RawBytes) + if rawBytes == nil || len(*rawBytes) == 0 { + row[i] = "" + } else if formatted := p.GormPluginFunctions.FormatGeometryValue(*rawBytes, typeName); formatted != "" { + row[i] = formatted + } else { + row[i] = "0x" + hex.EncodeToString(*rawBytes) + } + case "TIME": + // TIME columns are returned as full datetime strings with zero date (e.g., "0001-01-01T12:00:00Z") + // Extract just the time portion for display + val := colPtr.(*sql.NullString) + if val.Valid { + row[i] = formatTimeOnly(val.String) + } else { + row[i] = "" + } + default: + val := colPtr.(*sql.NullString) + if val.Valid { + row[i] = val.String + } else { + row[i] = "" + } + } + } + } + + result.Rows = append(result.Rows, row) + } + + result.TotalCount = int64(len(result.Rows)) + return result, nil +} + +// FindMissingDataType resolves unknown column types by querying system catalogs. +func (p *GormPlugin) FindMissingDataType(db *gorm.DB, columnType string) string { + if p.Type == engine.DatabaseType_Postgres { + var typname string + if err := db.Table("pg_type"). + Select("typname"). + Where("oid = ?", columnType). + Scan(&typname).Error; err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to find PostgreSQL type name for OID: %s", columnType)) + typname = columnType + } + return strings.ToUpper(typname) + } + return columnType +} + +// GetRowsOrderBy returns the ORDER BY clause for pagination queries. +// Default implementation returns empty string (no ordering). +func (p *GormPlugin) GetRowsOrderBy(db *gorm.DB, schema string, storageUnit string) string { + return "" +} + +// ShouldHandleColumnType returns false by default. +func (p *GormPlugin) ShouldHandleColumnType(columnType string) bool { + return false +} + +// GetColumnScanner returns nil by default. +func (p *GormPlugin) GetColumnScanner(columnType string) any { + return nil +} + +// FormatColumnValue returns empty string by default. +func (p *GormPlugin) FormatColumnValue(columnType string, scanner any) (string, error) { + return "", nil +} + +// GetCustomColumnTypeName returns empty string by default. +func (p *GormPlugin) GetCustomColumnTypeName(columnName string, defaultTypeName string) string { + return "" +} + +// IsGeometryType returns true for common geometry type names. +func (p *GormPlugin) IsGeometryType(columnType string) bool { + switch columnType { + case "GEOMETRY", "POINT", "LINESTRING", "POLYGON", "GEOGRAPHY", + "MULTIPOINT", "MULTILINESTRING", "MULTIPOLYGON", "GEOMETRYCOLLECTION": + return true + default: + return false + } +} + +// FormatGeometryValue returns empty string by default (use hex formatting). +func (p *GormPlugin) FormatGeometryValue(rawBytes []byte, columnType string) string { + return "" +} + +// formatTimeOnly extracts just the time portion from a datetime string. +// Database drivers return TIME columns as full datetime with zero date (e.g., "0001-01-01T12:00:00Z"). +// This function extracts just the time portion for cleaner display. +func formatTimeOnly(value string) string { + c := carbon.Parse(value) + if c.Error != nil || c.IsInvalid() { + // If carbon can't parse it, return as-is + return value + } + + // Check if it has sub-second precision + if c.Nanosecond() > 0 { + return c.ToTimeMilliString() + } + return c.ToTimeString() +} + +// HandleCustomDataType returns false by default (no custom handling). +func (p *GormPlugin) HandleCustomDataType(value string, columnType string, isNullable bool) (any, bool, error) { + return nil, false, nil +} + +// IsArrayType returns false by default. +// PostgreSQL overrides this to detect underscore-prefixed array types. +func (p *GormPlugin) IsArrayType(columnType string) bool { + return false +} + +// ResolveGraphSchema returns the schema parameter unchanged by default. +// ClickHouse overrides this to return the database name. +func (p *GormPlugin) ResolveGraphSchema(config *engine.PluginConfig, schema string) string { + return schema +} + +// ShouldCheckRowsAffected returns true by default. +// ClickHouse overrides this to return false since its GORM driver +// doesn't report affected rows for mutations. +func (p *GormPlugin) ShouldCheckRowsAffected() bool { + return true +} diff --git a/dataflow/core/src/plugins/gorm/db.go b/dataflow/core/src/plugins/gorm/db.go new file mode 100644 index 0000000..68e4f38 --- /dev/null +++ b/dataflow/core/src/plugins/gorm/db.go @@ -0,0 +1,169 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "context" + "fmt" + "net/url" + "strconv" + "time" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + "github.com/clidey/whodb/core/src/plugins/ssl" + "gorm.io/gorm" +) + +const ( + portKey = "Port" + parseTimeKey = "Parse Time" + locKey = "Loc" + allowClearTextPasswordsKey = "Allow clear text passwords" + httpProtocolKey = "HTTP Protocol" + readOnlyKey = "Readonly" + debugKey = "Debug" + connectionTimeoutKey = "Connection Timeout" +) + +type ConnectionInput struct { + //common + Username string `validate:"required"` + Password string `validate:"required"` + Database string `validate:"required"` + Hostname string `validate:"required"` + Port int `validate:"required"` + + //mysql/mariadb + ParseTime bool `validate:"boolean"` + Loc *time.Location `validate:"required"` + AllowClearTextPasswords bool `validate:"boolean"` + + //clickhouse + HTTPProtocol string + ReadOnly string + Debug string + + ConnectionTimeout int + + SSLConfig *ssl.SSLConfig + + ExtraOptions map[string]string `validate:"omitnil"` +} + +func (p *GormPlugin) ParseConnectionConfig(config *engine.PluginConfig) (*ConnectionInput, error) { + //common + defaultPort, ok := plugins.GetDefaultPort(p.Type) + if !ok { + return nil, fmt.Errorf("unsupported database type: %v", p.Type) + } + port, err := strconv.Atoi(common.GetRecordValueOrDefault(config.Credentials.Advanced, portKey, defaultPort)) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to parse port for database type %s", p.Type)) + return nil, err + } + + //mysql/mariadb specific + parseTime, err := strconv.ParseBool(common.GetRecordValueOrDefault(config.Credentials.Advanced, parseTimeKey, "True")) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to parse parseTime setting for database type %s", p.Type)) + return nil, err + } + loc, err := time.LoadLocation(common.GetRecordValueOrDefault(config.Credentials.Advanced, locKey, "UTC")) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to load time location for database type %s", p.Type)) + return nil, err + } + allowClearTextPasswords, err := strconv.ParseBool(common.GetRecordValueOrDefault(config.Credentials.Advanced, allowClearTextPasswordsKey, "0")) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to parse allowClearTextPasswords setting for database type %s", p.Type)) + return nil, err + } + + //clickhouse specific + httpProtocol := common.GetRecordValueOrDefault(config.Credentials.Advanced, httpProtocolKey, "disable") + readOnly := common.GetRecordValueOrDefault(config.Credentials.Advanced, readOnlyKey, "disable") + debug := common.GetRecordValueOrDefault(config.Credentials.Advanced, debugKey, "disable") + + sslConfig := ssl.ParseSSLConfig(p.Type, config.Credentials.Advanced, config.Credentials.Hostname, config.Credentials.IsProfile) + + connectionTimeout, err := strconv.Atoi(common.GetRecordValueOrDefault(config.Credentials.Advanced, connectionTimeoutKey, "90")) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to parse connection timeout for database type %s", p.Type)) + return nil, err + } + + database := config.Credentials.Database + username := config.Credentials.Username + password := config.Credentials.Password + hostname := config.Credentials.Hostname + + input := &ConnectionInput{ + Username: username, + Password: password, + Database: database, + Hostname: hostname, + Port: port, + ParseTime: parseTime, + Loc: loc, + AllowClearTextPasswords: allowClearTextPasswords, + HTTPProtocol: httpProtocol, + ReadOnly: readOnly, + Debug: debug, + ConnectionTimeout: connectionTimeout, + SSLConfig: sslConfig, + } + + // if this config is a pre-configured profile, then allow reading of additional params + if config.Credentials.IsProfile { + params := make(map[string]string) + for _, record := range config.Credentials.Advanced { + switch record.Key { + // Skip known keys that are already parsed + skip SSL keys + case portKey, parseTimeKey, locKey, allowClearTextPasswordsKey, httpProtocolKey, readOnlyKey, debugKey, connectionTimeoutKey, + ssl.KeySSLMode, ssl.KeySSLCACertContent, ssl.KeySSLClientCertContent, ssl.KeySSLClientKeyContent, + ssl.KeySSLCACertPath, ssl.KeySSLClientCertPath, ssl.KeySSLClientKeyPath, ssl.KeySSLServerName: + continue + default: + // PostgreSQL doesn't need URL escaping for params + if p.Type == engine.DatabaseType_Postgres { + params[record.Key] = record.Value + } else { + params[record.Key] = url.QueryEscape(record.Value) + } + } + } + input.ExtraOptions = params + } + + return input, nil +} + +func (p *GormPlugin) IsAvailable(ctx context.Context, config *engine.PluginConfig) bool { + // WithConnection calls getOrCreateConnection which already pings to validate the connection + available, err := plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + return true, nil + }) + + if err != nil { + return false + } + + return available +} diff --git a/dataflow/core/src/plugins/gorm/delete.go b/dataflow/core/src/plugins/gorm/delete.go new file mode 100644 index 0000000..ec530a5 --- /dev/null +++ b/dataflow/core/src/plugins/gorm/delete.go @@ -0,0 +1,97 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "errors" + "fmt" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + "gorm.io/gorm" + "slices" +) + +func (p *GormPlugin) DeleteRow(config *engine.PluginConfig, schema string, storageUnit string, values map[string]string) (bool, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + pkColumns, err := p.GormPluginFunctions.GetPrimaryKeyColumns(db, schema, storageUnit) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to get primary key columns for table %s.%s during delete operation", schema, storageUnit)) + pkColumns = []string{} + } + + columnTypeInfos, err := p.GormPluginFunctions.GetColumnTypes(db, schema, storageUnit) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to get column types for table %s.%s during delete operation", schema, storageUnit)) + return false, err + } + + conditions := make(map[string]any) + convertedValues := make(map[string]any) + hasPKs := len(pkColumns) > 0 + + for column, strValue := range values { + isPK := slices.Contains(pkColumns, column) + + // Only convert columns we need for the WHERE clause: + // - PK columns if PKs exist + // - All columns if no PKs (fallback to identify row) + if hasPKs && !isPK { + continue + } + + colInfo, exists := columnTypeInfos[column] + if !exists { + return false, fmt.Errorf("column '%s' does not exist in table %s", column, storageUnit) + } + + convertedValue, err := p.GormPluginFunctions.ConvertStringValue(strValue, colInfo.Type, colInfo.IsNullable) + if err != nil { + return false, fmt.Errorf("failed to convert value for column '%s' in table %s.%s: %w", column, schema, storageUnit, err) + } + + // GORM handles identifier escaping automatically + if isPK { + conditions[column] = convertedValue + } else { + convertedValues[column] = convertedValue + } + } + + builder := p.GormPluginFunctions.CreateSQLBuilder(db) + + var whereConditions map[string]any + if len(conditions) == 0 { + whereConditions = convertedValues + } else { + whereConditions = conditions + } + + result := builder.DeleteQuery(schema, storageUnit, whereConditions) + + if result.Error != nil { + log.WithError(result.Error).Error(fmt.Sprintf("Failed to delete rows from table %s.%s", schema, storageUnit)) + return false, result.Error + } + + if p.GormPluginFunctions.ShouldCheckRowsAffected() && result.RowsAffected == 0 { + return false, errors.New("no rows were deleted") + } + + return true, nil + }) +} diff --git a/dataflow/core/src/plugins/gorm/errors.go b/dataflow/core/src/plugins/gorm/errors.go new file mode 100644 index 0000000..ef6832e --- /dev/null +++ b/dataflow/core/src/plugins/gorm/errors.go @@ -0,0 +1,224 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/clidey/whodb/core/src/log" + "gorm.io/gorm" +) + +// ErrorHandler provides centralized error handling for GORM operations +type ErrorHandler struct { + plugin GormPluginFunctions +} + +// NewErrorHandler creates a new error handler +func NewErrorHandler(plugin GormPluginFunctions) *ErrorHandler { + return &ErrorHandler{plugin: plugin} +} + +// HandleError processes GORM errors and returns user-friendly messages +func (h *ErrorHandler) HandleError(err error, operation string, details map[string]any) error { + if err == nil { + return nil + } + + // Log the original error with context + logger := log.WithError(err).WithField("operation", operation) + for k, v := range details { + logger = logger.WithField(k, v) + } + + // Handle specific GORM errors + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + logger.Debug("Record not found") + return fmt.Errorf("record not found") + + case errors.Is(err, gorm.ErrInvalidTransaction): + logger.Error("Invalid transaction") + return fmt.Errorf("transaction error: please retry the operation") + + case errors.Is(err, gorm.ErrNotImplemented): + logger.Error("Feature not implemented") + return fmt.Errorf("this feature is not supported for the current database") + + case errors.Is(err, gorm.ErrMissingWhereClause): + logger.Warn("Missing WHERE clause in destructive operation") + return fmt.Errorf("WHERE clause required for this operation") + + case errors.Is(err, gorm.ErrUnsupportedRelation): + logger.Error("Unsupported relation") + return fmt.Errorf("relationship operation not supported") + + case errors.Is(err, gorm.ErrPrimaryKeyRequired): + logger.Error("Primary key required") + return fmt.Errorf("primary key is required for this operation") + + case errors.Is(err, gorm.ErrModelValueRequired): + logger.Error("Model value required") + return fmt.Errorf("value is required for this operation") + + case errors.Is(err, gorm.ErrUnsupportedDriver): + logger.Error("Unsupported database driver") + return fmt.Errorf("database driver not supported") + + case h.isDuplicateKeyError(err): + logger.Warn("Duplicate key violation") + return fmt.Errorf("duplicate key: a record with these values already exists") + + case h.isForeignKeyError(err): + logger.Warn("Foreign key constraint violation") + return fmt.Errorf("foreign key constraint: referenced record does not exist or is in use") + + case h.isCheckConstraintError(err): + logger.Warn("Check constraint violation") + return fmt.Errorf("check constraint violation: value does not meet requirements") + + case h.isNotNullError(err): + logger.Warn("NOT NULL constraint violation") + return fmt.Errorf("required field cannot be empty") + + case h.isConnectionError(err): + logger.Error("Database connection error") + return fmt.Errorf("database connection error: please check your connection settings") + + case h.isTimeoutError(err): + logger.Error("Operation timeout") + return fmt.Errorf("operation timed out: the database took too long to respond") + + case h.isPermissionError(err): + logger.Error("Permission denied") + return fmt.Errorf("permission denied: insufficient privileges for this operation") + + default: + // Log full error for debugging but return sanitized message + logger.Error("Unhandled database error") + return fmt.Errorf("database operation failed: %s", h.sanitizeErrorMessage(err)) + } +} + +// isDuplicateKeyError checks if error is a duplicate key violation +func (h *ErrorHandler) isDuplicateKeyError(err error) bool { + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "duplicate") || + strings.Contains(errStr, "unique constraint") || + strings.Contains(errStr, "unique_violation") || + strings.Contains(errStr, "23505") // PostgreSQL error code +} + +// isForeignKeyError checks if error is a foreign key violation +func (h *ErrorHandler) isForeignKeyError(err error) bool { + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "foreign key") || + strings.Contains(errStr, "fk_") || + strings.Contains(errStr, "23503") || // PostgreSQL error code + strings.Contains(errStr, "1452") // MySQL error code +} + +// isCheckConstraintError checks if error is a check constraint violation +func (h *ErrorHandler) isCheckConstraintError(err error) bool { + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "check constraint") || + strings.Contains(errStr, "chk_") || + strings.Contains(errStr, "23514") // PostgreSQL error code +} + +// isNotNullError checks if error is a NOT NULL violation +func (h *ErrorHandler) isNotNullError(err error) bool { + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "not null") || + strings.Contains(errStr, "cannot be null") || + strings.Contains(errStr, "23502") || // PostgreSQL error code + strings.Contains(errStr, "1048") // MySQL error code +} + +// isConnectionError checks if error is connection-related +func (h *ErrorHandler) isConnectionError(err error) bool { + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "connection") || + strings.Contains(errStr, "connect") || + strings.Contains(errStr, "refused") || + strings.Contains(errStr, "closed") || + strings.Contains(errStr, "dial") || + strings.Contains(errStr, "no such host") +} + +// isTimeoutError checks if error is timeout-related +func (h *ErrorHandler) isTimeoutError(err error) bool { + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "timeout") || + strings.Contains(errStr, "deadline") || + strings.Contains(errStr, "context canceled") +} + +// isPermissionError checks if error is permission-related +func (h *ErrorHandler) isPermissionError(err error) bool { + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "permission") || + strings.Contains(errStr, "denied") || + strings.Contains(errStr, "privilege") || + strings.Contains(errStr, "access denied") || + strings.Contains(errStr, "1044") || // MySQL error code + strings.Contains(errStr, "1045") || // MySQL error code + strings.Contains(errStr, "42501") // PostgreSQL error code +} + +// sanitizePattern pairs a pre-compiled regex with its replacement string. +type sanitizePattern struct { + re *regexp.Regexp + replacement string +} + +// sanitizePatterns are pre-compiled patterns for removing sensitive information +// from error messages. These patterns are intentionally broad to avoid leaking +// credentials from driver/SDK error strings (DSNs, query params, etc.). +var sanitizePatterns = []sanitizePattern{ + {regexp.MustCompile(`(?i)\bpassword=\S+`), "[REDACTED]"}, + {regexp.MustCompile(`(?i)\bpasswd=\S+`), "[REDACTED]"}, + {regexp.MustCompile(`(?i)\bpwd=\S+`), "[REDACTED]"}, + {regexp.MustCompile(`(?i)\btoken=\S+`), "[REDACTED]"}, + {regexp.MustCompile(`(?i)\bapi_key=\S+`), "[REDACTED]"}, + {regexp.MustCompile(`(?i)\baccess_key_id=\S+`), "[REDACTED]"}, + {regexp.MustCompile(`(?i)\bsecret_access_key=\S+`), "[REDACTED]"}, + {regexp.MustCompile(`(?i)\bsession_token=\S+`), "[REDACTED]"}, + {regexp.MustCompile(`(?i)\bkey=\S+`), "[REDACTED]"}, + {regexp.MustCompile(`(?i)\bsecret=\S+`), "[REDACTED]"}, + {regexp.MustCompile(`(?i)://[^/\s]+@`), "://[REDACTED]@"}, + {regexp.MustCompile(`@[\w.\-]+:\d+`), "[REDACTED]"}, +} + +// sanitizeErrorMessage removes sensitive information from error messages +func (h *ErrorHandler) sanitizeErrorMessage(err error) string { + msg := err.Error() + + for _, p := range sanitizePatterns { + msg = p.re.ReplaceAllString(msg, p.replacement) + } + + // Truncate very long error messages + if len(msg) > 500 { + msg = msg[:500] + "..." + } + + return msg +} diff --git a/dataflow/core/src/plugins/gorm/errors_test.go b/dataflow/core/src/plugins/gorm/errors_test.go new file mode 100644 index 0000000..323c3db --- /dev/null +++ b/dataflow/core/src/plugins/gorm/errors_test.go @@ -0,0 +1,109 @@ +package gorm_plugin + +import ( + "errors" + "strings" + "testing" + + "gorm.io/gorm" +) + +func TestErrorHandlerHandleErrorMapsCommonCases(t *testing.T) { + h := NewErrorHandler(nil) + + cases := []struct { + name string + err error + expected string + }{ + { + name: "duplicate key", + err: errors.New("duplicate key value violates unique constraint \"users_email_key\""), + expected: "duplicate key: a record with these values already exists", + }, + { + name: "foreign key", + err: errors.New("violates foreign key constraint"), + expected: "foreign key constraint: referenced record does not exist or is in use", + }, + { + name: "check constraint", + err: errors.New("check constraint violation"), + expected: "check constraint violation: value does not meet requirements", + }, + { + name: "not null", + err: errors.New("cannot be null"), + expected: "required field cannot be empty", + }, + { + name: "connection error", + err: errors.New("dial tcp: no such host"), + expected: "database connection error: please check your connection settings", + }, + { + name: "timeout", + err: errors.New("context deadline exceeded"), + expected: "operation timed out: the database took too long to respond", + }, + { + name: "permission", + err: errors.New("permission denied"), + expected: "permission denied: insufficient privileges for this operation", + }, + { + name: "missing where clause", + err: gorm.ErrMissingWhereClause, + expected: "WHERE clause required for this operation", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := h.HandleError(tc.err, "op", map[string]any{"k": "v"}) + if got == nil { + t.Fatalf("expected error, got nil") + } + if got.Error() != tc.expected { + t.Fatalf("expected %q, got %q", tc.expected, got.Error()) + } + }) + } +} + +func TestErrorHandlerSanitizeErrorMessageRedactsSecrets(t *testing.T) { + h := NewErrorHandler(nil) + + raw := strings.Join([]string{ + "connect failed:", + "password=supersecret", + "token=abcd", + "api_key=key123", + "postgres://user:pass@localhost:5432/db", + }, " ") + + sanitized := h.sanitizeErrorMessage(errors.New(raw)) + + for _, leaked := range []string{"supersecret", "abcd", "key123", "user:pass", "@localhost:5432"} { + if strings.Contains(sanitized, leaked) { + t.Fatalf("expected sanitized message to redact %q, got %q", leaked, sanitized) + } + } + if !strings.Contains(sanitized, "[REDACTED]") { + t.Fatalf("expected sanitized message to contain redaction marker, got %q", sanitized) + } +} + +func TestErrorHandlerSanitizeErrorMessageTruncatesLongStrings(t *testing.T) { + h := NewErrorHandler(nil) + + raw := strings.Repeat("a", 600) + sanitized := h.sanitizeErrorMessage(errors.New(raw)) + + if len(sanitized) != 503 { + t.Fatalf("expected 503 chars (500 + ...), got %d", len(sanitized)) + } + if !strings.HasSuffix(sanitized, "...") { + t.Fatalf("expected suffix ..., got %q", sanitized[len(sanitized)-10:]) + } +} diff --git a/dataflow/core/src/plugins/gorm/export.go b/dataflow/core/src/plugins/gorm/export.go new file mode 100644 index 0000000..59cc256 --- /dev/null +++ b/dataflow/core/src/plugins/gorm/export.go @@ -0,0 +1,192 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "database/sql" + "fmt" + "time" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" +) + +// ExportData exports data to tabular format (CSV/Excel). If selectedRows is nil/empty, exports all rows from the table. +func (p *GormPlugin) ExportData(config *engine.PluginConfig, schema string, storageUnit string, writer func([]string) error, selectedRows []map[string]any) error { + // If selected rows are provided, export only those + if len(selectedRows) > 0 { + // Extract column names from the first row + columns := make([]string, 0, len(selectedRows[0])) + for col := range selectedRows[0] { + columns = append(columns, col) + } + + // Write header row + if err := writer(columns); err != nil { + log.WithError(err).Error("Failed to write CSV headers for selected rows export") + return fmt.Errorf("failed to write headers: %v", err) + } + + // Write selected rows + for i, row := range selectedRows { + rowData := make([]string, len(columns)) + for j, col := range columns { + if val, ok := row[col]; ok { + rowData[j] = p.FormatValue(val) + } else { + rowData[j] = "" + } + } + if err := writer(rowData); err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to write selected row %d during export", i+1)) + return fmt.Errorf("failed to write row %d: %v", i+1, err) + } + } + + return nil + } + + // Export all rows from the database + db, err := p.DB(config) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to connect to database for export of table %s.%s", schema, storageUnit)) + return err + } + + // Get ordered columns using GORM migrator (preserves column order) + helper := NewMigratorHelper(db, p.GormPluginFunctions) + fullTableName := p.FormTableName(schema, storageUnit) + orderedColumns, err := helper.GetOrderedColumns(fullTableName) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to get columns for export of table %s.%s", schema, storageUnit)) + return fmt.Errorf("failed to get columns: %v", err) + } + + if len(orderedColumns) == 0 { + return fmt.Errorf("no columns found for table %s.%s", schema, storageUnit) + } + + // Convert to separate arrays for columns and types + columns := make([]string, len(orderedColumns)) + columnTypes := make([]string, len(orderedColumns)) + for i, col := range orderedColumns { + columns[i] = col.Name + columnTypes[i] = col.Type + } + + // Write headers with type information + headers := make([]string, len(columns)) + for i, col := range columns { + headers[i] = common.FormatCSVHeader(col, columnTypes[i]) + } + if err := writer(headers); err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to write CSV headers for table %s.%s export", schema, storageUnit)) + return fmt.Errorf("failed to write headers: %v", err) + } + + // Use batch processor for efficient export + processor := NewBatchProcessor(p, p.Type, &BatchConfig{ + BatchSize: 10000, // Larger batch size for exports + LogProgress: true, // Log export progress + }) + + // Export data in batches to avoid memory issues + totalRows := 0 + err = processor.ExportInBatches(db, schema, storageUnit, columns, func(batch []map[string]any) error { + // Process each batch + for _, record := range batch { + row := make([]string, len(columns)) + for i, col := range columns { + if val, exists := record[col]; exists { + row[i] = p.FormatValue(val) + } else { + row[i] = "" + } + } + + if err := writer(row); err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to write row %d during export of table %s.%s", totalRows+1, schema, storageUnit)) + return fmt.Errorf("failed to write row %d: %v", totalRows+1, err) + } + totalRows++ + } + return nil + }) + + if err != nil { + return fmt.Errorf("export failed after %d rows: %v", totalRows, err) + } + + log.WithField("totalRows", totalRows). + WithField("table", fmt.Sprintf("%s.%s", schema, storageUnit)). + Info("Export completed successfully") + + return nil +} + +// FormatValue converts any values to strings appropriately for CSV +func (p *GormPlugin) FormatValue(val any) string { + if val == nil { + return "" + } + + switch v := val.(type) { + case []byte: + return common.EscapeFormula(string(v)) + case string: + return common.EscapeFormula(v) + case time.Time: + // Format time in ISO 8601 format that can be parsed back + if v.Hour() == 0 && v.Minute() == 0 && v.Second() == 0 && v.Nanosecond() == 0 { + // Date only + return common.EscapeFormula(v.Format("2006-01-02")) + } + // Full datetime + return common.EscapeFormula(v.Format("2006-01-02T15:04:05")) + case *time.Time: + if v == nil { + return "" + } + // Format time in ISO 8601 format that can be parsed back + if v.Hour() == 0 && v.Minute() == 0 && v.Second() == 0 && v.Nanosecond() == 0 { + // Date only + return common.EscapeFormula(v.Format("2006-01-02")) + } + // Full datetime + return common.EscapeFormula(v.Format("2006-01-02T15:04:05")) + case sql.NullTime: + if !v.Valid { + return "" + } + // Format time in ISO 8601 format that can be parsed back + if v.Time.Hour() == 0 && v.Time.Minute() == 0 && v.Time.Second() == 0 && v.Time.Nanosecond() == 0 { + // Date only + return common.EscapeFormula(v.Time.Format("2006-01-02")) + } + // Full datetime + return common.EscapeFormula(v.Time.Format("2006-01-02T15:04:05")) + default: + return common.EscapeFormula(fmt.Sprintf("%v", v)) + } +} + +// GetPlaceholder returns the placeholder for prepared statements +// Override this in database-specific implementations +func (p *GormPlugin) GetPlaceholder(index int) string { + return "?" +} diff --git a/dataflow/core/src/plugins/gorm/export_sql.go b/dataflow/core/src/plugins/gorm/export_sql.go new file mode 100644 index 0000000..36b289f --- /dev/null +++ b/dataflow/core/src/plugins/gorm/export_sql.go @@ -0,0 +1,234 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "database/sql" + "fmt" + "io" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/plugins" + "github.com/clidey/whodb/core/src/sqlexport" + "gorm.io/gorm" +) + +// ExportSQLData streams SQL Data Export statements for a SQL Table. +func (p *GormPlugin) ExportSQLData(config *engine.PluginConfig, req *engine.SQLDataExportRequest, writer io.Writer) error { + if req == nil { + return fmt.Errorf("missing SQL data export request") + } + + _, err := plugins.WithConnection(config, p.GormPluginFunctions.DB, func(db *gorm.DB) (bool, error) { + columns, err := p.GormPluginFunctions.GetColumnsForTable(config, req.Schema, req.StorageUnit) + if err != nil { + return false, fmt.Errorf("failed to get columns for SQL data export: %w", err) + } + if err := p.GormPluginFunctions.MarkGeneratedColumns(config, req.Schema, req.StorageUnit, columns); err != nil { + return false, fmt.Errorf("failed to inspect generated columns for SQL data export: %w", err) + } + + insertColumns, setColumns, primaryKeyColumns, rowColumns, err := selectSQLDataExportColumns(req.Mode, columns) + if err != nil { + return false, err + } + + query, err := p.buildSQLDataExportQuery(db, req, rowColumns) + if err != nil { + return false, err + } + + rows, err := query.Rows() + if err != nil { + return false, fmt.Errorf("failed to read rows for SQL data export: %w", err) + } + defer rows.Close() + + dialect := sqlexport.GenericDialect{ + QuoteIdentifierFunc: p.GormPluginFunctions.CreateSQLBuilder(db).QuoteIdentifier, + } + table := sqlexport.Table{Schema: req.Schema, Name: req.StorageUnit} + + switch req.Mode { + case engine.SQLDataExportModeInsert: + err = streamInsertSQLDataExport(writer, dialect, table, insertColumns, rowColumns, rows) + case engine.SQLDataExportModeUpdate: + err = streamUpdateSQLDataExport(writer, dialect, table, setColumns, primaryKeyColumns, rowColumns, rows) + default: + err = fmt.Errorf("unsupported SQL data export mode: %s", req.Mode) + } + if err != nil { + return false, err + } + if err := rows.Err(); err != nil { + return false, fmt.Errorf("failed to scan rows for SQL data export: %w", err) + } + return true, nil + }) + return err +} + +func (p *GormPlugin) buildSQLDataExportQuery(db *gorm.DB, req *engine.SQLDataExportRequest, columns []engine.Column) (*gorm.DB, error) { + if req.Limit != nil && *req.Limit < 0 { + return nil, fmt.Errorf("row limit must not be negative") + } + + var columnTypes map[string]ColumnTypeInfo + if req.Where != nil { + var err error + columnTypes, err = p.GormPluginFunctions.GetColumnTypes(db, req.Schema, req.StorageUnit) + if err != nil { + return nil, fmt.Errorf("failed to get column types for SQL data export filters: %w", err) + } + } + + builder := p.GormPluginFunctions.CreateSQLBuilder(db) + fullTable := builder.BuildFullTableName(req.Schema, req.StorageUnit) + query := db.Table(fullTable).Select(columnNames(columns)) + + var err error + query, err = p.ApplyWhereConditions(query, req.Where, columnTypes) + if err != nil { + return nil, err + } + + if len(req.Sort) > 0 { + query = builder.BuildOrderBy(query, toPluginSort(req.Sort)) + } + + if req.Limit != nil { + query = query.Limit(*req.Limit) + } + + return query, nil +} + +func selectSQLDataExportColumns(mode engine.SQLDataExportMode, columns []engine.Column) ([]engine.Column, []engine.Column, []engine.Column, []engine.Column, error) { + insertColumns := make([]engine.Column, 0, len(columns)) + setColumns := make([]engine.Column, 0, len(columns)) + primaryKeyColumns := make([]engine.Column, 0) + rowColumns := make([]engine.Column, 0, len(columns)) + + for _, column := range columns { + if column.IsComputed { + continue + } + insertColumns = append(insertColumns, column) + rowColumns = append(rowColumns, column) + if column.IsPrimary { + primaryKeyColumns = append(primaryKeyColumns, column) + continue + } + setColumns = append(setColumns, column) + } + + switch mode { + case engine.SQLDataExportModeInsert: + if len(insertColumns) == 0 { + return nil, nil, nil, nil, fmt.Errorf("no writable columns found for SQL Data Export") + } + return insertColumns, setColumns, primaryKeyColumns, rowColumns, nil + case engine.SQLDataExportModeUpdate: + if len(primaryKeyColumns) == 0 { + return nil, nil, nil, nil, fmt.Errorf("SQL Update Export requires a primary key") + } + if len(setColumns) == 0 { + return nil, nil, nil, nil, fmt.Errorf("SQL Update Export requires at least one non-primary writable column") + } + return insertColumns, setColumns, primaryKeyColumns, rowColumns, nil + default: + return nil, nil, nil, nil, fmt.Errorf("unsupported SQL data export mode: %s", mode) + } +} + +func streamInsertSQLDataExport(w io.Writer, dialect sqlexport.Dialect, table sqlexport.Table, insertColumns []engine.Column, rowColumns []engine.Column, rows *sql.Rows) error { + batch := make([]sqlexport.Row, 0, sqlexport.DefaultInsertBatchSize) + for rows.Next() { + row, err := scanSQLDataExportRow(rows, rowColumns) + if err != nil { + return err + } + batch = append(batch, row) + if len(batch) == sqlexport.DefaultInsertBatchSize { + if err := sqlexport.WriteInsert(w, dialect, table, insertColumns, batch); err != nil { + return err + } + batch = batch[:0] + } + } + if len(batch) > 0 { + return sqlexport.WriteInsert(w, dialect, table, insertColumns, batch) + } + return nil +} + +func streamUpdateSQLDataExport(w io.Writer, dialect sqlexport.Dialect, table sqlexport.Table, setColumns []engine.Column, primaryKeyColumns []engine.Column, rowColumns []engine.Column, rows *sql.Rows) error { + for rows.Next() { + row, err := scanSQLDataExportRow(rows, rowColumns) + if err != nil { + return err + } + if err := sqlexport.WriteUpdate(w, dialect, table, setColumns, primaryKeyColumns, row); err != nil { + return err + } + } + return nil +} + +func scanSQLDataExportRow(rows *sql.Rows, columns []engine.Column) (sqlexport.Row, error) { + values := make([]any, len(columns)) + destinations := make([]any, len(columns)) + for i := range values { + destinations[i] = &values[i] + } + if err := rows.Scan(destinations...); err != nil { + return nil, fmt.Errorf("failed to scan row for SQL data export: %w", err) + } + + row := make(sqlexport.Row, len(columns)) + for i, column := range columns { + if bytesValue, ok := values[i].([]byte); ok { + row[column.Name] = append([]byte(nil), bytesValue...) + continue + } + row[column.Name] = values[i] + } + return row, nil +} + +func columnNames(columns []engine.Column) []string { + names := make([]string, len(columns)) + for i, column := range columns { + names[i] = column.Name + } + return names +} + +func toPluginSort(sortConditions []*model.SortCondition) []plugins.Sort { + sortList := make([]plugins.Sort, len(sortConditions)) + for i, sort := range sortConditions { + sortList[i] = plugins.Sort{ + Column: sort.Column, + Direction: plugins.Down, + } + if sort.Direction == model.SortDirectionAsc { + sortList[i].Direction = plugins.Up + } + } + return sortList +} diff --git a/dataflow/core/src/plugins/gorm/graph.go b/dataflow/core/src/plugins/gorm/graph.go new file mode 100644 index 0000000..4b6f14b --- /dev/null +++ b/dataflow/core/src/plugins/gorm/graph.go @@ -0,0 +1,79 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "fmt" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + "gorm.io/gorm" +) + +type tableRelations struct { + Table1 string + Table2 string + Relation string + SourceColumn *string + TargetColumn *string +} + +func (p *GormPlugin) GetGraph(config *engine.PluginConfig, schema string) ([]engine.GraphUnit, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) ([]engine.GraphUnit, error) { + var tableRelations []tableRelations + + schema = p.GormPluginFunctions.ResolveGraphSchema(config, schema) + if err := p.GetGraphQueryDB(db, schema).Scan(&tableRelations).Error; err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to execute graph query for schema: %s", schema)) + return nil, err + } + + tableMap := make(map[string][]engine.GraphUnitRelationship) + for _, tr := range tableRelations { + tableMap[tr.Table1] = append(tableMap[tr.Table1], engine.GraphUnitRelationship{ + Name: tr.Table2, + RelationshipType: engine.GraphUnitRelationshipType(tr.Relation), + SourceColumn: tr.SourceColumn, + TargetColumn: tr.TargetColumn, + }) + } + + storageUnits, err := p.GetStorageUnits(config, schema) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to get storage units for graph generation in schema: %s", schema)) + return nil, err + } + + storageUnitsMap := map[string]engine.StorageUnit{} + for _, storageUnit := range storageUnits { + storageUnitsMap[storageUnit.Name] = storageUnit + } + + var tables []engine.GraphUnit + for _, storageUnit := range storageUnits { + foundTable, ok := tableMap[storageUnit.Name] + var relations []engine.GraphUnitRelationship + if ok { + relations = foundTable + } + tables = append(tables, engine.GraphUnit{Unit: storageUnit, Relations: relations}) + } + + return tables, nil + }) +} diff --git a/dataflow/core/src/plugins/gorm/migrator.go b/dataflow/core/src/plugins/gorm/migrator.go new file mode 100644 index 0000000..c5968a6 --- /dev/null +++ b/dataflow/core/src/plugins/gorm/migrator.go @@ -0,0 +1,203 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "fmt" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "gorm.io/gorm" +) + +// MigratorHelper provides schema operations using GORM's Migrator interface +// NOTE: Most methods are not yet used but are prepared for future schema modification features +type MigratorHelper struct { + db *gorm.DB + plugin GormPluginFunctions + migrator gorm.Migrator +} + +// NewMigratorHelper creates a new migrator helper +func NewMigratorHelper(db *gorm.DB, plugin GormPluginFunctions) *MigratorHelper { + return &MigratorHelper{ + db: db, + plugin: plugin, + migrator: db.Migrator(), + } +} + +// TableExists checks if a table exists using Migrator +func (m *MigratorHelper) TableExists(tableName string) bool { + return m.migrator.HasTable(tableName) +} + +// GetConstraints gets table constraints using Migrator +func (m *MigratorHelper) GetConstraints(tableName string) (map[string][]gorm.ColumnType, error) { + // GORM's Migrator doesn't directly expose constraints + // We can get column types which include some constraint info + columnTypes, err := m.migrator.ColumnTypes(tableName) + if err != nil { + return nil, err + } + + constraints := make(map[string][]gorm.ColumnType) + for _, col := range columnTypes { + // Check for various constraints + if primary, ok := col.PrimaryKey(); ok && primary { + constraints["PRIMARY"] = append(constraints["PRIMARY"], col) + } + if unique, ok := col.Unique(); ok && unique { + constraints["UNIQUE"] = append(constraints["UNIQUE"], col) + } + if nullable, ok := col.Nullable(); ok && !nullable { + constraints["NOT_NULL"] = append(constraints["NOT_NULL"], col) + } + } + + return constraints, nil +} + +// GetColumnTypes gets column types and nullability using Migrator's ColumnTypes. +// Returns types with length info when available (e.g., "VARCHAR(255)"). +func (m *MigratorHelper) GetColumnTypes(tableName string) (map[string]ColumnTypeInfo, error) { + columnTypes := make(map[string]ColumnTypeInfo) + + types, err := m.migrator.ColumnTypes(tableName) + if err != nil { + return nil, err + } + + for _, col := range types { + fullType := m.buildFullTypeName(col) + normalizedType := m.plugin.NormalizeType(fullType) + + isNullable := true // safe default: assume nullable + if nullable, ok := col.Nullable(); ok { + isNullable = nullable + } + + columnTypes[col.Name()] = ColumnTypeInfo{ + Type: normalizedType, + IsNullable: isNullable, + } + } + + return columnTypes, nil +} + +// ColumnTypeInfo holds a column's type string alongside its nullability. +// Used by CRUD operations that need both pieces of information for value conversion. +type ColumnTypeInfo struct { + Type string + IsNullable bool +} + +// typesWithLength lists types where showing length is meaningful to users. +// These are types where users can specify a length/size when creating columns. +var typesWithLength = map[string]bool{ + // Character types + "VARCHAR": true, "CHAR": true, "CHARACTER": true, "CHARACTER VARYING": true, + "NVARCHAR": true, "NCHAR": true, "BPCHAR": true, + // Binary types + "BINARY": true, "VARBINARY": true, "BIT": true, "BIT VARYING": true, "VARBIT": true, + // ClickHouse string types + "FIXEDSTRING": true, +} + +// typesWithPrecision lists types where showing precision/scale is meaningful. +var typesWithPrecision = map[string]bool{ + "DECIMAL": true, "NUMERIC": true, "NUMBER": true, + // Some databases use FLOAT/DOUBLE with precision + "FLOAT": true, "DOUBLE": true, "REAL": true, +} + +// buildFullTypeName constructs the full type name including length/precision. +// Only appends length for types where it's user-specifiable (VARCHAR, CHAR, etc.) +// to avoid showing internal storage sizes for types like BOX or POLYGON. +func (m *MigratorHelper) buildFullTypeName(col gorm.ColumnType) string { + baseName := strings.ToUpper(col.DatabaseTypeName()) + + // Only show length for types where users can specify it + if typesWithLength[baseName] { + if length, ok := col.Length(); ok && length > 0 { + return fmt.Sprintf("%s(%d)", baseName, length) + } + } + + // show precision/scale for decimal-like types + if typesWithPrecision[baseName] { + if precision, scale, ok := col.DecimalSize(); ok && precision > 0 { + return fmt.Sprintf("%s(%d,%d)", baseName, precision, scale) + } + } + + return baseName +} + +// GetOrderedColumns returns columns in their definition order. +// Returns types with length info when available and normalized to canonical form. +func (m *MigratorHelper) GetOrderedColumns(tableName string) ([]engine.Column, error) { + types, err := m.migrator.ColumnTypes(tableName) + if err != nil { + return nil, err + } + + columns := make([]engine.Column, 0, len(types)) + for _, col := range types { + fullType := m.buildFullTypeName(col) + normalizedType := m.plugin.NormalizeType(fullType) + baseName := strings.ToUpper(col.DatabaseTypeName()) + + isAutoIncr, _ := col.AutoIncrement() + + isNullable := true // safe default: assume nullable + if nullable, ok := col.Nullable(); ok { + isNullable = nullable + } + + column := engine.Column{ + Name: col.Name(), + Type: normalizedType, + IsNullable: isNullable, + IsAutoIncrement: isAutoIncr, + } + + // Only extract length for types where it's user-specifiable + if typesWithLength[baseName] { + if length, ok := col.Length(); ok && length > 0 { + l := int(length) + column.Length = &l + } + } + + // Only extract precision/scale for decimal-like types + // Always include scale (even if 0) because NUMBER(10,0) and NUMBER(10) are semantically different + if typesWithPrecision[baseName] { + if precision, scale, ok := col.DecimalSize(); ok && precision > 0 { + p := int(precision) + column.Precision = &p + s := int(scale) + column.Scale = &s + } + } + + columns = append(columns, column) + } + + return columns, nil +} diff --git a/dataflow/core/src/plugins/gorm/multi_statement_test.go b/dataflow/core/src/plugins/gorm/multi_statement_test.go new file mode 100644 index 0000000..a81d1b0 --- /dev/null +++ b/dataflow/core/src/plugins/gorm/multi_statement_test.go @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import "testing" + +func TestIsMultiStatement(t *testing.T) { + tests := []struct { + name string + query string + want bool + }{ + {"single statement no semicolon", "SELECT 1", false}, + {"single statement trailing semicolon", "SELECT 1;", false}, + {"two statements", "SELECT 1; SELECT 2;", true}, + {"transaction block", "BEGIN; SELECT 1; COMMIT;", true}, + {"semicolon inside single-quoted string", "SELECT ';' FROM t;", false}, + {"semicolon inside double-quoted identifier", `SELECT "col;name" FROM t;`, false}, + {"semicolon inside line comment", "SELECT 1 -- comment; more\n;", false}, + {"semicolon inside block comment", "SELECT 1 /* ; */ ;", false}, + {"leading header comment", "-- header\nSELECT 1;", false}, + {"empty string", "", false}, + {"trailing comment after semicolon", "SELECT 1; -- trailing comment\n", false}, + {"multiline transaction with comments", "-- setup\nBEGIN;\nCREATE TABLE t (id int);\nINSERT INTO t VALUES (1);\nCOMMIT;", true}, + {"escaped quote inside string", "SELECT 'it''s'; SELECT 2;", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := IsMultiStatement(tc.query); got != tc.want { + t.Fatalf("IsMultiStatement(%q) = %v, want %v", tc.query, got, tc.want) + } + }) + } +} diff --git a/dataflow/core/src/plugins/gorm/plugin.go b/dataflow/core/src/plugins/gorm/plugin.go new file mode 100644 index 0000000..c868e31 --- /dev/null +++ b/dataflow/core/src/plugins/gorm/plugin.go @@ -0,0 +1,853 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "context" + "database/sql" + "fmt" + "slices" + "strconv" + "strings" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// IsMultiStatement returns true if query contains more than one SQL statement, +// ignoring semicolons inside single-line comments (--), block comments (/* */), +// and single/double-quoted strings. Used by plugins whose default driver protocol +// does not support multiple commands per query (e.g., PostgreSQL extended protocol, +// MySQL without MultiStatements). +func IsMultiStatement(query string) bool { + i := 0 + for i < len(query) { + ch := query[i] + + // Line comment: skip to end of line + if ch == '-' && i+1 < len(query) && query[i+1] == '-' { + for i < len(query) && query[i] != '\n' { + i++ + } + continue + } + // Block comment: skip to closing */ + if ch == '/' && i+1 < len(query) && query[i+1] == '*' { + i += 2 + for i+1 < len(query) && !(query[i] == '*' && query[i+1] == '/') { + i++ + } + if i+1 < len(query) { + i += 2 + } + continue + } + // Quoted string/identifier: skip to matching close quote + if ch == '\'' || ch == '"' { + quote := ch + i++ + for i < len(query) { + if query[i] == '\\' && i+1 < len(query) { + i += 2 + continue + } + if query[i] == quote { + i++ + // SQL-standard doubled-quote escape + if i < len(query) && query[i] == quote { + i++ + continue + } + break + } + i++ + } + continue + } + if ch == ';' { + if hasStatementAfter(query[i+1:]) { + return true + } + } + i++ + } + return false +} + +// hasStatementAfter reports whether the tail contains meaningful SQL content +// beyond the separator — i.e., anything that isn't whitespace, a comment, or +// another empty separator. Used by IsMultiStatement to distinguish a trailing +// semicolon (and any comment after it) from a real second statement. +func hasStatementAfter(tail string) bool { + i := 0 + for i < len(tail) { + ch := tail[i] + if ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' || ch == ';' { + i++ + continue + } + if ch == '-' && i+1 < len(tail) && tail[i+1] == '-' { + for i < len(tail) && tail[i] != '\n' { + i++ + } + continue + } + if ch == '/' && i+1 < len(tail) && tail[i+1] == '*' { + i += 2 + for i+1 < len(tail) && !(tail[i] == '*' && tail[i+1] == '/') { + i++ + } + if i+1 < len(tail) { + i += 2 + } + continue + } + return true + } + return false +} + +type GormPlugin struct { + engine.Plugin + GormPluginFunctions + errorHandler *ErrorHandler +} + +// InitPlugin initializes the plugin with an error handler +func (p *GormPlugin) InitPlugin() { + if p.errorHandler == nil { + p.errorHandler = NewErrorHandler(p) + } +} + +// CreateSQLBuilder creates a SQL builder instance - default implementation +// Can be overridden by specific database plugins (e.g., MySQL) +func (p *GormPlugin) CreateSQLBuilder(db *gorm.DB) SQLBuilderInterface { + return NewSQLBuilder(db, p) +} + +// FormTableName returns the qualified table name for a given schema and storage unit. +func (p *GormPlugin) FormTableName(schema string, storageUnit string) string { + if schema == "" { + return storageUnit + } + return schema + "." + storageUnit +} + +type GormPluginFunctions interface { + // these below are meant to be generic-ish implementations by the base gorm plugin + ParseConnectionConfig(config *engine.PluginConfig) (*ConnectionInput, error) + + ConvertStringValue(value, columnType string, isNullable bool) (any, error) + ConvertRawToRows(raw *sql.Rows) (*engine.GetRowsResult, error) + ConvertRecordValuesToMap(values []engine.Record) (map[string]any, error) + + // CreateSQLBuilder creates a SQL builder instance - can be overridden by specific plugins + CreateSQLBuilder(db *gorm.DB) SQLBuilderInterface + + // these below are meant to be implemented by the specific database plugins + DB(config *engine.PluginConfig) (*gorm.DB, error) + GetPlaceholder(index int) string + + GetTableInfoQuery() string + GetStorageUnitExistsQuery() string + GetPrimaryKeyColQuery() string + GetAllSchemasQuery() string + GetCreateTableQuery(db *gorm.DB, schema string, storageUnit string, columns []engine.Record) string + + FormTableName(schema string, storageUnit string) string + + GetSupportedOperators() map[string]string + + GetGraphQueryDB(db *gorm.DB, schema string) *gorm.DB + GetTableNameAndAttributes(rows *sql.Rows) (string, []engine.Record) + + // GetRowsOrderBy returns the ORDER BY clause for pagination queries + GetRowsOrderBy(db *gorm.DB, schema string, storageUnit string) string + + // ShouldHandleColumnType returns true if the plugin wants to handle a specific column type + ShouldHandleColumnType(columnType string) bool + + // GetColumnScanner returns a scanner for a specific column type + // This is called when ShouldHandleColumnType returns true + GetColumnScanner(columnType string) any + + // FormatColumnValue formats a scanned value for a specific column type + // This is called when ShouldHandleColumnType returns true + FormatColumnValue(columnType string, scanner any) (string, error) + + // GetCustomColumnTypeName returns a custom column type name for display + // Return empty string to use the default type name + GetCustomColumnTypeName(columnName string, defaultTypeName string) string + + // IsGeometryType returns true if the column type represents spatial/geometry data + IsGeometryType(columnType string) bool + + // FormatGeometryValue formats geometry data for display + // Return empty string to use default hex formatting + FormatGeometryValue(rawBytes []byte, columnType string) string + + // HandleCustomDataType allows plugins to handle their own data type conversions + // Return (value, true) if handled, or (nil, false) to use default handling + HandleCustomDataType(value string, columnType string, isNullable bool) (any, bool, error) + + // IsArrayType returns true if the column type represents an array type. + // Used to format array type names for display (e.g., PostgreSQL _int4 -> []int4). + IsArrayType(columnType string) bool + + // ResolveGraphSchema returns the schema to use for graph queries. + // Most databases use the schema parameter as-is; ClickHouse uses the database name instead. + ResolveGraphSchema(config *engine.PluginConfig, schema string) string + + // ShouldCheckRowsAffected returns whether the plugin supports checking RowsAffected + // after UPDATE/DELETE mutations. Returns false for databases like ClickHouse where + // the GORM driver doesn't report affected rows. + ShouldCheckRowsAffected() bool + + // GetPrimaryKeyColumns returns the primary key columns for a table + GetPrimaryKeyColumns(db *gorm.DB, schema string, tableName string) ([]string, error) + + // GetForeignKeyRelationships returns foreign key relationships for a table + GetForeignKeyRelationships(config *engine.PluginConfig, schema string, storageUnit string) (map[string]*engine.ForeignKeyRelationship, error) + + // GetDatabaseType returns the database type + GetDatabaseType() engine.DatabaseType + + // GetColumnsForTable returns ordered table columns with relational metadata. + GetColumnsForTable(config *engine.PluginConfig, schema string, storageUnit string) ([]engine.Column, error) + + // MarkGeneratedColumns enriches columns with auto-increment and computed column flags. + MarkGeneratedColumns(config *engine.PluginConfig, schema string, storageUnit string, columns []engine.Column) error + + // GetColumnConstraints retrieves column constraints for a table + GetColumnConstraints(config *engine.PluginConfig, schema string, storageUnit string) (map[string]map[string]any, error) + + // NormalizeType converts a type alias to its canonical form for this database. + // For example, PostgreSQL: "INT4" -> "INTEGER", "VARCHAR" -> "CHARACTER VARYING" + // Returns the input unchanged if no mapping exists. + NormalizeType(typeName string) string + + // GetColumnTypes returns a map of column names to their type info (type + nullability). + // Used for type conversion during CRUD operations. + GetColumnTypes(db *gorm.DB, schema, tableName string) (map[string]ColumnTypeInfo, error) + + // GetLastInsertID returns the most recently auto-generated ID after an INSERT. + // This is used by the mock data generator to track PKs for FK references. + // Returns 0 if the database doesn't support this or no ID was generated. + GetLastInsertID(db *gorm.DB) (int64, error) + + // GetMaxBulkInsertParameters returns the maximum number of parameters supported + // for bulk insert operations. Used to calculate appropriate batch sizes. + // Default: 65535 (PostgreSQL/MySQL limit). Override for databases with lower limits. + GetMaxBulkInsertParameters() int + + // BuildSkipConflictClause returns an OnConflict clause that skips duplicate rows + // during append-mode imports. Dialect-specific because Postgres uses DO NOTHING + // while MySQL needs identity assignments (pk = pk) since GORM can't generate the + // fallback without schema info when using .Table() with map records. + BuildSkipConflictClause(pkColumns []string) clause.OnConflict +} + +func (p *GormPlugin) GetStorageUnits(config *engine.PluginConfig, schema string) ([]engine.StorageUnit, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) ([]engine.StorageUnit, error) { + var storageUnits []engine.StorageUnit + rows, err := db.Raw(p.GetTableInfoQuery(), schema).Rows() + if err != nil { + log.WithError(err).Error("Failed to execute table info query for schema: " + schema) + return nil, err + } + defer rows.Close() + + for rows.Next() { + tableName, attributes := p.GetTableNameAndAttributes(rows) + if attributes == nil && tableName == "" { + continue + } + storageUnits = append(storageUnits, engine.StorageUnit{ + Name: tableName, + Attributes: attributes, + }) + } + + return storageUnits, nil + }) +} + +func (p *GormPlugin) StorageUnitExists(config *engine.PluginConfig, schema string, storageUnit string) (bool, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + var exists bool + err := db.Raw(p.GetStorageUnitExistsQuery(), schema, storageUnit).Scan(&exists).Error + if err != nil { + return false, err + } + return exists, nil + }) +} + +func (p *GormPlugin) GetAllSchemas(config *engine.PluginConfig) ([]string, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) ([]string, error) { + var schemas []any + query := p.GetAllSchemasQuery() + if err := db.Raw(query).Scan(&schemas).Error; err != nil { + // Use error handler for consistent error handling + return nil, p.errorHandler.HandleError(err, "GetAllSchemas", map[string]any{ + "query": query, + }) + } + schemaNames := make([]string, len(schemas)) + for i, schema := range schemas { + switch v := schema.(type) { + case string: + schemaNames[i] = v + case []byte: + schemaNames[i] = string(v) + default: + schemaNames[i] = fmt.Sprintf("%v", v) + } + } + return schemaNames, nil + }) +} + +func (p *GormPlugin) GetRows(config *engine.PluginConfig, req *engine.GetRowsRequest) (*engine.GetRowsResult, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (*engine.GetRowsResult, error) { + // Use generic implementation; database-specific behavior should be handled in each plugin + return p.getGenericRows(db, req.Schema, req.StorageUnit, req.Where, req.Sort, req.PageSize, req.PageOffset) + }) +} + +func (p *GormPlugin) GetRowCount(config *engine.PluginConfig, schema string, storageUnit string, where *model.WhereCondition) (int64, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (int64, error) { + var columnTypes map[string]ColumnTypeInfo + if where != nil { + var err error + columnTypes, err = p.GormPluginFunctions.GetColumnTypes(db, schema, storageUnit) + if err != nil { + log.WithError(err).Warn(fmt.Sprintf("Failed to get column types for table %s.%s during row count; proceeding without type info", schema, storageUnit)) + } + } + + builder := p.GormPluginFunctions.CreateSQLBuilder(db) + fullTable := builder.BuildFullTableName(schema, storageUnit) + + // codeql[go/sql-injection]: table name validated by StorageUnitExists before reaching this code + query := db.Table(fullTable) + query, err := p.ApplyWhereConditions(query, where, columnTypes) + if err != nil { + return 0, err + } + + var count int64 + if err := query.Count(&count).Error; err != nil { + return 0, err + } + return count, nil + }) +} + +func (p *GormPlugin) GetColumnsForTable(config *engine.PluginConfig, schema string, storageUnit string) ([]engine.Column, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) ([]engine.Column, error) { + migrator := NewMigratorHelper(db, p.GormPluginFunctions) + fullTableName := p.FormTableName(schema, storageUnit) + columns, err := migrator.GetOrderedColumns(fullTableName) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to get columns for table %s.%s", schema, storageUnit)) + return nil, err + } + + fkRelationships, err := p.GormPluginFunctions.GetForeignKeyRelationships(config, schema, storageUnit) + if err != nil { + log.WithError(err).Warn(fmt.Sprintf("Failed to get foreign key relationships for table %s.%s", schema, storageUnit)) + fkRelationships = make(map[string]*engine.ForeignKeyRelationship) + } + + primaryKeys, err := p.GetPrimaryKeyColumns(db, schema, storageUnit) + if err != nil { + log.WithError(err).Warn(fmt.Sprintf("Failed to get primary keys for table %s.%s", schema, storageUnit)) + primaryKeys = []string{} + } + + log.Debugf("[FK DEBUG] GetColumns for %s.%s: fkCount=%d, pkCount=%d, pks=%v", schema, storageUnit, len(fkRelationships), len(primaryKeys), primaryKeys) + + // Enrich columns with primary key and foreign key information + // Note: IsAutoIncrement is already set by GORM's native detection in GetOrderedColumns + for i := range columns { + // Check if column is a primary key + columns[i].IsPrimary = slices.Contains(primaryKeys, columns[i].Name) + + // Check if column is a foreign key + if fk, exists := fkRelationships[columns[i].Name]; exists { + columns[i].IsForeignKey = true + columns[i].ReferencedTable = &fk.ReferencedTable + columns[i].ReferencedColumn = &fk.ReferencedColumn + } + } + + return columns, nil + }) +} + +// SQLite-specific row retrieval is implemented in the sqlite3 plugin override. +func (p *GormPlugin) getGenericRows(db *gorm.DB, schema, storageUnit string, where *model.WhereCondition, sort []*model.SortCondition, pageSize, pageOffset int) (*engine.GetRowsResult, error) { + var columnTypes map[string]ColumnTypeInfo + if where != nil { + var err error + columnTypes, err = p.GormPluginFunctions.GetColumnTypes(db, schema, storageUnit) + if err != nil { + log.WithError(err).Warn(fmt.Sprintf("Failed to get column types for table %s.%s during row retrieval; proceeding without type info", schema, storageUnit)) + } + } + + builder := p.GormPluginFunctions.CreateSQLBuilder(db) + fullTable := builder.BuildFullTableName(schema, storageUnit) + + // Parallel count query improves performance for large tables + var totalCount int64 + countDone := make(chan error, 1) + go func() { + // codeql[go/sql-injection]: table name validated by StorageUnitExists before reaching this code + countQuery := db.Table(fullTable) + var err error + countQuery, err = p.ApplyWhereConditions(countQuery, where, columnTypes) + if err != nil { + countDone <- err + return + } + countDone <- countQuery.Count(&totalCount).Error + }() + + // codeql[go/sql-injection]: table name validated by StorageUnitExists before reaching this code + query := db.Table(fullTable) + query, err := p.ApplyWhereConditions(query, where, columnTypes) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to apply where conditions for table %s.%s", schema, storageUnit)) + return nil, err + } + + // Apply sorting conditions if provided + if len(sort) > 0 { + // Convert to Sort type for builder + sortList := make([]plugins.Sort, len(sort)) + for i, s := range sort { + sortList[i] = plugins.Sort{ + Column: s.Column, + Direction: plugins.Down, + } + if s.Direction == model.SortDirectionAsc { + sortList[i].Direction = plugins.Up + } + } + query = builder.BuildOrderBy(query, sortList) + } else { + // Apply custom ordering if specified by the database plugin + if orderBy := p.GormPluginFunctions.GetRowsOrderBy(db, schema, storageUnit); orderBy != "" { + query = query.Order(orderBy) + } + } + + // Only apply pagination if pageSize > 0 + // pageSize <= 0 means fetch all rows + if pageSize > 0 { + query = query.Limit(pageSize).Offset(pageOffset) + } + + rows, err := query.Rows() + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to execute generic rows query for table %s.%s", schema, storageUnit)) + return nil, err + } + defer rows.Close() + + result, err := p.GormPluginFunctions.ConvertRawToRows(rows) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to convert raw rows for table %s.%s", schema, storageUnit)) + return nil, err + } + + // Fix any missing column type metadata + for i, col := range result.Columns { + if _, err := strconv.Atoi(col.Type); err == nil { + result.Columns[i].Type = p.FindMissingDataType(db, col.Type) + } + } + + // Wait for count query to complete and set TotalCount + if countErr := <-countDone; countErr != nil { + log.WithError(countErr).Warn(fmt.Sprintf("Failed to get row count for table %s.%s", schema, storageUnit)) + // Don't fail the whole operation if count fails + } else { + result.TotalCount = totalCount + } + + return result, nil +} + +func (p *GormPlugin) ApplyWhereConditions(query *gorm.DB, condition *model.WhereCondition, columnTypes map[string]ColumnTypeInfo) (*gorm.DB, error) { + if condition == nil { + return query, nil + } + + switch condition.Type { + case model.WhereConditionTypeAtomic: + return p.applyAtomicCondition(query, condition.Atomic, columnTypes) + case model.WhereConditionTypeAnd: + return p.applyAndConditions(query, condition.And, columnTypes) + case model.WhereConditionTypeOr: + return p.applyOrConditions(query, condition.Or, columnTypes) + } + + return query, nil +} + +// applyAtomicCondition handles a single atomic WHERE condition (e.g., column = value). +func (p *GormPlugin) applyAtomicCondition(query *gorm.DB, atomic *model.AtomicWhereCondition, columnTypes map[string]ColumnTypeInfo) (*gorm.DB, error) { + if atomic == nil { + return query, nil + } + + // Use actual column type from database if available + columnType := atomic.ColumnType + isNullable := false + if columnTypes != nil { + if colInfo, exists := columnTypes[atomic.Key]; exists && colInfo.Type != "" { + columnType = colInfo.Type + isNullable = colInfo.IsNullable + } + } + + operator, ok := p.GetSupportedOperators()[atomic.Operator] + if !ok { + return nil, fmt.Errorf("invalid SQL operator: %s", atomic.Operator) + } + + builder := p.GormPluginFunctions.CreateSQLBuilder(query.Session(&gorm.Session{NewDB: true})) + col := builder.QuoteIdentifier(atomic.Key) + + switch operator { + case "IS NULL", "IS NOT NULL": + return query.Where(col + " " + operator), nil + case "IN", "NOT IN": + return p.applyInCondition(query, col, operator, atomic.Value, columnType, isNullable) + case "BETWEEN", "NOT BETWEEN": + return p.applyBetweenCondition(query, col, operator, atomic.Value, columnType, isNullable) + default: + return p.applySingleValueCondition(query, col, operator, atomic.Value, columnType, isNullable) + } +} + +// applyInCondition handles IN and NOT IN operators with comma-separated values. +func (p *GormPlugin) applyInCondition(query *gorm.DB, col, operator, rawValue, columnType string, isNullable bool) (*gorm.DB, error) { + raw := strings.TrimSpace(rawValue) + if raw == "" { + // empty IN list should return no rows; use a false predicate + return query.Where("1 = 0"), nil + } + parts := strings.Split(raw, ",") + vals := make([]any, 0, len(parts)) + for _, part := range parts { + v := strings.TrimSpace(part) + cv, err := p.GormPluginFunctions.ConvertStringValue(v, columnType, isNullable) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to convert IN value '%s' for column type '%s'", v, columnType)) + return nil, err + } + vals = append(vals, cv) + } + return query.Where(col+" "+operator+" ?", vals), nil +} + +// applyBetweenCondition handles BETWEEN and NOT BETWEEN operators with "min,max" values. +func (p *GormPlugin) applyBetweenCondition(query *gorm.DB, col, operator, rawValue, columnType string, isNullable bool) (*gorm.DB, error) { + raw := strings.TrimSpace(rawValue) + parts := strings.Split(raw, ",") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid BETWEEN value; expected 'min,max'") + } + v1, err := p.GormPluginFunctions.ConvertStringValue(strings.TrimSpace(parts[0]), columnType, isNullable) + if err != nil { + log.WithError(err).Error("Failed to convert BETWEEN min value") + return nil, err + } + v2, err := p.GormPluginFunctions.ConvertStringValue(strings.TrimSpace(parts[1]), columnType, isNullable) + if err != nil { + log.WithError(err).Error("Failed to convert BETWEEN max value") + return nil, err + } + if operator == "BETWEEN" { + return query.Where(col+" BETWEEN ? AND ?", v1, v2), nil + } + return query.Where(col+" NOT BETWEEN ? AND ?", v1, v2), nil +} + +// applySingleValueCondition handles operators that compare against a single value (=, <, >, LIKE, etc.). +func (p *GormPlugin) applySingleValueCondition(query *gorm.DB, col, operator, rawValue, columnType string, isNullable bool) (*gorm.DB, error) { + value, err := p.GormPluginFunctions.ConvertStringValue(rawValue, columnType, isNullable) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to convert string value '%s' for column type '%s'", rawValue, columnType)) + return nil, err + } + return query.Where(col+" "+operator+" ?", value), nil +} + +// applyAndConditions applies all children as AND-combined WHERE clauses. +func (p *GormPlugin) applyAndConditions(query *gorm.DB, and *model.OperationWhereCondition, columnTypes map[string]ColumnTypeInfo) (*gorm.DB, error) { + if and == nil { + return query, nil + } + for _, child := range and.Children { + var err error + query, err = p.ApplyWhereConditions(query, child, columnTypes) + if err != nil { + log.WithError(err).Error("Failed to apply AND where condition") + return nil, err + } + } + return query, nil +} + +// applyOrConditions applies all children as OR-combined WHERE clauses. +func (p *GormPlugin) applyOrConditions(query *gorm.DB, or *model.OperationWhereCondition, columnTypes map[string]ColumnTypeInfo) (*gorm.DB, error) { + if or == nil { + return query, nil + } + orQueries := query + for _, child := range or.Children { + childQuery, err := p.ApplyWhereConditions(query, child, columnTypes) + if err != nil { + log.WithError(err).Error("Failed to apply OR where condition") + return nil, err + } + orQueries = orQueries.Or(childQuery) + } + return orQueries, nil +} + +// GetDatabaseType returns the database type +func (p *GormPlugin) GetDatabaseType() engine.DatabaseType { + return p.Type +} + +// WithTransaction executes the given operation within a database transaction +func (p *GormPlugin) WithTransaction(config *engine.PluginConfig, operation func(tx any) error) error { + _, err := plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + // Begin transaction + tx := db.Begin() + if tx.Error != nil { + return false, fmt.Errorf("failed to begin transaction: %w", tx.Error) + } + + // Execute the operation + if err := operation(tx); err != nil { + // Rollback on error + tx.Rollback() + return false, err + } + + // Commit transaction + if err := tx.Commit().Error; err != nil { + return false, fmt.Errorf("failed to commit transaction: %w", err) + } + + return true, nil + }) + + return err +} + +// ExecuteRawSQL handles raw SQL execution with optional multi-statement support. +// openMultiStatementDB is called when config.MultiStatement is true to get a DB connection +// that supports multiple statements. Pass nil if multi-statement is not supported. +func (p *GormPlugin) ExecuteRawSQL(config *engine.PluginConfig, openMultiStatementDB func(*engine.PluginConfig) (*gorm.DB, error), query string, params ...any) (*engine.GetRowsResult, error) { + multiStatement := config != nil && config.MultiStatement + dbFunc := p.DB + if multiStatement && openMultiStatementDB != nil { + dbFunc = openMultiStatementDB + } + + return plugins.WithConnection(config, dbFunc, func(db *gorm.DB) (*engine.GetRowsResult, error) { + if multiStatement { + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + + conn, err := sqlDB.Conn(context.Background()) + if err != nil { + return nil, err + } + defer conn.Close() + + rows, err := conn.QueryContext(context.Background(), query) + if err != nil { + // Clean up any aborted transaction left on this conn. + _, _ = conn.ExecContext(context.Background(), "ROLLBACK") + return nil, err + } + defer rows.Close() + + // Walk every result set; keep the LAST one that has columns. + // DDL/DML statements without RETURNING produce a result set with + // zero columns in simple protocol — skip those. + var lastResult *engine.GetRowsResult + for { + result, convErr := p.ConvertRawToRows(rows) + if convErr != nil { + return nil, convErr + } + if len(result.Columns) > 0 { + lastResult = result + } + if !rows.NextResultSet() { + break + } + } + + if lastResult == nil { + return &engine.GetRowsResult{ + Columns: []engine.Column{}, + Rows: [][]string{}, + }, nil + } + return lastResult, nil + } + + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + + conn, err := sqlDB.Conn(context.Background()) + if err != nil { + return nil, err + } + defer conn.Close() + + rows, err := conn.QueryContext(context.Background(), query, params...) + if err != nil { + // Clean up any aborted transaction on this connection. + // ROLLBACK is a no-op when no transaction is active (PostgreSQL + // issues a WARNING, MySQL/SQLite/ClickHouse silently ignore it). + _, _ = conn.ExecContext(context.Background(), "ROLLBACK") + return nil, err + } + defer rows.Close() + + return p.ConvertRawToRows(rows) + }) +} + +// GetForeignKeyRelationships returns foreign key relationships for a table (default empty implementation) +func (p *GormPlugin) GetForeignKeyRelationships(config *engine.PluginConfig, schema string, storageUnit string) (map[string]*engine.ForeignKeyRelationship, error) { + return make(map[string]*engine.ForeignKeyRelationship), nil +} + +// QueryForeignKeyRelationships executes a foreign key query and returns the relationships map. +// This is a helper for SQL plugins that query system catalogs for FK information. +// The query must return exactly 3 columns: column_name, referenced_table, referenced_column. +func (p *GormPlugin) QueryForeignKeyRelationships(config *engine.PluginConfig, query string, params ...any) (map[string]*engine.ForeignKeyRelationship, error) { + log.Debugf("[FK DEBUG] QueryForeignKeyRelationships called with params: %v", params) + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (map[string]*engine.ForeignKeyRelationship, error) { + rows, err := db.Raw(query, params...).Rows() + if err != nil { + log.Debugf("[FK DEBUG] ERROR: Raw query error: %v", err) + return nil, err + } + defer rows.Close() + + relationships := make(map[string]*engine.ForeignKeyRelationship) + for rows.Next() { + var columnName, referencedTable, referencedColumn string + if err := rows.Scan(&columnName, &referencedTable, &referencedColumn); err != nil { + log.WithError(err).Error("Failed to scan foreign key relationship") + continue + } + relationships[columnName] = &engine.ForeignKeyRelationship{ + ColumnName: columnName, + ReferencedTable: referencedTable, + ReferencedColumn: referencedColumn, + } + } + log.Debugf("[FK DEBUG] Found %d FK relationships for params %v", len(relationships), params) + return relationships, nil + }) +} + +// QueryComputedColumns executes a query and returns a set of computed column names. +// This is a helper for SQL plugins that query system catalogs for generated/computed columns. +// The query must return exactly 1 column: column_name. +func (p *GormPlugin) QueryComputedColumns(config *engine.PluginConfig, query string, params ...any) (map[string]bool, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (map[string]bool, error) { + rows, err := db.Raw(query, params...).Rows() + if err != nil { + return nil, err + } + defer rows.Close() + + computed := make(map[string]bool) + for rows.Next() { + var columnName string + if err := rows.Scan(&columnName); err != nil { + continue + } + computed[columnName] = true + } + return computed, nil + }) +} + +// NormalizeType returns the type unchanged by default. +// Database plugins should override this to normalize aliases to canonical types. +func (p *GormPlugin) NormalizeType(typeName string) string { + // Default: strip length and return uppercase base type + spec := common.ParseTypeSpec(typeName) + return common.FormatTypeSpec(spec) +} + +// GetMaxBulkInsertParameters returns the default limit of 65535 parameters. +func (p *GormPlugin) GetMaxBulkInsertParameters() int { + return 65535 +} + +// BuildSkipConflictClause returns ON CONFLICT (pk) DO NOTHING — works for Postgres, SQLite, ClickHouse. +// MySQL/MariaDB plugins override this with identity assignments. +func (p *GormPlugin) BuildSkipConflictClause(pkColumns []string) clause.OnConflict { + conflictCols := make([]clause.Column, len(pkColumns)) + for i, col := range pkColumns { + conflictCols[i] = clause.Column{Name: col} + } + return clause.OnConflict{ + Columns: conflictCols, + DoNothing: true, + } +} + +// MarkGeneratedColumns is a no-op base implementation. +// Database plugins should override this to detect auto-increment and computed columns. +func (p *GormPlugin) MarkGeneratedColumns(config *engine.PluginConfig, schema string, storageUnit string, columns []engine.Column) error { + return nil +} + +// GetDatabaseMetadata returns nil by default. +// Database plugins should override this to provide metadata for frontend configuration. +func (p *GormPlugin) GetDatabaseMetadata() *engine.DatabaseMetadata { + return nil +} diff --git a/dataflow/core/src/plugins/gorm/sqlbuilder.go b/dataflow/core/src/plugins/gorm/sqlbuilder.go new file mode 100644 index 0000000..9a7d3b6 --- /dev/null +++ b/dataflow/core/src/plugins/gorm/sqlbuilder.go @@ -0,0 +1,304 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "fmt" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/plugins" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// SQLBuilderInterface defines the methods that can be overridden by database-specific implementations +type SQLBuilderInterface interface { + QuoteIdentifier(identifier string) string + BuildFullTableName(schema, table string) string + GetTableQuery(schema, table string) *gorm.DB + SelectQuery(schema, table string, columns []string, conditions map[string]any) *gorm.DB + BuildOrderBy(query *gorm.DB, sortList []plugins.Sort) *gorm.DB + CreateTableQuery(schema, table string, columns []ColumnDef) string + CreateTableQueryWithSuffix(schema, table string, columns []ColumnDef, suffix string) string + DropTableQuery(schema, table string) string + InsertRow(schema, table string, data map[string]any) error + UpdateQuery(schema, table string, updates map[string]any, conditions map[string]any) *gorm.DB + DeleteQuery(schema, table string, conditions map[string]any) *gorm.DB + CountQuery(schema, table string) (int64, error) +} + +// SQLBuilder provides SQL query building functionality +// This builder prioritizes GORM's native methods for all operations where possible. +type SQLBuilder struct { + db *gorm.DB + plugin GormPluginFunctions + self SQLBuilderInterface // Reference to the actual implementation +} + +// NewSQLBuilder creates a new SQL builder +func NewSQLBuilder(db *gorm.DB, plugin GormPluginFunctions) *SQLBuilder { + sb := &SQLBuilder{ + db: db, + plugin: plugin, + } + sb.self = sb // Default to self, will be overridden if wrapped + return sb +} + +// SetSelf sets the reference to the actual implementation (used by subclasses) +func (sb *SQLBuilder) SetSelf(self SQLBuilderInterface) { + sb.self = self +} + +// GetDB returns the underlying GORM DB instance +func (sb *SQLBuilder) GetDB() *gorm.DB { + return sb.db +} + +// QuoteIdentifier quotes an identifier (column/table name) ONLY for DDL operations +// For DML operations (SELECT, INSERT, UPDATE, DELETE), use GORM's native methods +// which handle escaping automatically +func (sb *SQLBuilder) QuoteIdentifier(identifier string) string { + // Prefer GORM dialect quoting when available + if sb.db != nil && sb.db.Dialector != nil { + var b strings.Builder + sb.db.Dialector.QuoteTo(&b, identifier) + return b.String() + } + // If no dialect is available (should be rare), return as-is + return identifier +} + +// BuildFullTableName builds a fully qualified table name for GORM operations +// GORM's dialector handles identifier quoting internally via QuoteTo method. +func (sb *SQLBuilder) BuildFullTableName(schema, table string) string { + if table == "" { + return "" + } + if schema == "" { + return table + } + return schema + "." + table +} + +// GetTableQuery creates a GORM query with the appropriate table reference +// This method can be overridden by database-specific implementations +func (sb *SQLBuilder) GetTableQuery(schema, table string) *gorm.DB { + fullTableName := sb.BuildFullTableName(schema, table) + return sb.db.Table(fullTableName) +} + +// SelectQuery builds a SELECT query using GORM's query builder +// GORM handles all escaping automatically +func (sb *SQLBuilder) SelectQuery(schema, table string, columns []string, conditions map[string]any) *gorm.DB { + query := sb.self.GetTableQuery(schema, table) + + // GORM handles column name escaping automatically + if len(columns) > 0 { + query = query.Select(columns) + } + + // Add WHERE conditions using GORM's native support + if len(conditions) > 0 { + query = query.Where(conditions) + } + + return query +} + +// BuildOrderBy builds ORDER BY clause using GORM's Order method +// GORM handles column name escaping automatically +func (sb *SQLBuilder) BuildOrderBy(query *gorm.DB, sortList []plugins.Sort) *gorm.DB { + for _, sort := range sortList { + query = query.Order(clause.OrderByColumn{ + Column: clause.Column{Name: sort.Column}, + Desc: sort.Direction == plugins.Down, + }) + } + return query +} + +// CreateTableQuery builds a CREATE TABLE statement for DDL operations +// DDL requires manual SQL building as GORM doesn't support dynamic table creation +func (sb *SQLBuilder) CreateTableQuery(schema, table string, columns []ColumnDef) string { + columnDefs := make([]string, len(columns)) + var primaryKeys []string + + for i, col := range columns { + def := sb.QuoteIdentifier(col.Name) + " " + col.Type + + if col.Primary { + primaryKeys = append(primaryKeys, sb.QuoteIdentifier(col.Name)) + } + + if col.Extra != "" { + def += " " + col.Extra + } else if col.NotNull || (!col.Nullable && !col.Primary) { + def += " NOT NULL" + } + + columnDefs[i] = def + } + + // Add primary key constraint if there are primary keys + if len(primaryKeys) > 0 { + columnDefs = append(columnDefs, fmt.Sprintf("PRIMARY KEY (%s)", strings.Join(primaryKeys, ", "))) + } + + // For DDL, we need proper identifier escaping + var fullTableName string + if schema == "" { + fullTableName = sb.QuoteIdentifier(table) + } else { + fullTableName = sb.QuoteIdentifier(schema) + "." + sb.QuoteIdentifier(table) + } + return fmt.Sprintf("CREATE TABLE %s (%s)", fullTableName, strings.Join(columnDefs, ", ")) +} + +// CreateTableQueryWithSuffix builds a CREATE TABLE statement with a suffix (for ClickHouse ENGINE, etc) +func (sb *SQLBuilder) CreateTableQueryWithSuffix(schema, table string, columns []ColumnDef, suffix string) string { + baseQuery := sb.CreateTableQuery(schema, table, columns) + if suffix != "" { + return baseQuery + " " + suffix + } + return baseQuery +} + +// DropTableQuery builds a DROP TABLE statement with dialect identifier quoting. +func (sb *SQLBuilder) DropTableQuery(schema, table string) string { + if schema == "" { + return fmt.Sprintf("DROP TABLE IF EXISTS %s", sb.QuoteIdentifier(table)) + } + return fmt.Sprintf("DROP TABLE IF EXISTS %s.%s", sb.QuoteIdentifier(schema), sb.QuoteIdentifier(table)) +} + +// InsertRow inserts a row using GORM's Create method +// GORM handles all escaping automatically +func (sb *SQLBuilder) InsertRow(schema, table string, data map[string]any) error { + if table == "" { + return fmt.Errorf("table name cannot be empty when inserting row") + } + + // Let GORM handle the table name formatting + // GORM's dialect will properly escape and format the table name + tableName := table + if schema != "" { + // For databases that support schemas, use schema.table format + // GORM will handle this appropriately for each dialect + tableName = schema + "." + table + } + + result := sb.db.Table(tableName).Create(data) + if result.Error != nil { + return result.Error + } + + return nil +} + +// UpdateQuery builds an UPDATE query using GORM's Update methods +// GORM handles all escaping automatically +func (sb *SQLBuilder) UpdateQuery(schema, table string, updates map[string]any, conditions map[string]any) *gorm.DB { + // Let GORM handle the table name formatting + tableName := table + if schema != "" { + tableName = schema + "." + table + } + + query := sb.db.Table(tableName) + + // Add WHERE conditions using GORM's native support + if len(conditions) > 0 { + query = query.Where(conditions) + } + + // Add updates - GORM handles column escaping + return query.Updates(updates) +} + +// DeleteQuery builds a DELETE query using GORM's Delete method +// GORM handles all escaping automatically +func (sb *SQLBuilder) DeleteQuery(schema, table string, conditions map[string]any) *gorm.DB { + // Let GORM handle the table name formatting + tableName := table + if schema != "" { + tableName = schema + "." + table + } + + query := sb.db.Table(tableName) + + // Add WHERE conditions using GORM's native support + if len(conditions) > 0 { + query = query.Where(conditions) + } + + return query.Delete(nil) +} + +// CountQuery builds a COUNT query using GORM's Count method +// GORM handles all escaping automatically +func (sb *SQLBuilder) CountQuery(schema, table string) (int64, error) { + var count int64 + // Let GORM handle the table name formatting + tableName := table + if schema != "" { + tableName = schema + "." + table + } + + err := sb.db.Table(tableName).Count(&count).Error + return count, err +} + +// ColumnDef represents a column definition for CREATE TABLE +type ColumnDef struct { + Name string + Type string + Primary bool + Nullable bool + NotNull bool // Explicit NOT NULL flag (opposite of Nullable) + Extra string // Additional column modifiers (e.g., AUTO_INCREMENT, DEFAULT) +} + +// PrimaryKeyDecorator customizes how a primary key column is decorated. +// It receives the column def (with Name and Type already set) and the +// original engine.Record, and returns the modified def. +type PrimaryKeyDecorator func(def ColumnDef, column engine.Record) ColumnDef + +// RecordsToColumnDefs converts engine.Record columns to ColumnDef slices. +// The decorator callback handles database-specific primary key decoration +// (e.g., AUTO_INCREMENT for MySQL, GENERATED ALWAYS AS IDENTITY for Postgres). +func RecordsToColumnDefs(columns []engine.Record, decorator PrimaryKeyDecorator) []ColumnDef { + defs := make([]ColumnDef, len(columns)) + for i, column := range columns { + def := ColumnDef{ + Name: column.Key, + Type: column.Value, + } + + if primary, ok := column.Extra["primary"]; ok && primary == "true" { + def = decorator(def, column) + } else { + if nullable, ok := column.Extra["nullable"]; ok && nullable == "false" { + def.NotNull = true + } + } + + defs[i] = def + } + return defs +} diff --git a/dataflow/core/src/plugins/gorm/sqlbuilder_test.go b/dataflow/core/src/plugins/gorm/sqlbuilder_test.go new file mode 100644 index 0000000..d2728db --- /dev/null +++ b/dataflow/core/src/plugins/gorm/sqlbuilder_test.go @@ -0,0 +1,124 @@ +package gorm_plugin + +import ( + "strings" + "testing" + + "github.com/clidey/whodb/core/src/plugins" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newDryRunDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ + DryRun: true, + }) + if err != nil { + t.Fatalf("failed to open sqlite dry-run db: %v", err) + } + return db +} + +func varsContain(vars []any, value any) bool { + for _, v := range vars { + if v == value { + return true + } + } + return false +} + +func TestSQLBuilderUsesParameterizedQueries(t *testing.T) { + db := newDryRunDB(t) + sb := NewSQLBuilder(db, nil) + + injected := "Robert'); DROP TABLE users;--" + + selectQuery := sb.SelectQuery("", "users", []string{"id", "name"}, map[string]any{"name": injected}) + selectResult := selectQuery.Find(&[]map[string]any{}) + selectSQL := selectResult.Statement.SQL.String() + + if strings.Contains(selectSQL, injected) { + t.Fatalf("expected SELECT SQL to not include raw user input, got %q", selectSQL) + } + if !strings.Contains(selectSQL, "?") { + t.Fatalf("expected SELECT SQL to use placeholders, got %q", selectSQL) + } + if !varsContain(selectResult.Statement.Vars, injected) { + t.Fatalf("expected SELECT vars to include injected value, got %#v", selectResult.Statement.Vars) + } + + updateResult := sb.UpdateQuery("", "users", map[string]any{"name": injected}, map[string]any{"id": 1}) + updateSQL := updateResult.Statement.SQL.String() + if strings.Contains(updateSQL, injected) { + t.Fatalf("expected UPDATE SQL to not include raw user input, got %q", updateSQL) + } + if !strings.Contains(updateSQL, "?") { + t.Fatalf("expected UPDATE SQL to use placeholders, got %q", updateSQL) + } + if !varsContain(updateResult.Statement.Vars, injected) { + t.Fatalf("expected UPDATE vars to include injected value, got %#v", updateResult.Statement.Vars) + } + + deleteResult := sb.DeleteQuery("", "users", map[string]any{"id": injected}) + deleteSQL := deleteResult.Statement.SQL.String() + if strings.Contains(deleteSQL, injected) { + t.Fatalf("expected DELETE SQL to not include raw user input, got %q", deleteSQL) + } + if !strings.Contains(deleteSQL, "?") { + t.Fatalf("expected DELETE SQL to use placeholders, got %q", deleteSQL) + } + if !varsContain(deleteResult.Statement.Vars, injected) { + t.Fatalf("expected DELETE vars to include injected value, got %#v", deleteResult.Statement.Vars) + } +} + +func TestSQLBuilderBuildOrderByAddsClause(t *testing.T) { + db := newDryRunDB(t) + sb := NewSQLBuilder(db, nil) + + query := sb.SelectQuery("", "users", []string{"id"}, nil) + query = sb.BuildOrderBy(query, []plugins.Sort{ + {Column: "name", Direction: plugins.Down}, + }) + + result := query.Find(&[]map[string]any{}) + sql := result.Statement.SQL.String() + if !strings.Contains(strings.ToUpper(sql), "ORDER BY") { + t.Fatalf("expected ORDER BY in SQL, got %q", sql) + } + if !strings.Contains(strings.ToUpper(sql), "DESC") { + t.Fatalf("expected DESC in SQL, got %q", sql) + } +} + +func TestSQLBuilderCreateTableQuotesIdentifiers(t *testing.T) { + db := newDryRunDB(t) + sb := NewSQLBuilder(db, nil) + + ddl := sb.CreateTableQuery("public", "users", []ColumnDef{ + {Name: "id", Type: "INTEGER", Primary: true}, + {Name: "name", Type: "TEXT", Nullable: false}, + }) + + if !strings.HasPrefix(ddl, "CREATE TABLE ") { + t.Fatalf("expected CREATE TABLE prefix, got %q", ddl) + } + + quotedTable := sb.QuoteIdentifier("public") + "." + sb.QuoteIdentifier("users") + if !strings.Contains(ddl, quotedTable) { + t.Fatalf("expected quoted schema/table identifiers %q, got %q", quotedTable, ddl) + } + + expectedPK := "PRIMARY KEY (" + sb.QuoteIdentifier("id") + ")" + if !strings.Contains(ddl, expectedPK) { + t.Fatalf("expected PRIMARY KEY clause %q, got %q", expectedPK, ddl) + } + + expectedNotNull := sb.QuoteIdentifier("name") + " TEXT NOT NULL" + if !strings.Contains(ddl, expectedNotNull) { + t.Fatalf("expected NOT NULL column clause %q, got %q", expectedNotNull, ddl) + } +} diff --git a/dataflow/core/src/plugins/gorm/update.go b/dataflow/core/src/plugins/gorm/update.go new file mode 100644 index 0000000..9416526 --- /dev/null +++ b/dataflow/core/src/plugins/gorm/update.go @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "errors" + "fmt" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + "gorm.io/gorm" + "slices" +) + +func (p *GormPlugin) UpdateStorageUnit(config *engine.PluginConfig, schema string, storageUnit string, values map[string]string, updatedColumns []string) (bool, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + pkColumns, err := p.GormPluginFunctions.GetPrimaryKeyColumns(db, schema, storageUnit) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to get primary key columns for table %s.%s during update operation", schema, storageUnit)) + pkColumns = []string{} + } + + columnTypeInfos, err := p.GormPluginFunctions.GetColumnTypes(db, schema, storageUnit) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to get column types for table %s.%s during update operation", schema, storageUnit)) + return false, err + } + + conditions := make(map[string]any) + convertedValues := make(map[string]any) + unchangedValues := make(map[string]any) + + for column, strValue := range values { + isPK := slices.Contains(pkColumns, column) + isUpdated := slices.Contains(updatedColumns, column) + + // Only convert columns we actually need: + // - PK columns (for WHERE clause) + // - Updated columns (for SET clause) + // - Unchanged columns only if no PKs exist (fallback WHERE) + needsConversion := isPK || isUpdated || len(pkColumns) == 0 + + if !needsConversion { + continue + } + + colInfo, exists := columnTypeInfos[column] + if !exists { + return false, fmt.Errorf("column '%s' does not exist in table %s", column, storageUnit) + } + + convertedValue, err := p.GormPluginFunctions.ConvertStringValue(strValue, colInfo.Type, colInfo.IsNullable) + if err != nil { + return false, fmt.Errorf("failed to convert value for column '%s' in table %s.%s: %w", column, schema, storageUnit, err) + } + + // GORM handles identifier escaping automatically + if isPK { + conditions[column] = convertedValue + } else if isUpdated { + convertedValues[column] = convertedValue + } else { + // Store unchanged values for WHERE clause if no PKs + unchangedValues[column] = convertedValue + } + } + + // If no columns to update, return early + if len(convertedValues) == 0 { + return true, nil + } + + // Use SQL builder for update query + builder := p.GormPluginFunctions.CreateSQLBuilder(db) + + // Use SQLBuilder for consistent behavior for all database types. + var whereConditions map[string]any + if len(conditions) == 0 { + whereConditions = unchangedValues + } else { + whereConditions = conditions + } + + result := builder.UpdateQuery(schema, storageUnit, convertedValues, whereConditions) + + if result.Error != nil { + log.WithError(result.Error).Error(fmt.Sprintf("Failed to update rows in table %s.%s", schema, storageUnit)) + return false, result.Error + } + + if p.GormPluginFunctions.ShouldCheckRowsAffected() && result.RowsAffected == 0 { + return false, errors.New("no rows were updated") + } + + return true, nil + }) +} diff --git a/dataflow/core/src/plugins/gorm/utils.go b/dataflow/core/src/plugins/gorm/utils.go new file mode 100644 index 0000000..0cd4cfc --- /dev/null +++ b/dataflow/core/src/plugins/gorm/utils.go @@ -0,0 +1,468 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "net" + "strconv" + "strings" + "time" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + mapset "github.com/deckarep/golang-set/v2" + "github.com/dromara/carbon/v2" + "github.com/google/uuid" + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +var ( + intTypes = common.IntTypes + uintTypes = common.UintTypes + bigIntTypes = common.BigIntTypes + decimalTypes = common.DecimalTypes + floatTypes = common.FloatTypes + boolTypes = common.BoolTypes + dateTypes = common.DateTypes + dateTimeTypes = common.DateTimeTypes + uuidTypes = common.UuidTypes + binaryTypes = common.BinaryTypes + jsonTypes = common.JsonTypes + networkTypes = common.NetworkTypes + geometryTypes = common.GeometryTypes + xmlTypes = common.XMLTypes +) + +func (p *GormPlugin) ConvertRecordValuesToMap(values []engine.Record) (map[string]any, error) { + data := make(map[string]any, len(values)) + for _, value := range values { + // Check if this is a NULL value + if value.Extra != nil && value.Extra["IsNull"] == "true" { + data[value.Key] = nil + } else { + isNullable := value.Extra != nil && value.Extra["IsNullable"] == "true" + val, err := p.GormPluginFunctions.ConvertStringValue(value.Value, value.Extra["Type"], isNullable) + if err != nil { + return nil, err + } + data[value.Key] = val + } + } + return data, nil +} + +// GetPrimaryKeyColumns returns the primary key columns for a table using raw SQL +func (p *GormPlugin) GetPrimaryKeyColumns(db *gorm.DB, schema string, tableName string) ([]string, error) { + var primaryKeys []string + query := p.GetPrimaryKeyColQuery() + + // If no query is provided by the plugin, return empty (no primary keys) + if query == "" { + return primaryKeys, nil + } + + rows, err := db.Raw(query, schema, tableName).Rows() + + if err != nil { + // Primary keys might not exist, which is ok - return empty array + log.Debug(fmt.Sprintf("No primary keys found for table %s.%s: %v", schema, tableName, err)) + return primaryKeys, nil + } + defer rows.Close() + + for rows.Next() { + var columnName string + if err := rows.Scan(&columnName); err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to scan primary key column name for table %s.%s", schema, tableName)) + continue + } + primaryKeys = append(primaryKeys, columnName) + } + + // It's ok if there are no primary keys - return empty array + return primaryKeys, nil +} + +// GetColumnTypes uses GORM's Migrator when possible, falls back to raw SQL +func (p *GormPlugin) GetColumnTypes(db *gorm.DB, schema, tableName string) (map[string]ColumnTypeInfo, error) { + migrator := NewMigratorHelper(db, p.GormPluginFunctions) + + fullTableName := p.FormTableName(schema, tableName) + return migrator.GetColumnTypes(fullTableName) +} + +// typeConverter defines how to handle null values and value conversion for a type category. +type typeConverter struct { + // types is the set of base type names this converter handles + types mapset.Set[string] + // isNumeric indicates whether empty strings should be rejected + isNumeric bool + // nullValue returns the appropriate null representation for this type + nullValue func() any + // convert parses the string value into the appropriate Go type. + // baseType and columnType are provided for converters that need them. + convert func(p *GormPlugin, value, baseType, columnType string, isNullable bool) (any, error) +} + +// typeConverters defines the conversion table for all supported type categories. +// Order matters: the first matching converter wins. +var typeConverters = []typeConverter{ + { + types: intTypes, isNumeric: true, + nullValue: func() any { return sql.NullInt64{Valid: false} }, + convert: convertInt, + }, + { + types: uintTypes, isNumeric: true, + nullValue: func() any { return nil }, + convert: convertUint, + }, + { + types: bigIntTypes, isNumeric: true, + nullValue: func() any { return nil }, + convert: convertBigInt, + }, + { + types: decimalTypes, isNumeric: true, + nullValue: func() any { return nil }, + convert: convertDecimal, + }, + { + types: floatTypes, isNumeric: true, + nullValue: func() any { return sql.NullFloat64{Valid: false} }, + convert: convertFloat, + }, + { + types: boolTypes, + nullValue: func() any { return sql.NullBool{Valid: false} }, + convert: convertBool, + }, + { + types: dateTypes, + nullValue: func() any { return sql.NullTime{Valid: false} }, + convert: convertDate, + }, + { + types: dateTimeTypes, + nullValue: func() any { return sql.NullTime{Valid: false} }, + convert: convertDateTime, + }, + { + types: binaryTypes, + nullValue: func() any { return sql.NullString{Valid: false} }, + convert: convertBinary, + }, + { + types: uuidTypes, + nullValue: func() any { return sql.NullString{Valid: false} }, + convert: convertUUID, + }, + { + types: jsonTypes, + nullValue: func() any { return sql.NullString{Valid: false} }, + convert: convertJSON, + }, + { + types: networkTypes, + nullValue: func() any { return sql.NullString{Valid: false} }, + convert: convertNetwork, + }, + { + types: geometryTypes, + nullValue: func() any { return sql.NullString{Valid: false} }, + convert: convertString, + }, + { + types: xmlTypes, + nullValue: func() any { return sql.NullString{Valid: false} }, + convert: convertString, + }, +} + +// ConvertStringValue converts a string value to the appropriate Go type based on column type. +func (p *GormPlugin) ConvertStringValue(value, columnType string, isNullable bool) (any, error) { + columnType = strings.ToUpper(columnType) + columnType = p.NormalizeType(columnType) + + baseType := common.ParseTypeSpec(columnType).BaseType + + // Check if plugin wants to handle this data type + if customValue, handled, err := p.GormPluginFunctions.HandleCustomDataType(value, columnType, isNullable); handled { + return customValue, err + } + + // Find matching converter + for i := range typeConverters { + tc := &typeConverters[i] + if !tc.types.Contains(baseType) { + continue + } + + // Handle NULL values + if isNullable && (value == "" || strings.EqualFold(value, "NULL")) { + return tc.nullValue(), nil + } + + // Reject empty strings for numeric types + if value == "" && tc.isNumeric { + return nil, fmt.Errorf("cannot convert empty string to %s (column is not nullable)", columnType) + } + + return tc.convert(p, value, baseType, columnType, isNullable) + } + + // Default: treat as string/text + if isNullable && (value == "" || strings.EqualFold(value, "NULL")) { + return sql.NullString{Valid: false}, nil + } + if isNullable { + return sql.NullString{String: value, Valid: true}, nil + } + return value, nil +} + +func convertInt(_ *GormPlugin, value, _, columnType string, isNullable bool) (any, error) { + parsedValue, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return nil, err + } + if isNullable { + return sql.NullInt64{Int64: parsedValue, Valid: true}, nil + } + return parsedValue, nil +} + +func convertUint(_ *GormPlugin, value, baseType, _ string, isNullable bool) (any, error) { + bitSize := 64 + if len(baseType) > 4 { + if size, err := strconv.Atoi(baseType[4:]); err == nil { + bitSize = size + } + } + parsedValue, err := strconv.ParseUint(value, 10, bitSize) + if err != nil { + return nil, err + } + if isNullable { + return &parsedValue, nil + } + return parsedValue, nil +} + +func convertBigInt(_ *GormPlugin, value, _, _ string, isNullable bool) (any, error) { + parsedValue := new(big.Int) + if _, ok := parsedValue.SetString(value, 10); !ok { + return nil, fmt.Errorf("invalid big integer value: %s", value) + } + return parsedValue, nil +} + +func convertDecimal(_ *GormPlugin, value, _, _ string, isNullable bool) (any, error) { + parsedValue, err := decimal.NewFromString(value) + if err != nil { + return nil, err + } + if isNullable { + return &parsedValue, nil + } + return parsedValue, nil +} + +func convertFloat(_ *GormPlugin, value, _, _ string, isNullable bool) (any, error) { + parsedValue, err := strconv.ParseFloat(value, 64) + if err != nil { + return nil, err + } + if isNullable { + return sql.NullFloat64{Float64: parsedValue, Valid: true}, nil + } + return parsedValue, nil +} + +func convertBool(_ *GormPlugin, value, _, _ string, isNullable bool) (any, error) { + parsedValue, err := strconv.ParseBool(value) + if err != nil { + return nil, err + } + if isNullable { + return sql.NullBool{Bool: parsedValue, Valid: true}, nil + } + return parsedValue, nil +} + +func convertDate(p *GormPlugin, value, _, _ string, isNullable bool) (any, error) { + date, err := p.parseDate(value) + if err != nil { + return nil, fmt.Errorf("invalid date format: %v", err) + } + if isNullable { + return sql.NullTime{Time: date, Valid: true}, nil + } + return date, nil +} + +func convertDateTime(p *GormPlugin, value, baseType, _ string, isNullable bool) (any, error) { + // TIME-only and YEAR types pass through as strings + if baseType == "TIME" || baseType == "TIME WITH TIME ZONE" || baseType == "YEAR" { + if isNullable { + return sql.NullString{String: value, Valid: true}, nil + } + return value, nil + } + datetime, err := p.parseDateTime(value) + if err != nil { + return nil, fmt.Errorf("invalid datetime format: %v", err) + } + if isNullable { + return sql.NullTime{Time: datetime, Valid: true}, nil + } + return datetime, nil +} + +func convertBinary(_ *GormPlugin, value, _, _ string, isNullable bool) (any, error) { + var blobData []byte + if strings.HasPrefix(value, "0x") || strings.HasPrefix(value, "0X") { + var err error + blobData, err = hex.DecodeString(value[2:]) + if err != nil { + return nil, fmt.Errorf("invalid hex binary format: %v", err) + } + } else { + blobData = []byte(value) + } + if isNullable && len(blobData) == 0 { + return sql.NullString{Valid: false}, nil + } + return blobData, nil +} + +func convertUUID(_ *GormPlugin, value, _, _ string, isNullable bool) (any, error) { + if _, err := uuid.Parse(value); err != nil { + return nil, fmt.Errorf("invalid UUID format: %v", err) + } + if isNullable { + return sql.NullString{String: value, Valid: true}, nil + } + return value, nil +} + +func convertJSON(_ *GormPlugin, value, _, _ string, _ bool) (any, error) { + if !json.Valid([]byte(value)) { + return nil, fmt.Errorf("invalid JSON format") + } + return value, nil +} + +func convertNetwork(_ *GormPlugin, value, baseType, _ string, isNullable bool) (any, error) { + if baseType == "IPV4" || baseType == "IPV6" || baseType == "INET" || baseType == "CIDR" { + ipStr := value + if strings.Contains(value, "/") { + ipStr = strings.Split(value, "/")[0] + } + if ip := net.ParseIP(ipStr); ip == nil { + return nil, fmt.Errorf("invalid IP address format: %s", value) + } + } + if isNullable { + return sql.NullString{String: value, Valid: true}, nil + } + return value, nil +} + +func convertString(_ *GormPlugin, value, _, _ string, isNullable bool) (any, error) { + if isNullable { + return sql.NullString{String: value, Valid: true}, nil + } + return value, nil +} + +func (p *GormPlugin) parseDateTime(value string) (time.Time, error) { + c := carbon.Parse(value) + if c.Error != nil { + return time.Time{}, fmt.Errorf("could not parse datetime '%s': %v", value, c.Error) + } + if c.IsInvalid() { + return time.Time{}, fmt.Errorf("could not parse datetime '%s': invalid date", value) + } + return c.StdTime(), nil +} + +// parseDate converts a string to a time.Time object, truncated to date only +func (p *GormPlugin) parseDate(value string) (time.Time, error) { + c := carbon.Parse(value) + if c.Error != nil { + return time.Time{}, fmt.Errorf("could not parse date '%s': %v", value, c.Error) + } + if c.IsInvalid() { + return time.Time{}, fmt.Errorf("could not parse date '%s': invalid date", value) + } + // Truncate to date only (no time component) + return c.StartOfDay().StdTime(), nil +} + +// GetLastInsertID returns the most recently auto-generated ID. +// The default implementation returns 0 (unsupported). +// Database plugins that support this should override it. +func (p *GormPlugin) GetLastInsertID(db *gorm.DB) (int64, error) { + return 0, nil +} + +// ConvertArrayValue parses array literal values (e.g., "[1, 2, 3]") into Go slices. +// Used by ClickHouse for ARRAY() type conversion. +func (p *GormPlugin) ConvertArrayValue(value string, columnType string) (any, error) { + // Extract the element type from ARRAY(Type) + upperType := strings.ToUpper(columnType) + if strings.HasPrefix(upperType, "ARRAY(") { + columnType = columnType[6:] // strip "ARRAY(" (6 chars) + } + if strings.HasSuffix(columnType, ")") { + columnType = columnType[:len(columnType)-1] + } + elementType := columnType + + // Remove brackets and split by comma + value = strings.Trim(value, "[]") + if value == "" { + return []any{}, nil + } + + elements := strings.Split(value, ",") + result := make([]any, 0, len(elements)) + + for _, element := range elements { + element = strings.TrimSpace(element) + if element == "" { + continue + } + + converted, err := p.GormPluginFunctions.ConvertStringValue(element, elementType, false) + if err != nil { + log.WithError(err).WithField("element", element).WithField("elementType", elementType).Error("Failed to convert array element") + return nil, fmt.Errorf("converting array element: %w", err) + } + result = append(result, converted) + } + + return result, nil +} diff --git a/dataflow/core/src/plugins/gorm/utils_test.go b/dataflow/core/src/plugins/gorm/utils_test.go new file mode 100644 index 0000000..0c3cdde --- /dev/null +++ b/dataflow/core/src/plugins/gorm/utils_test.go @@ -0,0 +1,180 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package gorm_plugin + +import ( + "database/sql" + "testing" + + "github.com/clidey/whodb/core/src/engine" +) + +// newTestPlugin creates a GormPlugin suitable for unit testing. +// It uses the default GormPlugin implementations (no DB needed). +func newTestPlugin() *GormPlugin { + p := &GormPlugin{} + p.Type = engine.DatabaseType_Postgres // type doesn't matter for conversion tests + p.GormPluginFunctions = p // self-referencing so interface calls resolve to defaults + return p +} + +func TestConvertStringValue_NullableCaseInsensitive(t *testing.T) { + p := newTestPlugin() + + tests := []struct { + name string + value string + columnType string + isNullable bool + wantErr bool + checkVal func(t *testing.T, val any) + }{ + // NULLABLE() and LOWCARDINALITY() wrapper tests are in the ClickHouse plugin + // test suite since those are ClickHouse-specific type encodings. + { + name: "plain INT32 without nullable", + value: "42", + columnType: "INT32", + checkVal: func(t *testing.T, val any) { + iv, ok := val.(int64) + if !ok { + t.Fatalf("expected int64, got %T", val) + } + if iv != 42 { + t.Fatalf("expected 42, got %d", iv) + } + }, + }, + { + name: "empty value for nullable INTEGER via isNullable param", + value: "", + columnType: "INTEGER", + isNullable: true, + checkVal: func(t *testing.T, val any) { + nv, ok := val.(sql.NullInt64) + if !ok { + t.Fatalf("expected sql.NullInt64, got %T", val) + } + if nv.Valid { + t.Fatalf("expected NullInt64 with Valid=false, got %+v", nv) + } + }, + }, + { + name: "empty value for non-nullable INTEGER errors", + value: "", + columnType: "INTEGER", + isNullable: false, + wantErr: true, + }, + { + name: "empty value for nullable FLOAT via isNullable param", + value: "", + columnType: "FLOAT", + isNullable: true, + checkVal: func(t *testing.T, val any) { + nv, ok := val.(sql.NullFloat64) + if !ok { + t.Fatalf("expected sql.NullFloat64, got %T", val) + } + if nv.Valid { + t.Fatalf("expected NullFloat64 with Valid=false, got %+v", nv) + } + }, + }, + { + name: "empty value for nullable TEXT via isNullable param", + value: "", + columnType: "TEXT", + isNullable: true, + checkVal: func(t *testing.T, val any) { + nv, ok := val.(sql.NullString) + if !ok { + t.Fatalf("expected sql.NullString, got %T", val) + } + if nv.Valid { + t.Fatalf("expected NullString with Valid=false, got %+v", nv) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + val, err := p.ConvertStringValue(tt.value, tt.columnType, tt.isNullable) + if (err != nil) != tt.wantErr { + t.Fatalf("error = %v, wantErr = %v", err, tt.wantErr) + } + if tt.checkVal != nil { + tt.checkVal(t, val) + } + }) + } +} + +func TestConvertArrayValue_NoInfiniteRecursion(t *testing.T) { + p := newTestPlugin() + + // Array type handling is now in the ClickHouse plugin's ConvertStringValue. + // This test verifies the base convertArrayValue helper still works correctly + // when called directly (it's used by ClickHouse through embedding). + tests := []struct { + name string + value string + columnType string + wantLen int + wantErr bool + }{ + { + name: "ARRAY(INT32) uppercase", + value: "[100, 200]", + columnType: "ARRAY(INT32)", + wantLen: 2, + }, + { + name: "Array(Int32) mixed case", + value: "[1, 2, 3]", + columnType: "Array(Int32)", + wantLen: 3, + }, + { + name: "ARRAY(INT32) empty", + value: "[]", + columnType: "ARRAY(INT32)", + wantLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + val, err := p.ConvertArrayValue(tt.value, tt.columnType) + if (err != nil) != tt.wantErr { + t.Fatalf("error = %v, wantErr = %v", err, tt.wantErr) + } + if err != nil { + return + } + arr, ok := val.([]any) + if !ok { + t.Fatalf("expected []any, got %T", val) + } + if len(arr) != tt.wantLen { + t.Fatalf("expected %d elements, got %d: %v", tt.wantLen, len(arr), arr) + } + }) + } +} diff --git a/dataflow/core/src/plugins/graphutil/graphutil.go b/dataflow/core/src/plugins/graphutil/graphutil.go new file mode 100644 index 0000000..fd3a9a8 --- /dev/null +++ b/dataflow/core/src/plugins/graphutil/graphutil.go @@ -0,0 +1,112 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package graphutil + +import ( + "strings" + + "github.com/clidey/whodb/core/src/engine" +) + +// Relation represents an inferred relationship between two storage units. +type Relation struct { + Table1 string + Table2 string + Relation string + SourceColumn string + TargetColumn string +} + +// InferForeignKeys examines a set of field names from a given storage unit and +// returns a map of referenced storage unit name to the field name that references it. +// It uses singular/plural heuristics to match field names like "user_id" to a "users" collection. +func InferForeignKeys(currentUnit string, fieldNames []string, allUnits []string) map[string]string { + foreignKeys := make(map[string]string) + + for _, fieldName := range fieldNames { + if fieldName == "_id" { + continue + } + + lowerField := strings.ToLower(fieldName) + + // Check for explicit relation hints (e.g., "order.id") + if strings.HasSuffix(lowerField, ".id") || strings.HasSuffix(lowerField, "id") || strings.HasSuffix(lowerField, "_id") { + for _, otherUnit := range allUnits { + if otherUnit == currentUnit { + continue + } + if strings.Contains(lowerField, strings.ToLower(otherUnit)) { + foreignKeys[otherUnit] = fieldName + break + } + } + } + + // Check for naming convention matches (e.g., "user_id" → "users") + for _, otherUnit := range allUnits { + if otherUnit == currentUnit { + continue + } + + singularName := strings.TrimSuffix(otherUnit, "s") + pluralName := otherUnit + if !strings.HasSuffix(otherUnit, "s") { + pluralName = otherUnit + "s" + } + + if lowerField == strings.ToLower(singularName)+"_id" || + lowerField == strings.ToLower(singularName)+"id" || + lowerField == strings.ToLower(otherUnit)+"_id" || + lowerField == strings.ToLower(otherUnit)+"id" || + lowerField == strings.ToLower(pluralName)+"_id" || + lowerField == strings.ToLower(pluralName)+"id" { + foreignKeys[otherUnit] = fieldName + break + } + } + } + + return foreignKeys +} + +// BuildGraphUnits assembles GraphUnit slices from a relation list and the full set of storage units. +func BuildGraphUnits(relations []Relation, storageUnits []engine.StorageUnit) []engine.GraphUnit { + tableMap := make(map[string][]engine.GraphUnitRelationship) + for _, tr := range relations { + sourceCol := tr.SourceColumn + targetCol := tr.TargetColumn + tableMap[tr.Table1] = append(tableMap[tr.Table1], engine.GraphUnitRelationship{ + Name: tr.Table2, + RelationshipType: engine.GraphUnitRelationshipType(tr.Relation), + SourceColumn: &sourceCol, + TargetColumn: &targetCol, + }) + } + + tables := make([]engine.GraphUnit, 0, len(storageUnits)) + for _, storageUnit := range storageUnits { + foundTable, ok := tableMap[storageUnit.Name] + var rels []engine.GraphUnitRelationship + if ok { + rels = foundTable + } + tables = append(tables, engine.GraphUnit{Unit: storageUnit, Relations: rels}) + } + + return tables +} diff --git a/dataflow/core/src/plugins/mongodb/add.go b/dataflow/core/src/plugins/mongodb/add.go new file mode 100644 index 0000000..097252c --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/add.go @@ -0,0 +1,290 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mongodb + +import ( + "context" + "errors" + "strconv" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func (p *MongoDBPlugin) AddStorageUnit(config *engine.PluginConfig, schema string, storageUnit string, fields []engine.Record) (bool, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "schema": schema, + "storageUnit": storageUnit, + }).Error("Failed to connect to MongoDB for adding storage unit") + return false, err + } + defer disconnectClient(client) + + database := client.Database(schema) + + err = createCollectionIfNotExists(database, storageUnit, fields) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "schema": schema, + "storageUnit": storageUnit, + }).Error("Failed to create MongoDB collection") + return false, err + } + + return true, nil +} + +func (p *MongoDBPlugin) AddRow(config *engine.PluginConfig, schema string, storageUnit string, values []engine.Record) (bool, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "schema": schema, + "storageUnit": storageUnit, + }).Error("Failed to connect to MongoDB for adding row") + return false, err + } + defer disconnectClient(client) + + collection := client.Database(schema).Collection(storageUnit) + + document := make(map[string]any) + for _, value := range values { + // Skip null values - MongoDB treats missing fields as null for optional fields + if value.Extra["IsNull"] == "true" { + continue + } + typeHint := value.Extra["Type"] + document[value.Key] = coerceMongoValue(value.Key, value.Value, typeHint) + } + + // If _id provided as hex string, convert to ObjectID for proper identity handling + if rawID, exists := document["_id"]; exists { + if id, err := normalizeMongoID(rawID); err == nil { + document["_id"] = id + } + } + + _, err = collection.InsertOne(context.Background(), document) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "schema": schema, + "storageUnit": storageUnit, + "documentFields": len(values), + }).Error("Failed to insert document into MongoDB collection") + return false, handleMongoError(err) + } + + return true, nil +} + +// AddRowReturningID is not supported for MongoDB (document IDs are generated client-side). +// Returns 0 as MongoDB uses ObjectIDs, not auto-increment integers. +func (p *MongoDBPlugin) AddRowReturningID(config *engine.PluginConfig, schema string, storageUnit string, values []engine.Record) (int64, error) { + _, err := p.AddRow(config, schema, storageUnit, values) + if err != nil { + return 0, err + } + return 0, nil +} + +func (p *MongoDBPlugin) BulkAddRows(config *engine.PluginConfig, schema string, storageUnit string, rows [][]engine.Record) (bool, error) { + if len(rows) == 0 { + return true, nil + } + + client, err := DB(config) + if err != nil { + return false, err + } + defer disconnectClient(client) + + collection := client.Database(schema).Collection(storageUnit) + + if len(config.UpsertPKColumns) > 0 { + return bulkUpsertMongo(collection, config.UpsertPKColumns, rows) + } + + documents := make([]any, len(rows)) + for i, row := range rows { + document := recordsToMongoDoc(row) + documents[i] = document + } + + _, err = collection.InsertMany(context.Background(), documents) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "schema": schema, + "storageUnit": storageUnit, + "rowCount": len(rows), + }).Error("Failed to bulk insert documents into MongoDB collection") + return false, handleMongoError(err) + } + + return true, nil +} + +// recordsToMongoDoc converts a row of engine.Record values into a MongoDB document map. +func recordsToMongoDoc(row []engine.Record) map[string]any { + document := make(map[string]any) + for _, value := range row { + if value.Extra["IsNull"] == "true" { + continue + } + typeHint := value.Extra["Type"] + document[value.Key] = coerceMongoValue(value.Key, value.Value, typeHint) + } + if rawID, exists := document["_id"]; exists { + if id, err := normalizeMongoID(rawID); err == nil { + document["_id"] = id + } + } + return document +} + +// bulkUpsertMongo performs ReplaceOne with upsert:true for each document, batched via BulkWrite. +func bulkUpsertMongo(collection *mongo.Collection, pkColumns []string, rows [][]engine.Record) (bool, error) { + const batchSize = 1000 + + for i := 0; i < len(rows); i += batchSize { + end := i + batchSize + if end > len(rows) { + end = len(rows) + } + + models := make([]mongo.WriteModel, 0, end-i) + for _, row := range rows[i:end] { + document := recordsToMongoDoc(row) + + filter := bson.D{} + for _, pk := range pkColumns { + if val, exists := document[pk]; exists { + filter = append(filter, bson.E{Key: pk, Value: val}) + } + } + + models = append(models, mongo.NewReplaceOneModel(). + SetFilter(filter). + SetReplacement(document). + SetUpsert(true)) + } + + _, err := collection.BulkWrite(context.Background(), models) + if err != nil { + return false, handleMongoError(err) + } + } + + return true, nil +} + +func createCollectionIfNotExists(database *mongo.Database, collectionName string, fields []engine.Record) error { + collections, err := database.ListCollectionNames(context.Background(), bson.D{}) + if err != nil { + log.WithError(err).WithField("collectionName", collectionName).Error("Failed to list MongoDB collection names") + return err + } + + for _, col := range collections { + if col == collectionName { + return errors.New("collection already exists") + } + } + + opts := options.CreateCollection() + if validator := buildValidator(fields); validator != nil { + opts.SetValidator(validator) + } + + err = database.CreateCollection(context.Background(), collectionName, opts) + if err != nil { + log.WithError(err).WithField("collectionName", collectionName).Error("Failed to create MongoDB collection") + return err + } + + return nil +} + +// buildValidator creates a simple JSON Schema validator from provided field definitions. +// It is best-effort: if no fields are provided, returns nil to keep default collection behavior. +func buildValidator(fields []engine.Record) bson.M { + if len(fields) == 0 { + return nil + } + + properties := bson.M{} + var required []string + + for _, f := range fields { + bsonType := mapMongoFieldType(f.Value) + properties[f.Key] = bson.M{"bsonType": bsonType} + + nullable, err := strconv.ParseBool(f.Extra["Nullable"]) + if err != nil { + nullable = false + } + if !nullable { + required = append(required, f.Key) + } + } + + schema := bson.M{ + "bsonType": "object", + "properties": properties, + } + if len(required) > 0 { + schema["required"] = required + } + + return bson.M{ + "$jsonSchema": schema, + } +} + +// mapMongoFieldType maps an arbitrary field type string to a BSON type for validator use. +func mapMongoFieldType(typeStr string) string { + lower := strings.ToLower(typeStr) + switch { + case strings.Contains(lower, "objectid"): + return "objectId" + case strings.Contains(lower, "int"): + return "int" + case strings.Contains(lower, "long"): + return "long" + case strings.Contains(lower, "double"), strings.Contains(lower, "float"), strings.Contains(lower, "decimal"): + return "double" + case strings.Contains(lower, "bool"): + return "bool" + case strings.Contains(lower, "date"), strings.Contains(lower, "time"): + return "date" + case strings.Contains(lower, "array"): + return "array" + case strings.Contains(lower, "object"), strings.Contains(lower, "json"): + return "object" + default: + return "string" + } +} diff --git a/dataflow/core/src/plugins/mongodb/constraints_test.go b/dataflow/core/src/plugins/mongodb/constraints_test.go new file mode 100644 index 0000000..cf817e7 --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/constraints_test.go @@ -0,0 +1,224 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mongodb + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" +) + +func TestParseMongoDBJsonSchema(t *testing.T) { + // Test schema matching the validated_products collection + schema := bson.M{ + "bsonType": "object", + "required": bson.A{"name", "price", "status", "category"}, + "properties": bson.M{ + "name": bson.M{ + "bsonType": "string", + "maxLength": float64(100), + }, + "price": bson.M{ + "bsonType": "double", + "minimum": float64(0), + "maximum": float64(99999.99), + }, + "status": bson.M{ + "bsonType": "string", + "enum": bson.A{"active", "inactive", "discontinued"}, + }, + "category": bson.M{ + "bsonType": "string", + "enum": bson.A{"electronics", "clothing", "food", "other"}, + }, + "stock_quantity": bson.M{ + "bsonType": "int", + "minimum": float64(0), + "maximum": float64(10000), + }, + "description": bson.M{ + "bsonType": "string", + "maxLength": float64(500), + }, + "rating": bson.M{ + "bsonType": "double", + "minimum": float64(0), + "maximum": float64(5), + }, + }, + } + + constraints := parseMongoDBJsonSchema(schema) + + // Test required fields have nullable: false + t.Run("required fields are not nullable", func(t *testing.T) { + requiredFields := []string{"name", "price", "status", "category"} + for _, field := range requiredFields { + if constraints[field] == nil { + t.Errorf("expected constraints for required field %q", field) + continue + } + if nullable, ok := constraints[field]["nullable"].(bool); !ok || nullable { + t.Errorf("expected field %q to have nullable=false, got %v", field, constraints[field]["nullable"]) + } + } + }) + + // Test optional fields have nullable: true + t.Run("optional fields are nullable", func(t *testing.T) { + optionalFields := []string{"stock_quantity", "description", "rating"} + for _, field := range optionalFields { + if constraints[field] == nil { + t.Errorf("expected constraints for optional field %q", field) + continue + } + if nullable, ok := constraints[field]["nullable"].(bool); !ok || !nullable { + t.Errorf("expected field %q to have nullable=true, got %v", field, constraints[field]["nullable"]) + } + } + }) + + // Test enum values are extracted as check_values + t.Run("enum values extracted as check_values", func(t *testing.T) { + statusValues, ok := constraints["status"]["check_values"].([]string) + if !ok { + t.Fatalf("expected check_values for status field") + } + expected := []string{"active", "inactive", "discontinued"} + if len(statusValues) != len(expected) { + t.Errorf("expected %d status values, got %d", len(expected), len(statusValues)) + } + for i, v := range expected { + if statusValues[i] != v { + t.Errorf("expected status value %q at index %d, got %q", v, i, statusValues[i]) + } + } + + categoryValues, ok := constraints["category"]["check_values"].([]string) + if !ok { + t.Fatalf("expected check_values for category field") + } + expectedCat := []string{"electronics", "clothing", "food", "other"} + if len(categoryValues) != len(expectedCat) { + t.Errorf("expected %d category values, got %d", len(expectedCat), len(categoryValues)) + } + }) + + // Test numeric constraints are extracted + t.Run("numeric min/max extracted as check_min/check_max", func(t *testing.T) { + // Price constraints + if min, ok := constraints["price"]["check_min"].(float64); !ok || min != 0 { + t.Errorf("expected price check_min=0, got %v", constraints["price"]["check_min"]) + } + if max, ok := constraints["price"]["check_max"].(float64); !ok || max != 99999.99 { + t.Errorf("expected price check_max=99999.99, got %v", constraints["price"]["check_max"]) + } + + // Rating constraints + if min, ok := constraints["rating"]["check_min"].(float64); !ok || min != 0 { + t.Errorf("expected rating check_min=0, got %v", constraints["rating"]["check_min"]) + } + if max, ok := constraints["rating"]["check_max"].(float64); !ok || max != 5 { + t.Errorf("expected rating check_max=5, got %v", constraints["rating"]["check_max"]) + } + + // Stock quantity constraints (int32 values) + if min, ok := constraints["stock_quantity"]["check_min"].(float64); !ok || min != 0 { + t.Errorf("expected stock_quantity check_min=0, got %v", constraints["stock_quantity"]["check_min"]) + } + if max, ok := constraints["stock_quantity"]["check_max"].(float64); !ok || max != 10000 { + t.Errorf("expected stock_quantity check_max=10000, got %v", constraints["stock_quantity"]["check_max"]) + } + }) + + // Test maxLength is extracted as length + t.Run("maxLength extracted as length", func(t *testing.T) { + if length, ok := constraints["name"]["length"].(int); !ok || length != 100 { + t.Errorf("expected name length=100, got %v", constraints["name"]["length"]) + } + if length, ok := constraints["description"]["length"].(int); !ok || length != 500 { + t.Errorf("expected description length=500, got %v", constraints["description"]["length"]) + } + }) + + // Test bsonType is extracted as type + t.Run("bsonType extracted as type", func(t *testing.T) { + if typ, ok := constraints["name"]["type"].(string); !ok || typ != "string" { + t.Errorf("expected name type=string, got %v", constraints["name"]["type"]) + } + if typ, ok := constraints["price"]["type"].(string); !ok || typ != "double" { + t.Errorf("expected price type=double, got %v", constraints["price"]["type"]) + } + if typ, ok := constraints["stock_quantity"]["type"].(string); !ok || typ != "int" { + t.Errorf("expected stock_quantity type=int, got %v", constraints["stock_quantity"]["type"]) + } + }) +} + +func TestParseMongoDBJsonSchemaEmptySchema(t *testing.T) { + constraints := parseMongoDBJsonSchema(bson.M{}) + if len(constraints) != 0 { + t.Errorf("expected empty constraints for empty schema, got %v", constraints) + } +} + +func TestParseMongoDBJsonSchemaNoProperties(t *testing.T) { + schema := bson.M{ + "bsonType": "object", + "required": bson.A{"field1"}, + } + constraints := parseMongoDBJsonSchema(schema) + if len(constraints) != 0 { + t.Errorf("expected empty constraints when no properties defined, got %v", constraints) + } +} + +// TestParseMongoDBJsonSchemaInt32Values verifies that int32 values (as MongoDB +// actually stores integer literals in $jsonSchema) are correctly converted. +func TestParseMongoDBJsonSchemaInt32Values(t *testing.T) { + schema := bson.M{ + "bsonType": "object", + "properties": bson.M{ + "rating": bson.M{ + "bsonType": "double", + "minimum": int32(0), + "maximum": int32(5), + }, + "name": bson.M{ + "bsonType": "string", + "maxLength": int32(100), + }, + }, + } + + constraints := parseMongoDBJsonSchema(schema) + + t.Run("int32 minimum/maximum converted to float64", func(t *testing.T) { + if minVal, ok := constraints["rating"]["check_min"].(float64); !ok || minVal != 0 { + t.Errorf("expected rating check_min=0 (float64), got %v (%T)", constraints["rating"]["check_min"], constraints["rating"]["check_min"]) + } + if maxVal, ok := constraints["rating"]["check_max"].(float64); !ok || maxVal != 5 { + t.Errorf("expected rating check_max=5 (float64), got %v (%T)", constraints["rating"]["check_max"], constraints["rating"]["check_max"]) + } + }) + + t.Run("int32 maxLength converted to int", func(t *testing.T) { + if length, ok := constraints["name"]["length"].(int); !ok || length != 100 { + t.Errorf("expected name length=100 (int), got %v (%T)", constraints["name"]["length"], constraints["name"]["length"]) + } + }) +} diff --git a/dataflow/core/src/plugins/mongodb/db.go b/dataflow/core/src/plugins/mongodb/db.go new file mode 100644 index 0000000..4fdd94f --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/db.go @@ -0,0 +1,136 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mongodb + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins/ssl" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// DefaultOperationTimeout is the default timeout for MongoDB operations. +const DefaultOperationTimeout = 30 * time.Second + +// opCtx returns a context with a timeout for MongoDB operations. +func opCtx() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), DefaultOperationTimeout) +} + +// disconnectClient disconnects a MongoDB client using a fresh short-lived context, +func disconnectClient(client *mongo.Client) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client.Disconnect(ctx) +} + +func DB(config *engine.PluginConfig) (*mongo.Client, error) { + ctx, cancel := opCtx() + defer cancel() + port, err := strconv.Atoi(common.GetRecordValueOrDefault(config.Credentials.Advanced, "Port", "27017")) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "portValue": common.GetRecordValueOrDefault(config.Credentials.Advanced, "Port", "27017"), + }).Error("Failed to parse MongoDB port number") + return nil, err + } + queryParams := common.GetRecordValueOrDefault(config.Credentials.Advanced, "URL Params", "") + dnsEnabled, err := strconv.ParseBool(common.GetRecordValueOrDefault(config.Credentials.Advanced, "DNS Enabled", "false")) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "dnsEnabledValue": common.GetRecordValueOrDefault(config.Credentials.Advanced, "DNS Enabled", "false"), + }).Error("Failed to parse MongoDB DNS enabled flag") + return nil, err + } + + connectionURI := strings.Builder{} + clientOptions := options.Client() + + if dnsEnabled { + connectionURI.WriteString("mongodb+srv://") + connectionURI.WriteString(fmt.Sprintf("%s/", config.Credentials.Hostname)) + } else { + connectionURI.WriteString("mongodb://") + connectionURI.WriteString(fmt.Sprintf("%s:%d/", config.Credentials.Hostname, port)) + } + + connectionURI.WriteString(config.Credentials.Database) + connectionURI.WriteString(queryParams) + + clientOptions.ApplyURI(connectionURI.String()) + clientOptions.SetMaxPoolSize(10) + clientOptions.SetMinPoolSize(2) + clientOptions.SetMaxConnIdleTime(5 * time.Minute) + if config.Credentials.Username != "" || config.Credentials.Password != "" { + clientOptions.SetAuth(options.Credential{ + Username: url.QueryEscape(config.Credentials.Username), + Password: url.QueryEscape(config.Credentials.Password), + }) + } + + // Configure SSL/TLS + sslMode := "disabled" + sslConfig := ssl.ParseSSLConfig(engine.DatabaseType_MongoDB, config.Credentials.Advanced, config.Credentials.Hostname, config.Credentials.IsProfile) + if sslConfig != nil && sslConfig.IsEnabled() { + sslMode = string(sslConfig.Mode) + tlsConfig, err := ssl.BuildTLSConfig(sslConfig, config.Credentials.Hostname) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "sslMode": sslConfig.Mode, + }).Error("Failed to build TLS configuration for MongoDB") + return nil, err + } + if tlsConfig != nil { + clientOptions.SetTLSConfig(tlsConfig) + } + } + + client, err := mongo.Connect(ctx, clientOptions) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": config.Credentials.Database, + "username": config.Credentials.Username, + "dnsEnabled": dnsEnabled, + "port": port, + "sslMode": sslMode, + }).Error("Failed to connect to MongoDB") + return nil, err + } + err = client.Ping(ctx, nil) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": config.Credentials.Database, + "username": config.Credentials.Username, + }).Error("Failed to ping MongoDB server") + return nil, err + } + return client, nil +} diff --git a/dataflow/core/src/plugins/mongodb/delete.go b/dataflow/core/src/plugins/mongodb/delete.go new file mode 100644 index 0000000..148cb3c --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/delete.go @@ -0,0 +1,110 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mongodb + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "go.mongodb.org/mongo-driver/bson" +) + +func (p *MongoDBPlugin) DeleteRow(config *engine.PluginConfig, database string, storageUnit string, values map[string]string) (bool, error) { + ctx, cancel := opCtx() + defer cancel() + client, err := DB(config) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + }).Error("Failed to connect to MongoDB for row deletion") + return false, err + } + defer disconnectClient(client) + + db := client.Database(database) + collection := db.Collection(storageUnit) + + documentJSON, ok := values["document"] + if !ok { + log.WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + "availableKeys": getMapKeys(values), + }).Error("Missing 'document' key in values map for MongoDB row deletion") + return false, errors.New("missing 'document' key in values map") + } + + var jsonValues bson.M + if err := json.Unmarshal([]byte(documentJSON), &jsonValues); err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + "documentJSON": documentJSON, + }).Error("Failed to unmarshal document JSON for MongoDB row deletion") + return false, err + } + + id, ok := jsonValues["_id"] + if !ok { + log.WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + "documentFields": getDocumentFieldNames(jsonValues), + }).Error("Missing '_id' field in document for MongoDB row deletion") + return false, errors.New("missing '_id' field in the document") + } + + objectID, err := normalizeMongoID(id) + if err != nil { + return false, fmt.Errorf("invalid '_id' value: %w", err) + } + + delete(jsonValues, "_id") + + filter := bson.M{"_id": objectID} + + result, err := collection.DeleteOne(ctx, filter) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + "objectID": objectID, + }).Error("Failed to delete document from MongoDB collection") + return false, handleMongoError(err) + } + + if result.DeletedCount == 0 { + log.WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + "objectID": objectID, + }).Warn("No documents were deleted from MongoDB collection") + return false, errors.New("no documents were deleted") + } + + return true, nil +} diff --git a/dataflow/core/src/plugins/mongodb/errors.go b/dataflow/core/src/plugins/mongodb/errors.go new file mode 100644 index 0000000..8e7cd4d --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/errors.go @@ -0,0 +1,60 @@ +/* + * // Copyright 2025 Clidey, Inc. + * // + * // 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. + */ + +package mongodb + +import ( + "errors" + "fmt" + "strings" + + "go.mongodb.org/mongo-driver/mongo" +) + +// handleMongoError converts low-level MongoDB errors into user-friendly messages. +func handleMongoError(err error) error { + if err == nil { + return nil + } + + var we mongo.WriteException + if errors.As(err, &we) { + for _, werr := range we.WriteErrors { + switch werr.Code { + case 11000: + return fmt.Errorf("duplicate key: a document with the same identifier already exists") + default: + return fmt.Errorf("write error (%d): %s", werr.Code, werr.Message) + } + } + } + + var ce mongo.CommandError + if errors.As(err, &ce) { + switch ce.Code { + case 48: // NamespaceExists + return fmt.Errorf("collection already exists") + case 121: // Document validation failure + return fmt.Errorf("document validation failed: %s", ce.Message) + } + return fmt.Errorf("command error (%d): %s", ce.Code, ce.Message) + } + + // Default: sanitized error text + msg := err.Error() + msg = strings.ReplaceAll(msg, "\n", " ") + return fmt.Errorf("mongodb operation failed: %s", msg) +} diff --git a/dataflow/core/src/plugins/mongodb/export.go b/dataflow/core/src/plugins/mongodb/export.go new file mode 100644 index 0000000..97487e9 --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/export.go @@ -0,0 +1,329 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mongodb + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "time" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// ExportData exports MongoDB collection data to tabular format +func (p *MongoDBPlugin) ExportData(config *engine.PluginConfig, schema string, storageUnit string, writer func([]string) error, selectedRows []map[string]any) error { + // If selected rows are provided, export only those + if len(selectedRows) > 0 { + // Extract field names from selected rows + fieldSet := make(map[string]bool) + for _, row := range selectedRows { + for k := range row { + fieldSet[k] = true + } + } + + // Convert to sorted slice + fieldNames := make([]string, 0, len(fieldSet)) + for field := range fieldSet { + fieldNames = append(fieldNames, field) + } + sort.Strings(fieldNames) + + // Write headers + headers := make([]string, len(fieldNames)) + for i, field := range fieldNames { + headers[i] = common.FormatCSVHeader(field, "BSON") + } + if err := writer(headers); err != nil { + return fmt.Errorf("failed to write headers: %v", err) + } + + // Write selected rows + for _, row := range selectedRows { + rowData := make([]string, len(fieldNames)) + for i, field := range fieldNames { + if val, exists := row[field]; exists { + rowData[i] = p.formatBSONValue(val) + } else { + rowData[i] = "" + } + } + if err := writer(rowData); err != nil { + return fmt.Errorf("failed to write row: %v", err) + } + } + return nil + } + client, err := DB(config) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "schema": schema, + "storageUnit": storageUnit, + }).Error("Failed to connect to MongoDB for data export") + return err + } + + db := client.Database(schema) + collection := db.Collection(storageUnit) + + // First, get all field names from a sample of documents + fieldNames, err := p.getCollectionFields(collection) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "schema": schema, + "storageUnit": storageUnit, + }).Error("Failed to get MongoDB collection fields for export") + return fmt.Errorf("failed to get collection fields: %v", err) + } + + // Write headers with type information + headers := make([]string, len(fieldNames)) + for i, field := range fieldNames { + headers[i] = common.FormatCSVHeader(field, "BSON") + } + if err := writer(headers); err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "schema": schema, + "storageUnit": storageUnit, + "headerCount": len(headers), + }).Error("Failed to write CSV headers for MongoDB export") + return fmt.Errorf("failed to write headers: %v", err) + } + + // Export all documents + cursor, err := collection.Find(context.Background(), bson.D{}) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "schema": schema, + "storageUnit": storageUnit, + }).Error("Failed to query MongoDB collection for export") + return fmt.Errorf("failed to query collection: %v", err) + } + defer cursor.Close(context.Background()) + + rowCount := 0 + for cursor.Next(context.Background()) { + var doc bson.M + if err := cursor.Decode(&doc); err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "schema": schema, + "storageUnit": storageUnit, + "rowNumber": rowCount + 1, + }).Error("Failed to decode MongoDB document during export") + return fmt.Errorf("failed to decode document: %v", err) + } + + row := make([]string, len(fieldNames)) + for i, field := range fieldNames { + if val, exists := doc[field]; exists { + row[i] = p.formatBSONValue(val) + } else { + row[i] = "" + } + } + + if err := writer(row); err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "schema": schema, + "storageUnit": storageUnit, + "rowNumber": rowCount + 1, + }).Error("Failed to write CSV row for MongoDB export") + return fmt.Errorf("failed to write row: %v", err) + } + + rowCount++ + } + + return cursor.Err() +} + +// ExportDataNDJSON streams MongoDB data as NDJSON. +func (p *MongoDBPlugin) ExportDataNDJSON(config *engine.PluginConfig, schema string, storageUnit string, writer func(string) error, selectedRows []map[string]any) error { + if len(selectedRows) > 0 { + for _, row := range selectedRows { + line, err := json.Marshal(row) + if err != nil { + return err + } + if err := writer(string(line)); err != nil { + return err + } + } + return nil + } + + client, err := DB(config) + if err != nil { + return err + } + defer disconnectClient(client) + + collection := client.Database(schema).Collection(storageUnit) + cursor, err := collection.Find(context.Background(), bson.D{}) + if err != nil { + return err + } + defer cursor.Close(context.Background()) + + for cursor.Next(context.Background()) { + var doc bson.M + if err := cursor.Decode(&doc); err != nil { + return err + } + line, err := json.Marshal(doc) + if err != nil { + return err + } + if err := writer(string(line)); err != nil { + return err + } + } + + return cursor.Err() +} + +// Helper functions + +func (p *MongoDBPlugin) getCollectionFields(collection *mongo.Collection) ([]string, error) { + // Sample documents to get field names + opts := options.Find().SetLimit(100) + cursor, err := collection.Find(context.Background(), bson.D{}, opts) + if err != nil { + log.WithError(err).WithField("collectionName", collection.Name()).Error("Failed to sample MongoDB collection documents for field extraction") + return nil, err + } + defer cursor.Close(context.Background()) + + fieldSet := make(map[string]bool) + for cursor.Next(context.Background()) { + var doc bson.M + if err := cursor.Decode(&doc); err != nil { + continue + } + for field := range doc { + fieldSet[field] = true + } + } + + // Convert to sorted slice + fields := make([]string, 0, len(fieldSet)) + for field := range fieldSet { + fields = append(fields, field) + } + + // Ensure _id is first if present + for i, field := range fields { + if field == "_id" && i != 0 { + fields[0], fields[i] = fields[i], fields[0] + break + } + } + + return fields, nil +} + +func (p *MongoDBPlugin) formatBSONValue(val any) string { + if val == nil { + return "" + } + + var strVal string + switch v := val.(type) { + case string: + strVal = v + case bool: + strVal = fmt.Sprintf("%t", v) + case int, int8, int16, int32, int64: + strVal = fmt.Sprintf("%d", v) + case uint, uint8, uint16, uint32, uint64: + strVal = fmt.Sprintf("%d", v) + case float32, float64: + strVal = fmt.Sprintf("%g", v) + case time.Time: + strVal = v.Format(time.RFC3339) + case primitive.DateTime: + strVal = v.Time().Format(time.RFC3339) + case primitive.Timestamp: + strVal = fmt.Sprintf("Timestamp(%d,%d)", v.T, v.I) + case primitive.ObjectID: + strVal = v.Hex() + case primitive.Binary: + strVal = fmt.Sprintf("Binary(subtype:%d,len:%d)", v.Subtype, len(v.Data)) + case primitive.Regex: + strVal = fmt.Sprintf("/%s/%s", v.Pattern, v.Options) + case primitive.DBPointer: + strVal = fmt.Sprintf("DBPointer(%s,%s)", v.DB, v.Pointer.Hex()) + case primitive.JavaScript: + strVal = string(v) + case primitive.Symbol: + strVal = string(v) + case primitive.CodeWithScope: + scopeJSON, _ := json.Marshal(v.Scope) + strVal = fmt.Sprintf("CodeWithScope(%s, %s)", v.Code, string(scopeJSON)) + case primitive.Decimal128: + strVal = v.String() + case primitive.MinKey: + strVal = "MinKey" + case primitive.MaxKey: + strVal = "MaxKey" + case primitive.Null: + strVal = "null" + case primitive.Undefined: + strVal = "undefined" + case []any, bson.A: + // Convert arrays to JSON + data, err := json.Marshal(v) + if err != nil { + log.WithError(err).WithField("valueType", "array").Warn("Failed to marshal array value to JSON during MongoDB export, using string representation") + strVal = fmt.Sprintf("%v", v) + } else { + strVal = string(data) + } + case bson.M, map[string]any, bson.D: + // Convert documents to JSON + data, err := json.Marshal(v) + if err != nil { + log.WithError(err).WithField("valueType", "document").Warn("Failed to marshal document value to JSON during MongoDB export, using string representation") + strVal = fmt.Sprintf("%v", v) + } else { + strVal = string(data) + } + case bson.E: + // Handle single BSON element + strVal = fmt.Sprintf("%s: %v", v.Key, v.Value) + default: + // Fallback for any other types + strVal = fmt.Sprintf("%v", v) + } + + // Apply formula injection protection + return common.EscapeFormula(strVal) +} diff --git a/dataflow/core/src/plugins/mongodb/graph.go b/dataflow/core/src/plugins/mongodb/graph.go new file mode 100644 index 0000000..55cc9d1 --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/graph.go @@ -0,0 +1,244 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mongodb + +import ( + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type tableRelation struct { + Table1 string + Table2 string + Relation string + SourceColumn string + TargetColumn string +} + +func (p *MongoDBPlugin) GetGraph(config *engine.PluginConfig, database string) ([]engine.GraphUnit, error) { + ctx, cancel := opCtx() + defer cancel() + client, err := DB(config) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + }).Error("Failed to connect to MongoDB for graph generation") + return nil, err + } + defer disconnectClient(client) + + db := client.Database(database) + cursor, err := db.ListCollections(ctx, bson.M{}) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + }).Error("Failed to list MongoDB collections for graph generation") + return nil, err + } + defer cursor.Close(ctx) + + collections := []string{} + collectionTypes := make(map[string]string) + + for cursor.Next(ctx) { + var collectionInfo bson.M + if err := cursor.Decode(&collectionInfo); err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + }).Error("Failed to decode MongoDB collection info for graph generation") + return nil, err + } + + name, _ := collectionInfo["name"].(string) + collectionType, _ := collectionInfo["type"].(string) + + collections = append(collections, name) + collectionTypes[name] = collectionType + } + + uniqueRelations := make(map[string]bool) + relations := []tableRelation{} + + log.WithFields(map[string]any{ + "database": database, + "collections": collections, + }).Info("MongoDB Graph: Starting relationship detection") + + for _, collectionName := range collections { + collectionType := collectionTypes[collectionName] + + if collectionType == "view" { + log.WithField("collection", collectionName).Info("MongoDB Graph: Skipping view") + continue + } + + collection := db.Collection(collectionName) + + cursorSample, err := collection.Find(ctx, bson.M{}, options.Find().SetLimit(100)) + if err != nil { + log.WithError(err).WithField("collection", collectionName).Warn("MongoDB Graph: Unable to sample documents") + continue + } + + fieldFrequency := make(map[string]int) + fieldSamples := make(map[string]any) + + for cursorSample.Next(ctx) { + var doc bson.M + if err := cursorSample.Decode(&doc); err != nil { + continue + } + for k, v := range doc { + fieldFrequency[k]++ + // store a representative value for id detection + if _, exists := fieldSamples[k]; !exists { + fieldSamples[k] = v + } + } + } + cursorSample.Close(ctx) + + if len(fieldFrequency) == 0 { + log.WithField("collection", collectionName).Warn("MongoDB Graph: No documents found or empty collection") + continue + } + + foreignKeys := make(map[string]string) + + for fieldName := range fieldFrequency { + if fieldName == "_id" { + continue + } + + // If fieldName contains ".id" or ends with "Id", treat as potential reference + lowerField := strings.ToLower(fieldName) + if strings.HasSuffix(lowerField, ".id") || strings.HasSuffix(lowerField, "id") || strings.HasSuffix(lowerField, "_id") { + for _, otherCollection := range collections { + if otherCollection == collectionName { + continue + } + if strings.Contains(lowerField, strings.ToLower(otherCollection)) { + foreignKeys[otherCollection] = fieldName + break + } + } + } + + for _, otherCollection := range collections { + if otherCollection == collectionName { + continue + } + + singularName := strings.TrimSuffix(otherCollection, "s") + pluralName := otherCollection + if !strings.HasSuffix(otherCollection, "s") { + pluralName = otherCollection + "s" + } + + lowerField := strings.ToLower(fieldName) + if lowerField == strings.ToLower(singularName)+"_id" || + lowerField == strings.ToLower(singularName)+"id" || + lowerField == strings.ToLower(otherCollection)+"_id" || + lowerField == strings.ToLower(otherCollection)+"id" || + lowerField == strings.ToLower(pluralName)+"_id" || + lowerField == strings.ToLower(pluralName)+"id" { + foreignKeys[otherCollection] = fieldName + log.WithFields(map[string]any{ + "collection": collectionName, + "field": fieldName, + "references": otherCollection, + }).Info("MongoDB Graph: FOUND FK RELATIONSHIP") + break + } + } + } + + for fk, fieldName := range foreignKeys { + relKey1 := collectionName + ":" + fk + + if !uniqueRelations[relKey1+":ManyToOne"] { + uniqueRelations[relKey1+":ManyToOne"] = true + relations = append(relations, tableRelation{ + Table1: collectionName, + Table2: fk, + Relation: "ManyToOne", + SourceColumn: fieldName, + TargetColumn: "_id", + }) + log.WithFields(map[string]any{ + "from": collectionName, + "to": fk, + "sourceColumn": fieldName, + "targetColumn": "_id", + }).Info("MongoDB Graph: ADDED RELATION") + } + } + } + + log.WithFields(map[string]any{ + "database": database, + "relationsCount": len(relations), + }).Info("MongoDB Graph: Finished relationship detection") + + tableMap := make(map[string][]engine.GraphUnitRelationship) + for _, tr := range relations { + sourceCol := tr.SourceColumn + targetCol := tr.TargetColumn + tableMap[tr.Table1] = append(tableMap[tr.Table1], engine.GraphUnitRelationship{ + Name: tr.Table2, + RelationshipType: engine.GraphUnitRelationshipType(tr.Relation), + SourceColumn: &sourceCol, + TargetColumn: &targetCol, + }) + } + + storageUnits, err := p.GetStorageUnits(config, database) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + }).Error("Failed to get MongoDB storage units for graph generation") + return nil, err + } + + storageUnitsMap := map[string]engine.StorageUnit{} + for _, storageUnit := range storageUnits { + storageUnitsMap[storageUnit.Name] = storageUnit + } + + tables := []engine.GraphUnit{} + for _, storageUnit := range storageUnits { + foundTable, ok := tableMap[storageUnit.Name] + var relations []engine.GraphUnitRelationship + if ok { + relations = foundTable + } + tables = append(tables, engine.GraphUnit{ + Unit: storageUnit, + Relations: relations, + }) + } + + return tables, nil +} diff --git a/dataflow/core/src/plugins/mongodb/import.go b/dataflow/core/src/plugins/mongodb/import.go new file mode 100644 index 0000000..3585848 --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/import.go @@ -0,0 +1,174 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mongodb + +import ( + "context" + "errors" + + "github.com/clidey/whodb/core/src/engine" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// MongoDBPlugin provides document imports for Collection File Import. +var _ engine.CollectionImporter = (*MongoDBPlugin)(nil) + +const documentImportBatchSize = 1000 + +// InsertDocuments inserts documents into the collection using unordered bulk +// writes, so a document that fails (for example a duplicate _id) is skipped and +// reported rather than aborting the whole import. It implements +// engine.CollectionImporter. +func (p *MongoDBPlugin) InsertDocuments(config *engine.PluginConfig, schema string, collection string, documents []map[string]any) (int, []engine.DocumentImportFailure, error) { + if len(documents) == 0 { + return 0, nil, nil + } + + client, err := DB(config) + if err != nil { + return 0, nil, err + } + defer disconnectClient(client) + + coll := client.Database(schema).Collection(collection) + + inserted := 0 + var failures []engine.DocumentImportFailure + for start := 0; start < len(documents); start += documentImportBatchSize { + end := start + documentImportBatchSize + if end > len(documents) { + end = len(documents) + } + + models := make([]mongo.WriteModel, 0, end-start) + for _, doc := range documents[start:end] { + models = append(models, mongo.NewInsertOneModel().SetDocument(normalizeDocumentID(doc))) + } + + result, batchFailures, err := bulkWriteUnordered(coll, models, start) + if err != nil { + return inserted, failures, err + } + inserted += int(result.InsertedCount) + failures = append(failures, batchFailures...) + } + + return inserted, failures, nil +} + +// UpsertDocuments replaces or inserts each document, matching existing documents +// by keys (defaulting to ["_id"]). Documents missing a key are inserted instead +// of matched, so a missing key never produces an empty filter. Per-document +// failures are skipped and reported. It implements engine.CollectionImporter. +func (p *MongoDBPlugin) UpsertDocuments(config *engine.PluginConfig, schema string, collection string, keys []string, documents []map[string]any) (engine.DocumentUpsertResult, []engine.DocumentImportFailure, error) { + var outcome engine.DocumentUpsertResult + if len(documents) == 0 { + return outcome, nil, nil + } + if len(keys) == 0 { + keys = []string{"_id"} + } + + client, err := DB(config) + if err != nil { + return outcome, nil, err + } + defer disconnectClient(client) + + coll := client.Database(schema).Collection(collection) + + var failures []engine.DocumentImportFailure + for start := 0; start < len(documents); start += documentImportBatchSize { + end := start + documentImportBatchSize + if end > len(documents) { + end = len(documents) + } + + models := make([]mongo.WriteModel, 0, end-start) + for _, doc := range documents[start:end] { + normalized := normalizeDocumentID(doc) + if filter, ok := upsertFilter(normalized, keys); ok { + models = append(models, mongo.NewReplaceOneModel().SetFilter(filter).SetReplacement(normalized).SetUpsert(true)) + } else { + models = append(models, mongo.NewInsertOneModel().SetDocument(normalized)) + } + } + + result, batchFailures, err := bulkWriteUnordered(coll, models, start) + if err != nil { + return outcome, failures, err + } + outcome.Matched += int(result.MatchedCount) + outcome.Modified += int(result.ModifiedCount) + outcome.Upserted += int(result.UpsertedCount) + int(result.InsertedCount) + failures = append(failures, batchFailures...) + } + + return outcome, failures, nil +} + +// bulkWriteUnordered runs an unordered bulk write and treats per-document write +// errors as skipped records rather than a fatal error. offset is added to each +// write-error index so failures map to positions in the full document slice. +func bulkWriteUnordered(coll *mongo.Collection, models []mongo.WriteModel, offset int) (*mongo.BulkWriteResult, []engine.DocumentImportFailure, error) { + result, err := coll.BulkWrite(context.Background(), models, options.BulkWrite().SetOrdered(false)) + if err != nil { + var bwe mongo.BulkWriteException + if errors.As(err, &bwe) { + failures := make([]engine.DocumentImportFailure, 0, len(bwe.WriteErrors)) + for _, we := range bwe.WriteErrors { + failures = append(failures, engine.DocumentImportFailure{ + Index: offset + we.Index, + Reason: we.Message, + }) + } + return result, failures, nil + } + return result, nil, handleMongoError(err) + } + return result, nil, nil +} + +// normalizeDocumentID converts a string _id that is a valid hex ObjectID into an +// ObjectID, matching how documents added through the editor are stored. Values +// already of a non-string type (such as an Extended JSON ObjectID) are left +// unchanged. The document is modified in place and returned for convenience. +func normalizeDocumentID(doc map[string]any) map[string]any { + if rawID, ok := doc["_id"]; ok { + if id, err := normalizeMongoID(rawID); err == nil { + doc["_id"] = id + } + } + return doc +} + +// upsertFilter builds an equality filter from keys. It returns ok=false when any +// key is absent from the document, signalling the caller to insert the document +// instead of matching an existing one (avoiding an empty or partial filter). +func upsertFilter(doc map[string]any, keys []string) (bson.D, bool) { + filter := make(bson.D, 0, len(keys)) + for _, key := range keys { + value, ok := doc[key] + if !ok { + return nil, false + } + filter = append(filter, bson.E{Key: key, Value: value}) + } + return filter, true +} diff --git a/dataflow/core/src/plugins/mongodb/mongodb.go b/dataflow/core/src/plugins/mongodb/mongodb.go new file mode 100644 index 0000000..7816380 --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/mongodb.go @@ -0,0 +1,437 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mongodb + +import ( + "context" + "fmt" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var ( + supportedOperators = map[string]string{ + "eq": "eq", "ne": "ne", "gt": "gt", "gte": "gte", "lt": "lt", "lte": "lte", + "in": "in", "nin": "nin", "and": "and", "or": "or", "not": "not", "nor": "nor", + "exists": "exists", "type": "type", "regex": "regex", "expr": "expr", "mod": "mod", + "all": "all", "elemMatch": "elemMatch", "size": "size", "bitsAllClear": "bitsAllClear", + "bitsAllSet": "bitsAllSet", "bitsAnyClear": "bitsAnyClear", "bitsAnySet": "bitsAnySet", + "geoIntersects": "geoIntersects", "geoWithin": "geoWithin", "near": "near", "nearSphere": "nearSphere", + } +) + +type MongoDBPlugin struct { + engine.BasePlugin +} + +func (p *MongoDBPlugin) IsAvailable(ctx context.Context, config *engine.PluginConfig) bool { + client, err := DB(config) + if err != nil { + log.WithError(err).WithField("hostname", config.Credentials.Hostname).Error("Failed to connect to MongoDB for availability check") + return false + } + defer disconnectClient(client) + return true +} + +func (p *MongoDBPlugin) GetDatabases(config *engine.PluginConfig) ([]string, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).WithField("hostname", config.Credentials.Hostname).Error("Failed to connect to MongoDB for database listing") + return nil, err + } + ctx, cancel := opCtx() + defer cancel() + defer disconnectClient(client) + + databases, err := client.ListDatabaseNames(ctx, bson.M{}) + if err != nil { + log.WithError(err).WithField("hostname", config.Credentials.Hostname).Error("Failed to list MongoDB database names") + return nil, err + } + + return databases, nil +} + +func (p *MongoDBPlugin) GetAllSchemas(config *engine.PluginConfig) ([]string, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).WithField("hostname", config.Credentials.Hostname).Error("Failed to connect to MongoDB for schema listing") + return nil, err + } + ctx, cancel := opCtx() + defer cancel() + defer disconnectClient(client) + + databases, err := client.ListDatabaseNames(ctx, bson.M{}) + if err != nil { + log.WithError(err).WithField("hostname", config.Credentials.Hostname).Error("Failed to list MongoDB database names") + return nil, err + } + return databases, nil +} + +func (p *MongoDBPlugin) GetStorageUnits(config *engine.PluginConfig, database string) ([]engine.StorageUnit, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + }).Error("Failed to connect to MongoDB for storage unit listing") + return nil, err + } + ctx, cancel := opCtx() + defer cancel() + defer disconnectClient(client) + + db := client.Database(database) + listOpts := options.ListCollections().SetAuthorizedCollections(true) + cursor, err := db.ListCollections(ctx, bson.M{}, listOpts) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + }).Error("Failed to list MongoDB collections") + return nil, err + } + defer cursor.Close(ctx) + + storageUnits := []engine.StorageUnit{} + for cursor.Next(ctx) { + var collectionInfo bson.M + if err := cursor.Decode(&collectionInfo); err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + }).Error("Failed to decode MongoDB collection info") + return nil, err + } + + collectionName, _ := collectionInfo["name"].(string) + collectionType, _ := collectionInfo["type"].(string) + + // Skip MongoDB system collections (e.g., system.views, system.profile) + if strings.HasPrefix(collectionName, "system.") { + continue + } + + storageUnit := engine.StorageUnit{Name: collectionName} + + if collectionType == "view" { + viewOn, _ := collectionInfo["options"].(bson.M)["viewOn"].(string) + + storageUnit.Attributes = []engine.Record{ + {Key: "Type", Value: "View"}, + {Key: "View On", Value: viewOn}, + } + } else { + stats := bson.M{} + err := db.RunCommand(ctx, bson.D{{Key: "collStats", Value: collectionName}}).Decode(&stats) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "collection": collectionName, + }).Error("Failed to get MongoDB collection statistics") + return nil, err + } + + storageUnit.Attributes = []engine.Record{ + {Key: "Type", Value: "Collection"}, + {Key: "Storage Size", Value: fmt.Sprintf("%v", stats["storageSize"])}, + {Key: "Count", Value: fmt.Sprintf("%v", stats["count"])}, + } + } + + storageUnits = append(storageUnits, storageUnit) + } + + if err := cursor.Err(); err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + }).Error("MongoDB cursor error while listing collections") + return nil, err + } + + return storageUnits, nil +} + +func (p *MongoDBPlugin) StorageUnitExists(config *engine.PluginConfig, database string, collection string) (bool, error) { + client, err := DB(config) + if err != nil { + return false, err + } + ctx, cancel := opCtx() + defer cancel() + defer disconnectClient(client) + + db := client.Database(database) + names, err := db.ListCollectionNames(ctx, bson.M{"name": collection}) + if err != nil { + return false, err + } + return len(names) > 0, nil +} + +func (p *MongoDBPlugin) FormatValue(val any) string { + if val == nil { + return "" + } + return fmt.Sprintf("%v", val) +} + +// GetColumnConstraints retrieves MongoDB schema validation rules and maps them to the constraint format +// used by the mock data generator. Supports $jsonSchema validator with: +// - required fields → nullable: false +// - enum → check_values +// - minimum/maximum → check_min/check_max +// - minLength/maxLength → length (uses maxLength) +// - pattern → pattern (stored for reference) +func (p *MongoDBPlugin) GetColumnConstraints(config *engine.PluginConfig, schema string, storageUnit string) (map[string]map[string]any, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "schema": schema, + "storageUnit": storageUnit, + }).Error("Failed to connect to MongoDB for column constraints") + return make(map[string]map[string]any), nil + } + ctx, cancel := opCtx() + defer cancel() + defer disconnectClient(client) + + db := client.Database(schema) + + // Get collection info with validator + filter := bson.M{"name": storageUnit} + cursor, err := db.ListCollections(ctx, filter) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "schema": schema, + "storageUnit": storageUnit, + }).Debug("Failed to list collections for schema validation") + return make(map[string]map[string]any), nil + } + defer cursor.Close(ctx) + + var collInfo bson.M + if !cursor.Next(ctx) { + return make(map[string]map[string]any), nil + } + if err := cursor.Decode(&collInfo); err != nil { + log.WithError(err).WithField("collection", storageUnit).Debug("Failed to decode collection info") + return make(map[string]map[string]any), nil + } + + // Extract validator from collection options + opts, ok := collInfo["options"].(bson.M) + if !ok { + return make(map[string]map[string]any), nil + } + + validator, ok := opts["validator"].(bson.M) + if !ok { + return make(map[string]map[string]any), nil + } + + // Extract $jsonSchema + jsonSchema, ok := validator["$jsonSchema"].(bson.M) + if !ok { + return make(map[string]map[string]any), nil + } + + constraints := parseMongoDBJsonSchema(jsonSchema) + log.WithFields(map[string]any{ + "collection": storageUnit, + "constraintCount": len(constraints), + }).Debug("Parsed MongoDB schema validation constraints") + + return constraints, nil +} + +// parseMongoDBJsonSchema extracts constraints from a MongoDB $jsonSchema validator. +func parseMongoDBJsonSchema(schema bson.M) map[string]map[string]any { + constraints := make(map[string]map[string]any) + + // Get required fields + requiredFields := make(map[string]bool) + if required, ok := schema["required"].(bson.A); ok { + for _, field := range required { + if fieldName, ok := field.(string); ok { + requiredFields[fieldName] = true + } + } + } + + // Get properties + properties, ok := schema["properties"].(bson.M) + if !ok { + return constraints + } + + for fieldName, fieldSchemaRaw := range properties { + fieldSchema, ok := fieldSchemaRaw.(bson.M) + if !ok { + continue + } + + colConstraints := make(map[string]any) + + // Set nullable based on required array + colConstraints["nullable"] = !requiredFields[fieldName] + + // Get bsonType + if bsonType, ok := fieldSchema["bsonType"].(string); ok { + colConstraints["type"] = bsonType + } + + // Get enum values → check_values + if enum, ok := fieldSchema["enum"].(bson.A); ok { + values := make([]string, 0, len(enum)) + for _, v := range enum { + if s, ok := v.(string); ok { + values = append(values, s) + } + } + if len(values) > 0 { + colConstraints["check_values"] = values + } + } + + // Get minimum/maximum → check_min/check_max + // MongoDB may return int32 or float64 depending on how the schema was defined + if minVal := toFloat64(fieldSchema["minimum"]); minVal != nil { + colConstraints["check_min"] = *minVal + } + if maxVal := toFloat64(fieldSchema["maximum"]); maxVal != nil { + colConstraints["check_max"] = *maxVal + } + + // Get maxLength → length (mock data generator uses this to limit string length) + if maxLen := toFloat64(fieldSchema["maxLength"]); maxLen != nil { + colConstraints["length"] = int(*maxLen) + } + + // Store pattern for reference (could be used for custom validation in future) + if pattern, ok := fieldSchema["pattern"].(string); ok { + colConstraints["pattern"] = pattern + } + + if len(colConstraints) > 0 { + constraints[fieldName] = colConstraints + } + } + + return constraints +} + +// toFloat64 converts common BSON numeric types to float64. +func toFloat64(v any) *float64 { + var result float64 + switch val := v.(type) { + case float64: + result = val + case float32: + result = float64(val) + case int: + result = float64(val) + case int32: + result = float64(val) + case int64: + result = float64(val) + default: + return nil + } + return &result +} + +// ClearTableData deletes all documents from a MongoDB collection. +// This is used by the mock data generator when overwrite mode is enabled. +func (p *MongoDBPlugin) ClearTableData(config *engine.PluginConfig, schema string, storageUnit string) (bool, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "schema": schema, + "storageUnit": storageUnit, + }).Error("Failed to connect to MongoDB to clear collection") + return false, err + } + ctx, cancel := opCtx() + defer cancel() + defer disconnectClient(client) + + collection := client.Database(schema).Collection(storageUnit) + result, err := collection.DeleteMany(ctx, bson.M{}) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "schema": schema, + "storageUnit": storageUnit, + }).Error("Failed to clear MongoDB collection") + return false, err + } + + log.WithFields(map[string]any{ + "schema": schema, + "storageUnit": storageUnit, + "deletedCount": result.DeletedCount, + }).Info("Cleared MongoDB collection for mock data generation") + + return true, nil +} + +// GetDatabaseMetadata returns MongoDB metadata for frontend configuration. +// MongoDB is a document database without traditional type definitions. +func (p *MongoDBPlugin) GetDatabaseMetadata() *engine.DatabaseMetadata { + operators := make([]string, 0, len(supportedOperators)) + for op := range supportedOperators { + operators = append(operators, op) + } + return &engine.DatabaseMetadata{ + DatabaseType: engine.DatabaseType_MongoDB, + TypeDefinitions: []engine.TypeDefinition{ + {ID: "string", Label: "string", Category: engine.TypeCategoryText}, + {ID: "int", Label: "int", Category: engine.TypeCategoryNumeric}, + {ID: "double", Label: "double", Category: engine.TypeCategoryNumeric}, + {ID: "bool", Label: "bool", Category: engine.TypeCategoryBoolean}, + {ID: "date", Label: "date", Category: engine.TypeCategoryDatetime}, + {ID: "objectId", Label: "objectId", Category: engine.TypeCategoryOther}, + {ID: "array", Label: "array", Category: engine.TypeCategoryOther}, + {ID: "object", Label: "object", Category: engine.TypeCategoryOther}, + {ID: "mixed", Label: "mixed", Category: engine.TypeCategoryOther}, + }, + Operators: operators, + AliasMap: map[string]string{}, + Capabilities: engine.Capabilities{ + SupportsDatabaseSwitch: true, + }, + SystemSchemas: []string{"admin", "local", "config"}, + } +} + +func NewMongoDBPlugin() *engine.Plugin { + return &engine.Plugin{ + Type: engine.DatabaseType_MongoDB, + PluginFunctions: &MongoDBPlugin{}, + } +} diff --git a/dataflow/core/src/plugins/mongodb/raw_execute.go b/dataflow/core/src/plugins/mongodb/raw_execute.go new file mode 100644 index 0000000..d86c59f --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/raw_execute.go @@ -0,0 +1,464 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mongodb + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "time" + + "github.com/clidey/whodb/core/src/engine" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// RawExecute executes a MongoDB shell command (e.g. db.users.find({...})) and +// returns the results as a tabular GetRowsResult. All results have DisableUpdate +// set to true because raw results cannot be mapped back to individual documents. +func (p *MongoDBPlugin) RawExecute(config *engine.PluginConfig, query string, _ ...any) (*engine.GetRowsResult, error) { + cmd, err := parseShellCommand(query) + if err != nil { + return nil, err + } + args, err := parseArgs(cmd.RawArgs) + if err != nil { + return nil, err + } + + client, err := DB(config) + if err != nil { + return nil, err + } + ctx, cancel := opCtx() + defer cancel() + defer disconnectClient(client) + + db := client.Database(config.Credentials.Database) + if cmd.Collection == "" { + switch cmd.Method { + case "dropDatabase": + if err := db.Drop(ctx); err != nil { + return nil, err + } + return mutationResult(map[string]string{ + "acknowledged": "true", + "database": config.Credentials.Database, + }), nil + default: + return nil, fmt.Errorf("unsupported database method %q; supported methods: dropDatabase", cmd.Method) + } + } + coll := db.Collection(cmd.Collection) + + switch cmd.Method { + case "find": + return execFind(ctx, coll, args) + case "findOne": + return execFindOne(ctx, coll, args) + case "insertOne": + return execInsertOne(ctx, coll, args) + case "insertMany": + return execInsertMany(ctx, coll, args) + case "updateOne": + return execUpdateOne(ctx, coll, args) + case "updateMany": + return execUpdateMany(ctx, coll, args) + case "deleteOne": + return execDeleteOne(ctx, coll, args) + case "deleteMany": + return execDeleteMany(ctx, coll, args) + case "countDocuments": + return execCountDocuments(ctx, coll, args) + case "aggregate": + return execAggregate(ctx, coll, args) + case "distinct": + return execDistinct(ctx, coll, args) + case "createIndex": + return execCreateIndex(ctx, coll, args) + case "drop": + return execDrop(ctx, coll) + default: + return nil, fmt.Errorf("unsupported method %q; supported methods: find, findOne, insertOne, insertMany, updateOne, updateMany, deleteOne, deleteMany, countDocuments, aggregate, distinct, createIndex, drop", cmd.Method) + } +} + +// execFind runs db..find(filter, projection) with a hard limit of 1000 documents. +func execFind(ctx context.Context, coll *mongo.Collection, args []any) (*engine.GetRowsResult, error) { + filter := toBsonM(argAt(args, 0)) + opts := options.Find().SetLimit(1000) + if proj, ok := argAt(args, 1).(map[string]any); ok && len(proj) > 0 { + opts.SetProjection(bson.M(proj)) + } + cursor, err := coll.Find(ctx, filter, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var docs []bson.M + if err := cursor.All(ctx, &docs); err != nil { + return nil, err + } + return documentsToResult(docs), nil +} + +// execFindOne runs db..findOne(filter) and returns at most one document. +func execFindOne(ctx context.Context, coll *mongo.Collection, args []any) (*engine.GetRowsResult, error) { + filter := toBsonM(argAt(args, 0)) + var doc bson.M + err := coll.FindOne(ctx, filter).Decode(&doc) + if err == mongo.ErrNoDocuments { + return &engine.GetRowsResult{Columns: []engine.Column{}, Rows: [][]string{}, DisableUpdate: true}, nil + } + if err != nil { + return nil, err + } + return documentsToResult([]bson.M{doc}), nil +} + +// execInsertOne runs db..insertOne(doc) and returns the inserted ID. +func execInsertOne(ctx context.Context, coll *mongo.Collection, args []any) (*engine.GetRowsResult, error) { + if len(args) == 0 { + return nil, fmt.Errorf("insertOne requires a document argument") + } + res, err := coll.InsertOne(ctx, args[0]) + if err != nil { + return nil, err + } + return mutationResult(map[string]string{ + "insertedId": formatBSONValueForCell(res.InsertedID), + }), nil +} + +// execInsertMany runs db..insertMany([docs]) and returns the count of inserted documents. +func execInsertMany(ctx context.Context, coll *mongo.Collection, args []any) (*engine.GetRowsResult, error) { + if len(args) == 0 { + return nil, fmt.Errorf("insertMany requires an array argument") + } + arr, ok := args[0].([]any) + if !ok { + return nil, fmt.Errorf("insertMany: first argument must be an array of documents") + } + res, err := coll.InsertMany(ctx, arr) + if err != nil { + return nil, err + } + return mutationResult(map[string]string{ + "insertedCount": fmt.Sprintf("%d", len(res.InsertedIDs)), + }), nil +} + +// execUpdateOne runs db..updateOne(filter, update) and returns matched/modified/upserted counts. +func execUpdateOne(ctx context.Context, coll *mongo.Collection, args []any) (*engine.GetRowsResult, error) { + return execUpdate(ctx, coll, args, false) +} + +// execUpdateMany runs db..updateMany(filter, update) and returns matched/modified/upserted counts. +func execUpdateMany(ctx context.Context, coll *mongo.Collection, args []any) (*engine.GetRowsResult, error) { + return execUpdate(ctx, coll, args, true) +} + +func execUpdate(ctx context.Context, coll *mongo.Collection, args []any, multi bool) (*engine.GetRowsResult, error) { + if len(args) < 2 { + return nil, fmt.Errorf("update requires filter and update arguments") + } + filter := toBsonM(args[0]) + update := toBsonM(args[1]) + var res *mongo.UpdateResult + var err error + if multi { + res, err = coll.UpdateMany(ctx, filter, update) + } else { + res, err = coll.UpdateOne(ctx, filter, update) + } + if err != nil { + return nil, err + } + kvs := map[string]string{ + "matchedCount": fmt.Sprintf("%d", res.MatchedCount), + "modifiedCount": fmt.Sprintf("%d", res.ModifiedCount), + } + if res.UpsertedCount > 0 { + kvs["upsertedId"] = formatBSONValueForCell(res.UpsertedID) + } + return mutationResult(kvs), nil +} + +// execDeleteOne runs db..deleteOne(filter) and returns the deleted count. +func execDeleteOne(ctx context.Context, coll *mongo.Collection, args []any) (*engine.GetRowsResult, error) { + filter := toBsonM(argAt(args, 0)) + res, err := coll.DeleteOne(ctx, filter) + if err != nil { + return nil, err + } + return mutationResult(map[string]string{ + "deletedCount": fmt.Sprintf("%d", res.DeletedCount), + }), nil +} + +// execDeleteMany runs db..deleteMany(filter) and returns the deleted count. +func execDeleteMany(ctx context.Context, coll *mongo.Collection, args []any) (*engine.GetRowsResult, error) { + filter := toBsonM(argAt(args, 0)) + res, err := coll.DeleteMany(ctx, filter) + if err != nil { + return nil, err + } + return mutationResult(map[string]string{ + "deletedCount": fmt.Sprintf("%d", res.DeletedCount), + }), nil +} + +// execCountDocuments runs db..countDocuments(filter) and returns the count. +func execCountDocuments(ctx context.Context, coll *mongo.Collection, args []any) (*engine.GetRowsResult, error) { + filter := toBsonM(argAt(args, 0)) + count, err := coll.CountDocuments(ctx, filter) + if err != nil { + return nil, err + } + return mutationResult(map[string]string{ + "count": fmt.Sprintf("%d", count), + }), nil +} + +// execAggregate runs db..aggregate(pipeline) with a hard limit of 1000 documents +// appended automatically unless the pipeline already contains a $limit stage. +func execAggregate(ctx context.Context, coll *mongo.Collection, args []any) (*engine.GetRowsResult, error) { + if len(args) == 0 { + return nil, fmt.Errorf("aggregate requires a pipeline argument") + } + arr, ok := args[0].([]any) + if !ok { + return nil, fmt.Errorf("aggregate: first argument must be an array (pipeline)") + } + + // Append $limit if no $limit stage already exists in the pipeline. + hasLimit := false + for _, stage := range arr { + if m, ok := stage.(map[string]any); ok { + if _, found := m["$limit"]; found { + hasLimit = true + break + } + } + } + if !hasLimit { + arr = append(arr, map[string]any{"$limit": 1000}) + } + + cursor, err := coll.Aggregate(ctx, arr) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var docs []bson.M + if err := cursor.All(ctx, &docs); err != nil { + return nil, err + } + return documentsToResult(docs), nil +} + +// execDistinct runs db..distinct(field, filter) and returns distinct values as a single column. +func execDistinct(ctx context.Context, coll *mongo.Collection, args []any) (*engine.GetRowsResult, error) { + if len(args) == 0 { + return nil, fmt.Errorf("distinct requires a field name argument") + } + field, ok := args[0].(string) + if !ok { + return nil, fmt.Errorf("distinct: first argument must be a field name string") + } + filter := toBsonM(argAt(args, 1)) + values, err := coll.Distinct(ctx, field, filter) + if err != nil { + return nil, err + } + result := &engine.GetRowsResult{ + Columns: []engine.Column{{Name: field, Type: "mixed"}}, + Rows: make([][]string, len(values)), + DisableUpdate: true, + } + for i, v := range values { + result.Rows[i] = []string{formatBSONValueForCell(v)} + } + return result, nil +} + +// execCreateIndex runs db..createIndex(keys, options) and returns the created index name. +func execCreateIndex(ctx context.Context, coll *mongo.Collection, args []any) (*engine.GetRowsResult, error) { + if len(args) == 0 { + return nil, fmt.Errorf("createIndex requires a keys argument") + } + keys := toBsonD(args[0]) + indexModel := mongo.IndexModel{Keys: keys} + if len(args) >= 2 { + if opts, ok := args[1].(map[string]any); ok { + indexOpts := options.Index() + if unique, ok := opts["unique"].(bool); ok { + indexOpts.SetUnique(unique) + } + if name, ok := opts["name"].(string); ok { + indexOpts.SetName(name) + } + indexModel.Options = indexOpts + } + } + name, err := coll.Indexes().CreateOne(ctx, indexModel) + if err != nil { + return nil, err + } + return mutationResult(map[string]string{ + "indexName": name, + }), nil +} + +// execDrop drops the collection and returns an acknowledgement. +func execDrop(ctx context.Context, coll *mongo.Collection) (*engine.GetRowsResult, error) { + name := coll.Name() + if err := coll.Drop(ctx); err != nil { + return nil, err + } + return mutationResult(map[string]string{ + "acknowledged": "true", + "collection": name, + }), nil +} + +// documentsToResult converts a slice of BSON documents to a GetRowsResult. +// All unique keys across documents are collected, sorted alphabetically with _id +// first. All results have DisableUpdate set to true. +func documentsToResult(docs []bson.M) *engine.GetRowsResult { + // Collect all unique keys across all documents. + keySet := make(map[string]struct{}) + for _, doc := range docs { + for k := range doc { + keySet[k] = struct{}{} + } + } + + // Sort keys alphabetically, with _id always first. + keys := make([]string, 0, len(keySet)) + for k := range keySet { + if k != "_id" { + keys = append(keys, k) + } + } + sort.Strings(keys) + if _, hasID := keySet["_id"]; hasID { + keys = append([]string{"_id"}, keys...) + } + + columns := make([]engine.Column, len(keys)) + for i, k := range keys { + columns[i] = engine.Column{Name: k, Type: "mixed"} + } + + rows := make([][]string, len(docs)) + for i, doc := range docs { + row := make([]string, len(keys)) + for j, k := range keys { + row[j] = formatBSONValueForCell(doc[k]) + } + rows[i] = row + } + + return &engine.GetRowsResult{ + Columns: columns, + Rows: rows, + DisableUpdate: true, + } +} + +// mutationResult builds a single-row GetRowsResult from a map of string key-value +// pairs. Column names are sorted alphabetically. DisableUpdate is always true. +func mutationResult(kvs map[string]string) *engine.GetRowsResult { + keys := make([]string, 0, len(kvs)) + for k := range kvs { + keys = append(keys, k) + } + sort.Strings(keys) + + columns := make([]engine.Column, len(keys)) + row := make([]string, len(keys)) + for i, k := range keys { + columns[i] = engine.Column{Name: k, Type: "string"} + row[i] = kvs[k] + } + + return &engine.GetRowsResult{ + Columns: columns, + Rows: [][]string{row}, + DisableUpdate: true, + } +} + +// toBsonM converts a map[string]any to bson.M. Returns an empty bson.M if v is +// not a map. +func toBsonM(v any) bson.M { + if m, ok := v.(map[string]any); ok { + return bson.M(m) + } + return bson.M{} +} + +// toBsonD converts a map to a bson.D (ordered document). This is used for index +// key specifications where field order matters. Note that compound index key +// ordering follows map iteration order, which is non-deterministic in Go; callers +// that need a specific order should use an already-ordered pipeline stage instead. +func toBsonD(v any) bson.D { + m, ok := v.(map[string]any) + if !ok { + return bson.D{} + } + d := make(bson.D, 0, len(m)) + for k, val := range m { + d = append(d, bson.E{Key: k, Value: val}) + } + return d +} + +// formatBSONValueForCell formats a BSON value for display in a table cell. +// ObjectIDs are rendered as their hex string, DateTimes as RFC3339, nil as "", +// plain strings as-is, and everything else as its JSON representation. +func formatBSONValueForCell(v any) string { + if v == nil { + return "" + } + switch val := v.(type) { + case primitive.ObjectID: + return val.Hex() + case primitive.DateTime: + return val.Time().UTC().Format(time.RFC3339) + case string: + return val + default: + data, err := json.Marshal(val) + if err != nil { + return fmt.Sprintf("%v", val) + } + return string(data) + } +} + +// argAt returns args[i] if it exists, or nil otherwise. +func argAt(args []any, i int) any { + if i < len(args) { + return args[i] + } + return nil +} diff --git a/dataflow/core/src/plugins/mongodb/rows.go b/dataflow/core/src/plugins/mongodb/rows.go new file mode 100644 index 0000000..ab38f7e --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/rows.go @@ -0,0 +1,393 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mongodb + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func (p *MongoDBPlugin) GetRows(config *engine.PluginConfig, req *engine.GetRowsRequest) (*engine.GetRowsResult, error) { + database, collection := req.Schema, req.StorageUnit + where, sortConds, pageSize, pageOffset := req.Where, req.Sort, req.PageSize, req.PageOffset + client, err := DB(config) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "collection": collection, + }).Error("Failed to connect to MongoDB for row retrieval") + return nil, err + } + ctx, cancel := opCtx() + defer cancel() + defer disconnectClient(client) + + db := client.Database(database) + coll := db.Collection(collection) + + bsonFilter, err := convertWhereConditionToMongoDB(where) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "collection": collection, + }).Error("Failed to convert where condition to MongoDB filter") + return nil, fmt.Errorf("error converting where condition: %v", err) + } + + // Start count query in parallel + var totalCount int64 + countDone := make(chan error, 1) + go func() { + var countErr error + // codeql[go/nosql-injection]: collection name validated by StorageUnitExists before reaching this code + totalCount, countErr = coll.CountDocuments(ctx, bsonFilter) + countDone <- countErr + }() + + findOptions := options.Find() + findOptions.SetLimit(int64(pageSize)) + findOptions.SetSkip(int64(pageOffset)) + + // Apply sorting if provided + if len(sortConds) > 0 { + sortMap := bson.D{} + for _, s := range sortConds { + direction := 1 // ASC + if s.Direction == model.SortDirectionDesc { + direction = -1 // DESC + } + sortMap = append(sortMap, bson.E{Key: s.Column, Value: direction}) + } + findOptions.SetSort(sortMap) + } + + cursor, err := coll.Find(ctx, bsonFilter, findOptions) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "collection": collection, + "pageSize": pageSize, + "pageOffset": pageOffset, + }).Error("Failed to execute MongoDB find query") + return nil, err + } + defer cursor.Close(ctx) + + result := &engine.GetRowsResult{ + Columns: []engine.Column{ + {Name: "document", Type: "Document"}, + }, + Rows: [][]string{}, + } + + for cursor.Next(ctx) { + var doc bson.D + if err = cursor.Decode(&doc); err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "collection": collection, + }).Error("Failed to decode MongoDB query result") + return nil, err + } + + jsonBytes, err := marshalMongoDocumentJSON(doc) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "collection": collection, + }).Error("Failed to marshal MongoDB document to JSON") + return nil, err + } + result.Rows = append(result.Rows, []string{string(jsonBytes)}) + } + + if err := cursor.Err(); err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "collection": collection, + }).Error("MongoDB cursor error while retrieving rows") + return nil, err + } + + // Wait for count query to complete + if countErr := <-countDone; countErr != nil { + log.WithError(countErr).Warn("Failed to get MongoDB document count") + } else { + result.TotalCount = totalCount + } + + return result, nil +} + +func marshalMongoDocumentJSON(doc bson.D) ([]byte, error) { + var buffer bytes.Buffer + if err := writeMongoDocumentJSON(&buffer, doc); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func writeMongoDocumentJSON(buffer *bytes.Buffer, doc bson.D) error { + buffer.WriteByte('{') + for i, elem := range doc { + if i > 0 { + buffer.WriteByte(',') + } + + keyBytes, err := json.Marshal(elem.Key) + if err != nil { + return err + } + buffer.Write(keyBytes) + buffer.WriteByte(':') + + if err := writeMongoValueJSON(buffer, elem.Value); err != nil { + return err + } + } + buffer.WriteByte('}') + return nil +} + +func writeMongoArrayJSON(buffer *bytes.Buffer, values []any) error { + buffer.WriteByte('[') + for i, value := range values { + if i > 0 { + buffer.WriteByte(',') + } + if err := writeMongoValueJSON(buffer, value); err != nil { + return err + } + } + buffer.WriteByte(']') + return nil +} + +func writeMongoValueJSON(buffer *bytes.Buffer, value any) error { + switch v := value.(type) { + case bson.D: + return writeMongoDocumentJSON(buffer, v) + case bson.A: + return writeMongoArrayJSON(buffer, []any(v)) + case []any: + return writeMongoArrayJSON(buffer, v) + default: + jsonBytes, err := json.Marshal(v) + if err != nil { + return err + } + buffer.Write(jsonBytes) + return nil + } +} + +func (p *MongoDBPlugin) GetRowCount(config *engine.PluginConfig, database, collection string, where *model.WhereCondition) (int64, error) { + client, err := DB(config) + if err != nil { + return 0, err + } + ctx, cancel := opCtx() + defer cancel() + defer disconnectClient(client) + + db := client.Database(database) + coll := db.Collection(collection) + + bsonFilter, err := convertWhereConditionToMongoDB(where) + if err != nil { + return 0, fmt.Errorf("error converting where condition: %v", err) + } + + // codeql[go/nosql-injection]: collection name validated by StorageUnitExists before reaching this code + count, err := coll.CountDocuments(ctx, bsonFilter) + if err != nil { + return 0, err + } + + return count, nil +} + +func (p *MongoDBPlugin) GetColumnsForTable(config *engine.PluginConfig, schema string, storageUnit string) ([]engine.Column, error) { + ctx, cancel := opCtx() + defer cancel() + client, err := DB(config) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": schema, + "collection": storageUnit, + }).Error("Failed to connect to MongoDB for column inference") + return nil, err + } + defer disconnectClient(client) + + db := client.Database(schema) + collection := db.Collection(storageUnit) + + // Sample up to 100 documents to build a merged schema view + cursor, err := collection.Find(ctx, bson.M{}, options.Find().SetLimit(100)) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + fieldTypes := make(map[string]string) + for cursor.Next(ctx) { + var doc bson.M + if err := cursor.Decode(&doc); err != nil { + continue + } + for fieldName, fieldValue := range doc { + fieldType := inferMongoDBType(fieldValue) + fieldTypes[fieldName] = mergeMongoTypes(fieldTypes[fieldName], fieldType) + } + } + + if err := cursor.Err(); err != nil { + return nil, err + } + + if len(fieldTypes) == 0 { + log.WithField("collection", storageUnit).Warn("MongoDB GetColumns: No documents found, returning empty schema") + return []engine.Column{}, nil + } + + cursor, err = db.ListCollections(ctx, bson.M{}) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": schema, + }).Error("Failed to list MongoDB collections for FK detection") + return nil, err + } + defer cursor.Close(ctx) + + var collections []string + for cursor.Next(ctx) { + var collectionInfo bson.M + if err := cursor.Decode(&collectionInfo); err != nil { + continue + } + name, _ := collectionInfo["name"].(string) + collections = append(collections, name) + } + + fieldNames := make([]string, 0, len(fieldTypes)) + for name := range fieldTypes { + fieldNames = append(fieldNames, name) + } + sort.Strings(fieldNames) + + columns := []engine.Column{} + for _, fieldName := range fieldNames { + fieldType := fieldTypes[fieldName] + isPrimary := fieldName == "_id" + + var isForeignKey bool + var referencedTable *string + + if fieldName != "_id" { + lowerField := strings.ToLower(fieldName) + for _, otherCollection := range collections { + if otherCollection == storageUnit { + continue + } + + singularName := strings.TrimSuffix(otherCollection, "s") + pluralName := otherCollection + if !strings.HasSuffix(otherCollection, "s") { + pluralName = otherCollection + "s" + } + + if lowerField == strings.ToLower(singularName)+"_id" || + lowerField == strings.ToLower(singularName)+"id" || + lowerField == strings.ToLower(otherCollection)+"_id" || + lowerField == strings.ToLower(otherCollection)+"id" || + lowerField == strings.ToLower(pluralName)+"_id" || + lowerField == strings.ToLower(pluralName)+"id" { + isForeignKey = true + referencedTable = &otherCollection + break + } + } + } + + columns = append(columns, engine.Column{ + Name: fieldName, + Type: fieldType, + IsPrimary: isPrimary, + IsForeignKey: isForeignKey, + ReferencedTable: referencedTable, + }) + } + + return columns, nil +} + +func inferMongoDBType(value any) string { + if value == nil { + return "null" + } + + switch value.(type) { + case primitive.ObjectID: + return "ObjectId" + case string: + return "string" + case int, int32, int64: + return "int" + case float32, float64: + return "double" + case bool: + return "bool" + case primitive.DateTime: + return "date" + case []any: + return "array" + case map[string]any, bson.M: + return "object" + default: + return "mixed" + } +} + +// mergeMongoTypes combines type hints; if conflicting, returns "mixed". +func mergeMongoTypes(current, next string) string { + if current == "" { + return next + } + if current == next { + return current + } + return "mixed" +} diff --git a/dataflow/core/src/plugins/mongodb/rows_test.go b/dataflow/core/src/plugins/mongodb/rows_test.go new file mode 100644 index 0000000..63cc993 --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/rows_test.go @@ -0,0 +1,35 @@ +package mongodb + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" +) + +func TestMarshalMongoDocumentJSONPreservesFieldOrder(t *testing.T) { + doc := bson.D{ + {Key: "z", Value: int32(1)}, + {Key: "nested", Value: bson.D{ + {Key: "b", Value: int32(2)}, + {Key: "a", Value: int32(1)}, + }}, + {Key: "arr", Value: bson.A{ + bson.D{ + {Key: "y", Value: int32(1)}, + {Key: "x", Value: int32(2)}, + }, + }}, + {Key: "_id", Value: "1"}, + {Key: "a", Value: int32(3)}, + } + + gotBytes, err := marshalMongoDocumentJSON(doc) + if err != nil { + t.Fatalf("marshalMongoDocumentJSON returned error: %v", err) + } + + want := `{"z":1,"nested":{"b":2,"a":1},"arr":[{"y":1,"x":2}],"_id":"1","a":3}` + if string(gotBytes) != want { + t.Fatalf("expected %s, got %s", want, string(gotBytes)) + } +} diff --git a/dataflow/core/src/plugins/mongodb/shell_parser.go b/dataflow/core/src/plugins/mongodb/shell_parser.go new file mode 100644 index 0000000..543a857 --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/shell_parser.go @@ -0,0 +1,259 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mongodb + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// shellCommand holds the parsed components of a MongoDB shell command. +type shellCommand struct { + Collection string + Method string + RawArgs string +} + +// shellCommandPattern matches MongoDB shell syntax: db..() +// The (?s) flag allows '.' to match newlines so multi-line argument blocks are captured. +var shellCommandPattern = regexp.MustCompile(`(?s)^\s*db\.([a-zA-Z_][\w.]*?)\.(\w+)\s*\((.*)\)\s*;?\s*$`) +var getCollectionCommandPattern = regexp.MustCompile(`(?s)^\s*db\.getCollection\(\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')\s*\)\.(\w+)\s*\((.*)\)\s*;?\s*$`) +var databaseCommandPattern = regexp.MustCompile(`(?s)^\s*db\.(\w+)\s*\((.*)\)\s*;?\s*$`) + +// parseShellCommand parses a MongoDB shell command string such as +// `db.users.find({ "age": { "$gt": 25 } })` into its components. +// It strips comments before matching so inline and block comments are ignored. +func parseShellCommand(input string) (*shellCommand, error) { + cleaned := stripComments(input) + matches := shellCommandPattern.FindStringSubmatch(cleaned) + if matches != nil { + return &shellCommand{ + Collection: matches[1], + Method: matches[2], + RawArgs: strings.TrimSpace(matches[3]), + }, nil + } + + matches = getCollectionCommandPattern.FindStringSubmatch(cleaned) + if matches != nil { + collection, err := strconv.Unquote(matches[1]) + if err != nil { + return nil, fmt.Errorf("invalid getCollection argument: %w", err) + } + + return &shellCommand{ + Collection: collection, + Method: matches[2], + RawArgs: strings.TrimSpace(matches[3]), + }, nil + } + + matches = databaseCommandPattern.FindStringSubmatch(cleaned) + if matches != nil { + return &shellCommand{ + Collection: "", + Method: matches[1], + RawArgs: strings.TrimSpace(matches[2]), + }, nil + } + + return nil, fmt.Errorf( + "only single db.collection.method(...) calls are supported; JavaScript constructs such as variables, functions, and loops are not — use mongosh for full scripts. Received: %s", + truncate(strings.TrimSpace(input), 120), + ) +} + +// stripComments removes single-line (//) and block (/* */) comments from s. +func stripComments(s string) string { + // Remove block comments first, then single-line comments. + var b strings.Builder + i := 0 + for i < len(s) { + if i+1 < len(s) && s[i] == '/' && s[i+1] == '*' { + // Skip until closing */ + end := strings.Index(s[i+2:], "*/") + if end == -1 { + // Unclosed block comment — skip to end of string. + break + } + i = i + 2 + end + 2 + continue + } + if i+1 < len(s) && s[i] == '/' && s[i+1] == '/' { + // Skip until end of line. + end := strings.IndexByte(s[i:], '\n') + if end == -1 { + break + } + i += end + continue + } + b.WriteByte(s[i]) + i++ + } + return b.String() +} + +// truncate returns s truncated to n runes, appending "..." when truncation occurs. +func truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "..." +} + +// unquotedKeyPattern matches unquoted object keys in JS object literals so they +// can be replaced with double-quoted JSON keys. +var unquotedKeyPattern = regexp.MustCompile(`(?m)([\{,]\s*)([a-zA-Z_$][\w$]*)\s*:`) + +// trailingCommaPattern matches trailing commas before a closing brace or bracket. +var trailingCommaPattern = regexp.MustCompile(`,\s*([\}\]])`) + +// preprocessJS converts MongoDB shell JS constructs in s to valid JSON-compatible +// syntax. It handles ObjectId, new Date, ISODate, NumberInt, NumberLong, +// single-quoted strings, unquoted object keys, and trailing commas. +func preprocessJS(s string) string { + // ObjectId("hex") → {"$oid":"hex"} + s = regexp.MustCompile(`ObjectId\("([^"]+)"\)`).ReplaceAllString(s, `{"$$oid":"$1"}`) + + // new Date("str") → {"$date":"str"} + s = regexp.MustCompile(`new\s+Date\("([^"]*)"\)`).ReplaceAllString(s, `{"$$date":"$1"}`) + + // new Date() with no args → {"$date":""} + now := time.Now().UTC().Format(time.RFC3339) + s = regexp.MustCompile(`new\s+Date\(\s*\)`).ReplaceAllString(s, `{"$$date":"`+now+`"}`) + + // ISODate("str") → {"$date":"str"} + s = regexp.MustCompile(`ISODate\("([^"]*)"\)`).ReplaceAllString(s, `{"$$date":"$1"}`) + + // NumberInt(n) → n + s = regexp.MustCompile(`NumberInt\((\d+)\)`).ReplaceAllString(s, `$1`) + + // NumberLong(n) → n + s = regexp.MustCompile(`NumberLong\((\d+)\)`).ReplaceAllString(s, `$1`) + + // Single-quoted strings → double-quoted + s = replaceSingleQuotedStrings(s) + + // Unquoted object keys → quoted + s = unquotedKeyPattern.ReplaceAllString(s, `${1}"$2":`) + + // Trailing commas before } or ] → removed + s = trailingCommaPattern.ReplaceAllString(s, `$1`) + + return s +} + +// convertExtendedJSON recursively walks a parsed JSON value and converts +// Extended JSON markers to BSON types. Recognised markers: +// - {"$oid": ""} → primitive.ObjectID +// - {"$date": ""} → primitive.DateTime (RFC3339 or "2006-01-02") +func convertExtendedJSON(v any) any { + switch val := v.(type) { + case map[string]any: + if len(val) == 1 { + if hex, ok := val["$oid"].(string); ok { + if oid, err := primitive.ObjectIDFromHex(hex); err == nil { + return oid + } + } + if s, ok := val["$date"].(string); ok { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + t, err = time.Parse("2006-01-02", s) + } + if err == nil { + return primitive.NewDateTimeFromTime(t) + } + } + } + for k, item := range val { + val[k] = convertExtendedJSON(item) + } + return val + case []any: + for i, item := range val { + val[i] = convertExtendedJSON(item) + } + return val + default: + return v + } +} + +// parseArgs preprocesses and parses the raw argument string from a MongoDB +// shell command into a slice of Go values. Extended JSON markers such as +// {"$oid": "..."} and {"$date": "..."} are converted to BSON types. +// Returns nil, nil when rawArgs is empty. +func parseArgs(rawArgs string) ([]any, error) { + if rawArgs == "" { + return nil, nil + } + processed := preprocessJS(rawArgs) + wrapped := "[" + processed + "]" + var args []any + if err := json.Unmarshal([]byte(wrapped), &args); err != nil { + return nil, fmt.Errorf("failed to parse arguments: %w", err) + } + for i, arg := range args { + args[i] = convertExtendedJSON(arg) + } + return args, nil +} + +// replaceSingleQuotedStrings converts single-quoted string literals in s to +// double-quoted ones. It skips single quotes that appear inside already +// double-quoted strings so it does not corrupt valid JSON strings. +func replaceSingleQuotedStrings(s string) string { + var b strings.Builder + b.Grow(len(s)) + inDouble := false + i := 0 + for i < len(s) { + c := s[i] + if c == '"' { + inDouble = !inDouble + b.WriteByte(c) + i++ + continue + } + if !inDouble && c == '\'' { + // Collect characters until the closing single quote. + b.WriteByte('"') + i++ + for i < len(s) && s[i] != '\'' { + b.WriteByte(s[i]) + i++ + } + b.WriteByte('"') + if i < len(s) { + i++ // consume closing ' + } + continue + } + b.WriteByte(c) + i++ + } + return b.String() +} diff --git a/dataflow/core/src/plugins/mongodb/shell_parser_test.go b/dataflow/core/src/plugins/mongodb/shell_parser_test.go new file mode 100644 index 0000000..2d5afd7 --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/shell_parser_test.go @@ -0,0 +1,430 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mongodb + +import ( + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestParseShellCommand_Find(t *testing.T) { + cmd, err := parseShellCommand(`db.users.find({ "age": { "$gt": 25 } })`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cmd.Collection != "users" { + t.Errorf("expected collection %q, got %q", "users", cmd.Collection) + } + if cmd.Method != "find" { + t.Errorf("expected method %q, got %q", "find", cmd.Method) + } + if cmd.RawArgs != `{ "age": { "$gt": 25 } }` { + t.Errorf("unexpected RawArgs: %q", cmd.RawArgs) + } +} + +func TestParseShellCommand_InsertMany(t *testing.T) { + input := `db.orders.insertMany([ + { "item": "abc", "qty": 10 }, + { "item": "def", "qty": 20 } +])` + cmd, err := parseShellCommand(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cmd.Collection != "orders" { + t.Errorf("expected collection %q, got %q", "orders", cmd.Collection) + } + if cmd.Method != "insertMany" { + t.Errorf("expected method %q, got %q", "insertMany", cmd.Method) + } + if cmd.RawArgs == "" { + t.Error("expected non-empty RawArgs") + } +} + +func TestParseShellCommand_NoArgs(t *testing.T) { + cmd, err := parseShellCommand(`db.users.drop()`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cmd.Collection != "users" { + t.Errorf("expected collection %q, got %q", "users", cmd.Collection) + } + if cmd.Method != "drop" { + t.Errorf("expected method %q, got %q", "drop", cmd.Method) + } + if cmd.RawArgs != "" { + t.Errorf("expected empty RawArgs, got %q", cmd.RawArgs) + } +} + +func TestParseShellCommand_DropDatabase(t *testing.T) { + cmd, err := parseShellCommand(`db.dropDatabase()`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cmd.Collection != "" { + t.Errorf("expected empty collection, got %q", cmd.Collection) + } + if cmd.Method != "dropDatabase" { + t.Errorf("expected method %q, got %q", "dropDatabase", cmd.Method) + } + if cmd.RawArgs != "" { + t.Errorf("expected empty RawArgs, got %q", cmd.RawArgs) + } +} + +func TestParseShellCommand_InvalidFormat(t *testing.T) { + _, err := parseShellCommand(`SELECT * FROM users`) + if err == nil { + t.Fatal("expected error for invalid format, got nil") + } +} + +func TestParseShellCommand_InvalidFormat_ErrorMessageExplainsLimitation(t *testing.T) { + _, err := parseShellCommand(`const users = [1, 2, 3]`) + if err == nil { + t.Fatal("expected error for JS variable declaration, got nil") + } + msg := err.Error() + if !strings.Contains(msg, "db.collection.method") { + t.Errorf("expected error message to reference db.collection.method(...), got: %q", msg) + } + if !strings.Contains(msg, "mongosh") { + t.Errorf("expected error message to suggest mongosh, got: %q", msg) + } +} + +func TestParseShellCommand_WithComments(t *testing.T) { + input := `// find all adult users +db.users.find({ /* filter */ "age": { "$gt": 18 } })` + cmd, err := parseShellCommand(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cmd.Collection != "users" { + t.Errorf("expected collection %q, got %q", "users", cmd.Collection) + } + if cmd.Method != "find" { + t.Errorf("expected method %q, got %q", "find", cmd.Method) + } +} + +func TestParseShellCommand_WithSemicolon(t *testing.T) { + cmd, err := parseShellCommand(`db.users.find({});`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cmd.Collection != "users" { + t.Errorf("expected collection %q, got %q", "users", cmd.Collection) + } + if cmd.Method != "find" { + t.Errorf("expected method %q, got %q", "find", cmd.Method) + } + if cmd.RawArgs != "{}" { + t.Errorf("expected RawArgs %q, got %q", "{}", cmd.RawArgs) + } +} + +func TestParseShellCommand_SubCollection(t *testing.T) { + cmd, err := parseShellCommand(`db.system.users.find({})`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cmd.Collection != "system.users" { + t.Errorf("expected collection %q, got %q", "system.users", cmd.Collection) + } + if cmd.Method != "find" { + t.Errorf("expected method %q, got %q", "find", cmd.Method) + } +} + +func TestPreprocessJS_ObjectId(t *testing.T) { + input := `ObjectId("507f1f77bcf86cd799439011")` + want := `{"$oid":"507f1f77bcf86cd799439011"}` + got := preprocessJS(input) + if got != want { + t.Errorf("expected %q, got %q", want, got) + } +} + +func TestPreprocessJS_NewDate(t *testing.T) { + input := `new Date("2024-01-01")` + want := `{"$date":"2024-01-01"}` + got := preprocessJS(input) + if got != want { + t.Errorf("expected %q, got %q", want, got) + } +} + +func TestPreprocessJS_NewDateNow(t *testing.T) { + got := preprocessJS(`new Date()`) + if !strings.HasPrefix(got, `{"$date":"`) { + t.Errorf("expected output to start with {\"$date\":\"..., got %q", got) + } +} + +func TestPreprocessJS_ISODate(t *testing.T) { + input := `ISODate("2024-06-15T10:30:00Z")` + want := `{"$date":"2024-06-15T10:30:00Z"}` + got := preprocessJS(input) + if got != want { + t.Errorf("expected %q, got %q", want, got) + } +} + +func TestPreprocessJS_UnquotedKeys(t *testing.T) { + input := `{ name: "John", age: 30 }` + got := preprocessJS(input) + if !strings.Contains(got, `"name"`) { + t.Errorf("expected key 'name' to be quoted, got %q", got) + } + if !strings.Contains(got, `"age"`) { + t.Errorf("expected key 'age' to be quoted, got %q", got) + } +} + +func TestPreprocessJS_SingleQuotes(t *testing.T) { + input := `{ "name": 'John' }` + got := preprocessJS(input) + if !strings.Contains(got, `"John"`) { + t.Errorf("expected single-quoted value to become double-quoted, got %q", got) + } +} + +func TestPreprocessJS_TrailingComma(t *testing.T) { + input := `{ "a": 1, "b": 2, }` + got := preprocessJS(input) + if strings.Contains(got, `,}`) || strings.Contains(got, `, }`) { + t.Errorf("expected trailing comma to be removed, got %q", got) + } +} + +func TestPreprocessJS_NumberInt(t *testing.T) { + input := `NumberInt(42)` + want := `42` + got := preprocessJS(input) + if got != want { + t.Errorf("expected %q, got %q", want, got) + } +} + +func TestPreprocessJS_NumberLong(t *testing.T) { + input := `NumberLong(9999999999)` + want := `9999999999` + got := preprocessJS(input) + if got != want { + t.Errorf("expected %q, got %q", want, got) + } +} + +func TestPreprocessJS_Complex(t *testing.T) { + input := `{ _id: ObjectId("507f1f77bcf86cd799439011"), name: 'Alice', age: NumberInt(30), createdAt: ISODate("2024-01-01T00:00:00Z"), }` + got := preprocessJS(input) + if !strings.Contains(got, `{"$oid":"507f1f77bcf86cd799439011"}`) { + t.Errorf("expected ObjectId conversion in %q", got) + } + if !strings.Contains(got, `"Alice"`) { + t.Errorf("expected single-quoted string conversion in %q", got) + } + if !strings.Contains(got, `{"$date":"2024-01-01T00:00:00Z"}`) { + t.Errorf("expected ISODate conversion in %q", got) + } + if strings.Contains(got, `,}`) || strings.Contains(got, `, }`) { + t.Errorf("expected trailing comma removal in %q", got) + } +} + +func TestConvertExtendedJSON_ObjectId(t *testing.T) { + input := map[string]any{"_id": map[string]any{"$oid": "507f1f77bcf86cd799439011"}} + result := convertExtendedJSON(input) + m, ok := result.(map[string]any) + if !ok { + t.Fatalf("expected map result, got %T", result) + } + if _, ok := m["_id"].(primitive.ObjectID); !ok { + t.Errorf("expected _id to be primitive.ObjectID, got %T", m["_id"]) + } +} + +func TestConvertExtendedJSON_Date(t *testing.T) { + input := map[string]any{"created": map[string]any{"$date": "2024-01-01T00:00:00Z"}} + result := convertExtendedJSON(input) + m, ok := result.(map[string]any) + if !ok { + t.Fatalf("expected map result, got %T", result) + } + if _, ok := m["created"].(primitive.DateTime); !ok { + t.Errorf("expected created to be primitive.DateTime, got %T", m["created"]) + } +} + +func TestConvertExtendedJSON_Nested(t *testing.T) { + input := map[string]any{ + "outer": map[string]any{ + "inner": map[string]any{"$oid": "507f1f77bcf86cd799439011"}, + }, + } + result := convertExtendedJSON(input) + outer, ok := result.(map[string]any)["outer"].(map[string]any) + if !ok { + t.Fatalf("expected outer to be map, got %T", result.(map[string]any)["outer"]) + } + if _, ok := outer["inner"].(primitive.ObjectID); !ok { + t.Errorf("expected inner to be primitive.ObjectID, got %T", outer["inner"]) + } +} + +func TestConvertExtendedJSON_Array(t *testing.T) { + input := []any{ + map[string]any{"$oid": "507f1f77bcf86cd799439011"}, + map[string]any{"$oid": "507f191e810c19729de860ea"}, + } + result := convertExtendedJSON(input) + arr, ok := result.([]any) + if !ok { + t.Fatalf("expected slice result, got %T", result) + } + for i, elem := range arr { + if _, ok := elem.(primitive.ObjectID); !ok { + t.Errorf("expected element %d to be primitive.ObjectID, got %T", i, elem) + } + } +} + +func TestParseArgs_FindWithFilter(t *testing.T) { + args, err := parseArgs(`{ name: "John", age: { "$gt": 25 } }`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(args) != 1 { + t.Fatalf("expected 1 arg, got %d", len(args)) + } + m, ok := args[0].(map[string]any) + if !ok { + t.Fatalf("expected map arg, got %T", args[0]) + } + if m["name"] != "John" { + t.Errorf("expected name=%q, got %v", "John", m["name"]) + } +} + +func TestParseArgs_InsertManyDocs(t *testing.T) { + input := `[{ name: "Alice", createdAt: new Date() }, { name: "Bob", createdAt: new Date() }]` + args, err := parseArgs(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(args) != 1 { + t.Fatalf("expected 1 arg (the array), got %d", len(args)) + } + arr, ok := args[0].([]any) + if !ok { + t.Fatalf("expected slice arg, got %T", args[0]) + } + if len(arr) != 2 { + t.Errorf("expected 2 elements in array, got %d", len(arr)) + } +} + +func TestParseArgs_UpdateWithObjectId(t *testing.T) { + input := `{ _id: ObjectId("507f1f77bcf86cd799439011") }, { "$set": { name: "Updated" } }` + args, err := parseArgs(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(args) != 2 { + t.Fatalf("expected 2 args, got %d", len(args)) + } + filter, ok := args[0].(map[string]any) + if !ok { + t.Fatalf("expected first arg to be map, got %T", args[0]) + } + if _, ok := filter["_id"].(primitive.ObjectID); !ok { + t.Errorf("expected _id to be primitive.ObjectID, got %T", filter["_id"]) + } +} + +func TestParseArgs_Empty(t *testing.T) { + args, err := parseArgs("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if args != nil { + t.Errorf("expected nil args, got %v", args) + } +} + +func TestDocumentsToGetRowsResult(t *testing.T) { + oid, _ := primitive.ObjectIDFromHex("507f1f77bcf86cd799439011") + docs := []bson.M{ + {"_id": oid, "name": "Alice", "age": 30}, + {"_id": primitive.NewObjectID(), "name": "Bob", "email": "bob@example.com"}, + } + result := documentsToResult(docs) + + if len(result.Columns) != 4 { + t.Fatalf("expected 4 columns, got %d: %v", len(result.Columns), result.Columns) + } + if result.Columns[0].Name != "_id" { + t.Errorf("expected first column to be _id, got %q", result.Columns[0].Name) + } + // Remaining columns are sorted alphabetically: age, email, name + wantCols := []string{"_id", "age", "email", "name"} + for i, want := range wantCols { + if result.Columns[i].Name != want { + t.Errorf("column[%d]: expected %q, got %q", i, want, result.Columns[i].Name) + } + } + if len(result.Rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(result.Rows)) + } + if !result.DisableUpdate { + t.Error("expected DisableUpdate to be true") + } +} + +func TestMutationResult(t *testing.T) { + result := mutationResult(map[string]string{ + "modifiedCount": "3", + "matchedCount": "3", + }) + + if len(result.Rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(result.Rows)) + } + if len(result.Columns) != 2 { + t.Fatalf("expected 2 columns, got %d", len(result.Columns)) + } + // Columns must be sorted alphabetically + if result.Columns[0].Name != "matchedCount" { + t.Errorf("expected first column matchedCount, got %q", result.Columns[0].Name) + } + if result.Columns[1].Name != "modifiedCount" { + t.Errorf("expected second column modifiedCount, got %q", result.Columns[1].Name) + } + if result.Rows[0][0] != "3" || result.Rows[0][1] != "3" { + t.Errorf("unexpected row values: %v", result.Rows[0]) + } + if !result.DisableUpdate { + t.Error("expected DisableUpdate to be true") + } +} diff --git a/dataflow/core/src/plugins/mongodb/ssl_status.go b/dataflow/core/src/plugins/mongodb/ssl_status.go new file mode 100644 index 0000000..5f323e0 --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/ssl_status.go @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mongodb + +import ( + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins/ssl" +) + +// GetSSLStatus returns the SSL status based on the configured SSL mode for MongoDB. +// we return the configured SSL mode (for now) +func (p *MongoDBPlugin) GetSSLStatus(config *engine.PluginConfig) (*engine.SSLStatus, error) { + log.Debug("[SSL] MongoDBPlugin.GetSSLStatus: checking configured SSL mode") + sslConfig := ssl.ParseSSLConfig(engine.DatabaseType_MongoDB, config.Credentials.Advanced, config.Credentials.Hostname, config.Credentials.IsProfile) + + if sslConfig == nil || !sslConfig.IsEnabled() { + log.Debug("[SSL] MongoDBPlugin.GetSSLStatus: SSL not configured or disabled") + return &engine.SSLStatus{ + IsEnabled: false, + Mode: string(ssl.SSLModeDisabled), + }, nil + } + + log.Debugf("[SSL] MongoDBPlugin.GetSSLStatus: SSL enabled, mode=%s", sslConfig.Mode) + return &engine.SSLStatus{ + IsEnabled: true, + Mode: string(sslConfig.Mode), + }, nil +} diff --git a/dataflow/core/src/plugins/mongodb/update.go b/dataflow/core/src/plugins/mongodb/update.go new file mode 100644 index 0000000..571e3d4 --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/update.go @@ -0,0 +1,248 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mongodb + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func (p *MongoDBPlugin) UpdateStorageUnit(config *engine.PluginConfig, database string, storageUnit string, values map[string]string, updatedColumns []string) (bool, error) { + ctx, cancel := opCtx() + defer cancel() + client, err := DB(config) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + }).Error("Failed to connect to MongoDB for storage unit update") + return false, err + } + defer disconnectClient(client) + + db := client.Database(database) + collection := db.Collection(storageUnit) + + documentJSON, ok := values["document"] + if !ok { + log.WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + "availableKeys": getMapKeys(values), + "updatedColumns": updatedColumns, + }).Error("Missing 'document' key in values map for MongoDB storage unit update") + return false, errors.New("missing 'document' key in values map") + } + + var jsonValues bson.M + if err := json.Unmarshal([]byte(documentJSON), &jsonValues); err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + "documentJSON": documentJSON, + }).Error("Failed to unmarshal document JSON for MongoDB storage unit update") + return false, err + } + + id, ok := jsonValues["_id"] + if !ok { + log.WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + "documentFields": getDocumentFieldNames(jsonValues), + }).Error("Missing '_id' field in document for MongoDB storage unit update") + return false, errors.New("missing '_id' field in the document") + } + + objectID, err := normalizeMongoID(id) + if err != nil { + return false, fmt.Errorf("invalid '_id' value: %w", err) + } + + delete(jsonValues, "_id") + + filter := bson.M{"_id": objectID} + update := bson.M{"$set": jsonValues} + + var objectIDLog string + switch v := objectID.(type) { + case primitive.ObjectID: + objectIDLog = v.Hex() + default: + objectIDLog = fmt.Sprintf("%v", v) + } + + result, err := collection.UpdateOne(ctx, filter, update) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + "objectID": objectIDLog, + "updatedColumns": updatedColumns, + }).Error("Failed to update document in MongoDB collection") + return false, handleMongoError(err) + } + + if result.MatchedCount == 0 { + log.WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + "objectID": objectIDLog, + }).Warn("No documents matched the filter for MongoDB storage unit update") + return false, errors.New("no documents matched the filter") + } + if result.ModifiedCount == 0 { + log.WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + "objectID": objectIDLog, + "matchedCount": result.MatchedCount, + }).Warn("No documents were modified during MongoDB storage unit update") + return false, errors.New("no documents were updated") + } + + return true, nil +} + +// ReplaceRow replaces a MongoDB document while preserving the authored BSON field order. +func (p *MongoDBPlugin) ReplaceRow(config *engine.PluginConfig, database string, storageUnit string, values map[string]string) (bool, error) { + ctx, cancel := opCtx() + defer cancel() + client, err := DB(config) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + }).Error("Failed to connect to MongoDB for row replacement") + return false, err + } + defer disconnectClient(client) + + documentJSON, ok := values["document"] + if !ok { + log.WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + "availableKeys": getMapKeys(values), + }).Error("Missing 'document' key in values map for MongoDB row replacement") + return false, errors.New("missing 'document' key in values map") + } + + replacement, objectID, err := parseMongoReplacementDocument(documentJSON) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + }).Error("Failed to parse MongoDB replacement document") + return false, err + } + + collection := client.Database(database).Collection(storageUnit) + filter := bson.D{{Key: "_id", Value: objectID}} + + result, err := collection.ReplaceOne(ctx, filter, replacement) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + "objectID": formatMongoIDForLog(objectID), + }).Error("Failed to replace document in MongoDB collection") + return false, handleMongoError(err) + } + + if result.MatchedCount == 0 { + log.WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + "objectID": formatMongoIDForLog(objectID), + }).Warn("No documents matched the filter for MongoDB row replacement") + return false, errors.New("no documents matched the filter") + } + if result.ModifiedCount == 0 { + log.WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "storageUnit": storageUnit, + "objectID": formatMongoIDForLog(objectID), + "matchedCount": result.MatchedCount, + }).Warn("No documents were modified during MongoDB row replacement") + return false, errors.New("no documents were replaced") + } + + return true, nil +} + +func parseMongoReplacementDocument(documentJSON string) (bson.D, any, error) { + var document bson.D + if err := bson.UnmarshalExtJSON([]byte(documentJSON), false, &document); err != nil { + return nil, nil, err + } + + idIndex := -1 + var idValue any + for index, element := range document { + if element.Key == "_id" { + idIndex = index + idValue = element.Value + break + } + } + if idIndex < 0 { + return nil, nil, errors.New("missing '_id' field in the document") + } + + objectID, err := normalizeMongoID(idValue) + if err != nil { + return nil, nil, fmt.Errorf("invalid '_id' value: %w", err) + } + + document[idIndex].Value = objectID + if idIndex > 0 { + idElement := document[idIndex] + copy(document[1:idIndex+1], document[0:idIndex]) + document[0] = idElement + } + + return document, objectID, nil +} + +func formatMongoIDForLog(id any) string { + switch v := id.(type) { + case primitive.ObjectID: + return v.Hex() + default: + return fmt.Sprintf("%v", v) + } +} diff --git a/dataflow/core/src/plugins/mongodb/update_test.go b/dataflow/core/src/plugins/mongodb/update_test.go new file mode 100644 index 0000000..426ce4f --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/update_test.go @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mongodb + +import ( + "testing" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestParseMongoReplacementDocumentPreservesAuthoredOrder(t *testing.T) { + document, id, err := parseMongoReplacementDocument(`{"z":1,"_id":"507f1f77bcf86cd799439011","nested":{"b":2,"a":1},"a":2}`) + if err != nil { + t.Fatalf("expected replacement document to parse, got %v", err) + } + + objectID, ok := id.(primitive.ObjectID) + if !ok { + t.Fatalf("expected ObjectID identity, got %T", id) + } + if objectID.Hex() != "507f1f77bcf86cd799439011" { + t.Fatalf("unexpected object id %s", objectID.Hex()) + } + + keys := make([]string, len(document)) + for index, element := range document { + keys[index] = element.Key + } + expectedKeys := []string{"_id", "z", "nested", "a"} + for index, expected := range expectedKeys { + if keys[index] != expected { + t.Fatalf("expected key %d to be %q, got keys %#v", index, expected, keys) + } + } + + if document[0].Value != objectID { + t.Fatalf("expected replacement _id to be normalized ObjectID, got %#v", document[0].Value) + } + + nested, ok := document[2].Value.(bson.D) + if !ok { + t.Fatalf("expected nested document to preserve order as bson.D, got %T", document[2].Value) + } + if nested[0].Key != "b" || nested[1].Key != "a" { + t.Fatalf("expected nested key order b,a, got %#v", nested) + } +} + +func TestParseMongoReplacementDocumentRequiresID(t *testing.T) { + if _, _, err := parseMongoReplacementDocument(`{"name":"alice"}`); err == nil { + t.Fatalf("expected missing _id to fail") + } +} diff --git a/dataflow/core/src/plugins/mongodb/utils.go b/dataflow/core/src/plugins/mongodb/utils.go new file mode 100644 index 0000000..d68be62 --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/utils.go @@ -0,0 +1,117 @@ +/* + * // Copyright 2025 Clidey, Inc. + * // + * // 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. + */ + +package mongodb + +import ( + "strconv" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// normalizeMongoID converts supported ID formats to a MongoDB-friendly value. +// Returns the original value if no conversion is possible. +func normalizeMongoID(value any) (any, error) { + switch v := value.(type) { + case primitive.ObjectID: + return v, nil + case string: + oid, err := primitive.ObjectIDFromHex(v) + if err != nil { + // Not an ObjectID, use the raw string as-is + return v, nil + } + return oid, nil + default: + return value, nil + } +} + +// coerceMongoValue attempts to convert a string to a sensible BSON value. +// Numbers and booleans are converted; everything else remains a string. +// The typeHint (from schema validation) is used to select the correct type. +func coerceMongoValue(key string, raw string, typeHint string) any { + if key == "_id" { + id, err := normalizeMongoID(raw) + if err == nil { + return id + } + } + + // If we have a type hint, use it to coerce to the correct type + // This prevents issues like "0" being parsed as boolean false when double is expected + switch typeHint { + case "double": + if f, err := strconv.ParseFloat(raw, 64); err == nil { + return f + } + return raw + case "int": + if i, err := strconv.ParseInt(raw, 10, 32); err == nil { + return int32(i) + } + return raw + case "long": + if i, err := strconv.ParseInt(raw, 10, 64); err == nil { + return i + } + return raw + case "bool": + if b, err := strconv.ParseBool(raw); err == nil { + return b + } + return raw + case "string": + return raw + } + + // No type hint - try to infer the type (original behavior) + // Try boolean first (but only for explicit true/false values, not "0"/"1") + if raw == "true" || raw == "false" { + b, _ := strconv.ParseBool(raw) + return b + } + + // Try integer + if i, err := strconv.ParseInt(raw, 10, 64); err == nil { + return i + } + + // Try float + if f, err := strconv.ParseFloat(raw, 64); err == nil { + return f + } + + return raw +} + +// Helper functions for logging +func getMapKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +func getDocumentFieldNames(doc bson.M) []string { + fields := make([]string, 0, len(doc)) + for k := range doc { + fields = append(fields, k) + } + return fields +} diff --git a/dataflow/core/src/plugins/mongodb/where_conditions.go b/dataflow/core/src/plugins/mongodb/where_conditions.go new file mode 100644 index 0000000..7903c85 --- /dev/null +++ b/dataflow/core/src/plugins/mongodb/where_conditions.go @@ -0,0 +1,189 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mongodb + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/log" + "go.mongodb.org/mongo-driver/bson" +) + +func convertWhereConditionToMongoDB(where *model.WhereCondition) (bson.M, error) { + if where == nil { + return bson.M{}, nil + } + + // Normalize operator to lower for comparisons + getOp := func(op string) string { return strings.ToLower(op) } + + switch where.Type { + case model.WhereConditionTypeAtomic: + if where.Atomic == nil { + return nil, fmt.Errorf("atomic condition must have an atomicwherecondition") + } + + operator := getOp(where.Atomic.Operator) + + switch operator { + case "eq", "ne", "gt", "gte", "lt", "lte": + mongoOperator := "$" + operator + value := convertMongoValue(where.Atomic.Key, where.Atomic.Value) + return bson.M{where.Atomic.Key: bson.M{mongoOperator: value}}, nil + + case "in", "nin": + values := parseCommaSeparatedValues(where.Atomic.Key, where.Atomic.Value) + mongoOperator := "$" + operator + return bson.M{where.Atomic.Key: bson.M{mongoOperator: values}}, nil + + case "regex": + return bson.M{where.Atomic.Key: bson.M{"$regex": where.Atomic.Value}}, nil + + case "exists": + exists, err := strconv.ParseBool(where.Atomic.Value) + if err != nil { + return nil, fmt.Errorf("invalid exists value: %s", where.Atomic.Value) + } + return bson.M{where.Atomic.Key: bson.M{"$exists": exists}}, nil + + case "type": + return bson.M{where.Atomic.Key: bson.M{"$type": where.Atomic.Value}}, nil + + case "expr": + // Expect a JSON expression; try to decode + var expr any + if err := json.Unmarshal([]byte(where.Atomic.Value), &expr); err != nil { + return nil, fmt.Errorf("invalid expr payload: %w", err) + } + return bson.M{"$expr": expr}, nil + + case "mod": + parts := strings.Split(where.Atomic.Value, ",") + if len(parts) != 2 { + return nil, fmt.Errorf("mod expects 'divisor,remainder'") + } + div, err1 := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64) + rem, err2 := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) + if err1 != nil || err2 != nil { + return nil, fmt.Errorf("mod operands must be integers") + } + return bson.M{where.Atomic.Key: bson.M{"$mod": []int64{div, rem}}}, nil + + case "all": + values := parseCommaSeparatedValues(where.Atomic.Key, where.Atomic.Value) + return bson.M{where.Atomic.Key: bson.M{"$all": values}}, nil + + case "elemmatch": + var elem bson.M + if err := json.Unmarshal([]byte(where.Atomic.Value), &elem); err != nil { + return nil, fmt.Errorf("elemMatch expects JSON object: %w", err) + } + return bson.M{where.Atomic.Key: bson.M{"$elemMatch": elem}}, nil + + case "size": + size, err := strconv.ParseInt(where.Atomic.Value, 10, 64) + if err != nil { + return nil, fmt.Errorf("size expects integer: %w", err) + } + return bson.M{where.Atomic.Key: bson.M{"$size": size}}, nil + + case "bitsallclear", "bitsallset", "bitsanyclear", "bitsanyset": + mask, err := strconv.ParseInt(where.Atomic.Value, 10, 64) + if err != nil { + return nil, fmt.Errorf("%s expects integer bitmask", operator) + } + return bson.M{where.Atomic.Key: bson.M{"$" + operator: mask}}, nil + + case "geointersects", "geowithin", "near", "nearsphere": + var payload any + if err := json.Unmarshal([]byte(where.Atomic.Value), &payload); err != nil { + return nil, fmt.Errorf("%s expects JSON payload: %w", operator, err) + } + return bson.M{where.Atomic.Key: bson.M{"$" + operator: payload}}, nil + + default: + return nil, fmt.Errorf("unsupported operator: %s", where.Atomic.Operator) + } + + case model.WhereConditionTypeAnd: + if where.And == nil || len(where.And.Children) == 0 { + return bson.M{}, nil + } + + andConditions := []bson.M{} + for _, child := range where.And.Children { + childCondition, err := convertWhereConditionToMongoDB(child) + if err != nil { + log.WithError(err).Error("Failed to convert child AND condition to MongoDB filter") + return nil, err + } + andConditions = append(andConditions, childCondition) + } + + return bson.M{"$and": andConditions}, nil + + case model.WhereConditionTypeOr: + if where.Or == nil || len(where.Or.Children) == 0 { + return bson.M{}, nil + } + + orConditions := []bson.M{} + for _, child := range where.Or.Children { + childCondition, err := convertWhereConditionToMongoDB(child) + if err != nil { + log.WithError(err).Error("Failed to convert child OR condition to MongoDB filter") + return nil, err + } + orConditions = append(orConditions, childCondition) + } + + return bson.M{"$or": orConditions}, nil + + default: + return nil, fmt.Errorf("unknown whereconditiontype: %v", where.Type) + } +} + +// convertMongoValue handles ObjectID conversion for _id and basic numeric/bool coercion. +// Used for query building where type hints are not available. +func convertMongoValue(key string, raw string) any { + if key == "_id" { + id, err := normalizeMongoID(raw) + if err == nil { + return id + } + return raw + } + return coerceMongoValue(key, raw, "") // No type hint for queries +} + +func parseCommaSeparatedValues(key string, raw string) []any { + if strings.TrimSpace(raw) == "" { + return []any{} + } + parts := strings.Split(raw, ",") + values := make([]any, 0, len(parts)) + for _, p := range parts { + v := strings.TrimSpace(p) + values = append(values, convertMongoValue(key, v)) + } + return values +} diff --git a/dataflow/core/src/plugins/mysql/add.go b/dataflow/core/src/plugins/mysql/add.go new file mode 100755 index 0000000..5a56f56 --- /dev/null +++ b/dataflow/core/src/plugins/mysql/add.go @@ -0,0 +1,39 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package mysql + +import ( + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/plugins/gorm" + "gorm.io/gorm" +) + +func (p *MySQLPlugin) GetCreateTableQuery(db *gorm.DB, schema string, storageUnit string, columns []engine.Record) string { + builder := p.GormPluginFunctions.CreateSQLBuilder(db) + + columnDefs := gorm_plugin.RecordsToColumnDefs(columns, func(def gorm_plugin.ColumnDef, column engine.Record) gorm_plugin.ColumnDef { + def.Primary = true + if column.Extra[gorm_plugin.PrimaryKeysUseSourceValuesExtraKey] != "true" && strings.Contains(strings.ToLower(column.Value), "int") { + def.Extra = "AUTO_INCREMENT" + } + return def + }) + + return builder.CreateTableQuery(schema, storageUnit, columnDefs) +} diff --git a/dataflow/core/src/plugins/mysql/constraints.go b/dataflow/core/src/plugins/mysql/constraints.go new file mode 100644 index 0000000..939a00a --- /dev/null +++ b/dataflow/core/src/plugins/mysql/constraints.go @@ -0,0 +1,233 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package mysql + +import ( + "strings" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/plugins" + gorm_plugin "github.com/clidey/whodb/core/src/plugins/gorm" + "gorm.io/gorm" +) + +// GetColumnConstraints retrieves column constraints for MySQL/MariaDB tables +func (p *MySQLPlugin) GetColumnConstraints(config *engine.PluginConfig, schema string, storageUnit string) (map[string]map[string]any, error) { + constraints := make(map[string]map[string]any) + + _, err := plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + // Get primary keys using information_schema + primaryRows, err := db.Table("information_schema.table_constraints t"). + Select("k.COLUMN_NAME"). + Joins("JOIN information_schema.key_column_usage k ON k.CONSTRAINT_NAME = t.CONSTRAINT_NAME AND k.TABLE_SCHEMA = t.TABLE_SCHEMA AND k.TABLE_NAME = t.TABLE_NAME"). + Where("t.CONSTRAINT_TYPE = 'PRIMARY KEY' AND t.TABLE_SCHEMA = DATABASE() AND t.TABLE_NAME = ?", storageUnit). + Order("k.ORDINAL_POSITION"). + Rows() + if err == nil { + defer primaryRows.Close() + for primaryRows.Next() { + var columnName string + if err := primaryRows.Scan(&columnName); err != nil { + continue + } + gorm_plugin.EnsureConstraintEntry(constraints, columnName)["primary"] = true + } + } + + // Get nullability and column type (for ENUM detection) using GORM's query builder + rows, err := db.Table("information_schema.columns"). + Select("COLUMN_NAME, IS_NULLABLE, COLUMN_TYPE"). + Where("table_schema = DATABASE() AND table_name = ?", storageUnit). + Rows() + if err != nil { + return false, err + } + defer rows.Close() + + for rows.Next() { + var columnName, isNullable, columnType string + if err := rows.Scan(&columnName, &isNullable, &columnType); err != nil { + continue + } + + colConstraints := gorm_plugin.EnsureConstraintEntry(constraints, columnName) + colConstraints["nullable"] = strings.EqualFold(isNullable, "YES") + + // Parse ENUM types to extract allowed values + // MySQL ENUM columns have column_type like: enum('active','inactive','pending') + if strings.HasPrefix(strings.ToLower(columnType), "enum(") { + values := parseEnumValues(columnType) + if len(values) > 0 { + colConstraints["check_values"] = values + } + } + } + + // Get unique single-column indexes using GORM's query builder + uniqueRows, err := db.Table("information_schema.statistics"). + Select("COLUMN_NAME"). + Where("table_schema = DATABASE() AND table_name = ? AND NON_UNIQUE = 0", storageUnit). + Group("COLUMN_NAME, INDEX_NAME"). + Having("COUNT(*) = 1"). + Rows() + if err != nil { + return false, err + } + defer uniqueRows.Close() + + for uniqueRows.Next() { + var columnName string + if err := uniqueRows.Scan(&columnName); err != nil { + continue + } + + gorm_plugin.EnsureConstraintEntry(constraints, columnName)["unique"] = true + } + + // Get CHECK constraints (MySQL 8.0.16+) + // We'll parse simple patterns like >= 0, <= 100, etc. + // MySQL's CHECK_CONSTRAINTS table does not have TABLE_NAME; need to join with TABLE_CONSTRAINTS to get table name + checkRows, err := db.Table("information_schema.CHECK_CONSTRAINTS cc"). + Select("cc.CONSTRAINT_NAME, cc.CHECK_CLAUSE"). + Joins("JOIN information_schema.TABLE_CONSTRAINTS tc ON cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA AND cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME"). + Where("cc.CONSTRAINT_SCHEMA = DATABASE() AND tc.TABLE_NAME = ?", storageUnit). + Rows() + if err == nil { + defer checkRows.Close() + + for checkRows.Next() { + var constraintName, checkClause string + if err := checkRows.Scan(&constraintName, &checkClause); err != nil { + continue + } + + // Parse the CHECK clause to extract column and condition + p.parseCheckConstraint(checkClause, constraints) + } + } + // Ignore error if CHECK_CONSTRAINTS table doesn't exist (MySQL < 8.0.16) + + return true, nil + }) + + if err != nil { + return constraints, err + } + + return constraints, nil +} + +// parseCheckConstraint parses MySQL CHECK constraint clauses to extract column constraints +func (p *MySQLPlugin) parseCheckConstraint(checkClause string, constraints map[string]map[string]any) { + // MySQL formats CHECK constraints like: + // - (`price` >= 0) + // - (`stock_quantity` >= 0) + // - (`age` between 18 and 120) + // - (`status` in (_utf8mb4'active',_utf8mb4'inactive')) + // - json_valid(`col_json`) - MariaDB's implicit JSON constraint + + // Check for JSON_VALID constraint BEFORE trimming parentheses + // (trimming would remove the closing paren from json_valid()) + lowerClause := strings.ToLower(checkClause) + if strings.Contains(lowerClause, "json_valid(") { + columnName := extractJSONValidColumn(checkClause) + if columnName != "" && common.ValidateColumnName(columnName) { + colConstraints := gorm_plugin.EnsureConstraintEntry(constraints, columnName) + colConstraints["is_json"] = true + return + } + } + + // Now trim outer parentheses for other constraint types + clause := strings.Trim(checkClause, "()") + + columnName := gorm_plugin.ExtractColumnNameFromClause(clause) + if columnName == "" { + return + } + + // Validate column name to prevent SQL injection + if !common.ValidateColumnName(columnName) { + return + } + + colConstraints := gorm_plugin.EnsureConstraintEntry(constraints, columnName) + + minMax := gorm_plugin.ParseMinMaxConstraints(clause) + gorm_plugin.ApplyMinMaxToConstraints(colConstraints, minMax) + + if values := gorm_plugin.ParseINClauseValues(clause); len(values) > 0 { + colConstraints["check_values"] = values + } +} + +// extractJSONValidColumn extracts the column name from a json_valid() constraint +// Handles: json_valid(`col_name`), json_valid(col_name), JSON_VALID(`col`) +func extractJSONValidColumn(clause string) string { + lowerClause := strings.ToLower(clause) + idx := strings.Index(lowerClause, "json_valid(") + if idx == -1 { + return "" + } + + // Find the opening paren position in the original clause + start := idx + len("json_valid(") + if start >= len(clause) { + return "" + } + + // Find the closing paren + end := strings.Index(clause[start:], ")") + if end == -1 { + return "" + } + + colRef := strings.TrimSpace(clause[start : start+end]) + // Remove backticks or quotes + colRef = strings.Trim(colRef, "`\"'") + return colRef +} + +// parseEnumValues extracts values from MySQL ENUM type definition +// Input format: enum('active','inactive','pending') +func parseEnumValues(columnType string) []string { + // Find the content between parentheses + start := strings.Index(columnType, "(") + end := strings.LastIndex(columnType, ")") + if start == -1 || end == -1 || end <= start { + return nil + } + + content := columnType[start+1 : end] + var values []string + + // Split by comma, handling quoted strings + // Values are quoted with single quotes: 'value1','value2' + parts := strings.Split(content, ",") + for _, part := range parts { + cleaned := strings.TrimSpace(part) + cleaned = strings.Trim(cleaned, "'\"") + if cleaned != "" { + if sanitized, ok := common.SanitizeConstraintValue(cleaned); ok { + values = append(values, sanitized) + } + } + } + + return values +} diff --git a/dataflow/core/src/plugins/mysql/db.go b/dataflow/core/src/plugins/mysql/db.go new file mode 100755 index 0000000..590a7e2 --- /dev/null +++ b/dataflow/core/src/plugins/mysql/db.go @@ -0,0 +1,108 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mysql + +import ( + "fmt" + "net" + "strconv" + "time" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + "github.com/clidey/whodb/core/src/plugins/ssl" + mysqldriver "github.com/go-sql-driver/mysql" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func (p *MySQLPlugin) DB(config *engine.PluginConfig) (*gorm.DB, error) { + return p.openDB(config, false) +} + +func (p *MySQLPlugin) openDB(config *engine.PluginConfig, multiStatements bool) (*gorm.DB, error) { + connectionInput, err := p.ParseConnectionConfig(config) + if err != nil { + return nil, err + } + + mysqlConfig := mysqldriver.NewConfig() + mysqlConfig.User = connectionInput.Username + mysqlConfig.Passwd = connectionInput.Password + mysqlConfig.Net = "tcp" + mysqlConfig.Addr = net.JoinHostPort(connectionInput.Hostname, strconv.Itoa(connectionInput.Port)) + mysqlConfig.DBName = connectionInput.Database + mysqlConfig.AllowCleartextPasswords = connectionInput.AllowClearTextPasswords + mysqlConfig.ParseTime = connectionInput.ParseTime + mysqlConfig.Loc = connectionInput.Loc + mysqlConfig.Timeout = time.Duration(connectionInput.ConnectionTimeout) * time.Second + mysqlConfig.Params = connectionInput.ExtraOptions + mysqlConfig.MultiStatements = multiStatements + + // Configure SSL/TLS + sslMode := "disabled" + if connectionInput.SSLConfig != nil && connectionInput.SSLConfig.IsEnabled() { + sslMode = string(connectionInput.SSLConfig.Mode) + + // MySQL driver requires registering TLS configs by name + // Handle "preferred" mode specially - it's a DSN parameter, not a registered config + if connectionInput.SSLConfig.Mode == ssl.SSLModePreferred { + mysqlConfig.TLSConfig = "preferred" + } else { + // Build and register TLS config for other modes + tlsConfig, err := ssl.BuildTLSConfig(connectionInput.SSLConfig, connectionInput.Hostname) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": connectionInput.Hostname, + "sslMode": connectionInput.SSLConfig.Mode, + }).Error("Failed to build TLS configuration for MySQL") + return nil, err + } + + // Register TLS config with unique name + configName := fmt.Sprintf("whodb_%s_%d", connectionInput.Database, time.Now().UnixNano()) + if err := mysqldriver.RegisterTLSConfig(configName, tlsConfig); err != nil { + log.WithError(err).WithField("configName", configName).Error("Failed to register TLS config for MySQL") + return nil, err + } + mysqlConfig.TLSConfig = configName + } + } + + l := log.WithFields(map[string]any{ + "hostname": connectionInput.Hostname, + "port": connectionInput.Port, + "database": connectionInput.Database, + "username": connectionInput.Username, + "sslMode": sslMode, + }) + + db, err := gorm.Open(mysql.Open(mysqlConfig.FormatDSN()), &gorm.Config{Logger: logger.Default.LogMode(plugins.GetGormLogConfig())}) + if err != nil { + l.WithError(err).Error("Failed to connect to MySQL database") + return nil, err + } + + // Configure connection pool for better reconnection behavior + if err := plugins.ConfigureConnectionPool(db); err != nil { + l.WithError(err).Warn("Failed to configure connection pool") + } + + return db, nil +} diff --git a/dataflow/core/src/plugins/mysql/geometry.go b/dataflow/core/src/plugins/mysql/geometry.go new file mode 100644 index 0000000..195e99c --- /dev/null +++ b/dataflow/core/src/plugins/mysql/geometry.go @@ -0,0 +1,51 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package mysql + +import ( + "encoding/binary" + "fmt" + + "github.com/twpayne/go-geom/encoding/wkb" + "github.com/twpayne/go-geom/encoding/wkt" +) + +// FormatGeometryValue formats MySQL geometry data for display. +// MySQL stores geometry as WKB with a 4-byte SRID prefix. +func (p *MySQLPlugin) FormatGeometryValue(rawBytes []byte, columnType string) string { + if len(rawBytes) == 0 { + return "" + } + + // MySQL geometry format: 4-byte SRID (little-endian) + WKB data + if len(rawBytes) > 4 { + srid := binary.LittleEndian.Uint32(rawBytes[:4]) + wkbData := rawBytes[4:] + + if geom, err := wkb.Unmarshal(wkbData); err == nil { + if wktStr, err := wkt.Marshal(geom); err == nil { + if srid != 0 { + return fmt.Sprintf("%s (SRID:%d)", wktStr, srid) + } + return wktStr + } + } + } + + // If WKB decoding fails, return as string + return string(rawBytes) +} diff --git a/dataflow/core/src/plugins/mysql/graph.go b/dataflow/core/src/plugins/mysql/graph.go new file mode 100755 index 0000000..df8524b --- /dev/null +++ b/dataflow/core/src/plugins/mysql/graph.go @@ -0,0 +1,37 @@ +// Copyright 2025 Clidey, Inc. +// +// 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. + +package mysql + +import "gorm.io/gorm" + +const graphQuery = ` +SELECT DISTINCT + rc.REFERENCED_TABLE_NAME AS Table1, + rc.TABLE_NAME AS Table2, + 'OneToMany' AS Relation, + kcu.COLUMN_NAME AS SourceColumn, + kcu.REFERENCED_COLUMN_NAME AS TargetColumn +FROM + INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu + ON rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + AND rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA +WHERE + rc.CONSTRAINT_SCHEMA = ? +` + +func (p *MySQLPlugin) GetGraphQueryDB(db *gorm.DB, schema string) *gorm.DB { + return db.Raw(graphQuery, schema) +} diff --git a/dataflow/core/src/plugins/mysql/mysql.go b/dataflow/core/src/plugins/mysql/mysql.go new file mode 100755 index 0000000..ac285c9 --- /dev/null +++ b/dataflow/core/src/plugins/mysql/mysql.go @@ -0,0 +1,229 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mysql + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + gorm_plugin "github.com/clidey/whodb/core/src/plugins/gorm" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + supportedOperators = map[string]string{ + "=": "=", ">=": ">=", ">": ">", "<=": "<=", "<": "<", "<>": "<>", + "!=": "!=", "BETWEEN": "BETWEEN", "NOT BETWEEN": "NOT BETWEEN", + "LIKE": "LIKE", "NOT LIKE": "NOT LIKE", "IN": "IN", "NOT IN": "NOT IN", + "IS NULL": "IS NULL", "IS NOT NULL": "IS NOT NULL", "AND": "AND", "OR": "OR", "NOT": "NOT", + } +) + +type MySQLPlugin struct { + gorm_plugin.GormPlugin +} + +func (p *MySQLPlugin) GetDatabases(config *engine.PluginConfig) ([]string, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) ([]string, error) { + var allDBs []struct { + Database string `gorm:"column:Database"` + } + if err := db.Raw("SHOW DATABASES").Scan(&allDBs).Error; err != nil { + return nil, err + } + + // Verify each database with USE — the only reliable access check in MySQL. + // INFORMATION_SCHEMA privilege tables are unreliable due to GRANTEE format + // mismatches, role-based grants, and server-specific defaults. + // Cost is O(N) but runs once per profile (Apollo caches the result). + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + ctx := context.Background() + conn, err := sqlDB.Conn(ctx) + if err != nil { + return nil, err + } + defer conn.Close() + + var currentDB sql.NullString + conn.QueryRowContext(ctx, "SELECT DATABASE()").Scan(¤tDB) + + accessible := make([]string, 0, len(allDBs)) + for _, d := range allDBs { + escaped := strings.ReplaceAll(d.Database, "`", "``") + if _, err := conn.ExecContext(ctx, "USE `"+escaped+"`"); err == nil { + accessible = append(accessible, d.Database) + } + } + + if currentDB.Valid && currentDB.String != "" { + escaped := strings.ReplaceAll(currentDB.String, "`", "``") + conn.ExecContext(ctx, "USE `"+escaped+"`") + } + + return accessible, nil + }) +} + +func (p *MySQLPlugin) GetAllSchemasQuery() string { + return "SELECT SCHEMA_NAME AS schemaname FROM INFORMATION_SCHEMA.SCHEMATA" +} + +// GetLastInsertID returns the most recently auto-generated ID using MySQL's LAST_INSERT_ID(). +func (p *MySQLPlugin) GetLastInsertID(db *gorm.DB) (int64, error) { + var id int64 + if err := db.Raw("SELECT LAST_INSERT_ID()").Scan(&id).Error; err != nil { + return 0, err + } + return id, nil +} + +func (p *MySQLPlugin) GetSupportedOperators() map[string]string { + return supportedOperators +} + +func (p *MySQLPlugin) GetTableInfoQuery() string { + return ` + SELECT + TABLE_NAME, + TABLE_TYPE, + IFNULL(ROUND(((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024), 2), 0) AS total_size, + IFNULL(ROUND((DATA_LENGTH / 1024 / 1024), 2), 0) AS data_size + FROM + INFORMATION_SCHEMA.TABLES + WHERE + TABLE_SCHEMA = ?` +} + +func (p *MySQLPlugin) GetStorageUnitExistsQuery() string { + return `SELECT EXISTS(SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?)` +} + +func (p *MySQLPlugin) GetPlaceholder(index int) string { + return "?" +} + +func (p *MySQLPlugin) GetTableNameAndAttributes(rows *sql.Rows) (string, []engine.Record) { + var tableName, tableType string + var totalSize, dataSize float64 + if err := rows.Scan(&tableName, &tableType, &totalSize, &dataSize); err != nil { + log.WithError(err).Error("Failed to scan MySQL table information") + return "", []engine.Record{} + } + + attributes := []engine.Record{ + {Key: "Type", Value: tableType}, + {Key: "Total Size", Value: fmt.Sprintf("%.2f MB", totalSize)}, + {Key: "Data Size", Value: fmt.Sprintf("%.2f MB", dataSize)}, + } + return tableName, attributes +} + +func (p *MySQLPlugin) RawExecute(config *engine.PluginConfig, query string, params ...any) (*engine.GetRowsResult, error) { + if config != nil && !config.MultiStatement && gorm_plugin.IsMultiStatement(query) { + cfgCopy := *config + cfgCopy.MultiStatement = true + config = &cfgCopy + } + return p.ExecuteRawSQL(config, func(cfg *engine.PluginConfig) (*gorm.DB, error) { + return p.openDB(cfg, true) + }, query, params...) +} + +// CreateSQLBuilder creates a MySQL-specific SQL builder +func (p *MySQLPlugin) CreateSQLBuilder(db *gorm.DB) gorm_plugin.SQLBuilderInterface { + return NewMySQLSQLBuilder(db, p) +} + +func (p *MySQLPlugin) GetForeignKeyRelationships(config *engine.PluginConfig, schema string, storageUnit string) (map[string]*engine.ForeignKeyRelationship, error) { + query := ` + SELECT + COLUMN_NAME, + REFERENCED_TABLE_NAME, + REFERENCED_COLUMN_NAME + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = ? + AND TABLE_NAME = ? + AND REFERENCED_TABLE_NAME IS NOT NULL + ` + return p.QueryForeignKeyRelationships(config, query, schema, storageUnit) +} + +// NormalizeType converts MySQL type aliases to their canonical form. +func (p *MySQLPlugin) NormalizeType(typeName string) string { + return NormalizeType(typeName) +} + +// MarkGeneratedColumns detects MySQL generated columns (VIRTUAL or STORED) +// and marks them as IsComputed. +func (p *MySQLPlugin) MarkGeneratedColumns(config *engine.PluginConfig, schema string, storageUnit string, columns []engine.Column) error { + computed, err := p.QueryComputedColumns(config, ` + SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND GENERATION_EXPRESSION IS NOT NULL AND GENERATION_EXPRESSION != '' + `, schema, storageUnit) + if err != nil { + return err + } + + for i := range columns { + if computed[columns[i].Name] { + columns[i].IsComputed = true + } + } + return nil +} + +// BuildSkipConflictClause returns ON DUPLICATE KEY UPDATE pk = pk for MySQL. +// MySQL's GORM driver can't generate the id=id fallback without schema info when +// using .Table() with map records, so we provide explicit identity assignments. +func (p *MySQLPlugin) BuildSkipConflictClause(pkColumns []string) clause.OnConflict { + conflictCols := make([]clause.Column, len(pkColumns)) + assignments := make([]clause.Assignment, len(pkColumns)) + for i, col := range pkColumns { + c := clause.Column{Name: col} + conflictCols[i] = c + assignments[i] = clause.Assignment{Column: c, Value: c} + } + return clause.OnConflict{ + Columns: conflictCols, + DoUpdates: assignments, + } +} + +func NewMySQLPlugin() *engine.Plugin { + mysqlPlugin := &MySQLPlugin{} + mysqlPlugin.Type = engine.DatabaseType_MySQL + mysqlPlugin.PluginFunctions = mysqlPlugin + mysqlPlugin.GormPluginFunctions = mysqlPlugin + return &mysqlPlugin.Plugin +} + +func NewMyMariaDBPlugin() *engine.Plugin { + mysqlPlugin := &MySQLPlugin{} + mysqlPlugin.Type = engine.DatabaseType_MariaDB + mysqlPlugin.PluginFunctions = mysqlPlugin + mysqlPlugin.GormPluginFunctions = mysqlPlugin + return &mysqlPlugin.Plugin +} diff --git a/dataflow/core/src/plugins/mysql/sqlbuilder.go b/dataflow/core/src/plugins/mysql/sqlbuilder.go new file mode 100644 index 0000000..6f241ce --- /dev/null +++ b/dataflow/core/src/plugins/mysql/sqlbuilder.go @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package mysql + +import ( + gorm_plugin "github.com/clidey/whodb/core/src/plugins/gorm" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// MySQLSQLBuilder extends the base SQLBuilder with MySQL-specific behavior +type MySQLSQLBuilder struct { + *gorm_plugin.SQLBuilder +} + +// NewMySQLSQLBuilder creates a new MySQL-specific SQL builder +func NewMySQLSQLBuilder(db *gorm.DB, plugin gorm_plugin.GormPluginFunctions) gorm_plugin.SQLBuilderInterface { + msb := &MySQLSQLBuilder{ + SQLBuilder: gorm_plugin.NewSQLBuilder(db, plugin), + } + msb.SQLBuilder.SetSelf(msb) // Set self reference to enable polymorphic calls + return msb +} + +// GetTableQuery creates a GORM query with the appropriate table reference for MySQL/MariaDB +func (msb *MySQLSQLBuilder) GetTableQuery(schema, table string) *gorm.DB { + return msb.SQLBuilder.GetDB().Table(table).Clauses(clause.From{ + Tables: []clause.Table{ + {Name: table}, + }, + }) +} diff --git a/dataflow/core/src/plugins/mysql/ssl_status.go b/dataflow/core/src/plugins/mysql/ssl_status.go new file mode 100644 index 0000000..b2e6c36 --- /dev/null +++ b/dataflow/core/src/plugins/mysql/ssl_status.go @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mysql + +import ( + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/plugins" + "github.com/clidey/whodb/core/src/plugins/ssl" + "gorm.io/gorm" +) + +// GetSSLStatus queries MySQL/MariaDB to get the actual SSL status of the connection. +// Checks the Ssl_cipher session variable. Results are cached. +func (p *MySQLPlugin) GetSSLStatus(config *engine.PluginConfig) (*engine.SSLStatus, error) { + if cached := plugins.GetCachedSSLStatus(config); cached != nil { + return cached, nil + } + + status, err := plugins.WithConnection(config, p.DB, func(db *gorm.DB) (*engine.SSLStatus, error) { + var result struct { + VariableName string `gorm:"column:Variable_name"` + Value string `gorm:"column:Value"` + } + + if err := db.Raw("SHOW SESSION STATUS LIKE 'Ssl_cipher'").Scan(&result).Error; err != nil { + return nil, err + } + + if result.Value == "" { + return &engine.SSLStatus{ + IsEnabled: false, + Mode: string(ssl.SSLModeDisabled), + }, nil + } + + sslConfig := ssl.ParseSSLConfig(p.Type, config.Credentials.Advanced, config.Credentials.Hostname, config.Credentials.IsProfile) + mode := "enabled" + if sslConfig != nil { + mode = string(sslConfig.Mode) + } + + return &engine.SSLStatus{ + IsEnabled: true, + Mode: mode, + }, nil + }) + + if err == nil && status != nil { + plugins.SetCachedSSLStatus(config, status) + } + return status, err +} diff --git a/dataflow/core/src/plugins/mysql/types.go b/dataflow/core/src/plugins/mysql/types.go new file mode 100644 index 0000000..b613e34 --- /dev/null +++ b/dataflow/core/src/plugins/mysql/types.go @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package mysql + +import ( + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" +) + +// AliasMap maps MySQL type aliases to their canonical names. +// All keys and values are UPPERCASE. +var AliasMap = map[string]string{ + "INTEGER": "INT", + "BOOL": "BOOLEAN", + "DEC": "DECIMAL", + "FIXED": "DECIMAL", + "NUMERIC": "DECIMAL", + "DOUBLE PRECISION": "DOUBLE", + "REAL": "DOUBLE", + "CHARACTER": "CHAR", + "CHARACTER VARYING": "VARCHAR", +} + +// TypeDefinitions contains the canonical MySQL types with metadata for UI. +var TypeDefinitions = []engine.TypeDefinition{ + {ID: "TINYINT", Label: "TINYINT", Category: engine.TypeCategoryNumeric}, + {ID: "SMALLINT", Label: "SMALLINT", Category: engine.TypeCategoryNumeric}, + {ID: "MEDIUMINT", Label: "MEDIUMINT", Category: engine.TypeCategoryNumeric}, + {ID: "INT", Label: "INT", Category: engine.TypeCategoryNumeric}, + {ID: "BIGINT", Label: "BIGINT", Category: engine.TypeCategoryNumeric}, + {ID: "DECIMAL", Label: "DECIMAL", HasPrecision: true, DefaultPrecision: engine.IntPtr(10), Category: engine.TypeCategoryNumeric}, + {ID: "FLOAT", Label: "FLOAT", Category: engine.TypeCategoryNumeric}, + {ID: "DOUBLE", Label: "DOUBLE", Category: engine.TypeCategoryNumeric}, + {ID: "VARCHAR", Label: "VARCHAR", HasLength: true, DefaultLength: engine.IntPtr(255), Category: engine.TypeCategoryText}, + {ID: "CHAR", Label: "CHAR", HasLength: true, DefaultLength: engine.IntPtr(1), Category: engine.TypeCategoryText}, + {ID: "TINYTEXT", Label: "TINYTEXT", Category: engine.TypeCategoryText}, + {ID: "TEXT", Label: "TEXT", Category: engine.TypeCategoryText}, + {ID: "MEDIUMTEXT", Label: "MEDIUMTEXT", Category: engine.TypeCategoryText}, + {ID: "LONGTEXT", Label: "LONGTEXT", Category: engine.TypeCategoryText}, + {ID: "BINARY", Label: "BINARY", HasLength: true, DefaultLength: engine.IntPtr(1), Category: engine.TypeCategoryBinary}, + {ID: "VARBINARY", Label: "VARBINARY", HasLength: true, DefaultLength: engine.IntPtr(255), Category: engine.TypeCategoryBinary}, + {ID: "TINYBLOB", Label: "TINYBLOB", Category: engine.TypeCategoryBinary}, + {ID: "BLOB", Label: "BLOB", Category: engine.TypeCategoryBinary}, + {ID: "MEDIUMBLOB", Label: "MEDIUMBLOB", Category: engine.TypeCategoryBinary}, + {ID: "LONGBLOB", Label: "LONGBLOB", Category: engine.TypeCategoryBinary}, + {ID: "DATE", Label: "DATE", Category: engine.TypeCategoryDatetime}, + {ID: "TIME", Label: "TIME", Category: engine.TypeCategoryDatetime}, + {ID: "DATETIME", Label: "DATETIME", Category: engine.TypeCategoryDatetime}, + {ID: "TIMESTAMP", Label: "TIMESTAMP", Category: engine.TypeCategoryDatetime}, + {ID: "YEAR", Label: "YEAR", Category: engine.TypeCategoryDatetime}, + {ID: "BOOLEAN", Label: "BOOL", Category: engine.TypeCategoryBoolean}, + {ID: "JSON", Label: "JSON", Category: engine.TypeCategoryJSON}, + {ID: "ENUM", Label: "ENUM", Category: engine.TypeCategoryOther}, + {ID: "SET", Label: "SET", Category: engine.TypeCategoryOther}, +} + +// NormalizeType converts a MySQL type alias to its canonical form. +func NormalizeType(typeName string) string { + return common.NormalizeTypeWithMap(typeName, AliasMap) +} + +// GetDatabaseMetadata returns MySQL/MariaDB metadata for frontend configuration. +func (p *MySQLPlugin) GetDatabaseMetadata() *engine.DatabaseMetadata { + operators := make([]string, 0, len(supportedOperators)) + for op := range supportedOperators { + operators = append(operators, op) + } + return &engine.DatabaseMetadata{ + DatabaseType: p.Type, // Uses the plugin's actual type (MySQL or MariaDB) + TypeDefinitions: TypeDefinitions, + Operators: operators, + AliasMap: AliasMap, + Capabilities: engine.Capabilities{ + SupportsScratchpad: true, + SupportsChat: true, + SupportsGraph: true, + SupportsDatabaseSwitch: true, + SupportsModifiers: true, + }, + SystemSchemas: []string{"information_schema", "mysql", "performance_schema", "sys"}, + } +} diff --git a/dataflow/core/src/plugins/mysql/types_test.go b/dataflow/core/src/plugins/mysql/types_test.go new file mode 100644 index 0000000..790a925 --- /dev/null +++ b/dataflow/core/src/plugins/mysql/types_test.go @@ -0,0 +1,33 @@ +package mysql + +import ( + "testing" + + "github.com/clidey/whodb/core/src/engine" +) + +func TestNormalizeType(t *testing.T) { + if got := NormalizeType("integer"); got != "INT" { + t.Fatalf("expected INT, got %q", got) + } + + if got := NormalizeType("character varying(50)"); got != "VARCHAR(50)" { + t.Fatalf("expected VARCHAR(50), got %q", got) + } +} + +func TestGetDatabaseMetadataUsesPluginType(t *testing.T) { + p := &MySQLPlugin{} + p.Type = engine.DatabaseType_MySQL + + meta := p.GetDatabaseMetadata() + if meta == nil { + t.Fatalf("expected metadata, got nil") + } + if meta.DatabaseType != engine.DatabaseType_MySQL { + t.Fatalf("expected DatabaseType %q, got %q", engine.DatabaseType_MySQL, meta.DatabaseType) + } + if meta.AliasMap["INTEGER"] != "INT" { + t.Fatalf("expected INTEGER alias to be INT, got %q", meta.AliasMap["INTEGER"]) + } +} diff --git a/dataflow/core/src/plugins/mysql/utils.go b/dataflow/core/src/plugins/mysql/utils.go new file mode 100755 index 0000000..1ce025f --- /dev/null +++ b/dataflow/core/src/plugins/mysql/utils.go @@ -0,0 +1,34 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package mysql + +// Identifier quoting handled by GORM Dialector + +func (p *MySQLPlugin) GetPrimaryKeyColQuery() string { + return ` + SELECT k.COLUMN_NAME + FROM information_schema.TABLE_CONSTRAINTS t + JOIN information_schema.KEY_COLUMN_USAGE k + ON k.CONSTRAINT_NAME = t.CONSTRAINT_NAME + AND k.TABLE_SCHEMA = t.TABLE_SCHEMA + AND k.TABLE_NAME = t.TABLE_NAME + WHERE t.CONSTRAINT_TYPE = 'PRIMARY KEY' + AND t.TABLE_SCHEMA = ? + AND t.TABLE_NAME = ? + ORDER BY k.ORDINAL_POSITION; + ` +} diff --git a/dataflow/core/src/plugins/ports.go b/dataflow/core/src/plugins/ports.go new file mode 100644 index 0000000..2c4f9e0 --- /dev/null +++ b/dataflow/core/src/plugins/ports.go @@ -0,0 +1,56 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package plugins + +import ( + "github.com/clidey/whodb/core/src/engine" +) + +// defaultDatabasePorts maps database systems to their standard default ports +var defaultDatabasePorts = map[engine.DatabaseType]string{ + engine.DatabaseType_MySQL: "3306", + engine.DatabaseType_MariaDB: "3306", + engine.DatabaseType_Postgres: "5432", + engine.DatabaseType_Sqlite3: "0", // SQLite is file-based, no port + engine.DatabaseType_ClickHouse: "9000", // TCP port (HTTP port is 8123) + engine.DatabaseType_MongoDB: "27017", + engine.DatabaseType_ElasticSearch: "9200", // HTTP port (Transport port is 9300) + engine.DatabaseType_Redis: "6379", +} + +// additionalPorts holds ports registered by external packages (e.g., enterprise edition) +var additionalPorts = make(map[engine.DatabaseType]string) + +// GetDefaultPort returns the default port for a database type +func GetDefaultPort(dbType engine.DatabaseType) (string, bool) { + // First check standard ports + if port, ok := defaultDatabasePorts[dbType]; ok { + return port, true + } + + // Then check additional ports registered by external packages + if port, ok := additionalPorts[dbType]; ok { + return port, true + } + + return "", false +} + +// RegisterDatabasePort allows external packages to register additional database ports +func RegisterDatabasePort(dbType engine.DatabaseType, port string) { + additionalPorts[dbType] = port +} \ No newline at end of file diff --git a/dataflow/core/src/plugins/ports_test.go b/dataflow/core/src/plugins/ports_test.go new file mode 100644 index 0000000..526fabf --- /dev/null +++ b/dataflow/core/src/plugins/ports_test.go @@ -0,0 +1,62 @@ +// Copyright 2025 Clidey, Inc. +// +// 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. + +package plugins + +import ( + "testing" + + "github.com/clidey/whodb/core/src/engine" +) + +func TestGetDefaultPort(t *testing.T) { + cases := map[engine.DatabaseType]string{ + engine.DatabaseType_Postgres: "5432", + engine.DatabaseType_MySQL: "3306", + engine.DatabaseType_MariaDB: "3306", + engine.DatabaseType_Sqlite3: "0", + engine.DatabaseType_ElasticSearch: "9200", + engine.DatabaseType_Redis: "6379", + } + + for dbType, expected := range cases { + port, ok := GetDefaultPort(dbType) + if !ok { + t.Fatalf("expected default port to exist for %s", dbType) + } + if port != expected { + t.Fatalf("expected default port %s for %s, got %s", expected, dbType, port) + } + } + + if _, ok := GetDefaultPort("unknown"); ok { + t.Fatalf("expected unknown database type to return ok=false") + } +} + +func TestRegisterDatabasePort(t *testing.T) { + original := additionalPorts + additionalPorts = make(map[engine.DatabaseType]string) + t.Cleanup(func() { + additionalPorts = original + }) + + customType := engine.DatabaseType("CustomDB") + RegisterDatabasePort(customType, "7777") + + port, ok := GetDefaultPort(customType) + if !ok || port != "7777" { + t.Fatalf("expected registered port 7777 to be returned, got %s", port) + } +} diff --git a/dataflow/core/src/plugins/postgres/add.go b/dataflow/core/src/plugins/postgres/add.go new file mode 100644 index 0000000..9962f19 --- /dev/null +++ b/dataflow/core/src/plugins/postgres/add.go @@ -0,0 +1,41 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package postgres + +import ( + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/plugins/gorm" + "gorm.io/gorm" +) + +func (p *PostgresPlugin) GetCreateTableQuery(db *gorm.DB, schema string, storageUnit string, columns []engine.Record) string { + builder := gorm_plugin.NewSQLBuilder(db, p) + + columnDefs := gorm_plugin.RecordsToColumnDefs(columns, func(def gorm_plugin.ColumnDef, column engine.Record) gorm_plugin.ColumnDef { + lowerType := strings.ToLower(column.Value) + if column.Extra[gorm_plugin.PrimaryKeysUseSourceValuesExtraKey] != "true" && (strings.Contains(lowerType, "int") || strings.Contains(lowerType, "integer")) { + def.Extra = "PRIMARY KEY GENERATED ALWAYS AS IDENTITY" + } else { + def.Primary = true + } + return def + }) + + return builder.CreateTableQuery(schema, storageUnit, columnDefs) +} diff --git a/dataflow/core/src/plugins/postgres/constraints.go b/dataflow/core/src/plugins/postgres/constraints.go new file mode 100644 index 0000000..48b77dd --- /dev/null +++ b/dataflow/core/src/plugins/postgres/constraints.go @@ -0,0 +1,253 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package postgres + +import ( + "regexp" + "strings" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + gorm_plugin "github.com/clidey/whodb/core/src/plugins/gorm" + "gorm.io/gorm" +) + +// Pre-compiled regex for PostgreSQL type casts like ::text, ::character varying +var pgTypeCastPattern = regexp.MustCompile(`::\w+(\s+\w+)?`) + +// GetColumnConstraints retrieves column constraints for PostgreSQL tables +func (p *PostgresPlugin) GetColumnConstraints(config *engine.PluginConfig, schema string, storageUnit string) (map[string]map[string]any, error) { + constraints := make(map[string]map[string]any) + + _, err := plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + // Get primary keys using Postgres system catalogs + fullTableName := schema + "." + storageUnit + primaryRows, err := db.Raw(` + SELECT a.attname AS column_name + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = $1::regclass AND i.indisprimary + `, fullTableName).Rows() + if err == nil { + defer primaryRows.Close() + for primaryRows.Next() { + var columnName string + if err := primaryRows.Scan(&columnName); err != nil { + continue + } + gorm_plugin.EnsureConstraintEntry(constraints, columnName)["primary"] = true + } + } + + // Get nullability using GORM's query builder + rows, err := db.Table("information_schema.columns"). + Select("column_name, is_nullable"). + Where("table_schema = ? AND table_name = ?", schema, storageUnit). + Rows() + if err != nil { + return false, err + } + defer rows.Close() + + for rows.Next() { + var columnName, isNullable string + if err := rows.Scan(&columnName, &isNullable); err != nil { + continue + } + + gorm_plugin.EnsureConstraintEntry(constraints, columnName)["nullable"] = strings.EqualFold(isNullable, "YES") + } + + // Get unique single-column indexes using GORM's query builder + uniqueRows, err := db.Table("pg_index i"). + Select("a.attname AS column_name"). + Joins("JOIN pg_class c ON c.oid = i.indrelid"). + Joins("JOIN pg_namespace n ON n.oid = c.relnamespace"). + Joins("JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(i.indkey)"). + Where("c.relname = ? AND n.nspname = ? AND i.indisunique = true AND i.indnkeyatts = 1", storageUnit, schema). + Rows() + if err != nil { + return false, err + } + defer uniqueRows.Close() + + for uniqueRows.Next() { + var columnName string + if err := uniqueRows.Scan(&columnName); err != nil { + continue + } + + gorm_plugin.EnsureConstraintEntry(constraints, columnName)["unique"] = true + } + + // Get CHECK constraints using GORM's query builder + checkRows, err := db.Table("pg_constraint"). + Select("conname AS constraint_name, pg_get_constraintdef(oid) AS check_clause"). + Where("contype = 'c' AND conrelid = ?::regclass", fullTableName). + Rows() + if err == nil { + defer checkRows.Close() + + for checkRows.Next() { + var constraintName, checkClause string + if err := checkRows.Scan(&constraintName, &checkClause); err != nil { + continue + } + + // Parse the CHECK clause to extract column and condition + p.parseCheckConstraint(checkClause, constraints) + } + } + // Ignore error if query fails + + // Get ENUM type values for columns that use native PostgreSQL ENUMs + enumRows, err := db.Raw(` + SELECT c.column_name, e.enumlabel + FROM information_schema.columns c + JOIN pg_type t ON t.typname = c.udt_name + JOIN pg_enum e ON e.enumtypid = t.oid + WHERE c.table_schema = $1 AND c.table_name = $2 + ORDER BY c.column_name, e.enumsortorder + `, schema, storageUnit).Rows() + if err == nil { + defer enumRows.Close() + + // Group enum values by column name + enumValues := make(map[string][]string) + for enumRows.Next() { + var columnName, enumLabel string + if err := enumRows.Scan(&columnName, &enumLabel); err != nil { + continue + } + enumValues[columnName] = append(enumValues[columnName], enumLabel) + } + + // Add enum values to constraints + for columnName, values := range enumValues { + gorm_plugin.EnsureConstraintEntry(constraints, columnName)["check_values"] = values + } + } + // Ignore error if query fails (table may not have any ENUM columns) + + return true, nil + }) + + if err != nil { + return constraints, err + } + + return constraints, nil +} + +// parseCheckConstraint parses PostgreSQL CHECK constraint clauses to extract column constraints +func (p *PostgresPlugin) parseCheckConstraint(checkClause string, constraints map[string]map[string]any) { + // PostgreSQL formats CHECK constraints like: + // - CHECK ((price >= (0)::numeric)) + // - CHECK ((stock_quantity >= 0)) + // - CHECK ((age >= 18) AND (age <= 120)) + // - CHECK ((status)::text = ANY (ARRAY['active'::text, 'inactive'::text])) + // - CHECK (status IN ('pending', 'completed', 'canceled')) + + log.WithField("checkClause", checkClause).Debug("Parsing CHECK constraint") + + // Remove CHECK keyword and outer parentheses + clause := strings.TrimPrefix(checkClause, "CHECK ") + clause = strings.Trim(clause, "()") + + columnName := gorm_plugin.ExtractColumnNameFromClause(clause) + if columnName == "" { + return + } + + if !common.ValidateColumnName(columnName) { + return + } + + colConstraints := gorm_plugin.EnsureConstraintEntry(constraints, columnName) + + minMax := gorm_plugin.ParseMinMaxConstraints(clause) + gorm_plugin.ApplyMinMaxToConstraints(colConstraints, minMax) + + // Try to extract values from ARRAY[...] syntax + // This handles any PostgreSQL format: ANY(ARRAY[...]), ANY((ARRAY[...])::text[]), etc. + if values := extractArrayValues(clause); len(values) > 0 { + colConstraints["check_values"] = values + log.WithField("column", columnName).WithField("values", values).Debug("Extracted check_values via ARRAY pattern") + return + } + + // Fallback to IN clause parsing + if values := gorm_plugin.ParseINClauseValues(clause); len(values) > 0 { + colConstraints["check_values"] = values + log.WithField("column", columnName).WithField("values", values).Debug("Extracted check_values via IN clause pattern") + } else { + log.WithField("column", columnName).WithField("clause", clause).Debug("No check_values extracted from clause") + } +} + +// extractArrayValues extracts values from PostgreSQL ARRAY[...] syntax. +// it just finds ARRAY[ and extracts values up to the matching ]. +func extractArrayValues(clause string) []string { + upper := strings.ToUpper(clause) + arrayIdx := strings.Index(upper, "ARRAY[") + if arrayIdx == -1 { + return nil + } + + // Find the start of values (after "ARRAY[") + startIdx := arrayIdx + 6 + + // Find matching closing bracket, accounting for nested brackets + depth := 1 + endIdx := -1 + for i := startIdx; i < len(clause); i++ { + switch clause[i] { + case '[': + depth++ + case ']': + depth-- + if depth == 0 { + endIdx = i + } + } + if endIdx != -1 { + break + } + } + + if endIdx == -1 || endIdx <= startIdx { + return nil + } + + content := clause[startIdx:endIdx] + + var values []string + parts := strings.Split(content, ",") + for _, part := range parts { + cleaned := strings.TrimSpace(part) + // Remove PostgreSQL type casts like ::text, ::character varying + cleaned = pgTypeCastPattern.ReplaceAllString(cleaned, "") + cleaned = strings.Trim(cleaned, "'\"") + if cleaned != "" { + values = append(values, cleaned) + } + } + + return values +} diff --git a/dataflow/core/src/plugins/postgres/db.go b/dataflow/core/src/plugins/postgres/db.go new file mode 100644 index 0000000..84324db --- /dev/null +++ b/dataflow/core/src/plugins/postgres/db.go @@ -0,0 +1,112 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package postgres + +import ( + "maps" + "time" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + "github.com/clidey/whodb/core/src/plugins/ssl" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func (p *PostgresPlugin) DB(config *engine.PluginConfig) (*gorm.DB, error) { + return p.openDB(config, false) +} + +func (p *PostgresPlugin) openDB(config *engine.PluginConfig, multiStatement bool) (*gorm.DB, error) { + connectionInput, err := p.ParseConnectionConfig(config) + if err != nil { + return nil, err + } + + pgxConfig, err := pgx.ParseConfig("") + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": connectionInput.Hostname, + "port": connectionInput.Port, + "database": connectionInput.Database, + "username": connectionInput.Username, + }).Error("Failed to parse PostgreSQL configuration") + return nil, err + } + + pgxConfig.Host = connectionInput.Hostname + pgxConfig.Port = uint16(connectionInput.Port) + pgxConfig.User = connectionInput.Username + pgxConfig.Password = connectionInput.Password + pgxConfig.Database = connectionInput.Database + pgxConfig.ConnectTimeout = time.Duration(connectionInput.ConnectionTimeout) * time.Second + + // Use simple query protocol for multi-statement SQL scripts. This allows + // both Exec and Query paths to send multiple commands in a single query + // and receive multiple result sets via sql.Rows.NextResultSet(). Results + // are returned as text (a property of simple protocol). + if multiStatement { + pgxConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol + } + + // Configure SSL/TLS + sslMode := "disabled" + if connectionInput.SSLConfig != nil && connectionInput.SSLConfig.IsEnabled() { + sslMode = string(connectionInput.SSLConfig.Mode) + tlsConfig, err := ssl.BuildTLSConfig(connectionInput.SSLConfig, connectionInput.Hostname) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": connectionInput.Hostname, + "sslMode": connectionInput.SSLConfig.Mode, + }).Error("Failed to build TLS configuration for PostgreSQL") + return nil, err + } + pgxConfig.TLSConfig = tlsConfig + } + + if connectionInput.ExtraOptions != nil { + if pgxConfig.RuntimeParams == nil { + pgxConfig.RuntimeParams = make(map[string]string) + } + maps.Copy(pgxConfig.RuntimeParams, connectionInput.ExtraOptions) + } + + l := log.WithFields(map[string]any{ + "hostname": connectionInput.Hostname, + "port": connectionInput.Port, + "database": connectionInput.Database, + "username": connectionInput.Username, + "sslMode": sslMode, + }) + + db, err := gorm.Open(postgres.New(postgres.Config{Conn: stdlib.OpenDB(*pgxConfig)}), &gorm.Config{Logger: logger.Default.LogMode(plugins.GetGormLogConfig())}) + if err != nil { + l.WithError(err).Error("Failed to connect to PostgreSQL database") + return nil, err + } + + // Configure connection pool for better reconnection behavior + if err := plugins.ConfigureConnectionPool(db); err != nil { + l.WithError(err).Warn("Failed to configure connection pool") + } + + return db, nil +} diff --git a/dataflow/core/src/plugins/postgres/geometry.go b/dataflow/core/src/plugins/postgres/geometry.go new file mode 100644 index 0000000..e3190fc --- /dev/null +++ b/dataflow/core/src/plugins/postgres/geometry.go @@ -0,0 +1,41 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package postgres + +import ( + "github.com/twpayne/go-geom/encoding/ewkb" + "github.com/twpayne/go-geom/encoding/wkt" +) + +// FormatGeometryValue formats PostgreSQL geometry data for display. +// PostGIS columns use EWKB format which we decode to WKT. +// Native PostgreSQL geometric types (point, line, etc.) return text representation. +func (p *PostgresPlugin) FormatGeometryValue(rawBytes []byte, columnType string) string { + if len(rawBytes) == 0 { + return "" + } + + // Try PostGIS EWKB decoding first + if geom, err := ewkb.Unmarshal(rawBytes); err == nil { + if wktStr, err := wkt.Marshal(geom); err == nil { + return wktStr + } + } + + // Native PostgreSQL geometry types return text representation as bytes + return string(rawBytes) +} diff --git a/dataflow/core/src/plugins/postgres/graph.go b/dataflow/core/src/plugins/postgres/graph.go new file mode 100644 index 0000000..dbd72cf --- /dev/null +++ b/dataflow/core/src/plugins/postgres/graph.go @@ -0,0 +1,121 @@ +// Copyright 2025 Clidey, Inc. +// +// 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. + +package postgres + +import "gorm.io/gorm" + +const graphQuery = ` +WITH fk_constraints AS ( + SELECT DISTINCT + ccu.table_name AS table1, + tc.table_name AS table2, + 'OneToMany' AS relation, + kcu.column_name AS source_column, + ccu.column_name AS target_column + FROM + information_schema.table_constraints AS tc + JOIN + information_schema.key_column_usage AS kcu + ON + tc.constraint_name = kcu.constraint_name + JOIN + information_schema.constraint_column_usage AS ccu + ON + ccu.constraint_name = tc.constraint_name + WHERE + tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = ? + AND ccu.table_schema = ? +), +pk_constraints AS ( + SELECT DISTINCT + tc.table_name AS table1, + ccu.table_name AS table2, + 'OneToOne' AS relation, + NULL::text AS source_column, + NULL::text AS target_column + FROM + information_schema.table_constraints AS tc + JOIN + information_schema.key_column_usage AS kcu + ON + tc.constraint_name = kcu.constraint_name + JOIN + information_schema.constraint_column_usage AS ccu + ON + ccu.constraint_name = tc.constraint_name + WHERE + tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = ? + AND ccu.table_schema = ? + AND tc.table_name != ccu.table_name +), +unique_constraints AS ( + SELECT DISTINCT + tc.table_name AS table1, + ccu.table_name AS table2, + 'ManyToOne' AS relation, + NULL::text AS source_column, + NULL::text AS target_column + FROM + information_schema.table_constraints AS tc + JOIN + information_schema.key_column_usage AS kcu + ON + tc.constraint_name = kcu.constraint_name + JOIN + information_schema.constraint_column_usage AS ccu + ON + ccu.constraint_name = tc.constraint_name + WHERE + tc.constraint_type = 'UNIQUE' + AND tc.table_schema = ? + AND ccu.table_schema = ? + AND tc.table_name != ccu.table_name +), +many_to_many_constraints AS ( + SELECT DISTINCT + kcu1.table_name AS table1, + kcu2.table_name AS table2, + 'ManyToMany' AS relation, + NULL::text AS source_column, + NULL::text AS target_column + FROM + information_schema.key_column_usage kcu1 + JOIN + information_schema.referential_constraints rc + ON + kcu1.constraint_name = rc.constraint_name + JOIN + information_schema.key_column_usage kcu2 + ON + kcu2.constraint_name = rc.unique_constraint_name + WHERE + kcu1.ordinal_position = 1 AND kcu2.ordinal_position = 2 + AND kcu1.table_schema = ? + AND kcu2.table_schema = ? +) +SELECT * FROM fk_constraints +UNION +SELECT * FROM pk_constraints +UNION +SELECT * FROM unique_constraints +UNION +SELECT * FROM many_to_many_constraints +` + +func (p *PostgresPlugin) GetGraphQueryDB(db *gorm.DB, schema string) *gorm.DB { + return db.Raw(graphQuery, schema, schema, schema, schema, schema, schema, schema, schema) +} diff --git a/dataflow/core/src/plugins/postgres/postgres.go b/dataflow/core/src/plugins/postgres/postgres.go new file mode 100644 index 0000000..f9b64c5 --- /dev/null +++ b/dataflow/core/src/plugins/postgres/postgres.go @@ -0,0 +1,205 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package postgres + +import ( + "database/sql" + "fmt" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + gorm_plugin "github.com/clidey/whodb/core/src/plugins/gorm" + "gorm.io/gorm" +) + +var ( + supportedOperators = map[string]string{ + "=": "=", ">=": ">=", ">": ">", "<=": "<=", "<": "<", "<>": "<>", + "!=": "!=", "BETWEEN": "BETWEEN", "NOT BETWEEN": "NOT BETWEEN", + "LIKE": "LIKE", "NOT LIKE": "NOT LIKE", "ILIKE": "ILIKE", "NOT ILIKE": "NOT ILIKE", + "IN": "IN", "NOT IN": "NOT IN", + "IS NULL": "IS NULL", "IS NOT NULL": "IS NOT NULL", "AND": "AND", "OR": "OR", "NOT": "NOT", + } +) + +type PostgresPlugin struct { + gorm_plugin.GormPlugin + + // classifySystemObjects overrides the catalog fingerprint query in tests; + // nil means querySystemObjectNames. + classifySystemObjects func(config *engine.PluginConfig, schema string) (map[string]bool, error) +} + +// GetStorageUnits lists the schema's storage units and marks System Objects +// with SystemObjectAttributeKey. Classification runs as a separate fail-open +// query so it can never break the listing itself. +func (p *PostgresPlugin) GetStorageUnits(config *engine.PluginConfig, schema string) ([]engine.StorageUnit, error) { + units, err := p.GormPlugin.GetStorageUnits(config, schema) + if err != nil { + return nil, err + } + p.markSystemObjects(config, schema, units) + return units, nil +} + +func (p *PostgresPlugin) GetSupportedOperators() map[string]string { + return supportedOperators +} + +// GetLastInsertID returns the most recently auto-generated ID using PostgreSQL's lastval(). +func (p *PostgresPlugin) GetLastInsertID(db *gorm.DB) (int64, error) { + var id int64 + if err := db.Raw("SELECT lastval()").Scan(&id).Error; err != nil { + if strings.Contains(err.Error(), "lastval is not yet defined") { + return 0, nil + } + return 0, err + } + return id, nil +} + +func (p *PostgresPlugin) GetAllSchemasQuery() string { + return "SELECT schema_name AS schemaname FROM information_schema.schemata WHERE has_schema_privilege(schema_name, 'USAGE')" +} + +func (p *PostgresPlugin) GetTableInfoQuery() string { + return ` + SELECT + t.table_name, + t.table_type, + pg_size_pretty(pg_total_relation_size(quote_ident(t.table_schema) || '.' || quote_ident(t.table_name))) AS total_size, + pg_size_pretty(pg_relation_size(quote_ident(t.table_schema) || '.' || quote_ident(t.table_name))) AS data_size + FROM + information_schema.tables t + WHERE + t.table_schema = ?; + ` + + // AND t.table_type = 'BASE TABLE' this removes the view tables +} + +// GetStorageUnitExistsQuery returns a query that quotes raw schema and relation names before resolving them. +func (p *PostgresPlugin) GetStorageUnitExistsQuery() string { + return `SELECT to_regclass(quote_ident($1) || '.' || quote_ident($2)) IS NOT NULL` +} + +func (p *PostgresPlugin) GetPlaceholder(index int) string { + return fmt.Sprintf("$%d", index) +} + +func (p *PostgresPlugin) GetTableNameAndAttributes(rows *sql.Rows) (string, []engine.Record) { + var tableName, tableType, totalSize, dataSize string + if err := rows.Scan(&tableName, &tableType, &totalSize, &dataSize); err != nil { + log.WithError(err).Error("Failed to scan table info row data") + return "", nil + } + + attributes := []engine.Record{ + {Key: "Type", Value: tableType}, + {Key: "Total Size", Value: totalSize}, + {Key: "Data Size", Value: dataSize}, + } + + return tableName, attributes +} + +func (p *PostgresPlugin) GetDatabases(config *engine.PluginConfig) ([]string, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) ([]string, error) { + var databases []struct { + Datname string `gorm:"column:datname"` + } + if err := db.Raw("SELECT datname FROM pg_database WHERE datistemplate = false AND datallowconn AND has_database_privilege(datname, 'CONNECT')"). + Scan(&databases).Error; err != nil { + return nil, err + } + databaseNames := []string{} + for _, database := range databases { + databaseNames = append(databaseNames, database.Datname) + } + return databaseNames, nil + }) +} + +func (p *PostgresPlugin) RawExecute(config *engine.PluginConfig, query string, params ...any) (*engine.GetRowsResult, error) { + if config != nil && !config.MultiStatement && gorm_plugin.IsMultiStatement(query) { + cfgCopy := *config + cfgCopy.MultiStatement = true + config = &cfgCopy + } + return p.ExecuteRawSQL(config, func(cfg *engine.PluginConfig) (*gorm.DB, error) { + return p.openDB(cfg, true) + }, query, params...) +} + +func (p *PostgresPlugin) GetForeignKeyRelationships(config *engine.PluginConfig, schema string, storageUnit string) (map[string]*engine.ForeignKeyRelationship, error) { + query := ` + SELECT + kcu.column_name, + ccu.table_name AS referenced_table, + ccu.column_name AS referenced_column + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = ? + AND tc.table_name = ? + ` + return p.QueryForeignKeyRelationships(config, query, schema, storageUnit) +} + +// NormalizeType converts PostgreSQL type aliases to their canonical form. +func (p *PostgresPlugin) NormalizeType(typeName string) string { + return NormalizeType(typeName) +} + +// MarkGeneratedColumns detects PostgreSQL generated columns (GENERATED ALWAYS AS) +// and marks them as IsComputed. +func (p *PostgresPlugin) MarkGeneratedColumns(config *engine.PluginConfig, schema string, storageUnit string, columns []engine.Column) error { + computed, err := p.QueryComputedColumns(config, ` + SELECT column_name FROM information_schema.columns + WHERE table_schema = ? AND table_name = ? AND is_generated = 'ALWAYS' + `, schema, storageUnit) + if err != nil { + return err + } + + for i := range columns { + if computed[columns[i].Name] { + columns[i].IsComputed = true + } + } + return nil +} + +// IsArrayType returns true for PostgreSQL array types which use an underscore prefix +// (e.g., _int4 for int[], _text for text[]). +func (p *PostgresPlugin) IsArrayType(columnType string) bool { + return strings.HasPrefix(columnType, "_") +} + +func NewPostgresPlugin() *engine.Plugin { + plugin := &PostgresPlugin{} + plugin.Type = engine.DatabaseType_Postgres + plugin.PluginFunctions = plugin + plugin.GormPluginFunctions = plugin + return &plugin.Plugin +} diff --git a/dataflow/core/src/plugins/postgres/ssl_status.go b/dataflow/core/src/plugins/postgres/ssl_status.go new file mode 100644 index 0000000..cc1e0ff --- /dev/null +++ b/dataflow/core/src/plugins/postgres/ssl_status.go @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package postgres + +import ( + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/plugins" + "github.com/clidey/whodb/core/src/plugins/ssl" + "gorm.io/gorm" +) + +// GetSSLStatus queries PostgreSQL to get the actual SSL status of the connection. +// Uses pg_stat_ssl system view. Results are cached. +func (p *PostgresPlugin) GetSSLStatus(config *engine.PluginConfig) (*engine.SSLStatus, error) { + if cached := plugins.GetCachedSSLStatus(config); cached != nil { + return cached, nil + } + + status, err := plugins.WithConnection(config, p.DB, func(db *gorm.DB) (*engine.SSLStatus, error) { + var result struct { + SSL bool `gorm:"column:ssl"` + } + + query := db.Raw("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()").Scan(&result) + if query.Error != nil { + return nil, query.Error + } + + if query.RowsAffected == 0 { + // No row returned - might be permissions issue, fall back to config + sslConfig := ssl.ParseSSLConfig(engine.DatabaseType_Postgres, config.Credentials.Advanced, config.Credentials.Hostname, config.Credentials.IsProfile) + if sslConfig != nil && sslConfig.IsEnabled() { + return &engine.SSLStatus{ + IsEnabled: true, + Mode: string(sslConfig.Mode), + }, nil + } + return &engine.SSLStatus{ + IsEnabled: false, + Mode: string(ssl.SSLModeDisabled), + }, nil + } + + if !result.SSL { + return &engine.SSLStatus{ + IsEnabled: false, + Mode: string(ssl.SSLModeDisabled), + }, nil + } + + sslConfig := ssl.ParseSSLConfig(engine.DatabaseType_Postgres, config.Credentials.Advanced, config.Credentials.Hostname, config.Credentials.IsProfile) + mode := "enabled" + if sslConfig != nil { + mode = string(sslConfig.Mode) + } + + return &engine.SSLStatus{ + IsEnabled: true, + Mode: mode, + }, nil + }) + + if err == nil && status != nil { + plugins.SetCachedSSLStatus(config, status) + } + return status, err +} diff --git a/dataflow/core/src/plugins/postgres/system_objects.go b/dataflow/core/src/plugins/postgres/system_objects.go new file mode 100644 index 0000000..dfc8d53 --- /dev/null +++ b/dataflow/core/src/plugins/postgres/system_objects.go @@ -0,0 +1,114 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package postgres + +import ( + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + "gorm.io/gorm" +) + +// SystemObjectAttributeKey marks a storage unit as provisioned by the database +// engine, an installed extension, or platform operator tooling rather than +// authored by the user. The namespaced key signals a transport-only marker: +// consumers promote it to a typed field at their contract boundary instead of +// displaying it as object metadata. +const SystemObjectAttributeKey = "whodb:system-object" + +// systemObjectsQuery fingerprints System Objects from catalog structure, never +// from object names, so a user table can only match by genuinely being (1) an +// extension member, (2) a foreign table served by file_fdw, or (3) a regular +// table whose inheritance children are all file_fdw foreign tables. The one +// deliberate name-based rule is (4): Spilo pins its failed_authentication_[0-7] +// views, which carry no structural marker of their own. +const systemObjectsQuery = ` + SELECT c.relname + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = ? + AND ( + EXISTS ( + SELECT 1 + FROM pg_catalog.pg_depend d + WHERE d.classid = 'pg_catalog.pg_class'::pg_catalog.regclass + AND d.objid = c.oid + AND d.deptype = 'e' + ) + OR (c.relkind = 'f' AND EXISTS ( + SELECT 1 + FROM pg_catalog.pg_foreign_table ft + JOIN pg_catalog.pg_foreign_server fs ON fs.oid = ft.ftserver + JOIN pg_catalog.pg_foreign_data_wrapper w ON w.oid = fs.srvfdw + WHERE ft.ftrelid = c.oid AND w.fdwname = 'file_fdw' + )) + OR (c.relkind = 'r' + AND EXISTS ( + SELECT 1 FROM pg_catalog.pg_inherits i WHERE i.inhparent = c.oid + ) + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_inherits i + JOIN pg_catalog.pg_class child ON child.oid = i.inhrelid + WHERE i.inhparent = c.oid + AND NOT (child.relkind = 'f' AND EXISTS ( + SELECT 1 + FROM pg_catalog.pg_foreign_table ft + JOIN pg_catalog.pg_foreign_server fs ON fs.oid = ft.ftserver + JOIN pg_catalog.pg_foreign_data_wrapper w ON w.oid = fs.srvfdw + WHERE ft.ftrelid = child.oid AND w.fdwname = 'file_fdw' + )) + )) + OR (c.relkind = 'v' AND c.relname ~ '^failed_authentication_[0-7]$') + ) +` + +// querySystemObjectNames runs the fingerprint query and returns the names of +// System Objects in the schema. +func (p *PostgresPlugin) querySystemObjectNames(config *engine.PluginConfig, schema string) (map[string]bool, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (map[string]bool, error) { + var names []string + if err := db.Raw(systemObjectsQuery, schema).Scan(&names).Error; err != nil { + return nil, err + } + systemNames := make(map[string]bool, len(names)) + for _, name := range names { + systemNames[name] = true + } + return systemNames, nil + }) +} + +// markSystemObjects appends the System Object attribute to every classified +// unit. It fails open: any classification error leaves the listing intact and +// unmarked. +func (p *PostgresPlugin) markSystemObjects(config *engine.PluginConfig, schema string, units []engine.StorageUnit) { + classify := p.classifySystemObjects + if classify == nil { + classify = p.querySystemObjectNames + } + systemNames, err := classify(config, schema) + if err != nil { + log.WithError(err).Warn("System Object classification failed; listing objects unmarked") + return + } + for i := range units { + if systemNames[units[i].Name] { + units[i].Attributes = append(units[i].Attributes, engine.Record{Key: SystemObjectAttributeKey, Value: "true"}) + } + } +} diff --git a/dataflow/core/src/plugins/postgres/system_objects_test.go b/dataflow/core/src/plugins/postgres/system_objects_test.go new file mode 100644 index 0000000..31f7bcc --- /dev/null +++ b/dataflow/core/src/plugins/postgres/system_objects_test.go @@ -0,0 +1,110 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package postgres + +import ( + "errors" + "testing" + + "github.com/clidey/whodb/core/src/engine" +) + +func systemObjectsTestPlugin(t *testing.T) *PostgresPlugin { + t.Helper() + + plugin, ok := NewPostgresPlugin().PluginFunctions.(*PostgresPlugin) + if !ok { + t.Fatalf("unexpected postgres plugin type %T", NewPostgresPlugin().PluginFunctions) + } + return plugin +} + +func hasSystemObjectAttribute(unit engine.StorageUnit) bool { + for _, attribute := range unit.Attributes { + if attribute.Key == SystemObjectAttributeKey { + return attribute.Value == "true" + } + } + return false +} + +func TestMarkSystemObjectsAppendsAttributeToClassifiedUnits(t *testing.T) { + plugin := systemObjectsTestPlugin(t) + plugin.classifySystemObjects = func(config *engine.PluginConfig, schema string) (map[string]bool, error) { + if schema != "public" { + t.Fatalf("expected classification for schema %q, got %q", "public", schema) + } + return map[string]bool{"postgres_log": true}, nil + } + + units := []engine.StorageUnit{ + {Name: "orders", Attributes: []engine.Record{{Key: "Type", Value: "BASE TABLE"}}}, + {Name: "postgres_log", Attributes: []engine.Record{{Key: "Type", Value: "BASE TABLE"}}}, + } + + plugin.markSystemObjects(engine.NewPluginConfig(&engine.Credentials{}), "public", units) + + if hasSystemObjectAttribute(units[0]) { + t.Fatal("expected user table to stay unmarked") + } + if !hasSystemObjectAttribute(units[1]) { + t.Fatal("expected classified unit to carry the System Object attribute") + } + if units[1].Attributes[0].Key != "Type" { + t.Fatal("expected existing attributes to be preserved") + } +} + +func TestMarkSystemObjectsFailsOpenOnClassificationError(t *testing.T) { + plugin := systemObjectsTestPlugin(t) + plugin.classifySystemObjects = func(config *engine.PluginConfig, schema string) (map[string]bool, error) { + return nil, errors.New("catalog incompatibility") + } + + units := []engine.StorageUnit{ + {Name: "orders", Attributes: []engine.Record{{Key: "Type", Value: "BASE TABLE"}}}, + {Name: "postgres_log", Attributes: []engine.Record{{Key: "Type", Value: "BASE TABLE"}}}, + } + + plugin.markSystemObjects(engine.NewPluginConfig(&engine.Credentials{}), "public", units) + + for _, unit := range units { + if hasSystemObjectAttribute(unit) { + t.Fatalf("expected no unit to be marked when classification fails, %q was", unit.Name) + } + } +} + +func TestMarkSystemObjectsLeavesUnitsUntouchedWhenNoneClassified(t *testing.T) { + plugin := systemObjectsTestPlugin(t) + plugin.classifySystemObjects = func(config *engine.PluginConfig, schema string) (map[string]bool, error) { + return map[string]bool{}, nil + } + + units := []engine.StorageUnit{ + {Name: "orders", Attributes: []engine.Record{{Key: "Type", Value: "BASE TABLE"}}}, + } + + plugin.markSystemObjects(engine.NewPluginConfig(&engine.Credentials{}), "public", units) + + if hasSystemObjectAttribute(units[0]) { + t.Fatal("expected no marks when classification returns an empty set") + } + if len(units[0].Attributes) != 1 { + t.Fatalf("expected attributes untouched, got %d entries", len(units[0].Attributes)) + } +} diff --git a/dataflow/core/src/plugins/postgres/types.go b/dataflow/core/src/plugins/postgres/types.go new file mode 100644 index 0000000..84c3267 --- /dev/null +++ b/dataflow/core/src/plugins/postgres/types.go @@ -0,0 +1,115 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package postgres + +import ( + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" +) + +// AliasMap maps PostgreSQL type aliases to their canonical names. +// All keys and values are UPPERCASE. +var AliasMap = map[string]string{ + "INT": "INTEGER", + "INT2": "SMALLINT", + "INT4": "INTEGER", + "INT8": "BIGINT", + "SERIAL2": "SMALLSERIAL", + "SERIAL4": "SERIAL", + "SERIAL8": "BIGSERIAL", + "FLOAT": "DOUBLE PRECISION", + "FLOAT4": "REAL", + "FLOAT8": "DOUBLE PRECISION", + "BOOL": "BOOLEAN", + "VARCHAR": "CHARACTER VARYING", + "CHAR": "CHARACTER", + "BPCHAR": "CHARACTER", + "TIMESTAMP WITHOUT TIME ZONE": "TIMESTAMP", + "TIMESTAMPTZ": "TIMESTAMP WITH TIME ZONE", + "TIME WITHOUT TIME ZONE": "TIME", + "TIMETZ": "TIME WITH TIME ZONE", +} + +// TypeDefinitions contains the canonical PostgreSQL types with metadata for UI. +var TypeDefinitions = []engine.TypeDefinition{ + {ID: "SMALLINT", Label: "smallint", Category: engine.TypeCategoryNumeric}, + {ID: "INTEGER", Label: "integer", Category: engine.TypeCategoryNumeric}, + {ID: "BIGINT", Label: "bigint", Category: engine.TypeCategoryNumeric}, + {ID: "SERIAL", Label: "serial", Category: engine.TypeCategoryNumeric}, + {ID: "BIGSERIAL", Label: "bigserial", Category: engine.TypeCategoryNumeric}, + {ID: "SMALLSERIAL", Label: "smallserial", Category: engine.TypeCategoryNumeric}, + {ID: "DECIMAL", Label: "decimal", HasPrecision: true, DefaultPrecision: engine.IntPtr(10), Category: engine.TypeCategoryNumeric}, + {ID: "NUMERIC", Label: "numeric", HasPrecision: true, DefaultPrecision: engine.IntPtr(10), Category: engine.TypeCategoryNumeric}, + {ID: "REAL", Label: "real", Category: engine.TypeCategoryNumeric}, + {ID: "DOUBLE PRECISION", Label: "double precision", Category: engine.TypeCategoryNumeric}, + {ID: "MONEY", Label: "money", Category: engine.TypeCategoryNumeric}, + {ID: "CHARACTER VARYING", Label: "varchar", HasLength: true, DefaultLength: engine.IntPtr(255), Category: engine.TypeCategoryText}, + {ID: "CHARACTER", Label: "char", HasLength: true, DefaultLength: engine.IntPtr(1), Category: engine.TypeCategoryText}, + {ID: "TEXT", Label: "text", Category: engine.TypeCategoryText}, + {ID: "BYTEA", Label: "bytea", Category: engine.TypeCategoryBinary}, + {ID: "TIMESTAMP", Label: "timestamp", Category: engine.TypeCategoryDatetime}, + {ID: "TIMESTAMP WITH TIME ZONE", Label: "timestamptz", Category: engine.TypeCategoryDatetime}, + {ID: "DATE", Label: "date", Category: engine.TypeCategoryDatetime}, + {ID: "TIME", Label: "time", Category: engine.TypeCategoryDatetime}, + {ID: "TIME WITH TIME ZONE", Label: "timetz", Category: engine.TypeCategoryDatetime}, + // {ID: "INTERVAL", Label: "interval", Category: engine.TypeCategoryDatetime}, + {ID: "BOOLEAN", Label: "boolean", Category: engine.TypeCategoryBoolean}, + {ID: "JSON", Label: "json", Category: engine.TypeCategoryJSON}, + {ID: "JSONB", Label: "jsonb", Category: engine.TypeCategoryJSON}, + {ID: "UUID", Label: "uuid", Category: engine.TypeCategoryOther}, + {ID: "CIDR", Label: "cidr", Category: engine.TypeCategoryOther}, + {ID: "INET", Label: "inet", Category: engine.TypeCategoryOther}, + {ID: "MACADDR", Label: "macaddr", Category: engine.TypeCategoryOther}, + {ID: "POINT", Label: "point", Category: engine.TypeCategoryOther}, + {ID: "LINE", Label: "line", Category: engine.TypeCategoryOther}, + {ID: "LSEG", Label: "lseg", Category: engine.TypeCategoryOther}, + {ID: "BOX", Label: "box", Category: engine.TypeCategoryOther}, + {ID: "PATH", Label: "path", Category: engine.TypeCategoryOther}, + {ID: "CIRCLE", Label: "circle", Category: engine.TypeCategoryOther}, + {ID: "POLYGON", Label: "polygon", Category: engine.TypeCategoryOther}, + {ID: "XML", Label: "xml", Category: engine.TypeCategoryOther}, + {ID: "ARRAY", Label: "array", Category: engine.TypeCategoryOther}, + {ID: "HSTORE", Label: "hstore", Category: engine.TypeCategoryOther}, +} + +// NormalizeType converts a PostgreSQL type alias to its canonical form. +func NormalizeType(typeName string) string { + return common.NormalizeTypeWithMap(typeName, AliasMap) +} + +// GetDatabaseMetadata returns PostgreSQL metadata for frontend configuration. +func (p *PostgresPlugin) GetDatabaseMetadata() *engine.DatabaseMetadata { + operators := make([]string, 0, len(supportedOperators)) + for op := range supportedOperators { + operators = append(operators, op) + } + return &engine.DatabaseMetadata{ + DatabaseType: engine.DatabaseType_Postgres, + TypeDefinitions: TypeDefinitions, + Operators: operators, + AliasMap: AliasMap, + Capabilities: engine.Capabilities{ + SupportsScratchpad: true, + SupportsChat: true, + SupportsGraph: true, + SupportsSchema: true, + SupportsDatabaseSwitch: true, + SupportsModifiers: true, + }, + SystemSchemas: []string{"information_schema", "pg_catalog", "pg_toast"}, + } +} diff --git a/dataflow/core/src/plugins/postgres/types_test.go b/dataflow/core/src/plugins/postgres/types_test.go new file mode 100644 index 0000000..0de9bb7 --- /dev/null +++ b/dataflow/core/src/plugins/postgres/types_test.go @@ -0,0 +1,33 @@ +package postgres + +import ( + "testing" + + "github.com/clidey/whodb/core/src/engine" +) + +func TestNormalizeType(t *testing.T) { + if got := NormalizeType("int"); got != "INTEGER" { + t.Fatalf("expected INTEGER, got %q", got) + } + + if got := NormalizeType("varchar(25)"); got != "CHARACTER VARYING(25)" { + t.Fatalf("expected CHARACTER VARYING(25), got %q", got) + } +} + +func TestGetDatabaseMetadataIncludesAliasMap(t *testing.T) { + p := &PostgresPlugin{} + p.Type = engine.DatabaseType_Postgres + + meta := p.GetDatabaseMetadata() + if meta == nil { + t.Fatalf("expected metadata, got nil") + } + if meta.DatabaseType != engine.DatabaseType_Postgres { + t.Fatalf("expected DatabaseType %q, got %q", engine.DatabaseType_Postgres, meta.DatabaseType) + } + if meta.AliasMap["INT"] != "INTEGER" { + t.Fatalf("expected INT alias to be INTEGER, got %q", meta.AliasMap["INT"]) + } +} diff --git a/dataflow/core/src/plugins/postgres/utils.go b/dataflow/core/src/plugins/postgres/utils.go new file mode 100644 index 0000000..8891921 --- /dev/null +++ b/dataflow/core/src/plugins/postgres/utils.go @@ -0,0 +1,28 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package postgres + +func (p *PostgresPlugin) GetPrimaryKeyColQuery() string { + return ` + SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + JOIN pg_class c ON c.oid = i.indrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = ? AND c.relname = ? AND i.indisprimary; + ` +} diff --git a/dataflow/core/src/plugins/redis/add.go b/dataflow/core/src/plugins/redis/add.go new file mode 100644 index 0000000..ea8c9d3 --- /dev/null +++ b/dataflow/core/src/plugins/redis/add.go @@ -0,0 +1,182 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package redis + +import ( + "context" + "fmt" + "strconv" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/go-redis/redis/v8" +) + +// AddStorageUnit creates a new Redis key with initial data. +// The storageUnit name becomes the Redis key. The first field's type (from Extra["type"]) +// determines the Redis data structure: string, hash, list, set, or zset. +// If no type is specified, defaults to "string". +func (p *RedisPlugin) AddStorageUnit(config *engine.PluginConfig, schema string, storageUnit string, fields []engine.Record) (bool, error) { + client, err := DB(config) + if err != nil { + return false, err + } + defer client.Close() + + ctx := context.Background() + + // Determine key type from the first field's Extra metadata + keyType := "string" + if len(fields) > 0 { + if t, ok := fields[0].Extra["type"]; ok && t != "" { + keyType = t + } + } + + switch keyType { + case "string": + value := "" + if len(fields) > 0 { + value = fields[0].Value + } + if err := client.Set(ctx, storageUnit, value, 0).Err(); err != nil { + log.WithError(err).WithField("key", storageUnit).Error("Failed to create Redis string key") + return false, err + } + case "hash": + fieldValues := map[string]any{} + for _, f := range fields { + fieldValues[f.Key] = f.Value + } + if len(fieldValues) == 0 { + fieldValues["default"] = "" + } + if err := client.HSet(ctx, storageUnit, fieldValues).Err(); err != nil { + log.WithError(err).WithField("key", storageUnit).Error("Failed to create Redis hash key") + return false, err + } + case "list": + values := make([]any, 0, len(fields)) + for _, f := range fields { + values = append(values, f.Value) + } + if len(values) == 0 { + values = append(values, "") + } + if err := client.RPush(ctx, storageUnit, values...).Err(); err != nil { + log.WithError(err).WithField("key", storageUnit).Error("Failed to create Redis list key") + return false, err + } + case "set": + members := make([]any, 0, len(fields)) + for _, f := range fields { + members = append(members, f.Value) + } + if len(members) == 0 { + members = append(members, "") + } + if err := client.SAdd(ctx, storageUnit, members...).Err(); err != nil { + log.WithError(err).WithField("key", storageUnit).Error("Failed to create Redis set key") + return false, err + } + case "zset": + // Fields should have Key=member, Value=score + for _, f := range fields { + score, parseErr := strconv.ParseFloat(f.Value, 64) + if parseErr != nil { + score = 0 + } + if err := client.ZAdd(ctx, storageUnit, &redis.Z{Score: score, Member: f.Key}).Err(); err != nil { + log.WithError(err).WithField("key", storageUnit).Error("Failed to create Redis sorted set key") + return false, err + } + } + default: + return false, fmt.Errorf("unsupported Redis key type: %s", keyType) + } + + return true, nil +} + +// AddRow adds data to an existing Redis key based on its type. +// The values map column names (from GetColumnsForTable) to data: +// - string: values[0].Value is the new value +// - hash: Key=field name, Value=field value +// - list: Value=element to append +// - set: Value=member to add +// - zset: Key=member, Value=score +func (p *RedisPlugin) AddRow(config *engine.PluginConfig, schema string, storageUnit string, values []engine.Record) (bool, error) { + client, err := DB(config) + if err != nil { + return false, err + } + defer client.Close() + + ctx := context.Background() + + keyType, err := client.Type(ctx, storageUnit).Result() + if err != nil { + return false, err + } + + // Build a lookup from the values + valuesMap := map[string]string{} + for _, v := range values { + valuesMap[v.Key] = v.Value + } + + switch keyType { + case "string": + value := valuesMap["value"] + if err := client.Set(ctx, storageUnit, value, 0).Err(); err != nil { + return false, err + } + case "hash": + field := valuesMap["field"] + value := valuesMap["value"] + if field == "" { + return false, fmt.Errorf("field name is required for hash") + } + if err := client.HSet(ctx, storageUnit, field, value).Err(); err != nil { + return false, err + } + case "list": + value := valuesMap["value"] + if err := client.RPush(ctx, storageUnit, value).Err(); err != nil { + return false, err + } + case "set": + value := valuesMap["value"] + if err := client.SAdd(ctx, storageUnit, value).Err(); err != nil { + return false, err + } + case "zset": + member := valuesMap["member"] + scoreStr := valuesMap["score"] + score, parseErr := strconv.ParseFloat(scoreStr, 64) + if parseErr != nil { + score = 0 + } + if err := client.ZAdd(ctx, storageUnit, &redis.Z{Score: score, Member: member}).Err(); err != nil { + return false, err + } + default: + return false, fmt.Errorf("unsupported Redis data type: %s", keyType) + } + + return true, nil +} diff --git a/dataflow/core/src/plugins/redis/db.go b/dataflow/core/src/plugins/redis/db.go new file mode 100644 index 0000000..2311d33 --- /dev/null +++ b/dataflow/core/src/plugins/redis/db.go @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package redis + +import ( + "context" + "net" + "strconv" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins/ssl" + "github.com/go-redis/redis/v8" +) + +func DB(config *engine.PluginConfig) (*redis.Client, error) { + ctx := context.Background() + port, err := strconv.Atoi(common.GetRecordValueOrDefault(config.Credentials.Advanced, "Port", "6379")) + if err != nil { + log.WithError(err).WithField("hostname", config.Credentials.Hostname).Error("Failed to parse Redis port number") + return nil, err + } + database := 0 + if config.Credentials.Database != "" { + var err error + database, err = strconv.Atoi(config.Credentials.Database) + if err != nil { + log.WithError(err).WithField("database", config.Credentials.Database).WithField("hostname", config.Credentials.Hostname).Error("Failed to parse Redis database number") + return nil, err + } + } + addr := net.JoinHostPort(config.Credentials.Hostname, strconv.Itoa(port)) + + opts := &redis.Options{ + Addr: addr, + Username: config.Credentials.Username, + Password: config.Credentials.Password, + DB: database, + } + + // Configure SSL/TLS + sslMode := "disabled" + sslConfig := ssl.ParseSSLConfig(engine.DatabaseType_Redis, config.Credentials.Advanced, config.Credentials.Hostname, config.Credentials.IsProfile) + if sslConfig != nil && sslConfig.IsEnabled() { + sslMode = string(sslConfig.Mode) + tlsConfig, err := ssl.BuildTLSConfig(sslConfig, config.Credentials.Hostname) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "sslMode": sslConfig.Mode, + }).Error("Failed to build TLS configuration for Redis") + return nil, err + } + opts.TLSConfig = tlsConfig + } + + client := redis.NewClient(opts) + if _, err := client.Ping(ctx).Result(); err != nil { + log.WithError(err).WithFields(map[string]any{ + "hostname": config.Credentials.Hostname, + "database": database, + "sslMode": sslMode, + }).Error("Failed to ping Redis server") + return nil, err + } + return client, nil +} diff --git a/dataflow/core/src/plugins/redis/db_test.go b/dataflow/core/src/plugins/redis/db_test.go new file mode 100644 index 0000000..955e6de --- /dev/null +++ b/dataflow/core/src/plugins/redis/db_test.go @@ -0,0 +1,39 @@ +package redis + +import ( + "testing" + + "github.com/clidey/whodb/core/src/engine" +) + +func TestDBRejectsInvalidPortBeforeDialing(t *testing.T) { + cfg := &engine.PluginConfig{ + Credentials: &engine.Credentials{ + Type: string(engine.DatabaseType_Redis), + Hostname: "localhost", + Advanced: []engine.Record{ + {Key: "Port", Value: "not-a-number"}, + }, + }, + } + + _, err := DB(cfg) + if err == nil { + t.Fatalf("expected error for invalid port") + } +} + +func TestDBRejectsInvalidDatabaseBeforeDialing(t *testing.T) { + cfg := &engine.PluginConfig{ + Credentials: &engine.Credentials{ + Type: string(engine.DatabaseType_Redis), + Hostname: "localhost", + Database: "not-a-number", + }, + } + + _, err := DB(cfg) + if err == nil { + t.Fatalf("expected error for invalid database") + } +} diff --git a/dataflow/core/src/plugins/redis/delete.go b/dataflow/core/src/plugins/redis/delete.go new file mode 100644 index 0000000..6e2f637 --- /dev/null +++ b/dataflow/core/src/plugins/redis/delete.go @@ -0,0 +1,128 @@ +// Copyright 2025 Clidey, Inc. +// +// 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. + +package redis + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" +) + +func isDeleteKeyRequest(storageUnit string, values map[string]string) bool { + key, ok := values["key"] + return ok && key == storageUnit +} + +func (p *RedisPlugin) DeleteRow(config *engine.PluginConfig, schema string, storageUnit string, values map[string]string) (bool, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to connect to Redis for row deletion") + return false, err + } + defer client.Close() + + ctx := context.Background() + + if isDeleteKeyRequest(storageUnit, values) { + err := client.Del(ctx, storageUnit).Err() + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to delete Redis key") + return false, err + } + return true, nil + } + + keyType, err := client.Type(ctx, storageUnit).Result() + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to get Redis key type for row deletion") + return false, err + } + + switch keyType { + case "string": + // Deleting the entire string key + err := client.Del(ctx, storageUnit).Err() + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to delete Redis string key") + return false, err + } + case "hash": + // Deleting a specific field from a hash + field, ok := values["field"] + if !ok { + err := errors.New("missing 'field' for hash deletion") + log.WithError(err).WithField("storageUnit", storageUnit).Error("Missing field parameter for Redis hash deletion") + return false, err + } + err := client.HDel(ctx, storageUnit, field).Err() + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).WithField("field", field).Error("Failed to delete Redis hash field") + return false, err + } + case "list": + // Removing an element from a list + indexStr, ok := values["index"] + if !ok { + err := errors.New("missing 'index' for list deletion") + log.WithError(err).WithField("storageUnit", storageUnit).Error("Missing index parameter for Redis list deletion") + return false, err + } + index, err := strconv.ParseInt(indexStr, 10, 64) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).WithField("index", indexStr).Error("Failed to parse list index for Redis deletion") + return false, errors.New("unable to convert index to int") + } + value := client.LIndex(ctx, storageUnit, index).Val() + if err := client.LRem(ctx, storageUnit, 1, value).Err(); err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).WithField("index", index).WithField("value", value).Error("Failed to remove Redis list item") + return false, errors.New("unable to remove the list item") + } + case "set": + // Removing a specific member from a set + member, ok := values["member"] + if !ok { + err := errors.New("missing 'member' for set deletion") + log.WithError(err).WithField("storageUnit", storageUnit).Error("Missing member parameter for Redis set deletion") + return false, err + } + err := client.SRem(ctx, storageUnit, member).Err() + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).WithField("member", member).Error("Failed to remove Redis set member") + return false, err + } + case "zset": + // Removing a specific member from a sorted set + member, ok := values["member"] + if !ok { + err := errors.New("missing 'member' for sorted set deletion") + log.WithError(err).WithField("storageUnit", storageUnit).Error("Missing member parameter for Redis sorted set deletion") + return false, err + } + err := client.ZRem(ctx, storageUnit, member).Err() + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).WithField("member", member).Error("Failed to remove Redis sorted set member") + return false, err + } + default: + err := fmt.Errorf("unsupported Redis data type: %s", keyType) + return false, err + } + + return true, nil +} diff --git a/dataflow/core/src/plugins/redis/export.go b/dataflow/core/src/plugins/redis/export.go new file mode 100644 index 0000000..4bf5a12 --- /dev/null +++ b/dataflow/core/src/plugins/redis/export.go @@ -0,0 +1,263 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package redis + +import ( + "encoding/json" + "fmt" + "sort" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" +) + +// ExportData exports Redis data to tabular format +func (p *RedisPlugin) ExportData(config *engine.PluginConfig, schema string, storageUnit string, writer func([]string) error, selectedRows []map[string]any) error { + client, err := DB(config) + if err != nil { + return err + } + defer client.Close() + + keyType, err := client.Type(client.Context(), storageUnit).Result() + if err != nil { + return err + } + + switch keyType { + case "string": + headers := []string{common.FormatCSVHeader("value", "string")} + if err := writer(headers); err != nil { + return err + } + val, err := client.Get(client.Context(), storageUnit).Result() + if err != nil { + return err + } + return writer([]string{val}) + + case "hash": + headers := []string{common.FormatCSVHeader("field", "string"), common.FormatCSVHeader("value", "string")} + if err := writer(headers); err != nil { + return err + } + + // If selected rows provided, use them + if len(selectedRows) > 0 { + for _, row := range selectedRows { + writer([]string{fmt.Sprintf("%v", row["field"]), fmt.Sprintf("%v", row["value"])}) + } + return nil + } + + values, err := client.HGetAll(client.Context(), storageUnit).Result() + if err != nil { + return err + } + fields := make([]string, 0, len(values)) + for f := range values { + fields = append(fields, f) + } + sort.Strings(fields) + for _, f := range fields { + if err := writer([]string{f, values[f]}); err != nil { + return err + } + } + return nil + + case "list": + headers := []string{common.FormatCSVHeader("index", "string"), common.FormatCSVHeader("value", "string")} + if err := writer(headers); err != nil { + return err + } + + if len(selectedRows) > 0 { + for _, row := range selectedRows { + writer([]string{fmt.Sprintf("%v", row["index"]), fmt.Sprintf("%v", row["value"])}) + } + return nil + } + + values, err := client.LRange(client.Context(), storageUnit, 0, -1).Result() + if err != nil { + return err + } + for i, v := range values { + if err := writer([]string{fmt.Sprintf("%d", i), v}); err != nil { + return err + } + } + return nil + + case "set": + headers := []string{common.FormatCSVHeader("index", "string"), common.FormatCSVHeader("value", "string")} + if err := writer(headers); err != nil { + return err + } + + if len(selectedRows) > 0 { + for _, row := range selectedRows { + writer([]string{fmt.Sprintf("%v", row["index"]), fmt.Sprintf("%v", row["value"])}) + } + return nil + } + + values, err := client.SMembers(client.Context(), storageUnit).Result() + if err != nil { + return err + } + sort.Strings(values) + for i, v := range values { + if err := writer([]string{fmt.Sprintf("%d", i), v}); err != nil { + return err + } + } + return nil + + case "zset": + headers := []string{ + common.FormatCSVHeader("index", "string"), + common.FormatCSVHeader("member", "string"), + common.FormatCSVHeader("score", "string"), + } + if err := writer(headers); err != nil { + return err + } + + if len(selectedRows) > 0 { + for _, row := range selectedRows { + writer([]string{ + fmt.Sprintf("%v", row["index"]), + fmt.Sprintf("%v", row["member"]), + fmt.Sprintf("%v", row["score"]), + }) + } + return nil + } + + values, err := client.ZRangeWithScores(client.Context(), storageUnit, 0, -1).Result() + if err != nil { + return err + } + for i, m := range values { + if err := writer([]string{ + fmt.Sprintf("%d", i), + fmt.Sprintf("%v", m.Member), + fmt.Sprintf("%.2f", m.Score), + }); err != nil { + return err + } + } + return nil + } + + return fmt.Errorf("unsupported Redis data type") +} + +// ExportDataNDJSON streams Redis data as NDJSON. +func (p *RedisPlugin) ExportDataNDJSON(config *engine.PluginConfig, schema string, storageUnit string, writer func(string) error, selectedRows []map[string]any) error { + client, err := DB(config) + if err != nil { + return err + } + defer client.Close() + + keyType, err := client.Type(client.Context(), storageUnit).Result() + if err != nil { + return err + } + + emit := func(rows []map[string]any) error { + for _, row := range rows { + line, err := json.Marshal(row) + if err != nil { + return err + } + if err := writer(string(line)); err != nil { + return err + } + } + return nil + } + + if len(selectedRows) > 0 { + return emit(selectedRows) + } + + ctx := client.Context() + + switch keyType { + case "string": + val, err := client.Get(ctx, storageUnit).Result() + if err != nil { + return err + } + return emit([]map[string]any{{"value": val}}) + + case "hash": + values, err := client.HGetAll(ctx, storageUnit).Result() + if err != nil { + return err + } + rows := make([]map[string]any, 0, len(values)) + for field, value := range values { + rows = append(rows, map[string]any{"field": field, "value": value}) + } + return emit(rows) + + case "list": + values, err := client.LRange(ctx, storageUnit, 0, -1).Result() + if err != nil { + return err + } + rows := make([]map[string]any, 0, len(values)) + for i, v := range values { + rows = append(rows, map[string]any{"index": i, "value": v}) + } + return emit(rows) + + case "set": + values, err := client.SMembers(ctx, storageUnit).Result() + if err != nil { + return err + } + sort.Strings(values) + rows := make([]map[string]any, 0, len(values)) + for i, v := range values { + rows = append(rows, map[string]any{"index": i, "value": v}) + } + return emit(rows) + + case "zset": + values, err := client.ZRangeWithScores(ctx, storageUnit, 0, -1).Result() + if err != nil { + return err + } + rows := make([]map[string]any, 0, len(values)) + for i, m := range values { + rows = append(rows, map[string]any{ + "index": i, + "member": m.Member, + "score": fmt.Sprintf("%.2f", m.Score), + }) + } + return emit(rows) + } + + return fmt.Errorf("unsupported Redis data type") +} diff --git a/dataflow/core/src/plugins/redis/raw_execute.go b/dataflow/core/src/plugins/redis/raw_execute.go new file mode 100644 index 0000000..32d6dfa --- /dev/null +++ b/dataflow/core/src/plugins/redis/raw_execute.go @@ -0,0 +1,166 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package redis + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/go-redis/redis/v8" +) + +// kvPairCommands is the set of Redis commands whose []any reply is a flat +// [field, value, field, value, …] list that should be rendered as two columns. +var kvPairCommands = map[string]bool{ + "HGETALL": true, + "CONFIG": true, + "XRANGE": true, + "XREVRANGE": true, +} + +// RawExecute executes an arbitrary Redis command expressed as a single query +// string and returns the result formatted as a GetRowsResult. +func (p *RedisPlugin) RawExecute(config *engine.PluginConfig, query string, _ ...any) (*engine.GetRowsResult, error) { + tokens := tokenizeRedisCommand(query) + if len(tokens) == 0 { + return nil, errors.New("empty Redis command") + } + + client, err := DB(config) + if err != nil { + return nil, err + } + defer client.Close() + + ctx := context.Background() + + args := make([]any, len(tokens)) + for i, t := range tokens { + args[i] = t + } + + result, err := client.Do(ctx, args...).Result() + if err != nil { + if err == redis.Nil { + return formatRedisResult(strings.ToUpper(tokens[0]), nil), nil + } + return nil, err + } + + return formatRedisResult(strings.ToUpper(tokens[0]), result), nil +} + +// formatRedisResult converts a raw Redis reply into a GetRowsResult. +func formatRedisResult(command string, result any) *engine.GetRowsResult { + switch v := result.(type) { + case nil: + return singleValueResult("value", "(nil)") + case string: + return singleValueResult("value", v) + case int64: + return singleValueResult("value", strconv.FormatInt(v, 10)) + case []any: + if kvPairCommands[command] && len(v)%2 == 0 { + return kvPairResult(v) + } + return indexedListResult(v) + default: + return singleValueResult("value", fmt.Sprintf("%v", v)) + } +} + +// singleValueResult returns a one-column, one-row result. +func singleValueResult(name, value string) *engine.GetRowsResult { + return &engine.GetRowsResult{ + Columns: []engine.Column{{Name: name, Type: "string"}}, + Rows: [][]string{{value}}, + DisableUpdate: true, + TotalCount: 1, + } +} + +// kvPairResult renders a flat [field, value, …] slice as two columns: "field" and "value". +func kvPairResult(flat []any) *engine.GetRowsResult { + rows := make([][]string, 0, len(flat)/2) + for i := 0; i+1 < len(flat); i += 2 { + field := fmt.Sprintf("%v", flat[i]) + value := fmt.Sprintf("%v", flat[i+1]) + rows = append(rows, []string{field, value}) + } + return &engine.GetRowsResult{ + Columns: []engine.Column{{Name: "field", Type: "string"}, {Name: "value", Type: "string"}}, + Rows: rows, + DisableUpdate: true, + TotalCount: int64(len(rows)), + } +} + +// indexedListResult renders a []any as two columns: "index" and "value". +func indexedListResult(items []any) *engine.GetRowsResult { + rows := make([][]string, len(items)) + for i, item := range items { + rows[i] = []string{strconv.Itoa(i), fmt.Sprintf("%v", item)} + } + return &engine.GetRowsResult{ + Columns: []engine.Column{{Name: "index", Type: "string"}, {Name: "value", Type: "string"}}, + Rows: rows, + DisableUpdate: true, + TotalCount: int64(len(rows)), + } +} + +// tokenizeRedisCommand splits a Redis command string into tokens, respecting +// single- and double-quoted strings so that quoted whitespace is preserved as +// part of a single token. +func tokenizeRedisCommand(input string) []string { + input = strings.TrimSpace(input) + if input == "" { + return nil + } + + var tokens []string + var current strings.Builder + inDouble := false + inSingle := false + + for i := 0; i < len(input); i++ { + b := input[i] + switch { + case b == '"' && !inSingle: + inDouble = !inDouble + case b == '\'' && !inDouble: + inSingle = !inSingle + case (b == ' ' || b == '\t' || b == '\n' || b == '\r') && !inDouble && !inSingle: + if current.Len() > 0 { + tokens = append(tokens, current.String()) + current.Reset() + } + default: + current.WriteByte(b) + } + } + + if current.Len() > 0 { + tokens = append(tokens, current.String()) + } + + return tokens +} diff --git a/dataflow/core/src/plugins/redis/raw_execute_test.go b/dataflow/core/src/plugins/redis/raw_execute_test.go new file mode 100644 index 0000000..2872e27 --- /dev/null +++ b/dataflow/core/src/plugins/redis/raw_execute_test.go @@ -0,0 +1,135 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package redis + +import ( + "testing" +) + +func TestTokenize_Simple(t *testing.T) { + tokens := tokenizeRedisCommand("GET mykey") + if len(tokens) != 2 { + t.Fatalf("expected 2 tokens, got %d", len(tokens)) + } + if tokens[0] != "GET" || tokens[1] != "mykey" { + t.Errorf("unexpected tokens: %v", tokens) + } +} + +func TestTokenize_QuotedString(t *testing.T) { + tokens := tokenizeRedisCommand(`SET mykey "hello world"`) + if len(tokens) != 3 { + t.Fatalf("expected 3 tokens, got %d", len(tokens)) + } + if tokens[2] != "hello world" { + t.Errorf("expected last token to be \"hello world\", got %q", tokens[2]) + } +} + +func TestTokenize_SingleQuoted(t *testing.T) { + tokens := tokenizeRedisCommand("SET mykey 'hello world'") + if len(tokens) != 3 { + t.Fatalf("expected 3 tokens, got %d", len(tokens)) + } + if tokens[2] != "hello world" { + t.Errorf("expected last token to be \"hello world\", got %q", tokens[2]) + } +} + +func TestTokenize_MultipleArgs(t *testing.T) { + tokens := tokenizeRedisCommand("HSET myhash field1 value1 field2 value2") + if len(tokens) != 6 { + t.Fatalf("expected 6 tokens, got %d", len(tokens)) + } +} + +func TestTokenize_Multiline(t *testing.T) { + tokens := tokenizeRedisCommand("SET\n mykey\n myvalue") + if len(tokens) != 3 { + t.Fatalf("expected 3 tokens, got %d: %v", len(tokens), tokens) + } + if tokens[0] != "SET" || tokens[1] != "mykey" || tokens[2] != "myvalue" { + t.Errorf("unexpected tokens: %v", tokens) + } +} + +func TestTokenize_Empty(t *testing.T) { + tokens := tokenizeRedisCommand("") + if len(tokens) != 0 { + t.Fatalf("expected 0 tokens, got %d", len(tokens)) + } +} + +func TestFormatRedisResult_String(t *testing.T) { + result := formatRedisResult("GET", "hello") + if len(result.Columns) != 1 || result.Columns[0].Name != "value" { + t.Fatalf("expected 1 column named 'value', got %v", result.Columns) + } + if len(result.Rows) != 1 || result.Rows[0][0] != "hello" { + t.Errorf("expected row value 'hello', got %v", result.Rows) + } +} + +func TestFormatRedisResult_Integer(t *testing.T) { + result := formatRedisResult("DBSIZE", int64(42)) + if len(result.Rows) != 1 || result.Rows[0][0] != "42" { + t.Errorf("expected row value '42', got %v", result.Rows) + } +} + +func TestFormatRedisResult_HashPairs(t *testing.T) { + result := formatRedisResult("HGETALL", []any{"name", "Alice", "age", "30"}) + if len(result.Columns) != 2 || result.Columns[0].Name != "field" || result.Columns[1].Name != "value" { + t.Fatalf("expected columns [field, value], got %v", result.Columns) + } + if len(result.Rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(result.Rows)) + } + if result.Rows[0][0] != "name" || result.Rows[0][1] != "Alice" { + t.Errorf("unexpected first row: %v", result.Rows[0]) + } + if result.Rows[1][0] != "age" || result.Rows[1][1] != "30" { + t.Errorf("unexpected second row: %v", result.Rows[1]) + } +} + +func TestFormatRedisResult_List(t *testing.T) { + result := formatRedisResult("LRANGE", []any{"a", "b", "c"}) + if len(result.Columns) != 2 || result.Columns[0].Name != "index" || result.Columns[1].Name != "value" { + t.Fatalf("expected columns [index, value], got %v", result.Columns) + } + if len(result.Rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(result.Rows)) + } + if result.Rows[0][0] != "0" || result.Rows[2][1] != "c" { + t.Errorf("unexpected rows: %v", result.Rows) + } +} + +func TestFormatRedisResult_Nil(t *testing.T) { + result := formatRedisResult("GET", nil) + if len(result.Rows) != 1 || result.Rows[0][0] != "(nil)" { + t.Errorf("expected '(nil)' row, got %v", result.Rows) + } +} + +func TestFormatRedisResult_EmptyArray(t *testing.T) { + result := formatRedisResult("KEYS", []any{}) + if len(result.Rows) != 0 { + t.Errorf("expected 0 rows, got %d", len(result.Rows)) + } +} diff --git a/dataflow/core/src/plugins/redis/redis.go b/dataflow/core/src/plugins/redis/redis.go new file mode 100644 index 0000000..24fd791 --- /dev/null +++ b/dataflow/core/src/plugins/redis/redis.go @@ -0,0 +1,612 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package redis + +import ( + "context" + "errors" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/go-redis/redis/v8" +) + +type RedisPlugin struct { + engine.BasePlugin +} + +var redisOperators = map[string]string{ + "=": "=", + "!=": "!=", + "<>": "!=", + ">": ">", + ">=": ">=", + "<": "<", + "<=": "<=", + "CONTAINS": "CONTAINS", + "STARTS WITH": "STARTS WITH", + "ENDS WITH": "ENDS WITH", + "IN": "IN", + "NOT IN": "NOT IN", +} + +func (p *RedisPlugin) IsAvailable(ctx context.Context, config *engine.PluginConfig) bool { + client, err := DB(config) + if err != nil { + log.WithError(err).Error("Failed to connect to Redis for availability check") + return false + } + defer client.Close() + status := client.Ping(ctx) + return status.Err() == nil +} + +func (p *RedisPlugin) GetDatabases(config *engine.PluginConfig) ([]string, error) { + maxDatabases := 16 + var availableDatabases []string + + for i := 0; i < maxDatabases; i++ { + dbConfig := *config + dbConfig.Credentials.Database = strconv.Itoa(i) + + if p.IsAvailable(context.Background(), &dbConfig) { + availableDatabases = append(availableDatabases, strconv.Itoa(i)) + } + } + + if len(availableDatabases) == 0 { + err := errors.New("no available databases found") + log.WithError(err).Error("No Redis databases found during discovery") + return nil, err + } + + return availableDatabases, nil +} + +func (p *RedisPlugin) GetStorageUnits(config *engine.PluginConfig, schema string) ([]engine.StorageUnit, error) { + ctx := context.Background() + + client, err := DB(config) + if err != nil { + log.WithError(err).Error("Failed to connect to Redis for storage units retrieval") + return nil, err + } + defer client.Close() + + var keys []string + var cursor uint64 + + for { + var scanKeys []string + scanKeys, cursor, err = client.Scan(ctx, cursor, "*", 0).Result() // count = 0 will use the redis default of 10 + if err != nil { + log.WithError(err).Error("Failed to scan Redis keys") + return nil, err + } + + keys = append(keys, scanKeys...) + + // When cursor is 0, we've completed the full scan + if cursor == 0 { + break + } + } + + pipe := client.Pipeline() + cmds := make(map[string]*redis.StatusCmd, len(keys)) + + for _, key := range keys { + cmds[key] = pipe.Type(ctx, key) + } + + _, err = pipe.Exec(ctx) + if err != nil { + log.WithError(err).Error("Failed to execute Redis pipeline for key types") + return nil, err + } + + storageUnits := make([]engine.StorageUnit, 0, len(keys)) + for _, key := range keys { + keyType, err := cmds[key].Result() + if err != nil { + log.WithError(err).WithField("key", key).Error("Failed to get Redis key type") + return nil, err + } + + var attributes []engine.Record + switch keyType { + case "string": + sizeCmd := pipe.StrLen(ctx, key) + if _, err := pipe.Exec(ctx); err != nil { + log.WithError(err).WithField("key", key).Error("Failed to execute pipeline for string key size") + return nil, err + } + size, err := sizeCmd.Result() + if err != nil { + log.WithError(err).WithField("key", key).Error("Failed to get string key size") + return nil, err + } + attributes = []engine.Record{ + {Key: "Type", Value: "string"}, + {Key: "Size", Value: fmt.Sprintf("%d", size)}, + } + case "hash": + sizeCmd := pipe.HLen(ctx, key) + if _, err := pipe.Exec(ctx); err != nil { + log.WithError(err).WithField("key", key).Error("Failed to execute pipeline for hash key size") + return nil, err + } + size, err := sizeCmd.Result() + if err != nil { + log.WithError(err).WithField("key", key).Error("Failed to get hash key size") + return nil, err + } + attributes = []engine.Record{ + {Key: "Type", Value: "hash"}, + {Key: "Size", Value: fmt.Sprintf("%d", size)}, + } + case "list": + sizeCmd := pipe.LLen(ctx, key) + if _, err := pipe.Exec(ctx); err != nil { + log.WithError(err).WithField("key", key).Error("Failed to execute pipeline for list key size") + return nil, err + } + size, err := sizeCmd.Result() + if err != nil { + log.WithError(err).WithField("key", key).Error("Failed to get list key size") + return nil, err + } + attributes = []engine.Record{ + {Key: "Type", Value: "list"}, + {Key: "Size", Value: fmt.Sprintf("%d", size)}, + } + case "set": + sizeCmd := pipe.SCard(ctx, key) + if _, err := pipe.Exec(ctx); err != nil { + log.WithError(err).WithField("key", key).Error("Failed to execute pipeline for set key size") + return nil, err + } + size, err := sizeCmd.Result() + if err != nil { + log.WithError(err).WithField("key", key).Error("Failed to get set key size") + return nil, err + } + attributes = []engine.Record{ + {Key: "Type", Value: "set"}, + {Key: "Size", Value: fmt.Sprintf("%d", size)}, + } + case "zset": + sizeCmd := pipe.ZCard(ctx, key) + if _, err := pipe.Exec(ctx); err != nil { + log.WithError(err).WithField("key", key).Error("Failed to execute pipeline for zset key size") + return nil, err + } + size, err := sizeCmd.Result() + if err != nil { + log.WithError(err).WithField("key", key).Error("Failed to get zset key size") + return nil, err + } + attributes = []engine.Record{ + {Key: "Type", Value: "zset"}, + {Key: "Size", Value: fmt.Sprintf("%d", size)}, + } + default: + attributes = []engine.Record{ + {Key: "Type", Value: "unknown"}, + } + } + + storageUnits = append(storageUnits, engine.StorageUnit{ + Name: key, + Attributes: attributes, + }) + } + + return storageUnits, nil +} + +func (p *RedisPlugin) StorageUnitExists(config *engine.PluginConfig, schema string, storageUnit string) (bool, error) { + ctx := context.Background() + client, err := DB(config) + if err != nil { + return false, err + } + defer client.Close() + + exists, err := client.Exists(ctx, storageUnit).Result() + if err != nil { + return false, err + } + return exists > 0, nil +} + +func (p *RedisPlugin) GetRows( + config *engine.PluginConfig, + req *engine.GetRowsRequest, +) (*engine.GetRowsResult, error) { + storageUnit := req.StorageUnit + where := req.Where + ctx := context.Background() + + client, err := DB(config) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to connect to Redis for rows retrieval") + return nil, err + } + defer client.Close() + + var result *engine.GetRowsResult + + keyType, err := client.Type(ctx, storageUnit).Result() + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to get Redis key type for rows retrieval") + return nil, err + } + + switch keyType { + case "string": + val, err := client.Get(ctx, storageUnit).Result() + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to get Redis string value") + return nil, err + } + result = &engine.GetRowsResult{ + Columns: []engine.Column{{Name: "value", Type: "string"}}, + Rows: [][]string{{val}}, + } + case "hash": + hashValues, err := client.HGetAll(ctx, storageUnit).Result() + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to get Redis hash values") + return nil, err + } + var rows [][]string + for field, value := range hashValues { + if where == nil || filterRedisHash(field, value, where) { + rows = append(rows, []string{field, value}) + } + } + // Sort rows by field name (first column) alphabetically + sort.Slice(rows, func(i, j int) bool { + return rows[i][0] < rows[j][0] + }) + result = &engine.GetRowsResult{ + Columns: []engine.Column{{Name: "field", Type: "string"}, {Name: "value", Type: "string"}}, + Rows: rows, + } + case "list": + listValues, err := client.LRange(ctx, storageUnit, 0, -1).Result() + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to get Redis list values") + return nil, err + } + var rows [][]string + for i, value := range listValues { + if where == nil || filterRedisList(value, where) { + rows = append(rows, []string{strconv.Itoa(i), value}) + } + } + result = &engine.GetRowsResult{ + Columns: []engine.Column{{Name: "index", Type: "string"}, {Name: "value", Type: "string"}}, + Rows: rows, + } + case "set": + setValues, err := client.SMembers(ctx, storageUnit).Result() + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to get Redis set values") + return nil, err + } + var rows [][]string + for i, value := range setValues { + if where == nil || filterRedisSet(value, where) { + rows = append(rows, []string{strconv.Itoa(i), value}) + } + } + result = &engine.GetRowsResult{ + Columns: []engine.Column{{Name: "index", Type: "string"}, {Name: "value", Type: "string"}}, + Rows: rows, + DisableUpdate: true, + } + case "zset": + zsetValues, err := client.ZRangeWithScores(ctx, storageUnit, 0, -1).Result() + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to get Redis zset values") + return nil, err + } + var rows [][]string + for i, member := range zsetValues { + value := member.Member.(string) + scoreStr := fmt.Sprintf("%.2f", member.Score) + if where == nil || filterRedisZSet(value, scoreStr, where) { + rows = append(rows, []string{strconv.Itoa(i), value, scoreStr}) + } + } + result = &engine.GetRowsResult{ + Columns: []engine.Column{{Name: "index", Type: "string"}, {Name: "member", Type: "string"}, {Name: "score", Type: "string"}}, + Rows: rows, + } + default: + err := errors.New("unsupported Redis data type") + return nil, err + } + + // Set TotalCount from the number of rows (Redis data is fully loaded) + result.TotalCount = int64(len(result.Rows)) + + return result, nil +} + +func (p *RedisPlugin) GetRowCount(config *engine.PluginConfig, schema, storageUnit string, where *model.WhereCondition) (int64, error) { + ctx := context.Background() + + client, err := DB(config) + if err != nil { + return 0, err + } + defer client.Close() + + keyType, err := client.Type(ctx, storageUnit).Result() + if err != nil { + return 0, err + } + + switch keyType { + case "string": + return 1, nil + case "hash": + count, err := client.HLen(ctx, storageUnit).Result() + if err != nil { + return 0, err + } + return count, nil + case "list": + count, err := client.LLen(ctx, storageUnit).Result() + if err != nil { + return 0, err + } + return count, nil + case "set": + count, err := client.SCard(ctx, storageUnit).Result() + if err != nil { + return 0, err + } + return count, nil + case "zset": + count, err := client.ZCard(ctx, storageUnit).Result() + if err != nil { + return 0, err + } + return count, nil + default: + return 0, errors.New("unsupported Redis data type") + } +} + +func (p *RedisPlugin) GetColumnsForTable(config *engine.PluginConfig, schema string, storageUnit string) ([]engine.Column, error) { + ctx := context.Background() + + client, err := DB(config) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to connect to Redis for columns retrieval") + return nil, err + } + defer client.Close() + + keyType, err := client.Type(ctx, storageUnit).Result() + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to get Redis key type for columns retrieval") + return nil, err + } + + switch keyType { + case "string": + return []engine.Column{{Name: "value", Type: "string"}}, nil + case "hash": + return []engine.Column{{Name: "field", Type: "string"}, {Name: "value", Type: "string"}}, nil + case "list": + return []engine.Column{{Name: "index", Type: "string"}, {Name: "value", Type: "string"}}, nil + case "set": + return []engine.Column{{Name: "index", Type: "string"}, {Name: "value", Type: "string"}}, nil + case "zset": + return []engine.Column{{Name: "index", Type: "string"}, {Name: "member", Type: "string"}, {Name: "score", Type: "string"}}, nil + default: + return nil, errors.New("unsupported Redis data type") + } +} + +func filterRedisHash(field, value string, where *model.WhereCondition) bool { + condition, err := convertWhereConditionToRedisFilter(where) + if err != nil { + return true // Ignore filtering on error + } + + for key, op := range condition { + if key == "field" { + if !evaluateRedisCondition(field, op.Operator, op.Value) { + return false + } + } else if key == "value" { + if !evaluateRedisCondition(value, op.Operator, op.Value) { + return false + } + } + } + return true +} + +func filterRedisList(value string, where *model.WhereCondition) bool { + condition, err := convertWhereConditionToRedisFilter(where) + if err != nil { + return true // Ignore filtering on error + } + + for key, op := range condition { + if key == "value" { + if !evaluateRedisCondition(value, op.Operator, op.Value) { + return false + } + } + } + return true +} + +func filterRedisSet(value string, where *model.WhereCondition) bool { + condition, err := convertWhereConditionToRedisFilter(where) + if err != nil { + return true + } + + for key, op := range condition { + if key == "value" || key == "member" { + if !evaluateRedisCondition(value, op.Operator, op.Value) { + return false + } + } + } + return true +} + +func filterRedisZSet(member string, score string, where *model.WhereCondition) bool { + condition, err := convertWhereConditionToRedisFilter(where) + if err != nil { + return true + } + + for key, op := range condition { + switch strings.ToLower(key) { + case "member": + if !evaluateRedisCondition(member, op.Operator, op.Value) { + return false + } + case "score": + if !evaluateRedisCondition(score, op.Operator, op.Value) { + return false + } + } + } + return true +} + +type redisFilter struct { + Operator string + Value string +} + +func convertWhereConditionToRedisFilter(where *model.WhereCondition) (map[string]redisFilter, error) { + if where == nil { + return nil, nil + } + + switch where.Type { + case model.WhereConditionTypeAtomic: + if where.Atomic == nil { + return nil, fmt.Errorf("atomic condition must have an atomicwherecondition") + } + + return map[string]redisFilter{ + where.Atomic.Key: { + Operator: strings.ToUpper(where.Atomic.Operator), + Value: where.Atomic.Value, + }, + }, nil + + default: + return nil, fmt.Errorf("unsupported Redis filtering condition type: %v", where.Type) + } +} + +func evaluateRedisCondition(value, operator, target string) bool { + switch operator { + case "=", "EQ": + return value == target + case "!=", "NE", "<>": + return value != target + case ">": + return value > target + case ">=": + return value >= target + case "<": + return value < target + case "<=": + return value <= target + case "CONTAINS": + return strings.Contains(value, target) + case "STARTS WITH": + return strings.HasPrefix(value, target) + case "ENDS WITH": + return strings.HasSuffix(value, target) + case "IN": + parts := strings.Split(target, ",") + for _, p := range parts { + if value == strings.TrimSpace(p) { + return true + } + } + return false + case "NOT IN": + parts := strings.Split(target, ",") + for _, p := range parts { + if value == strings.TrimSpace(p) { + return false + } + } + return true + } + + switch operator { + default: + return false + } +} + +func (p *RedisPlugin) FormatValue(val any) string { + if val == nil { + return "" + } + return fmt.Sprintf("%v", val) +} + +// GetDatabaseMetadata returns Redis metadata for frontend configuration. +// Redis is a key-value store without traditional type definitions or operators. +func (p *RedisPlugin) GetDatabaseMetadata() *engine.DatabaseMetadata { + ops := make([]string, 0, len(redisOperators)) + for op := range redisOperators { + ops = append(ops, op) + } + sort.Strings(ops) + return &engine.DatabaseMetadata{ + DatabaseType: engine.DatabaseType_Redis, + TypeDefinitions: []engine.TypeDefinition{}, + Operators: ops, + AliasMap: map[string]string{}, + Capabilities: engine.Capabilities{ + SupportsDatabaseSwitch: true, + }, + } +} + +func NewRedisPlugin() *engine.Plugin { + return &engine.Plugin{ + Type: engine.DatabaseType_Redis, + PluginFunctions: &RedisPlugin{}, + } +} diff --git a/dataflow/core/src/plugins/redis/ssl_status.go b/dataflow/core/src/plugins/redis/ssl_status.go new file mode 100644 index 0000000..268da55 --- /dev/null +++ b/dataflow/core/src/plugins/redis/ssl_status.go @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package redis + +import ( + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins/ssl" +) + +// GetSSLStatus returns the SSL status based on the configured TLS settings for Redis. +// we return the configured TLS mode (for now) +func (p *RedisPlugin) GetSSLStatus(config *engine.PluginConfig) (*engine.SSLStatus, error) { + log.Debug("[SSL] RedisPlugin.GetSSLStatus: checking configured TLS mode") + sslConfig := ssl.ParseSSLConfig(engine.DatabaseType_Redis, config.Credentials.Advanced, config.Credentials.Hostname, config.Credentials.IsProfile) + + if sslConfig == nil || !sslConfig.IsEnabled() { + log.Debug("[SSL] RedisPlugin.GetSSLStatus: TLS not configured or disabled") + return &engine.SSLStatus{ + IsEnabled: false, + Mode: string(ssl.SSLModeDisabled), + }, nil + } + + log.Debugf("[SSL] RedisPlugin.GetSSLStatus: TLS enabled, mode=%s", sslConfig.Mode) + return &engine.SSLStatus{ + IsEnabled: true, + Mode: string(sslConfig.Mode), + }, nil +} diff --git a/dataflow/core/src/plugins/redis/update.go b/dataflow/core/src/plugins/redis/update.go new file mode 100644 index 0000000..74ff7ef --- /dev/null +++ b/dataflow/core/src/plugins/redis/update.go @@ -0,0 +1,81 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package redis + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" +) + +func (p *RedisPlugin) UpdateStorageUnit(config *engine.PluginConfig, schema string, storageUnit string, values map[string]string, updatedColumns []string) (bool, error) { + client, err := DB(config) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to connect to Redis for storage unit update") + return false, err + } + defer client.Close() + + ctx := context.Background() + + keyType, err := client.Type(ctx, storageUnit).Result() + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).Error("Failed to get Redis key type for storage unit update") + return false, err + } + + switch keyType { + case "string": + if len(values) != 1 { + err := errors.New("invalid number of fields for a string key") + log.WithError(err).WithField("storageUnit", storageUnit).WithField("valueCount", len(values)).Error("Invalid number of fields for Redis string key update") + return false, err + } + err := client.Set(ctx, storageUnit, values["value"], 0).Err() + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).WithField("value", values["value"]).Error("Failed to update Redis string value") + return false, err + } + case "hash": + err := client.HSet(ctx, storageUnit, values["field"], values["value"]).Err() + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).WithField("field", values["field"]).WithField("value", values["value"]).Error("Failed to update Redis hash field") + return false, err + } + case "list": + indexInt, err := strconv.ParseInt(values["index"], 10, 64) + if err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).WithField("index", values["index"]).Error("Failed to parse list index for Redis update") + return false, errors.New("unable to convert to int") + } + if err := client.LSet(ctx, storageUnit, indexInt, values["value"]).Err(); err != nil { + log.WithError(err).WithField("storageUnit", storageUnit).WithField("index", indexInt).WithField("value", values["value"]).Error("Failed to update Redis list item") + return false, errors.New("unable to update the list item") + } + case "set": + return false, errors.ErrUnsupported + default: + err := fmt.Errorf("unsupported Redis data type: %s", keyType) + return false, err + } + + return true, nil +} diff --git a/dataflow/core/src/plugins/sqlite3/add.go b/dataflow/core/src/plugins/sqlite3/add.go new file mode 100644 index 0000000..a3bec9e --- /dev/null +++ b/dataflow/core/src/plugins/sqlite3/add.go @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package sqlite3 + +import ( + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/plugins/gorm" + "gorm.io/gorm" +) + +func (p *Sqlite3Plugin) GetCreateTableQuery(db *gorm.DB, schema string, storageUnit string, columns []engine.Record) string { + builder := gorm_plugin.NewSQLBuilder(db, p) + + columnDefs := gorm_plugin.RecordsToColumnDefs(columns, func(def gorm_plugin.ColumnDef, column engine.Record) gorm_plugin.ColumnDef { + def.Primary = true + if strings.Contains(strings.ToLower(column.Value), "int") { + def.Type = "INTEGER" + } + return def + }) + + // SQLite doesn't use schema, only table name + return builder.CreateTableQuery("", storageUnit, columnDefs) +} diff --git a/dataflow/core/src/plugins/sqlite3/constraints.go b/dataflow/core/src/plugins/sqlite3/constraints.go new file mode 100644 index 0000000..b989cd7 --- /dev/null +++ b/dataflow/core/src/plugins/sqlite3/constraints.go @@ -0,0 +1,249 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package sqlite3 + +import ( + "fmt" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + gorm_plugin "github.com/clidey/whodb/core/src/plugins/gorm" + "gorm.io/gorm" +) + +// GetColumnConstraints retrieves column constraints for SQLite tables +func (p *Sqlite3Plugin) GetColumnConstraints(config *engine.PluginConfig, schema string, storageUnit string) (map[string]map[string]any, error) { + constraints := make(map[string]map[string]any) + + _, err := plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + // Use SQLite-specific SQL builder. + builder, ok := p.CreateSQLBuilder(db).(*SQLiteSQLBuilder) + if !ok { + return false, fmt.Errorf("failed to create SQLite SQL builder") + } + + // Get table schema including nullability + // SQLite PRAGMA commands don't support placeholders + tableInfoQuery, err := builder.PragmaQuery("table_info", storageUnit) + if err != nil { + return false, err + } + + rows, err := db.Raw(tableInfoQuery).Rows() + if err != nil { + return false, err + } + defer rows.Close() + + for rows.Next() { + var cid int + var name string + var dataType string + var notNull int + var dfltValue any + var pk int + + if err := rows.Scan(&cid, &name, &dataType, ¬Null, &dfltValue, &pk); err != nil { + continue + } + + if constraints[name] == nil { + constraints[name] = map[string]any{} + } + constraints[name]["nullable"] = notNull == 0 + + // Primary key columns + if pk == 1 { + constraints[name]["primary"] = true + constraints[name]["unique"] = true + } + } + + // Get unique indexes + indexListQuery, err := builder.PragmaQuery("index_list", storageUnit) + if err != nil { + // This is not a critical error? table might not have indexes. + return true, nil + } + + indexRows, err := db.Raw(indexListQuery).Rows() + if err != nil { + // Some tables might not have indexes, that's ok + return true, nil + } + defer indexRows.Close() + + for indexRows.Next() { + var seq int + var name string + var unique int + var origin string + var partial int + + if err := indexRows.Scan(&seq, &name, &unique, &origin, &partial); err != nil { + continue + } + + // Only process unique indexes + if unique == 1 { + // Get columns in this index + indexInfoQuery, err := builder.PragmaQuery("index_info", name) + if err != nil { + return false, err + } + infoRows, err := db.Raw(indexInfoQuery).Rows() + if err != nil { + continue + } + + var columnCount int + var columnName string + for infoRows.Next() { + var seqno int + var cid int + var colName string + if err := infoRows.Scan(&seqno, &cid, &colName); err != nil { + continue + } + columnCount++ + columnName = colName + } + infoRows.Close() + + // Only mark as unique if it's a single-column index + if columnCount == 1 && columnName != "" { + if constraints[columnName] == nil { + constraints[columnName] = map[string]any{} + } + constraints[columnName]["unique"] = true + } + } + } + + // Get CHECK constraints from sqlite_master + // SQLite stores CHECK constraints in the CREATE TABLE statement + checkQuery := `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?` + var createSQL string + err = db.Raw(checkQuery, storageUnit).Row().Scan(&createSQL) + if err == nil && createSQL != "" { + // Parse CHECK constraints from the CREATE TABLE statement + p.parseCheckConstraints(createSQL, constraints) + } + + return true, nil + }) + + if err != nil { + return constraints, err + } + + return constraints, nil +} + +// parseCheckConstraints extracts CHECK constraints from SQLite's CREATE TABLE statement +func (p *Sqlite3Plugin) parseCheckConstraints(createSQL string, constraints map[string]map[string]any) { + // SQLite stores CHECK constraints in the CREATE TABLE statement like: + // CREATE TABLE products ( + // price DECIMAL(10,2) NOT NULL CHECK (price >= 0), + // stock_quantity INT CHECK (stock_quantity >= 0), + // status TEXT CHECK (status IN ('active', 'inactive')) + // ) + + upper := strings.ToUpper(createSQL) + searchStart := 0 + + for { + checkIdx := strings.Index(upper[searchStart:], "CHECK") + if checkIdx == -1 { + break + } + checkIdx += searchStart + + parenStart := strings.Index(createSQL[checkIdx:], "(") + if parenStart == -1 { + searchStart = checkIdx + 5 + continue + } + parenStart += checkIdx + + depth := 1 + parenEnd := -1 + for i := parenStart + 1; i < len(createSQL); i++ { + switch createSQL[i] { + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + parenEnd = i + } + } + if parenEnd != -1 { + break + } + } + + if parenEnd == -1 { + searchStart = checkIdx + 5 + continue + } + + checkClause := createSQL[parenStart+1 : parenEnd] + p.parseSingleCheckConstraint(checkClause, constraints) + + searchStart = parenEnd + 1 + } +} + +// parseSingleCheckConstraint parses a single CHECK constraint clause +func (p *Sqlite3Plugin) parseSingleCheckConstraint(checkClause string, constraints map[string]map[string]any) { + columnName := gorm_plugin.ExtractColumnNameFromClause(checkClause) + if columnName == "" { + log.WithField("checkClause", checkClause).Debug("Could not extract column name from CHECK clause") + return + } + + // SQLite column names are case-insensitive. To ensure we merge constraints + // with those from PRAGMA table_info (which may use a different case), + // do a case-insensitive lookup first. + var existingKey string + lowerName := strings.ToLower(columnName) + for key := range constraints { + if strings.ToLower(key) == lowerName { + existingKey = key + break + } + } + if existingKey != "" { + columnName = existingKey // Use the existing key's case + } + + colConstraints := gorm_plugin.EnsureConstraintEntry(constraints, columnName) + + minMax := gorm_plugin.ParseMinMaxConstraints(checkClause) + gorm_plugin.ApplyMinMaxToConstraints(colConstraints, minMax) + + if values := gorm_plugin.ParseINClauseValues(checkClause); len(values) > 0 { + colConstraints["check_values"] = values + log.WithFields(map[string]any{ + "column": columnName, + "checkValues": values, + }).Debug("Parsed CHECK IN constraint") + } +} diff --git a/dataflow/core/src/plugins/sqlite3/db.go b/dataflow/core/src/plugins/sqlite3/db.go new file mode 100644 index 0000000..7f8a4ba --- /dev/null +++ b/dataflow/core/src/plugins/sqlite3/db.go @@ -0,0 +1,115 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package sqlite3 + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func getDefaultDirectory() string { + directory := "/db/" + if env.IsDevelopment { + directory = "tmp/" + } + return directory +} + +var errDoesNotExist = errors.New("unauthorized or the database doesn't exist") + +func (p *Sqlite3Plugin) DB(config *engine.PluginConfig) (*gorm.DB, error) { + connectionInput, err := p.ParseConnectionConfig(config) + if err != nil { + return nil, err + } + database := connectionInput.Database + + if IsSampleDatabase(database) { + return GetSampleDatabase() + } + + var fileNameDatabase string + + // Local mode (desktop/CLI): treat database field as full path + if env.GetIsLocalMode() { + // In desktop mode, the database field contains the full path + fileNameDatabase = database + + // Verify file exists + if _, err := os.Stat(fileNameDatabase); errors.Is(err, os.ErrNotExist) { + log.WithError(err).WithFields(map[string]any{ + "database": database, + "path": fileNameDatabase, + }).Error("SQLite database file does not exist") + return nil, errDoesNotExist + } + } else { + // Server mode: use default directory restriction + fileNameDatabase = filepath.Join(getDefaultDirectory(), database) + fileNameDatabase, err = filepath.EvalSymlinks(fileNameDatabase) + if err != nil { + log.WithError(err).WithFields(map[string]any{ + "database": database, + "path": fileNameDatabase, + }).Error("Failed to evaluate SQLite database symlinks") + return nil, err + } + if !strings.HasPrefix(fileNameDatabase, getDefaultDirectory()) { + log.WithFields(map[string]any{ + "database": database, + "path": fileNameDatabase, + "defaultDirectory": getDefaultDirectory(), + }).Error("SQLite database path is outside allowed directory") + return nil, errDoesNotExist + } + if _, err := os.Stat(fileNameDatabase); errors.Is(err, os.ErrNotExist) { + log.WithError(err).WithFields(map[string]any{ + "database": database, + "path": fileNameDatabase, + }).Error("SQLite database file does not exist") + return nil, errDoesNotExist + } + } + + l := log.WithFields(map[string]any{ + "database": database, + "path": fileNameDatabase, + }) + + db, err := gorm.Open(sqlite.Open(fileNameDatabase), &gorm.Config{Logger: logger.Default.LogMode(plugins.GetGormLogConfig())}) + if err != nil { + l.WithError(err).Error("Failed to connect to SQLite database") + return nil, err + } + + // Configure connection pool for consistency (SQLite benefits less but doesn't hurt) + if err := plugins.ConfigureConnectionPool(db); err != nil { + l.WithError(err).Warn("Failed to configure connection pool") + } + + return db, nil +} diff --git a/dataflow/core/src/plugins/sqlite3/graph.go b/dataflow/core/src/plugins/sqlite3/graph.go new file mode 100644 index 0000000..a2ab2a6 --- /dev/null +++ b/dataflow/core/src/plugins/sqlite3/graph.go @@ -0,0 +1,37 @@ +// Copyright 2025 Clidey, Inc. +// +// 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. + +package sqlite3 + +import ( + "gorm.io/gorm" +) + +const graphQuery = ` +SELECT DISTINCT + f."table" AS table1, + m.name AS table2, + 'OneToMany' AS relation, + f."from" AS source_column, + f."to" AS target_column +FROM + sqlite_master m, + pragma_foreign_key_list(m.name) f +WHERE + m.type = 'table' +` + +func (p *Sqlite3Plugin) GetGraphQueryDB(db *gorm.DB, schema string) *gorm.DB { + return db.Raw(graphQuery) +} diff --git a/dataflow/core/src/plugins/sqlite3/sample.go b/dataflow/core/src/plugins/sqlite3/sample.go new file mode 100644 index 0000000..865e211 --- /dev/null +++ b/dataflow/core/src/plugins/sqlite3/sample.go @@ -0,0 +1,83 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package sqlite3 + +import ( + _ "embed" + "sync" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + "github.com/clidey/whodb/core/src/types" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +//go:embed whodb-sample.sql +var sampleSQL string + +const SampleDatabaseName = "whodb-sample" + +const sampleDatabaseURI = "file:" + SampleDatabaseName + "?mode=memory&cache=shared" + +var ( + sampleDBOnce sync.Once + sampleDBErr error +) + +func IsSampleDatabase(name string) bool { + return name == SampleDatabaseName +} + +func GetSampleProfile() types.DatabaseCredentials { + return types.DatabaseCredentials{ + Alias: "Sample SQLite Database", + Database: SampleDatabaseName, + Type: engine.DatabaseType_Sqlite3, + IsProfile: true, + Source: "builtin", + } +} + +func GetSampleDatabase() (*gorm.DB, error) { + sampleDBOnce.Do(func() { + db, err := gorm.Open(sqlite.Open(sampleDatabaseURI), &gorm.Config{ + Logger: logger.Default.LogMode(plugins.GetGormLogConfig()), + }) + if err != nil { + log.WithError(err).Error("Failed to create sample in-memory database") + sampleDBErr = err + return + } + + if err := db.Exec(sampleSQL).Error; err != nil { + log.WithError(err).Error("Failed to initialize sample database schema") + sampleDBErr = err + return + } + }) + + if sampleDBErr != nil { + return nil, sampleDBErr + } + + return gorm.Open(sqlite.Open(sampleDatabaseURI), &gorm.Config{ + Logger: logger.Default.LogMode(plugins.GetGormLogConfig()), + }) +} diff --git a/dataflow/core/src/plugins/sqlite3/sqlite3.go b/dataflow/core/src/plugins/sqlite3/sqlite3.go new file mode 100644 index 0000000..3bc0139 --- /dev/null +++ b/dataflow/core/src/plugins/sqlite3/sqlite3.go @@ -0,0 +1,693 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package sqlite3 + +import ( + "database/sql" + "encoding/hex" + "errors" + "fmt" + "os" + "strings" + "sync" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/plugins" + gorm_plugin "github.com/clidey/whodb/core/src/plugins/gorm" + "gorm.io/gorm" +) + +// CreateSQLBuilder creates a SQLite-specific SQL builder. +func (p *Sqlite3Plugin) CreateSQLBuilder(db *gorm.DB) gorm_plugin.SQLBuilderInterface { + return NewSQLiteSQLBuilder(db, p) +} + +var ( + supportedOperators = map[string]string{ + "=": "=", ">=": ">=", ">": ">", "<=": "<=", "<": "<", "<>": "<>", "!=": "!=", + "BETWEEN": "BETWEEN", "NOT BETWEEN": "NOT BETWEEN", + "LIKE": "LIKE", "NOT LIKE": "NOT LIKE", "GLOB": "GLOB", + "IN": "IN", "NOT IN": "NOT IN", "IS NULL": "IS NULL", "IS NOT NULL": "IS NOT NULL", + "AND": "AND", "OR": "OR", "NOT": "NOT", + } +) + +type Sqlite3Plugin struct { + gorm_plugin.GormPlugin + strictTableCache map[string]bool + cacheMutex sync.RWMutex +} + +func (p *Sqlite3Plugin) GetSupportedOperators() map[string]string { + return supportedOperators +} + +func (p *Sqlite3Plugin) GetAllSchemasQuery() string { + return "" +} + +func (p *Sqlite3Plugin) FormTableName(schema string, storageUnit string) string { + return storageUnit +} + +// GetLastInsertID returns the most recently auto-generated ID using SQLite's last_insert_rowid(). +func (p *Sqlite3Plugin) GetLastInsertID(db *gorm.DB) (int64, error) { + var id int64 + if err := db.Raw("SELECT last_insert_rowid()").Scan(&id).Error; err != nil { + return 0, err + } + return id, nil +} + +func (p *Sqlite3Plugin) GetDatabases(config *engine.PluginConfig) ([]string, error) { + if env.GetIsLocalMode() { + return []string{}, nil + } + + directory := getDefaultDirectory() + entries, err := os.ReadDir(directory) + if err != nil { + if os.IsNotExist(err) { + return []string{}, nil + } + return nil, err + } + + var databases []string + for _, e := range entries { + databases = append(databases, e.Name()) + } + + return databases, nil +} + +func (p *Sqlite3Plugin) GetAllSchemas(config *engine.PluginConfig) ([]string, error) { + return nil, errors.ErrUnsupported +} + +func (p *Sqlite3Plugin) GetTableInfoQuery() string { + return ` + SELECT + name AS table_name, + type AS table_type + FROM + sqlite_master + WHERE + type='table' AND name NOT LIKE 'sqlite_%' + ` +} + +func (p *Sqlite3Plugin) GetStorageUnitExistsQuery() string { + // First param is schema (ignored for SQLite), second is table name + // LENGTH(IFNULL(?, '')) >= 0 always evaluates to true, consuming the schema param + return `SELECT LENGTH(IFNULL(?, '')) >= 0 AND EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?)` +} + +// IsTableStrict checks if a table is STRICT using PRAGMA table_list +// Returns false if detection fails or SQLite version doesn't support STRICT tables +func (p *Sqlite3Plugin) IsTableStrict(db *gorm.DB, tableName string) bool { + // Check cache first + p.cacheMutex.RLock() + if p.strictTableCache != nil { + if isStrict, exists := p.strictTableCache[tableName]; exists { + p.cacheMutex.RUnlock() + return isStrict + } + } + p.cacheMutex.RUnlock() + + // Query PRAGMA table_list to check if table is STRICT + // The strict column is available in SQLite 3.37.0+ + var strict int + err := db.Raw("SELECT strict FROM pragma_table_list WHERE name = ?", tableName).Scan(&strict).Error + + // If error or no result, assume non-STRICT for backward compatibility + isStrict := err == nil && strict == 1 + + // Cache the result + p.cacheMutex.Lock() + if p.strictTableCache == nil { + p.strictTableCache = make(map[string]bool) + } + p.strictTableCache[tableName] = isStrict + p.cacheMutex.Unlock() + + return isStrict +} + +func (p *Sqlite3Plugin) GetPlaceholder(index int) string { + return "?" +} + +// getColumnsViaPragma retrieves column information using PRAGMA table_info directly. +// This bypasses GORM's DDL parser which fails on Unicode table/column names because +// its regex uses \w (ASCII-only in Go). +func (p *Sqlite3Plugin) getColumnsViaPragma(db *gorm.DB, storageUnit string) ([]engine.Column, error) { + builder, ok := p.CreateSQLBuilder(db).(*SQLiteSQLBuilder) + if !ok { + return nil, fmt.Errorf("failed to create SQLite SQL builder") + } + + tableInfoQuery, err := builder.PragmaQuery("table_info", storageUnit) + if err != nil { + return nil, err + } + + rows, err := db.Raw(tableInfoQuery).Rows() + if err != nil { + return nil, err + } + defer rows.Close() + + var columns []engine.Column + + for rows.Next() { + var cid int + var name string + var dataType string + var notNull int + var dfltValue any + var pk int + + if err := rows.Scan(&cid, &name, &dataType, ¬Null, &dfltValue, &pk); err != nil { + continue + } + + normalizedType := NormalizeType(strings.ToUpper(dataType)) + col := engine.Column{ + Name: name, + Type: normalizedType, + IsNullable: notNull == 0, + IsPrimary: pk > 0, + } + columns = append(columns, col) + } + + // Enrich with foreign key relationships using the provided db connection directly + escapedTable := strings.ReplaceAll(storageUnit, "'", "''") + fkQuery := fmt.Sprintf("PRAGMA foreign_key_list('%s')", escapedTable) + fkRows, fkErr := db.Raw(fkQuery).Rows() + if fkErr == nil { + defer fkRows.Close() + for fkRows.Next() { + var id, seq int + var table, from, to, onUpdate, onDelete, match string + if err := fkRows.Scan(&id, &seq, &table, &from, &to, &onUpdate, &onDelete, &match); err != nil { + continue + } + for i := range columns { + if columns[i].Name == from { + columns[i].IsForeignKey = true + columns[i].ReferencedTable = &table + columns[i].ReferencedColumn = &to + break + } + } + } + } + + return columns, nil +} + +// GetColumnsForTable uses PRAGMA table_info directly instead of GORM's DDL parser, +// which fails on Unicode table/column names due to ASCII-only regex. +func (p *Sqlite3Plugin) GetColumnsForTable(config *engine.PluginConfig, schema, storageUnit string) ([]engine.Column, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) ([]engine.Column, error) { + return p.getColumnsViaPragma(db, storageUnit) + }) +} + +// MarkGeneratedColumns detects SQLite auto-increment (INTEGER PRIMARY KEY) and +// generated columns (via PRAGMA table_xinfo hidden flags). +func (p *Sqlite3Plugin) MarkGeneratedColumns(config *engine.PluginConfig, schema string, storageUnit string, columns []engine.Column) error { + _, err := plugins.WithConnection(config, p.DB, func(db *gorm.DB) (bool, error) { + builder, ok := p.CreateSQLBuilder(db).(*SQLiteSQLBuilder) + if !ok { + return false, fmt.Errorf("failed to create SQLite SQL builder") + } + + // Detect auto-increment: SQLite INTEGER PRIMARY KEY with a single PK column + // We need PK column types from table_info + tableInfoQuery, err := builder.PragmaQuery("table_info", storageUnit) + if err != nil { + return false, err + } + rows, err := db.Raw(tableInfoQuery).Rows() + if err != nil { + return false, err + } + defer rows.Close() + + pkColTypes := make(map[string]string) + for rows.Next() { + var cid int + var name, dataType string + var notNull int + var dfltValue any + var pk int + if err := rows.Scan(&cid, &name, &dataType, ¬Null, &dfltValue, &pk); err != nil { + continue + } + if pk > 0 { + pkColTypes[name] = strings.ToUpper(dataType) + } + } + + if len(pkColTypes) == 1 { + for i := range columns { + if columns[i].IsPrimary { + pkType := pkColTypes[columns[i].Name] + colType := strings.ToUpper(columns[i].Type) + if pkType == "INTEGER" || colType == "INTEGER" { + columns[i].IsAutoIncrement = true + } + } + } + } + + // Detect generated columns via table_xinfo + tableXInfoQuery, err := builder.PragmaQuery("table_xinfo", storageUnit) + if err != nil { + return true, nil + } + xrows, xerr := db.Raw(tableXInfoQuery).Rows() + if xerr != nil { + return true, nil + } + defer xrows.Close() + for xrows.Next() { + var cid int + var name, dataType string + var notNull int + var dfltValue any + var pk, hidden int + + if err := xrows.Scan(&cid, &name, &dataType, ¬Null, &dfltValue, &pk, &hidden); err != nil { + continue + } + + if hidden == 2 || hidden == 3 { + for i := range columns { + if columns[i].Name == name { + columns[i].IsComputed = true + break + } + } + } + } + + return true, nil + }) + return err +} + +// GetColumnTypes uses PRAGMA table_info directly instead of GORM's DDL parser. +func (p *Sqlite3Plugin) GetColumnTypes(db *gorm.DB, schema, tableName string) (map[string]gorm_plugin.ColumnTypeInfo, error) { + cols, err := p.getColumnsViaPragma(db, tableName) + if err != nil { + return nil, err + } + columnTypes := make(map[string]gorm_plugin.ColumnTypeInfo, len(cols)) + for _, col := range cols { + columnTypes[col.Name] = gorm_plugin.ColumnTypeInfo{ + Type: col.Type, + IsNullable: col.IsNullable, + } + } + return columnTypes, nil +} + +// GetOrderedColumnsWithTypes uses PRAGMA table_info directly instead of GORM's DDL parser. +func (p *Sqlite3Plugin) GetOrderedColumnsWithTypes(db *gorm.DB, schema, tableName string) ([]engine.Column, map[string]gorm_plugin.ColumnTypeInfo, error) { + cols, err := p.getColumnsViaPragma(db, tableName) + if err != nil { + return nil, nil, err + } + types := make(map[string]gorm_plugin.ColumnTypeInfo, len(cols)) + for _, col := range cols { + types[col.Name] = gorm_plugin.ColumnTypeInfo{ + Type: col.Type, + IsNullable: col.IsNullable, + } + } + return cols, types, nil +} + +func (p *Sqlite3Plugin) GetTableNameAndAttributes(rows *sql.Rows) (string, []engine.Record) { + var tableName, tableType string + if err := rows.Scan(&tableName, &tableType); err != nil { + log.WithError(err).Error("Failed to scan SQLite table information from rows") + return "", nil + } + + attributes := []engine.Record{ + {Key: "Type", Value: tableType}, + } + + return tableName, attributes +} + +// GetRows overrides the base GORM implementation to handle SQLite datetime quirks +func (p *Sqlite3Plugin) GetRows(config *engine.PluginConfig, req *engine.GetRowsRequest) (*engine.GetRowsResult, error) { + schema, storageUnit := req.Schema, req.StorageUnit + where, sort, pageSize, pageOffset := req.Where, req.Sort, req.PageSize, req.PageOffset + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (*engine.GetRowsResult, error) { + builder := gorm_plugin.NewSQLBuilder(db, p) + fullTable := builder.BuildFullTableName("", storageUnit) + + // Start count query in a separate goroutine for parallel execution + var totalCount int64 + countDone := make(chan error, 1) + go func() { + columnTypes, _ := p.GetColumnTypes(db, schema, storageUnit) + // codeql[go/sql-injection]: table name validated by StorageUnitExists before reaching this code + countQuery := db.Table(fullTable) + var err error + countQuery, err = p.ApplyWhereConditions(countQuery, where, columnTypes) + if err != nil { + countDone <- err + return + } + countDone <- countQuery.Count(&totalCount).Error + }() + + // Check if table is STRICT + isStrict := p.IsTableStrict(db, storageUnit) + + var result *engine.GetRowsResult + + // For STRICT tables, delegate to parent GORM implementation without CAST + if isStrict { + // codeql[go/sql-injection]: table name validated by StorageUnitExists before reaching this code + query := db.Table(fullTable) + + // Get column types for WHERE conditions + columnTypes, _ := p.GetColumnTypes(db, schema, storageUnit) + + query, err := p.ApplyWhereConditions(query, where, columnTypes) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to apply where conditions for STRICT table %s", storageUnit)) + return nil, err + } + + // Apply sorting + if len(sort) > 0 { + sortList := make([]plugins.Sort, len(sort)) + for i, s := range sort { + sortList[i] = plugins.Sort{Column: s.Column, Direction: plugins.Down} + if s.Direction == model.SortDirectionAsc { + sortList[i].Direction = plugins.Up + } + } + query = builder.BuildOrderBy(query, sortList) + } else { + if orderBy := p.GormPluginFunctions.GetRowsOrderBy(db, schema, storageUnit); orderBy != "" { + query = query.Order(orderBy) + } + } + + query = query.Limit(pageSize).Offset(pageOffset) + + rows, err := query.Rows() + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to execute SQLite rows query for STRICT table %s", storageUnit)) + return nil, err + } + defer rows.Close() + + // Use parent's ConvertRawToRows for STRICT tables + result, err = p.GormPlugin.ConvertRawToRows(rows) + if err != nil { + return nil, err + } + } else { + // For non-STRICT tables, use custom handling with CAST for date/time types + orderedColumns, columnTypes, err := p.GetOrderedColumnsWithTypes(db, schema, storageUnit) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to get column types for table %s.%s", schema, storageUnit)) + return nil, err + } + + selects := make([]string, 0, len(orderedColumns)) + for _, col := range orderedColumns { + upper := strings.ToUpper(col.Type) + // Only apply CAST for non-STRICT tables + if upper == "DATE" || upper == "DATETIME" || upper == "TIMESTAMP" { + selects = append(selects, fmt.Sprintf("CAST(%s AS TEXT) AS %s", builder.QuoteIdentifier(col.Name), builder.QuoteIdentifier(col.Name))) + } else { + selects = append(selects, builder.QuoteIdentifier(col.Name)) + } + } + + // codeql[go/sql-injection]: table name validated by StorageUnitExists before reaching this code + query := db.Table(fullTable).Select(selects) + + query, err = p.ApplyWhereConditions(query, where, columnTypes) + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to apply where conditions for table %s.%s", schema, storageUnit)) + return nil, err + } + + // Sorting + if len(sort) > 0 { + sortList := make([]plugins.Sort, len(sort)) + for i, s := range sort { + sortList[i] = plugins.Sort{Column: s.Column, Direction: plugins.Down} + if s.Direction == model.SortDirectionAsc { + sortList[i].Direction = plugins.Up + } + } + query = builder.BuildOrderBy(query, sortList) + } else { + if orderBy := p.GormPluginFunctions.GetRowsOrderBy(db, schema, storageUnit); orderBy != "" { + query = query.Order(orderBy) + } + } + + query = query.Limit(pageSize).Offset(pageOffset) + + rows, err := query.Rows() + if err != nil { + log.WithError(err).Error(fmt.Sprintf("Failed to execute SQLite rows query for table %s.%s", schema, storageUnit)) + return nil, err + } + defer rows.Close() + + result, err = p.ConvertRawToRows(rows) + if err != nil { + return nil, err + } + } + + // Wait for count query to complete and set TotalCount + if countErr := <-countDone; countErr != nil { + log.WithError(countErr).Warn(fmt.Sprintf("Failed to get row count for table %s", storageUnit)) + } else { + result.TotalCount = totalCount + } + + return result, nil + }) +} + +func (p *Sqlite3Plugin) executeRawSQL(config *engine.PluginConfig, query string, params ...any) (*engine.GetRowsResult, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (*engine.GetRowsResult, error) { + // For multi-statement scripts, use the underlying *sql.DB directly + // SQLite's driver supports multi-statement with Exec() + if config != nil && config.MultiStatement { + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + _, err = sqlDB.Exec(query) + if err != nil { + return nil, err + } + return &engine.GetRowsResult{ + Columns: []engine.Column{}, + Rows: [][]string{}, + }, nil + } + + rows, err := db.Raw(query, params...).Rows() + if err != nil { + return nil, err + } + defer rows.Close() + + // For raw SQL, we default to non-strict behavior for backward compatibility + // since we can't reliably determine table strictness from arbitrary queries + return p.ConvertRawToRows(rows) + }) +} + +func (p *Sqlite3Plugin) RawExecute(config *engine.PluginConfig, query string, params ...any) (*engine.GetRowsResult, error) { + return p.executeRawSQL(config, query, params...) +} + +// ConvertRawToRows overrides the parent to handle SQLite datetime columns specially +// This maintains backward compatibility for non-STRICT tables +func (p *Sqlite3Plugin) ConvertRawToRows(rows *sql.Rows) (*engine.GetRowsResult, error) { + // Default to non-STRICT handling for backward compatibility + columns, err := rows.Columns() + if err != nil { + return nil, err + } + + columnTypes, err := rows.ColumnTypes() + if err != nil { + return nil, err + } + + // Check if we have any datetime columns + hasDateTimeColumns := false + for _, colType := range columnTypes { + typeName := strings.ToUpper(colType.DatabaseTypeName()) + if typeName == "DATE" || typeName == "DATETIME" || typeName == "TIMESTAMP" { + hasDateTimeColumns = true + break + } + } + + // If no datetime columns even in non-STRICT table, use parent implementation + if !hasDateTimeColumns { + return p.GormPlugin.ConvertRawToRows(rows) + } + + // Custom implementation for datetime preservation + typeMap := make(map[string]*sql.ColumnType, len(columnTypes)) + for _, colType := range columnTypes { + typeMap[colType.Name()] = colType + } + + result := &engine.GetRowsResult{ + Columns: make([]engine.Column, 0, len(columns)), + Rows: make([][]string, 0, 100), + } + + // Build columns with type information + for _, col := range columns { + if colType, exists := typeMap[col]; exists { + colTypeName := colType.DatabaseTypeName() + result.Columns = append(result.Columns, engine.Column{Name: col, Type: colTypeName}) + } + } + + for rows.Next() { + columnPointers := make([]any, len(columns)) + row := make([]string, len(columns)) + + for i, col := range columns { + colType := typeMap[col] + typeName := strings.ToUpper(colType.DatabaseTypeName()) + + // Use custom DateTimeString type for datetime columns to prevent parsing + switch typeName { + case "DATE", "DATETIME", "TIMESTAMP": + columnPointers[i] = new(DateTimeString) + case "BLOB": + columnPointers[i] = new(sql.RawBytes) + default: + columnPointers[i] = new(sql.NullString) + } + } + + if err := rows.Scan(columnPointers...); err != nil { + return nil, err + } + + for i, colPtr := range columnPointers { + colType := typeMap[columns[i]] + typeName := strings.ToUpper(colType.DatabaseTypeName()) + + switch typeName { + case "DATE", "DATETIME", "TIMESTAMP": + dateStr := colPtr.(*DateTimeString) + row[i] = string(*dateStr) + case "BLOB": + rawBytes := colPtr.(*sql.RawBytes) + if rawBytes == nil || len(*rawBytes) == 0 { + row[i] = "" + } else { + row[i] = "0x" + hex.EncodeToString(*rawBytes) + } + default: + val := colPtr.(*sql.NullString) + if val.Valid { + row[i] = val.String + } else { + row[i] = "" + } + } + } + + result.Rows = append(result.Rows, row) + } + + return result, nil +} + +func (p *Sqlite3Plugin) GetForeignKeyRelationships(config *engine.PluginConfig, schema string, storageUnit string) (map[string]*engine.ForeignKeyRelationship, error) { + return plugins.WithConnection(config, p.DB, func(db *gorm.DB) (map[string]*engine.ForeignKeyRelationship, error) { + escapedTable := strings.ReplaceAll(storageUnit, "'", "''") + query := fmt.Sprintf("PRAGMA foreign_key_list('%s')", escapedTable) + + rows, err := db.Raw(query).Rows() + if err != nil { + return nil, err + } + defer rows.Close() + + relationships := make(map[string]*engine.ForeignKeyRelationship) + for rows.Next() { + var id, seq int + var table, from, to, onUpdate, onDelete, match string + if err := rows.Scan(&id, &seq, &table, &from, &to, &onUpdate, &onDelete, &match); err != nil { + log.WithError(err).Error("Failed to scan foreign key relationship") + continue + } + relationships[from] = &engine.ForeignKeyRelationship{ + ColumnName: from, + ReferencedTable: table, + ReferencedColumn: to, + } + } + + return relationships, nil + }) +} + +// NormalizeType converts SQLite type aliases to their canonical form. +func (p *Sqlite3Plugin) NormalizeType(typeName string) string { + return NormalizeType(typeName) +} + +// GetMaxBulkInsertParameters returns 999 for SQLite. +func (p *Sqlite3Plugin) GetMaxBulkInsertParameters() int { + return 999 +} + +func NewSqlite3Plugin() *engine.Plugin { + plugin := &Sqlite3Plugin{} + plugin.Type = engine.DatabaseType_Sqlite3 + plugin.PluginFunctions = plugin + plugin.GormPluginFunctions = plugin + return &plugin.Plugin +} diff --git a/dataflow/core/src/plugins/sqlite3/sqlite3_builder.go b/dataflow/core/src/plugins/sqlite3/sqlite3_builder.go new file mode 100644 index 0000000..f93b47e --- /dev/null +++ b/dataflow/core/src/plugins/sqlite3/sqlite3_builder.go @@ -0,0 +1,56 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package sqlite3 + +import ( + "fmt" + gorm_plugin "github.com/clidey/whodb/core/src/plugins/gorm" + "gorm.io/gorm" +) + +// SQLiteSQLBuilder embeds the generic SQLBuilder and overrides methods for SQLite-specific syntax. +type SQLiteSQLBuilder struct { + *gorm_plugin.SQLBuilder +} + +// NewSQLiteSQLBuilder creates a new SQL builder for SQLite. +func NewSQLiteSQLBuilder(db *gorm.DB, plugin gorm_plugin.GormPluginFunctions) *SQLiteSQLBuilder { + baseBuilder := gorm_plugin.NewSQLBuilder(db, plugin) + builder := &SQLiteSQLBuilder{ + SQLBuilder: baseBuilder, + } + baseBuilder.SetSelf(builder) + return builder +} + +// PragmaQuery builds a SQLite PRAGMA query. +func (sb *SQLiteSQLBuilder) PragmaQuery(pragma, table string) (string, error) { + allowedPragmas := map[string]bool{ + "table_info": true, + "table_xinfo": true, + "index_list": true, + "index_info": true, + "foreign_key_list": true, + "table_list": true, + } + + if !allowedPragmas[pragma] { + return "", fmt.Errorf("disallowed pragma: %s", pragma) + } + + return fmt.Sprintf("PRAGMA %s(%s)", pragma, sb.QuoteIdentifier(table)), nil +} diff --git a/dataflow/core/src/plugins/sqlite3/ssl_status.go b/dataflow/core/src/plugins/sqlite3/ssl_status.go new file mode 100644 index 0000000..abdc592 --- /dev/null +++ b/dataflow/core/src/plugins/sqlite3/ssl_status.go @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package sqlite3 + +import ( + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" +) + +// GetSSLStatus returns nil for SQLite as it's a local file-based database +// that doesn't use network connections or SSL/TLS. +func (p *Sqlite3Plugin) GetSSLStatus(config *engine.PluginConfig) (*engine.SSLStatus, error) { + log.Debug("[SSL] Sqlite3Plugin.GetSSLStatus: SQLite does not support SSL (local file-based)") + return nil, nil +} diff --git a/dataflow/core/src/plugins/sqlite3/types.go b/dataflow/core/src/plugins/sqlite3/types.go new file mode 100644 index 0000000..8985112 --- /dev/null +++ b/dataflow/core/src/plugins/sqlite3/types.go @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package sqlite3 + +import ( + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" +) + +// AliasMap maps SQLite type aliases to their canonical storage class or affinity. +// SQLite's type affinity rules mean many types map to the same storage class. +// All keys and values are UPPERCASE. +var AliasMap = map[string]string{ + "INT": "INTEGER", + "TINYINT": "INTEGER", + "SMALLINT": "INTEGER", + "MEDIUMINT": "INTEGER", + "BIGINT": "INTEGER", + "INT2": "INTEGER", + "INT8": "INTEGER", + "DOUBLE": "REAL", + "DOUBLE PRECISION": "REAL", + "FLOAT": "REAL", + "CHARACTER": "TEXT", + "VARCHAR": "TEXT", + "VARYING CHARACTER": "TEXT", + "NCHAR": "TEXT", + "NATIVE CHARACTER": "TEXT", + "NVARCHAR": "TEXT", + "CLOB": "TEXT", + "CHAR": "TEXT", + "DECIMAL": "NUMERIC", + "BOOL": "BOOLEAN", + "TIMESTAMP": "DATETIME", +} + +// TypeDefinitions contains the canonical SQLite types with metadata for UI. +var TypeDefinitions = []engine.TypeDefinition{ + {ID: "NULL", Label: "NULL", Category: engine.TypeCategoryOther}, + {ID: "INTEGER", Label: "INTEGER", Category: engine.TypeCategoryNumeric}, + {ID: "REAL", Label: "REAL", Category: engine.TypeCategoryNumeric}, + {ID: "TEXT", Label: "TEXT", Category: engine.TypeCategoryText}, + {ID: "BLOB", Label: "BLOB", Category: engine.TypeCategoryBinary}, + {ID: "NUMERIC", Label: "NUMERIC", Category: engine.TypeCategoryNumeric}, + {ID: "BOOLEAN", Label: "BOOLEAN", Category: engine.TypeCategoryBoolean}, + {ID: "DATE", Label: "DATE", Category: engine.TypeCategoryDatetime}, + {ID: "DATETIME", Label: "DATETIME", Category: engine.TypeCategoryDatetime}, +} + +// NormalizeType converts a SQLite type alias to its canonical form. +func NormalizeType(typeName string) string { + return common.NormalizeTypeWithMap(typeName, AliasMap) +} + +// GetDatabaseMetadata returns SQLite metadata for frontend configuration. +func (p *Sqlite3Plugin) GetDatabaseMetadata() *engine.DatabaseMetadata { + operators := make([]string, 0, len(supportedOperators)) + for op := range supportedOperators { + operators = append(operators, op) + } + return &engine.DatabaseMetadata{ + DatabaseType: engine.DatabaseType_Sqlite3, + TypeDefinitions: TypeDefinitions, + Operators: operators, + AliasMap: AliasMap, + Capabilities: engine.Capabilities{ + SupportsScratchpad: true, + SupportsChat: true, + SupportsGraph: true, + SupportsModifiers: true, + }, + } +} diff --git a/dataflow/core/src/plugins/sqlite3/types_test.go b/dataflow/core/src/plugins/sqlite3/types_test.go new file mode 100644 index 0000000..48dbf91 --- /dev/null +++ b/dataflow/core/src/plugins/sqlite3/types_test.go @@ -0,0 +1,33 @@ +package sqlite3 + +import ( + "testing" + + "github.com/clidey/whodb/core/src/engine" +) + +func TestNormalizeType(t *testing.T) { + if got := NormalizeType("int"); got != "INTEGER" { + t.Fatalf("expected INTEGER, got %q", got) + } + + if got := NormalizeType("varchar(100)"); got != "TEXT(100)" { + t.Fatalf("expected TEXT(100), got %q", got) + } +} + +func TestGetDatabaseMetadataIncludesAliasMap(t *testing.T) { + p := &Sqlite3Plugin{} + p.Type = engine.DatabaseType_Sqlite3 + + meta := p.GetDatabaseMetadata() + if meta == nil { + t.Fatalf("expected metadata, got nil") + } + if meta.DatabaseType != engine.DatabaseType_Sqlite3 { + t.Fatalf("expected DatabaseType %q, got %q", engine.DatabaseType_Sqlite3, meta.DatabaseType) + } + if meta.AliasMap["INT"] != "INTEGER" { + t.Fatalf("expected INT alias to be INTEGER, got %q", meta.AliasMap["INT"]) + } +} diff --git a/dataflow/core/src/plugins/sqlite3/utils.go b/dataflow/core/src/plugins/sqlite3/utils.go new file mode 100644 index 0000000..7bd1f35 --- /dev/null +++ b/dataflow/core/src/plugins/sqlite3/utils.go @@ -0,0 +1,110 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package sqlite3 + +import ( + "database/sql/driver" + "fmt" + "strings" + + "gorm.io/gorm" +) + +// DateTimeString is a custom type that stores datetime values as plain strings +// without any parsing or formatting. This is needed because SQLite stores +// datetime values as TEXT and we want to preserve the exact format. +type DateTimeString string + +// Scan implements sql.Scanner interface to read datetime values as strings +func (ds *DateTimeString) Scan(value any) error { + if value == nil { + *ds = "" + return nil + } + + switch v := value.(type) { + case string: + *ds = DateTimeString(v) + case []byte: + *ds = DateTimeString(v) + case driver.Value: + // Handle case where the driver returns a driver.Value + str := fmt.Sprintf("%v", v) + *ds = DateTimeString(str) + default: + // For any other type (including time.Time), convert to string + // This handles the case where the SQLite driver has already parsed the datetime + str := fmt.Sprintf("%v", value) + *ds = DateTimeString(str) + } + return nil +} + +// Value implements driver.Valuer interface to write datetime values as strings +func (ds DateTimeString) Value() (driver.Value, error) { + return string(ds), nil +} + +// ConvertStringValue overrides the base GORM implementation to preserve datetime strings +func (p *Sqlite3Plugin) ConvertStringValue(value, columnType string, isNullable bool) (any, error) { + // For datetime types, preserve the original string value + normalizedType := strings.ToUpper(columnType) + if normalizedType == "DATE" || normalizedType == "DATETIME" || normalizedType == "TIMESTAMP" { + return value, nil + } + // For non-datetime types, delegate to the base GORM implementation + return p.GormPlugin.ConvertStringValue(value, columnType, isNullable) +} + +// GetPrimaryKeyColumns overrides the base implementation because SQLite's +// primary key query takes only a table name parameter (no schema). +func (p *Sqlite3Plugin) GetPrimaryKeyColumns(db *gorm.DB, schema string, tableName string) ([]string, error) { + query := p.GetPrimaryKeyColQuery() + if query == "" { + return nil, nil + } + + rows, err := db.Raw(query, tableName).Rows() + if err != nil { + return nil, nil + } + defer rows.Close() + + var primaryKeys []string + for rows.Next() { + var columnName string + if err := rows.Scan(&columnName); err != nil { + continue + } + primaryKeys = append(primaryKeys, columnName) + } + return primaryKeys, nil +} + +func (p *Sqlite3Plugin) GetPrimaryKeyColQuery() string { + return ` + SELECT p.name AS pk_column + FROM sqlite_master m, + pragma_table_info(m.name) p + WHERE m.type = 'table' + AND m.name = ? + AND m.name NOT LIKE 'sqlite_%' + AND p.pk > 0 + ORDER BY m.name, p.pk;` +} + +// Identifier quoting handled by GORM Dialector diff --git a/dataflow/core/src/plugins/sqlite3/whodb-sample.sql b/dataflow/core/src/plugins/sqlite3/whodb-sample.sql new file mode 100644 index 0000000..cd9159f --- /dev/null +++ b/dataflow/core/src/plugins/sqlite3/whodb-sample.sql @@ -0,0 +1,317 @@ +-- Copyright 2025 Clidey, Inc. +-- +-- 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. + +-- WhoDB Sample Database +-- A simple e-commerce schema to demonstrate WhoDB features + +-- Enable foreign key support +PRAGMA +foreign_keys = ON; + +-- Users Table +CREATE TABLE IF NOT EXISTS users +( + id + INTEGER + PRIMARY + KEY + AUTOINCREMENT, + username + TEXT + UNIQUE + NOT + NULL, + email + TEXT + UNIQUE + NOT + NULL, + password + TEXT + NOT + NULL, + created_at + DATETIME + DEFAULT + CURRENT_TIMESTAMP, + CHECK ( + LENGTH +( + password +) >= 8) + ); + +-- Products Table +CREATE TABLE IF NOT EXISTS products +( + id + INTEGER + PRIMARY + KEY + AUTOINCREMENT, + name + TEXT + NOT + NULL + UNIQUE, + description + TEXT, + price + REAL + NOT + NULL + CHECK +( + price + >= + 0 +), + stock_quantity INTEGER NOT NULL DEFAULT 0 CHECK +( + stock_quantity + >= + 0 +), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + +-- Orders Table +CREATE TABLE IF NOT EXISTS orders +( + id + INTEGER + PRIMARY + KEY + AUTOINCREMENT, + user_id + INTEGER + NOT + NULL, + order_date + DATETIME + DEFAULT + CURRENT_TIMESTAMP, + total_amount + REAL + NOT + NULL + DEFAULT + 0, + status + TEXT + DEFAULT + 'pending' + CHECK ( + status + IN +( + 'pending', + 'completed', + 'canceled' +)), + FOREIGN KEY +( + user_id +) REFERENCES users +( + id +) ON DELETE CASCADE + ); + +-- Order Items Table +CREATE TABLE IF NOT EXISTS order_items +( + id + INTEGER + PRIMARY + KEY + AUTOINCREMENT, + order_id + INTEGER + NOT + NULL, + product_id + INTEGER + NOT + NULL, + quantity + INTEGER + NOT + NULL + CHECK +( + quantity > + 0 +), + price_at_purchase REAL NOT NULL CHECK +( + price_at_purchase + >= + 0 +), + FOREIGN KEY +( + order_id +) REFERENCES orders +( + id +) ON DELETE CASCADE, + FOREIGN KEY +( + product_id +) REFERENCES products +( + id +) + ON DELETE CASCADE + ); + +-- Payments Table +CREATE TABLE IF NOT EXISTS payments +( + id + INTEGER + PRIMARY + KEY + AUTOINCREMENT, + order_id + INTEGER + NOT + NULL, + payment_date + DATETIME + DEFAULT + CURRENT_TIMESTAMP, + amount + REAL + NOT + NULL + CHECK +( + amount + >= + 0 +), + payment_method TEXT CHECK +( + payment_method + IN +( + 'credit_card', + 'paypal', + 'bank_transfer' +)), + FOREIGN KEY +( + order_id +) REFERENCES orders +( + id +) ON DELETE CASCADE + ); + +-- Indexes for faster queries +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id); +CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id); +CREATE INDEX IF NOT EXISTS idx_payments_order_id ON payments(order_id); + +-- View for Order Summary +CREATE VIEW IF NOT EXISTS order_summary AS +SELECT o.id AS order_id, + u.username, + o.order_date, + o.status, + o.total_amount +FROM orders o + JOIN users u ON o.user_id = u.id; + +-- Triggers to update total_amount in orders +CREATE TRIGGER IF NOT EXISTS trg_insert_order_total +AFTER INSERT ON order_items +BEGIN +UPDATE orders +SET total_amount = (SELECT COALESCE(SUM(price_at_purchase * quantity), 0) + FROM order_items + WHERE order_id = NEW.order_id) +WHERE id = NEW.order_id; +END; + +CREATE TRIGGER IF NOT EXISTS trg_update_order_total +AFTER +UPDATE ON order_items +BEGIN +UPDATE orders +SET total_amount = (SELECT COALESCE(SUM(price_at_purchase * quantity), 0) + FROM order_items + WHERE order_id = NEW.order_id) +WHERE id = NEW.order_id; +END; + +CREATE TRIGGER IF NOT EXISTS trg_delete_order_total +AFTER +DELETE +ON order_items +BEGIN +UPDATE orders +SET total_amount = (SELECT COALESCE(SUM(price_at_purchase * quantity), 0) + FROM order_items + WHERE order_id = OLD.order_id) +WHERE id = OLD.order_id; +END; + +-- Sample Data for Users +INSERT INTO users (username, email, password) +VALUES ('john_doe', 'john@example.com', 'securepassword1'), + ('jane_smith', 'jane@example.com', 'securepassword2'), + ('admin_user', 'admin@example.com', 'adminpass1'); + +-- Sample Data for Products +INSERT INTO products (name, description, price, stock_quantity) +VALUES ('Laptop', 'High-performance laptop', 1200.00, 10), + ('Smartphone', 'Latest model smartphone', 800.00, 20), + ('Headphones', 'Noise-canceling headphones', 150.00, 50), + ('Monitor', '4K UHD Monitor', 400.00, 15); + +-- Sample Orders +INSERT INTO orders (user_id, total_amount, status) +VALUES (1, 0, 'completed'), + (2, 0, 'pending'); + +-- Sample Order Items (triggers will update total_amount) +INSERT INTO order_items (order_id, product_id, quantity, price_at_purchase) +VALUES (1, 1, 1, 1200.00), + (1, 2, 1, 800.00), + (2, 3, 1, 150.00); + +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +-- Sample Payments +INSERT INTO payments (order_id, amount, payment_method) +VALUES (1, 2000.00, 'credit_card'), + (2, 150.00, 'paypal'); diff --git a/dataflow/core/src/plugins/ssl/ssl.go b/dataflow/core/src/plugins/ssl/ssl.go new file mode 100644 index 0000000..c63e4ac --- /dev/null +++ b/dataflow/core/src/plugins/ssl/ssl.go @@ -0,0 +1,262 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +// Package ssl provides SSL/TLS configuration types and utilities for database connections. +// It defines an extensible registry of SSL modes per database type, allowing EE to register +// additional modes without modifying CE code. +package ssl + +import ( + "slices" + + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" +) + +type SSLMode string + +const ( + SSLModeDisabled SSLMode = "disabled" // No SSL/TLS encryption + SSLModePreferred SSLMode = "preferred" // Use TLS if server supports it (MySQL) + SSLModeRequired SSLMode = "required" // Require TLS, skip certificate verification + SSLModeVerifyCA SSLMode = "verify-ca" // Verify server certificate against CA + SSLModeVerifyIdentity SSLMode = "verify-identity" // Verify CA + hostname (PostgreSQL: verify-full) + SSLModeEnabled SSLMode = "enabled" // Simple TLS toggle with verification (CH/Mongo/Redis/ES) + SSLModeInsecure SSLMode = "insecure" // TLS enabled but skip all verification +) + +// Advanced option keys for SSL configuration. +// Content keys: Used by frontend (reads files client-side, sends content directly) +// Path keys: Used by server-side profiles only +const ( + KeySSLMode = "SSL Mode" + KeySSLCACertContent = "SSL CA Content" + KeySSLClientCertContent = "SSL Client Cert Content" + KeySSLClientKeyContent = "SSL Client Key Content" + KeySSLServerName = "SSL Server Name" + + // Path keys - only for profile-based connections (server-side, admin-controlled) + KeySSLCACertPath = "SSL CA Path" + KeySSLClientCertPath = "SSL Client Cert Path" + KeySSLClientKeyPath = "SSL Client Key Path" +) + +// CertificateInput holds PEM certificate content or a file path to the cert. +// Content: Used by frontend (reads files client-side, sends content via API) +// Path: Used by profile-based connections only (server reads file directly) +type CertificateInput struct { + Content string // PEM content directly (from frontend or inline in profile) + Path string // File path (profile-based connections only, server reads file) +} + +// SSLConfig holds all SSL-related configuration for a database connection +type SSLConfig struct { + Mode SSLMode // SSL mode (required) + CACert CertificateInput // CA certificate for server verification + ClientCert CertificateInput // Client certificate for mutual TLS + ClientKey CertificateInput // Client private key for mutual TLS + ServerName string // Server hostname for verification (overrides connection hostname) +} + +// SSLModeInfo provides metadata for a single SSL mode, used for frontend display +type SSLModeInfo struct { + Value SSLMode // The mode value used in configuration + Label string // Human-readable label for UI (localization key: ssl.modes.{value}.label) + Description string // Detailed description for tooltips (localization key: ssl.modes.{value}.description) +} + +// Pre-defined mode info entries (frontend can override with localized strings using Value as key) +var ( + ModeInfoDisabled = SSLModeInfo{Value: SSLModeDisabled, Label: "Disabled", Description: "No SSL/TLS encryption"} + ModeInfoPreferred = SSLModeInfo{Value: SSLModePreferred, Label: "Preferred", Description: "Use TLS if server supports it"} + ModeInfoRequired = SSLModeInfo{Value: SSLModeRequired, Label: "Required", Description: "Require TLS, skip certificate verification"} + ModeInfoVerifyCA = SSLModeInfo{Value: SSLModeVerifyCA, Label: "Verify CA", Description: "Verify server certificate against CA"} + ModeInfoVerifyIdentity = SSLModeInfo{Value: SSLModeVerifyIdentity, Label: "Verify Identity", Description: "Verify CA and server hostname"} + ModeInfoEnabled = SSLModeInfo{Value: SSLModeEnabled, Label: "Enabled", Description: "Enable TLS with certificate verification"} + ModeInfoInsecure = SSLModeInfo{Value: SSLModeInsecure, Label: "Insecure", Description: "Enable TLS, skip certificate verification"} +) + +// Alias mappings for database-native SSL mode names +var ( + postgresAliases = map[string]SSLMode{ + "disable": SSLModeDisabled, + "require": SSLModeRequired, + "verify-full": SSLModeVerifyIdentity, + } + mysqlAliases = map[string]SSLMode{ + "DISABLED": SSLModeDisabled, + "PREFERRED": SSLModePreferred, + "REQUIRED": SSLModeRequired, + "VERIFY_CA": SSLModeVerifyCA, + "VERIFY_IDENTITY": SSLModeVerifyIdentity, + } +) + +// sslModeAliases maps database-native SSL mode names to our unified mode names. +var sslModeAliases = map[engine.DatabaseType]map[string]SSLMode{ + engine.DatabaseType_Postgres: postgresAliases, + engine.DatabaseType_MySQL: mysqlAliases, + engine.DatabaseType_MariaDB: mysqlAliases, +} + +// Common mode sets for databases with similar SSL support +var ( + modesStandard = []SSLModeInfo{ModeInfoDisabled, ModeInfoRequired, ModeInfoVerifyCA, ModeInfoVerifyIdentity} + modesWithPreferred = []SSLModeInfo{ModeInfoDisabled, ModeInfoPreferred, ModeInfoRequired, ModeInfoVerifyCA, ModeInfoVerifyIdentity} + modesSimple = []SSLModeInfo{ModeInfoDisabled, ModeInfoEnabled, ModeInfoInsecure} +) + +var ( + // databaseSSLModes holds CE database SSL modes + databaseSSLModes = map[engine.DatabaseType][]SSLModeInfo{ + engine.DatabaseType_Postgres: modesStandard, + engine.DatabaseType_MySQL: modesWithPreferred, + engine.DatabaseType_MariaDB: modesWithPreferred, + engine.DatabaseType_ClickHouse: modesSimple, + engine.DatabaseType_MongoDB: modesSimple, + engine.DatabaseType_Redis: modesSimple, + engine.DatabaseType_ElasticSearch: modesSimple, + } + + // additionalSSLModes holds EE-registered modes + additionalSSLModes = make(map[engine.DatabaseType][]SSLModeInfo) +) + +// RegisterDatabaseSSLModes allows EE to register SSL modes for EE databases. +func RegisterDatabaseSSLModes(dbType engine.DatabaseType, modes []SSLModeInfo) { + additionalSSLModes[dbType] = modes +} + +// RegisterSSLModeAliases allows EE to register SSL mode aliases for EE databases. +func RegisterSSLModeAliases(dbType engine.DatabaseType, aliases map[string]SSLMode) { + sslModeAliases[dbType] = aliases +} + +// GetSSLModes returns available SSL modes for a database type. +// Returns nil for database types that don't support SSL (e.g., Sqlite3). +func GetSSLModes(dbType engine.DatabaseType) []SSLModeInfo { + // Check CE modes first + if modes, ok := databaseSSLModes[dbType]; ok { + return modes + } + + // Check EE-registered modes + if modes, ok := additionalSSLModes[dbType]; ok { + return modes + } + + return nil +} + +// ValidateSSLMode checks if the given mode is valid for the database type. +func ValidateSSLMode(dbType engine.DatabaseType, mode SSLMode) bool { + return slices.ContainsFunc(GetSSLModes(dbType), func(m SSLModeInfo) bool { + return m.Value == mode + }) +} + +// NormalizeSSLMode converts database-native SSL mode names to our unified names. +// For example, PostgreSQL's "require" becomes "required", "verify-full" becomes "verify-identity". +// If no alias exists, returns the original mode unchanged. +func NormalizeSSLMode(dbType engine.DatabaseType, mode string) SSLMode { + if aliases, ok := sslModeAliases[dbType]; ok { + if normalized, found := aliases[mode]; found { + return normalized + } + } + // No alias found, return as-is + return SSLMode(mode) +} + +// GetSSLModeAliases returns all accepted alias names for a specific SSL mode. +// For example, for PostgreSQL's "required" mode, this returns ["require"]. +// This is used by the frontend to know which alternative names are accepted. +func GetSSLModeAliases(dbType engine.DatabaseType, mode SSLMode) []string { + aliases, ok := sslModeAliases[dbType] + if !ok { + return nil + } + + var result []string + for alias, normalizedMode := range aliases { + if normalizedMode == mode { + result = append(result, alias) + } + } + return result +} + +// HasSSLSupport returns true if the database type supports SSL configuration. +func HasSSLSupport(dbType engine.DatabaseType) bool { + return GetSSLModes(dbType) != nil +} + +// IsEnabled returns true if the SSL config has a non-disabled mode. +func (c *SSLConfig) IsEnabled() bool { + return c != nil && c.Mode != SSLModeDisabled && c.Mode != "" +} + +// ParseSSLConfig extracts SSL configuration from advanced options. +// This is a shared implementation for databases using simple SSL modes (enabled/insecure/disabled). +// Parameters: +// - dbType: database type for mode validation +// - advanced: key-value records containing SSL settings +// - hostname: default server name for certificate verification +// - isProfile: if true, allows path-based certificate loading (admin-controlled) +func ParseSSLConfig(dbType engine.DatabaseType, advanced []engine.Record, hostname string, isProfile bool) *SSLConfig { + log.Debugf("[SSL] ParseSSLConfig: received %d advanced records", len(advanced)) + for _, rec := range advanced { + log.Debugf("[SSL] ParseSSLConfig: key=%q value=%q", rec.Key, rec.Value) + } + modeStr := common.GetRecordValueOrDefault(advanced, KeySSLMode, string(SSLModeDisabled)) + log.Debugf("[SSL] ParseSSLConfig: modeStr=%q (looking for key=%q)", modeStr, KeySSLMode) + + // Normalize database-native mode names + mode := NormalizeSSLMode(dbType, modeStr) + + // Validate the normalized mode for this database + if !ValidateSSLMode(dbType, mode) { + return nil + } + + if mode == SSLModeDisabled { + return nil + } + + config := &SSLConfig{ + Mode: mode, + CACert: CertificateInput{ + Content: common.GetRecordValueOrDefault(advanced, KeySSLCACertContent, ""), + }, + ClientCert: CertificateInput{ + Content: common.GetRecordValueOrDefault(advanced, KeySSLClientCertContent, ""), + }, + ClientKey: CertificateInput{ + Content: common.GetRecordValueOrDefault(advanced, KeySSLClientKeyContent, ""), + }, + ServerName: common.GetRecordValueOrDefault(advanced, KeySSLServerName, hostname), + } + + // Path-based loading only for profile connections + if isProfile { + config.CACert.Path = common.GetRecordValueOrDefault(advanced, KeySSLCACertPath, "") + config.ClientCert.Path = common.GetRecordValueOrDefault(advanced, KeySSLClientCertPath, "") + config.ClientKey.Path = common.GetRecordValueOrDefault(advanced, KeySSLClientKeyPath, "") + } + + return config +} diff --git a/dataflow/core/src/plugins/ssl/ssl_test.go b/dataflow/core/src/plugins/ssl/ssl_test.go new file mode 100644 index 0000000..2b7f329 --- /dev/null +++ b/dataflow/core/src/plugins/ssl/ssl_test.go @@ -0,0 +1,443 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package ssl + +import ( + "testing" + + "github.com/clidey/whodb/core/src/engine" +) + +// Test helper functions - moved from ssl.go as they're only used in tests + +// requiresCertificates returns true if the SSL mode requires CA certificate. +// This is a test helper, not exported from the package. +func (c *SSLConfig) requiresCertificates() bool { + if c == nil { + return false + } + switch c.Mode { + case SSLModeVerifyCA, SSLModeVerifyIdentity: + return true + default: + return false + } +} + +// requiresHostnameVerification returns true if the SSL mode requires server hostname verification. +// This is a test helper, not exported from the package. +func (c *SSLConfig) requiresHostnameVerification() bool { + if c == nil { + return false + } + return c.Mode == SSLModeVerifyIdentity +} + +func TestGetSSLModes(t *testing.T) { + tests := []struct { + name string + dbType engine.DatabaseType + wantNil bool + minModes int + }{ + { + name: "Postgres has SSL modes", + dbType: engine.DatabaseType_Postgres, + wantNil: false, + minModes: 4, + }, + { + name: "MySQL has SSL modes", + dbType: engine.DatabaseType_MySQL, + wantNil: false, + minModes: 5, + }, + { + name: "MariaDB has SSL modes", + dbType: engine.DatabaseType_MariaDB, + wantNil: false, + minModes: 5, + }, + { + name: "ClickHouse has SSL modes", + dbType: engine.DatabaseType_ClickHouse, + wantNil: false, + minModes: 3, + }, + { + name: "MongoDB has SSL modes", + dbType: engine.DatabaseType_MongoDB, + wantNil: false, + minModes: 3, + }, + { + name: "Redis has SSL modes", + dbType: engine.DatabaseType_Redis, + wantNil: false, + minModes: 3, + }, + { + name: "Elasticsearch has SSL modes", + dbType: engine.DatabaseType_ElasticSearch, + wantNil: false, + minModes: 3, + }, + { + name: "Sqlite3 has no SSL modes", + dbType: engine.DatabaseType_Sqlite3, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + modes := GetSSLModes(tt.dbType) + if tt.wantNil { + if modes != nil { + t.Errorf("GetSSLModes(%s) = %v, want nil", tt.dbType, modes) + } + } else { + if modes == nil { + t.Errorf("GetSSLModes(%s) = nil, want non-nil", tt.dbType) + } else if len(modes) < tt.minModes { + t.Errorf("GetSSLModes(%s) returned %d modes, want at least %d", tt.dbType, len(modes), tt.minModes) + } + } + }) + } +} + +func TestGetSSLModesContainsDisabled(t *testing.T) { + // All databases with SSL support should have a disabled option + dbTypes := []engine.DatabaseType{ + engine.DatabaseType_Postgres, + engine.DatabaseType_MySQL, + engine.DatabaseType_MariaDB, + engine.DatabaseType_ClickHouse, + engine.DatabaseType_MongoDB, + engine.DatabaseType_Redis, + engine.DatabaseType_ElasticSearch, + } + + for _, dbType := range dbTypes { + t.Run(string(dbType), func(t *testing.T) { + modes := GetSSLModes(dbType) + hasDisabled := false + for _, m := range modes { + if m.Value == SSLModeDisabled { + hasDisabled = true + break + } + } + if !hasDisabled { + t.Errorf("GetSSLModes(%s) does not contain disabled mode", dbType) + } + }) + } +} + +func TestValidateSSLMode(t *testing.T) { + tests := []struct { + name string + dbType engine.DatabaseType + mode SSLMode + want bool + }{ + // Postgres modes + {"Postgres disabled valid", engine.DatabaseType_Postgres, SSLModeDisabled, true}, + {"Postgres required valid", engine.DatabaseType_Postgres, SSLModeRequired, true}, + {"Postgres verify-ca valid", engine.DatabaseType_Postgres, SSLModeVerifyCA, true}, + {"Postgres verify-identity valid", engine.DatabaseType_Postgres, SSLModeVerifyIdentity, true}, + {"Postgres preferred invalid", engine.DatabaseType_Postgres, SSLModePreferred, false}, + + // MySQL modes + {"MySQL disabled valid", engine.DatabaseType_MySQL, SSLModeDisabled, true}, + {"MySQL preferred valid", engine.DatabaseType_MySQL, SSLModePreferred, true}, + {"MySQL required valid", engine.DatabaseType_MySQL, SSLModeRequired, true}, + {"MySQL insecure invalid", engine.DatabaseType_MySQL, SSLModeInsecure, false}, + + // ClickHouse modes + {"ClickHouse disabled valid", engine.DatabaseType_ClickHouse, SSLModeDisabled, true}, + {"ClickHouse enabled valid", engine.DatabaseType_ClickHouse, SSLModeEnabled, true}, + {"ClickHouse insecure valid", engine.DatabaseType_ClickHouse, SSLModeInsecure, true}, + {"ClickHouse verify-ca invalid", engine.DatabaseType_ClickHouse, SSLModeVerifyCA, false}, + + // SQLite (no SSL support) + {"Sqlite3 disabled invalid", engine.DatabaseType_Sqlite3, SSLModeDisabled, false}, + {"Sqlite3 enabled invalid", engine.DatabaseType_Sqlite3, SSLModeEnabled, false}, + + // Invalid mode + {"Unknown mode", engine.DatabaseType_Postgres, SSLMode("unknown"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ValidateSSLMode(tt.dbType, tt.mode) + if got != tt.want { + t.Errorf("ValidateSSLMode(%s, %s) = %v, want %v", tt.dbType, tt.mode, got, tt.want) + } + }) + } +} + +func TestHasSSLSupport(t *testing.T) { + tests := []struct { + name string + dbType engine.DatabaseType + want bool + }{ + {"Postgres supports SSL", engine.DatabaseType_Postgres, true}, + {"MySQL supports SSL", engine.DatabaseType_MySQL, true}, + {"MariaDB supports SSL", engine.DatabaseType_MariaDB, true}, + {"ClickHouse supports SSL", engine.DatabaseType_ClickHouse, true}, + {"MongoDB supports SSL", engine.DatabaseType_MongoDB, true}, + {"Redis supports SSL", engine.DatabaseType_Redis, true}, + {"Elasticsearch supports SSL", engine.DatabaseType_ElasticSearch, true}, + {"Sqlite3 no SSL support", engine.DatabaseType_Sqlite3, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HasSSLSupport(tt.dbType) + if got != tt.want { + t.Errorf("HasSSLSupport(%s) = %v, want %v", tt.dbType, got, tt.want) + } + }) + } +} + +func TestSSLConfigIsEnabled(t *testing.T) { + tests := []struct { + name string + config *SSLConfig + want bool + }{ + {"nil config", nil, false}, + {"empty mode", &SSLConfig{Mode: ""}, false}, + {"disabled mode", &SSLConfig{Mode: SSLModeDisabled}, false}, + {"required mode", &SSLConfig{Mode: SSLModeRequired}, true}, + {"verify-ca mode", &SSLConfig{Mode: SSLModeVerifyCA}, true}, + {"verify-identity mode", &SSLConfig{Mode: SSLModeVerifyIdentity}, true}, + {"enabled mode", &SSLConfig{Mode: SSLModeEnabled}, true}, + {"insecure mode", &SSLConfig{Mode: SSLModeInsecure}, true}, + {"preferred mode", &SSLConfig{Mode: SSLModePreferred}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.config.IsEnabled() + if got != tt.want { + t.Errorf("SSLConfig.IsEnabled() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSSLConfigRequiresCertificates(t *testing.T) { + tests := []struct { + name string + config *SSLConfig + want bool + }{ + {"nil config", nil, false}, + {"disabled mode", &SSLConfig{Mode: SSLModeDisabled}, false}, + {"required mode", &SSLConfig{Mode: SSLModeRequired}, false}, + {"enabled mode", &SSLConfig{Mode: SSLModeEnabled}, false}, + {"insecure mode", &SSLConfig{Mode: SSLModeInsecure}, false}, + {"verify-ca mode", &SSLConfig{Mode: SSLModeVerifyCA}, true}, + {"verify-identity mode", &SSLConfig{Mode: SSLModeVerifyIdentity}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.config.requiresCertificates() + if got != tt.want { + t.Errorf("SSLConfig.requiresCertificates() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSSLConfigRequiresHostnameVerification(t *testing.T) { + tests := []struct { + name string + config *SSLConfig + want bool + }{ + {"nil config", nil, false}, + {"disabled mode", &SSLConfig{Mode: SSLModeDisabled}, false}, + {"required mode", &SSLConfig{Mode: SSLModeRequired}, false}, + {"verify-ca mode", &SSLConfig{Mode: SSLModeVerifyCA}, false}, + {"verify-identity mode", &SSLConfig{Mode: SSLModeVerifyIdentity}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.config.requiresHostnameVerification() + if got != tt.want { + t.Errorf("SSLConfig.requiresHostnameVerification() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRegisterDatabaseSSLModes(t *testing.T) { + // Use a fake database type for testing + fakeDBType := engine.DatabaseType("TestDB") + + // Verify it doesn't exist initially + if modes := GetSSLModes(fakeDBType); modes != nil { + t.Fatalf("GetSSLModes(%s) should return nil before registration", fakeDBType) + } + + // Register modes + testModes := []SSLModeInfo{ + {Value: SSLModeDisabled, Label: "Off", Description: "No encryption"}, + {Value: SSLModeEnabled, Label: "On", Description: "Full encryption"}, + } + RegisterDatabaseSSLModes(fakeDBType, testModes) + + // Verify registration worked + modes := GetSSLModes(fakeDBType) + if modes == nil { + t.Fatalf("GetSSLModes(%s) returned nil after registration", fakeDBType) + } + if len(modes) != 2 { + t.Errorf("GetSSLModes(%s) returned %d modes, want 2", fakeDBType, len(modes)) + } + + // Verify mode values + if modes[0].Value != SSLModeDisabled { + t.Errorf("First mode value = %s, want %s", modes[0].Value, SSLModeDisabled) + } + if modes[1].Value != SSLModeEnabled { + t.Errorf("Second mode value = %s, want %s", modes[1].Value, SSLModeEnabled) + } + + // Cleanup + delete(additionalSSLModes, fakeDBType) +} + +func TestNormalizeSSLMode(t *testing.T) { + tests := []struct { + name string + dbType engine.DatabaseType + input string + want SSLMode + }{ + // PostgreSQL aliases + {"Postgres disable -> disabled", engine.DatabaseType_Postgres, "disable", SSLModeDisabled}, + {"Postgres require -> required", engine.DatabaseType_Postgres, "require", SSLModeRequired}, + {"Postgres verify-full -> verify-identity", engine.DatabaseType_Postgres, "verify-full", SSLModeVerifyIdentity}, + {"Postgres verify-ca unchanged", engine.DatabaseType_Postgres, "verify-ca", SSLModeVerifyCA}, + {"Postgres disabled unchanged", engine.DatabaseType_Postgres, "disabled", SSLModeDisabled}, + + // MySQL aliases + {"MySQL DISABLED -> disabled", engine.DatabaseType_MySQL, "DISABLED", SSLModeDisabled}, + {"MySQL REQUIRED -> required", engine.DatabaseType_MySQL, "REQUIRED", SSLModeRequired}, + {"MySQL PREFERRED -> preferred", engine.DatabaseType_MySQL, "PREFERRED", SSLModePreferred}, + {"MySQL VERIFY_CA -> verify-ca", engine.DatabaseType_MySQL, "VERIFY_CA", SSLModeVerifyCA}, + {"MySQL VERIFY_IDENTITY -> verify-identity", engine.DatabaseType_MySQL, "VERIFY_IDENTITY", SSLModeVerifyIdentity}, + + // MariaDB (same as MySQL) + {"MariaDB REQUIRED -> required", engine.DatabaseType_MariaDB, "REQUIRED", SSLModeRequired}, + + // No aliases for simple-mode databases + {"ClickHouse enabled unchanged", engine.DatabaseType_ClickHouse, "enabled", SSLModeEnabled}, + {"MongoDB insecure unchanged", engine.DatabaseType_MongoDB, "insecure", SSLModeInsecure}, + + // Unknown mode passed through + {"Unknown mode unchanged", engine.DatabaseType_Postgres, "unknown-mode", SSLMode("unknown-mode")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NormalizeSSLMode(tt.dbType, tt.input) + if got != tt.want { + t.Errorf("NormalizeSSLMode(%s, %q) = %q, want %q", tt.dbType, tt.input, got, tt.want) + } + }) + } +} + +func TestGetSSLModeAliases(t *testing.T) { + tests := []struct { + name string + dbType engine.DatabaseType + mode SSLMode + wantCount int + wantAlias string // one expected alias to check + }{ + {"Postgres required has alias", engine.DatabaseType_Postgres, SSLModeRequired, 1, "require"}, + {"Postgres verify-identity has alias", engine.DatabaseType_Postgres, SSLModeVerifyIdentity, 1, "verify-full"}, + {"Postgres disabled has alias", engine.DatabaseType_Postgres, SSLModeDisabled, 1, "disable"}, + {"Postgres verify-ca no alias", engine.DatabaseType_Postgres, SSLModeVerifyCA, 0, ""}, + {"MySQL required has alias", engine.DatabaseType_MySQL, SSLModeRequired, 1, "REQUIRED"}, + {"ClickHouse no aliases", engine.DatabaseType_ClickHouse, SSLModeEnabled, 0, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + aliases := GetSSLModeAliases(tt.dbType, tt.mode) + if len(aliases) != tt.wantCount { + t.Errorf("GetSSLModeAliases(%s, %s) returned %d aliases, want %d", tt.dbType, tt.mode, len(aliases), tt.wantCount) + } + if tt.wantAlias != "" { + found := false + for _, a := range aliases { + if a == tt.wantAlias { + found = true + break + } + } + if !found { + t.Errorf("GetSSLModeAliases(%s, %s) = %v, want to contain %q", tt.dbType, tt.mode, aliases, tt.wantAlias) + } + } + }) + } +} + +func TestSSLModeInfoFields(t *testing.T) { + // Verify that all registered modes have non-empty fields + dbTypes := []engine.DatabaseType{ + engine.DatabaseType_Postgres, + engine.DatabaseType_MySQL, + engine.DatabaseType_MariaDB, + engine.DatabaseType_ClickHouse, + engine.DatabaseType_MongoDB, + engine.DatabaseType_Redis, + engine.DatabaseType_ElasticSearch, + } + + for _, dbType := range dbTypes { + t.Run(string(dbType), func(t *testing.T) { + modes := GetSSLModes(dbType) + for _, mode := range modes { + if mode.Value == "" { + t.Errorf("Mode in %s has empty Value", dbType) + } + if mode.Label == "" { + t.Errorf("Mode %s in %s has empty Label", mode.Value, dbType) + } + if mode.Description == "" { + t.Errorf("Mode %s in %s has empty Description", mode.Value, dbType) + } + } + }) + } +} diff --git a/dataflow/core/src/plugins/ssl/tls.go b/dataflow/core/src/plugins/ssl/tls.go new file mode 100644 index 0000000..17a5163 --- /dev/null +++ b/dataflow/core/src/plugins/ssl/tls.go @@ -0,0 +1,193 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package ssl + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + + "github.com/clidey/whodb/core/src/log" +) + +// Load resolves a CertificateInput to PEM bytes. +// Priority: Content first, then Path if Content is empty. +// Path-based loading is only safe for profile-based connections where paths +// are admin-controlled. Frontend connections should always use Content. +func (c *CertificateInput) Load() ([]byte, error) { + if c == nil { + return nil, nil + } + + if c.Content != "" { + log.Debug("[SSL] CertificateInput.Load: using Content") + return []byte(c.Content), nil + } + + if c.Path != "" { + log.Debugf("[SSL] CertificateInput.Load: reading from path %s", c.Path) + data, err := os.ReadFile(c.Path) + if err != nil { + log.Warnf("[SSL] CertificateInput.Load: failed to read file %s: %v", c.Path, err) + return nil, fmt.Errorf("failed to read certificate file %s: %w", c.Path, err) + } + log.Debugf("[SSL] CertificateInput.Load: read %d bytes from %s", len(data), c.Path) + return data, nil + } + + return nil, nil +} + +// IsEmpty returns true if no certificate is configured (neither Content nor Path). +func (c *CertificateInput) IsEmpty() bool { + if c == nil { + return true + } + return c.Content == "" && c.Path == "" +} + +// BuildTLSConfig creates a *tls.Config from SSLConfig. +// serverHostname is used for ServerName verification if not explicitly set in SSLConfig. +// Returns nil if SSL is disabled or config is nil. +func BuildTLSConfig(cfg *SSLConfig, serverHostname string) (*tls.Config, error) { + if cfg == nil || cfg.Mode == SSLModeDisabled || cfg.Mode == "" { + return nil, nil + } + + tlsConfig := &tls.Config{} + + // Handle modes that skip verification + switch cfg.Mode { + case SSLModeInsecure, SSLModePreferred, SSLModeRequired: + tlsConfig.InsecureSkipVerify = true + return tlsConfig, nil + case SSLModeEnabled: + // Enabled mode: use TLS with verification, but CA cert is optional + // If no CA cert provided, use system root CAs + if !cfg.CACert.IsEmpty() { + if err := loadRootCAs(tlsConfig, &cfg.CACert); err != nil { + return nil, err + } + } + // Load client certs if provided + if err := loadClientCerts(tlsConfig, &cfg.ClientCert, &cfg.ClientKey); err != nil { + return nil, err + } + return tlsConfig, nil + } + + // For verify-ca and verify-identity modes, CA certificate handling + var rootCAs *x509.CertPool + if !cfg.CACert.IsEmpty() { + if err := loadRootCAs(tlsConfig, &cfg.CACert); err != nil { + return nil, err + } + rootCAs = tlsConfig.RootCAs + } + + // Load client certificates if provided (mutual TLS) + if err := loadClientCerts(tlsConfig, &cfg.ClientCert, &cfg.ClientKey); err != nil { + return nil, err + } + + // Handle verify-ca vs verify-identity + if cfg.Mode == SSLModeVerifyCA { + // verify-ca: Verify cert chain but NOT hostname + // We use InsecureSkipVerify + VerifyConnection to manually verify chain only + tlsConfig.InsecureSkipVerify = true + if rootCAs != nil { + tlsConfig.VerifyConnection = func(cs tls.ConnectionState) error { + if len(cs.PeerCertificates) == 0 { + return fmt.Errorf("no peer certificates presented") + } + opts := x509.VerifyOptions{ + Roots: rootCAs, + Intermediates: x509.NewCertPool(), + } + // Add intermediate certs + for _, cert := range cs.PeerCertificates[1:] { + opts.Intermediates.AddCert(cert) + } + _, err := cs.PeerCertificates[0].Verify(opts) + if err != nil { + log.Warnf("[SSL] verify-ca: certificate chain verification failed: %v", err) + } + return err + } + } + } else if cfg.Mode == SSLModeVerifyIdentity { + // verify-identity: Verify cert chain AND hostname + if cfg.ServerName != "" { + tlsConfig.ServerName = cfg.ServerName + } else if serverHostname != "" { + tlsConfig.ServerName = serverHostname + } + } + + return tlsConfig, nil +} + +// loadRootCAs loads CA certificates into the TLS config's RootCAs pool. +func loadRootCAs(tlsConfig *tls.Config, caCert *CertificateInput) error { + caPEM, err := caCert.Load() + if err != nil { + return fmt.Errorf("failed to load CA certificate: %w", err) + } + if caPEM == nil { + return nil + } + + rootCAs := x509.NewCertPool() + if !rootCAs.AppendCertsFromPEM(caPEM) { + return fmt.Errorf("failed to parse CA certificate PEM") + } + tlsConfig.RootCAs = rootCAs + return nil +} + +// loadClientCerts loads client certificate and key for mutual TLS. +func loadClientCerts(tlsConfig *tls.Config, clientCert, clientKey *CertificateInput) error { + if clientCert.IsEmpty() || clientKey.IsEmpty() { + log.Debug("[SSL] loadClientCerts: no client cert/key provided, skipping") + return nil + } + + log.Debug("[SSL] loadClientCerts: loading client certificate") + certPEM, err := clientCert.Load() + if err != nil { + return fmt.Errorf("failed to load client certificate: %w", err) + } + log.Debugf("[SSL] loadClientCerts: cert PEM loaded, %d bytes, starts with: %.50s", len(certPEM), string(certPEM)) + + log.Debug("[SSL] loadClientCerts: loading client key") + keyPEM, err := clientKey.Load() + if err != nil { + return fmt.Errorf("failed to load client key: %w", err) + } + log.Debugf("[SSL] loadClientCerts: key PEM loaded, %d bytes, starts with: %.50s", len(keyPEM), string(keyPEM)) + + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + log.Warnf("[SSL] loadClientCerts: X509KeyPair failed: %v", err) + return fmt.Errorf("failed to load client key pair: %w", err) + } + + log.Debug("[SSL] loadClientCerts: client certificate loaded successfully") + tlsConfig.Certificates = []tls.Certificate{cert} + return nil +} diff --git a/dataflow/core/src/plugins/ssl/tls_test.go b/dataflow/core/src/plugins/ssl/tls_test.go new file mode 100644 index 0000000..50db376 --- /dev/null +++ b/dataflow/core/src/plugins/ssl/tls_test.go @@ -0,0 +1,323 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package ssl + +import ( + "crypto/tls" + "testing" +) + +// Test helper functions - moved from tls.go as they're only used in tests + +// buildInsecureTLSConfig creates a simple TLS config with verification disabled. +// This is a test helper, not exported from the package. +func buildInsecureTLSConfig() *tls.Config { + return &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: true, + } +} + +// Sample self-signed CA certificate for testing (not for production use) +const testCACertPEM = `-----BEGIN CERTIFICATE----- +MIIBkTCB+wIJAKHBfpvJHIMdMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl +c3RjYTAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMBExDzANBgNVBAMM +BnRlc3RjYTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC7o96WdWGkfJFq8bBM+Ufk +PiP8XMmB4etXjHv0NBzfnOuLJdOLHl5dj8JHKlrFqEYbzLApQZWaPKDqxJd/NHXP +AgMBAAGjUzBRMB0GA1UdDgQWBBQExample0123456789012345678901MB8GA1Ud +IwQYMBaAFBQExample0123456789012345678901MA8GA1UdEwEB/wQFMAMBAf8w +DQYJKoZIhvcNAQELBQADQQBExample +-----END CERTIFICATE-----` + +// Test client certificate (not for production use) +const testClientCertPEM = `-----BEGIN CERTIFICATE----- +MIIBjTCB9wIJAKHBfpvJHIMeMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl +c3RjYTAeFw0yNDAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBaMBMxETAPBgNVBAMM +CHRlc3R1c2VyMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALuj3pZ1YaR8kWrxsEz5 +R+Q+I/xcyYHh61eMe/Q0HN+c64sl04seXl2PwkcqWsWoRhvMsClBlZo8oOrEl380 +dc8CAwEAAaNTMFEwHQYDVR0OBBYEFBQExample0123456789012345678901MB8G +A1UdIwQYMBaAFBQExample0123456789012345678901MA8GA1UdEwEB/wQFMAMB +Af8wDQYJKoZIhvcNAQELBQADQQBExample +-----END CERTIFICATE-----` + +// Test client key (not for production use) +const testClientKeyPEM = `-----BEGIN RSA PRIVATE KEY----- +MIIBOgIBAAJBALuj3pZ1YaR8kWrxsEz5R+Q+I/xcyYHh61eMe/Q0HN+c64sl04se +Xl2PwkcqWsWoRhvMsClBlZo8oOrEl380dc8CAwEAAQJAExample01234567890123 +456789012345678901234567890123456789012345678901234567890123456789 +0123456789012345678901234567890IYwJiIYwJiIYwJiIYwJiIYwIhAExample01 +234567890123456789012345678901AiEAExample01234567890123456789012345 +678901ECIBExample01234567890123456789012345678901AiEAExample01234567 +890123456789012345678901ECIBExample012345678901234567890123456789 +-----END RSA PRIVATE KEY-----` + +func TestCertificateInputIsEmpty(t *testing.T) { + tests := []struct { + name string + input *CertificateInput + want bool + }{ + {"nil input", nil, true}, + {"empty struct", &CertificateInput{}, true}, + {"empty string", &CertificateInput{Content: ""}, true}, + {"has content", &CertificateInput{Content: "cert content"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.input.IsEmpty() + if got != tt.want { + t.Errorf("CertificateInput.IsEmpty() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCertificateInputLoad(t *testing.T) { + tests := []struct { + name string + input *CertificateInput + wantNil bool + wantErr bool + }{ + {"nil input", nil, true, false}, + {"empty input", &CertificateInput{}, true, false}, + {"loads from content", &CertificateInput{Content: testCACertPEM}, false, false}, + {"loads plain text content", &CertificateInput{Content: "test content"}, false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := tt.input.Load() + + if tt.wantErr { + if err == nil { + t.Errorf("CertificateInput.Load() expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("CertificateInput.Load() unexpected error: %v", err) + return + } + + if tt.wantNil && data != nil { + t.Errorf("CertificateInput.Load() = %v, want nil", data) + } + if !tt.wantNil && data == nil { + t.Errorf("CertificateInput.Load() = nil, want non-nil") + } + }) + } +} + +func TestCertificateInputLoadReturnsExactContent(t *testing.T) { + directContent := "direct content" + + input := &CertificateInput{ + Content: directContent, + } + + data, err := input.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + if string(data) != directContent { + t.Errorf("Load() = %s, want %s", string(data), directContent) + } +} + +func TestBuildTLSConfigNilDisabled(t *testing.T) { + tests := []struct { + name string + config *SSLConfig + }{ + {"nil config", nil}, + {"disabled mode", &SSLConfig{Mode: SSLModeDisabled}}, + {"empty mode", &SSLConfig{Mode: ""}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tlsConfig, err := BuildTLSConfig(tt.config, "localhost") + if err != nil { + t.Errorf("BuildTLSConfig() error = %v", err) + } + if tlsConfig != nil { + t.Errorf("BuildTLSConfig() = %v, want nil", tlsConfig) + } + }) + } +} + +func TestBuildTLSConfigInsecureModes(t *testing.T) { + modes := []SSLMode{SSLModeInsecure, SSLModePreferred, SSLModeRequired} + + for _, mode := range modes { + t.Run(string(mode), func(t *testing.T) { + config := &SSLConfig{Mode: mode} + tlsConfig, err := BuildTLSConfig(config, "localhost") + + if err != nil { + t.Errorf("BuildTLSConfig() error = %v", err) + return + } + if tlsConfig == nil { + t.Errorf("BuildTLSConfig() = nil, want non-nil") + return + } + if !tlsConfig.InsecureSkipVerify { + t.Errorf("TLSConfig.InsecureSkipVerify = false, want true for mode %s", mode) + } + }) + } +} + +func TestBuildTLSConfigEnabledMode(t *testing.T) { + config := &SSLConfig{Mode: SSLModeEnabled} + tlsConfig, err := BuildTLSConfig(config, "localhost") + + if err != nil { + t.Fatalf("BuildTLSConfig() error = %v", err) + } + if tlsConfig == nil { + t.Fatalf("BuildTLSConfig() = nil, want non-nil") + } + if tlsConfig.InsecureSkipVerify { + t.Errorf("TLSConfig.InsecureSkipVerify = true, want false for enabled mode") + } +} + +func TestBuildTLSConfigVerifyIdentityServerName(t *testing.T) { + tests := []struct { + name string + configServer string + hostname string + wantServerName string + }{ + {"uses config server name", "config.example.com", "host.example.com", "config.example.com"}, + {"uses hostname when config empty", "", "host.example.com", "host.example.com"}, + {"empty both", "", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &SSLConfig{ + Mode: SSLModeVerifyIdentity, + ServerName: tt.configServer, + } + tlsConfig, err := BuildTLSConfig(config, tt.hostname) + + if err != nil { + t.Fatalf("BuildTLSConfig() error = %v", err) + } + if tlsConfig == nil { + t.Fatalf("BuildTLSConfig() = nil, want non-nil") + } + if tlsConfig.ServerName != tt.wantServerName { + t.Errorf("TLSConfig.ServerName = %s, want %s", tlsConfig.ServerName, tt.wantServerName) + } + }) + } +} + +func TestBuildTLSConfigVerifyCANoServerName(t *testing.T) { + config := &SSLConfig{Mode: SSLModeVerifyCA} + tlsConfig, err := BuildTLSConfig(config, "localhost") + + if err != nil { + t.Fatalf("BuildTLSConfig() error = %v", err) + } + if tlsConfig == nil { + t.Fatalf("BuildTLSConfig() = nil, want non-nil") + } + // verify-ca should not set ServerName (that's only for verify-identity) + if tlsConfig.ServerName != "" { + t.Errorf("TLSConfig.ServerName = %s, want empty for verify-ca mode", tlsConfig.ServerName) + } +} + +func TestBuildInsecureTLSConfig(t *testing.T) { + tlsConfig := buildInsecureTLSConfig() + + if tlsConfig == nil { + t.Fatalf("buildInsecureTLSConfig() = nil, want non-nil") + } + if !tlsConfig.InsecureSkipVerify { + t.Errorf("TLSConfig.InsecureSkipVerify = false, want true") + } + if tlsConfig.MinVersion != tls.VersionTLS12 { + t.Errorf("TLSConfig.MinVersion = %d, want %d", tlsConfig.MinVersion, tls.VersionTLS12) + } +} + +func TestBuildTLSConfigWithCACertContent(t *testing.T) { + // Note: We can't test with a real valid CA cert easily without generating one, + // but we can test that invalid PEM is rejected + config := &SSLConfig{ + Mode: SSLModeVerifyCA, + CACert: CertificateInput{ + Content: "not a valid PEM certificate", + }, + } + + _, err := BuildTLSConfig(config, "localhost") + if err == nil { + t.Errorf("BuildTLSConfig() with invalid CA cert should return error") + } +} + +// Note: Path-based loading is no longer supported to prevent path traversal attacks. +// Frontend reads files client-side and sends Content directly. + +func TestBuildTLSConfigClientCertsRequireBoth(t *testing.T) { + tests := []struct { + name string + clientCert CertificateInput + clientKey CertificateInput + wantCerts bool + }{ + {"neither provided", CertificateInput{}, CertificateInput{}, false}, + {"only cert provided", CertificateInput{Content: "cert"}, CertificateInput{}, false}, + {"only key provided", CertificateInput{}, CertificateInput{Content: "key"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &SSLConfig{ + Mode: SSLModeEnabled, + ClientCert: tt.clientCert, + ClientKey: tt.clientKey, + } + tlsConfig, err := BuildTLSConfig(config, "localhost") + + if err != nil { + t.Fatalf("BuildTLSConfig() error = %v", err) + } + if tlsConfig == nil { + t.Fatalf("BuildTLSConfig() = nil, want non-nil") + } + + hasCerts := len(tlsConfig.Certificates) > 0 + if hasCerts != tt.wantCerts { + t.Errorf("TLSConfig has certificates = %v, want %v", hasCerts, tt.wantCerts) + } + }) + } +} diff --git a/dataflow/core/src/plugins/table_dependencies.go b/dataflow/core/src/plugins/table_dependencies.go new file mode 100644 index 0000000..b00bad8 --- /dev/null +++ b/dataflow/core/src/plugins/table_dependencies.go @@ -0,0 +1,118 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package plugins + +import ( + "fmt" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" +) + +// FindChildTables returns all tables that have FK references to the given table. +// The graph structure stores parent -> children relationships, so we look at the +// target table's Relations to find its children (tables that reference it). +func FindChildTables(graph []engine.GraphUnit, targetTable string) []string { + for _, unit := range graph { + if unit.Unit.Name == targetTable { + var children []string + for _, rel := range unit.Relations { + children = append(children, rel.Name) + } + return children + } + } + return nil +} + +// NullifyFKColumn sets the FK column in childTable that references parentTable to NULL. +// Used to break circular FK constraints before deleting rows. +func NullifyFKColumn( + plugin engine.PluginFunctions, + config *engine.PluginConfig, + schema, childTable, parentTable string, + graph []engine.GraphUnit, +) error { + for _, unit := range graph { + if unit.Unit.Name == parentTable { + for _, rel := range unit.Relations { + if rel.Name == childTable && rel.SourceColumn != nil { + return plugin.NullifyFKColumn(config, schema, childTable, *rel.SourceColumn) + } + } + return nil + } + } + return nil +} + +// ClearTableWithDependencies clears a table and all tables that reference it (children first). +// This ensures FK constraints are respected by deleting in the correct order. +// Circular FK references (e.g., employees <-> departments) are handled by NULLing out +// the FK column that forms the cycle before any rows are deleted. +func ClearTableWithDependencies( + plugin engine.PluginFunctions, + config *engine.PluginConfig, + schema, table string, + graph []engine.GraphUnit, +) error { + cleared := make(map[string]bool) + return clearTableRecursive(plugin, config, schema, table, graph, cleared) +} + +func clearTableRecursive( + plugin engine.PluginFunctions, + config *engine.PluginConfig, + schema, table string, + graph []engine.GraphUnit, + cleared map[string]bool, +) error { + if cleared[table] { + return nil + } + cleared[table] = true + + children := FindChildTables(graph, table) + + for _, child := range children { + if cleared[child] { + // Cycle detected: NULL out the FK column in child that points to this + // table so the subsequent DELETE won't violate the constraint. + if err := NullifyFKColumn(plugin, config, schema, child, table, graph); err != nil { + log.WithFields(map[string]any{ + "child": child, + "parent": table, + }).WithError(err).Error("Failed to nullify FK column for cycle breaking") + } + continue + } + if err := clearTableRecursive(plugin, config, schema, child, graph, cleared); err != nil { + return err + } + } + + log.WithFields(map[string]any{ + "table": table, + "children": children, + }).Debug("Clearing table data (children already cleared)") + + if _, err := plugin.ClearTableData(config, schema, table); err != nil { + return fmt.Errorf("failed to clear table %s: %w", table, err) + } + + return nil +} diff --git a/dataflow/core/src/providers/aws/connectivity.go b/dataflow/core/src/providers/aws/connectivity.go new file mode 100644 index 0000000..d4c1c0a --- /dev/null +++ b/dataflow/core/src/providers/aws/connectivity.go @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "net" + "sync" + "time" + + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/providers" +) + +const ( + connectivityTimeout = 3 * time.Second + maxConcurrentChecks = 10 + connectivityReachable = "reachable" + connectivityUnreachable = "unreachable" +) + +// CheckConnectivity performs a TCP dial to test network reachability. +// This is called during RefreshDiscovery (not cached DiscoverAll) to avoid slowing down page loads. +func CheckConnectivity(connections []providers.DiscoveredConnection) { + log.Infof("Connectivity: checking %d connections", len(connections)) + sem := make(chan struct{}, maxConcurrentChecks) + var wg sync.WaitGroup + + reachable := 0 + unreachable := 0 + + for i := range connections { + conn := &connections[i] + endpoint := conn.Metadata["endpoint"] + port := conn.Metadata["port"] + if endpoint == "" || port == "" { + continue + } + + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + + addr := net.JoinHostPort(endpoint, port) + c, err := net.DialTimeout("tcp", addr, connectivityTimeout) + if err != nil { + log.Debugf("Connectivity: %s unreachable: %v", addr, err) + conn.Metadata["connectivity"] = connectivityUnreachable + unreachable++ + return + } + c.Close() + log.Debugf("Connectivity: %s reachable", addr) + conn.Metadata["connectivity"] = connectivityReachable + reachable++ + }() + } + + wg.Wait() + log.Infof("Connectivity: done — %d reachable, %d unreachable", reachable, unreachable) +} diff --git a/dataflow/core/src/providers/aws/documentdb.go b/dataflow/core/src/providers/aws/documentdb.go new file mode 100644 index 0000000..142dfbc --- /dev/null +++ b/dataflow/core/src/providers/aws/documentdb.go @@ -0,0 +1,130 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "context" + "strconv" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/docdb" + docdbTypes "github.com/aws/aws-sdk-go-v2/service/docdb/types" + + awsinfra "github.com/clidey/whodb/core/src/aws" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/providers" +) + +func (p *Provider) discoverDocumentDB(ctx context.Context) ([]providers.DiscoveredConnection, error) { + var connections []providers.DiscoveredConnection + var nextToken *string + + log.Debugf("DocumentDB: starting discovery for provider %s", p.config.ID) + + for page := 0; page < maxPaginationPages; page++ { + input := &docdb.DescribeDBClustersInput{ + Marker: nextToken, + MaxRecords: aws.Int32(50), + } + + output, err := p.docdbClient.DescribeDBClusters(ctx, input) + if err != nil { + log.Errorf("DocumentDB: DescribeDBClusters failed: %v", err) + return nil, awsinfra.HandleAWSError(err) + } + + log.Debugf("DocumentDB: DescribeDBClusters returned %d clusters", len(output.DBClusters)) + + for _, cluster := range output.DBClusters { + clusterID := aws.ToString(cluster.DBClusterIdentifier) + clusterEngine := aws.ToString(cluster.Engine) + + if cluster.Engine != nil && !strings.Contains(strings.ToLower(*cluster.Engine), "docdb") { + log.Debugf("DocumentDB: skipping cluster %s (engine=%s)", clusterID, clusterEngine) + continue + } + + conn := p.docdbClusterToConnection(&cluster) + if conn != nil { + log.Debugf("DocumentDB: cluster %s converted to connection (endpoint=%s)", clusterID, conn.Metadata["endpoint"]) + connections = append(connections, *conn) + } else { + log.Warnf("DocumentDB: cluster %s returned nil connection", clusterID) + } + } + + if output.Marker == nil { + break + } + nextToken = output.Marker + } + + log.Debugf("DocumentDB: completed, found %d connections", len(connections)) + return connections, nil +} + +func (p *Provider) docdbClusterToConnection(cluster *docdbTypes.DBCluster) *providers.DiscoveredConnection { + if cluster.DBClusterIdentifier == nil { + return nil + } + + if cluster.Endpoint == nil { + log.Warnf("DocumentDB: cluster %s has no endpoint, skipping", *cluster.DBClusterIdentifier) + return nil + } + + metadata := make(map[string]string) + metadata["endpoint"] = *cluster.Endpoint + + if cluster.Port != nil { + metadata["port"] = strconv.Itoa(int(*cluster.Port)) + } + + return &providers.DiscoveredConnection{ + ID: p.connectionID(*cluster.DBClusterIdentifier), + ProviderType: providers.ProviderTypeAWS, + ProviderID: p.config.ID, + Name: *cluster.DBClusterIdentifier, + DatabaseType: engine.DatabaseType_DocumentDB, + Region: p.config.Region, + Status: mapDocDBStatus(cluster.Status), + Metadata: metadata, + } +} + +func mapDocDBStatus(status *string) providers.ConnectionStatus { + if status == nil { + return providers.ConnectionStatusUnknown + } + + switch strings.ToLower(*status) { + case "available": + return providers.ConnectionStatusAvailable + case "creating", "modifying", "upgrading", "migrating", "preparing-data-migration": + return providers.ConnectionStatusStarting + case "stopped", "stopping", "starting": + return providers.ConnectionStatusStopped + case "deleting": + return providers.ConnectionStatusDeleting + case "failed", "inaccessible-encryption-credentials": + return providers.ConnectionStatusFailed + default: + return providers.ConnectionStatusUnknown + } +} diff --git a/dataflow/core/src/providers/aws/documentdb_test.go b/dataflow/core/src/providers/aws/documentdb_test.go new file mode 100644 index 0000000..0e024b7 --- /dev/null +++ b/dataflow/core/src/providers/aws/documentdb_test.go @@ -0,0 +1,139 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + docdbTypes "github.com/aws/aws-sdk-go-v2/service/docdb/types" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/providers" +) + +func TestMapDocDBStatus(t *testing.T) { + testCases := []struct { + status string + expected providers.ConnectionStatus + }{ + {"available", providers.ConnectionStatusAvailable}, + {"Available", providers.ConnectionStatusAvailable}, + {"AVAILABLE", providers.ConnectionStatusAvailable}, + {"creating", providers.ConnectionStatusStarting}, + {"modifying", providers.ConnectionStatusStarting}, + {"upgrading", providers.ConnectionStatusStarting}, + {"migrating", providers.ConnectionStatusStarting}, + {"preparing-data-migration", providers.ConnectionStatusStarting}, + {"stopped", providers.ConnectionStatusStopped}, + {"stopping", providers.ConnectionStatusStopped}, + {"starting", providers.ConnectionStatusStopped}, + {"deleting", providers.ConnectionStatusDeleting}, + {"failed", providers.ConnectionStatusFailed}, + {"inaccessible-encryption-credentials", providers.ConnectionStatusFailed}, + {"unknown-status", providers.ConnectionStatusUnknown}, + {"", providers.ConnectionStatusUnknown}, + } + + for _, tc := range testCases { + status := tc.status + result := mapDocDBStatus(&status) + if result != tc.expected { + t.Errorf("mapDocDBStatus(%s): expected %s, got %s", tc.status, tc.expected, result) + } + } + + // Test nil status + result := mapDocDBStatus(nil) + if result != providers.ConnectionStatusUnknown { + t.Errorf("mapDocDBStatus(nil): expected %s, got %s", providers.ConnectionStatusUnknown, result) + } +} + +func TestMapDocDBStatus_CaseInsensitive(t *testing.T) { + statuses := []string{"available", "Available", "AVAILABLE", "AvAiLaBlE"} + for _, s := range statuses { + result := mapDocDBStatus(&s) + if result != providers.ConnectionStatusAvailable { + t.Errorf("mapDocDBStatus(%s): expected Available, got %s", s, result) + } + } +} + +func newTestDocDBProvider() *Provider { + p, _ := New(&Config{ + ID: "test-docdb", + Name: "Test DocumentDB", + Region: "us-west-2", + DiscoverDocumentDB: true, + }) + return p +} + +func TestDocDBClusterToConnection_HappyPath(t *testing.T) { + p := newTestDocDBProvider() + status := "available" + cluster := &docdbTypes.DBCluster{ + DBClusterIdentifier: aws.String("my-docdb"), + Endpoint: aws.String("my-docdb.cluster-abc.us-west-2.docdb.amazonaws.com"), + Port: aws.Int32(27017), + Status: &status, + } + + conn := p.docdbClusterToConnection(cluster) + if conn == nil { + t.Fatal("expected non-nil connection") + } + if conn.DatabaseType != engine.DatabaseType_DocumentDB { + t.Errorf("expected DocumentDB, got %s", conn.DatabaseType) + } + if conn.Metadata["endpoint"] != "my-docdb.cluster-abc.us-west-2.docdb.amazonaws.com" { + t.Errorf("unexpected endpoint: %s", conn.Metadata["endpoint"]) + } + if conn.Metadata["port"] != "27017" { + t.Errorf("unexpected port: %s", conn.Metadata["port"]) + } + if conn.Status != providers.ConnectionStatusAvailable { + t.Errorf("expected Available status, got %s", conn.Status) + } +} + +func TestDocDBClusterToConnection_NilID(t *testing.T) { + p := newTestDocDBProvider() + cluster := &docdbTypes.DBCluster{ + DBClusterIdentifier: nil, + Endpoint: aws.String("some-endpoint"), + } + + conn := p.docdbClusterToConnection(cluster) + if conn != nil { + t.Error("expected nil for nil ID") + } +} + +func TestDocDBClusterToConnection_NilEndpoint(t *testing.T) { + p := newTestDocDBProvider() + cluster := &docdbTypes.DBCluster{ + DBClusterIdentifier: aws.String("my-docdb"), + Endpoint: nil, + } + + conn := p.docdbClusterToConnection(cluster) + if conn != nil { + t.Error("expected nil for nil endpoint") + } +} diff --git a/dataflow/core/src/providers/aws/elasticache.go b/dataflow/core/src/providers/aws/elasticache.go new file mode 100644 index 0000000..ef1e495 --- /dev/null +++ b/dataflow/core/src/providers/aws/elasticache.go @@ -0,0 +1,346 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "context" + "strconv" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/elasticache" + ectypes "github.com/aws/aws-sdk-go-v2/service/elasticache/types" + + awsinfra "github.com/clidey/whodb/core/src/aws" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/providers" +) + +func (p *Provider) discoverElastiCache(ctx context.Context) ([]providers.DiscoveredConnection, error) { + var connections []providers.DiscoveredConnection + + log.Debugf("ElastiCache: starting discovery for provider %s", p.config.ID) + + // Discover Serverless caches + serverless, err := p.discoverElastiCacheServerless(ctx) + if err != nil { + return nil, err + } + log.Debugf("ElastiCache: found %d serverless caches", len(serverless)) + connections = append(connections, serverless...) + + // Discover traditional replication groups + replicationGroups, err := p.discoverElastiCacheReplicationGroups(ctx) + if err != nil { + return nil, err + } + log.Debugf("ElastiCache: found %d replication groups", len(replicationGroups)) + connections = append(connections, replicationGroups...) + + // Discover standalone clusters (not in replication groups) + clusters, err := p.discoverElastiCacheClusters(ctx) + if err != nil { + return nil, err + } + log.Debugf("ElastiCache: found %d standalone clusters", len(clusters)) + connections = append(connections, clusters...) + + return connections, nil +} + +func (p *Provider) discoverElastiCacheServerless(ctx context.Context) ([]providers.DiscoveredConnection, error) { + var connections []providers.DiscoveredConnection + var nextToken *string + + log.Debugf("ElastiCache: calling DescribeServerlessCaches for provider %s", p.config.ID) + + for page := 0; page < maxPaginationPages; page++ { + input := &elasticache.DescribeServerlessCachesInput{ + NextToken: nextToken, + MaxResults: aws.Int32(50), + } + + output, err := p.elasticacheClient.DescribeServerlessCaches(ctx, input) + if err != nil { + log.Errorf("ElastiCache: DescribeServerlessCaches failed: %v", err) + return nil, awsinfra.HandleAWSError(err) + } + + log.Debugf("ElastiCache: DescribeServerlessCaches returned %d caches", len(output.ServerlessCaches)) + + for _, cache := range output.ServerlessCaches { + cacheName := aws.ToString(cache.ServerlessCacheName) + cacheEngine := aws.ToString(cache.Engine) + status := aws.ToString(cache.Status) + log.Debugf("ElastiCache: processing serverless cache %s (engine=%s, status=%s)", cacheName, cacheEngine, status) + + if !isRedisCompatibleEngine(cacheEngine) { + log.Debugf("ElastiCache: skipping serverless cache %s (engine=%s, only Redis/Valkey supported)", cacheName, cacheEngine) + continue + } + + conn := p.serverlessCacheToConnection(&cache) + if conn != nil { + log.Debugf("ElastiCache: serverless cache %s converted to connection (endpoint=%s)", cacheName, conn.Metadata["endpoint"]) + connections = append(connections, *conn) + } else { + log.Warnf("ElastiCache: serverless cache %s returned nil connection", cacheName) + } + } + + if output.NextToken == nil { + break + } + nextToken = output.NextToken + } + + return connections, nil +} + +func (p *Provider) discoverElastiCacheReplicationGroups(ctx context.Context) ([]providers.DiscoveredConnection, error) { + var connections []providers.DiscoveredConnection + var nextToken *string + + log.Debugf("ElastiCache: calling DescribeReplicationGroups for provider %s", p.config.ID) + + for page := 0; page < maxPaginationPages; page++ { + input := &elasticache.DescribeReplicationGroupsInput{ + Marker: nextToken, + MaxRecords: aws.Int32(50), + } + + output, err := p.elasticacheClient.DescribeReplicationGroups(ctx, input) + if err != nil { + log.Errorf("ElastiCache: DescribeReplicationGroups failed: %v", err) + return nil, awsinfra.HandleAWSError(err) + } + + log.Debugf("ElastiCache: DescribeReplicationGroups returned %d groups", len(output.ReplicationGroups)) + + for _, rg := range output.ReplicationGroups { + rgID := aws.ToString(rg.ReplicationGroupId) + status := aws.ToString(rg.Status) + log.Debugf("ElastiCache: processing replication group %s (status=%s)", rgID, status) + + conn := p.replicationGroupToConnection(&rg) + if conn != nil { + log.Debugf("ElastiCache: replication group %s converted to connection (endpoint=%s)", rgID, conn.Metadata["endpoint"]) + connections = append(connections, *conn) + } else { + log.Warnf("ElastiCache: replication group %s returned nil connection", rgID) + } + } + + if output.Marker == nil { + break + } + nextToken = output.Marker + } + + return connections, nil +} + +func (p *Provider) discoverElastiCacheClusters(ctx context.Context) ([]providers.DiscoveredConnection, error) { + var connections []providers.DiscoveredConnection + var nextToken *string + + log.Debugf("ElastiCache: calling DescribeCacheClusters for provider %s", p.config.ID) + + for page := 0; page < maxPaginationPages; page++ { + input := &elasticache.DescribeCacheClustersInput{ + Marker: nextToken, + MaxRecords: aws.Int32(50), + ShowCacheNodeInfo: aws.Bool(true), + } + + output, err := p.elasticacheClient.DescribeCacheClusters(ctx, input) + if err != nil { + log.Errorf("ElastiCache: DescribeCacheClusters failed: %v", err) + return nil, awsinfra.HandleAWSError(err) + } + + log.Debugf("ElastiCache: DescribeCacheClusters returned %d clusters", len(output.CacheClusters)) + + for _, cluster := range output.CacheClusters { + clusterID := aws.ToString(cluster.CacheClusterId) + engineName := aws.ToString(cluster.Engine) + replicationGroupID := aws.ToString(cluster.ReplicationGroupId) + log.Debugf("ElastiCache: processing cluster %s (engine=%s, replicationGroupId=%s)", clusterID, engineName, replicationGroupID) + + if cluster.ReplicationGroupId != nil { + log.Debugf("ElastiCache: skipping cluster %s (belongs to replication group %s)", clusterID, *cluster.ReplicationGroupId) + continue + } + + if cluster.Engine != nil && !isRedisCompatibleEngine(*cluster.Engine) { + log.Debugf("ElastiCache: skipping cluster %s (engine=%s, only Redis/Valkey supported)", clusterID, engineName) + continue + } + + conn := p.cacheClusterToConnection(&cluster) + if conn != nil { + connections = append(connections, *conn) + } + } + + if output.Marker == nil { + break + } + nextToken = output.Marker + } + + return connections, nil +} + +func (p *Provider) replicationGroupToConnection(rg *ectypes.ReplicationGroup) *providers.DiscoveredConnection { + if rg.ReplicationGroupId == nil { + return nil + } + + metadata := make(map[string]string) + metadata["transitEncryption"] = strconv.FormatBool(aws.ToBool(rg.TransitEncryptionEnabled)) + metadata["authTokenEnabled"] = strconv.FormatBool(aws.ToBool(rg.AuthTokenEnabled)) + if len(rg.UserGroupIds) > 0 { + metadata["iamAuthSupported"] = "true" + } + + var endpoint string + var port int32 + if rg.ConfigurationEndpoint != nil { + endpoint = aws.ToString(rg.ConfigurationEndpoint.Address) + port = aws.ToInt32(rg.ConfigurationEndpoint.Port) + } else if len(rg.NodeGroups) > 0 && rg.NodeGroups[0].PrimaryEndpoint != nil { + endpoint = aws.ToString(rg.NodeGroups[0].PrimaryEndpoint.Address) + port = aws.ToInt32(rg.NodeGroups[0].PrimaryEndpoint.Port) + } + + if endpoint == "" { + log.Warnf("ElastiCache: replication group %s has no endpoint, skipping", *rg.ReplicationGroupId) + return nil + } + + metadata["endpoint"] = endpoint + metadata["port"] = strconv.Itoa(int(port)) + + return &providers.DiscoveredConnection{ + ID: p.connectionID(*rg.ReplicationGroupId), + ProviderType: providers.ProviderTypeAWS, + ProviderID: p.config.ID, + Name: *rg.ReplicationGroupId, + DatabaseType: engine.DatabaseType_ElastiCache, + Region: p.config.Region, + Status: mapElastiCacheStatus(aws.ToString(rg.Status)), + Metadata: metadata, + } +} + +func (p *Provider) cacheClusterToConnection(cluster *ectypes.CacheCluster) *providers.DiscoveredConnection { + if cluster.CacheClusterId == nil { + return nil + } + + metadata := make(map[string]string) + metadata["transitEncryption"] = strconv.FormatBool(aws.ToBool(cluster.TransitEncryptionEnabled)) + metadata["authTokenEnabled"] = strconv.FormatBool(aws.ToBool(cluster.AuthTokenEnabled)) + + if len(cluster.CacheNodes) == 0 || cluster.CacheNodes[0].Endpoint == nil { + log.Warnf("ElastiCache: cluster %s has no endpoint, skipping", *cluster.CacheClusterId) + return nil + } + + metadata["endpoint"] = aws.ToString(cluster.CacheNodes[0].Endpoint.Address) + metadata["port"] = strconv.Itoa(int(aws.ToInt32(cluster.CacheNodes[0].Endpoint.Port))) + + return &providers.DiscoveredConnection{ + ID: p.connectionID(*cluster.CacheClusterId), + ProviderType: providers.ProviderTypeAWS, + ProviderID: p.config.ID, + Name: *cluster.CacheClusterId, + DatabaseType: engine.DatabaseType_ElastiCache, + Region: p.config.Region, + Status: mapElastiCacheStatus(aws.ToString(cluster.CacheClusterStatus)), + Metadata: metadata, + } +} + +func (p *Provider) serverlessCacheToConnection(cache *ectypes.ServerlessCache) *providers.DiscoveredConnection { + if cache.ServerlessCacheName == nil { + return nil + } + + metadata := make(map[string]string) + metadata["serverless"] = "true" + + // Serverless caches always have TLS enabled + metadata["transitEncryption"] = "true" + + if cache.Endpoint == nil { + log.Warnf("ElastiCache: serverless cache %s has no endpoint, skipping", *cache.ServerlessCacheName) + return nil + } + + metadata["endpoint"] = aws.ToString(cache.Endpoint.Address) + metadata["port"] = strconv.Itoa(int(aws.ToInt32(cache.Endpoint.Port))) + + return &providers.DiscoveredConnection{ + ID: p.connectionID(*cache.ServerlessCacheName), + ProviderType: providers.ProviderTypeAWS, + ProviderID: p.config.ID, + Name: *cache.ServerlessCacheName, + DatabaseType: engine.DatabaseType_ElastiCache, + Region: p.config.Region, + Status: mapServerlessCacheStatus(aws.ToString(cache.Status)), + Metadata: metadata, + } +} + +func mapServerlessCacheStatus(status string) providers.ConnectionStatus { + switch strings.ToLower(status) { + case "available": + return providers.ConnectionStatusAvailable + case "creating", "modifying": + return providers.ConnectionStatusStarting + case "deleting": + return providers.ConnectionStatusDeleting + case "create-failed", "update-failed", "delete-failed": + return providers.ConnectionStatusFailed + default: + return providers.ConnectionStatusUnknown + } +} + +// isRedisCompatibleEngine returns true for Redis-compatible engines (redis, valkey). +func isRedisCompatibleEngine(engine string) bool { + engine = strings.ToLower(engine) + return engine == "redis" || engine == "valkey" +} + +func mapElastiCacheStatus(status string) providers.ConnectionStatus { + switch strings.ToLower(status) { + case "available": + return providers.ConnectionStatusAvailable + case "creating", "modifying", "snapshotting", "rebooting cluster nodes": + return providers.ConnectionStatusStarting + case "deleted", "deleting": + return providers.ConnectionStatusDeleting + case "create-failed", "restore-failed": + return providers.ConnectionStatusFailed + default: + return providers.ConnectionStatusUnknown + } +} diff --git a/dataflow/core/src/providers/aws/elasticache_test.go b/dataflow/core/src/providers/aws/elasticache_test.go new file mode 100644 index 0000000..1c09566 --- /dev/null +++ b/dataflow/core/src/providers/aws/elasticache_test.go @@ -0,0 +1,331 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + ectypes "github.com/aws/aws-sdk-go-v2/service/elasticache/types" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/providers" +) + +func TestMapElastiCacheStatus(t *testing.T) { + testCases := []struct { + status string + expected providers.ConnectionStatus + }{ + {"available", providers.ConnectionStatusAvailable}, + {"Available", providers.ConnectionStatusAvailable}, + {"AVAILABLE", providers.ConnectionStatusAvailable}, + {"creating", providers.ConnectionStatusStarting}, + {"modifying", providers.ConnectionStatusStarting}, + {"snapshotting", providers.ConnectionStatusStarting}, + {"rebooting cluster nodes", providers.ConnectionStatusStarting}, + {"deleted", providers.ConnectionStatusDeleting}, + {"deleting", providers.ConnectionStatusDeleting}, + {"create-failed", providers.ConnectionStatusFailed}, + {"restore-failed", providers.ConnectionStatusFailed}, + {"unknown-status", providers.ConnectionStatusUnknown}, + {"", providers.ConnectionStatusUnknown}, + } + + for _, tc := range testCases { + result := mapElastiCacheStatus(tc.status) + if result != tc.expected { + t.Errorf("mapElastiCacheStatus(%s): expected %s, got %s", tc.status, tc.expected, result) + } + } +} + +func TestMapElastiCacheStatus_CaseInsensitive(t *testing.T) { + statuses := []string{"available", "Available", "AVAILABLE", "AvAiLaBlE"} + for _, s := range statuses { + result := mapElastiCacheStatus(s) + if result != providers.ConnectionStatusAvailable { + t.Errorf("mapElastiCacheStatus(%s): expected Available, got %s", s, result) + } + } +} + +func TestMapServerlessCacheStatus(t *testing.T) { + testCases := []struct { + status string + expected providers.ConnectionStatus + }{ + {"available", providers.ConnectionStatusAvailable}, + {"creating", providers.ConnectionStatusStarting}, + {"modifying", providers.ConnectionStatusStarting}, + {"deleting", providers.ConnectionStatusDeleting}, + {"create-failed", providers.ConnectionStatusFailed}, + {"update-failed", providers.ConnectionStatusFailed}, + {"delete-failed", providers.ConnectionStatusFailed}, + {"unknown", providers.ConnectionStatusUnknown}, + } + + for _, tc := range testCases { + result := mapServerlessCacheStatus(tc.status) + if result != tc.expected { + t.Errorf("mapServerlessCacheStatus(%s): expected %s, got %s", tc.status, tc.expected, result) + } + } +} + +func newTestElastiCacheProvider() *Provider { + p, _ := New(&Config{ + ID: "test-ec", + Name: "Test ElastiCache", + Region: "us-west-2", + DiscoverElastiCache: true, + }) + return p +} + +func TestReplicationGroupToConnection_HappyPath(t *testing.T) { + p := newTestElastiCacheProvider() + rg := &ectypes.ReplicationGroup{ + ReplicationGroupId: aws.String("my-redis-rg"), + Status: aws.String("available"), + TransitEncryptionEnabled: aws.Bool(true), + AuthTokenEnabled: aws.Bool(false), + ConfigurationEndpoint: &ectypes.Endpoint{ + Address: aws.String("my-redis-rg.abc.cache.amazonaws.com"), + Port: aws.Int32(6379), + }, + } + + conn := p.replicationGroupToConnection(rg) + if conn == nil { + t.Fatal("expected non-nil connection") + } + if conn.DatabaseType != engine.DatabaseType_ElastiCache { + t.Errorf("expected ElastiCache, got %s", conn.DatabaseType) + } + if conn.Metadata["endpoint"] != "my-redis-rg.abc.cache.amazonaws.com" { + t.Errorf("unexpected endpoint: %s", conn.Metadata["endpoint"]) + } + if conn.Metadata["port"] != "6379" { + t.Errorf("unexpected port: %s", conn.Metadata["port"]) + } +} + +func TestReplicationGroupToConnection_WithUserGroups(t *testing.T) { + p := newTestElastiCacheProvider() + rg := &ectypes.ReplicationGroup{ + ReplicationGroupId: aws.String("my-redis-iam"), + Status: aws.String("available"), + TransitEncryptionEnabled: aws.Bool(true), + AuthTokenEnabled: aws.Bool(false), + UserGroupIds: []string{"my-user-group"}, + ConfigurationEndpoint: &ectypes.Endpoint{ + Address: aws.String("my-redis-iam.abc.cache.amazonaws.com"), + Port: aws.Int32(6379), + }, + } + + conn := p.replicationGroupToConnection(rg) + if conn == nil { + t.Fatal("expected non-nil connection") + } + if conn.Metadata["iamAuthSupported"] != "true" { + t.Error("expected iamAuthSupported=true when user groups present") + } +} + +func TestReplicationGroupToConnection_NoUserGroups(t *testing.T) { + p := newTestElastiCacheProvider() + rg := &ectypes.ReplicationGroup{ + ReplicationGroupId: aws.String("my-redis-basic"), + Status: aws.String("available"), + TransitEncryptionEnabled: aws.Bool(false), + AuthTokenEnabled: aws.Bool(true), + ConfigurationEndpoint: &ectypes.Endpoint{ + Address: aws.String("my-redis-basic.abc.cache.amazonaws.com"), + Port: aws.Int32(6379), + }, + } + + conn := p.replicationGroupToConnection(rg) + if conn == nil { + t.Fatal("expected non-nil connection") + } + if _, ok := conn.Metadata["iamAuthSupported"]; ok { + t.Error("expected no iamAuthSupported when no user groups") + } + if conn.Metadata["authTokenEnabled"] != "true" { + t.Error("expected authTokenEnabled=true") + } +} + +func TestReplicationGroupToConnection_NilID(t *testing.T) { + p := newTestElastiCacheProvider() + rg := &ectypes.ReplicationGroup{ + ReplicationGroupId: nil, + } + + conn := p.replicationGroupToConnection(rg) + if conn != nil { + t.Error("expected nil for nil ID") + } +} + +func TestReplicationGroupToConnection_NoEndpoint(t *testing.T) { + p := newTestElastiCacheProvider() + rg := &ectypes.ReplicationGroup{ + ReplicationGroupId: aws.String("my-redis-rg"), + Status: aws.String("creating"), + TransitEncryptionEnabled: aws.Bool(false), + AuthTokenEnabled: aws.Bool(false), + // No ConfigurationEndpoint, no NodeGroups + } + + conn := p.replicationGroupToConnection(rg) + if conn != nil { + t.Error("expected nil for no endpoint") + } +} + +func TestReplicationGroupToConnection_NodeGroupEndpoint(t *testing.T) { + p := newTestElastiCacheProvider() + rg := &ectypes.ReplicationGroup{ + ReplicationGroupId: aws.String("my-redis-rg"), + Status: aws.String("available"), + TransitEncryptionEnabled: aws.Bool(false), + AuthTokenEnabled: aws.Bool(false), + NodeGroups: []ectypes.NodeGroup{ + { + PrimaryEndpoint: &ectypes.Endpoint{ + Address: aws.String("primary.abc.cache.amazonaws.com"), + Port: aws.Int32(6379), + }, + }, + }, + } + + conn := p.replicationGroupToConnection(rg) + if conn == nil { + t.Fatal("expected non-nil connection from NodeGroup endpoint") + } + if conn.Metadata["endpoint"] != "primary.abc.cache.amazonaws.com" { + t.Errorf("unexpected endpoint: %s", conn.Metadata["endpoint"]) + } +} + +func TestServerlessCacheToConnection_HappyPath(t *testing.T) { + p := newTestElastiCacheProvider() + cache := &ectypes.ServerlessCache{ + ServerlessCacheName: aws.String("my-serverless"), + Status: aws.String("available"), + Endpoint: &ectypes.Endpoint{ + Address: aws.String("my-serverless.abc.cache.amazonaws.com"), + Port: aws.Int32(6379), + }, + } + + conn := p.serverlessCacheToConnection(cache) + if conn == nil { + t.Fatal("expected non-nil connection") + } + if conn.Metadata["serverless"] != "true" { + t.Error("expected serverless=true in metadata") + } + if conn.Metadata["transitEncryption"] != "true" { + t.Error("expected transitEncryption=true for serverless") + } +} + +func TestServerlessCacheToConnection_NilName(t *testing.T) { + p := newTestElastiCacheProvider() + cache := &ectypes.ServerlessCache{ + ServerlessCacheName: nil, + } + + conn := p.serverlessCacheToConnection(cache) + if conn != nil { + t.Error("expected nil for nil name") + } +} + +func TestServerlessCacheToConnection_NoEndpoint(t *testing.T) { + p := newTestElastiCacheProvider() + cache := &ectypes.ServerlessCache{ + ServerlessCacheName: aws.String("my-serverless"), + Status: aws.String("creating"), + Endpoint: nil, + } + + conn := p.serverlessCacheToConnection(cache) + if conn != nil { + t.Error("expected nil for no endpoint") + } +} + +func TestCacheClusterToConnection_HappyPath(t *testing.T) { + p := newTestElastiCacheProvider() + cluster := &ectypes.CacheCluster{ + CacheClusterId: aws.String("my-cluster"), + CacheClusterStatus: aws.String("available"), + TransitEncryptionEnabled: aws.Bool(false), + AuthTokenEnabled: aws.Bool(false), + CacheNodes: []ectypes.CacheNode{ + { + Endpoint: &ectypes.Endpoint{ + Address: aws.String("my-cluster.abc.cache.amazonaws.com"), + Port: aws.Int32(6379), + }, + }, + }, + } + + conn := p.cacheClusterToConnection(cluster) + if conn == nil { + t.Fatal("expected non-nil connection") + } + if conn.Metadata["endpoint"] != "my-cluster.abc.cache.amazonaws.com" { + t.Errorf("unexpected endpoint: %s", conn.Metadata["endpoint"]) + } +} + +func TestCacheClusterToConnection_NilID(t *testing.T) { + p := newTestElastiCacheProvider() + cluster := &ectypes.CacheCluster{ + CacheClusterId: nil, + } + + conn := p.cacheClusterToConnection(cluster) + if conn != nil { + t.Error("expected nil for nil ID") + } +} + +func TestCacheClusterToConnection_NoNodes(t *testing.T) { + p := newTestElastiCacheProvider() + cluster := &ectypes.CacheCluster{ + CacheClusterId: aws.String("my-cluster"), + CacheClusterStatus: aws.String("creating"), + TransitEncryptionEnabled: aws.Bool(false), + AuthTokenEnabled: aws.Bool(false), + CacheNodes: []ectypes.CacheNode{}, + } + + conn := p.cacheClusterToConnection(cluster) + if conn != nil { + t.Error("expected nil for no cache nodes") + } +} diff --git a/dataflow/core/src/providers/aws/provider.go b/dataflow/core/src/providers/aws/provider.go new file mode 100644 index 0000000..416e2cf --- /dev/null +++ b/dataflow/core/src/providers/aws/provider.go @@ -0,0 +1,368 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +// Package aws implements the AWS connection provider for WhoDB. +// +// This provider discovers database resources from an AWS account (RDS, ElastiCache, +// DocumentDB) and generates credentials that existing WhoDB plugins understand. +// The provider acts as a "credential factory" - it finds AWS databases and produces +// standard engine.Credentials that MySQL, PostgreSQL, Redis, and MongoDB plugins +// can use without modification. +// +// Supported AWS services (CE): +// - RDS: MySQL, PostgreSQL, MariaDB instances +// - ElastiCache: Redis clusters +// - DocumentDB: MongoDB-compatible clusters +// +// Authentication methods: +// - default: AWS SDK default credential chain (env → shared config → IAM role) +// - profile: Named AWS profile from ~/.aws/credentials +// +// # Extension Points for EE +// +// The package provides extension points for Enterprise Edition to add support +// for additional database types without modifying CE code: +// - SetRDSEngineMapper: Add mappings for Oracle, SQL Server RDS engines +// - SetDiscoveryExtension: Add discovery for DynamoDB +package aws + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/docdb" + "github.com/aws/aws-sdk-go-v2/service/elasticache" + "github.com/aws/aws-sdk-go-v2/service/rds" + "github.com/aws/aws-sdk-go-v2/service/sts" + "golang.org/x/sync/errgroup" + + awsinfra "github.com/clidey/whodb/core/src/aws" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/providers" +) + +// maxPaginationPages is a safety limit to prevent infinite loops if AWS pagination is broken. +const maxPaginationPages = 1000 + +// DiscoveryExtension discovers additional database connections. +type DiscoveryExtension func(ctx context.Context, p *Provider) ([]providers.DiscoveredConnection, error) + +// discoveryExtensions allows EE to register additional discovery functions. +var discoveryExtensions []DiscoveryExtension + +// SetDiscoveryExtension registers an additional discovery function. +func SetDiscoveryExtension(ext DiscoveryExtension) { + discoveryExtensions = append(discoveryExtensions, ext) +} + +// GetAWSConfig returns the provider's AWS configuration. +// Used by EE extensions that need direct AWS SDK access. +func (p *Provider) GetAWSConfig() aws.Config { + return p.awsConfig +} + +// Config holds the configuration for an AWS provider instance. +type Config struct { + // ID uniquely identifies this provider instance. + ID string + + // Name is a human-readable name (e.g., "Production AWS", "Dev Account"). + Name string + + // Region is the AWS region to discover resources in. + Region string + + // AuthMethod determines how to authenticate with AWS. + AuthMethod awsinfra.AuthMethod + + // ProfileName for profile auth. + ProfileName string + + // DiscoverRDS enables RDS instance discovery. + DiscoverRDS bool + + // DiscoverElastiCache enables ElastiCache cluster discovery. + DiscoverElastiCache bool + + // DiscoverDocumentDB enables DocumentDB cluster discovery. + DiscoverDocumentDB bool +} + +// String returns a safe string representation that excludes sensitive credentials. +// This prevents accidental logging of AccessKeyID, SecretAccessKey, and SessionToken. +func (c *Config) String() string { + return fmt.Sprintf("Config{ID:%s, Name:%s, Region:%s, AuthMethod:%s, ProfileName:%s, DiscoverRDS:%t, DiscoverElastiCache:%t, DiscoverDocumentDB:%t}", + c.ID, c.Name, c.Region, c.AuthMethod, c.ProfileName, c.DiscoverRDS, c.DiscoverElastiCache, c.DiscoverDocumentDB) +} + +// DefaultConfig returns a Config with sensible defaults. +func DefaultConfig(id, name, region string) *Config { + return &Config{ + ID: id, + Name: name, + Region: region, + AuthMethod: awsinfra.AuthMethodDefault, + DiscoverRDS: true, + DiscoverElastiCache: true, + DiscoverDocumentDB: true, + } +} + +// Provider implements providers.ConnectionProvider for AWS. +// Provider implements providers.ConnectionProvider for AWS. +type Provider struct { + config *Config + awsConfig aws.Config + + rdsClient *rds.Client + elasticacheClient *elasticache.Client + docdbClient *docdb.Client + + initMu sync.Mutex + initialized bool +} + +// New creates a new AWS provider with the given configuration. +func New(config *Config) (*Provider, error) { + if config == nil { + return nil, errors.New("config is required") + } + if config.ID == "" { + return nil, errors.New("provider ID is required") + } + if config.Region == "" { + return nil, errors.New("region is required") + } + + return &Provider{ + config: config, + }, nil +} + +// Type implements providers.ConnectionProvider. +func (p *Provider) Type() providers.ProviderType { + return providers.ProviderTypeAWS +} + +// ID implements providers.ConnectionProvider. +func (p *Provider) ID() string { + return p.config.ID +} + +// Name implements providers.ConnectionProvider. +func (p *Provider) Name() string { + return p.config.Name +} + +func (p *Provider) initialize(ctx context.Context) error { + p.initMu.Lock() + defer p.initMu.Unlock() + + if p.initialized { + return nil + } + + creds := p.buildInternalCredentials() + + cfg, err := awsinfra.LoadAWSConfig(ctx, creds) + if err != nil { + return fmt.Errorf("failed to load AWS config: %w", err) + } + + p.awsConfig = cfg + + if p.config.DiscoverRDS { + p.rdsClient = rds.NewFromConfig(cfg) + } + if p.config.DiscoverElastiCache { + p.elasticacheClient = elasticache.NewFromConfig(cfg) + } + if p.config.DiscoverDocumentDB { + p.docdbClient = docdb.NewFromConfig(cfg) + } + + p.initialized = true + return nil +} + +func (p *Provider) buildInternalCredentials() *engine.Credentials { + creds := &engine.Credentials{ + Hostname: p.config.Region, + } + + var advanced []engine.Record + advanced = append(advanced, engine.Record{ + Key: awsinfra.AdvancedKeyAuthMethod, + Value: string(p.config.AuthMethod), + }) + + if p.config.ProfileName != "" { + advanced = append(advanced, engine.Record{ + Key: awsinfra.AdvancedKeyProfileName, + Value: p.config.ProfileName, + }) + } + + creds.Advanced = advanced + return creds +} + +// discoveryResult holds the outcome of a single service discovery. +type discoveryResult struct { + conns []providers.DiscoveredConnection + err error + name string +} + +// DiscoverConnections implements providers.ConnectionProvider. +// Discovery runs in parallel across all enabled services for faster results. +func (p *Provider) DiscoverConnections(ctx context.Context) ([]providers.DiscoveredConnection, error) { + log.Infof("AWS Provider DiscoverConnections called for id=%s, region=%s, authMethod=%s, profileName=%s", + p.config.ID, p.config.Region, p.config.AuthMethod, p.config.ProfileName) + + if err := p.initialize(ctx); err != nil { + log.Errorf("AWS Provider initialize failed: %v", err) + return nil, err + } + log.Infof("AWS Provider initialized successfully, rdsClient=%v", p.rdsClient != nil) + + // Count how many discovery tasks we'll run + taskCount := len(discoveryExtensions) + if p.config.DiscoverRDS && p.rdsClient != nil { + taskCount += 3 // instances, clusters, proxies + } + if p.config.DiscoverElastiCache && p.elasticacheClient != nil { + taskCount++ + } + if p.config.DiscoverDocumentDB && p.docdbClient != nil { + taskCount++ + } + + if taskCount == 0 { + return nil, nil + } + + results := make(chan discoveryResult, taskCount) + g, gctx := errgroup.WithContext(ctx) + + if p.config.DiscoverRDS && p.rdsClient != nil { + g.Go(func() error { + conns, err := p.discoverRDS(gctx) + results <- discoveryResult{conns, err, "RDS"} + return nil + }) + g.Go(func() error { + conns, err := p.discoverRDSClusters(gctx) + results <- discoveryResult{conns, err, "RDS Clusters"} + return nil + }) + g.Go(func() error { + conns, err := p.discoverRDSProxies(gctx) + results <- discoveryResult{conns, err, "RDS Proxies"} + return nil + }) + } + + if p.config.DiscoverElastiCache && p.elasticacheClient != nil { + g.Go(func() error { + conns, err := p.discoverElastiCache(gctx) + results <- discoveryResult{conns, err, "ElastiCache"} + return nil + }) + } + + if p.config.DiscoverDocumentDB && p.docdbClient != nil { + g.Go(func() error { + conns, err := p.discoverDocumentDB(gctx) + results <- discoveryResult{conns, err, "DocumentDB"} + return nil + }) + } + + for _, ext := range discoveryExtensions { + ext := ext + g.Go(func() error { + conns, err := ext(gctx, p) + results <- discoveryResult{conns, err, "extension"} + return nil + }) + } + + _ = g.Wait() + close(results) + + var allConns []providers.DiscoveredConnection + var allErrs []error + for r := range results { + if r.err != nil { + log.Errorf("AWS Provider: %s discovery failed: %v", r.name, r.err) + allErrs = append(allErrs, fmt.Errorf("%s: %w", r.name, r.err)) + } else { + log.Infof("AWS Provider: %s discovery found %d resources", r.name, len(r.conns)) + allConns = append(allConns, r.conns...) + } + } + + if len(allErrs) > 0 { + return allConns, errors.Join(allErrs...) + } + return allConns, nil +} + +// TestConnection implements providers.ConnectionProvider. +func (p *Provider) TestConnection(ctx context.Context) error { + if err := p.initialize(ctx); err != nil { + return err + } + + stsClient := sts.NewFromConfig(p.awsConfig) + validateCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + _, err := stsClient.GetCallerIdentity(validateCtx, &sts.GetCallerIdentityInput{}) + if err != nil { + return awsinfra.HandleAWSError(err) + } + return nil +} + +// RefreshConnection implements providers.ConnectionProvider. +// This is intentionally a no-op — the AWS SDK handles credential refresh internally +// through the configured credential chain (env, shared config, IAM role). +func (p *Provider) RefreshConnection(ctx context.Context, connectionID string) (bool, error) { + return false, nil +} + +// Close implements providers.ConnectionProvider. +// AWS SDK clients don't require explicit cleanup. +func (p *Provider) Close(ctx context.Context) error { + return nil +} + +// GetConfig returns the provider's configuration. +func (p *Provider) GetConfig() *Config { + return p.config +} + +// connectionID generates a unique connection ID. +func (p *Provider) connectionID(resourceID string) string { + return fmt.Sprintf("%s/%s", p.config.ID, resourceID) +} diff --git a/dataflow/core/src/providers/aws/provider_test.go b/dataflow/core/src/providers/aws/provider_test.go new file mode 100644 index 0000000..edf762c --- /dev/null +++ b/dataflow/core/src/providers/aws/provider_test.go @@ -0,0 +1,187 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "testing" + + awsinfra "github.com/clidey/whodb/core/src/aws" + "github.com/clidey/whodb/core/src/providers" +) + +func TestNew_ValidConfig(t *testing.T) { + config := &Config{ + ID: "aws-us-west-2", + Name: "Test AWS", + Region: "us-west-2", + } + + p, err := New(config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if p.Type() != providers.ProviderTypeAWS { + t.Errorf("expected type %s, got %s", providers.ProviderTypeAWS, p.Type()) + } + if p.ID() != "aws-us-west-2" { + t.Errorf("expected ID aws-us-west-2, got %s", p.ID()) + } + if p.Name() != "Test AWS" { + t.Errorf("expected Name Test AWS, got %s", p.Name()) + } +} + +func TestNew_NilConfig(t *testing.T) { + _, err := New(nil) + if err == nil { + t.Error("expected error for nil config") + } +} + +func TestNew_MissingID(t *testing.T) { + config := &Config{ + Name: "Test AWS", + Region: "us-west-2", + } + + _, err := New(config) + if err == nil { + t.Error("expected error for missing ID") + } +} + +func TestNew_MissingRegion(t *testing.T) { + config := &Config{ + ID: "aws-us-west-2", + Name: "Test AWS", + } + + _, err := New(config) + if err == nil { + t.Error("expected error for missing region") + } +} + +func TestDefaultConfig(t *testing.T) { + config := DefaultConfig("aws-us-west-2", "Test AWS", "us-west-2") + + if config.ID != "aws-us-west-2" { + t.Errorf("expected ID aws-us-west-2, got %s", config.ID) + } + if config.Name != "Test AWS" { + t.Errorf("expected Name Test AWS, got %s", config.Name) + } + if config.Region != "us-west-2" { + t.Errorf("expected Region us-west-2, got %s", config.Region) + } + if config.AuthMethod != awsinfra.AuthMethodDefault { + t.Errorf("expected AuthMethod default, got %s", config.AuthMethod) + } + if !config.DiscoverRDS { + t.Error("expected DiscoverRDS to be true") + } + if !config.DiscoverElastiCache { + t.Error("expected DiscoverElastiCache to be true") + } + if !config.DiscoverDocumentDB { + t.Error("expected DiscoverDocumentDB to be true") + } +} + +func TestProvider_ConnectionID(t *testing.T) { + p, _ := New(&Config{ + ID: "aws-us-west-2", + Name: "Test AWS", + Region: "us-west-2", + }) + + connID := p.connectionID("my-rds-instance") + expected := "aws-us-west-2/my-rds-instance" + if connID != expected { + t.Errorf("expected %s, got %s", expected, connID) + } +} + +func TestProvider_BuildInternalCredentials(t *testing.T) { + p, _ := New(&Config{ + ID: "aws-us-west-2", + Name: "Test AWS", + Region: "us-west-2", + AuthMethod: awsinfra.AuthMethodDefault, + }) + + creds := p.buildInternalCredentials() + + if creds.Hostname != "us-west-2" { + t.Errorf("expected Hostname us-west-2, got %s", creds.Hostname) + } + + // Check advanced records + authMethodFound := false + for _, r := range creds.Advanced { + if r.Key == awsinfra.AdvancedKeyAuthMethod && r.Value == "default" { + authMethodFound = true + } + } + if !authMethodFound { + t.Error("expected auth method in advanced records") + } +} + +func TestProvider_BuildInternalCredentials_Profile(t *testing.T) { + p, _ := New(&Config{ + ID: "aws-us-west-2", + Name: "Test AWS", + Region: "us-west-2", + AuthMethod: awsinfra.AuthMethodProfile, + ProfileName: "production", + }) + + creds := p.buildInternalCredentials() + + profileFound := false + for _, r := range creds.Advanced { + if r.Key == awsinfra.AdvancedKeyProfileName && r.Value == "production" { + profileFound = true + } + } + if !profileFound { + t.Error("expected profile name in advanced records") + } +} + +func TestConfig_DiscoveryFlags(t *testing.T) { + config := &Config{ + ID: "test", + Name: "Test", + Region: "us-east-1", + DiscoverRDS: true, + DiscoverElastiCache: false, + DiscoverDocumentDB: true, + } + + if !config.DiscoverRDS { + t.Error("expected DiscoverRDS to be true") + } + if config.DiscoverElastiCache { + t.Error("expected DiscoverElastiCache to be false") + } + if !config.DiscoverDocumentDB { + t.Error("expected DiscoverDocumentDB to be true") + } +} diff --git a/dataflow/core/src/providers/aws/rds.go b/dataflow/core/src/providers/aws/rds.go new file mode 100644 index 0000000..533f940 --- /dev/null +++ b/dataflow/core/src/providers/aws/rds.go @@ -0,0 +1,388 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "context" + "strconv" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/rds" + rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" + + awsinfra "github.com/clidey/whodb/core/src/aws" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/providers" +) + +func (p *Provider) discoverRDS(ctx context.Context) ([]providers.DiscoveredConnection, error) { + var connections []providers.DiscoveredConnection + var nextToken *string + + log.Debugf("RDS discoverRDS: starting discovery for provider %s", p.config.ID) + + for page := 0; page < maxPaginationPages; page++ { + input := &rds.DescribeDBInstancesInput{ + Marker: nextToken, + MaxRecords: aws.Int32(50), + } + + log.Debugf("RDS discoverRDS: calling DescribeDBInstances") + output, err := p.rdsClient.DescribeDBInstances(ctx, input) + if err != nil { + log.Errorf("RDS discoverRDS: DescribeDBInstances failed: %v", err) + return nil, awsinfra.HandleAWSError(err) + } + log.Debugf("RDS discoverRDS: DescribeDBInstances returned %d instances", len(output.DBInstances)) + + for _, instance := range output.DBInstances { + instanceID := aws.ToString(instance.DBInstanceIdentifier) + engineName := aws.ToString(instance.Engine) + log.Debugf("RDS discoverRDS: processing instance %s (engine=%s)", instanceID, engineName) + + conn := p.rdsInstanceToConnection(&instance) + if conn != nil { + log.Debugf("RDS discoverRDS: instance %s converted to connection (type=%s)", instanceID, conn.DatabaseType) + connections = append(connections, *conn) + } else if isEngineHandledByDedicatedDiscovery(engineName) { + log.Debugf("RDS discoverRDS: skipping instance %s (engine=%s handled by dedicated discovery)", instanceID, engineName) + } else { + log.Warnf("RDS discoverRDS: instance %s could not be converted (engine=%s not supported)", instanceID, engineName) + } + } + + if output.Marker == nil { + break + } + nextToken = output.Marker + } + + log.Debugf("RDS discoverRDS: completed, found %d connections", len(connections)) + return connections, nil +} + +func (p *Provider) rdsInstanceToConnection(instance *rdstypes.DBInstance) *providers.DiscoveredConnection { + if instance.Engine == nil || instance.DBInstanceIdentifier == nil { + return nil + } + + dbType, ok := mapRDSEngine(*instance.Engine) + if !ok { + return nil + } + + if instance.Endpoint == nil { + log.Warnf("RDS: instance %s has no endpoint, skipping", *instance.DBInstanceIdentifier) + return nil + } + + metadata := make(map[string]string) + metadata["endpoint"] = aws.ToString(instance.Endpoint.Address) + metadata["port"] = strconv.Itoa(int(aws.ToInt32(instance.Endpoint.Port))) + + if instance.DBName != nil { + metadata["databaseName"] = *instance.DBName + } + + if aws.ToBool(instance.IAMDatabaseAuthenticationEnabled) { + metadata["iamAuthEnabled"] = "true" + } + + return &providers.DiscoveredConnection{ + ID: p.connectionID(*instance.DBInstanceIdentifier), + ProviderType: providers.ProviderTypeAWS, + ProviderID: p.config.ID, + Name: *instance.DBInstanceIdentifier, + DatabaseType: dbType, + Region: p.config.Region, + Status: mapRDSStatus(instance.DBInstanceStatus), + Metadata: metadata, + } +} + +// discoverRDSClusters discovers Aurora cluster-level endpoints (writer and reader). +func (p *Provider) discoverRDSClusters(ctx context.Context) ([]providers.DiscoveredConnection, error) { + var connections []providers.DiscoveredConnection + var nextToken *string + + log.Debugf("RDS discoverRDSClusters: starting for provider %s", p.config.ID) + + for page := 0; page < maxPaginationPages; page++ { + input := &rds.DescribeDBClustersInput{ + Marker: nextToken, + MaxRecords: aws.Int32(50), + } + + output, err := p.rdsClient.DescribeDBClusters(ctx, input) + if err != nil { + log.Errorf("RDS discoverRDSClusters: DescribeDBClusters failed: %v", err) + return nil, awsinfra.HandleAWSError(err) + } + + log.Debugf("RDS discoverRDSClusters: DescribeDBClusters returned %d clusters", len(output.DBClusters)) + + for _, cluster := range output.DBClusters { + if cluster.Engine == nil || cluster.DBClusterIdentifier == nil { + continue + } + + dbType, ok := mapRDSEngine(*cluster.Engine) + if !ok { + log.Debugf("RDS discoverRDSClusters: skipping cluster %s (engine=%s not supported)", aws.ToString(cluster.DBClusterIdentifier), aws.ToString(cluster.Engine)) + continue + } + + clusterID := aws.ToString(cluster.DBClusterIdentifier) + log.Debugf("RDS discoverRDSClusters: processing cluster %s (engine=%s)", clusterID, aws.ToString(cluster.Engine)) + + // Writer endpoint + if cluster.Endpoint != nil { + port := int32(0) + if cluster.Port != nil { + port = *cluster.Port + } + metadata := map[string]string{ + "endpoint": *cluster.Endpoint, + "port": strconv.Itoa(int(port)), + "endpointType": "writer", + "clusterIdentifier": clusterID, + } + if aws.ToBool(cluster.IAMDatabaseAuthenticationEnabled) { + metadata["iamAuthEnabled"] = "true" + } + connections = append(connections, providers.DiscoveredConnection{ + ID: p.connectionID(clusterID + "-writer"), + ProviderType: providers.ProviderTypeAWS, + ProviderID: p.config.ID, + Name: clusterID + " (writer)", + DatabaseType: dbType, + Region: p.config.Region, + Status: mapRDSStatus(cluster.Status), + Metadata: metadata, + }) + } + + // Reader endpoint + if cluster.ReaderEndpoint != nil { + port := int32(0) + if cluster.Port != nil { + port = *cluster.Port + } + metadata := map[string]string{ + "endpoint": *cluster.ReaderEndpoint, + "port": strconv.Itoa(int(port)), + "endpointType": "reader", + "clusterIdentifier": clusterID, + } + if aws.ToBool(cluster.IAMDatabaseAuthenticationEnabled) { + metadata["iamAuthEnabled"] = "true" + } + connections = append(connections, providers.DiscoveredConnection{ + ID: p.connectionID(clusterID + "-reader"), + ProviderType: providers.ProviderTypeAWS, + ProviderID: p.config.ID, + Name: clusterID + " (reader)", + DatabaseType: dbType, + Region: p.config.Region, + Status: mapRDSStatus(cluster.Status), + Metadata: metadata, + }) + } + } + + if output.Marker == nil { + break + } + nextToken = output.Marker + } + + log.Debugf("RDS discoverRDSClusters: found %d cluster endpoints", len(connections)) + return connections, nil +} + +// discoverRDSProxies discovers RDS Proxy endpoints. +func (p *Provider) discoverRDSProxies(ctx context.Context) ([]providers.DiscoveredConnection, error) { + var connections []providers.DiscoveredConnection + var nextToken *string + + log.Debugf("RDS discoverRDSProxies: starting for provider %s", p.config.ID) + + for page := 0; page < maxPaginationPages; page++ { + input := &rds.DescribeDBProxiesInput{ + Marker: nextToken, + MaxRecords: aws.Int32(50), + } + + output, err := p.rdsClient.DescribeDBProxies(ctx, input) + if err != nil { + log.Errorf("RDS discoverRDSProxies: DescribeDBProxies failed: %v", err) + return nil, awsinfra.HandleAWSError(err) + } + + log.Debugf("RDS discoverRDSProxies: DescribeDBProxies returned %d proxies", len(output.DBProxies)) + + for _, proxy := range output.DBProxies { + if proxy.DBProxyName == nil || proxy.Endpoint == nil { + continue + } + + dbType := mapProxyEngineFamily(aws.ToString(proxy.EngineFamily)) + if dbType == "" { + log.Debugf("RDS discoverRDSProxies: skipping proxy %s (engine=%s not supported)", aws.ToString(proxy.DBProxyName), aws.ToString(proxy.EngineFamily)) + continue + } + log.Debugf("RDS discoverRDSProxies: processing proxy %s (engine=%s, status=%s)", aws.ToString(proxy.DBProxyName), aws.ToString(proxy.EngineFamily), proxy.Status) + + defaultPort := "3306" + if dbType == engine.DatabaseType_Postgres { + defaultPort = "5432" + } + + metadata := map[string]string{ + "endpoint": *proxy.Endpoint, + "port": defaultPort, + "endpointType": "proxy", + "proxyName": *proxy.DBProxyName, + } + if aws.ToBool(proxy.RequireTLS) { + metadata["requireTLS"] = "true" + } + + connections = append(connections, providers.DiscoveredConnection{ + ID: p.connectionID("proxy-" + *proxy.DBProxyName), + ProviderType: providers.ProviderTypeAWS, + ProviderID: p.config.ID, + Name: *proxy.DBProxyName + " (proxy)", + DatabaseType: dbType, + Region: p.config.Region, + Status: mapProxyStatus(string(proxy.Status)), + Metadata: metadata, + }) + } + + if output.Marker == nil { + break + } + nextToken = output.Marker + } + + log.Debugf("RDS discoverRDSProxies: found %d proxies", len(connections)) + return connections, nil +} + +var mapRDSEngineExtension func(string) (engine.DatabaseType, bool) + +// SetRDSEngineMapper registers an extension function for mapping RDS engine names +// to database types. This allows EE to add support for additional engines without modifying CE code +func SetRDSEngineMapper(fn func(string) (engine.DatabaseType, bool)) { + mapRDSEngineExtension = fn +} + +// isEngineHandledByDedicatedDiscovery returns true for engines that have their own +// dedicated AWS discovery mechanism and should be silently skipped in RDS discovery. +func isEngineHandledByDedicatedDiscovery(engineName string) bool { + engineName = strings.ToLower(engineName) + switch { + case strings.HasPrefix(engineName, "docdb"): + // DocumentDB clusters are discovered via discoverDocumentDB() + return true + case strings.HasPrefix(engineName, "neptune"): + // Neptune is a graph database with its own API (not currently supported) + return true + default: + return false + } +} + +func mapRDSEngine(engineName string) (engine.DatabaseType, bool) { + engineName = strings.ToLower(engineName) + + switch { + case engineName == "mysql" || strings.HasPrefix(engineName, "mysql-"): + return engine.DatabaseType_MySQL, true + case engineName == "mariadb" || strings.HasPrefix(engineName, "mariadb-"): + return engine.DatabaseType_MariaDB, true + case engineName == "postgres" || engineName == "postgresql" || + strings.HasPrefix(engineName, "postgres-") || strings.HasPrefix(engineName, "postgresql-"): + return engine.DatabaseType_Postgres, true + case strings.HasPrefix(engineName, "aurora-mysql"): + return engine.DatabaseType_MySQL, true + case strings.HasPrefix(engineName, "aurora-postgresql"): + return engine.DatabaseType_Postgres, true + } + + if mapRDSEngineExtension != nil { + return mapRDSEngineExtension(engineName) + } + + return "", false +} + +// mapProxyEngineFamily maps RDS Proxy EngineFamily to DatabaseType. +func mapProxyEngineFamily(family string) engine.DatabaseType { + switch strings.ToUpper(family) { + case "MYSQL": + return engine.DatabaseType_MySQL + case "POSTGRESQL": + return engine.DatabaseType_Postgres + default: + if mapRDSEngineExtension != nil { + if dbType, ok := mapRDSEngineExtension(strings.ToLower(family)); ok { + return dbType + } + } + return "" + } +} + +func mapProxyStatus(status string) providers.ConnectionStatus { + switch strings.ToLower(status) { + case "available": + return providers.ConnectionStatusAvailable + case "creating", "modifying": + return providers.ConnectionStatusStarting + case "deleting": + return providers.ConnectionStatusDeleting + case "incompatible-network", "insufficient-resource-limits": + return providers.ConnectionStatusFailed + default: + return providers.ConnectionStatusUnknown + } +} + +func mapRDSStatus(status *string) providers.ConnectionStatus { + if status == nil { + return providers.ConnectionStatusUnknown + } + + switch strings.ToLower(*status) { + case "available": + return providers.ConnectionStatusAvailable + case "starting", "creating", "configuring-enhanced-monitoring", "modifying", "upgrading": + return providers.ConnectionStatusStarting + case "stopped", "stopping", "storage-optimization": + return providers.ConnectionStatusStopped + case "deleting": + return providers.ConnectionStatusDeleting + case "failed", "restore-error", "incompatible-credentials", "incompatible-parameters", "incompatible-options": + return providers.ConnectionStatusFailed + default: + return providers.ConnectionStatusUnknown + } +} diff --git a/dataflow/core/src/providers/aws/rds_test.go b/dataflow/core/src/providers/aws/rds_test.go new file mode 100644 index 0000000..d91bee5 --- /dev/null +++ b/dataflow/core/src/providers/aws/rds_test.go @@ -0,0 +1,321 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package aws + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/providers" +) + +func TestMapRDSEngine(t *testing.T) { + testCases := []struct { + engine string + expected engine.DatabaseType + ok bool + }{ + {"mysql", engine.DatabaseType_MySQL, true}, + {"MySQL", engine.DatabaseType_MySQL, true}, + {"mariadb", engine.DatabaseType_MariaDB, true}, + {"postgres", engine.DatabaseType_Postgres, true}, + {"postgresql", engine.DatabaseType_Postgres, true}, + {"aurora-mysql", engine.DatabaseType_MySQL, true}, + {"aurora-postgresql", engine.DatabaseType_Postgres, true}, + {"sqlserver-se", "", false}, // SQL Server not in CE + {"oracle-ee", "", false}, // Oracle not in CE + {"docdb", "", false}, // DocumentDB handled separately + {"unknown-engine", "", false}, + } + + for _, tc := range testCases { + dbType, ok := mapRDSEngine(tc.engine) + if ok != tc.ok { + t.Errorf("mapRDSEngine(%s): expected ok=%v, got ok=%v", tc.engine, tc.ok, ok) + continue + } + if ok && dbType != tc.expected { + t.Errorf("mapRDSEngine(%s): expected %s, got %s", tc.engine, tc.expected, dbType) + } + } +} + +func TestMapRDSStatus(t *testing.T) { + testCases := []struct { + status string + expected providers.ConnectionStatus + }{ + {"available", providers.ConnectionStatusAvailable}, + {"Available", providers.ConnectionStatusAvailable}, + {"AVAILABLE", providers.ConnectionStatusAvailable}, + {"starting", providers.ConnectionStatusStarting}, + {"creating", providers.ConnectionStatusStarting}, + {"configuring-enhanced-monitoring", providers.ConnectionStatusStarting}, + {"modifying", providers.ConnectionStatusStarting}, + {"upgrading", providers.ConnectionStatusStarting}, + {"stopped", providers.ConnectionStatusStopped}, + {"stopping", providers.ConnectionStatusStopped}, + {"storage-optimization", providers.ConnectionStatusStopped}, + {"deleting", providers.ConnectionStatusDeleting}, + {"failed", providers.ConnectionStatusFailed}, + {"restore-error", providers.ConnectionStatusFailed}, + {"incompatible-credentials", providers.ConnectionStatusFailed}, + {"incompatible-parameters", providers.ConnectionStatusFailed}, + {"incompatible-options", providers.ConnectionStatusFailed}, + {"unknown-status", providers.ConnectionStatusUnknown}, + {"", providers.ConnectionStatusUnknown}, + } + + for _, tc := range testCases { + status := tc.status + result := mapRDSStatus(&status) + if result != tc.expected { + t.Errorf("mapRDSStatus(%s): expected %s, got %s", tc.status, tc.expected, result) + } + } + + // Test nil status + result := mapRDSStatus(nil) + if result != providers.ConnectionStatusUnknown { + t.Errorf("mapRDSStatus(nil): expected %s, got %s", providers.ConnectionStatusUnknown, result) + } +} + +func TestMapRDSEngine_CaseInsensitive(t *testing.T) { + engines := []string{"mysql", "MySQL", "MYSQL", "MysQL"} + for _, eng := range engines { + dbType, ok := mapRDSEngine(eng) + if !ok { + t.Errorf("mapRDSEngine(%s): expected to match", eng) + continue + } + if dbType != engine.DatabaseType_MySQL { + t.Errorf("mapRDSEngine(%s): expected MySQL, got %s", eng, dbType) + } + } +} + +func TestMapRDSEngine_AuroraVariants(t *testing.T) { + // Aurora MySQL variants + mysqlVariants := []string{ + "aurora-mysql", + "aurora-mysql-5.7", + "aurora-mysql-8.0", + } + for _, eng := range mysqlVariants { + dbType, ok := mapRDSEngine(eng) + if !ok { + t.Errorf("mapRDSEngine(%s): expected to match", eng) + continue + } + if dbType != engine.DatabaseType_MySQL { + t.Errorf("mapRDSEngine(%s): expected MySQL, got %s", eng, dbType) + } + } + + // Aurora PostgreSQL variants + pgVariants := []string{ + "aurora-postgresql", + "aurora-postgresql-13", + "aurora-postgresql-14", + } + for _, eng := range pgVariants { + dbType, ok := mapRDSEngine(eng) + if !ok { + t.Errorf("mapRDSEngine(%s): expected to match", eng) + continue + } + if dbType != engine.DatabaseType_Postgres { + t.Errorf("mapRDSEngine(%s): expected Postgres, got %s", eng, dbType) + } + } +} + +func newTestRDSProvider() *Provider { + p, _ := New(&Config{ + ID: "test-rds", + Name: "Test RDS", + Region: "us-west-2", + DiscoverRDS: true, + }) + return p +} + +func TestRDSInstanceToConnection_HappyPath(t *testing.T) { + p := newTestRDSProvider() + status := "available" + instance := &rdstypes.DBInstance{ + DBInstanceIdentifier: aws.String("my-postgres"), + Engine: aws.String("postgres"), + DBInstanceStatus: &status, + Endpoint: &rdstypes.Endpoint{ + Address: aws.String("my-postgres.abc.us-west-2.rds.amazonaws.com"), + Port: aws.Int32(5432), + }, + DBName: aws.String("mydb"), + } + + conn := p.rdsInstanceToConnection(instance) + if conn == nil { + t.Fatal("expected non-nil connection") + } + if conn.DatabaseType != engine.DatabaseType_Postgres { + t.Errorf("expected Postgres, got %s", conn.DatabaseType) + } + if conn.Metadata["endpoint"] != "my-postgres.abc.us-west-2.rds.amazonaws.com" { + t.Errorf("unexpected endpoint: %s", conn.Metadata["endpoint"]) + } + if conn.Metadata["port"] != "5432" { + t.Errorf("unexpected port: %s", conn.Metadata["port"]) + } + if conn.Metadata["databaseName"] != "mydb" { + t.Errorf("unexpected databaseName: %s", conn.Metadata["databaseName"]) + } + if conn.Status != providers.ConnectionStatusAvailable { + t.Errorf("expected Available status, got %s", conn.Status) + } +} + +func TestRDSInstanceToConnection_NilEngine(t *testing.T) { + p := newTestRDSProvider() + instance := &rdstypes.DBInstance{ + DBInstanceIdentifier: aws.String("my-instance"), + Engine: nil, + } + + conn := p.rdsInstanceToConnection(instance) + if conn != nil { + t.Error("expected nil for nil engine") + } +} + +func TestRDSInstanceToConnection_NilID(t *testing.T) { + p := newTestRDSProvider() + instance := &rdstypes.DBInstance{ + DBInstanceIdentifier: nil, + Engine: aws.String("postgres"), + } + + conn := p.rdsInstanceToConnection(instance) + if conn != nil { + t.Error("expected nil for nil ID") + } +} + +func TestRDSInstanceToConnection_UnsupportedEngine(t *testing.T) { + p := newTestRDSProvider() + instance := &rdstypes.DBInstance{ + DBInstanceIdentifier: aws.String("my-oracle"), + Engine: aws.String("oracle-ee"), + } + + conn := p.rdsInstanceToConnection(instance) + if conn != nil { + t.Error("expected nil for unsupported engine") + } +} + +func TestRDSInstanceToConnection_IAMAuth(t *testing.T) { + p := newTestRDSProvider() + status := "available" + instance := &rdstypes.DBInstance{ + DBInstanceIdentifier: aws.String("my-iam-postgres"), + Engine: aws.String("postgres"), + DBInstanceStatus: &status, + IAMDatabaseAuthenticationEnabled: aws.Bool(true), + Endpoint: &rdstypes.Endpoint{ + Address: aws.String("iam-db.abc.us-west-2.rds.amazonaws.com"), + Port: aws.Int32(5432), + }, + } + + conn := p.rdsInstanceToConnection(instance) + if conn == nil { + t.Fatal("expected non-nil connection") + } + if conn.Metadata["iamAuthEnabled"] != "true" { + t.Error("expected iamAuthEnabled=true in metadata") + } +} + +func TestMapProxyEngineFamily(t *testing.T) { + testCases := []struct { + family string + expected engine.DatabaseType + }{ + {"MYSQL", engine.DatabaseType_MySQL}, + {"mysql", engine.DatabaseType_MySQL}, + {"POSTGRESQL", engine.DatabaseType_Postgres}, + {"postgresql", engine.DatabaseType_Postgres}, + {"PostgreSQL", engine.DatabaseType_Postgres}, + } + + for _, tc := range testCases { + dbType := mapProxyEngineFamily(tc.family) + if dbType != tc.expected { + t.Errorf("mapProxyEngineFamily(%s): expected %s, got %s", tc.family, tc.expected, dbType) + } + } +} + +func TestMapProxyEngineFamily_Unsupported(t *testing.T) { + // SQL Server not in CE + dbType := mapProxyEngineFamily("SQLSERVERCE") + if dbType != "" { + t.Errorf("expected empty for unsupported engine, got %s", dbType) + } +} + +func TestMapProxyStatus(t *testing.T) { + testCases := []struct { + status string + expected providers.ConnectionStatus + }{ + {"available", providers.ConnectionStatusAvailable}, + {"creating", providers.ConnectionStatusStarting}, + {"modifying", providers.ConnectionStatusStarting}, + {"deleting", providers.ConnectionStatusDeleting}, + {"incompatible-network", providers.ConnectionStatusFailed}, + {"insufficient-resource-limits", providers.ConnectionStatusFailed}, + {"suspended", providers.ConnectionStatusUnknown}, + {"unknown", providers.ConnectionStatusUnknown}, + } + + for _, tc := range testCases { + result := mapProxyStatus(tc.status) + if result != tc.expected { + t.Errorf("mapProxyStatus(%s): expected %s, got %s", tc.status, tc.expected, result) + } + } +} + +func TestRDSInstanceToConnection_NilEndpoint(t *testing.T) { + p := newTestRDSProvider() + instance := &rdstypes.DBInstance{ + DBInstanceIdentifier: aws.String("my-postgres"), + Engine: aws.String("postgres"), + Endpoint: nil, + } + + conn := p.rdsInstanceToConnection(instance) + if conn != nil { + t.Error("expected nil for nil endpoint") + } +} diff --git a/dataflow/core/src/providers/provider.go b/dataflow/core/src/providers/provider.go new file mode 100644 index 0000000..47f4aef --- /dev/null +++ b/dataflow/core/src/providers/provider.go @@ -0,0 +1,158 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +// Package providers defines the ConnectionProvider interface for database connection sources. +// +// A ConnectionProvider is a source of database connections. It abstracts where connection +// credentials come from - whether manually entered by users or auto-discovered from cloud +// platforms like AWS. +// +// The key insight is that a provider is a "credential factory". It discovers available +// databases and produces standard engine.Credentials that existing database plugins understand. +// A MySQL plugin doesn't know or care if credentials came from AWS RDS or manual entry - +// it just receives standard credentials and connects. +// +// Built-in providers: +// - Manual: Wraps user-entered credentials (the default behavior) +// - AWS: Discovers RDS, ElastiCache, DocumentDB instances and generates credentials +// +// Example flow: +// +// 1. User configures AWS provider with region and auth method +// 2. AWS provider discovers RDS MySQL instance "prod-db" +// 3. User clicks "prod-db" in UI +// 4. AWS provider builds engine.Credentials with hostname, IAM auth token, etc. +// 5. Existing MySQL plugin receives credentials and connects normally +package providers + +import ( + "context" + + "github.com/clidey/whodb/core/src/engine" +) + +// ProviderType identifies a connection provider type. +type ProviderType string + +const ( + // ProviderTypeManual represents manually-entered connections. + ProviderTypeManual ProviderType = "manual" + + // ProviderTypeAWS represents AWS-discovered connections. + ProviderTypeAWS ProviderType = "aws" +) + +// ConnectionProvider discovers and builds credentials for database connections. +// Implementations must be safe for concurrent use. +type ConnectionProvider interface { + // Type returns the provider type (e.g., "manual", "aws"). + Type() ProviderType + + // ID returns the unique identifier for this provider instance. + // For manual provider, this might be "default". + // For AWS provider, this identifies the specific AWS account/region combo. + ID() string + + // Name returns a human-readable name for this provider. + Name() string + + // DiscoverConnections returns all available connections from this provider. + // The result should be cached by the caller - implementations may make + // expensive API calls each time. + DiscoverConnections(ctx context.Context) ([]DiscoveredConnection, error) + + // TestConnection verifies the provider can access its source. + // For AWS, this tests AWS API access. For manual, this always succeeds. + TestConnection(ctx context.Context) error + + // RefreshConnection refreshes credentials for a connection if needed. + // Some providers (like AWS with IAM auth) need periodic credential refresh. + // Returns true if credentials were refreshed, false if no refresh needed. + RefreshConnection(ctx context.Context, connectionID string) (bool, error) + + // Close releases any resources held by the provider. + Close(ctx context.Context) error +} + +// DiscoveredConnection represents a database found by a provider. +type DiscoveredConnection struct { + // ID uniquely identifies this connection within the provider. + // Format: providerID/connectionID (e.g., "aws-us-west-2/prod-mysql") + ID string + + // ProviderType identifies the provider that discovered this connection. + ProviderType ProviderType + + // ProviderID identifies the specific provider instance. + ProviderID string + + // Name is the display name for the connection (e.g., "prod-mysql"). + Name string + + // DatabaseType maps to existing engine.DatabaseType constants. + // This determines which plugin handles the connection. + DatabaseType engine.DatabaseType + + // Region indicates the geographic location (for cloud resources). + Region string + + // Status indicates the current state of the resource. + Status ConnectionStatus + + // Metadata contains provider-specific details. + // For AWS RDS: engine version, instance class, endpoint, port, etc. + Metadata map[string]string +} + +// ConnectionStatus represents the health/availability of a discovered connection. +type ConnectionStatus string + +const ( + // ConnectionStatusAvailable means the connection is ready to use. + ConnectionStatusAvailable ConnectionStatus = "available" + + // ConnectionStatusStarting means the resource is starting up. + ConnectionStatusStarting ConnectionStatus = "starting" + + // ConnectionStatusStopped means the resource is stopped but can be started. + ConnectionStatusStopped ConnectionStatus = "stopped" + + // ConnectionStatusDeleting means the resource is being deleted. + ConnectionStatusDeleting ConnectionStatus = "deleting" + + // ConnectionStatusFailed means the resource is in a failed state. + ConnectionStatusFailed ConnectionStatus = "failed" + + // ConnectionStatusUnknown means the status couldn't be determined. + ConnectionStatusUnknown ConnectionStatus = "unknown" +) + +// IsAvailable returns true if the connection status indicates it can be connected to. +func (s ConnectionStatus) IsAvailable() bool { + return s == ConnectionStatusAvailable +} + +// ProviderConfig is the base configuration for all providers. +type ProviderConfig struct { + // ID uniquely identifies this provider instance. + ID string + + // Name is a human-readable name for this provider. + Name string + + // Enabled controls whether this provider is active. + Enabled bool +} diff --git a/dataflow/core/src/providers/registry.go b/dataflow/core/src/providers/registry.go new file mode 100644 index 0000000..586e8e1 --- /dev/null +++ b/dataflow/core/src/providers/registry.go @@ -0,0 +1,323 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package providers + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/log" +) + +// cachedDiscovery holds discovered connections and when they were fetched. +type cachedDiscovery struct { + connections []DiscoveredConnection + discoveredAt time.Time +} + +var ( + // ErrProviderNotFound indicates the requested provider doesn't exist. + ErrProviderNotFound = errors.New("provider not found") + + // ErrProviderAlreadyExists indicates a provider with this ID already exists. + ErrProviderAlreadyExists = errors.New("provider already exists") + + // ErrConnectionNotFound indicates the requested connection doesn't exist. + ErrConnectionNotFound = errors.New("connection not found") +) + +// DefaultDiscoveryCacheTTL is the default max age for cached discoveries. +// When DiscoverAll is called and a provider's cache is older than this, it auto-refreshes. +var DefaultDiscoveryCacheTTL = 5 * time.Minute + +// Registry manages connection providers and aggregates their discovered connections. +// It is safe for concurrent use. +type Registry struct { + mu sync.RWMutex + providers map[string]ConnectionProvider + + cacheMu sync.RWMutex + connCache map[string]*cachedDiscovery +} + +// NewRegistry creates a new provider registry. +func NewRegistry() *Registry { + return &Registry{ + providers: make(map[string]ConnectionProvider), + connCache: make(map[string]*cachedDiscovery), + } +} + +// Register adds a provider to the registry. +// Returns ErrProviderAlreadyExists if a provider with this ID already exists. +func (r *Registry) Register(provider ConnectionProvider) error { + r.mu.Lock() + defer r.mu.Unlock() + + id := provider.ID() + if _, exists := r.providers[id]; exists { + return ErrProviderAlreadyExists + } + + r.providers[id] = provider + return nil +} + +// Unregister removes a provider from the registry. +// Also clears any cached connections for this provider. +func (r *Registry) Unregister(providerID string) error { + r.mu.Lock() + defer r.mu.Unlock() + + provider, exists := r.providers[providerID] + if !exists { + return ErrProviderNotFound + } + + if err := provider.Close(context.Background()); err != nil { + log.Warnf("Provider %s close failed during unregister: %v", providerID, err) + } + + delete(r.providers, providerID) + + r.cacheMu.Lock() + delete(r.connCache, providerID) + r.cacheMu.Unlock() + + return nil +} + +// Get returns a provider by ID. +func (r *Registry) Get(providerID string) (ConnectionProvider, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + provider, exists := r.providers[providerID] + if !exists { + return nil, ErrProviderNotFound + } + return provider, nil +} + +// GetByType returns all providers of a given type. +func (r *Registry) GetByType(providerType ProviderType) []ConnectionProvider { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []ConnectionProvider + for _, p := range r.providers { + if p.Type() == providerType { + result = append(result, p) + } + } + return result +} + +// List returns all registered providers. +func (r *Registry) List() []ConnectionProvider { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]ConnectionProvider, 0, len(r.providers)) + for _, p := range r.providers { + result = append(result, p) + } + return result +} + +// DiscoverAll discovers connections from all registered providers. +// Uses cached results when fresh (within DefaultDiscoveryCacheTTL), otherwise auto-refreshes. +func (r *Registry) DiscoverAll(ctx context.Context) ([]DiscoveredConnection, error) { + r.mu.RLock() + providers := make([]ConnectionProvider, 0, len(r.providers)) + for _, p := range r.providers { + providers = append(providers, p) + } + r.mu.RUnlock() + + log.Infof("DiscoverAll: found %d registered providers", len(providers)) + + var allConns []DiscoveredConnection + var errs []error + + for _, p := range providers { + providerID := p.ID() + + r.cacheMu.RLock() + cached, hasCached := r.connCache[providerID] + r.cacheMu.RUnlock() + + if hasCached && time.Since(cached.discoveredAt) < DefaultDiscoveryCacheTTL { + age := time.Since(cached.discoveredAt).Round(time.Second) + log.Infof("DiscoverAll: using %d cached connections for provider %s (age=%s, ttl=%s)", len(cached.connections), providerID, age, DefaultDiscoveryCacheTTL) + allConns = append(allConns, cached.connections...) + continue + } + + if hasCached { + age := time.Since(cached.discoveredAt).Round(time.Second) + log.Infof("DiscoverAll: cache expired for provider %s (age=%s, ttl=%s), re-discovering", providerID, age, DefaultDiscoveryCacheTTL) + } else { + log.Infof("DiscoverAll: no cache for provider %s, discovering", providerID) + } + + conns, err := p.DiscoverConnections(ctx) + + if len(conns) > 0 { + log.Infof("DiscoverAll: got %d connections from provider %s", len(conns), providerID) + r.cacheMu.Lock() + r.connCache[providerID] = &cachedDiscovery{connections: conns, discoveredAt: time.Now()} + r.cacheMu.Unlock() + allConns = append(allConns, conns...) + } + + if err != nil { + log.Warnf("DiscoverAll: error from provider %s: %v", providerID, err) + errs = append(errs, err) + } + } + + log.Infof("DiscoverAll: returning %d total connections", len(allConns)) + + if len(errs) > 0 { + return allConns, errors.Join(errs...) + } + return allConns, nil +} + +// RefreshDiscovery clears the cache for a provider and re-discovers connections. +// If providerID is empty, refreshes all providers. +func (r *Registry) RefreshDiscovery(ctx context.Context, providerID string) ([]DiscoveredConnection, error) { + if providerID == "" { + r.cacheMu.Lock() + r.connCache = make(map[string]*cachedDiscovery) + r.cacheMu.Unlock() + return r.DiscoverAll(ctx) + } + + r.cacheMu.Lock() + delete(r.connCache, providerID) + r.cacheMu.Unlock() + + provider, err := r.Get(providerID) + if err != nil { + return nil, err + } + + conns, err := provider.DiscoverConnections(ctx) + if err != nil { + if len(conns) > 0 { + r.cacheMu.Lock() + r.connCache[providerID] = &cachedDiscovery{connections: conns, discoveredAt: time.Now()} + r.cacheMu.Unlock() + } + return conns, err + } + + r.cacheMu.Lock() + r.connCache[providerID] = &cachedDiscovery{connections: conns, discoveredAt: time.Now()} + r.cacheMu.Unlock() + + return conns, nil +} + +// FilterByDatabaseType returns connections that match the given database type. +func (r *Registry) FilterByDatabaseType(ctx context.Context, dbType engine.DatabaseType) ([]DiscoveredConnection, error) { + all, err := r.DiscoverAll(ctx) + if err != nil { + return nil, err + } + + var result []DiscoveredConnection + for _, c := range all { + if c.DatabaseType == dbType { + result = append(result, c) + } + } + return result, nil +} + +// FilterByProvider returns connections from a specific provider. +func (r *Registry) FilterByProvider(ctx context.Context, providerID string) ([]DiscoveredConnection, error) { + all, err := r.DiscoverAll(ctx) + if err != nil { + return nil, err + } + + var result []DiscoveredConnection + for _, c := range all { + if c.ProviderID == providerID { + result = append(result, c) + } + } + return result, nil +} + +// FilterAvailable returns only connections that are currently available. +func (r *Registry) FilterAvailable(ctx context.Context) ([]DiscoveredConnection, error) { + all, err := r.DiscoverAll(ctx) + if err != nil { + return nil, err + } + + var result []DiscoveredConnection + for _, c := range all { + if c.Status.IsAvailable() { + result = append(result, c) + } + } + return result, nil +} + +// Close closes all registered providers and clears the registry. +func (r *Registry) Close(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + + var errs []error + for _, p := range r.providers { + if err := p.Close(ctx); err != nil { + errs = append(errs, err) + } + } + + r.providers = make(map[string]ConnectionProvider) + + r.cacheMu.Lock() + r.connCache = make(map[string]*cachedDiscovery) + r.cacheMu.Unlock() + + return errors.Join(errs...) +} + +// DefaultRegistry is the global provider registry. +// It is initialized lazily on first access. +var ( + defaultRegistry *Registry + defaultRegistryOnce sync.Once +) + +// GetDefaultRegistry returns the global provider registry. +func GetDefaultRegistry() *Registry { + defaultRegistryOnce.Do(func() { + defaultRegistry = NewRegistry() + }) + return defaultRegistry +} diff --git a/dataflow/core/src/providers/registry_test.go b/dataflow/core/src/providers/registry_test.go new file mode 100644 index 0000000..6feacb7 --- /dev/null +++ b/dataflow/core/src/providers/registry_test.go @@ -0,0 +1,398 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package providers + +import ( + "context" + "errors" + "testing" + + "github.com/clidey/whodb/core/src/engine" +) + +// mockProvider implements ConnectionProvider for testing. +type mockProvider struct { + id string + name string + connections []DiscoveredConnection + testErr error +} + +func (m *mockProvider) Type() ProviderType { + return "mock" +} + +func (m *mockProvider) ID() string { + return m.id +} + +func (m *mockProvider) Name() string { + return m.name +} + +func (m *mockProvider) DiscoverConnections(ctx context.Context) ([]DiscoveredConnection, error) { + if m.testErr != nil { + return nil, m.testErr + } + return m.connections, nil +} + +func (m *mockProvider) TestConnection(ctx context.Context) error { + return m.testErr +} + +func (m *mockProvider) RefreshConnection(ctx context.Context, connectionID string) (bool, error) { + return false, nil +} + +func (m *mockProvider) Close(ctx context.Context) error { + return nil +} + +// Verify mockProvider implements ConnectionProvider +var _ ConnectionProvider = (*mockProvider)(nil) + +func TestRegistry_Register(t *testing.T) { + r := NewRegistry() + + p := &mockProvider{id: "test-1", name: "Test Provider"} + err := r.Register(p) + if err != nil { + t.Fatalf("unexpected error registering provider: %v", err) + } + + // Registering again should fail + err = r.Register(p) + if !errors.Is(err, ErrProviderAlreadyExists) { + t.Errorf("expected ErrProviderAlreadyExists, got %v", err) + } +} + +func TestRegistry_Get(t *testing.T) { + r := NewRegistry() + + p := &mockProvider{id: "test-1", name: "Test Provider"} + _ = r.Register(p) + + got, err := r.Get("test-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.ID() != "test-1" { + t.Errorf("expected ID test-1, got %s", got.ID()) + } + + // Non-existent provider + _, err = r.Get("non-existent") + if !errors.Is(err, ErrProviderNotFound) { + t.Errorf("expected ErrProviderNotFound, got %v", err) + } +} + +func TestRegistry_Unregister(t *testing.T) { + r := NewRegistry() + + p := &mockProvider{id: "test-1", name: "Test Provider"} + _ = r.Register(p) + + err := r.Unregister("test-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should not be found after unregistering + _, err = r.Get("test-1") + if !errors.Is(err, ErrProviderNotFound) { + t.Errorf("expected ErrProviderNotFound, got %v", err) + } + + // Unregistering non-existent should fail + err = r.Unregister("non-existent") + if !errors.Is(err, ErrProviderNotFound) { + t.Errorf("expected ErrProviderNotFound, got %v", err) + } +} + +func TestRegistry_List(t *testing.T) { + r := NewRegistry() + + p1 := &mockProvider{id: "test-1", name: "Provider 1"} + p2 := &mockProvider{id: "test-2", name: "Provider 2"} + + _ = r.Register(p1) + _ = r.Register(p2) + + list := r.List() + if len(list) != 2 { + t.Errorf("expected 2 providers, got %d", len(list)) + } +} + +func TestRegistry_DiscoverAll(t *testing.T) { + r := NewRegistry() + + p1 := &mockProvider{ + id: "test-1", + name: "Provider 1", + connections: []DiscoveredConnection{ + { + ID: "test-1/conn-1", + ProviderID: "test-1", + Name: "Connection 1", + DatabaseType: engine.DatabaseType_MySQL, + Status: ConnectionStatusAvailable, + }, + }, + } + p2 := &mockProvider{ + id: "test-2", + name: "Provider 2", + connections: []DiscoveredConnection{ + { + ID: "test-2/conn-2", + ProviderID: "test-2", + Name: "Connection 2", + DatabaseType: engine.DatabaseType_Postgres, + Status: ConnectionStatusAvailable, + }, + }, + } + + _ = r.Register(p1) + _ = r.Register(p2) + + ctx := context.Background() + conns, err := r.DiscoverAll(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(conns) != 2 { + t.Errorf("expected 2 connections, got %d", len(conns)) + } +} + +func TestRegistry_DiscoverAll_WithErrors(t *testing.T) { + r := NewRegistry() + + p1 := &mockProvider{ + id: "test-1", + name: "Working Provider", + connections: []DiscoveredConnection{ + {ID: "test-1/conn-1", ProviderID: "test-1", Name: "Connection 1"}, + }, + } + p2 := &mockProvider{ + id: "test-2", + name: "Failing Provider", + testErr: errors.New("discovery failed"), + } + + _ = r.Register(p1) + _ = r.Register(p2) + + ctx := context.Background() + conns, err := r.DiscoverAll(ctx) + + // Should still return connections from working provider + if len(conns) != 1 { + t.Errorf("expected 1 connection, got %d", len(conns)) + } + // Should also return the error + if err == nil { + t.Error("expected error from failing provider") + } +} + +func TestRegistry_FilterByDatabaseType(t *testing.T) { + r := NewRegistry() + + p := &mockProvider{ + id: "test-1", + name: "Test Provider", + connections: []DiscoveredConnection{ + {ID: "test-1/mysql", DatabaseType: engine.DatabaseType_MySQL}, + {ID: "test-1/postgres", DatabaseType: engine.DatabaseType_Postgres}, + {ID: "test-1/redis", DatabaseType: engine.DatabaseType_Redis}, + }, + } + _ = r.Register(p) + + ctx := context.Background() + + mysqlConns, err := r.FilterByDatabaseType(ctx, engine.DatabaseType_MySQL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(mysqlConns) != 1 { + t.Errorf("expected 1 MySQL connection, got %d", len(mysqlConns)) + } +} + +func TestRegistry_FilterAvailable(t *testing.T) { + r := NewRegistry() + + p := &mockProvider{ + id: "test-1", + name: "Test Provider", + connections: []DiscoveredConnection{ + {ID: "test-1/available", Status: ConnectionStatusAvailable}, + {ID: "test-1/stopped", Status: ConnectionStatusStopped}, + {ID: "test-1/starting", Status: ConnectionStatusStarting}, + }, + } + _ = r.Register(p) + + ctx := context.Background() + + availableConns, err := r.FilterAvailable(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(availableConns) != 1 { + t.Errorf("expected 1 available connection, got %d", len(availableConns)) + } +} + +func TestRegistry_RefreshDiscovery(t *testing.T) { + r := NewRegistry() + + callCount := 0 + p := &mockProvider{ + id: "test-1", + name: "Test Provider", + } + // Override DiscoverConnections to track calls + originalConns := p.connections + p.connections = []DiscoveredConnection{ + {ID: "test-1/conn-1", ProviderID: "test-1"}, + } + _ = r.Register(p) + + ctx := context.Background() + + // First discovery + _, _ = r.DiscoverAll(ctx) + + // Refresh + conns, err := r.RefreshDiscovery(ctx, "test-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(conns) == 0 && len(originalConns) == 0 { + // OK - empty is fine for this test + } + _ = callCount // Just to track that we did the call +} + +func TestRegistry_DiscoverAll_CacheStaleness(t *testing.T) { + r := NewRegistry() + + callCount := 0 + p := &mockProviderWithCounter{ + mockProvider: mockProvider{ + id: "test-stale", + name: "Stale Test", + connections: []DiscoveredConnection{ + {ID: "test-stale/conn-1", ProviderID: "test-stale"}, + }, + }, + callCount: &callCount, + } + _ = r.Register(p) + + ctx := context.Background() + + // First call should discover + _, _ = r.DiscoverAll(ctx) + if callCount != 1 { + t.Errorf("expected 1 discovery call, got %d", callCount) + } + + // Second call should use cache (within TTL) + _, _ = r.DiscoverAll(ctx) + if callCount != 1 { + t.Errorf("expected still 1 discovery call (cached), got %d", callCount) + } + + // Manually expire cache + r.cacheMu.Lock() + if cached, ok := r.connCache["test-stale"]; ok { + cached.discoveredAt = cached.discoveredAt.Add(-DefaultDiscoveryCacheTTL - 1) + } + r.cacheMu.Unlock() + + // Third call should re-discover (cache stale) + _, _ = r.DiscoverAll(ctx) + if callCount != 2 { + t.Errorf("expected 2 discovery calls (stale cache), got %d", callCount) + } +} + +// mockProviderWithCounter wraps mockProvider and counts DiscoverConnections calls. +type mockProviderWithCounter struct { + mockProvider + callCount *int +} + +func (m *mockProviderWithCounter) DiscoverConnections(ctx context.Context) ([]DiscoveredConnection, error) { + *m.callCount++ + return m.mockProvider.DiscoverConnections(ctx) +} + +func TestConnectionStatus_IsAvailable(t *testing.T) { + testCases := []struct { + status ConnectionStatus + expected bool + }{ + {ConnectionStatusAvailable, true}, + {ConnectionStatusStarting, false}, + {ConnectionStatusStopped, false}, + {ConnectionStatusDeleting, false}, + {ConnectionStatusFailed, false}, + {ConnectionStatusUnknown, false}, + } + + for _, tc := range testCases { + result := tc.status.IsAvailable() + if result != tc.expected { + t.Errorf("IsAvailable() for %s: expected %v, got %v", tc.status, tc.expected, result) + } + } +} + +func TestDiscoveredConnection_Fields(t *testing.T) { + conn := DiscoveredConnection{ + ID: "aws-us-west-2/prod-mysql", + ProviderType: ProviderTypeAWS, + ProviderID: "aws-us-west-2", + Name: "prod-mysql", + DatabaseType: engine.DatabaseType_MySQL, + Region: "us-west-2", + Status: ConnectionStatusAvailable, + Metadata: map[string]string{"engine": "mysql", "version": "8.0"}, + } + + if conn.ID != "aws-us-west-2/prod-mysql" { + t.Errorf("unexpected ID: %s", conn.ID) + } + if conn.DatabaseType != engine.DatabaseType_MySQL { + t.Errorf("unexpected DatabaseType: %s", conn.DatabaseType) + } + if !conn.Status.IsAvailable() { + t.Error("expected connection to be available") + } +} diff --git a/dataflow/core/src/router/.gitignore b/dataflow/core/src/router/.gitignore new file mode 100644 index 0000000..a75cd4e --- /dev/null +++ b/dataflow/core/src/router/.gitignore @@ -0,0 +1,7 @@ +build/ + +# Keep upstream tracked embed placeholders, excluding generated assets. +!build/ +build/* +!build/app.js +!build/index.html diff --git a/dataflow/core/src/router/build/app.js b/dataflow/core/src/router/build/app.js new file mode 100644 index 0000000..30bd9a8 --- /dev/null +++ b/dataflow/core/src/router/build/app.js @@ -0,0 +1 @@ +console.log('WhoDB router test build placeholder'); diff --git a/dataflow/core/src/router/build/index.html b/dataflow/core/src/router/build/index.html new file mode 100644 index 0000000..8993fad --- /dev/null +++ b/dataflow/core/src/router/build/index.html @@ -0,0 +1,12 @@ + + + + + + WhoDB Router Test Build + + +
WhoDB router test build placeholder
+ + + diff --git a/dataflow/core/src/router/file_server.go b/dataflow/core/src/router/file_server.go new file mode 100644 index 0000000..36aaf6d --- /dev/null +++ b/dataflow/core/src/router/file_server.go @@ -0,0 +1,97 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package router + +import ( + "embed" + "io" + "io/fs" + "net/http" + "path" + "strings" + + "github.com/clidey/whodb/core/src/log" + "github.com/go-chi/chi/v5" +) + +func fileServer(r chi.Router, staticFiles embed.FS) { + // Support assets embedded under common build roots. + // Prefer a root that actually contains index.html. If not found, continue in API-only mode. + candidates := []string{"build", "dist", "."} + var staticFS fs.FS + var err error + found := false + for _, base := range candidates { + var sub fs.FS + if base == "." { + sub = staticFiles + } else { + if sub, err = fs.Sub(staticFiles, base); err != nil { + continue + } + } + if f, openErr := sub.Open("index.html"); openErr == nil { + _ = f.Close() + staticFS = sub + found = true + break + } + } + if !found { + log.Warn("No embedded web assets found - running in API-only mode (start DataFlow separately in dev)") + return + } + + server := http.FileServer(http.FS(staticFS)) + + r.Handle("/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if hasExtension(r.URL.Path) { + server.ServeHTTP(w, r) + } else { + file, err := staticFS.Open("index.html") + if err != nil { + http.Error(w, "index.html not found", http.StatusNotFound) + log.Error("Failed to open index.html:", err) + return + } + defer func(file fs.File) { + err := file.Close() + if err != nil { + log.Error("Failed to close file:", err) + } + }(file) + + data, err := io.ReadAll(file) + if err != nil { + http.Error(w, "Failed to read index.html", http.StatusInternalServerError) + log.Error("Failed to read index.html:", err) + return + } + + w.Header().Set("Content-Type", "text/html") + _, err = w.Write(data) + if err != nil { + return + } + } + })) +} + +func hasExtension(pathFile string) bool { + ext := strings.ToLower(path.Ext(pathFile)) + return ext != "" +} diff --git a/dataflow/core/src/router/file_server_test.go b/dataflow/core/src/router/file_server_test.go new file mode 100644 index 0000000..aae6091 --- /dev/null +++ b/dataflow/core/src/router/file_server_test.go @@ -0,0 +1,84 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package router + +import ( + "embed" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +//go:embed all:build +var testStaticFiles embed.FS + +func TestFileServerServesIndexAndAssets(t *testing.T) { + // Check if test files are available - the fileServer looks for "build" subdirectory with index.html + buildFS, err := fs.Sub(testStaticFiles, "build") + if err != nil { + t.Skip("Build directory not available, skipping file server tests") + } + if _, err := buildFS.Open("index.html"); err != nil { + t.Skip("No index.html in build directory - tests require actual build files") + } + + r := chi.NewRouter() + fileServer(r, testStaticFiles) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected index.html to be served, got status %d", rr.Code) + } + body, _ := io.ReadAll(rr.Body) + if len(body) == 0 { + t.Fatalf("expected index.html content in response") + } + + req = httptest.NewRequest(http.MethodGet, "/app.js", nil) + rr = httptest.NewRecorder() + r.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected static asset to be served, got status %d", rr.Code) + } +} + +func TestFileServerFallsBackToIndexForNestedRoute(t *testing.T) { + // Check if test files are available - the fileServer looks for "build" subdirectory with index.html + buildFS, err := fs.Sub(testStaticFiles, "build") + if err != nil { + t.Skip("Build directory not available, skipping file server tests") + } + if _, err := buildFS.Open("index.html"); err != nil { + t.Skip("No index.html in build directory - tests require actual build files") + } + + r := chi.NewRouter() + fileServer(r, testStaticFiles) + + req := httptest.NewRequest(http.MethodGet, "/dashboard", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected nested route to serve index.html, got status %d", rr.Code) + } +} diff --git a/dataflow/core/src/router/graphql_ce.go b/dataflow/core/src/router/graphql_ce.go new file mode 100644 index 0000000..e5a7010 --- /dev/null +++ b/dataflow/core/src/router/graphql_ce.go @@ -0,0 +1,25 @@ +// Copyright 2025 Clidey, Inc. +// +// 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. + +package router + +import ( + "github.com/clidey/whodb/core/graph" + "github.com/99designs/gqlgen/graphql/handler" +) + +// createGraphQLServer creates a GraphQL server for Community Edition +func createGraphQLServer() *handler.Server { + return NewGraphQLServer(graph.NewExecutableSchema(graph.Config{Resolvers: graph.NewResolver()})) +} diff --git a/dataflow/core/src/router/middleware.go b/dataflow/core/src/router/middleware.go new file mode 100644 index 0000000..10a2f0e --- /dev/null +++ b/dataflow/core/src/router/middleware.go @@ -0,0 +1,42 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package router + +import ( + "context" + "net/http" + + "github.com/clidey/whodb/core/src/analytics" + "github.com/clidey/whodb/core/src/common" + "github.com/go-chi/chi/v5/middleware" +) + +func contextMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), common.RouterKey_ResponseWriter, w) + + metadata := analytics.BuildMetadata(r) + if metadata.RequestID == "" { + if requestID := middleware.GetReqID(ctx); requestID != "" { + metadata.RequestID = requestID + } + } + + ctx = analytics.WithMetadata(ctx, metadata) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/dataflow/core/src/router/middleware_test.go b/dataflow/core/src/router/middleware_test.go new file mode 100644 index 0000000..09b5eac --- /dev/null +++ b/dataflow/core/src/router/middleware_test.go @@ -0,0 +1,232 @@ +package router + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/clidey/whodb/core/src/analytics" +) + +func setValidRuntimeHealthConfig(t *testing.T) { + t.Helper() + t.Setenv("PORT", "") + t.Setenv("WHODB_METADATA_DSN", "host=metadata.local user=dataflow password=metadata-secret dbname=dataflow") + t.Setenv("WHODB_SESSION_DSN", "") + t.Setenv("WHODB_SESSION_ENCRYPTION_KEY", "12345678901234567890123456789012") + t.Setenv("WHODB_SESSION_TTL", "24h") + t.Setenv("WHODB_SEALOS_BOOTSTRAP_ENABLED", "") + t.Setenv("WHODB_STANDALONE_LOGIN_ENABLED", "") + t.Setenv("WHODB_ENABLE_AWS_PROVIDER", "") + t.Setenv("WHODB_AWS_PROVIDER", "") +} + +func decodeHealthResponse(t *testing.T, body string) healthResponse { + t.Helper() + var response healthResponse + if err := json.Unmarshal([]byte(body), &response); err != nil { + t.Fatalf("failed to decode health response: %v; body=%s", err, body) + } + return response +} + +func assertHealthIssue(t *testing.T, response healthResponse, name string) { + t.Helper() + for _, issue := range response.Checks { + if issue.Name == name { + return + } + } + t.Fatalf("expected health issue %s, got %+v", name, response.Checks) +} + +func TestContextMiddlewareAddsMetadata(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://api.local/data", nil) + req.Host = "api.local:8080" + req.Header.Set("User-Agent", "tester") + req.Header.Set("X-Whodb-Analytics-Id", "user-123") + req.Header.Set("X-Request-Id", "req-1") + + rr := httptest.NewRecorder() + var captured analytics.Metadata + + handler := contextMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = analytics.MetadataFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected middleware to allow request, got status %d", rr.Code) + } + + if captured.Domain != "api.local" { + t.Fatalf("expected domain to be derived from host, got %s", captured.Domain) + } + if captured.DistinctID != "user-123" { + t.Fatalf("expected distinct id to be captured from header, got %s", captured.DistinctID) + } + if captured.RequestID != "req-1" { + t.Fatalf("expected request id to be captured from header, got %s", captured.RequestID) + } +} + +func TestHealthCheckMiddlewareHandlesHealthz(t *testing.T) { + setValidRuntimeHealthConfig(t) + + handler := healthCheckMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + })) + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "http://api.local/healthz", nil) + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) + } + if got := rr.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("expected Cache-Control no-store, got %q", got) + } + if got := rr.Header().Get("Content-Type"); got != "application/json" { + t.Fatalf("expected Content-Type application/json, got %q", got) + } + response := decodeHealthResponse(t, rr.Body.String()) + if response.Service != "dataflow" || response.Status != "ok" { + t.Fatalf("unexpected healthz response: %+v", response) + } + if len(response.Checks) != 0 { + t.Fatalf("expected no checks on healthy response, got %+v", response.Checks) + } +} + +func TestHealthCheckMiddlewareReportsInvalidRuntimeConfig(t *testing.T) { + tests := []struct { + name string + mutate func(t *testing.T) + wantIssue string + forbidden string + }{ + { + name: "missing metadata dsn", + mutate: func(t *testing.T) { + t.Setenv("WHODB_METADATA_DSN", "") + }, + wantIssue: "WHODB_METADATA_DSN", + forbidden: "metadata-secret", + }, + { + name: "missing session source", + mutate: func(t *testing.T) { + t.Setenv("WHODB_METADATA_DSN", "") + t.Setenv("WHODB_SESSION_DSN", "") + }, + wantIssue: "WHODB_SESSION_DSN", + forbidden: "metadata-secret", + }, + { + name: "invalid session encryption key", + mutate: func(t *testing.T) { + t.Setenv("WHODB_SESSION_ENCRYPTION_KEY", "short-secret") + }, + wantIssue: "WHODB_SESSION_ENCRYPTION_KEY", + forbidden: "short-secret", + }, + { + name: "invalid session ttl", + mutate: func(t *testing.T) { + t.Setenv("WHODB_SESSION_TTL", "soon") + }, + wantIssue: "WHODB_SESSION_TTL", + forbidden: "soon", + }, + { + name: "invalid port", + mutate: func(t *testing.T) { + t.Setenv("PORT", "99999") + }, + wantIssue: "PORT", + forbidden: "99999", + }, + { + name: "invalid boolean", + mutate: func(t *testing.T) { + t.Setenv("WHODB_SEALOS_BOOTSTRAP_ENABLED", "yes") + }, + wantIssue: "WHODB_SEALOS_BOOTSTRAP_ENABLED", + forbidden: "yes", + }, + { + name: "invalid aws provider json", + mutate: func(t *testing.T) { + t.Setenv("WHODB_ENABLE_AWS_PROVIDER", "true") + t.Setenv("WHODB_AWS_PROVIDER", `{"region":"us-west-2"}`) + }, + wantIssue: "WHODB_AWS_PROVIDER", + forbidden: "us-west-2", + }, + { + name: "aws provider missing region", + mutate: func(t *testing.T) { + t.Setenv("WHODB_ENABLE_AWS_PROVIDER", "true") + t.Setenv("WHODB_AWS_PROVIDER", `[{"name":"prod"}]`) + }, + wantIssue: "WHODB_AWS_PROVIDER", + forbidden: "prod", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setValidRuntimeHealthConfig(t) + tt.mutate(t) + + handler := healthCheckMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + })) + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "http://api.local/healthz", nil) + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("expected status %d, got %d with body %s", http.StatusServiceUnavailable, rr.Code, rr.Body.String()) + } + response := decodeHealthResponse(t, rr.Body.String()) + if response.Service != "dataflow" || response.Status != "error" { + t.Fatalf("unexpected healthz response: %+v", response) + } + assertHealthIssue(t, response, tt.wantIssue) + if strings.Contains(rr.Body.String(), tt.forbidden) { + t.Fatalf("health response leaked configured value %q: %s", tt.forbidden, rr.Body.String()) + } + }) + } +} + +func TestHealthCheckMiddlewareDoesNotHandleHealth(t *testing.T) { + handler := healthCheckMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + w.Write([]byte("next")) + })) + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "http://api.local/health", nil) + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusTeapot { + t.Fatalf("expected request to continue to next handler, got status %d", rr.Code) + } + if got := rr.Header().Get("Cache-Control"); got != "" { + t.Fatalf("expected Cache-Control to be unset, got %q", got) + } + if got := strings.TrimSpace(rr.Body.String()); got != "next" { + t.Fatalf("expected next handler response, got %s", got) + } +} diff --git a/dataflow/core/src/router/playground.go b/dataflow/core/src/router/playground.go new file mode 100644 index 0000000..2ea7200 --- /dev/null +++ b/dataflow/core/src/router/playground.go @@ -0,0 +1,38 @@ +// Copyright 2025 Clidey, Inc. +// +// 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. + +package router + +import ( + "net/http" + + "github.com/99designs/gqlgen/graphql/handler" + "github.com/99designs/gqlgen/graphql/playground" + "github.com/clidey/whodb/core/src/env" + "github.com/go-chi/chi/v5" +) + +func setupPlaygroundHandler(router chi.Router, server *handler.Server) { + var pathHandler http.HandlerFunc + if env.IsDevelopment { + pathHandler = playground.Handler("API Gateway", "/api/query") + } + router.HandleFunc("/api/query", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" || r.Header.Get("Connection") == "upgrade" { + server.ServeHTTP(w, r) + } else if env.IsDevelopment { + pathHandler.ServeHTTP(w, r) + } + }) +} diff --git a/dataflow/core/src/router/playground_test.go b/dataflow/core/src/router/playground_test.go new file mode 100644 index 0000000..d420059 --- /dev/null +++ b/dataflow/core/src/router/playground_test.go @@ -0,0 +1,50 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/99designs/gqlgen/graphql/handler" + "github.com/clidey/whodb/core/src/env" + "github.com/go-chi/chi/v5" +) + +func TestSetupPlaygroundHandlerServesPlaygroundInDev(t *testing.T) { + originalDev := env.IsDevelopment + env.IsDevelopment = true + t.Cleanup(func() { env.IsDevelopment = originalDev }) + + r := chi.NewRouter() + var srv *handler.Server // not used for GET in dev + setupPlaygroundHandler(r, srv) + + req := httptest.NewRequest(http.MethodGet, "/api/query", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected playground to respond with 200, got %d", rr.Code) + } + if len(rr.Body.Bytes()) == 0 { + t.Fatalf("expected playground HTML content") + } +} + +func TestSetupPlaygroundHandlerNoPlaygroundOutsideDev(t *testing.T) { + originalDev := env.IsDevelopment + env.IsDevelopment = false + t.Cleanup(func() { env.IsDevelopment = originalDev }) + + r := chi.NewRouter() + var srv *handler.Server + setupPlaygroundHandler(r, srv) + + req := httptest.NewRequest(http.MethodGet, "/api/query", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected handler to respond 200 even without playground, got %d", rr.Code) + } +} diff --git a/dataflow/core/src/router/router.go b/dataflow/core/src/router/router.go new file mode 100644 index 0000000..872769c --- /dev/null +++ b/dataflow/core/src/router/router.go @@ -0,0 +1,259 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package router + +import ( + "embed" + "encoding/json" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/99designs/gqlgen/graphql/handler/extension" + + "github.com/99designs/gqlgen/graphql" + "github.com/99designs/gqlgen/graphql/handler" + "github.com/99designs/gqlgen/graphql/handler/transport" + "github.com/clidey/whodb/core/graph" + "github.com/clidey/whodb/core/src/auth" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/log" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" +) + +type OAuthLoginUrl struct { + Url string `json:"url"` +} + +type healthResponse struct { + Service string `json:"service"` + Status string `json:"status"` + Checks []healthCheckIssue `json:"checks,omitempty"` +} + +type healthCheckIssue struct { + Name string `json:"name"` + Reason string `json:"reason"` +} + +func NewGraphQLServer(es graphql.ExecutableSchema) *handler.Server { + srv := handler.New(es) + + srv.AddTransport(&transport.Websocket{ + KeepAlivePingInterval: 10 * time.Second, + }) + srv.AddTransport(&transport.Options{}) + srv.AddTransport(&transport.GET{}) + srv.AddTransport(&transport.POST{}) + srv.AddTransport(&transport.MultipartForm{}) + + srv.Use(extension.FixedComplexityLimit(100)) + + if env.IsDevelopment { + srv.Use(extension.Introspection{}) + } + + return srv +} + +func setupServer(router *chi.Mux, staticFiles embed.FS) { + if !env.IsAPIGatewayEnabled { + fileServer(router, staticFiles) + } + + server := createGraphQLServer() + server.AddTransport(&transport.Websocket{}) + graph.SetupHTTPServer(router) + setupPlaygroundHandler(router, server) +} + +// statusResponseWriter wraps http.ResponseWriter to capture the status code. +type statusResponseWriter struct { + http.ResponseWriter + statusCode int +} + +func (w *statusResponseWriter) WriteHeader(code int) { + w.statusCode = code + w.ResponseWriter.WriteHeader(code) +} + +// Flush forwards to the underlying ResponseWriter if it supports flushing (required for SSE streaming). +func (w *statusResponseWriter) Flush() { + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// healthCheckMiddleware responds to GET /healthz without requiring authentication. +// Used by probes and E2E setup scripts to verify the server has valid runtime configuration. +func healthCheckMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/healthz" { + statusCode, response := buildHealthResponse() + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(response) + return + } + next.ServeHTTP(w, r) + }) +} + +func buildHealthResponse() (int, healthResponse) { + issues := validateRuntimeConfiguration() + if len(issues) > 0 { + return http.StatusServiceUnavailable, healthResponse{ + Service: "dataflow", + Status: "error", + Checks: issues, + } + } + + return http.StatusOK, healthResponse{ + Service: "dataflow", + Status: "ok", + } +} + +func validateRuntimeConfiguration() []healthCheckIssue { + var issues []healthCheckIssue + + addIssue := func(name, reason string) { + issues = append(issues, healthCheckIssue{ + Name: name, + Reason: reason, + }) + } + + if port := strings.TrimSpace(os.Getenv("PORT")); port != "" { + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + addIssue("PORT", "must_be_valid_port") + } + } + + if strings.TrimSpace(os.Getenv("WHODB_METADATA_DSN")) == "" { + addIssue("WHODB_METADATA_DSN", "required") + } + + if env.GetSessionDSN() == "" { + addIssue("WHODB_SESSION_DSN", "required_or_metadata_dsn_fallback") + } + + if len(env.GetSessionEncryptionKey()) != 32 { + addIssue("WHODB_SESSION_ENCRYPTION_KEY", "must_be_32_bytes") + } + + ttl, err := time.ParseDuration(env.GetSessionTTL()) + if err != nil || ttl <= 0 { + addIssue("WHODB_SESSION_TTL", "must_be_positive_duration") + } + + validateOptionalBoolean("WHODB_SEALOS_BOOTSTRAP_ENABLED", addIssue) + validateOptionalBoolean("WHODB_STANDALONE_LOGIN_ENABLED", addIssue) + validateOptionalBoolean("WHODB_ENABLE_AWS_PROVIDER", addIssue) + validateAWSProviderConfig(addIssue) + + return issues +} + +func validateOptionalBoolean(name string, addIssue func(string, string)) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" || value == "true" || value == "false" { + return + } + addIssue(name, "must_be_true_or_false") +} + +func validateAWSProviderConfig(addIssue func(string, string)) { + if strings.TrimSpace(os.Getenv("WHODB_ENABLE_AWS_PROVIDER")) != "true" { + return + } + + value := strings.TrimSpace(os.Getenv("WHODB_AWS_PROVIDER")) + if value == "" { + return + } + + var providers []env.AWSProviderEnvConfig + if err := json.Unmarshal([]byte(value), &providers); err != nil { + addIssue("WHODB_AWS_PROVIDER", "must_be_json_array") + return + } + + for _, provider := range providers { + if strings.TrimSpace(provider.Region) == "" { + addIssue("WHODB_AWS_PROVIDER", "region_required") + return + } + } +} + +// accessLogMiddleware logs HTTP requests with method, path, status, duration, host, and remote address. +func accessLogMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusResponseWriter{ResponseWriter: w, statusCode: http.StatusOK} + next.ServeHTTP(sw, r) + log.LogAccess(r.Method, r.URL.Path, sw.statusCode, time.Since(start), r.Host, r.RemoteAddr) + }) +} + +func setupMiddlewares(router *chi.Mux) { + allowedOrigins := env.AllowedOrigins + if len(allowedOrigins) == 0 { + allowedOrigins = append(allowedOrigins, "https://*", "http://*") + } + + middlewares := []func(http.Handler) http.Handler{ + accessLogMiddleware, + healthCheckMiddleware, + middleware.ThrottleBacklog(100, 50, time.Second*5), + middleware.RequestID, + middleware.RealIP, + middleware.RedirectSlashes, + middleware.Recoverer, + middleware.Timeout(90 * time.Second), // Increased for LLM inference time + cors.Handler(cors.Options{ + AllowedOrigins: allowedOrigins, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + ExposedHeaders: []string{}, + AllowCredentials: true, + MaxAge: 300, + }), + contextMiddleware, + auth.AuthMiddleware, + } + + router.Use(middlewares...) +} + +func InitializeRouter(staticFiles embed.FS) *chi.Mux { + router := chi.NewRouter() + + setupMiddlewares(router) + setupServer(router, staticFiles) + + return router +} diff --git a/dataflow/core/src/sealos/resolver.go b/dataflow/core/src/sealos/resolver.go new file mode 100644 index 0000000..192a2ab --- /dev/null +++ b/dataflow/core/src/sealos/resolver.go @@ -0,0 +1,710 @@ +package sealos + +import ( + "context" + "errors" + "fmt" + "os" + "strconv" + "strings" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/env" + yaml "go.yaml.in/yaml/v3" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/clientcmd/api" +) + +// BootstrapInput describes the public Sealos bootstrap request. +type BootstrapInput struct { + Kubeconfig string + DBType string + ResourceName string + DatabaseName string + Host string + Port string + Namespace string +} + +// ResolvedBootstrap is the dbprovider-compatible bootstrap result returned to GraphQL. +type ResolvedBootstrap struct { + Namespace string + ResourceName string + DBType string + Host string + Port string + DatabaseName string + K8sUsername string + Credentials *engine.Credentials +} + +// InstanceIdentityInput identifies the Sealos database instance whose lifecycle identity must be resolved. +type InstanceIdentityInput struct { + ResourceName string + Namespace string +} + +// ResolvedInstanceIdentity is the authoritative Kubernetes identity of a Sealos database instance. +type ResolvedInstanceIdentity struct { + UID string + Namespace string + ResourceName string +} + +// BootstrapResolver resolves Sealos bootstrap metadata into database credentials. +type BootstrapResolver interface { + // ResolveInstanceIdentity resolves the authoritative Database Instance Identity. + ResolveInstanceIdentity(context.Context, InstanceIdentityInput) (*ResolvedInstanceIdentity, error) + ResolveBootstrap(context.Context, BootstrapInput) (*ResolvedBootstrap, error) +} + +// BootstrapResolverFactory creates a resolver from a Sealos kubeconfig. +type BootstrapResolverFactory func(kubeconfig string) (BootstrapResolver, error) + +// DefaultBootstrapResolverFactory creates the production Sealos bootstrap resolver. +var DefaultBootstrapResolverFactory BootstrapResolverFactory = NewBootstrapResolver + +type kubeconfig struct { + CurrentContext string `yaml:"current-context"` + Contexts []struct { + Name string `yaml:"name"` + Context struct { + Namespace string `yaml:"namespace"` + User string `yaml:"user"` + } `yaml:"context"` + } `yaml:"contexts"` + Users []struct { + Name string `yaml:"name"` + } `yaml:"users"` +} + +// SecretName returns the dbprovider-compatible secret name for a database resource. +func SecretName(resourceName string) string { + return resourceName + "-conn-credential" +} + +// NormalizeSecretHost returns the dbprovider-compatible host value from a secret. +func NormalizeSecretHost(host string, namespace string) string { + if strings.Contains(host, ".svc") { + return host + } + return host + "." + namespace + ".svc" +} + +// NamespaceFromKubeconfig resolves the effective namespace from a Sealos kubeconfig. +func NamespaceFromKubeconfig(raw string) (string, error) { + var cfg kubeconfig + if err := yaml.Unmarshal([]byte(raw), &cfg); err != nil { + return "", fmt.Errorf("parse kubeconfig: %w", err) + } + + for _, ctx := range cfg.Contexts { + if ctx.Name == cfg.CurrentContext { + if strings.TrimSpace(ctx.Context.Namespace) != "" { + return strings.TrimSpace(ctx.Context.Namespace), nil + } + if strings.TrimSpace(ctx.Context.User) != "" { + return "ns-" + strings.TrimSpace(ctx.Context.User), nil + } + } + } + + if len(cfg.Contexts) > 0 { + namespace := strings.TrimSpace(cfg.Contexts[0].Context.Namespace) + if namespace != "" { + return namespace, nil + } + user := strings.TrimSpace(cfg.Contexts[0].Context.User) + if user != "" { + return "ns-" + user, nil + } + } + + if len(cfg.Users) > 0 && strings.TrimSpace(cfg.Users[0].Name) != "" { + return "ns-" + strings.TrimSpace(cfg.Users[0].Name), nil + } + + return "", errors.New("namespace not found in kubeconfig") +} + +type resolver struct { + kubeconfig string + clientset kubernetes.Interface + discoveryClient discovery.DiscoveryInterface + dynamicClient dynamic.Interface +} + +// NewBootstrapResolver creates the production Sealos bootstrap resolver. +func NewBootstrapResolver(kubeconfig string) (BootstrapResolver, error) { + if strings.TrimSpace(kubeconfig) == "" { + return nil, errors.New("kubeconfig is required") + } + + config, err := clientConfigFromKubeconfig(kubeconfig) + if err != nil { + return nil, err + } + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("create kubernetes client: %w", err) + } + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("create kubernetes dynamic client: %w", err) + } + + return &resolver{ + kubeconfig: kubeconfig, + clientset: clientset, + discoveryClient: clientset.Discovery(), + dynamicClient: dynamicClient, + }, nil +} + +// ResolveInstanceIdentity resolves the owning KubeBlocks Cluster UID for a Sealos database instance. +func (r *resolver) ResolveInstanceIdentity(ctx context.Context, input InstanceIdentityInput) (*ResolvedInstanceIdentity, error) { + resourceName := strings.TrimSpace(input.ResourceName) + if resourceName == "" { + return nil, errors.New("resourceName is required") + } + + namespace := strings.TrimSpace(input.Namespace) + if namespace == "" { + var err error + namespace, err = NamespaceFromKubeconfig(r.kubeconfig) + if err != nil { + return nil, err + } + } + + gvr, err := discoverKubeBlocksClusterResource(r.discoveryClient) + if err != nil { + return nil, err + } + + cluster, err := r.dynamicClient.Resource(gvr).Namespace(namespace).Get(ctx, resourceName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("read KubeBlocks Cluster: %w", err) + } + + uid := strings.TrimSpace(string(cluster.GetUID())) + if uid == "" { + return nil, errors.New("KubeBlocks Cluster has an empty UID") + } + + return &ResolvedInstanceIdentity{ + UID: uid, + Namespace: namespace, + ResourceName: resourceName, + }, nil +} + +func discoverKubeBlocksClusterResource(client discovery.DiscoveryInterface) (schema.GroupVersionResource, error) { + groups, err := client.ServerGroups() + if err != nil { + return schema.GroupVersionResource{}, fmt.Errorf("discover Kubernetes API groups: %w", err) + } + + for _, group := range groups.Groups { + if group.Name != "apps.kubeblocks.io" { + continue + } + + versions := group.Versions + if group.PreferredVersion.GroupVersion != "" { + versions = append([]metav1.GroupVersionForDiscovery{group.PreferredVersion}, versions...) + } + seen := make(map[string]struct{}, len(versions)) + for _, version := range versions { + if _, ok := seen[version.GroupVersion]; ok { + continue + } + seen[version.GroupVersion] = struct{}{} + + resources, err := client.ServerResourcesForGroupVersion(version.GroupVersion) + if err != nil { + return schema.GroupVersionResource{}, fmt.Errorf("discover KubeBlocks Cluster resource: %w", err) + } + for _, resource := range resources.APIResources { + if resource.Name != "clusters" { + continue + } + if !resource.Namespaced { + return schema.GroupVersionResource{}, errors.New("KubeBlocks Cluster resource is not namespaced") + } + groupVersion, err := schema.ParseGroupVersion(resources.GroupVersion) + if err != nil { + return schema.GroupVersionResource{}, fmt.Errorf("parse KubeBlocks Cluster group version: %w", err) + } + return groupVersion.WithResource(resource.Name), nil + } + } + } + + return schema.GroupVersionResource{}, errors.New("KubeBlocks Cluster resource was not discovered") +} + +// ResolveBootstrap resolves bootstrap metadata into database credentials. +func (r *resolver) ResolveBootstrap(ctx context.Context, input BootstrapInput) (*ResolvedBootstrap, error) { + spec, ok := dbTypeSpecs[input.DBType] + if !ok { + return nil, fmt.Errorf("unsupported dbType %q", input.DBType) + } + + resourceName := strings.TrimSpace(input.ResourceName) + if resourceName == "" { + return nil, errors.New("resourceName is required") + } + + namespace := strings.TrimSpace(input.Namespace) + if namespace == "" { + var err error + namespace, err = NamespaceFromKubeconfig(r.kubeconfig) + if err != nil { + return nil, err + } + } + + resolvedData, err := r.resolveConnectionData(ctx, spec, namespace, resourceName) + if err != nil { + return nil, err + } + + normalized, err := normalizeResolvedCredentials(spec, resolvedData.username, resolvedData.password, resolvedData.host, resolvedData.port) + if err != nil { + return nil, err + } + + if requestedHost := strings.TrimSpace(input.Host); requestedHost != "" && requestedHost != normalized.host { + return nil, fmt.Errorf("host mismatch: requested %q resolved %q", requestedHost, normalized.host) + } + if requestedPort := strings.TrimSpace(input.Port); requestedPort != "" && requestedPort != normalized.port { + return nil, fmt.Errorf("port mismatch: requested %q resolved %q", requestedPort, normalized.port) + } + + databaseName := strings.TrimSpace(input.DatabaseName) + if databaseName == "" { + databaseName = spec.DefaultDatabase + } + + credentials := &engine.Credentials{ + Type: spec.EngineType, + Hostname: normalized.host, + Username: normalized.username, + Password: normalized.password, + Database: databaseName, + } + if normalized.port != "" { + credentials.Advanced = []engine.Record{{Key: "Port", Value: normalized.port}} + } + + return &ResolvedBootstrap{ + Namespace: namespace, + ResourceName: resourceName, + DBType: spec.EngineType, + Host: normalized.host, + Port: normalized.port, + DatabaseName: databaseName, + K8sUsername: currentUserName(r.kubeconfig), + Credentials: credentials, + }, nil +} + +type dbTypeSpec struct { + EngineType string + UsernameKey string + PasswordKey string + HostKey string + PortKey string + DefaultDatabase string + AllowEmptyAuth bool + DefaultUsername string +} + +var dbTypeSpecs = map[string]dbTypeSpec{ + "postgresql": { + EngineType: string(engine.DatabaseType_Postgres), + UsernameKey: "username", + PasswordKey: "password", + HostKey: "host", + PortKey: "port", + DefaultDatabase: "postgres", + }, + "apecloud-mysql": { + EngineType: string(engine.DatabaseType_MySQL), + UsernameKey: "username", + PasswordKey: "password", + HostKey: "host", + PortKey: "port", + DefaultDatabase: "", + }, + "mongodb": { + EngineType: string(engine.DatabaseType_MongoDB), + UsernameKey: "username", + PasswordKey: "password", + HostKey: "host", + PortKey: "port", + DefaultDatabase: "admin", + }, + "redis": { + EngineType: string(engine.DatabaseType_Redis), + UsernameKey: "username", + PasswordKey: "password", + HostKey: "host", + PortKey: "port", + DefaultDatabase: "", + AllowEmptyAuth: true, + }, + "clickhouse": { + EngineType: string(engine.DatabaseType_ClickHouse), + UsernameKey: "username", + PasswordKey: "password", + HostKey: "host", + PortKey: "port", + DefaultDatabase: "default", + AllowEmptyAuth: true, + DefaultUsername: "default", + }, +} + +func decodeSecretField(value []byte) string { + return strings.TrimSpace(string(value)) +} + +func genericResolveFromSecret(spec dbTypeSpec, secretData map[string][]byte, namespace string) *connectionData { + return &connectionData{ + username: decodeSecretField(secretData[spec.UsernameKey]), + password: decodeSecretField(secretData[spec.PasswordKey]), + host: NormalizeSecretHost(decodeSecretField(secretData[spec.HostKey]), namespace), + port: decodeSecretField(secretData[spec.PortKey]), + } +} + +type normalizedCredentials struct { + username string + password string + host string + port string +} + +func normalizeResolvedCredentials(spec dbTypeSpec, username, password, host, port string) (*normalizedCredentials, error) { + if host == "" || port == "" { + return nil, errors.New("secret missing required fields") + } + + if spec.AllowEmptyAuth { + if username == "" && spec.DefaultUsername != "" { + username = spec.DefaultUsername + } + return &normalizedCredentials{ + username: username, + password: password, + host: host, + port: port, + }, nil + } + + if username == "" || password == "" { + return nil, errors.New("secret missing required fields") + } + + return &normalizedCredentials{ + username: username, + password: password, + host: host, + port: port, + }, nil +} + +type connectionData struct { + username string + password string + host string + port string +} + +func (r *resolver) resolveConnectionData( + ctx context.Context, + spec dbTypeSpec, + namespace string, + resourceName string, +) (*connectionData, error) { + switch spec.EngineType { + case string(engine.DatabaseType_Redis): + return r.resolveRedisConnectionData(ctx, spec, namespace, resourceName) + case string(engine.DatabaseType_MongoDB): + return r.resolveMongoDBConnectionData(ctx, spec, namespace, resourceName) + case string(engine.DatabaseType_ClickHouse): + secretData, err := r.readConnCredentialSecret(ctx, namespace, resourceName) + if err != nil { + return nil, err + } + return r.resolveClickHouseConnectionData(ctx, spec, namespace, resourceName, secretData) + default: + secretData, err := r.readConnCredentialSecret(ctx, namespace, resourceName) + if err != nil { + return nil, err + } + return genericResolveFromSecret(spec, secretData, namespace), nil + } +} + +func (r *resolver) readConnCredentialSecret(ctx context.Context, namespace, resourceName string) (map[string][]byte, error) { + secret, err := r.clientset.CoreV1().Secrets(namespace).Get(ctx, SecretName(resourceName), metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("read secret: %w", err) + } + if secret.Data == nil { + return nil, errors.New("secret is empty") + } + return secret.Data, nil +} + +func (r *resolver) resolveMongoDBConnectionData( + ctx context.Context, + spec dbTypeSpec, + namespace string, + resourceName string, +) (*connectionData, error) { + connSecret, err := r.clientset.CoreV1().Secrets(namespace).Get(ctx, SecretName(resourceName), metav1.GetOptions{}) + if err == nil { + if connSecret.Data == nil { + return nil, errors.New("secret is empty") + } + return genericResolveFromSecret(spec, connSecret.Data, namespace), nil + } + if !apierrors.IsNotFound(err) { + return nil, fmt.Errorf("read secret: %w", err) + } + + secretName := resourceName + "-mongodb-account-root" + accountSecret, err := r.clientset.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("read mongodb account secret: %w", err) + } + + service, port, err := r.findPrimaryServiceByPort(ctx, namespace, resourceName, 27017) + if err != nil { + return nil, err + } + + return &connectionData{ + username: decodeSecretField(accountSecret.Data["username"]), + password: decodeSecretField(accountSecret.Data["password"]), + host: NormalizeSecretHost(service.Name, namespace), + port: strconv.Itoa(port), + }, nil +} + +func (r *resolver) resolveRedisConnectionData( + ctx context.Context, + spec dbTypeSpec, + namespace string, + resourceName string, +) (*connectionData, error) { + secretName := resourceName + "-redis-account-default" + accountSecret, err := r.clientset.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + secretData, fallbackErr := r.readConnCredentialSecret(ctx, namespace, resourceName) + if fallbackErr != nil { + return nil, fallbackErr + } + return genericResolveFromSecret(spec, secretData, namespace), nil + } + return nil, fmt.Errorf("read redis account secret: %w", err) + } + + service, port, err := r.findServiceByPort(ctx, namespace, resourceName, 6379) + if err != nil { + return nil, err + } + + return &connectionData{ + username: decodeSecretField(accountSecret.Data["username"]), + password: decodeSecretField(accountSecret.Data["password"]), + host: NormalizeSecretHost(service.Name, namespace), + port: strconv.Itoa(port), + }, nil +} + +func (r *resolver) resolveClickHouseConnectionData( + ctx context.Context, + spec dbTypeSpec, + namespace string, + resourceName string, + secretData map[string][]byte, +) (*connectionData, error) { + if _, ok := secretData["admin-password"]; !ok { + return genericResolveFromSecret(spec, secretData, namespace), nil + } + + username := decodeSecretField(secretData["username"]) + password := decodeSecretField(secretData["admin-password"]) + tcpEndpoint := decodeSecretField(secretData["tcpEndpoint"]) + + if tcpEndpoint != "" { + host, port, err := parseEndpoint(tcpEndpoint) + if err != nil { + return nil, err + } + if port == "" { + port = "9000" + } + if host != "" { + if _, err := strconv.Atoi(port); err == nil { + return &connectionData{ + username: username, + password: password, + host: NormalizeSecretHost(host, namespace), + port: port, + }, nil + } + } + } + + service, port, err := r.findServiceByPort(ctx, namespace, resourceName, 9000) + if err != nil { + return nil, err + } + return &connectionData{ + username: username, + password: password, + host: NormalizeSecretHost(service.Name, namespace), + port: strconv.Itoa(port), + }, nil +} + +func (r *resolver) findServiceByPort(ctx context.Context, namespace, resourceName string, expectedPort int32) (*corev1.Service, int, error) { + return r.findServiceByPortWithPreferredRole(ctx, namespace, resourceName, expectedPort, "") +} + +func (r *resolver) findPrimaryServiceByPort(ctx context.Context, namespace, resourceName string, expectedPort int32) (*corev1.Service, int, error) { + return r.findServiceByPortWithPreferredRole(ctx, namespace, resourceName, expectedPort, "primary") +} + +func (r *resolver) findServiceByPortWithPreferredRole(ctx context.Context, namespace, resourceName string, expectedPort int32, preferredRole string) (*corev1.Service, int, error) { + services, err := r.clientset.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: "app.kubernetes.io/instance=" + resourceName, + }) + if err != nil { + return nil, 0, fmt.Errorf("list services: %w", err) + } + + var fallback *corev1.Service + var fallbackPort int + for _, service := range services.Items { + if service.Spec.ClusterIP == "None" { + continue + } + for _, port := range service.Spec.Ports { + if port.Port == expectedPort { + if preferredRole != "" && service.Labels["kubeblocks.io/role"] == preferredRole { + matchedService := service + return &matchedService, int(port.Port), nil + } + if fallback == nil { + fallbackService := service + fallback = &fallbackService + fallbackPort = int(port.Port) + } + } + } + } + + if fallback != nil { + return fallback, fallbackPort, nil + } + + return nil, 0, errors.New("secret missing required fields") +} + +func parseEndpoint(endpoint string) (string, string, error) { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + return "", "", errors.New("secret missing required fields") + } + + if strings.Contains(endpoint, "://") { + parts := strings.SplitN(endpoint, "://", 2) + endpoint = parts[1] + } + + host, port, ok := strings.Cut(endpoint, ":") + if !ok { + return endpoint, "", nil + } + + return host, port, nil +} + +func currentUserName(raw string) string { + var cfg kubeconfig + if err := yaml.Unmarshal([]byte(raw), &cfg); err != nil { + return "" + } + for _, ctx := range cfg.Contexts { + if ctx.Name == cfg.CurrentContext { + return strings.TrimSpace(ctx.Context.User) + } + } + if len(cfg.Contexts) > 0 { + return strings.TrimSpace(cfg.Contexts[0].Context.User) + } + if len(cfg.Users) > 0 { + return strings.TrimSpace(cfg.Users[0].Name) + } + return "" +} + +func clientConfigFromKubeconfig(raw string) (*rest.Config, error) { + config, err := clientcmd.Load([]byte(raw)) + if err != nil { + return nil, fmt.Errorf("load kubeconfig: %w", err) + } + + cluster := currentCluster(config) + if cluster != nil && !env.IsDevelopment { + cluster.Server = effectiveAPIServer() + } + + clientConfig := clientcmd.NewDefaultClientConfig(*config, &clientcmd.ConfigOverrides{}) + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, fmt.Errorf("build rest config: %w", err) + } + return restConfig, nil +} + +func currentCluster(config *api.Config) *api.Cluster { + if config == nil { + return nil + } + if ctx, ok := config.Contexts[config.CurrentContext]; ok { + return config.Clusters[ctx.Cluster] + } + if len(config.Contexts) > 0 { + for _, ctx := range config.Contexts { + return config.Clusters[ctx.Cluster] + } + } + return nil +} + +func effectiveAPIServer() string { + host := strings.TrimSpace(os.Getenv("KUBERNETES_SERVICE_HOST")) + port := strings.TrimSpace(os.Getenv("KUBERNETES_SERVICE_PORT")) + if host != "" && port != "" { + return "https://" + host + ":" + port + } + return "https://apiserver.cluster.local:6443" +} diff --git a/dataflow/core/src/sealos/resolver_test.go b/dataflow/core/src/sealos/resolver_test.go new file mode 100644 index 0000000..2043666 --- /dev/null +++ b/dataflow/core/src/sealos/resolver_test.go @@ -0,0 +1,637 @@ +package sealos + +import ( + "context" + "errors" + "testing" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + discoveryfake "k8s.io/client-go/discovery/fake" + dynamicfake "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" +) + +func TestNormalizeNamespaceMatchesDbproviderFallback(t *testing.T) { + t.Run("prefer explicit context namespace", func(t *testing.T) { + namespace, err := NamespaceFromKubeconfig(testKubeconfig("workspace-a")) + if err != nil { + t.Fatalf("namespace from kubeconfig: %v", err) + } + if namespace != "workspace-a" { + t.Fatalf("expected explicit namespace, got %q", namespace) + } + }) + + t.Run("fallback to ns-user", func(t *testing.T) { + namespace, err := NamespaceFromKubeconfig(testKubeconfig("")) + if err != nil { + t.Fatalf("namespace from kubeconfig: %v", err) + } + if namespace != "ns-demo-user" { + t.Fatalf("expected dbprovider-compatible fallback, got %q", namespace) + } + }) +} + +func TestNormalizeSecretHostMatchesDbprovider(t *testing.T) { + if got := NormalizeSecretHost("db-host", "workspace-a"); got != "db-host.workspace-a.svc" { + t.Fatalf("expected namespace svc host, got %q", got) + } + if got := NormalizeSecretHost("db-host.workspace-a.svc", "workspace-a"); got != "db-host.workspace-a.svc" { + t.Fatalf("expected .svc host to remain unchanged, got %q", got) + } + if got := NormalizeSecretHost("db-host.workspace-a.svc.cluster.local", "workspace-a"); got != "db-host.workspace-a.svc.cluster.local" { + t.Fatalf("expected cluster-local host to remain unchanged, got %q", got) + } +} + +func TestSecretNameMatchesDbproviderConvention(t *testing.T) { + if got := SecretName("my-db"); got != "my-db-conn-credential" { + t.Fatalf("expected dbprovider secret naming, got %q", got) + } +} + +func TestResolveInstanceIdentityReturnsOwningClusterUID(t *testing.T) { + resolver := newIdentityTestResolver(t, "instance-uid-1") + + identity, err := resolver.ResolveInstanceIdentity(context.Background(), InstanceIdentityInput{ + ResourceName: "my-db", + }) + if err != nil { + t.Fatalf("resolve instance identity: %v", err) + } + if identity.UID != "instance-uid-1" || identity.Namespace != "ns-admin" || identity.ResourceName != "my-db" { + t.Fatalf("expected authoritative cluster identity, got %#v", identity) + } +} + +func TestResolveInstanceIdentityFailsClosed(t *testing.T) { + t.Run("missing cluster", func(t *testing.T) { + resolver := newIdentityTestResolver(t, "") + resolver.dynamicClient = dynamicfake.NewSimpleDynamicClient(runtime.NewScheme()) + + _, err := resolver.ResolveInstanceIdentity(context.Background(), InstanceIdentityInput{ResourceName: "my-db"}) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected not found error, got %v", err) + } + }) + + t.Run("forbidden cluster", func(t *testing.T) { + resolver := newIdentityTestResolver(t, "instance-uid-1") + resolver.dynamicClient.(*dynamicfake.FakeDynamicClient).PrependReactor("get", "clusters", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewForbidden(schema.GroupResource{Group: "apps.kubeblocks.io", Resource: "clusters"}, "my-db", errors.New("denied")) + }) + + _, err := resolver.ResolveInstanceIdentity(context.Background(), InstanceIdentityInput{ResourceName: "my-db"}) + if !apierrors.IsForbidden(err) { + t.Fatalf("expected forbidden error, got %v", err) + } + }) + + t.Run("empty uid", func(t *testing.T) { + resolver := newIdentityTestResolver(t, "") + + _, err := resolver.ResolveInstanceIdentity(context.Background(), InstanceIdentityInput{ResourceName: "my-db"}) + if err == nil { + t.Fatal("expected empty UID to be rejected") + } + }) + + t.Run("discovery failure", func(t *testing.T) { + resolver := newIdentityTestResolver(t, "instance-uid-1") + discoveryErr := errors.New("discovery unavailable") + resolver.discoveryClient.(*discoveryfake.FakeDiscovery).PrependReactor("get", "group", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, discoveryErr + }) + + _, err := resolver.ResolveInstanceIdentity(context.Background(), InstanceIdentityInput{ResourceName: "my-db"}) + if !errors.Is(err, discoveryErr) { + t.Fatalf("expected discovery error, got %v", err) + } + }) + + t.Run("transport failure", func(t *testing.T) { + resolver := newIdentityTestResolver(t, "instance-uid-1") + transportErr := errors.New("transport unavailable") + resolver.dynamicClient.(*dynamicfake.FakeDynamicClient).PrependReactor("get", "clusters", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, transportErr + }) + + _, err := resolver.ResolveInstanceIdentity(context.Background(), InstanceIdentityInput{ResourceName: "my-db"}) + if !errors.Is(err, transportErr) { + t.Fatalf("expected transport error, got %v", err) + } + }) +} + +func newIdentityTestResolver(t *testing.T, uid string) *resolver { + t.Helper() + + discoveryClient := &discoveryfake.FakeDiscovery{Fake: &k8stesting.Fake{}} + discoveryClient.Resources = []*metav1.APIResourceList{ + { + GroupVersion: "apps.kubeblocks.io/v1alpha1", + APIResources: []metav1.APIResource{{Name: "clusters", Kind: "Cluster", Namespaced: true}}, + }, + } + + cluster := &unstructured.Unstructured{} + cluster.SetGroupVersionKind(schema.GroupVersionKind{Group: "apps.kubeblocks.io", Version: "v1alpha1", Kind: "Cluster"}) + cluster.SetNamespace("ns-admin") + cluster.SetName("my-db") + cluster.SetUID(types.UID(uid)) + + return &resolver{ + kubeconfig: testKubeconfig("ns-admin"), + discoveryClient: discoveryClient, + dynamicClient: dynamicfake.NewSimpleDynamicClient(runtime.NewScheme(), cluster), + } +} + +func TestNormalizeCredentialsAllowsClickHouseDefaults(t *testing.T) { + spec := dbTypeSpecs["clickhouse"] + + normalized, err := normalizeResolvedCredentials(spec, "", "", "clickhouse.ns.svc", "8123") + if err != nil { + t.Fatalf("expected clickhouse credentials to allow empty auth, got %v", err) + } + if normalized.username != "default" { + t.Fatalf("expected clickhouse username to default to 'default', got %q", normalized.username) + } + if normalized.password != "" { + t.Fatalf("expected clickhouse password to remain empty, got %q", normalized.password) + } +} + +func TestResolveBootstrapForRedisUsesAccountSecretAndService(t *testing.T) { + clientset := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-stacks-redis-account-default", + Namespace: "ns-admin", + }, + Data: map[string][]byte{ + "username": []byte("default"), + "password": []byte("redis-password"), + }, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-stacks-redis-redis", + Namespace: "ns-admin", + Labels: map[string]string{ + "app.kubernetes.io/instance": "test-stacks", + }, + }, + Spec: corev1.ServiceSpec{ + ClusterIP: "10.0.0.1", + Ports: []corev1.ServicePort{ + {Name: "redis", Port: 6379}, + }, + }, + }, + ) + + resolver := &resolver{ + kubeconfig: testKubeconfig("ns-admin"), + clientset: clientset, + } + + result, err := resolver.ResolveBootstrap(context.Background(), BootstrapInput{ + DBType: "redis", + ResourceName: "test-stacks", + }) + if err != nil { + t.Fatalf("expected redis bootstrap to succeed, got %v", err) + } + if result.Host != "test-stacks-redis-redis.ns-admin.svc" || result.Port != "6379" { + t.Fatalf("expected redis host/port from service, got %q:%q", result.Host, result.Port) + } + if result.Credentials.Username != "default" || result.Credentials.Password != "redis-password" { + t.Fatalf("expected redis credentials from account secret, got %#v", result.Credentials) + } +} + +func TestResolveBootstrapForRedisFallsBackToConnCredential(t *testing.T) { + clientset := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-db-redis-conn-credential", + Namespace: "ns-q4jurwf5", + }, + Data: map[string][]byte{ + "username": []byte("default"), + "password": []byte("redis-password"), + "host": []byte("test-db-redis-redis"), + "port": []byte("6379"), + }, + }, + ) + + resolver := &resolver{ + kubeconfig: testKubeconfig("ns-q4jurwf5"), + clientset: clientset, + } + + result, err := resolver.ResolveBootstrap(context.Background(), BootstrapInput{ + DBType: "redis", + ResourceName: "test-db-redis", + }) + if err != nil { + t.Fatalf("expected redis generic conn-credential bootstrap to succeed, got %v", err) + } + if result.Host != "test-db-redis-redis.ns-q4jurwf5.svc" || result.Port != "6379" { + t.Fatalf("expected redis host/port from conn-credential, got %q:%q", result.Host, result.Port) + } + if result.Credentials.Username != "default" || result.Credentials.Password != "redis-password" { + t.Fatalf("expected redis credentials from conn-credential, got %#v", result.Credentials) + } +} + +func TestResolveBootstrapForMongoDBUsesAccountSecretAndService(t *testing.T) { + clientset := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-db-mongodb-account-root", + Namespace: "ns-admin", + }, + Data: map[string][]byte{ + "username": []byte("root"), + "password": []byte("mongo-password"), + }, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-db-mongodb-mongodb", + Namespace: "ns-admin", + Labels: map[string]string{ + "app.kubernetes.io/instance": "test-db", + "kubeblocks.io/role": "primary", + }, + }, + Spec: corev1.ServiceSpec{ + ClusterIP: "10.0.0.3", + Ports: []corev1.ServicePort{ + {Name: "mongodb", Port: 27017}, + }, + }, + }, + ) + + resolver := &resolver{ + kubeconfig: testKubeconfig("ns-admin"), + clientset: clientset, + } + + result, err := resolver.ResolveBootstrap(context.Background(), BootstrapInput{ + DBType: "mongodb", + ResourceName: "test-db", + }) + if err != nil { + t.Fatalf("expected mongodb bootstrap to succeed, got %v", err) + } + if result.Host != "test-db-mongodb-mongodb.ns-admin.svc" || result.Port != "27017" { + t.Fatalf("expected mongodb host/port from service, got %q:%q", result.Host, result.Port) + } + if result.Credentials.Username != "root" || result.Credentials.Password != "mongo-password" { + t.Fatalf("expected mongodb credentials from account secret, got %#v", result.Credentials) + } + if result.DatabaseName != "admin" || result.Credentials.Database != "admin" { + t.Fatalf("expected mongodb default database admin, got result=%q credentials=%q", result.DatabaseName, result.Credentials.Database) + } +} + +func TestResolveBootstrapForMongoDBKeepsConnCredentialCompatibility(t *testing.T) { + clientset := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-db-conn-credential", + Namespace: "ns-admin", + }, + Data: map[string][]byte{ + "username": []byte("root"), + "password": []byte("mongo-password"), + "host": []byte("test-db-mongodb"), + "port": []byte("27017"), + }, + }, + ) + + resolver := &resolver{ + kubeconfig: testKubeconfig("ns-admin"), + clientset: clientset, + } + + result, err := resolver.ResolveBootstrap(context.Background(), BootstrapInput{ + DBType: "mongodb", + ResourceName: "test-db", + }) + if err != nil { + t.Fatalf("expected mongodb legacy conn-credential bootstrap to succeed, got %v", err) + } + if result.Host != "test-db-mongodb.ns-admin.svc" || result.Port != "27017" { + t.Fatalf("expected mongodb host/port from conn-credential, got %q:%q", result.Host, result.Port) + } + if result.Credentials.Username != "root" || result.Credentials.Password != "mongo-password" { + t.Fatalf("expected mongodb credentials from conn-credential, got %#v", result.Credentials) + } +} + +func TestResolveBootstrapForMongoDBPrefersPrimaryService(t *testing.T) { + clientset := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-db-mongodb-account-root", + Namespace: "ns-admin", + }, + Data: map[string][]byte{ + "username": []byte("root"), + "password": []byte("mongo-password"), + }, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-db-mongodb", + Namespace: "ns-admin", + Labels: map[string]string{ + "app.kubernetes.io/instance": "test-db", + }, + }, + Spec: corev1.ServiceSpec{ + ClusterIP: "10.0.0.4", + Ports: []corev1.ServicePort{ + {Name: "mongodb", Port: 27017}, + }, + }, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-db-mongodb-mongodb-ro", + Namespace: "ns-admin", + Labels: map[string]string{ + "app.kubernetes.io/instance": "test-db", + "kubeblocks.io/role": "secondary", + }, + }, + Spec: corev1.ServiceSpec{ + ClusterIP: "10.0.0.5", + Ports: []corev1.ServicePort{ + {Name: "mongodb", Port: 27017}, + }, + }, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-db-mongodb-mongodb", + Namespace: "ns-admin", + Labels: map[string]string{ + "app.kubernetes.io/instance": "test-db", + "kubeblocks.io/role": "primary", + }, + }, + Spec: corev1.ServiceSpec{ + ClusterIP: "10.0.0.6", + Ports: []corev1.ServicePort{ + {Name: "mongodb", Port: 27017}, + }, + }, + }, + ) + + resolver := &resolver{ + kubeconfig: testKubeconfig("ns-admin"), + clientset: clientset, + } + + result, err := resolver.ResolveBootstrap(context.Background(), BootstrapInput{ + DBType: "mongodb", + ResourceName: "test-db", + }) + if err != nil { + t.Fatalf("expected mongodb bootstrap to succeed, got %v", err) + } + if result.Host != "test-db-mongodb-mongodb.ns-admin.svc" || result.Port != "27017" { + t.Fatalf("expected mongodb primary service endpoint, got %q:%q", result.Host, result.Port) + } +} + +func TestResolveBootstrapForClickHouseUsesAdminPasswordAndTcpEndpoint(t *testing.T) { + clientset := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-house-conn-credential", + Namespace: "ns-admin", + }, + Data: map[string][]byte{ + "username": []byte("admin"), + "admin-password": []byte("house-password"), + "tcpEndpoint": []byte("test-house-clickhouse:9000"), + }, + }, + ) + + resolver := &resolver{ + kubeconfig: testKubeconfig("ns-admin"), + clientset: clientset, + } + + result, err := resolver.ResolveBootstrap(context.Background(), BootstrapInput{ + DBType: "clickhouse", + ResourceName: "test-house", + }) + if err != nil { + t.Fatalf("expected clickhouse bootstrap to succeed, got %v", err) + } + if result.Host != "test-house-clickhouse.ns-admin.svc" || result.Port != "9000" { + t.Fatalf("expected clickhouse tcp endpoint to be normalized, got %q:%q", result.Host, result.Port) + } + if result.Credentials.Username != "admin" || result.Credentials.Password != "house-password" { + t.Fatalf("expected clickhouse auth from secret, got %#v", result.Credentials) + } +} + +func TestResolveBootstrapForClickHouseUsesNativeServiceWhenTcpEndpointMissing(t *testing.T) { + clientset := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-house-conn-credential", + Namespace: "ns-admin", + }, + Data: map[string][]byte{ + "username": []byte("admin"), + "admin-password": []byte("house-password"), + "endpoint": []byte("test-house-clickhouse:8123"), + }, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-house-clickhouse", + Namespace: "ns-admin", + Labels: map[string]string{ + "app.kubernetes.io/instance": "test-house", + }, + }, + Spec: corev1.ServiceSpec{ + ClusterIP: "10.0.0.2", + Ports: []corev1.ServicePort{ + {Name: "http", Port: 8123}, + {Name: "tcp", Port: 9000}, + }, + }, + }, + ) + + resolver := &resolver{ + kubeconfig: testKubeconfig("ns-admin"), + clientset: clientset, + } + + result, err := resolver.ResolveBootstrap(context.Background(), BootstrapInput{ + DBType: "clickhouse", + ResourceName: "test-house", + }) + if err != nil { + t.Fatalf("expected clickhouse bootstrap to succeed, got %v", err) + } + if result.Host != "test-house-clickhouse.ns-admin.svc" || result.Port != "9000" { + t.Fatalf("expected clickhouse native service endpoint, got %q:%q", result.Host, result.Port) + } + if result.Credentials.Username != "admin" || result.Credentials.Password != "house-password" { + t.Fatalf("expected clickhouse auth from secret, got %#v", result.Credentials) + } +} + +func TestResolveBootstrapForClickHouseUsesNativeServiceWhenTcpEndpointIsTemplated(t *testing.T) { + clientset := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-house-conn-credential", + Namespace: "ns-admin", + }, + Data: map[string][]byte{ + "username": []byte("admin"), + "admin-password": []byte("house-password"), + "tcpEndpoint": []byte("test-house-zookeeper:$(SVC_PORT_tcp)"), + }, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-house-clickhouse", + Namespace: "ns-admin", + Labels: map[string]string{ + "app.kubernetes.io/instance": "test-house", + }, + }, + Spec: corev1.ServiceSpec{ + ClusterIP: "10.0.0.2", + Ports: []corev1.ServicePort{ + {Name: "http", Port: 8123}, + {Name: "tcp", Port: 9000}, + }, + }, + }, + ) + + resolver := &resolver{ + kubeconfig: testKubeconfig("ns-admin"), + clientset: clientset, + } + + result, err := resolver.ResolveBootstrap(context.Background(), BootstrapInput{ + DBType: "clickhouse", + ResourceName: "test-house", + }) + if err != nil { + t.Fatalf("expected clickhouse bootstrap to succeed, got %v", err) + } + if result.Host != "test-house-clickhouse.ns-admin.svc" || result.Port != "9000" { + t.Fatalf("expected clickhouse native service endpoint, got %q:%q", result.Host, result.Port) + } + if result.Credentials.Username != "admin" || result.Credentials.Password != "house-password" { + t.Fatalf("expected clickhouse auth from secret, got %#v", result.Credentials) + } +} + +func TestResolveBootstrapForClickHouseFallsBackToGenericFields(t *testing.T) { + clientset := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-house-conn-credential", + Namespace: "ns-admin", + }, + Data: map[string][]byte{ + "username": []byte("admin"), + "password": []byte("house-password"), + "host": []byte("test-house-clickhouse"), + "port": []byte("9000"), + }, + }, + ) + + resolver := &resolver{ + kubeconfig: testKubeconfig("ns-admin"), + clientset: clientset, + } + + result, err := resolver.ResolveBootstrap(context.Background(), BootstrapInput{ + DBType: "clickhouse", + ResourceName: "test-house", + }) + if err != nil { + t.Fatalf("expected clickhouse generic fallback to succeed, got %v", err) + } + if result.Host != "test-house-clickhouse.ns-admin.svc" || result.Port != "9000" { + t.Fatalf("expected clickhouse host/port from generic fields, got %q:%q", result.Host, result.Port) + } + if result.Credentials.Username != "admin" || result.Credentials.Password != "house-password" { + t.Fatalf("expected clickhouse auth from generic fields, got %#v", result.Credentials) + } +} + +func testKubeconfig(namespace string) string { + if namespace == "" { + return `apiVersion: v1 +kind: Config +clusters: +- name: demo-cluster + cluster: + server: https://example.invalid +users: +- name: demo-user + user: + token: test +contexts: +- name: demo-user@demo-cluster + context: + cluster: demo-cluster + user: demo-user +current-context: demo-user@demo-cluster +` + } + + return `apiVersion: v1 +kind: Config +clusters: +- name: demo-cluster + cluster: + server: https://example.invalid +users: +- name: demo-user + user: + token: test +contexts: +- name: demo-user@demo-cluster + context: + cluster: demo-cluster + user: demo-user + namespace: ` + namespace + ` +current-context: demo-user@demo-cluster +` +} diff --git a/dataflow/core/src/session/crypto.go b/dataflow/core/src/session/crypto.go new file mode 100644 index 0000000..ab467da --- /dev/null +++ b/dataflow/core/src/session/crypto.go @@ -0,0 +1,101 @@ +package session + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/clidey/whodb/core/src/engine" + "github.com/google/uuid" +) + +// CreateParams describes the metadata required to create a new auth session. +type CreateParams struct { + Source string + SealosUserID string + K8sUsername string + Namespace string + ResourceName string + InstanceUID string + DBType string + Host string + Port string + DatabaseName string + Credentials *engine.Credentials +} + +// HashToken returns the SHA-256 hash for a raw session token. +func HashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func generateOpaqueToken() (string, error) { + return uuid.NewString(), nil +} + +func normalizeKey(key string) ([]byte, error) { + if len(key) != 32 { + return nil, errors.New("session encryption key must be 32 bytes") + } + return []byte(key), nil +} + +func encryptCredentials(key []byte, credentials *engine.Credentials) ([]byte, []byte, error) { + if credentials == nil { + return nil, nil, errors.New("credentials are required") + } + + plaintext, err := json.Marshal(credentials) + if err != nil { + return nil, nil, fmt.Errorf("marshal credentials: %w", err) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, nil, fmt.Errorf("new cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, nil, fmt.Errorf("new gcm: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, nil, fmt.Errorf("read nonce: %w", err) + } + + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + return nonce, ciphertext, nil +} + +func decryptCredentials(key []byte, nonce, ciphertext []byte) (*engine.Credentials, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("new cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("new gcm: %w", err) + } + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("decrypt credentials: %w", err) + } + + var credentials engine.Credentials + if err := json.Unmarshal(plaintext, &credentials); err != nil { + return nil, fmt.Errorf("unmarshal credentials: %w", err) + } + + return &credentials, nil +} diff --git a/dataflow/core/src/session/models.go b/dataflow/core/src/session/models.go new file mode 100644 index 0000000..32bd768 --- /dev/null +++ b/dataflow/core/src/session/models.go @@ -0,0 +1,30 @@ +package session + +import "time" + +// AuthSession stores an encrypted database session resolved during Sealos bootstrap. +type AuthSession struct { + ID string `gorm:"primaryKey;type:text"` + TokenHash string `gorm:"column:token_hash;uniqueIndex;not null"` + Source string `gorm:"column:source;not null"` + SealosUserID string `gorm:"column:sealos_user_id"` + K8sUsername string `gorm:"column:k8s_username"` + Namespace string `gorm:"column:namespace;not null"` + ResourceName string `gorm:"column:resource_name"` + InstanceUID string `gorm:"column:instance_uid"` + DBType string `gorm:"column:db_type;not null"` + Host string `gorm:"column:host;not null"` + Port string `gorm:"column:port;not null"` + DatabaseName string `gorm:"column:database_name;not null"` + CredentialsNonce []byte `gorm:"column:credentials_nonce;not null"` + CredentialsCiphertext []byte `gorm:"column:credentials_ciphertext;not null"` + ExpiresAt time.Time `gorm:"column:expires_at;not null"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + LastSeenAt time.Time `gorm:"column:last_seen_at;autoCreateTime"` + RevokedAt *time.Time `gorm:"column:revoked_at"` +} + +// TableName returns the auth session table name. +func (*AuthSession) TableName() string { + return "auth_sessions" +} diff --git a/dataflow/core/src/session/repository.go b/dataflow/core/src/session/repository.go new file mode 100644 index 0000000..a4725d2 --- /dev/null +++ b/dataflow/core/src/session/repository.go @@ -0,0 +1,78 @@ +package session + +import ( + "context" + "errors" + "fmt" + "time" + + "gorm.io/gorm" +) + +// Repository persists auth sessions in the metadata database. +type Repository struct { + db *gorm.DB +} + +// NewRepository creates a session repository and migrates its schema. +func NewRepository(db *gorm.DB) (*Repository, error) { + if db == nil { + return nil, errors.New("missing session database") + } + + repo := &Repository{db: db} + if err := repo.db.AutoMigrate(&AuthSession{}); err != nil { + return nil, fmt.Errorf("auto-migrate auth sessions: %w", err) + } + + return repo, nil +} + +// Create inserts a new auth session row. +func (r *Repository) Create(ctx context.Context, record *AuthSession) error { + return r.db.WithContext(ctx).Create(record).Error +} + +// GetByTokenHash loads an auth session row by token hash. +func (r *Repository) GetByTokenHash(ctx context.Context, tokenHash string) (*AuthSession, error) { + var record AuthSession + err := r.db.WithContext(ctx).First(&record, "token_hash = ?", tokenHash).Error + if err != nil { + return nil, err + } + return &record, nil +} + +// RevokeByID marks an auth session as revoked. +func (r *Repository) RevokeByID(ctx context.Context, id string) error { + now := time.Now().UTC() + return r.db.WithContext(ctx).Model(&AuthSession{}). + Where("id = ?", id). + Update("revoked_at", &now).Error +} + +// RevokeStaleSealosSessions revokes unverified or replaced Sealos Database Sessions for one Database Instance. +func (r *Repository) RevokeStaleSealosSessions(ctx context.Context, namespace, resourceName, currentUID string) error { + now := time.Now().UTC() + return r.db.WithContext(ctx).Model(&AuthSession{}). + Where("source = ?", "sealos"). + Where("namespace = ?", namespace). + Where("resource_name = ?", resourceName). + Where("revoked_at IS NULL"). + Where("instance_uid IS NULL OR instance_uid = ? OR instance_uid <> ?", "", currentUID). + Update("revoked_at", &now).Error +} + +// TouchLastSeen updates the last-seen timestamp for an auth session. +func (r *Repository) TouchLastSeen(ctx context.Context, id string) error { + return r.db.WithContext(ctx).Model(&AuthSession{}). + Where("id = ?", id). + Update("last_seen_at", time.Now().UTC()).Error +} + +// DeleteExpired removes expired auth sessions. +func (r *Repository) DeleteExpired(ctx context.Context, now time.Time) error { + return r.db.WithContext(ctx). + Where("expires_at < ?", now.UTC()). + Delete(&AuthSession{}).Error +} diff --git a/dataflow/core/src/session/service.go b/dataflow/core/src/session/service.go new file mode 100644 index 0000000..6c031bc --- /dev/null +++ b/dataflow/core/src/session/service.go @@ -0,0 +1,226 @@ +package session + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/plugins" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var ( + // ErrSessionStoreNotConfigured is returned when no session or metadata DSN is configured. + ErrSessionStoreNotConfigured = errors.New("auth session store is not configured; set WHODB_SESSION_DSN or WHODB_METADATA_DSN") + // ErrSessionNotFound is returned when a session token does not exist. + ErrSessionNotFound = errors.New("auth session not found") + // ErrSessionExpired is returned when a session token is past its expiry time. + ErrSessionExpired = errors.New("auth session expired") + // ErrSessionRevoked is returned when a session token has been revoked. + ErrSessionRevoked = errors.New("auth session revoked") +) + +// Service manages server-owned auth sessions. +type Service struct { + repo *Repository + key []byte + ttl time.Duration + cleanupInterval time.Duration + lastCleanupAt time.Time + cleanupMu sync.Mutex +} + +// DefaultServiceFactory creates the shared auth session service. +var DefaultServiceFactory = NewServiceFromEnv + +var ( + defaultServiceMu sync.Mutex + defaultService *Service + defaultServiceInitialized bool + defaultServiceErr error +) + +// NewService creates a session service from an existing gorm database handle. +func NewService(db *gorm.DB, encryptionKey string, ttl time.Duration) (*Service, error) { + key, err := normalizeKey(encryptionKey) + if err != nil { + return nil, err + } + + repo, err := NewRepository(db) + if err != nil { + return nil, err + } + + if ttl == 0 { + ttl = 24 * time.Hour + } + + return &Service{ + repo: repo, + key: key, + ttl: ttl, + cleanupInterval: time.Hour, + }, nil +} + +// NewServiceFromEnv creates a session service from environment configuration. +func NewServiceFromEnv() (*Service, error) { + dsn := env.GetSessionDSN() + if dsn == "" { + return nil, ErrSessionStoreNotConfigured + } + + ttl := 24 * time.Hour + parsed, err := time.ParseDuration(env.GetSessionTTL()) + if err != nil { + return nil, fmt.Errorf("parse session ttl: %w", err) + } + ttl = parsed + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(plugins.GetGormLogConfig()), + }) + if err != nil { + return nil, fmt.Errorf("open session database: %w", err) + } + if err := plugins.ConfigureConnectionPool(db); err != nil { + return nil, fmt.Errorf("configure session connection pool: %w", err) + } + + return NewService(db, env.GetSessionEncryptionKey(), ttl) +} + +// GetDefaultService returns the shared auth session service. +func GetDefaultService() (*Service, error) { + defaultServiceMu.Lock() + defer defaultServiceMu.Unlock() + + if defaultServiceInitialized { + return defaultService, defaultServiceErr + } + + defaultService, defaultServiceErr = DefaultServiceFactory() + defaultServiceInitialized = true + return defaultService, defaultServiceErr +} + +// ResetDefaultService clears the shared auth session service for tests. +func ResetDefaultService() { + defaultServiceMu.Lock() + defer defaultServiceMu.Unlock() + + defaultService = nil + defaultServiceErr = nil + defaultServiceInitialized = false +} + +// Create stores encrypted credentials and returns the created session plus raw token. +func (s *Service) Create(ctx context.Context, params CreateParams) (*AuthSession, string, error) { + _ = s.maybeDeleteExpiredSessions(ctx, time.Now().UTC()) + + token, err := generateOpaqueToken() + if err != nil { + return nil, "", fmt.Errorf("generate token: %w", err) + } + + nonce, ciphertext, err := encryptCredentials(s.key, params.Credentials) + if err != nil { + return nil, "", err + } + + now := time.Now().UTC() + record := &AuthSession{ + ID: uuidString(), + TokenHash: HashToken(token), + Source: params.Source, + SealosUserID: params.SealosUserID, + K8sUsername: params.K8sUsername, + Namespace: params.Namespace, + ResourceName: params.ResourceName, + InstanceUID: params.InstanceUID, + DBType: params.DBType, + Host: params.Host, + Port: params.Port, + DatabaseName: params.DatabaseName, + CredentialsNonce: nonce, + CredentialsCiphertext: ciphertext, + ExpiresAt: now.Add(s.ttl), + CreatedAt: now, + LastSeenAt: now, + } + + if err := s.repo.Create(ctx, record); err != nil { + return nil, "", err + } + + return record, token, nil +} + +// ResolveToken loads and decrypts a session token. +func (s *Service) ResolveToken(ctx context.Context, token string) (*engine.Credentials, *AuthSession, error) { + _ = s.maybeDeleteExpiredSessions(ctx, time.Now().UTC()) + + record, err := s.repo.GetByTokenHash(ctx, HashToken(token)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, ErrSessionNotFound + } + return nil, nil, err + } + + now := time.Now().UTC() + if record.RevokedAt != nil { + return nil, record, ErrSessionRevoked + } + if record.ExpiresAt.Before(now) { + return nil, record, ErrSessionExpired + } + + credentials, err := decryptCredentials(s.key, record.CredentialsNonce, record.CredentialsCiphertext) + if err != nil { + return nil, record, err + } + + if err := s.repo.TouchLastSeen(ctx, record.ID); err != nil { + return nil, record, err + } + + return credentials, record, nil +} + +// Revoke marks a session row as revoked. +func (s *Service) Revoke(ctx context.Context, id string) error { + return s.repo.RevokeByID(ctx, id) +} + +// RevokeStaleSealosSessions revokes unverified or replaced Sealos Database Sessions for one Database Instance. +func (s *Service) RevokeStaleSealosSessions(ctx context.Context, namespace, resourceName, currentUID string) error { + return s.repo.RevokeStaleSealosSessions(ctx, namespace, resourceName, currentUID) +} + +func uuidString() string { + return fmt.Sprintf("%d", time.Now().UTC().UnixNano()) +} + +func (s *Service) maybeDeleteExpiredSessions(ctx context.Context, now time.Time) error { + s.cleanupMu.Lock() + defer s.cleanupMu.Unlock() + + if s.cleanupInterval > 0 && !s.lastCleanupAt.IsZero() && now.Sub(s.lastCleanupAt) < s.cleanupInterval { + return nil + } + + if err := s.repo.DeleteExpired(ctx, now); err != nil { + return err + } + + s.lastCleanupAt = now + return nil +} diff --git a/dataflow/core/src/session/service_test.go b/dataflow/core/src/session/service_test.go new file mode 100644 index 0000000..b03ac59 --- /dev/null +++ b/dataflow/core/src/session/service_test.go @@ -0,0 +1,270 @@ +package session + +import ( + "context" + "testing" + "time" + + "github.com/clidey/whodb/core/src/engine" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestServiceCreateAndResolveToken(t *testing.T) { + service := newSessionServiceForTest(t, "12345678901234567890123456789012", time.Hour) + + creds := &engine.Credentials{ + Type: "Postgres", + Hostname: "db.ns.svc", + Username: "postgres", + Password: "secret", + Database: "postgres", + Advanced: []engine.Record{{Key: "Port", Value: "5432"}}, + } + + record, token, err := service.Create(context.Background(), CreateParams{ + Source: "sealos", + Namespace: "ns-demo", + ResourceName: "my-db", + InstanceUID: "instance-uid-1", + DBType: "Postgres", + Host: "db.ns.svc", + Port: "5432", + DatabaseName: "postgres", + Credentials: creds, + }) + if err != nil { + t.Fatalf("create session: %v", err) + } + if token == "" { + t.Fatalf("expected opaque token to be returned") + } + if record.TokenHash == token { + t.Fatalf("expected stored token hash to differ from raw token") + } + + resolved, loaded, err := service.ResolveToken(context.Background(), token) + if err != nil { + t.Fatalf("resolve token: %v", err) + } + if loaded == nil || loaded.ResourceName != "my-db" || loaded.InstanceUID != "instance-uid-1" { + t.Fatalf("expected session metadata to roundtrip, got %#v", loaded) + } + if resolved == nil || resolved.Username != "postgres" || resolved.Password != "secret" { + t.Fatalf("expected credentials to decrypt, got %#v", resolved) + } +} + +func TestServiceRevokesStaleSealosSessionsForTarget(t *testing.T) { + service := newSessionServiceForTest(t, "12345678901234567890123456789012", time.Hour) + ctx := context.Background() + + type sessionFixture struct { + name string + source string + namespace string + resourceName string + instanceUID string + wantRevoked bool + } + + fixtures := []sessionFixture{ + {name: "replaced instance", source: "sealos", namespace: "ns-demo", resourceName: "my-db", instanceUID: "old-uid", wantRevoked: true}, + {name: "legacy instance", source: "sealos", namespace: "ns-demo", resourceName: "my-db", instanceUID: "", wantRevoked: true}, + {name: "current instance", source: "sealos", namespace: "ns-demo", resourceName: "my-db", instanceUID: "current-uid"}, + {name: "other namespace", source: "sealos", namespace: "ns-other", resourceName: "my-db", instanceUID: "old-uid"}, + {name: "other resource", source: "sealos", namespace: "ns-demo", resourceName: "other-db", instanceUID: "old-uid"}, + {name: "standalone session", source: "standalone", namespace: "ns-demo", resourceName: "my-db", instanceUID: "old-uid"}, + } + + tokens := make(map[string]string, len(fixtures)) + for _, fixture := range fixtures { + record, token, err := service.Create(ctx, CreateParams{ + Source: fixture.source, + Namespace: fixture.namespace, + ResourceName: fixture.resourceName, + InstanceUID: fixture.instanceUID, + DBType: "Postgres", + Host: "db.ns.svc", + Port: "5432", + DatabaseName: "postgres", + Credentials: &engine.Credentials{ + Type: "Postgres", + Hostname: "db.ns.svc", + Username: "postgres", + Password: "secret", + Database: "postgres", + }, + }) + if err != nil { + t.Fatalf("create %s: %v", fixture.name, err) + } + if fixture.name == "legacy instance" { + if err := service.repo.db.WithContext(ctx).Model(&AuthSession{}). + Where("id = ?", record.ID). + Update("instance_uid", nil).Error; err != nil { + t.Fatalf("mark legacy session UID null: %v", err) + } + } + tokens[fixture.name] = token + } + + if err := service.RevokeStaleSealosSessions(ctx, "ns-demo", "my-db", "current-uid"); err != nil { + t.Fatalf("revoke stale target sessions: %v", err) + } + + for _, fixture := range fixtures { + _, _, err := service.ResolveToken(ctx, tokens[fixture.name]) + if fixture.wantRevoked && err != ErrSessionRevoked { + t.Errorf("%s: expected ErrSessionRevoked, got %v", fixture.name, err) + } + if !fixture.wantRevoked && err != nil { + t.Errorf("%s: expected session to remain valid, got %v", fixture.name, err) + } + } +} + +func TestServiceRejectsExpiredOrRevokedToken(t *testing.T) { + t.Run("expired", func(t *testing.T) { + service := newSessionServiceForTest(t, "12345678901234567890123456789012", -time.Minute) + _, token, err := service.Create(context.Background(), CreateParams{ + Source: "sealos", + Namespace: "ns-demo", + ResourceName: "my-db", + DBType: "Postgres", + Host: "db.ns.svc", + Port: "5432", + DatabaseName: "postgres", + Credentials: &engine.Credentials{ + Type: "Postgres", + Hostname: "db.ns.svc", + Username: "postgres", + Password: "secret", + Database: "postgres", + }, + }) + if err != nil { + t.Fatalf("create expired session: %v", err) + } + + if _, _, err := service.ResolveToken(context.Background(), token); err != ErrSessionExpired { + t.Fatalf("expected ErrSessionExpired, got %v", err) + } + }) + + t.Run("revoked", func(t *testing.T) { + service := newSessionServiceForTest(t, "12345678901234567890123456789012", time.Hour) + record, token, err := service.Create(context.Background(), CreateParams{ + Source: "sealos", + Namespace: "ns-demo", + ResourceName: "my-db", + DBType: "Postgres", + Host: "db.ns.svc", + Port: "5432", + DatabaseName: "postgres", + Credentials: &engine.Credentials{ + Type: "Postgres", + Hostname: "db.ns.svc", + Username: "postgres", + Password: "secret", + Database: "postgres", + }, + }) + if err != nil { + t.Fatalf("create session: %v", err) + } + if err := service.Revoke(context.Background(), record.ID); err != nil { + t.Fatalf("revoke session: %v", err) + } + + if _, _, err := service.ResolveToken(context.Background(), token); err != ErrSessionRevoked { + t.Fatalf("expected ErrSessionRevoked, got %v", err) + } + }) +} + +func TestHashTokenIsStable(t *testing.T) { + first := HashToken("token-value") + second := HashToken("token-value") + if first == "" || first != second { + t.Fatalf("expected stable token hash, got %q and %q", first, second) + } +} + +func TestServiceMaybeDeletesExpiredSessionsOnInterval(t *testing.T) { + service := newSessionServiceForTest(t, "12345678901234567890123456789012", time.Hour) + service.cleanupInterval = time.Hour + + expired := &AuthSession{ + ID: "expired-session", + TokenHash: "expired-token", + Source: "sealos", + Namespace: "ns-demo", + ResourceName: "my-db", + DBType: "Postgres", + Host: "db.ns.svc", + Port: "5432", + DatabaseName: "postgres", + CredentialsNonce: []byte("nonce"), + CredentialsCiphertext: []byte("ciphertext"), + ExpiresAt: time.Now().UTC().Add(-time.Hour), + CreatedAt: time.Now().UTC().Add(-2 * time.Hour), + LastSeenAt: time.Now().UTC().Add(-2 * time.Hour), + } + if err := service.repo.Create(context.Background(), expired); err != nil { + t.Fatalf("insert expired session: %v", err) + } + + if err := service.maybeDeleteExpiredSessions(context.Background(), time.Now().UTC()); err != nil { + t.Fatalf("first cleanup should succeed: %v", err) + } + + if _, err := service.repo.GetByTokenHash(context.Background(), expired.TokenHash); err == nil { + t.Fatalf("expected expired session to be deleted") + } + + service.lastCleanupAt = time.Now().UTC() + freshExpired := &AuthSession{ + ID: "fresh-expired-session", + TokenHash: "fresh-expired-token", + Source: "sealos", + Namespace: "ns-demo", + ResourceName: "my-db", + DBType: "Postgres", + Host: "db.ns.svc", + Port: "5432", + DatabaseName: "postgres", + CredentialsNonce: []byte("nonce"), + CredentialsCiphertext: []byte("ciphertext"), + ExpiresAt: time.Now().UTC().Add(-time.Minute), + CreatedAt: time.Now().UTC().Add(-2 * time.Hour), + LastSeenAt: time.Now().UTC().Add(-2 * time.Hour), + } + if err := service.repo.Create(context.Background(), freshExpired); err != nil { + t.Fatalf("insert second expired session: %v", err) + } + + if err := service.maybeDeleteExpiredSessions(context.Background(), time.Now().UTC()); err != nil { + t.Fatalf("second cleanup should succeed: %v", err) + } + + if _, err := service.repo.GetByTokenHash(context.Background(), freshExpired.TokenHash); err != nil { + t.Fatalf("expected cleanup interval to skip deletion, got %v", err) + } +} + +func newSessionServiceForTest(t *testing.T, key string, ttl time.Duration) *Service { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + service, err := NewService(db, key, ttl) + if err != nil { + t.Fatalf("new service: %v", err) + } + + return service +} diff --git a/dataflow/core/src/settings/providers.go b/dataflow/core/src/settings/providers.go new file mode 100644 index 0000000..cad5edb --- /dev/null +++ b/dataflow/core/src/settings/providers.go @@ -0,0 +1,397 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package settings + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + awsinfra "github.com/clidey/whodb/core/src/aws" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/envconfig" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/providers" + awsprovider "github.com/clidey/whodb/core/src/providers/aws" +) + +var ( + // ErrProviderNotFound indicates the provider doesn't exist. + ErrProviderNotFound = errors.New("aws provider not found") + + // ErrProviderAlreadyExists indicates a provider with this ID exists. + ErrProviderAlreadyExists = errors.New("aws provider already exists") +) + +// AWSProviderConfig holds the configuration for an AWS provider. +// All fields are non-sensitive — no credentials are stored. +type AWSProviderConfig struct { + ID string `json:"id"` + Name string `json:"name"` + Region string `json:"region"` + AuthMethod string `json:"authMethod"` + ProfileName string `json:"profileName,omitempty"` + DiscoverRDS bool `json:"discoverRDS"` + DiscoverElastiCache bool `json:"discoverElastiCache"` + DiscoverDocumentDB bool `json:"discoverDocumentDB"` +} + +// AWSProviderState holds the runtime state of an AWS provider including +// its configuration, provider instance, status, and discovery statistics. +type AWSProviderState struct { + Config *AWSProviderConfig + Provider *awsprovider.Provider + Status string + LastDiscoveryAt *time.Time + DiscoveredCount int + Error string +} + +var ( + awsProvidersMu sync.RWMutex + awsProviders = make(map[string]*AWSProviderState) + skipPersist bool +) + +// GetAWSProviders returns all configured AWS provider states. +func GetAWSProviders() []*AWSProviderState { + awsProvidersMu.RLock() + defer awsProvidersMu.RUnlock() + + result := make([]*AWSProviderState, 0, len(awsProviders)) + for _, state := range awsProviders { + result = append(result, state) + } + return result +} + +// GetAWSProvider retrieves an AWS provider state by ID. +// Returns ErrProviderNotFound if the provider doesn't exist. +func GetAWSProvider(id string) (*AWSProviderState, error) { + awsProvidersMu.RLock() + defer awsProvidersMu.RUnlock() + + state, exists := awsProviders[id] + if !exists { + return nil, ErrProviderNotFound + } + return state, nil +} + +// AddAWSProvider creates and registers a new AWS provider with the given configuration. +// Returns ErrProviderAlreadyExists if a provider with the same ID already exists. +func AddAWSProvider(cfg *AWSProviderConfig) (*AWSProviderState, error) { + awsProvidersMu.Lock() + + if _, exists := awsProviders[cfg.ID]; exists { + awsProvidersMu.Unlock() + return nil, ErrProviderAlreadyExists + } + + providerCfg := configToProviderConfig(cfg) + provider, err := awsprovider.New(providerCfg) + if err != nil { + awsProvidersMu.Unlock() + return nil, err + } + + registry := providers.GetDefaultRegistry() + if err := registry.Register(provider); err != nil { + awsProvidersMu.Unlock() + return nil, err + } + + state := &AWSProviderState{ + Config: cfg, + Provider: provider, + Status: "Connected", + } + + awsProviders[cfg.ID] = state + awsProvidersMu.Unlock() + + if !skipPersist { + if err := saveProvidersToFile(); err != nil { + log.Warnf("Failed to persist provider after add: %v", err) + } + } + + return state, nil +} + +// UpdateAWSProvider updates an existing AWS provider with new configuration. +// The new provider is created first — if creation fails, the old provider is untouched. +// Returns ErrProviderNotFound if the provider doesn't exist. +func UpdateAWSProvider(id string, cfg *AWSProviderConfig) (*AWSProviderState, error) { + cfg.ID = id + providerCfg := configToProviderConfig(cfg) + provider, err := awsprovider.New(providerCfg) + if err != nil { + return nil, err + } + + awsProvidersMu.Lock() + if _, exists := awsProviders[id]; !exists { + awsProvidersMu.Unlock() + return nil, ErrProviderNotFound + } + + registry := providers.GetDefaultRegistry() + if err := registry.Unregister(id); err != nil { + log.Warnf("Failed to unregister provider %s during update: %v", id, err) + } + + if err := registry.Register(provider); err != nil { + awsProvidersMu.Unlock() + return nil, err + } + + state := &AWSProviderState{ + Config: cfg, + Provider: provider, + Status: "Connected", + } + + awsProviders[id] = state + awsProvidersMu.Unlock() + + if !skipPersist { + if err := saveProvidersToFile(); err != nil { + log.Warnf("Failed to persist provider after update: %v", err) + } + } + + return state, nil +} + +// RemoveAWSProvider removes an AWS provider by ID. +// Returns ErrProviderNotFound if the provider doesn't exist. +func RemoveAWSProvider(id string) error { + awsProvidersMu.Lock() + + if _, exists := awsProviders[id]; !exists { + awsProvidersMu.Unlock() + return ErrProviderNotFound + } + + registry := providers.GetDefaultRegistry() + if err := registry.Unregister(id); err != nil { + log.Warnf("Failed to unregister provider %s during removal: %v", id, err) + } + + delete(awsProviders, id) + awsProvidersMu.Unlock() + + if !skipPersist { + if err := saveProvidersToFile(); err != nil { + log.Warnf("Failed to persist provider after remove: %v", err) + } + } + + return nil +} + +// TestAWSProvider tests the AWS connection for a provider and updates its status. +// Returns the new status ("Connected" or "Error") and any error encountered. +func TestAWSProvider(id string) (string, error) { + awsProvidersMu.RLock() + state, exists := awsProviders[id] + if !exists { + awsProvidersMu.RUnlock() + return "Disconnected", ErrProviderNotFound + } + provider := state.Provider + awsProvidersMu.RUnlock() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + testErr := provider.TestConnection(ctx) + + awsProvidersMu.Lock() + state, exists = awsProviders[id] + if !exists { + awsProvidersMu.Unlock() + return "Disconnected", ErrProviderNotFound + } + + if testErr != nil { + state.Status = "Error" + state.Error = testErr.Error() + awsProvidersMu.Unlock() + return "Error", testErr + } + + state.Status = "Connected" + state.Error = "" + awsProvidersMu.Unlock() + return "Connected", nil +} + +// RefreshAWSProvider triggers a re-discovery of connections for the specified provider. +// Updates the provider's status, discovered count, and last discovery time. +func RefreshAWSProvider(id string) (*AWSProviderState, error) { + log.Infof("RefreshAWSProvider called for id=%s", id) + + awsProvidersMu.Lock() + state, exists := awsProviders[id] + if !exists { + awsProvidersMu.Unlock() + log.Warnf("RefreshAWSProvider: provider not found id=%s", id) + return nil, ErrProviderNotFound + } + state.Status = "Discovering" + log.Infof("RefreshAWSProvider: provider found, id=%s, region=%s, authMethod=%s", + state.Config.ID, state.Config.Region, state.Config.AuthMethod) + awsProvidersMu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + registry := providers.GetDefaultRegistry() + log.Infof("RefreshAWSProvider: calling registry.RefreshDiscovery for id=%s", id) + conns, err := registry.RefreshDiscovery(ctx, id) + log.Infof("RefreshAWSProvider: registry.RefreshDiscovery returned %d connections, err=%v", len(conns), err) + + // Run connectivity checks on refresh (not on cached reads) + if len(conns) > 0 { + awsprovider.CheckConnectivity(conns) + } + + awsProvidersMu.Lock() + defer awsProvidersMu.Unlock() + + state, exists = awsProviders[id] + if !exists { + return nil, ErrProviderNotFound + } + + now := time.Now() + state.LastDiscoveryAt = &now + state.DiscoveredCount = len(conns) + + if err != nil { + if len(conns) > 0 { + state.Status = "Connected" + state.Error = err.Error() + return state, nil + } + state.Status = "Error" + state.Error = err.Error() + return state, err + } + + state.Status = "Connected" + state.Error = "" + return state, nil +} + +func configToProviderConfig(cfg *AWSProviderConfig) *awsprovider.Config { + return &awsprovider.Config{ + ID: cfg.ID, + Name: cfg.Name, + Region: cfg.Region, + AuthMethod: awsinfra.AuthMethod(cfg.AuthMethod), + ProfileName: cfg.ProfileName, + DiscoverRDS: cfg.DiscoverRDS, + DiscoverElastiCache: cfg.DiscoverElastiCache, + DiscoverDocumentDB: cfg.DiscoverDocumentDB, + } +} + +// GenerateProviderID creates a unique provider ID from a name and region. +// The format is "aws-{sanitized-name}-{region}". +func GenerateProviderID(name, region string) string { + return "aws-" + sanitizeID(name) + "-" + region +} + +// InitAWSProvidersFromEnv initializes AWS providers from the WHODB_AWS_PROVIDER +// environment variable. Each provider configuration is parsed and registered. +func InitAWSProvidersFromEnv() error { + if !env.IsAWSProviderEnabled { + return nil + } + + envConfigs, err := envconfig.GetAWSProvidersFromEnv() + if err != nil { + return err + } + + if len(envConfigs) == 0 { + return nil + } + + for i, envCfg := range envConfigs { + name := envCfg.Name + if name == "" { + name = fmt.Sprintf("AWS-%d", i+1) + } + id := GenerateProviderID(name, envCfg.Region) + + authMethod := "default" + if envCfg.ProfileName != "" { + authMethod = "profile" + } + + discoverRDS := true + if envCfg.DiscoverRDS != nil { + discoverRDS = *envCfg.DiscoverRDS + } + + discoverElastiCache := true + if envCfg.DiscoverElastiCache != nil { + discoverElastiCache = *envCfg.DiscoverElastiCache + } + + discoverDocumentDB := true + if envCfg.DiscoverDocumentDB != nil { + discoverDocumentDB = *envCfg.DiscoverDocumentDB + } + + cfg := &AWSProviderConfig{ + ID: id, + Name: name, + Region: envCfg.Region, + AuthMethod: authMethod, + ProfileName: envCfg.ProfileName, + DiscoverRDS: discoverRDS, + DiscoverElastiCache: discoverElastiCache, + DiscoverDocumentDB: discoverDocumentDB, + } + + if _, err := AddAWSProvider(cfg); err != nil { + log.Warnf("Failed to initialize AWS provider %s: %v", name, err) + } else { + log.Infof("Initialized AWS provider: %s (%s)", name, envCfg.Region) + } + } + + return nil +} + +func sanitizeID(s string) string { + result := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' { + result = append(result, c) + } + } + return string(result) +} diff --git a/dataflow/core/src/settings/providers_store.go b/dataflow/core/src/settings/providers_store.go new file mode 100644 index 0000000..2743c69 --- /dev/null +++ b/dataflow/core/src/settings/providers_store.go @@ -0,0 +1,144 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package settings + +import ( + "github.com/clidey/whodb/core/src/common/config" + "github.com/clidey/whodb/core/src/common/datadir" + "github.com/clidey/whodb/core/src/env" + "github.com/clidey/whodb/core/src/log" +) + +// awsSection is the structure stored in the "aws" section of config.json. +type awsSection struct { + Providers []persistedProviderConfig `json:"providers"` +} + +// persistedProviderConfig is the on-disk format for provider configs. +type persistedProviderConfig struct { + ID string `json:"id"` + Name string `json:"name"` + Region string `json:"region"` + AuthMethod string `json:"authMethod"` + ProfileName string `json:"profileName,omitempty"` + DiscoverRDS bool `json:"discoverRDS"` + DiscoverElastiCache bool `json:"discoverElastiCache"` + DiscoverDocumentDB bool `json:"discoverDocumentDB"` +} + +// getConfigOptions returns the datadir options for this environment. +func getConfigOptions() datadir.Options { + return datadir.Options{ + AppName: "whodb", + EnterpriseEdition: env.IsEnterpriseEdition, + Development: env.IsDevelopment, + } +} + +// saveProvidersToFile persists the current provider configs to the unified config file. +// This is called automatically after add/update/remove operations. +func saveProvidersToFile() error { + awsProvidersMu.RLock() + providers := make([]persistedProviderConfig, 0, len(awsProviders)) + for _, state := range awsProviders { + providers = append(providers, persistedProviderConfig{ + ID: state.Config.ID, + Name: state.Config.Name, + Region: state.Config.Region, + AuthMethod: state.Config.AuthMethod, + ProfileName: state.Config.ProfileName, + DiscoverRDS: state.Config.DiscoverRDS, + DiscoverElastiCache: state.Config.DiscoverElastiCache, + DiscoverDocumentDB: state.Config.DiscoverDocumentDB, + }) + } + awsProvidersMu.RUnlock() + + section := awsSection{Providers: providers} + opts := getConfigOptions() + + if err := config.WriteSection(config.SectionAWS, section, opts); err != nil { + log.Warnf("Failed to save AWS providers: %v", err) + return err + } + + log.Debugf("Saved %d AWS provider(s) to config", len(providers)) + return nil +} + +// LoadProvidersFromFile loads persisted provider configs from the unified config file. +// This should be called during server startup. +func LoadProvidersFromFile() error { + if !env.IsAWSProviderEnabled { + return nil + } + opts := getConfigOptions() + + var section awsSection + if err := config.ReadSection(config.SectionAWS, §ion, opts); err != nil { + log.Warnf("Failed to read AWS providers from config: %v", err) + return err + } + + if len(section.Providers) == 0 { + return nil + } + + // Prevent circular saves during load + skipPersist = true + defer func() { skipPersist = false }() + + loadedCount := 0 + for _, cfg := range section.Providers { + // Convert to AWSProviderConfig + providerCfg := &AWSProviderConfig{ + ID: cfg.ID, + Name: cfg.Name, + Region: cfg.Region, + AuthMethod: cfg.AuthMethod, + ProfileName: cfg.ProfileName, + DiscoverRDS: cfg.DiscoverRDS, + DiscoverElastiCache: cfg.DiscoverElastiCache, + DiscoverDocumentDB: cfg.DiscoverDocumentDB, + } + + // Check if this provider already exists (e.g., from env var) + awsProvidersMu.RLock() + _, exists := awsProviders[cfg.ID] + awsProvidersMu.RUnlock() + + if exists { + log.Debugf("Skipping persisted provider %s - already registered", cfg.ID) + continue + } + + // Add the provider + _, err := AddAWSProvider(providerCfg) + if err != nil { + log.Warnf("Failed to load persisted provider %s: %v", cfg.Name, err) + continue + } + + loadedCount++ + } + + if loadedCount > 0 { + log.Infof("Loaded %d AWS provider(s) from config", loadedCount) + } + + return nil +} diff --git a/dataflow/core/src/settings/providers_store_test.go b/dataflow/core/src/settings/providers_store_test.go new file mode 100644 index 0000000..c6cac08 --- /dev/null +++ b/dataflow/core/src/settings/providers_store_test.go @@ -0,0 +1,142 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package settings + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/clidey/whodb/core/src/common/config" +) + +func TestPersistedProviderConfig_DoesNotContainCredentials(t *testing.T) { + // Verify that persistedProviderConfig struct does not have credential fields + cfg := persistedProviderConfig{ + ID: "test-id", + Name: "Test", + Region: "us-west-2", + AuthMethod: "default", + ProfileName: "default", + DiscoverRDS: true, + DiscoverElastiCache: true, + DiscoverDocumentDB: true, + } + + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + str := string(data) + + // The struct should not have AccessKeyID, SecretAccessKey, or SessionToken fields + if stringContains(str, "accessKeyID") || stringContains(str, "AccessKeyID") { + t.Error("persistedProviderConfig should not contain accessKeyID field") + } + if stringContains(str, "secretAccessKey") || stringContains(str, "SecretAccessKey") { + t.Error("persistedProviderConfig should not contain secretAccessKey field") + } + if stringContains(str, "sessionToken") || stringContains(str, "SessionToken") { + t.Error("persistedProviderConfig should not contain sessionToken field") + } +} + +func TestAwsSection_JSONFormat(t *testing.T) { + section := awsSection{ + Providers: []persistedProviderConfig{ + { + ID: "provider-1", + Name: "Provider One", + Region: "us-west-2", + AuthMethod: "default", + }, + { + ID: "provider-2", + Name: "Provider Two", + Region: "eu-west-1", + AuthMethod: "profile", + ProfileName: "production", + }, + }, + } + + data, err := json.MarshalIndent(section, "", " ") + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + // Unmarshal back + var parsed awsSection + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if len(parsed.Providers) != 2 { + t.Errorf("expected 2 providers, got %d", len(parsed.Providers)) + } + + if parsed.Providers[0].ID != "provider-1" { + t.Errorf("expected first provider ID 'provider-1', got %s", parsed.Providers[0].ID) + } + + if parsed.Providers[1].ProfileName != "production" { + t.Errorf("expected profile name 'production', got %s", parsed.Providers[1].ProfileName) + } +} + +func TestGetConfigOptions(t *testing.T) { + opts := getConfigOptions() + + if opts.AppName != "whodb" { + t.Errorf("expected AppName 'whodb', got %s", opts.AppName) + } +} + +func TestLoadProvidersFromFile_EmptyConfig(t *testing.T) { + resetProviders() + config.ResetConfigPath() + + // Create a temp directory for testing + tempDir := t.TempDir() + + // Set up a config file with empty providers + configPath := filepath.Join(tempDir, "config.json") + emptyConfig := `{"aws": {"providers": []}}` + if err := os.WriteFile(configPath, []byte(emptyConfig), 0644); err != nil { + t.Fatalf("failed to write test config: %v", err) + } + + // LoadProvidersFromFile uses the datadir package which we can't easily mock, + // so this test just verifies the function doesn't panic with empty data + err := LoadProvidersFromFile() + // This will return an error because config path is different, but shouldn't panic + if err != nil { + // Expected - the config path from datadir won't match our temp dir + t.Logf("LoadProvidersFromFile returned expected error: %v", err) + } +} + +func stringContains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/dataflow/core/src/settings/providers_test.go b/dataflow/core/src/settings/providers_test.go new file mode 100644 index 0000000..d4c95fe --- /dev/null +++ b/dataflow/core/src/settings/providers_test.go @@ -0,0 +1,297 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package settings + +import ( + "errors" + "fmt" + "sync" + "testing" + + "github.com/clidey/whodb/core/src/providers" +) + +// resetProviders clears all providers for test isolation. +func resetProviders() { + awsProvidersMu.Lock() + defer awsProvidersMu.Unlock() + + // Clear the providers map + for id := range awsProviders { + delete(awsProviders, id) + } + + // Clear the registry + registry := providers.GetDefaultRegistry() + registry.Close(nil) +} + +func TestAddAWSProvider_Success(t *testing.T) { + resetProviders() + skipPersist = true + defer func() { skipPersist = false }() + + cfg := &AWSProviderConfig{ + ID: "test-provider-1", + Name: "Test Provider", + Region: "us-west-2", + AuthMethod: "default", + DiscoverRDS: true, + } + + state, err := AddAWSProvider(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if state.Config.ID != cfg.ID { + t.Errorf("expected ID %s, got %s", cfg.ID, state.Config.ID) + } + if state.Status != "Connected" { + t.Errorf("expected status Connected, got %s", state.Status) + } +} + +func TestAddAWSProvider_DuplicateID(t *testing.T) { + resetProviders() + skipPersist = true + defer func() { skipPersist = false }() + + cfg := &AWSProviderConfig{ + ID: "test-duplicate", + Name: "Test Provider", + Region: "us-west-2", + AuthMethod: "default", + } + + _, err := AddAWSProvider(cfg) + if err != nil { + t.Fatalf("first add should succeed: %v", err) + } + + _, err = AddAWSProvider(cfg) + if !errors.Is(err, ErrProviderAlreadyExists) { + t.Errorf("expected ErrProviderAlreadyExists, got %v", err) + } +} + +func TestGetAWSProvider_Success(t *testing.T) { + resetProviders() + skipPersist = true + defer func() { skipPersist = false }() + + cfg := &AWSProviderConfig{ + ID: "test-get", + Name: "Test Get", + Region: "us-east-1", + AuthMethod: "default", + } + + _, err := AddAWSProvider(cfg) + if err != nil { + t.Fatalf("add failed: %v", err) + } + + state, err := GetAWSProvider("test-get") + if err != nil { + t.Fatalf("get failed: %v", err) + } + + if state.Config.Name != "Test Get" { + t.Errorf("expected name 'Test Get', got %s", state.Config.Name) + } +} + +func TestGetAWSProvider_NotFound(t *testing.T) { + resetProviders() + + _, err := GetAWSProvider("nonexistent") + if !errors.Is(err, ErrProviderNotFound) { + t.Errorf("expected ErrProviderNotFound, got %v", err) + } +} + +func TestGetAWSProviders(t *testing.T) { + resetProviders() + skipPersist = true + defer func() { skipPersist = false }() + + // Add two providers + cfg1 := &AWSProviderConfig{ + ID: "test-list-1", Name: "Provider 1", Region: "us-west-2", AuthMethod: "default", + } + cfg2 := &AWSProviderConfig{ + ID: "test-list-2", Name: "Provider 2", Region: "eu-west-1", AuthMethod: "default", + } + + AddAWSProvider(cfg1) + AddAWSProvider(cfg2) + + providers := GetAWSProviders() + if len(providers) != 2 { + t.Errorf("expected 2 providers, got %d", len(providers)) + } +} + +func TestUpdateAWSProvider_Success(t *testing.T) { + resetProviders() + skipPersist = true + defer func() { skipPersist = false }() + + cfg := &AWSProviderConfig{ + ID: "test-update", + Name: "Original Name", + Region: "us-west-2", + AuthMethod: "default", + DiscoverRDS: true, + } + + _, err := AddAWSProvider(cfg) + if err != nil { + t.Fatalf("add failed: %v", err) + } + + updatedCfg := &AWSProviderConfig{ + ID: "test-update", + Name: "Updated Name", + Region: "us-west-2", + AuthMethod: "default", + DiscoverRDS: true, + DiscoverElastiCache: true, + } + + state, err := UpdateAWSProvider("test-update", updatedCfg) + if err != nil { + t.Fatalf("update failed: %v", err) + } + + if state.Config.Name != "Updated Name" { + t.Errorf("expected name 'Updated Name', got %s", state.Config.Name) + } + if !state.Config.DiscoverElastiCache { + t.Error("expected DiscoverElastiCache to be true") + } +} + +func TestUpdateAWSProvider_NotFound(t *testing.T) { + resetProviders() + skipPersist = true + defer func() { skipPersist = false }() + + cfg := &AWSProviderConfig{ + ID: "nonexistent", Name: "Test", Region: "us-west-2", AuthMethod: "default", + } + + _, err := UpdateAWSProvider("nonexistent", cfg) + if !errors.Is(err, ErrProviderNotFound) { + t.Errorf("expected ErrProviderNotFound, got %v", err) + } +} + +func TestRemoveAWSProvider_Success(t *testing.T) { + resetProviders() + skipPersist = true + defer func() { skipPersist = false }() + + cfg := &AWSProviderConfig{ + ID: "test-remove", Name: "To Remove", Region: "us-west-2", AuthMethod: "default", + } + + _, err := AddAWSProvider(cfg) + if err != nil { + t.Fatalf("add failed: %v", err) + } + + err = RemoveAWSProvider("test-remove") + if err != nil { + t.Fatalf("remove failed: %v", err) + } + + // Verify removed + _, err = GetAWSProvider("test-remove") + if !errors.Is(err, ErrProviderNotFound) { + t.Error("provider should be removed") + } +} + +func TestRemoveAWSProvider_NotFound(t *testing.T) { + resetProviders() + + err := RemoveAWSProvider("nonexistent") + if !errors.Is(err, ErrProviderNotFound) { + t.Errorf("expected ErrProviderNotFound, got %v", err) + } +} + +func TestGenerateProviderID(t *testing.T) { + tests := []struct { + name string + region string + expected string + }{ + {"MyProvider", "us-west-2", "aws-MyProvider-us-west-2"}, + {"Test Provider", "eu-west-1", "aws-TestProvider-eu-west-1"}, + {"test-with-dash", "ap-south-1", "aws-test-with-dash-ap-south-1"}, + {"Special@Chars!", "us-east-1", "aws-SpecialChars-us-east-1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GenerateProviderID(tt.name, tt.region) + if result != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, result) + } + }) + } +} + +// TestConcurrentAddRemove verifies no panics under concurrent Add/Remove. +// Run with -race to detect data races. +func TestConcurrentAddRemove(t *testing.T) { + resetProviders() + skipPersist = true + defer func() { skipPersist = false }() + + const numGoroutines = 10 + var wg sync.WaitGroup + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + id := fmt.Sprintf("concurrent-%d", i) + cfg := &AWSProviderConfig{ + ID: id, + Name: fmt.Sprintf("Concurrent %d", i), + Region: "us-west-2", + AuthMethod: "default", + } + + _, _ = AddAWSProvider(cfg) + _ = RemoveAWSProvider(id) + }(i) + } + + wg.Wait() + + // Verify clean state + remaining := GetAWSProviders() + if len(remaining) != 0 { + t.Errorf("expected 0 providers after concurrent add/remove, got %d", len(remaining)) + } +} + diff --git a/dataflow/core/src/settings/settings.go b/dataflow/core/src/settings/settings.go new file mode 100644 index 0000000..f936373 --- /dev/null +++ b/dataflow/core/src/settings/settings.go @@ -0,0 +1,58 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package settings + +import "github.com/clidey/whodb/core/src/analytics" + +type Settings struct { + MetricsEnabled bool `json:"metricsEnabled"` +} + +type ISettingsField interface { + Apply(*Settings) bool +} + +type MetricsEnabledField bool + +var currentSettings = Settings{MetricsEnabled: false} + +func Get() Settings { + return currentSettings +} + +func (m MetricsEnabledField) Apply(s *Settings) bool { + enabled := bool(m) + if s.MetricsEnabled == enabled { + analytics.SetEnabled(enabled) + return false + } + + s.MetricsEnabled = enabled + analytics.SetEnabled(enabled) + return true +} + +// UpdateSettings todo: this isn't a good idea when your settings are larger. you'll end up pushing more data than is needed back and forth. refactor so it's more flexible +func UpdateSettings(fields ...ISettingsField) bool { + changed := false + for _, field := range fields { + if field.Apply(¤tSettings) { + changed = true + } + } + return changed +} diff --git a/dataflow/core/src/settings/settings_test.go b/dataflow/core/src/settings/settings_test.go new file mode 100644 index 0000000..e60ebd7 --- /dev/null +++ b/dataflow/core/src/settings/settings_test.go @@ -0,0 +1,30 @@ +package settings + +import "testing" + +func TestUpdateSettingsTogglesMetricsEnabled(t *testing.T) { + original := currentSettings + t.Cleanup(func() { + currentSettings = original + }) + + // Enable metrics from default false + changed := UpdateSettings(MetricsEnabledField(true)) + if !changed { + t.Fatalf("expected settings change when enabling metrics") + } + if !Get().MetricsEnabled { + t.Fatalf("expected metrics to be enabled after update") + } + + // Calling with same value should not report change + if changed := UpdateSettings(MetricsEnabledField(true)); changed { + t.Fatalf("expected no change when value remains the same") + } + + // Disable metrics again + changed = UpdateSettings(MetricsEnabledField(false)) + if !changed || Get().MetricsEnabled { + t.Fatalf("expected metrics to be disabled and change reported") + } +} diff --git a/dataflow/core/src/sqlexport/statement_writer.go b/dataflow/core/src/sqlexport/statement_writer.go new file mode 100644 index 0000000..42739e4 --- /dev/null +++ b/dataflow/core/src/sqlexport/statement_writer.go @@ -0,0 +1,286 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package sqlexport + +import ( + "database/sql/driver" + "encoding/hex" + "fmt" + "io" + "math" + "regexp" + "strconv" + "strings" + "time" + + "github.com/clidey/whodb/core/src/engine" +) + +// DefaultInsertBatchSize is the backend-controlled number of rows per INSERT statement. +const DefaultInsertBatchSize = 500 + +var numericLiteralPattern = regexp.MustCompile(`^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$`) + +// Table identifies the SQL Table being exported. +type Table struct { + Schema string + Name string +} + +// Row stores raw row values keyed by column name. +type Row map[string]any + +// Dialect provides the SQL syntax hooks needed by the statement writer. +type Dialect interface { + QuoteIdentifier(identifier string) string + FormatLiteral(value any, column engine.Column) (string, error) +} + +// GenericDialect implements conservative SQL literal formatting for common SQL dialects. +type GenericDialect struct { + QuoteIdentifierFunc func(identifier string) string +} + +// QuoteIdentifier quotes an identifier using the configured callback. +func (d GenericDialect) QuoteIdentifier(identifier string) string { + if d.QuoteIdentifierFunc == nil { + return identifier + } + return d.QuoteIdentifierFunc(identifier) +} + +// FormatLiteral converts a raw database value into a SQL literal for one column. +func (d GenericDialect) FormatLiteral(value any, column engine.Column) (string, error) { + return FormatLiteral(value, column) +} + +// FormatLiteral converts a raw database value into a conservative SQL literal. +func FormatLiteral(value any, column engine.Column) (string, error) { + if value == nil { + return "NULL", nil + } + + if valuer, ok := value.(driver.Valuer); ok { + driverValue, err := valuer.Value() + if err != nil { + return "", err + } + return FormatLiteral(driverValue, column) + } + + switch v := value.(type) { + case []byte: + return formatBytesLiteral(v, column) + case string: + return formatTextLiteral(v, column) + case bool: + if v { + return "TRUE", nil + } + return "FALSE", nil + case time.Time: + return quoteString(formatTime(v)), nil + case int: + return strconv.FormatInt(int64(v), 10), nil + case int8: + return strconv.FormatInt(int64(v), 10), nil + case int16: + return strconv.FormatInt(int64(v), 10), nil + case int32: + return strconv.FormatInt(int64(v), 10), nil + case int64: + return strconv.FormatInt(v, 10), nil + case uint: + return strconv.FormatUint(uint64(v), 10), nil + case uint8: + return strconv.FormatUint(uint64(v), 10), nil + case uint16: + return strconv.FormatUint(uint64(v), 10), nil + case uint32: + return strconv.FormatUint(uint64(v), 10), nil + case uint64: + return strconv.FormatUint(v, 10), nil + case float32: + return formatFloat(float64(v), 32) + case float64: + return formatFloat(v, 64) + case fmt.Stringer: + return formatTextLiteral(v.String(), column) + default: + return quoteString(fmt.Sprintf("%v", v)), nil + } +} + +// WriteInsert writes one multi-row INSERT statement for the supplied rows. +func WriteInsert(w io.Writer, dialect Dialect, table Table, columns []engine.Column, rows []Row) error { + if len(rows) == 0 { + return nil + } + if len(columns) == 0 { + return fmt.Errorf("cannot export INSERT without writable columns") + } + + if _, err := fmt.Fprintf(w, "INSERT INTO %s (%s) VALUES\n", quoteTable(dialect, table), quoteColumns(dialect, columns)); err != nil { + return err + } + + for rowIndex, row := range rows { + literals := make([]string, len(columns)) + for columnIndex, column := range columns { + literal, err := dialect.FormatLiteral(row[column.Name], column) + if err != nil { + return fmt.Errorf("format column %s: %w", column.Name, err) + } + literals[columnIndex] = literal + } + + suffix := "," + if rowIndex == len(rows)-1 { + suffix = ";" + } + if _, err := fmt.Fprintf(w, " (%s)%s\n", strings.Join(literals, ", "), suffix); err != nil { + return err + } + } + + return nil +} + +// WriteUpdate writes one UPDATE statement for the supplied row. +func WriteUpdate(w io.Writer, dialect Dialect, table Table, setColumns []engine.Column, primaryKeyColumns []engine.Column, row Row) error { + if len(primaryKeyColumns) == 0 { + return fmt.Errorf("cannot export UPDATE without primary key columns") + } + if len(setColumns) == 0 { + return fmt.Errorf("cannot export UPDATE without writable non-primary columns") + } + + assignments := make([]string, len(setColumns)) + for i, column := range setColumns { + literal, err := dialect.FormatLiteral(row[column.Name], column) + if err != nil { + return fmt.Errorf("format SET column %s: %w", column.Name, err) + } + assignments[i] = fmt.Sprintf("%s = %s", dialect.QuoteIdentifier(column.Name), literal) + } + + predicates := make([]string, len(primaryKeyColumns)) + for i, column := range primaryKeyColumns { + literal, err := dialect.FormatLiteral(row[column.Name], column) + if err != nil { + return fmt.Errorf("format primary key column %s: %w", column.Name, err) + } + if literal == "NULL" { + return fmt.Errorf("primary key column %s is NULL", column.Name) + } + predicates[i] = fmt.Sprintf("%s = %s", dialect.QuoteIdentifier(column.Name), literal) + } + + _, err := fmt.Fprintf( + w, + "UPDATE %s SET %s WHERE %s;\n", + quoteTable(dialect, table), + strings.Join(assignments, ", "), + strings.Join(predicates, " AND "), + ) + return err +} + +func quoteTable(dialect Dialect, table Table) string { + if table.Schema == "" { + return dialect.QuoteIdentifier(table.Name) + } + return dialect.QuoteIdentifier(table.Schema) + "." + dialect.QuoteIdentifier(table.Name) +} + +func quoteColumns(dialect Dialect, columns []engine.Column) string { + quoted := make([]string, len(columns)) + for i, column := range columns { + quoted[i] = dialect.QuoteIdentifier(column.Name) + } + return strings.Join(quoted, ", ") +} + +func quoteString(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + +func formatFloat(value float64, bitSize int) (string, error) { + if math.IsNaN(value) || math.IsInf(value, 0) { + return "", fmt.Errorf("non-finite float values cannot be exported as SQL literals") + } + return strconv.FormatFloat(value, 'g', -1, bitSize), nil +} + +func formatTime(value time.Time) string { + if value.Nanosecond() == 0 { + return value.Format("2006-01-02 15:04:05") + } + return value.Format("2006-01-02 15:04:05.999999999") +} + +func formatTextLiteral(value string, column engine.Column) (string, error) { + trimmed := strings.TrimSpace(value) + if isNumericColumn(column.Type) && numericLiteralPattern.MatchString(trimmed) { + return trimmed, nil + } + if isBooleanColumn(column.Type) { + switch strings.ToLower(trimmed) { + case "true", "t", "1": + return "TRUE", nil + case "false", "f", "0": + return "FALSE", nil + } + } + return quoteString(value), nil +} + +func formatBytesLiteral(value []byte, column engine.Column) (string, error) { + if !isBinaryColumn(column.Type) { + return formatTextLiteral(string(value), column) + } + + encoded := strings.ToUpper(hex.EncodeToString(value)) + if strings.Contains(strings.ToLower(column.Type), "bytea") { + return quoteString("\\x" + encoded), nil + } + return "X'" + encoded + "'", nil +} + +func isBinaryColumn(columnType string) bool { + normalized := strings.ToLower(columnType) + return strings.Contains(normalized, "binary") || + strings.Contains(normalized, "blob") || + strings.Contains(normalized, "bytea") +} + +func isBooleanColumn(columnType string) bool { + normalized := strings.ToLower(columnType) + return strings.Contains(normalized, "bool") +} + +func isNumericColumn(columnType string) bool { + normalized := strings.ToLower(columnType) + return strings.Contains(normalized, "int") || + strings.Contains(normalized, "serial") || + strings.Contains(normalized, "decimal") || + strings.Contains(normalized, "numeric") || + strings.Contains(normalized, "number") || + strings.Contains(normalized, "float") || + strings.Contains(normalized, "double") || + strings.Contains(normalized, "real") +} diff --git a/dataflow/core/src/sqlexport/statement_writer_test.go b/dataflow/core/src/sqlexport/statement_writer_test.go new file mode 100644 index 0000000..af25002 --- /dev/null +++ b/dataflow/core/src/sqlexport/statement_writer_test.go @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package sqlexport + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/clidey/whodb/core/src/engine" +) + +func testDialect() Dialect { + return GenericDialect{QuoteIdentifierFunc: func(identifier string) string { + return `"` + strings.ReplaceAll(identifier, `"`, `""`) + `"` + }} +} + +func TestWriteInsertFormatsLiteralsAndIdentifiers(t *testing.T) { + columns := []engine.Column{ + {Name: "id", Type: "integer", IsPrimary: true}, + {Name: "name", Type: "text"}, + {Name: "price", Type: "numeric"}, + {Name: "active", Type: "boolean"}, + {Name: "created_at", Type: "timestamp"}, + {Name: "payload", Type: "jsonb"}, + {Name: "data", Type: "bytea"}, + } + rows := []Row{{ + "id": []byte("7"), + "name": "O'Malley", + "price": []byte("12.50"), + "active": []byte("true"), + "created_at": time.Date(2026, 6, 26, 12, 30, 0, 0, time.UTC), + "payload": []byte(`{"ok":true}`), + "data": []byte{0xde, 0xad}, + }} + + var out bytes.Buffer + if err := WriteInsert(&out, testDialect(), Table{Schema: "public", Name: "order"}, columns, rows); err != nil { + t.Fatalf("WriteInsert returned error: %v", err) + } + + want := "INSERT INTO \"public\".\"order\" (\"id\", \"name\", \"price\", \"active\", \"created_at\", \"payload\", \"data\") VALUES\n" + + " (7, 'O''Malley', 12.50, TRUE, '2026-06-26 12:30:00', '{\"ok\":true}', '\\xDEAD');\n" + if out.String() != want { + t.Fatalf("unexpected INSERT output:\n%s", out.String()) + } +} + +func TestWriteUpdateUsesCompositePrimaryKeyOnlyInWhere(t *testing.T) { + setColumns := []engine.Column{ + {Name: "name", Type: "text"}, + } + primaryKeyColumns := []engine.Column{ + {Name: "tenant_id", Type: "integer", IsPrimary: true}, + {Name: "id", Type: "integer", IsPrimary: true}, + } + row := Row{ + "tenant_id": 42, + "id": 7, + "name": "Alice", + } + + var out bytes.Buffer + if err := WriteUpdate(&out, testDialect(), Table{Name: "users"}, setColumns, primaryKeyColumns, row); err != nil { + t.Fatalf("WriteUpdate returned error: %v", err) + } + + want := "UPDATE \"users\" SET \"name\" = 'Alice' WHERE \"tenant_id\" = 42 AND \"id\" = 7;\n" + if out.String() != want { + t.Fatalf("unexpected UPDATE output:\n%s", out.String()) + } +} + +func TestWriteUpdateRejectsMissingPrimaryKey(t *testing.T) { + err := WriteUpdate(&bytes.Buffer{}, testDialect(), Table{Name: "users"}, []engine.Column{{Name: "name", Type: "text"}}, nil, Row{}) + if err == nil { + t.Fatal("expected missing primary key error") + } +} diff --git a/dataflow/core/src/src.go b/dataflow/core/src/src.go new file mode 100644 index 0000000..013515d --- /dev/null +++ b/dataflow/core/src/src.go @@ -0,0 +1,148 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package src + +import ( + "fmt" + + "github.com/clidey/whodb/core/src/plugins/clickhouse" + "github.com/clidey/whodb/core/src/plugins/elasticsearch" + "github.com/clidey/whodb/core/src/plugins/mongodb" + "github.com/clidey/whodb/core/src/plugins/mysql" + "github.com/clidey/whodb/core/src/plugins/redis" + "github.com/clidey/whodb/core/src/plugins/sqlite3" + "github.com/clidey/whodb/core/src/types" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/envconfig" + "github.com/clidey/whodb/core/src/llm" + "github.com/clidey/whodb/core/src/log" + "github.com/clidey/whodb/core/src/migrate" + "github.com/clidey/whodb/core/src/mockdata" + "github.com/clidey/whodb/core/src/plugins/postgres" +) + +var MainEngine *engine.Engine + +// InitEEFunc is a function type for initializing Enterprise Edition features +type InitEEFunc func(*engine.Engine) + +// initEE is a variable that will be set by the EE build to initialize EE features +var initEE InitEEFunc + +// SetEEInitializer allows external packages to register the EE initialization function +func SetEEInitializer(fn InitEEFunc) { + initEE = fn +} + +func InitializeEngine() *engine.Engine { + MainEngine = &engine.Engine{} + + // Register community edition plugins + MainEngine.RegistryPlugin(postgres.NewPostgresPlugin()) + MainEngine.RegistryPlugin(mysql.NewMySQLPlugin()) + MainEngine.RegistryPlugin(mysql.NewMyMariaDBPlugin()) + MainEngine.RegistryPlugin(sqlite3.NewSqlite3Plugin()) + MainEngine.RegistryPlugin(mongodb.NewMongoDBPlugin()) + MainEngine.RegistryPlugin(redis.NewRedisPlugin()) + MainEngine.RegistryPlugin(elasticsearch.NewElasticSearchPlugin()) + MainEngine.RegistryPlugin(clickhouse.NewClickHousePlugin()) + + // Parse and register generic AI providers from environment configuration. + // RegisterGenericProviders calls env.AddGenericProvider internally, + // so we iterate directly without pre-assigning env.GenericProviders. + for _, provider := range envconfig.ParseGenericProviders() { + llm.RegisterGenericProviders(provider.Name, provider.ProviderId, provider.Models, provider.ClientType, provider.BaseURL, provider.APIKey) + } + + // Initialize Enterprise Edition plugins if available + if initEE != nil { + initEE(MainEngine) + } + + for _, warning := range migrate.Warnings() { + log.Warn(warning) + } + + return MainEngine +} + +func GetLoginProfiles() []types.DatabaseCredentials { + profiles := []types.DatabaseCredentials{} + + profiles = append(profiles, MainEngine.LoginProfiles...) + + for _, retriever := range MainEngine.ProfileRetrievers { + retrievedProfiles, err := retriever() + if err != nil { + continue + } + profiles = append(profiles, retrievedProfiles...) + } + + for _, plugin := range MainEngine.Plugins { + databaseProfiles := envconfig.GetDefaultDatabaseCredentials(string(plugin.Type)) + for _, databaseProfile := range databaseProfiles { + databaseProfile.Type = string(plugin.Type) + databaseProfile.IsProfile = true + profiles = append(profiles, databaseProfile) + } + } + return profiles +} + +func GetLoginProfileId(index int, profile types.DatabaseCredentials) string { + if len(profile.CustomId) > 0 { + return profile.CustomId + } + if len(profile.Alias) > 0 { + return profile.Alias + } + return fmt.Sprintf("#%v - %v@%v [%v]", index+1, profile.Username, profile.Hostname, profile.Database) +} + +func GetLoginCredentials(profile types.DatabaseCredentials) *engine.Credentials { + advanced := []engine.Record{ + { + Key: "Port", + Value: profile.Port, + }, + } + + for key, value := range profile.Advanced { + advanced = append(advanced, engine.Record{ + Key: key, + Value: value, + }) + } + + return &engine.Credentials{ + Type: profile.Type, + Hostname: profile.Hostname, + Username: profile.Username, + Password: profile.Password, + Database: profile.Database, + Advanced: advanced, + IsProfile: profile.IsProfile, + } +} + +// NewMockDataGenerator creates a new mock data generator with the specified FK density ratio. +// Pass 0 for the default ratio (20). +func NewMockDataGenerator(fkDensityRatio int) *mockdata.Generator { + return mockdata.NewGenerator(fkDensityRatio) +} diff --git a/dataflow/core/src/src_test.go b/dataflow/core/src/src_test.go new file mode 100644 index 0000000..88d39ab --- /dev/null +++ b/dataflow/core/src/src_test.go @@ -0,0 +1,150 @@ +// Copyright 2025 Clidey, Inc. +// +// 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. + +package src + +import ( + "encoding/json" + "testing" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/types" +) + +func TestInitializeEngineRegistersCEPlugins(t *testing.T) { + t.Cleanup(func() { + MainEngine = nil + initEE = nil + }) + + eng := InitializeEngine() + + expectedTypes := map[engine.DatabaseType]bool{ + engine.DatabaseType_Postgres: false, + engine.DatabaseType_MySQL: false, + engine.DatabaseType_MariaDB: false, + engine.DatabaseType_Sqlite3: false, + engine.DatabaseType_MongoDB: false, + engine.DatabaseType_Redis: false, + engine.DatabaseType_ElasticSearch: false, + engine.DatabaseType_ClickHouse: false, + } + + for _, plugin := range eng.Plugins { + if _, ok := expectedTypes[plugin.Type]; ok { + expectedTypes[plugin.Type] = true + } + } + + for pluginType, seen := range expectedTypes { + if !seen { + t.Fatalf("expected CE plugin %s to be registered", pluginType) + } + } +} + +func TestInitializeEngineInvokesEEInitializer(t *testing.T) { + t.Cleanup(func() { + MainEngine = nil + initEE = nil + }) + + SetEEInitializer(func(e *engine.Engine) { + e.RegistryPlugin(&engine.Plugin{Type: "EEOnly"}) + }) + + eng := InitializeEngine() + if eng.Choose("EEOnly") == nil { + t.Fatalf("expected EE initializer to register EEOnly plugin") + } +} + +func TestGetLoginProfilesMergesSources(t *testing.T) { + t.Cleanup(func() { + MainEngine = nil + initEE = nil + }) + + MainEngine = &engine.Engine{} + MainEngine.RegistryPlugin(&engine.Plugin{Type: "Test"}) + + MainEngine.AddLoginProfile(types.DatabaseCredentials{ + Alias: "saved-profile", + Hostname: "host1", + Username: "alice", + Password: "pw", + Database: "db1", + IsProfile: true, + Type: "Test", + }) + + MainEngine.RegisterProfileRetriever(func() ([]types.DatabaseCredentials, error) { + return []types.DatabaseCredentials{{ + Hostname: "host2", + Username: "retrieved", + Database: "db2", + Type: "Test", + }}, nil + }) + + envCreds := []types.DatabaseCredentials{{ + Hostname: "env-host", + Username: "env-user", + Database: "env-db", + Password: "env-pw", + }} + envValue, err := json.Marshal(envCreds) + if err != nil { + t.Fatalf("failed to marshal env credentials: %v", err) + } + t.Setenv("WHODB_TEST", string(envValue)) + + profiles := GetLoginProfiles() + if len(profiles) != 3 { + t.Fatalf("expected 3 profiles (stored + retriever + env), got %d", len(profiles)) + } + + foundEnvProfile := false + for _, profile := range profiles { + if profile.Hostname == "env-host" && profile.IsProfile { + foundEnvProfile = true + } + } + if !foundEnvProfile { + t.Fatalf("expected env-provided profile to be marked as profile and returned") + } +} + +func TestGetLoginProfileIdPrioritizesFields(t *testing.T) { + profile := types.DatabaseCredentials{ + CustomId: "custom-id", + Alias: "alias-id", + Username: "user", + Hostname: "host", + Database: "db", + } + if got := GetLoginProfileId(0, profile); got != "custom-id" { + t.Fatalf("expected custom id to take priority, got %s", got) + } + + profile.CustomId = "" + if got := GetLoginProfileId(1, profile); got != "alias-id" { + t.Fatalf("expected alias to be used when custom id is empty, got %s", got) + } + + profile.Alias = "" + if got := GetLoginProfileId(2, profile); got == "" { + t.Fatalf("expected fallback id to be generated when no custom id or alias is present") + } +} diff --git a/dataflow/core/src/types/types.go b/dataflow/core/src/types/types.go new file mode 100644 index 0000000..0fd2bab --- /dev/null +++ b/dataflow/core/src/types/types.go @@ -0,0 +1,60 @@ +/* + * Copyright 2025 Clidey, Inc. + * + * 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. + */ + +package types + +import ( + "encoding/json" + + "github.com/clidey/whodb/core/src/migrate" +) + +// DatabaseCredentials holds database connection details parsed from environment +// variables or configuration files. The Advanced field accepts both "advanced" +// and the legacy "config" JSON key for backwards compatibility. +type DatabaseCredentials struct { + Alias string `json:"alias"` + Hostname string `json:"host"` + Username string `json:"user"` + Password string `json:"password"` + Database string `json:"database"` + Port string `json:"port"` + Advanced map[string]string `json:"advanced"` + Extra map[string]any + + IsProfile bool + Type string + CustomId string + Source string +} + +// UnmarshalJSON supports both "advanced" and the legacy "config" JSON key. +func (d *DatabaseCredentials) UnmarshalJSON(data []byte) error { + type Alias DatabaseCredentials + aux := &struct { + *Alias + Config map[string]string `json:"config"` + }{Alias: (*Alias)(d)} + + if err := json.Unmarshal(data, aux); err != nil { + return err + } + if d.Advanced == nil && aux.Config != nil { + d.Advanced = aux.Config + migrate.DeprecatedConfigKey = true + } + return nil +} diff --git a/dataflow/core/src/types/types_test.go b/dataflow/core/src/types/types_test.go new file mode 100644 index 0000000..d98621f --- /dev/null +++ b/dataflow/core/src/types/types_test.go @@ -0,0 +1,74 @@ +package types + +import ( + "encoding/json" + "testing" +) + +func TestDatabaseCredentials_UnmarshalJSON_AdvancedKey(t *testing.T) { + input := `{"host":"db.local","user":"alice","password":"secret","database":"app","advanced":{"SSL Mode":"require","Connection Timeout":"30"}}` + + var creds DatabaseCredentials + if err := json.Unmarshal([]byte(input), &creds); err != nil { + t.Fatalf("failed to unmarshal with advanced key: %v", err) + } + + if creds.Hostname != "db.local" { + t.Errorf("expected host db.local, got %s", creds.Hostname) + } + if len(creds.Advanced) != 2 { + t.Fatalf("expected 2 advanced entries, got %d", len(creds.Advanced)) + } + if creds.Advanced["SSL Mode"] != "require" { + t.Errorf("expected SSL Mode=require, got %s", creds.Advanced["SSL Mode"]) + } + if creds.Advanced["Connection Timeout"] != "30" { + t.Errorf("expected Connection Timeout=30, got %s", creds.Advanced["Connection Timeout"]) + } +} + +func TestDatabaseCredentials_UnmarshalJSON_LegacyConfigKey(t *testing.T) { + input := `{"host":"db.local","user":"alice","password":"secret","database":"app","config":{"SSL Mode":"verify-ca","Port":"5433"}}` + + var creds DatabaseCredentials + if err := json.Unmarshal([]byte(input), &creds); err != nil { + t.Fatalf("failed to unmarshal with legacy config key: %v", err) + } + + if len(creds.Advanced) != 2 { + t.Fatalf("expected 2 advanced entries from legacy config, got %d", len(creds.Advanced)) + } + if creds.Advanced["SSL Mode"] != "verify-ca" { + t.Errorf("expected SSL Mode=verify-ca, got %s", creds.Advanced["SSL Mode"]) + } + if creds.Advanced["Port"] != "5433" { + t.Errorf("expected Port=5433, got %s", creds.Advanced["Port"]) + } +} + +func TestDatabaseCredentials_UnmarshalJSON_AdvancedTakesPrecedence(t *testing.T) { + // If both "advanced" and "config" are present, "advanced" wins + input := `{"host":"db.local","user":"alice","password":"secret","database":"app","advanced":{"SSL Mode":"require"},"config":{"SSL Mode":"verify-full"}}` + + var creds DatabaseCredentials + if err := json.Unmarshal([]byte(input), &creds); err != nil { + t.Fatalf("failed to unmarshal with both keys: %v", err) + } + + if creds.Advanced["SSL Mode"] != "require" { + t.Errorf("expected advanced to take precedence, got SSL Mode=%s", creds.Advanced["SSL Mode"]) + } +} + +func TestDatabaseCredentials_UnmarshalJSON_NoAdvancedOrConfig(t *testing.T) { + input := `{"host":"db.local","user":"alice","password":"secret","database":"app"}` + + var creds DatabaseCredentials + if err := json.Unmarshal([]byte(input), &creds); err != nil { + t.Fatalf("failed to unmarshal without advanced or config: %v", err) + } + + if creds.Advanced != nil { + t.Errorf("expected nil Advanced when neither key is present, got %v", creds.Advanced) + } +} diff --git a/dataflow/core/src/version/check.go b/dataflow/core/src/version/check.go new file mode 100644 index 0000000..b35c88d --- /dev/null +++ b/dataflow/core/src/version/check.go @@ -0,0 +1,126 @@ +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package version + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + goversion "github.com/hashicorp/go-version" +) + +const ( + githubReleasesURL = "https://api.github.com/repos/clidey/whodb/releases/latest" + cacheTTL = 24 * time.Hour + httpTimeout = 5 * time.Second +) + +// UpdateInfo holds the result of an update check. +type UpdateInfo struct { + CurrentVersion string + LatestVersion string + UpdateAvailable bool + ReleaseURL string +} + +type cachedResult struct { + info UpdateInfo + checkedAt time.Time +} + +var ( + cache *cachedResult + cacheMu sync.Mutex +) + +type githubRelease struct { + TagName string `json:"tag_name"` +} + +// CheckForUpdate checks whether a newer version of WhoDB is available. +// Results are cached in memory for 24 hours. When disabled is true, no +// network request is made and UpdateAvailable is always false. +// On any error, returns UpdateAvailable: false +func CheckForUpdate(currentVersion string, disabled bool) UpdateInfo { + noUpdate := UpdateInfo{ + CurrentVersion: currentVersion, + LatestVersion: currentVersion, + UpdateAvailable: false, + } + + if disabled || currentVersion == "" || currentVersion == "development" { + return noUpdate + } + + cacheMu.Lock() + if cache != nil && time.Since(cache.checkedAt) < cacheTTL { + result := cache.info + cacheMu.Unlock() + return result + } + cacheMu.Unlock() + + result := noUpdate + + if release, err := fetchLatestRelease(); err == nil { + latestTag := strings.TrimPrefix(release.TagName, "v") + currentClean := strings.TrimPrefix(currentVersion, "v") + + current, currErr := goversion.NewVersion(currentClean) + latest, latErr := goversion.NewVersion(latestTag) + + if currErr == nil && latErr == nil && latest.GreaterThan(current) { + result = UpdateInfo{ + CurrentVersion: currentVersion, + LatestVersion: release.TagName, + UpdateAvailable: true, + ReleaseURL: fmt.Sprintf("https://github.com/clidey/whodb/releases/tag/%s", release.TagName), + } + } + } + + cacheMu.Lock() + cache = &cachedResult{info: result, checkedAt: time.Now()} + cacheMu.Unlock() + + return result +} + +func fetchLatestRelease() (*githubRelease, error) { + client := &http.Client{Timeout: httpTimeout} + resp, err := client.Get(githubReleasesURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.New("unexpected status") + } + + var release githubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, err + } + + return &release, nil +} diff --git a/dataflow/core/test/integration/full_roundtrip_test.go b/dataflow/core/test/integration/full_roundtrip_test.go new file mode 100644 index 0000000..3bf3ee5 --- /dev/null +++ b/dataflow/core/test/integration/full_roundtrip_test.go @@ -0,0 +1,1173 @@ +//go:build integration + +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package integration + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "reflect" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + elasticplugin "github.com/clidey/whodb/core/src/plugins/elasticsearch" + mongoplugin "github.com/clidey/whodb/core/src/plugins/mongodb" + redisplugin "github.com/clidey/whodb/core/src/plugins/redis" + redislib "github.com/go-redis/redis/v8" +) + +type typeCase struct { + name string + columnType string + value string + updated string + expect func(string) bool + category engine.TypeCategory +} + +type typeOverride struct { + columnType string + value string + updated string + expect func(string) bool + skip bool +} + +func TestSQLCanonicalAndAliasRoundTrips(t *testing.T) { + for _, target := range targets { + if target.plugin.GetDatabaseMetadata() == nil { + continue + } + switch target.plugin.Type { + case engine.DatabaseType_MongoDB, engine.DatabaseType_ElasticSearch, engine.DatabaseType_Redis: + continue + } + + t.Run(target.name, func(t *testing.T) { + // Ensure Postgres-only extensions needed for some types are present + if target.plugin.Type == engine.DatabaseType_Postgres { + _, _ = target.plugin.RawExecute(target.config, "CREATE EXTENSION IF NOT EXISTS hstore") + } + + cases := sqlCasesForTarget(target) + for idx, tc := range cases { + if tc.value == "" || tc.columnType == "" { + continue + } + runSQLTypeCase(t, target, tc, idx) + } + + // Unsupported type should fail validation + _, err := target.plugin.AddStorageUnit(target.config, target.schema, "intg_bad_type", []engine.Record{ + {Key: "id", Value: "INTEGER", Extra: map[string]string{"Primary": "true", "Nullable": "false"}}, + {Key: "val", Value: "NOT_A_REAL_TYPE"}, + }) + if err == nil { + t.Fatalf("expected unsupported type to fail on %s", target.name) + } + + meta := target.plugin.GetDatabaseMetadata() + for alias := range meta.AliasMap { + if err := engine.ValidateColumnType(alias, meta); err != nil { + t.Fatalf("expected alias %s to validate for %s: %v", alias, target.name, err) + } + } + }) + } +} + +func TestMongoCrudAndExport(t *testing.T) { + var mongoTarget *target + for i := range targets { + if targets[i].plugin.Type == engine.DatabaseType_MongoDB { + mongoTarget = &targets[i] + break + } + } + if mongoTarget == nil { + t.Skip("mongo target not configured") + } + + collection := fmt.Sprintf("intg_mongo_%d", time.Now().UnixNano()) + created, err := mongoTarget.plugin.AddStorageUnit(mongoTarget.config, mongoTarget.schema, collection, nil) + if err != nil || !created { + t.Fatalf("failed to create mongo collection: %v", err) + } + + docID := "507f1f77bcf86cd799439012" + ok, err := mongoTarget.plugin.AddRow(mongoTarget.config, mongoTarget.schema, collection, []engine.Record{ + {Key: "_id", Value: docID}, + {Key: "name", Value: "mongo-intg"}, + {Key: "count", Value: "2", Extra: map[string]string{"Type": "INT"}}, + {Key: "meta", Value: "meta-string"}, + }) + if err != nil || !ok { + t.Fatalf("failed to insert mongo doc: %v", err) + } + + rows, err := mongoTarget.plugin.GetRows(mongoTarget.config, &engine.GetRowsRequest{Schema: mongoTarget.schema, StorageUnit: collection, Sort: []*model.SortCondition{}, PageSize: 10}) + if err != nil { + t.Fatalf("mongo get rows failed: %v", err) + } + if len(rows.Rows) == 0 || !strings.Contains(rows.Rows[0][0], "mongo-intg") { + t.Fatalf("mongo doc not returned: %+v", rows.Rows) + } + + updateDoc := map[string]any{"_id": docID, "name": "mongo-updated", "count": 2, "meta": map[string]any{"env": "integration", "ok": false}} + updateJSON, _ := json.Marshal(updateDoc) + updated, err := mongoTarget.plugin.UpdateStorageUnit(mongoTarget.config, mongoTarget.schema, collection, map[string]string{ + "document": string(updateJSON), + }, []string{"name", "count", "meta"}) + if err != nil || !updated { + t.Fatalf("mongo update failed: %v", err) + } + + rows, err = mongoTarget.plugin.GetRows(mongoTarget.config, &engine.GetRowsRequest{Schema: mongoTarget.schema, StorageUnit: collection, Sort: []*model.SortCondition{}, PageSize: 10}) + if err != nil { + t.Fatalf("mongo get rows after update failed: %v", err) + } + if len(rows.Rows) == 0 || !strings.Contains(rows.Rows[0][0], "mongo-updated") || !strings.Contains(rows.Rows[0][0], "false") { + t.Fatalf("mongo update not reflected in rows: %+v", rows.Rows) + } + + var exported [][]string + err = mongoTarget.plugin.ExportData(mongoTarget.config, mongoTarget.schema, collection, func(row []string) error { + exported = append(exported, row) + return nil + }, nil) + if err != nil { + t.Fatalf("mongo export failed: %v", err) + } + if len(exported) < 2 { + t.Fatalf("expected exported data, got %d rows", len(exported)) + } + + deleted, err := mongoTarget.plugin.DeleteRow(mongoTarget.config, mongoTarget.schema, collection, map[string]string{ + "document": string(updateJSON), + }) + if err != nil || !deleted { + t.Fatalf("mongo delete failed: %v", err) + } + + rows, err = mongoTarget.plugin.GetRows(mongoTarget.config, &engine.GetRowsRequest{Schema: mongoTarget.schema, StorageUnit: collection, Sort: []*model.SortCondition{}, PageSize: 10}) + if err != nil { + t.Fatalf("mongo get rows after delete failed: %v", err) + } + if len(rows.Rows) != 0 { + t.Fatalf("expected mongo collection empty after delete, got %v", rows.Rows) + } + + // NDJSON export + mongoPlugin, ok := mongoTarget.plugin.PluginFunctions.(*mongoplugin.MongoDBPlugin) + if !ok { + t.Fatalf("unexpected mongo plugin type") + } + + var ndjsonLines []string + err = mongoPlugin.ExportDataNDJSON(mongoTarget.config, mongoTarget.schema, collection, func(line string) error { + ndjsonLines = append(ndjsonLines, line) + return nil + }, nil) + if err != nil { + t.Fatalf("mongo ndjson export failed: %v", err) + } + if len(ndjsonLines) != 0 { + t.Fatalf("expected no lines after deletion, got %d", len(ndjsonLines)) + } + + // Error paths: missing _id and invalid ObjectID + _, err = mongoTarget.plugin.UpdateStorageUnit(mongoTarget.config, mongoTarget.schema, collection, map[string]string{ + "document": `{"name":"noid"}`, + }, []string{"name"}) + if err == nil { + t.Fatalf("expected mongo update without _id to fail") + } + _, err = mongoTarget.plugin.DeleteRow(mongoTarget.config, mongoTarget.schema, collection, map[string]string{ + "document": `{"_id":"not-a-hex"}`, + }) + if err == nil { + t.Fatalf("expected mongo delete with invalid id to fail") + } + + _, err = mongoTarget.plugin.UpdateStorageUnit(mongoTarget.config, mongoTarget.schema, "nonexistent", map[string]string{ + "document": `{"_id":"507f1f77bcf86cd799439011","name":"x"}`, + }, []string{"name"}) + if err == nil { + t.Fatalf("expected mongo update on missing collection to fail") + } +} + +func TestElasticsearchCRUDAndSearch(t *testing.T) { + var esTarget *target + for i := range targets { + if targets[i].plugin.Type == engine.DatabaseType_ElasticSearch { + esTarget = &targets[i] + break + } + } + if esTarget == nil { + t.Skip("elasticsearch target not configured") + } + + index := fmt.Sprintf("intg-es-%d", time.Now().UnixNano()) + fields := []engine.Record{ + {Key: "title", Value: "text"}, + {Key: "count", Value: "integer"}, + {Key: "created_at", Value: "date"}, + } + created, err := esTarget.plugin.AddStorageUnit(esTarget.config, esTarget.schema, index, fields) + if err != nil || !created { + t.Fatalf("failed to create index: %v", err) + } + + esPlugin, ok := esTarget.plugin.PluginFunctions.(*elasticplugin.ElasticSearchPlugin) + if !ok { + t.Fatalf("unexpected elastic plugin type") + } + clientConfig := *esTarget.config + clientConfig.Credentials.Hostname = "localhost" + client, err := elasticplugin.DB(&clientConfig) + if err != nil { + t.Fatalf("failed to get elastic client: %v", err) + } + mapping, err := client.Indices.GetMapping(client.Indices.GetMapping.WithIndex(index)) + if err != nil { + t.Fatalf("get mapping failed: %v", err) + } + var mappingBody map[string]any + if err := json.NewDecoder(mapping.Body).Decode(&mappingBody); err != nil { + t.Fatalf("decode mapping failed: %v", err) + } + if len(mappingBody) == 0 { + t.Fatalf("expected mapping for %s", index) + } + + docID := "es-doc-1" + inserted, err := esTarget.plugin.AddRow(esTarget.config, esTarget.schema, index, []engine.Record{ + {Key: "_id", Value: docID}, + {Key: "title", Value: "Hello ES"}, + {Key: "count", Value: "1"}, + {Key: "created_at", Value: "2024-01-02T15:04:05Z"}, + }) + if err != nil || !inserted { + t.Fatalf("failed to insert elastic doc: %v", err) + } + + rows, err := esTarget.plugin.GetRows(esTarget.config, &engine.GetRowsRequest{Schema: esTarget.schema, StorageUnit: index, Sort: []*model.SortCondition{}, PageSize: 10}) + if err != nil { + t.Fatalf("elasticsearch get rows failed: %v", err) + } + if len(rows.Rows) == 0 || !strings.Contains(rows.Rows[0][0], "Hello ES") { + t.Fatalf("elastic doc missing: %+v", rows.Rows) + } + + // Insert another doc to exercise sort/pagination + _, err = esTarget.plugin.AddRow(esTarget.config, esTarget.schema, index, []engine.Record{ + {Key: "_id", Value: "es-doc-2"}, + {Key: "title", Value: "Hello ES 2"}, + {Key: "count", Value: "5"}, + {Key: "created_at", Value: "2024-03-04T11:00:00Z"}, + }) + if err != nil { + t.Fatalf("failed to insert second elastic doc: %v", err) + } + + where := &model.WhereCondition{ + Type: model.WhereConditionTypeAtomic, + Atomic: &model.AtomicWhereCondition{ + Key: "count", + Operator: ">", + Value: "0", + ColumnType: "integer", + }, + } + filtered, err := esTarget.plugin.GetRows(esTarget.config, &engine.GetRowsRequest{Schema: esTarget.schema, StorageUnit: index, Where: where, Sort: []*model.SortCondition{}, PageSize: 10}) + if err != nil { + t.Fatalf("elastic filtered query failed: %v", err) + } + if filtered.TotalCount < 1 { + t.Fatalf("elastic filtered query returned no rows") + } + + sortDesc := []*model.SortCondition{{Column: "count", Direction: model.SortDirectionDesc}} + sorted, err := esTarget.plugin.GetRows(esTarget.config, &engine.GetRowsRequest{Schema: esTarget.schema, StorageUnit: index, Sort: sortDesc, PageSize: 2}) + if err != nil { + t.Fatalf("elastic sorted query failed: %v", err) + } + parseCount := func(row string) (int, error) { + var obj map[string]any + if err := json.Unmarshal([]byte(row), &obj); err != nil { + return 0, err + } + switch c := obj["count"].(type) { + case float64: + return int(c), nil + case string: + return strconv.Atoi(c) + default: + return 0, fmt.Errorf("unexpected count type %T", c) + } + } + if len(sorted.Rows) < 2 { + t.Fatalf("elastic sort not applied correctly: %+v", sorted.Rows) + } + firstCount, err := parseCount(sorted.Rows[0][0]) + if err != nil { + t.Fatalf("failed to parse first sorted count: %v", err) + } + secondCount, err := parseCount(sorted.Rows[1][0]) + if err != nil { + t.Fatalf("failed to parse second sorted count: %v", err) + } + if !(firstCount >= secondCount && firstCount == 5) { + t.Fatalf("elastic sort not applied correctly: %+v", sorted.Rows) + } + + rangeFilter := &model.WhereCondition{ + Type: model.WhereConditionTypeAtomic, + Atomic: &model.AtomicWhereCondition{ + Key: "count", + Operator: ">", + Value: "1", + ColumnType: "integer", + }, + } + ranged, err := esTarget.plugin.GetRows(esTarget.config, &engine.GetRowsRequest{Schema: esTarget.schema, StorageUnit: index, Where: rangeFilter, Sort: []*model.SortCondition{}, PageSize: 10}) + if err != nil { + t.Fatalf("elastic range filter failed: %v", err) + } + if ranged.TotalCount < 1 { + t.Fatalf("expected range filter to return rows") + } + + updateJSON, _ := json.Marshal(map[string]any{ + "_id": docID, + "title": "Updated ES", + "count": 2, + "created_at": "2024-02-03T10:00:00Z", + }) + updated, err := esTarget.plugin.UpdateStorageUnit(esTarget.config, esTarget.schema, index, map[string]string{ + "document": string(updateJSON), + }, []string{"title", "count"}) + if err != nil || !updated { + t.Fatalf("elastic update failed: %v", err) + } + + rows, err = esTarget.plugin.GetRows(esTarget.config, &engine.GetRowsRequest{Schema: esTarget.schema, StorageUnit: index, Sort: []*model.SortCondition{}, PageSize: 10}) + if err != nil { + t.Fatalf("elastic get rows after update failed: %v", err) + } + foundUpdated := false + for _, r := range rows.Rows { + if strings.Contains(r[0], "Updated ES") && (strings.Contains(r[0], `"count":2`) || strings.Contains(r[0], `"count":"2"`)) { + foundUpdated = true + break + } + } + if !foundUpdated { + t.Fatalf("elastic update not reflected: %+v", rows.Rows) + } + + _, err = esTarget.plugin.UpdateStorageUnit(esTarget.config, esTarget.schema, index, map[string]string{ + "document": `{"title":"missing id"}`, + }, []string{"title"}) + if err == nil { + t.Fatalf("expected elastic update without _id to fail") + } + + deleted, err := esTarget.plugin.DeleteRow(esTarget.config, esTarget.schema, index, map[string]string{ + "document": string(updateJSON), + }) + if err != nil || !deleted { + t.Fatalf("elastic delete failed: %v", err) + } + + _, err = esTarget.plugin.DeleteRow(esTarget.config, esTarget.schema, index, map[string]string{ + "document": `{"title":"missing id"}`, + }) + if err == nil { + t.Fatalf("expected elastic delete without _id to fail") + } + + rows, err = esTarget.plugin.GetRows(esTarget.config, &engine.GetRowsRequest{Schema: esTarget.schema, StorageUnit: index, Sort: []*model.SortCondition{}, PageSize: 10}) + if err != nil { + t.Fatalf("elastic get rows after delete failed: %v", err) + } + for _, r := range rows.Rows { + if strings.Contains(r[0], docID) { + t.Fatalf("expected deleted doc to be gone, found %+v", rows.Rows) + } + } + + var exported [][]string + if err := esPlugin.ExportData(esTarget.config, esTarget.schema, index, func(row []string) error { + exported = append(exported, row) + return nil + }, nil); err != nil && !strings.Contains(err.Error(), "index") { + t.Fatalf("elastic export failed: %v", err) + } + + var ndjsonLines []string + if err := esPlugin.ExportDataNDJSON(esTarget.config, esTarget.schema, index, func(line string) error { + ndjsonLines = append(ndjsonLines, line) + return nil + }, nil); err != nil && !strings.Contains(err.Error(), "index") { + t.Fatalf("elastic ndjson export failed: %v", err) + } + + var selected [][]string + selectedRows := []map[string]any{{"_id": "sel1", "title": "Selected", "count": 9}} + if err := esPlugin.ExportData(esTarget.config, esTarget.schema, index, func(row []string) error { + selected = append(selected, row) + return nil + }, selectedRows); err != nil { + t.Fatalf("elastic selected export failed: %v", err) + } + if len(selected) != 2 { + t.Fatalf("expected selected export to emit header + 1 row, got %d", len(selected)) + } + if len(selected[1]) == 0 || (!strings.Contains(strings.Join(selected[1], ","), "Selected") && !strings.Contains(strings.Join(selected[1], ","), "sel1")) { + t.Fatalf("selected export row does not contain expected values: %+v", selected[1]) + } + + if _, err := esTarget.plugin.GetRows(esTarget.config, &engine.GetRowsRequest{Schema: esTarget.schema, StorageUnit: "missing-index", PageSize: 5}); err == nil { + t.Fatalf("expected missing index query to fail") + } +} + +func TestRedisLiveData(t *testing.T) { + var redisTarget *target + for i := range targets { + if targets[i].plugin.Type == engine.DatabaseType_Redis { + redisTarget = &targets[i] + break + } + } + if redisTarget == nil { + t.Skip("redis target not configured") + } + + client, err := redisplugin.DB(redisTarget.config) + if err != nil { + t.Fatalf("failed to connect to redis: %v", err) + } + defer client.Close() + ctx := context.Background() + + stringKey := "intg:string" + hashKey := "intg:hash" + listKey := "intg:list" + zsetKey := "intg:zset" + setKey := "intg:set" + + client.Del(ctx, stringKey, hashKey, listKey, zsetKey, setKey) + + if err := client.Set(ctx, stringKey, "value-one", 0).Err(); err != nil { + t.Fatalf("failed to seed string key: %v", err) + } + stringRows, err := redisTarget.plugin.GetRows(redisTarget.config, &engine.GetRowsRequest{Schema: redisTarget.schema, StorageUnit: stringKey, Sort: []*model.SortCondition{}, PageSize: 10}) + if err != nil || len(stringRows.Rows) != 1 || !strings.Contains(stringRows.Rows[0][0], "value-one") { + t.Fatalf("unexpected redis string rows: %+v err=%v", stringRows.Rows, err) + } + + if err := client.HSet(ctx, hashKey, "field", "v1").Err(); err != nil { + t.Fatalf("failed to seed hash: %v", err) + } + hashRows, err := redisTarget.plugin.GetRows(redisTarget.config, &engine.GetRowsRequest{Schema: redisTarget.schema, StorageUnit: hashKey, Sort: []*model.SortCondition{}, PageSize: 10}) + if err != nil || len(hashRows.Rows) == 0 { + t.Fatalf("hash rows missing: %+v err=%v", hashRows.Rows, err) + } + if _, err := redisTarget.plugin.UpdateStorageUnit(redisTarget.config, redisTarget.schema, hashKey, map[string]string{ + "field": "field", + "value": "v2", + }, []string{"value"}); err != nil { + t.Fatalf("hash update failed: %v", err) + } + hashRows, _ = redisTarget.plugin.GetRows(redisTarget.config, &engine.GetRowsRequest{Schema: redisTarget.schema, StorageUnit: hashKey, Sort: []*model.SortCondition{}, PageSize: 10}) + if !strings.Contains(hashRows.Rows[0][1], "v2") { + t.Fatalf("hash update not reflected: %+v", hashRows.Rows) + } + if _, err := redisTarget.plugin.DeleteRow(redisTarget.config, redisTarget.schema, hashKey, map[string]string{"field": "field"}); err != nil { + t.Fatalf("hash delete failed: %v", err) + } + + if err := client.RPush(ctx, listKey, "a", "b", "c").Err(); err != nil { + t.Fatalf("failed to seed list: %v", err) + } + listRows, err := redisTarget.plugin.GetRows(redisTarget.config, &engine.GetRowsRequest{Schema: redisTarget.schema, StorageUnit: listKey, Sort: []*model.SortCondition{}, PageSize: 10}) + if err != nil || len(listRows.Rows) != 3 { + t.Fatalf("list rows unexpected: %+v err=%v", listRows.Rows, err) + } + if _, err := redisTarget.plugin.UpdateStorageUnit(redisTarget.config, redisTarget.schema, listKey, map[string]string{ + "index": "1", + "value": "bee", + }, []string{"value"}); err != nil { + t.Fatalf("list update failed: %v", err) + } + listRows, _ = redisTarget.plugin.GetRows(redisTarget.config, &engine.GetRowsRequest{Schema: redisTarget.schema, StorageUnit: listKey, Sort: []*model.SortCondition{}, PageSize: 10}) + if listRows.Rows[1][1] != "bee" { + t.Fatalf("list update not reflected: %+v", listRows.Rows) + } + if _, err := redisTarget.plugin.DeleteRow(redisTarget.config, redisTarget.schema, listKey, map[string]string{"index": "0"}); err != nil { + t.Fatalf("list delete failed: %v", err) + } + + if err := client.SAdd(ctx, setKey, "red", "blue").Err(); err != nil { + t.Fatalf("failed to seed set: %v", err) + } + setRows, err := redisTarget.plugin.GetRows(redisTarget.config, &engine.GetRowsRequest{Schema: redisTarget.schema, StorageUnit: setKey, Sort: []*model.SortCondition{}, PageSize: 10}) + if err != nil || len(setRows.Rows) != 2 { + t.Fatalf("set rows unexpected: %+v err=%v", setRows.Rows, err) + } + if _, err := redisTarget.plugin.DeleteRow(redisTarget.config, redisTarget.schema, setKey, map[string]string{"member": "red"}); err != nil { + t.Fatalf("set delete failed: %v", err) + } + + if err := client.ZAdd(ctx, zsetKey, &redislib.Z{Score: 1, Member: "low"}, &redislib.Z{Score: 2, Member: "high"}).Err(); err != nil { + t.Fatalf("failed to seed zset: %v", err) + } + zsetRows, err := redisTarget.plugin.GetRows(redisTarget.config, &engine.GetRowsRequest{Schema: redisTarget.schema, StorageUnit: zsetKey, Sort: []*model.SortCondition{}, PageSize: 10}) + if err != nil || len(zsetRows.Rows) != 2 { + t.Fatalf("zset rows unexpected: %+v err=%v", zsetRows.Rows, err) + } + if _, err := redisTarget.plugin.DeleteRow(redisTarget.config, redisTarget.schema, zsetKey, map[string]string{ + "member": "low", + }); err != nil { + t.Fatalf("zset delete failed: %v", err) + } + zsetRows, _ = redisTarget.plugin.GetRows(redisTarget.config, &engine.GetRowsRequest{Schema: redisTarget.schema, StorageUnit: zsetKey, Sort: []*model.SortCondition{}, PageSize: 10}) + if len(zsetRows.Rows) != 1 || !strings.Contains(zsetRows.Rows[0][1], "high") { + t.Fatalf("zset delete not reflected: %+v", zsetRows.Rows) + } + + if cnt, err := redisTarget.plugin.GetRowCount(redisTarget.config, redisTarget.schema, stringKey, nil); err != nil || cnt != 1 { + t.Fatalf("row count for string unexpected: %d err=%v", cnt, err) + } + if _, err := redisTarget.plugin.DeleteRow(redisTarget.config, redisTarget.schema, setKey, map[string]string{}); err == nil { + t.Fatalf("expected delete without member to fail for set") + } + if _, err := redisTarget.plugin.DeleteRow(redisTarget.config, redisTarget.schema, listKey, map[string]string{}); err == nil { + t.Fatalf("expected delete without index to fail for list") + } + + if units, err := redisTarget.plugin.GetStorageUnits(redisTarget.config, redisTarget.schema); err != nil || len(units) == 0 { + t.Fatalf("expected redis storage units, got err=%v count=%d", err, len(units)) + } + + ttlKey := "intg:ttl" + if err := client.Set(ctx, ttlKey, "expire", 2*time.Second).Err(); err != nil { + t.Fatalf("failed to set ttl key: %v", err) + } + time.Sleep(3 * time.Second) + if exists, _ := redisTarget.plugin.StorageUnitExists(redisTarget.config, redisTarget.schema, ttlKey); exists { + t.Fatalf("expected ttl key to expire") + } + + client.Del(ctx, stringKey, hashKey, listKey, zsetKey, setKey) +} + +func runSQLTypeCase(t *testing.T, target target, tc typeCase, idx int) { + t.Helper() + + table := fmt.Sprintf("intg_%s_%s_%d", target.name, sanitize(tc.columnType), idx) + pkType := primaryKeyType(target) + fields := []engine.Record{ + {Key: "id", Value: pkType, Extra: map[string]string{"Primary": "true", "Nullable": "false"}}, + {Key: "val", Value: tc.columnType, Extra: map[string]string{"Primary": "false", "Nullable": "false"}}, + } + + created, err := target.plugin.AddStorageUnit(target.config, target.schema, table, fields) + if err != nil || !created { + t.Skipf("skip type %s on %s: %v", tc.columnType, target.name, err) + return + } + defer target.plugin.RawExecute(target.config, dropStatement(target, table)) + + ok, err := target.plugin.AddRow(target.config, target.schema, table, []engine.Record{ + {Key: "id", Value: "1", Extra: map[string]string{"Type": pkType}}, + {Key: "val", Value: tc.value, Extra: map[string]string{"Type": tc.columnType}}, + }) + if err != nil || !ok { + t.Fatalf("failed to insert sample for %s on %s: %v", tc.columnType, target.name, err) + } + + res, err := target.plugin.GetRows(target.config, &engine.GetRowsRequest{Schema: target.schema, StorageUnit: table, Sort: []*model.SortCondition{}, PageSize: 5}) + if err != nil { + t.Fatalf("GetRows failed for %s on %s: %v", tc.columnType, target.name, err) + } + valIdx := columnIndex(res.Columns, "val") + if valIdx == -1 || len(res.Rows) == 0 { + t.Fatalf("no val column for %s on %s", tc.columnType, target.name) + } + got := res.Rows[0][valIdx] + expect := tc.expect + if expect == nil { + expect = expectationForValue(tc, tc.value) + } + if !expect(got) { + t.Fatalf("round trip mismatch for %s on %s: got %s", tc.columnType, target.name, got) + } + + if tc.updated != "" { + updatedExpect := expect + if tc.updated != tc.value { + updatedExpect = expectationForValue(tc, tc.updated) + } + _, err := target.plugin.UpdateStorageUnit(target.config, target.schema, table, map[string]string{ + "id": "1", + "val": tc.updated, + }, []string{"val"}) + if err != nil { + if strings.Contains(err.Error(), "no rows were updated") || strings.Contains(err.Error(), "scale") || strings.Contains(err.Error(), "syntax for type date") { + return + } + t.Fatalf("update failed for %s on %s: %v", tc.columnType, target.name, err) + } + attempts := 1 + if target.plugin.Type == engine.DatabaseType_ClickHouse { + attempts = 5 + } + var lastGot string + for i := 0; i < attempts; i++ { + if i > 0 { + time.Sleep(200 * time.Millisecond) + } + res, err = target.plugin.GetRows(target.config, &engine.GetRowsRequest{Schema: target.schema, StorageUnit: table, Sort: []*model.SortCondition{}, PageSize: 5}) + if err != nil { + t.Fatalf("GetRows after update failed for %s on %s: %v", tc.columnType, target.name, err) + } + lastGot = res.Rows[0][valIdx] + if updatedExpect(lastGot) { + break + } + } + if !updatedExpect(lastGot) { + t.Fatalf("update mismatch for %s on %s: got %s expected %s", tc.columnType, target.name, lastGot, tc.updated) + } + } +} + +func sqlCasesForTarget(target target) []typeCase { + meta := target.plugin.GetDatabaseMetadata() + if meta == nil { + return nil + } + + overrides := overridesFor(target.plugin.Type) + baseCases := make([]typeCase, 0, len(meta.TypeDefinitions)) + for _, td := range meta.TypeDefinitions { + base := strings.ToUpper(td.ID) + override := overrides[base] + if override.skip { + continue + } + + columnType := override.columnType + if columnType == "" { + columnType = td.ID + if td.HasLength && td.DefaultLength != nil { + columnType = fmt.Sprintf("%s(%d)", td.ID, *td.DefaultLength) + } + if td.HasPrecision && td.DefaultPrecision != nil { + scale := 2 + columnType = fmt.Sprintf("%s(%d,%d)", td.ID, *td.DefaultPrecision, scale) + } + } + + value := override.value + if value == "" { + value = defaultValueFor(target.plugin.Type, base, td.Category) + } + + updated := override.updated + if updated == "" { + updated = defaultUpdatedValue(value, base) + } + + expect := override.expect + + baseCases = append(baseCases, typeCase{ + name: base, + columnType: columnType, + value: value, + updated: updated, + expect: expect, + category: td.Category, + }) + } + + return addAliasCases(meta, baseCases) +} + +func addAliasCases(meta *engine.DatabaseMetadata, baseCases []typeCase) []typeCase { + if meta == nil { + return baseCases + } + + cases := make([]typeCase, 0, len(baseCases)+len(meta.AliasMap)) + cases = append(cases, baseCases...) + + byBase := map[string]typeCase{} + for _, tc := range baseCases { + byBase[common.ParseTypeSpec(tc.columnType).BaseType] = tc + } + + for alias, canonical := range meta.AliasMap { + base := strings.ToUpper(canonical) + tc, ok := byBase[base] + if !ok { + continue + } + spec := common.ParseTypeSpec(tc.columnType) + spec.BaseType = strings.ToUpper(alias) + cases = append(cases, typeCase{ + name: fmt.Sprintf("%s_alias", alias), + columnType: common.FormatTypeSpec(spec), + value: tc.value, + updated: tc.updated, + expect: tc.expect, + category: tc.category, + }) + } + + return cases +} + +func overridesFor(dbType engine.DatabaseType) map[string]typeOverride { + mysqlOverrides := map[string]typeOverride{ + "TINYINT": {updated: ""}, + "DECIMAL": {columnType: "DECIMAL(10,2)"}, + "BOOLEAN": {value: "1", updated: "0"}, + "TIME": {value: "12:34:56", updated: "23:45:59"}, + "TIMESTAMP": { + value: "2024-01-02 15:04:05", + updated: "2025-01-02 15:04:05", + }, + "ENUM": {columnType: "ENUM('red','blue')", value: "red", updated: "blue"}, + "SET": {columnType: "SET('a','b','c')", value: "a,b", updated: "b"}, + "BINARY": {columnType: "BINARY(4)", value: "ab", updated: "cd", expect: expectHexContains("6162")}, + "VARBINARY": {value: "ab", expect: expectBinaryEqual("ab")}, + "TINYBLOB": {value: "a", expect: expectBinaryEqual("a")}, + "BLOB": {value: "b", expect: expectBinaryEqual("b")}, + "MEDIUMBLOB": {value: "c", expect: expectBinaryEqual("c")}, + "LONGBLOB": {value: "d", expect: expectBinaryEqual("d")}, + "YEAR": {value: "2024", updated: "2025"}, + "CHAR": {columnType: "CHAR(5)", value: "hello", updated: "world"}, + "VARCHAR": {columnType: "VARCHAR(64)"}, + } + + clickhouseOverrides := map[string]typeOverride{ + "DECIMAL": {columnType: "Decimal(10,2)"}, + "DECIMAL32": {columnType: "Decimal32(2)"}, + "DECIMAL64": {columnType: "Decimal64(2)"}, + "DECIMAL128": {columnType: "Decimal128(2)"}, + "FIXEDSTRING": {columnType: "FixedString(16)", value: "fixedstringvalue", updated: "fixedstringvalue"}, + "DATETIME64": {columnType: "DateTime64(3)", value: "2024-01-02 15:04:05.123", updated: "2024-02-03 10:00:00.456"}, + "ENUM8": {columnType: "Enum8('a' = 1, 'b' = 2)", value: "a", updated: "b"}, + "ENUM16": {columnType: "Enum16('a' = 1, 'b' = 2)", value: "a", updated: "b"}, + "JSON": {value: `{"ch":true}`, updated: `{"ch":false}`, expect: expectJSONStrict(`{"ch":true}`)}, + } + + pgOverrides := map[string]typeOverride{ + "CHARACTER VARYING": {columnType: "CHARACTER VARYING(64)", value: "varchar value", updated: "varchar updated"}, + "CHARACTER": {columnType: "CHARACTER(4)", value: "abcd", updated: "wxyz"}, + "DECIMAL": {columnType: "DECIMAL(10,2)"}, + "NUMERIC": {columnType: "NUMERIC(8,3)"}, + "BYTEA": {value: "hello", updated: "okay", expect: expectBinaryEqual("hello")}, + "TIMESTAMP": {value: "2024-01-02 15:04:05", updated: "2024-02-02 15:04:05"}, + "TIMESTAMP WITH TIME ZONE": {value: "2024-01-02T15:04:05Z", updated: "2025-01-02T00:00:00Z"}, + "TIME": {value: "12:34:56", updated: "23:59:59"}, + "TIME WITH TIME ZONE": {value: "12:34:56+00", updated: "01:02:03+00"}, + "MONEY": {value: "123.45", updated: "67.89", expect: expectContains("123.45")}, + "UUID": {value: "00000000-0000-0000-0000-000000000123"}, + "ARRAY": {columnType: "INTEGER[]", value: "{1,2,3}", updated: "{4,5,6}"}, + "JSONB": {value: `{"key":"value"}`, updated: `{"key":"updated"}`}, + "JSON": {value: `{"json":"value"}`, updated: `{"json":"updated"}`}, + "CIDR": {value: "10.0.0.0/24", updated: "10.0.1.0/24"}, + "INET": {value: "10.0.0.1", updated: "10.0.0.2"}, + "MACADDR": {value: "AA:BB:CC:DD:EE:FF", updated: "00:11:22:33:44:55"}, + "POINT": {value: "(1,2)", updated: "(3,4)"}, + "LSEG": {value: "[(0,0),(1,1)]", updated: "[(1,1),(2,2)]"}, + "BOX": {value: "((0,0),(1,1))", updated: "((0,0),(1,1))", expect: expectContains("(")}, + "PATH": {value: "[(0,0),(1,1),(2,0)]", updated: "[(0,0),(2,2)]"}, + "CIRCLE": {value: "<(0,0),1>", updated: "<(1,1),2>"}, + "POLYGON": {value: "((0,0),(1,0),(1,1))", updated: "((0,0),(2,0),(2,2))"}, + "XML": {value: "1", updated: "2"}, + "HSTORE": {value: "\"a\"=>\"1\"", updated: "\"a\"=>\"2\""}, + "SERIAL": {value: "5", updated: "6"}, + "BIGSERIAL": {value: "7", updated: "8"}, + "SMALLSERIAL": {value: "9", updated: "10"}, + "LINE": {value: "((0,0),(1,1))", updated: "((0,0),(1,1))", expect: expectContains("{")}, + } + + switch dbType { + case engine.DatabaseType_Postgres: + return pgOverrides + case engine.DatabaseType_ClickHouse: + return clickhouseOverrides + case engine.DatabaseType_MariaDB: + return mysqlOverrides + case engine.DatabaseType_MySQL: + return mysqlOverrides + default: + return map[string]typeOverride{} + } +} + +func defaultValueFor(dbType engine.DatabaseType, base string, category engine.TypeCategory) string { + switch category { + case engine.TypeCategoryNumeric: + if strings.Contains(base, "DECIMAL") || strings.Contains(base, "NUMERIC") { + return "123.45" + } + if strings.Contains(base, "MONEY") { + return "12.34" + } + return "42" + case engine.TypeCategoryText: + return "a" + case engine.TypeCategoryBinary: + return "a" + case engine.TypeCategoryDatetime: + switch base { + case "DATE", "DATE32": + return "2024-01-02" + case "TIME", "TIME WITH TIME ZONE": + return "12:34:56" + case "YEAR": + return "2024" + default: + return "2024-01-02 15:04:05" + } + case engine.TypeCategoryBoolean: + return "true" + case engine.TypeCategoryJSON: + return `{"key":"value"}` + case engine.TypeCategoryOther: + switch base { + case "UUID": + return "00000000-0000-0000-0000-000000000000" + case "CIDR": + return "192.168.0.0/24" + case "INET", "IPV4": + return "192.168.0.1" + case "IPV6": + return "2001:db8::1" + case "MACADDR": + return "AA:BB:CC:DD:EE:11" + case "BOOL": + return "true" + case "ENUM8", "ENUM16": + return "a" + default: + if strings.Contains(base, "ARRAY") { + return "{1,2,3}" + } + if strings.Contains(base, "UUID") { + return "00000000-0000-0000-0000-000000000001" + } + return "other" + } + default: + return "sample" + } +} + +func defaultUpdatedValue(original string, base string) string { + switch { + case strings.Contains(strings.ToUpper(base), "JSON"): + if mutated := mutateJSON(original); mutated != "" { + return mutated + } + return `{"updated":true}` + case strings.HasPrefix(original, "{") || strings.HasPrefix(original, "<") || strings.HasPrefix(original, "("): + return original + case regexp.MustCompile(`^[0-9a-fA-Fx]+$`).MatchString(original): + return original + } + switch strings.ToLower(original) { + case "true": + return "false" + case "false": + return "true" + } + upperBase := strings.ToUpper(base) + if strings.Contains(upperBase, "UUID") { + return original + } + if strings.Contains(upperBase, "IPV4") || strings.Contains(upperBase, "INET") { + return "192.168.0.2" + } + if strings.Contains(upperBase, "CIDR") { + return "192.168.1.0/24" + } + if strings.Contains(upperBase, "IPV6") { + return "2001:db8::2" + } + if strings.Contains(upperBase, "DATE") { + return "2024-02-02" + } + if strings.Contains(upperBase, "TIME") { + return "01:02:03" + } + if _, err := strconv.ParseFloat(original, 64); err == nil { + return "99" + } + return original + "-updated" +} + +func mutateJSON(original string) string { + var v any + if err := json.Unmarshal([]byte(original), &v); err != nil { + return "" + } + switch m := v.(type) { + case map[string]any: + m["updated"] = true + if b, err := json.Marshal(m); err == nil { + return string(b) + } + case []any: + m = append(m, "updated") + if b, err := json.Marshal(m); err == nil { + return string(b) + } + default: + if b, err := json.Marshal([]any{m, "updated"}); err == nil { + return string(b) + } + } + return "" +} + +func expectContains(substr string) func(string) bool { + return func(got string) bool { + return strings.Contains(strings.ToLower(got), strings.ToLower(substr)) + } +} + +func expectHexContains(hexStr string) func(string) bool { + lower := strings.ToLower(strings.TrimPrefix(hexStr, "0x")) + asciiHex := strings.ToLower(fmt.Sprintf("%x", []byte(lower))) + return func(got string) bool { + g := strings.ToLower(strings.TrimPrefix(got, "0x")) + return strings.Contains(g, lower) || strings.Contains(g, asciiHex) + } +} + +func expectBinaryEqual(sample string) func(string) bool { + return func(got string) bool { + isHexString := func(s string) bool { + for _, r := range s { + if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') { + continue + } + return false + } + return len(s) > 0 + } + + sampleLower := strings.ToLower(strings.TrimPrefix(sample, "0x")) + sampleIsHex := strings.HasPrefix(strings.ToLower(sample), "0x") || (len(sampleLower)%2 == 0 && len(sampleLower) >= 4 && isHexString(sampleLower)) + + raw := strings.TrimPrefix(strings.ToLower(got), "0x") + if decoded, err := hex.DecodeString(raw); err == nil { + decoded = bytes.TrimRight(decoded, "\x00") + // If sample is hex (explicitly marked or long hex), decode it; otherwise compare to raw string + if sampleIsHex { + if sampleHex, err := hex.DecodeString(sampleLower); err == nil { + return bytes.Equal(decoded, sampleHex) + } + } + return strings.EqualFold(string(decoded), sample) + } + return strings.Contains(strings.ToLower(got), strings.ToLower(sample)) + } +} + +func expectJSONStrict(expected string) func(string) bool { + return func(got string) bool { + var m map[string]any + var exp map[string]any + if err := json.Unmarshal([]byte(expected), &exp); err != nil { + return false + } + if err := json.Unmarshal([]byte(got), &m); err != nil { + trimmed := strings.Trim(got, `"`) + if err2 := json.Unmarshal([]byte(trimmed), &m); err2 != nil { + return false + } + } + return reflect.DeepEqual(m, exp) + } +} + +func expectAnyHex(values ...string) func(string) bool { + matchers := make([]func(string) bool, 0, len(values)) + for _, v := range values { + matchers = append(matchers, expectHexContains(v)) + } + return func(got string) bool { + for _, m := range matchers { + if m(got) { + return true + } + } + return false + } +} + +func defaultExpectation(tc typeCase) func(string) bool { + return expectationForValue(tc, tc.value) +} + +func expectationForValue(tc typeCase, value string) func(string) bool { + base := strings.ToUpper(common.ParseTypeSpec(tc.columnType).BaseType) + switch tc.category { + case engine.TypeCategoryNumeric: + if strings.Contains(base, "MONEY") { + return expectMoneyEqual(value) + } + return expectNumericEqual(value) + case engine.TypeCategoryDatetime: + return expectTimeLike(value) + case engine.TypeCategoryBoolean: + return expectEqualNormalized(value) + case engine.TypeCategoryBinary: + return expectBinaryEqual(value) + case engine.TypeCategoryJSON: + return expectJSONStrict(value) + default: + switch base { + case "UUID", "CIDR", "INET", "MACADDR", "IPV4", "IPV6": + return expectEqualNormalized(value) + } + return expectContains(value) + } +} + +func expectNumericEqual(expected string) func(string) bool { + return func(got string) bool { + e := new(big.Rat) + g := new(big.Rat) + if _, ok := e.SetString(expected); !ok { + return false + } + if _, ok := g.SetString(strings.Fields(got)[0]); !ok { + return false + } + return e.Cmp(g) == 0 + } +} + +func expectMoneyEqual(expected string) func(string) bool { + numExpect := expectNumericEqual(expected) + return func(got string) bool { + clean := strings.Map(func(r rune) rune { + if (r >= '0' && r <= '9') || r == '.' || r == '-' { + return r + } + return -1 + }, got) + return numExpect(clean) + } +} + +func expectTimeLike(expected string) func(string) bool { + return func(got string) bool { + exp := strings.TrimSpace(expected) + g := strings.TrimSpace(got) + if strings.Contains(exp, "T") || strings.Contains(exp, " ") { + // compare date portion + return strings.Contains(g, exp[:10]) + } + return strings.Contains(g, exp) + } +} + +func expectEqualNormalized(expected string) func(string) bool { + return func(got string) bool { + return strings.EqualFold(strings.TrimSpace(expected), strings.TrimSpace(got)) + } +} + +func expectHexLength(hexValue string, columnType string) func(string) bool { + valueBytes := len(strings.TrimPrefix(hexValue, "0x")) / 2 + typeLen := parseTypeLength(columnType) + expectedBytes := valueBytes + if typeLen > 0 && valueBytes >= typeLen { + expectedBytes = typeLen + } + return func(got string) bool { + raw := strings.TrimPrefix(strings.ToLower(got), "0x") + return len(raw)/2 == expectedBytes + } +} + +func parseTypeLength(typeStr string) int { + re := regexp.MustCompile(`\((\d+)`) + m := re.FindStringSubmatch(typeStr) + if len(m) == 2 { + if n, err := strconv.Atoi(m[1]); err == nil { + return n + } + } + return 0 +} + +func primaryKeyType(target target) string { + switch target.plugin.Type { + case engine.DatabaseType_ClickHouse: + return "Int32" + case engine.DatabaseType_Postgres: + return "INTEGER" + default: + return "INT" + } +} + +func dropStatement(target target, table string) string { + if target.schema != "" { + return fmt.Sprintf("DROP TABLE IF EXISTS %s.%s", target.schema, table) + } + return fmt.Sprintf("DROP TABLE IF EXISTS %s", table) +} + +func columnIndex(cols []engine.Column, name string) int { + for i, col := range cols { + if strings.EqualFold(col.Name, name) { + return i + } + } + return -1 +} + +func sanitize(typeName string) string { + reg := regexp.MustCompile(`[^a-zA-Z0-9]+`) + return reg.ReplaceAllString(strings.ToLower(typeName), "_") +} diff --git a/dataflow/core/test/integration/main_test.go b/dataflow/core/test/integration/main_test.go new file mode 100644 index 0000000..6d80359 --- /dev/null +++ b/dataflow/core/test/integration/main_test.go @@ -0,0 +1,196 @@ +//go:build integration + +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package integration + +import ( + "fmt" + "net" + "os" + "os/exec" + "testing" + "time" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/plugins/clickhouse" + "github.com/clidey/whodb/core/src/plugins/elasticsearch" + "github.com/clidey/whodb/core/src/plugins/mongodb" + "github.com/clidey/whodb/core/src/plugins/mysql" + "github.com/clidey/whodb/core/src/plugins/postgres" + "github.com/clidey/whodb/core/src/plugins/redis" +) + +type target struct { + name string + plugin *engine.Plugin + config *engine.PluginConfig + schema string + enabled bool +} + +var targets []target + +func TestMain(m *testing.M) { + if os.Getenv("WHODB_START_COMPOSE") == "1" { + if err := runComposeUp(); err != nil { + fmt.Fprintf(os.Stderr, "failed to start docker-compose: %v\n", err) + os.Exit(1) + } + } + + waitForServices() + initTargets() + + os.Exit(m.Run()) +} + +func runComposeUp() error { + cmd := exec.Command("docker", "compose", "-f", "dev/docker-compose.yml", "up", "-d") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func waitForServices() { + ports := []string{"5432", "3306", "3307", "3308", "27017", "9000", "6379", "8123", "9200"} + for _, p := range ports { + waitForPort("127.0.0.1", p, 2*time.Minute) + } +} + +func waitForPort(host, port string, timeout time.Duration) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), 3*time.Second) + if err == nil { + _ = conn.Close() + return + } + time.Sleep(2 * time.Second) + } +} + +func initTargets() { + targets = []target{ + { + name: "postgres", + plugin: postgres.NewPostgresPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_Postgres), + Hostname: "localhost", + Username: "user", + Password: "jio53$*(@nfe)", + Database: "test_db", + }), + schema: "public", + enabled: true, + }, + { + name: "mysql", + plugin: mysql.NewMySQLPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_MySQL), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db", + }), + schema: "test_db", + enabled: true, + }, + { + name: "mariadb", + plugin: mysql.NewMyMariaDBPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_MariaDB), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db", + Advanced: []engine.Record{{Key: "Port", Value: "3307"}}, + }), + schema: "test_db", + enabled: true, + }, + { + name: "mysql842", + plugin: mysql.NewMySQLPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_MySQL), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db_842", + Advanced: []engine.Record{{Key: "Port", Value: "3308"}}, + }), + schema: "test_db_842", + enabled: true, + }, + { + name: "clickhouse", + plugin: clickhouse.NewClickHousePlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_ClickHouse), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db", + Advanced: []engine.Record{{Key: "Port", Value: "9000"}}, + }), + schema: "test_db", + enabled: true, + }, + { + name: "mongo", + plugin: mongodb.NewMongoDBPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_MongoDB), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db", + }), + schema: "test_db", + enabled: true, + }, + { + name: "elasticsearch", + plugin: elasticsearch.NewElasticSearchPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_ElasticSearch), + Hostname: "localhost", + Advanced: []engine.Record{{Key: "Port", Value: "9200"}}, + }), + schema: "", + enabled: true, + }, + { + name: "redis", + plugin: redis.NewRedisPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_Redis), + Hostname: "localhost", + Password: "password", + Database: "0", + Advanced: []engine.Record{{Key: "Port", Value: "6379"}}, + }), + schema: "", + enabled: true, + }, + } +} diff --git a/dataflow/core/test/integration/postgres_storage_unit_exists_test.go b/dataflow/core/test/integration/postgres_storage_unit_exists_test.go new file mode 100644 index 0000000..7bf1b2e --- /dev/null +++ b/dataflow/core/test/integration/postgres_storage_unit_exists_test.go @@ -0,0 +1,150 @@ +//go:build integration + +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package integration + +import ( + "testing" + + "github.com/clidey/whodb/core/src/engine" +) + +// TestPostgresStorageUnitExistsPreservesRawIdentifiers verifies PostgreSQL relation lookup with raw names. +func TestPostgresStorageUnitExistsPreservesRawIdentifiers(t *testing.T) { + var postgresTarget *target + for i := range targets { + if targets[i].plugin.Type == engine.DatabaseType_Postgres { + postgresTarget = &targets[i] + break + } + } + if postgresTarget == nil { + t.Skip("postgres target not configured") + } + + tests := []struct { + name string + storageUnit string + createQuery string + dropQuery string + }{ + { + name: "lowercase", + storageUnit: "intg_exists_lowercase", + createQuery: `CREATE TABLE public."intg_exists_lowercase" (id integer)`, + dropQuery: `DROP TABLE IF EXISTS public."intg_exists_lowercase"`, + }, + { + name: "camel case", + storageUnit: "intgExistsCamelCase", + createQuery: `CREATE TABLE public."intgExistsCamelCase" (id integer)`, + dropQuery: `DROP TABLE IF EXISTS public."intgExistsCamelCase"`, + }, + { + name: "space", + storageUnit: "intg exists space", + createQuery: `CREATE TABLE public."intg exists space" (id integer)`, + dropQuery: `DROP TABLE IF EXISTS public."intg exists space"`, + }, + { + name: "dot", + storageUnit: "intg.exists.dot", + createQuery: `CREATE TABLE public."intg.exists.dot" (id integer)`, + dropQuery: `DROP TABLE IF EXISTS public."intg.exists.dot"`, + }, + { + name: "double quote", + storageUnit: `intg_exists_double"quote`, + createQuery: `CREATE TABLE public."intg_exists_double""quote" (id integer)`, + dropQuery: `DROP TABLE IF EXISTS public."intg_exists_double""quote"`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := postgresTarget.plugin.RawExecute(postgresTarget.config, test.dropQuery); err != nil { + t.Fatalf("failed to remove stale fixture: %v", err) + } + t.Cleanup(func() { + if _, err := postgresTarget.plugin.RawExecute(postgresTarget.config, test.dropQuery); err != nil { + t.Errorf("failed to clean up fixture: %v", err) + } + }) + + if _, err := postgresTarget.plugin.RawExecute(postgresTarget.config, test.createQuery); err != nil { + t.Fatalf("failed to create fixture: %v", err) + } + + exists, err := postgresTarget.plugin.StorageUnitExists( + postgresTarget.config, + postgresTarget.schema, + test.storageUnit, + ) + if err != nil { + t.Fatalf("StorageUnitExists returned an error: %v", err) + } + if !exists { + t.Fatalf("expected raw storage unit name %q to exist", test.storageUnit) + } + }) + } + + t.Run("special schema", func(t *testing.T) { + const ( + schema = `intg.Exists"Schema` + storageUnit = "intg_exists_special_schema" + dropQuery = `DROP SCHEMA IF EXISTS "intg.Exists""Schema" CASCADE` + ) + + if _, err := postgresTarget.plugin.RawExecute(postgresTarget.config, dropQuery); err != nil { + t.Fatalf("failed to remove stale schema fixture: %v", err) + } + t.Cleanup(func() { + if _, err := postgresTarget.plugin.RawExecute(postgresTarget.config, dropQuery); err != nil { + t.Errorf("failed to clean up schema fixture: %v", err) + } + }) + + if _, err := postgresTarget.plugin.RawExecute(postgresTarget.config, `CREATE SCHEMA "intg.Exists""Schema"`); err != nil { + t.Fatalf("failed to create schema fixture: %v", err) + } + if _, err := postgresTarget.plugin.RawExecute(postgresTarget.config, `CREATE TABLE "intg.Exists""Schema"."intg_exists_special_schema" (id integer)`); err != nil { + t.Fatalf("failed to create table fixture: %v", err) + } + + exists, err := postgresTarget.plugin.StorageUnitExists(postgresTarget.config, schema, storageUnit) + if err != nil { + t.Fatalf("StorageUnitExists returned an error: %v", err) + } + if !exists { + t.Fatalf("expected raw schema %q and storage unit %q to exist", schema, storageUnit) + } + }) + + exists, err := postgresTarget.plugin.StorageUnitExists( + postgresTarget.config, + postgresTarget.schema, + "intg_exists_missing", + ) + if err != nil { + t.Fatalf("StorageUnitExists returned an error for a missing relation: %v", err) + } + if exists { + t.Fatal("expected missing relation not to exist") + } +} diff --git a/dataflow/core/test/integration/roundtrip_test.go b/dataflow/core/test/integration/roundtrip_test.go new file mode 100644 index 0000000..066a160 --- /dev/null +++ b/dataflow/core/test/integration/roundtrip_test.go @@ -0,0 +1,382 @@ +//go:build integration + +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package integration + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/99designs/gqlgen/graphql/handler" + graph "github.com/clidey/whodb/core/graph" + "github.com/clidey/whodb/core/graph/model" + "github.com/clidey/whodb/core/src" + "github.com/clidey/whodb/core/src/auth" + "github.com/clidey/whodb/core/src/common" + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/types" +) + +func TestConnectorAvailability(t *testing.T) { + for _, target := range targets { + if !target.enabled { + continue + } + t.Run(target.name, func(t *testing.T) { + if ok := target.plugin.IsAvailable(context.Background(), target.config); !ok { + t.Fatalf("plugin reported unavailable for %s", target.name) + } + }) + } +} + +func TestSQLTypeRoundTrips(t *testing.T) { + for _, target := range targets { + if target.plugin.GetDatabaseMetadata() == nil { + continue + } + if target.plugin.Type == engine.DatabaseType_MongoDB || target.plugin.Type == engine.DatabaseType_ElasticSearch { + continue + } + t.Run(target.name, func(t *testing.T) { + meta := target.plugin.GetDatabaseMetadata() + for idx, td := range meta.TypeDefinitions { + sample, ok, expected := sampleValue(td) + if !ok { + continue + } + if strings.Contains(td.ID, "SERIAL") { + continue + } + table := fmt.Sprintf("intg_%s_%d", strings.ToLower(string(target.plugin.Type)), idx) + fields := []engine.Record{ + { + Key: "id", + Value: "INT", + Extra: map[string]string{"Primary": "true", "Nullable": "false"}, + }, + { + Key: "val", + Value: normalizeTypeForTest(target.plugin.Type, td.ID), + Extra: map[string]string{ + "Primary": "false", + "Nullable": "false", + }, + }, + } + + created, err := target.plugin.AddStorageUnit(target.config, target.schema, table, fields) + if err != nil || !created { + t.Skipf("skip type %s on %s: %v", td.ID, target.name, err) + continue + } + defer target.plugin.RawExecute(target.config, fmt.Sprintf("DROP TABLE IF EXISTS %s.%s", target.schema, table)) + + valRecord := []engine.Record{ + {Key: "id", Value: "1", Extra: map[string]string{"Type": "INT"}}, + {Key: "val", Value: sample, Extra: map[string]string{"Type": td.ID}}, + } + inserted, err := target.plugin.AddRow(target.config, target.schema, table, valRecord) + if err != nil || !inserted { + t.Fatalf("failed to insert sample for %s on %s: %v", td.ID, target.name, err) + } + + rows, err := target.plugin.GetRows(target.config, &engine.GetRowsRequest{Schema: target.schema, StorageUnit: table, Sort: []*model.SortCondition{}, PageSize: 10}) + if err != nil { + t.Fatalf("GetRows failed for %s on %s: %v", td.ID, target.name, err) + } + if len(rows.Rows) == 0 { + t.Fatalf("expected at least one row for %s on %s", td.ID, target.name) + } + + valIdx := 1 + if len(rows.Rows[0]) == 1 { + valIdx = 0 + } + got := rows.Rows[0][valIdx] + if expected != "" { + if !matchesExpectation(got, sample, expected) { + t.Fatalf("round trip mismatch for %s on %s: got %s expected %s", td.ID, target.name, got, expected) + } + } + + // Update row and read again + update := map[string]string{"id": "1", "val": sample} + if _, err := target.plugin.UpdateStorageUnit(target.config, target.schema, table, update, []string{"val"}); err != nil { + if strings.Contains(err.Error(), "WHERE conditions required") || strings.Contains(err.Error(), "no rows were updated") { + t.Skipf("skipping update for %s on %s: %v", td.ID, target.name, err) + return + } + t.Fatalf("update failed for %s on %s: %v", td.ID, target.name, err) + } + } + }) + } +} + +// normalizeTypeForTest adjusts type strings for integration portability (e.g., ClickHouse decimals require scale) +func normalizeTypeForTest(dbType engine.DatabaseType, typeID string) string { + if dbType == engine.DatabaseType_ClickHouse { + up := strings.ToUpper(typeID) + switch { + case strings.HasPrefix(up, "DECIMAL32"): + return "Decimal32(2)" + case strings.HasPrefix(up, "DECIMAL64"): + return "Decimal64(2)" + case strings.HasPrefix(up, "DECIMAL128"): + return "Decimal128(2)" + case strings.HasPrefix(up, "FIXEDSTRING"): + return "FixedString(8)" + } + return typeID + } + if dbType == engine.DatabaseType_Postgres && strings.Contains(strings.ToUpper(typeID), "ARRAY") { + return "INTEGER[]" + } + return typeID +} + +func matchesExpectation(got string, sample string, expected string) bool { + if expected == "" { + return true + } + + cleanMoney := func(s string) string { + return strings.Map(func(r rune) rune { + if (r >= '0' && r <= '9') || r == '.' || r == '-' { + return r + } + return -1 + }, s) + } + + if strings.ContainsAny(got, "$€£") { + return cleanMoney(got) == cleanMoney(expected) + } + + normalizeTime := func(s string) string { + s = strings.ReplaceAll(s, "T", " ") + s = strings.TrimSuffix(s, "Z") + return s + } + if (strings.ContainsAny(expected, "T:") && (strings.Contains(expected, "-") || strings.Contains(got, "-"))) || + (strings.Contains(got, "T") && strings.Contains(expected, "-")) { + return strings.Contains(normalizeTime(got), normalizeTime(expected)) + } + + if strings.Contains(expected, ":") && strings.Contains(got, ":") { + trimSuffixes := func(s string) string { + s = strings.SplitN(s, "+", 2)[0] + s = strings.SplitN(s, "-", 2)[0] + s = strings.TrimSpace(s) + return s + } + gTrim := trimSuffixes(got) + eTrim := trimSuffixes(expected) + if gTrim == eTrim { + return true + } + } + + if strings.HasPrefix(strings.TrimSpace(expected), "{") || strings.HasPrefix(strings.TrimSpace(expected), "[") { + var expObj any + var gotObj any + if err := json.Unmarshal([]byte(expected), &expObj); err == nil && json.Unmarshal([]byte(got), &gotObj) == nil { + return reflect.DeepEqual(expObj, gotObj) + } + } + + lowerGot := strings.ToLower(got) + lowerExpected := strings.ToLower(expected) + raw := strings.TrimPrefix(lowerGot, "0x") + if decoded, err := hex.DecodeString(raw); err == nil { + decoded = bytes.TrimRight(decoded, "\x00") + decodedStr := strings.ToLower(string(decoded)) + expHex := strings.TrimPrefix(lowerExpected, "0x") + if decodedStr == strings.ToLower(sample) || decodedStr == lowerExpected || hex.EncodeToString(decoded) == expHex { + return true + } + } + + if lowerGot == lowerExpected || lowerGot == strings.ToLower(sample) { + return true + } + + return false +} + +func TestMongoRoundTrip(t *testing.T) { + var mongoTarget *target + for i := range targets { + if targets[i].plugin.Type == engine.DatabaseType_MongoDB { + mongoTarget = &targets[i] + break + } + } + if mongoTarget == nil { + t.Skip("mongo target not configured") + } + + doc := map[string]any{"_id": "507f1f77bcf86cd799439011", "name": "integration", "count": 1} + b, _ := json.Marshal(doc) + ok, err := mongoTarget.plugin.AddRow(mongoTarget.config, mongoTarget.schema, "items", []engine.Record{ + {Key: "document", Value: string(b)}, + }) + if err != nil || !ok { + t.Fatalf("failed to insert mongo doc: %v", err) + } + rows, err := mongoTarget.plugin.GetRows(mongoTarget.config, &engine.GetRowsRequest{Schema: mongoTarget.schema, StorageUnit: "items", Sort: []*model.SortCondition{}, PageSize: 10}) + if err != nil { + t.Fatalf("mongo get rows failed: %v", err) + } + if len(rows.Rows) == 0 { + t.Fatalf("expected rows from mongo collection") + } +} + +func TestElasticsearchAvailability(t *testing.T) { + var esTarget *target + for i := range targets { + if targets[i].plugin.Type == engine.DatabaseType_ElasticSearch { + esTarget = &targets[i] + break + } + } + if esTarget == nil { + t.Skip("elasticsearch target not configured") + } + if !esTarget.plugin.IsAvailable(context.Background(), esTarget.config) { + t.Fatalf("elasticsearch not available") + } +} + +func sampleValue(td engine.TypeDefinition) (string, bool, string) { + switch td.Category { + case engine.TypeCategoryNumeric: + if strings.Contains(td.ID, "DECIMAL") || strings.Contains(td.ID, "NUMERIC") || strings.Contains(td.ID, "MONEY") { + return "123.45", true, "123.45" + } + return "42", true, "42" + case engine.TypeCategoryText: + return "a", true, "a" + case engine.TypeCategoryBinary: + return "ab", true, "ab" + case engine.TypeCategoryDatetime: + switch strings.ToUpper(td.ID) { + case "DATE", "DATE32": + return "2024-01-02", true, "2024-01-02" + case "TIME", "TIME WITH TIME ZONE": + return "12:34:56", true, "12:34:56" + case "YEAR": + return "2024", true, "2024" + default: + return "2024-01-02 15:04:05", true, "2024-01-02 15:04:05" + } + case engine.TypeCategoryBoolean: + return "true", true, "true" + case engine.TypeCategoryJSON: + return `{"key":"value"}`, true, `{"key":"value"}` + case engine.TypeCategoryOther: + if strings.Contains(strings.ToUpper(td.ID), "UUID") { + return "00000000-0000-0000-0000-000000000001", true, "00000000-0000-0000-0000-000000000001" + } + if strings.Contains(strings.ToUpper(td.ID), "ARRAY") { + return "{1,2,3}", true, "1" + } + return "", false, "" + default: + return "", false, "" + } +} + +func TestServerSmokeAgainstPostgres(t *testing.T) { + var pgTarget *target + for i := range targets { + if targets[i].plugin.Type == engine.DatabaseType_Postgres { + pgTarget = &targets[i] + break + } + } + if pgTarget == nil { + t.Skip("postgres target not configured") + } + + origEngine := src.MainEngine + src.MainEngine = &engine.Engine{} + src.MainEngine.RegistryPlugin(pgTarget.plugin) + t.Cleanup(func() { src.MainEngine = origEngine }) + + src.MainEngine.AddLoginProfile(types.DatabaseCredentials{ + Type: string(pgTarget.config.Credentials.Type), + Hostname: pgTarget.config.Credentials.Hostname, + Database: pgTarget.config.Credentials.Database, + Username: pgTarget.config.Credentials.Username, + Password: pgTarget.config.Credentials.Password, + Port: common.GetRecordValueOrDefault(pgTarget.config.Credentials.Advanced, "Port", ""), + }) + + // Start a minimal handler: use GraphQL server directly rather than full binary to avoid changing scripts + srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{Resolvers: &graph.Resolver{}})) + ctx := context.WithValue(context.Background(), auth.AuthKey_Credentials, pgTarget.config.Credentials) + + // simple AddRow/Row via GraphQL against live DB + table := "intg_smoke" + _, _ = pgTarget.plugin.RawExecute(pgTarget.config, fmt.Sprintf("DROP TABLE IF EXISTS %s.%s", pgTarget.schema, table)) + _, err := pgTarget.plugin.AddStorageUnit(pgTarget.config, pgTarget.schema, table, []engine.Record{ + {Key: "id", Value: "INTEGER", Extra: map[string]string{"Primary": "true", "Nullable": "false"}}, + }) + if err != nil { + t.Fatalf("failed to create table for smoke: %v", err) + } + + graphAdd := `mutation($schema:String!,$table:String!){ AddRow(schema:$schema, storageUnit:$table, values:[{Key:"id", Value:"1"}]){Status}}` + body, _ := json.Marshal(map[string]any{ + "query": graphAdd, + "variables": map[string]any{"schema": pgTarget.schema, "table": table}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBuffer(body)) + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("graphql add row failed: %d %s", w.Code, w.Body.String()) + } + + graphRow := `query($schema:String!,$table:String!){ Row(schema:$schema, storageUnit:$table, where:{Type:Atomic,Atomic:{Key:"id",Operator:"=",Value:"1",ColumnType:"integer"}}, sort:[{Column:"id", Direction:ASC}], pageSize:10, pageOffset:0){ Rows }}` + body, _ = json.Marshal(map[string]any{ + "query": graphRow, + "variables": map[string]any{"schema": pgTarget.schema, "table": table}, + }) + req = httptest.NewRequest(http.MethodPost, "/api/query", bytes.NewBuffer(body)) + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + srv.ServeHTTP(w, req) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "1") { + t.Fatalf("graphql row query failed: %d %s", w.Code, w.Body.String()) + } +} diff --git a/dataflow/core/test/integration/ssl_test.go b/dataflow/core/test/integration/ssl_test.go new file mode 100644 index 0000000..8aff6fb --- /dev/null +++ b/dataflow/core/test/integration/ssl_test.go @@ -0,0 +1,566 @@ +//go:build integration + +/* + * Copyright 2026 Clidey, Inc. + * + * 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. + */ + +package integration + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/clidey/whodb/core/src/engine" + "github.com/clidey/whodb/core/src/plugins/clickhouse" + "github.com/clidey/whodb/core/src/plugins/elasticsearch" + "github.com/clidey/whodb/core/src/plugins/mongodb" + "github.com/clidey/whodb/core/src/plugins/mysql" + "github.com/clidey/whodb/core/src/plugins/postgres" + "github.com/clidey/whodb/core/src/plugins/redis" + "github.com/clidey/whodb/core/src/plugins/ssl" +) + +// sslTarget represents an SSL-enabled database target for testing. +type sslTarget struct { + name string + plugin *engine.Plugin + config *engine.PluginConfig + schema string + sslMode ssl.SSLMode + expectFail bool // true if this config should fail to connect + skipReason string +} + +// getCertsDir returns the path to the dev/certs directory. +func getCertsDir() string { + // Try relative path from test directory + candidates := []string{ + "../../../dev/certs", + "../../dev/certs", + "dev/certs", + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + abs, _ := filepath.Abs(c) + return abs + } + } + return "" +} + +// readCertFile reads a certificate file and returns its contents. +func readCertFile(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return string(data) +} + +// initSSLTargets creates the SSL test targets. +// These require the SSL containers to be running (docker compose --profile ssl up). +func initSSLTargets() []sslTarget { + certsDir := getCertsDir() + if certsDir == "" { + return nil + } + + // Read certificate contents + postgresCA := readCertFile(filepath.Join(certsDir, "ca/postgres/ca.pem")) + postgresClientCert := readCertFile(filepath.Join(certsDir, "client/postgres/client-cert.pem")) + postgresClientKey := readCertFile(filepath.Join(certsDir, "client/postgres/client-key.pem")) + + mysqlCA := readCertFile(filepath.Join(certsDir, "ca/mysql/ca.pem")) + mysqlClientCert := readCertFile(filepath.Join(certsDir, "client/mysql/client-cert.pem")) + mysqlClientKey := readCertFile(filepath.Join(certsDir, "client/mysql/client-key.pem")) + + mariadbCA := readCertFile(filepath.Join(certsDir, "ca/mariadb/ca.pem")) + mariadbClientCert := readCertFile(filepath.Join(certsDir, "client/mariadb/client-cert.pem")) + mariadbClientKey := readCertFile(filepath.Join(certsDir, "client/mariadb/client-key.pem")) + + mongoCA := readCertFile(filepath.Join(certsDir, "ca/mongodb/ca.pem")) + mongoClientCert := readCertFile(filepath.Join(certsDir, "client/mongodb/client-cert.pem")) + mongoClientKey := readCertFile(filepath.Join(certsDir, "client/mongodb/client-key.pem")) + + redisCA := readCertFile(filepath.Join(certsDir, "ca/redis/ca.pem")) + redisClientCert := readCertFile(filepath.Join(certsDir, "client/redis/client-cert.pem")) + redisClientKey := readCertFile(filepath.Join(certsDir, "client/redis/client-key.pem")) + + clickhouseCA := readCertFile(filepath.Join(certsDir, "ca/clickhouse/ca.pem")) + + elasticsearchCA := readCertFile(filepath.Join(certsDir, "ca/elasticsearch/ca.pem")) + + return []sslTarget{ + // PostgreSQL SSL tests + { + name: "postgres_ssl_required", + plugin: postgres.NewPostgresPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_Postgres), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db", + Advanced: []engine.Record{ + {Key: "Port", Value: "5433"}, + {Key: "SSL Mode", Value: string(ssl.SSLModeRequired)}, + }, + }), + schema: "public", + sslMode: ssl.SSLModeRequired, + }, + { + name: "postgres_ssl_verify_ca", + plugin: postgres.NewPostgresPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_Postgres), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db", + Advanced: []engine.Record{ + {Key: "Port", Value: "5433"}, + {Key: "SSL Mode", Value: string(ssl.SSLModeVerifyCA)}, + {Key: "SSL CA Content", Value: postgresCA}, + {Key: "SSL Client Cert Content", Value: postgresClientCert}, + {Key: "SSL Client Key Content", Value: postgresClientKey}, + }, + }), + schema: "public", + sslMode: ssl.SSLModeVerifyCA, + }, + { + name: "postgres_ssl_verify_ca_invalid_ca", + plugin: postgres.NewPostgresPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_Postgres), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db", + Advanced: []engine.Record{ + {Key: "Port", Value: "5433"}, + {Key: "SSL Mode", Value: string(ssl.SSLModeVerifyCA)}, + // Invalid CA content - should fail certificate verification + {Key: "SSL CA Content", Value: "-----BEGIN CERTIFICATE-----\nINVALID_CERTIFICATE_DATA\n-----END CERTIFICATE-----"}, + }, + }), + schema: "public", + sslMode: ssl.SSLModeVerifyCA, + expectFail: true, + }, + + // MySQL SSL tests + { + name: "mysql_ssl_required", + plugin: mysql.NewMySQLPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_MySQL), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db", + Advanced: []engine.Record{ + {Key: "Port", Value: "3309"}, + {Key: "SSL Mode", Value: string(ssl.SSLModeRequired)}, + }, + }), + schema: "test_db", + sslMode: ssl.SSLModeRequired, + }, + { + name: "mysql_ssl_verify_ca", + plugin: mysql.NewMySQLPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_MySQL), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db", + Advanced: []engine.Record{ + {Key: "Port", Value: "3309"}, + {Key: "SSL Mode", Value: string(ssl.SSLModeVerifyCA)}, + {Key: "SSL CA Content", Value: mysqlCA}, + {Key: "SSL Client Cert Content", Value: mysqlClientCert}, + {Key: "SSL Client Key Content", Value: mysqlClientKey}, + }, + }), + schema: "test_db", + sslMode: ssl.SSLModeVerifyCA, + }, + + // MariaDB SSL tests + { + name: "mariadb_ssl_required", + plugin: mysql.NewMyMariaDBPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_MariaDB), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db", + Advanced: []engine.Record{ + {Key: "Port", Value: "3310"}, + {Key: "SSL Mode", Value: string(ssl.SSLModeRequired)}, + }, + }), + schema: "test_db", + sslMode: ssl.SSLModeRequired, + }, + { + name: "mariadb_ssl_verify_ca", + plugin: mysql.NewMyMariaDBPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_MariaDB), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db", + Advanced: []engine.Record{ + {Key: "Port", Value: "3310"}, + {Key: "SSL Mode", Value: string(ssl.SSLModeVerifyCA)}, + {Key: "SSL CA Content", Value: mariadbCA}, + {Key: "SSL Client Cert Content", Value: mariadbClientCert}, + {Key: "SSL Client Key Content", Value: mariadbClientKey}, + }, + }), + schema: "test_db", + sslMode: ssl.SSLModeVerifyCA, + }, + + // MongoDB SSL tests + { + name: "mongodb_ssl_enabled", + plugin: mongodb.NewMongoDBPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_MongoDB), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db", + Advanced: []engine.Record{ + {Key: "Port", Value: "27018"}, + {Key: "SSL Mode", Value: string(ssl.SSLModeEnabled)}, + {Key: "SSL CA Content", Value: mongoCA}, + {Key: "SSL Client Cert Content", Value: mongoClientCert}, + {Key: "SSL Client Key Content", Value: mongoClientKey}, + }, + }), + schema: "test_db", + sslMode: ssl.SSLModeEnabled, + }, + { + name: "mongodb_ssl_insecure", + plugin: mongodb.NewMongoDBPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_MongoDB), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db", + Advanced: []engine.Record{ + {Key: "Port", Value: "27018"}, + {Key: "SSL Mode", Value: string(ssl.SSLModeInsecure)}, + }, + }), + schema: "test_db", + sslMode: ssl.SSLModeInsecure, + }, + + // Redis SSL tests + { + name: "redis_ssl_enabled", + plugin: redis.NewRedisPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_Redis), + Hostname: "localhost", + Password: "password", + Database: "0", + Advanced: []engine.Record{ + {Key: "Port", Value: "6380"}, + {Key: "SSL Mode", Value: string(ssl.SSLModeEnabled)}, + {Key: "SSL CA Content", Value: redisCA}, + {Key: "SSL Client Cert Content", Value: redisClientCert}, + {Key: "SSL Client Key Content", Value: redisClientKey}, + }, + }), + schema: "", + sslMode: ssl.SSLModeEnabled, + }, + { + name: "redis_ssl_insecure", + plugin: redis.NewRedisPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_Redis), + Hostname: "localhost", + Password: "password", + Database: "0", + Advanced: []engine.Record{ + {Key: "Port", Value: "6380"}, + {Key: "SSL Mode", Value: string(ssl.SSLModeInsecure)}, + }, + }), + schema: "", + sslMode: ssl.SSLModeInsecure, + }, + + // ClickHouse SSL tests + { + name: "clickhouse_ssl_enabled", + plugin: clickhouse.NewClickHousePlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_ClickHouse), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db", + Advanced: []engine.Record{ + {Key: "Port", Value: "9440"}, + {Key: "SSL Mode", Value: string(ssl.SSLModeEnabled)}, + {Key: "SSL CA Content", Value: clickhouseCA}, + }, + }), + schema: "test_db", + sslMode: ssl.SSLModeEnabled, + }, + { + name: "clickhouse_ssl_insecure", + plugin: clickhouse.NewClickHousePlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_ClickHouse), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db", + Advanced: []engine.Record{ + {Key: "Port", Value: "9440"}, + {Key: "SSL Mode", Value: string(ssl.SSLModeInsecure)}, + }, + }), + schema: "test_db", + sslMode: ssl.SSLModeInsecure, + }, + + // Elasticsearch SSL tests + { + name: "elasticsearch_ssl_enabled", + plugin: elasticsearch.NewElasticSearchPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_ElasticSearch), + Hostname: "localhost", + Username: "elastic", + Password: "password", + Advanced: []engine.Record{ + {Key: "Port", Value: "9201"}, + {Key: "SSL Mode", Value: string(ssl.SSLModeEnabled)}, + {Key: "SSL CA Content", Value: elasticsearchCA}, + }, + }), + schema: "", + sslMode: ssl.SSLModeEnabled, + }, + { + name: "elasticsearch_ssl_insecure", + plugin: elasticsearch.NewElasticSearchPlugin(), + config: engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_ElasticSearch), + Hostname: "localhost", + Username: "elastic", + Password: "password", + Advanced: []engine.Record{ + {Key: "Port", Value: "9201"}, + {Key: "SSL Mode", Value: string(ssl.SSLModeInsecure)}, + }, + }), + schema: "", + sslMode: ssl.SSLModeInsecure, + }, + } +} + +// TestSSLConnectionAvailability tests that SSL connections can be established. +func TestSSLConnectionAvailability(t *testing.T) { + if os.Getenv("WHODB_SSL_TESTS") != "1" { + t.Skip("SSL tests disabled. Set WHODB_SSL_TESTS=1 and run docker compose --profile ssl up") + } + + sslTargets := initSSLTargets() + if len(sslTargets) == 0 { + t.Skip("Could not find certs directory") + } + + for _, target := range sslTargets { + t.Run(target.name, func(t *testing.T) { + if target.skipReason != "" { + t.Skip(target.skipReason) + } + + ok := target.plugin.IsAvailable(context.Background(), target.config) + if target.expectFail { + if ok { + t.Errorf("expected connection to fail for %s but it succeeded", target.name) + } + } else { + if !ok { + t.Errorf("expected connection to succeed for %s but it failed", target.name) + } + } + }) + } +} + +// TestSSLStatusResolver tests that GetSSLStatus returns correct values. +func TestSSLStatusResolver(t *testing.T) { + if os.Getenv("WHODB_SSL_TESTS") != "1" { + t.Skip("SSL tests disabled. Set WHODB_SSL_TESTS=1 and run docker compose --profile ssl up") + } + + sslTargets := initSSLTargets() + if len(sslTargets) == 0 { + t.Skip("Could not find certs directory") + } + + for _, target := range sslTargets { + if target.expectFail || target.skipReason != "" { + continue + } + + t.Run(target.name, func(t *testing.T) { + status, err := target.plugin.GetSSLStatus(target.config) + if err != nil { + t.Fatalf("GetSSLStatus failed: %v", err) + } + + if status == nil { + t.Fatal("GetSSLStatus returned nil") + } + + if !status.IsEnabled { + t.Errorf("expected SSL to be enabled for %s, got IsEnabled=false", target.name) + } + + // Check that mode matches what we configured + expectedMode := string(target.sslMode) + if status.Mode != expectedMode { + // Some databases normalize the mode, so check for common variations + if !strings.EqualFold(status.Mode, expectedMode) && status.Mode != "enabled" { + t.Errorf("expected SSL mode %q for %s, got %q", expectedMode, target.name, status.Mode) + } + } + }) + } +} + +// TestSSLDisabledConnection tests that non-SSL connections to SSL-only servers fail appropriately. +func TestSSLDisabledConnection(t *testing.T) { + if os.Getenv("WHODB_SSL_TESTS") != "1" { + t.Skip("SSL tests disabled. Set WHODB_SSL_TESTS=1 and run docker compose --profile ssl up") + } + + // Try connecting to SSL-only Postgres without SSL - should fail or connect without SSL + plugin := postgres.NewPostgresPlugin() + config := engine.NewPluginConfig(&engine.Credentials{ + Type: string(engine.DatabaseType_Postgres), + Hostname: "localhost", + Username: "user", + Password: "password", + Database: "test_db", + Advanced: []engine.Record{ + {Key: "Port", Value: "5433"}, + {Key: "SSL Mode", Value: string(ssl.SSLModeDisabled)}, + }, + }) + + // The server requires SSL, so this should either: + // 1. Fail to connect + // 2. Connect but report SSL as disabled + ok := plugin.IsAvailable(context.Background(), config) + if ok { + // If it connected, verify SSL status shows disabled + status, err := plugin.GetSSLStatus(config) + if err == nil && status != nil && status.IsEnabled { + t.Error("connected with SSL disabled but GetSSLStatus reports SSL enabled") + } + } + // If it failed to connect, that's expected for SSL-required servers +} + +// TestSSLBasicQueryAfterConnection tests that queries work over SSL connections. +func TestSSLBasicQueryAfterConnection(t *testing.T) { + if os.Getenv("WHODB_SSL_TESTS") != "1" { + t.Skip("SSL tests disabled. Set WHODB_SSL_TESTS=1 and run docker compose --profile ssl up") + } + + sslTargets := initSSLTargets() + if len(sslTargets) == 0 { + t.Skip("Could not find certs directory") + } + + for _, target := range sslTargets { + if target.expectFail || target.skipReason != "" { + continue + } + + // Skip non-SQL databases for basic query test + if target.plugin.Type == engine.DatabaseType_MongoDB || + target.plugin.Type == engine.DatabaseType_Redis || + target.plugin.Type == engine.DatabaseType_ElasticSearch { + continue + } + + t.Run(target.name, func(t *testing.T) { + // Simple query to verify connection works + result, err := target.plugin.RawExecute(target.config, "SELECT 1") + if err != nil { + t.Fatalf("RawExecute failed over SSL: %v", err) + } + + if result == nil || len(result.Rows) == 0 { + t.Error("expected non-empty result from SELECT 1") + } + }) + } +} + +// TestSSLModeValidation tests that invalid SSL modes are rejected. +func TestSSLModeValidation(t *testing.T) { + tests := []struct { + name string + dbType engine.DatabaseType + mode string + valid bool + }{ + {"postgres_valid_required", engine.DatabaseType_Postgres, "required", true}, + {"postgres_valid_verify_ca", engine.DatabaseType_Postgres, "verify-ca", true}, + {"postgres_invalid_preferred", engine.DatabaseType_Postgres, "preferred", false}, + {"mysql_valid_required", engine.DatabaseType_MySQL, "required", true}, + {"mysql_valid_preferred", engine.DatabaseType_MySQL, "preferred", true}, + {"mysql_invalid_verify_identity", engine.DatabaseType_MySQL, "verify-identity", true}, // MySQL supports this + {"clickhouse_valid_enabled", engine.DatabaseType_ClickHouse, "enabled", true}, + {"clickhouse_invalid_required", engine.DatabaseType_ClickHouse, "required", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + valid := ssl.ValidateSSLMode(tc.dbType, ssl.SSLMode(tc.mode)) + if valid != tc.valid { + t.Errorf("ValidateSSLMode(%s, %s) = %v, want %v", tc.dbType, tc.mode, valid, tc.valid) + } + }) + } +} diff --git a/dataflow/core/tools/tools.go b/dataflow/core/tools/tools.go new file mode 100644 index 0000000..d1ff9ca --- /dev/null +++ b/dataflow/core/tools/tools.go @@ -0,0 +1,22 @@ +// Copyright 2025 Clidey, Inc. +// +// 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. + +//go:build tools + +package tools + +import ( + _ "github.com/99designs/gqlgen" + _ "github.com/99designs/gqlgen/graphql/introspection" +) diff --git a/dataflow/dataflow/.nvmrc b/dataflow/dataflow/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/dataflow/dataflow/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/dataflow/dataflow/codegen.yml b/dataflow/dataflow/codegen.yml new file mode 100644 index 0000000..8c5313e --- /dev/null +++ b/dataflow/dataflow/codegen.yml @@ -0,0 +1,12 @@ +schema: "../core/graph/schema.graphqls" +documents: "src/**/*.graphql" +generates: + src/generated/graphql.tsx: + plugins: + - typescript + - typescript-operations + - typescript-react-apollo + config: + withHooks: true + withHOC: false + withComponent: false diff --git a/dataflow/dataflow/components.json b/dataflow/dataflow/components.json new file mode 100644 index 0000000..7033a49 --- /dev/null +++ b/dataflow/dataflow/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "tailwind": { + "config": "", + "css": "src/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "rsc": false, + "tsx": true, + "iconLibrary": "lucide", + "aliases": { + "utils": "@/lib/utils", + "components": "@/components", + "ui": "@/components/ui", + "hooks": "@/hooks", + "lib": "@/lib" + } +} diff --git a/dataflow/dataflow/docs/PRD.md b/dataflow/dataflow/docs/PRD.md new file mode 100644 index 0000000..f81fb5d --- /dev/null +++ b/dataflow/dataflow/docs/PRD.md @@ -0,0 +1,802 @@ +# DataFlow — 产品需求文档(PRD) + +> **范围**:本文件盘点 DataFlow 在当前版本**已实现的全部用户可感知能力**。SQL / MongoDB / Redis 三类数据源因功能差异较大,独立成节。 +> **读者**:产品 / 业务 / 解决方案同学。 +> **不包含**:技术实现(语言、框架、接口协议)、部署拓扑、未来路线图、Phased Rollout。 +> **当前版本**:`0.1.0` + +--- + +## 0. 产品定位 + +**DataFlow 是一个面向 Sealos 云平台数据库用户的 Web 数据库工作台。** + +用户在 Sealos 控制台开通 Postgres / MySQL / MongoDB / Redis / ClickHouse 实例后,点击"连接"即可免配置、免安装、免注册地进入 DataFlow,直接做三类事: + +1. **浏览和维护数据**——像传统 GUI 客户端一样查看库、表、集合、键;增删改记录;做表结构变更;导入导出数据。 +2. **写查询和命令**——在编辑器里写 SQL、Mongo shell 或 Redis 命令,多语句一次执行,结果分条呈现。 +3. **把查询结果沉淀成仪表盘**——把一条查询的结果一键变成图表,挂到可复用、可自动刷新的仪表盘上。 + +一句话总结:**"一个从 Sealos 点进来就能用的数据库 GUI + 轻量 BI 面板"**。 + +--- + +## 1. Executive Summary + +### 1.1 Problem + +Sealos 用户开通数据库实例后,目前缺少**一个和云控制台无缝衔接**的 Web 工作台。主流替代品都有一至多项关键缺陷: + +- **传统桌面客户端**(DBeaver / TablePlus / Redis Insight 等):需安装、需手工输入连接串、凭证管理是用户自己的事。 +- **各家云厂商的内嵌控制台**:通常只覆盖一两种数据源,能力各自为政,跨 Postgres / MySQL / MongoDB / Redis / ClickHouse 时体验断裂。 +- **开源 Web GUI**:多数面向"多连接管理员"设计,缺少"单实例、一点就进、开箱即用"的云端嵌入体验;且几乎都不带仪表盘。 + +结果是:工程师日常要在多个工具之间来回切换——在 DB 客户端里查数据,在 Grafana/Metabase 里做看板,在 Sealos 控制台里管实例。 + +### 1.2 Solution + +DataFlow 把"**数据库 GUI**"与"**轻量仪表盘**"合并成一个产品,并在以下 4 个维度做了差异化选择: + +1. **云原生的零配置登录**——从 Sealos 控制台的"连接"按钮点击进入后,完成身份验证、选定实例、加载元数据、打开工作区全部自动完成,用户不需要输入任何连接信息。 +2. **一套 UI 覆盖五种异构数据源**——关系型(Postgres / MySQL / ClickHouse)与非关系型(MongoDB / Redis)共享同一套交互骨架(对象树、Tab、分页、筛选、导出、图表入口),但针对各数据源的数据模型差异提供**独立的编辑管线和命令拦截规则**。 +3. **"预览 → 确认 → 执行"的安全编辑管线**——任何会写入数据库的修改(行编辑、结构变更、批量导出等)都在提交前展示将要发生的具体操作,用户确认后才执行;不会默写入、不会静默失败。 +4. **查询与仪表盘共生**——任何一次 SQL/Mongo/Redis 查询的结果,都可以一键进入"创建图表"流程,选择图表类型和轴即可挂到仪表盘;仪表盘支持手动或每分钟自动刷新,并缓存最近一次结果,保证再次打开时秒级可见。 + +### 1.3 Success Criteria(可落地的度量方案) + +以下 5 项指标目前尚未在线采集,本节给出**如何度量 + 建议阈值**,可直接作为产品埋点需求落地: + +| # | 指标 | 度量方式 | 建议阈值 | +|---|---|---|---| +| M1 | **首次可交互时间 (TTI)** —— 从 Sealos 点"连接"到 DataFlow 工作区侧边栏可点击的时长 | 前端埋点:跳转进入 → 侧边树首次渲染 | P95 ≤ 2.5s | +| M2 | **查询执行往返时延** —— 从用户点 Run 到查询结果首行可见 | 前端埋点:Run 按钮点击 → 结果表首行渲染 | P95 ≤ 800ms(10k 行以内) | +| M3 | **编辑提交成功率** —— 表/集合/Key 编辑的成功提交数 / 总提交数 | 前端埋点:预览确认 → 后端确认返回 | ≥ 98% | +| M4 | **仪表盘复用度** —— 过去 7 天被打开 ≥ 2 次的仪表盘占比 | 前端埋点:打开/切换仪表盘事件 | ≥ 40% | +| M5 | **多语句执行的错误可解释性** —— 多条语句批量执行时,每条失败语句都能独立定位到对应的错误气泡 | QA 自动化断言 / 人工抽检 | 100%(已实现) | + +> M1–M4 属于**待埋点**需求;M5 **已在产品中实现**,可直接通过自动化用例验证。 + +--- + +## 2. 用户与功能 + +### 2.1 用户画像 + +| Persona | 角色 | 在 DataFlow 里做什么 | 频率 | +|---|---|---|---| +| **P1 应用工程师** | 在 Sealos 上跑业务服务,临时查数据 / 改一两行 / 看表结构 | 写 SELECT、按条件筛选、编辑一两行、查看表 DDL、偶尔导出 | 高频 | +| **P2 DBA / 技术负责人** | 负责实例维护 | 建表/改表/导数据、多语句执行、清理旧表、整库导出 | 中频 | +| **P3 数据分析师** | 非 DBA,写查询并沉淀可视化 | 写 SQL → 配图表 → 挂到仪表盘 → 定期刷新 | 中频 | +| **P4 临时访客(支持 / PM)** | 排查具体问题 | 打开仪表盘看最新数据;不做写操作 | 低频 | + +### 2.2 能力总览 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ DataFlow │ +├──────────────┬──────────────────────┬────────────────────────────┤ +│ 活动栏 │ 数据工作区 │ 分析工作区 │ +│ ├──────────────────────┼────────────────────────────┤ +│ Connections │ ① 登录 & 连接 │ ⑥ 仪表盘与图表 │ +│ Analysis │ ② 对象树 + Tab │ │ +│ │ ③ 数据浏览与编辑 │ │ +│ │ ├ SQL │ │ +│ │ ├ MongoDB │ │ +│ │ └ Redis │ │ +│ │ ④ Schema/DDL │ │ +│ │ ⑤ 查询编辑器 │ │ +│ │ ├ SQL │ │ +│ │ ├ MongoDB │ │ +│ │ └ Redis │ │ +└──────────────┴──────────────────────┴────────────────────────────┘ +``` + +--- + +### 2.3 功能柱 ① 登录与连接 + +**用户故事** + +- **US-1.1** 作为 Sealos 用户,点击"连接数据库"后我不需要输入任何凭证,直接进入 DataFlow 并看到该实例的库列表。 +- **US-1.2** 作为已登录用户,刷新页面后继续保持登录,不被再次跳转。 + +**验收标准** + +- [x] 支持从 Sealos 控制台带加密凭证的链接直接登录,覆盖 **Postgres / MySQL / MongoDB / Redis / ClickHouse** 五种数据源类型。 +- [x] 不支持的数据源类型展示明确错误提示,而非白屏。 +- [x] 登录成功后凭证在浏览器本地会话中持久化;刷新页面自动恢复登录态。 +- [x] 整个会话只呈现**当前这个实例**的一条连接,不让用户误以为可以切换到其他实例。 +- [x] 脱离 Sealos 上下文(例如直接访问 URL)时给出"无可用凭证"的明确错误,不走半成品流程。 + +**明确不做** + +- 多连接管理、连接导入导出、连接测试向导。 +- 用户注册、用户组、权限体系(权限由数据库账号本身承担)。 +- 从 DataFlow 手工填连接串新建连接(本产品假设用户从 Sealos 跳转进入)。 + +--- + +### 2.4 功能柱 ② 对象树与 Tab 工作区 + +**用户故事** + +- **US-2.1** 作为工程师,左侧提供一棵可展开的对象树,能看到**库 → Schema → 表 / 视图 / 集合 / Key**的完整层级,点击叶节点直接打开数据视图。 +- **US-2.2** 作为 DBA,对每类节点右键可获得上下文相关的操作菜单。 +- **US-2.3** 作为多任务用户,能同时打开多个表和多个查询编辑器,顶部 Tab 之间自由切换。 + +#### 2.4.1 对象树层级(按数据源类型自适应) + +| 数据源 | 树层级 | +|---|---| +| **Postgres** | `连接 → 数据库 → Schema → Tables 文件夹 → 表` / `Views 文件夹 → 视图` | +| **MySQL / ClickHouse** | `连接 → 数据库 → Tables 文件夹 → 表` / `Views 文件夹 → 视图`(跳过 Schema 层) | +| **MongoDB** | `连接 → 数据库 → 集合` | +| **Redis** | `连接 → 数据库 → Keys 文件夹 → Key` | + +#### 2.4.2 通用行为 + +- [x] **懒加载**:展开父节点时才拉取子节点,大库不会一次性拉完整个元数据。 +- [x] **系统对象隐藏开关**:默认隐藏系统库/Schema/对象;右键"Show System Objects"切换。 + - 对 **Postgres**,该开关挂在**数据库节点**上(系统 Schema 属于某个数据库)。 + - 对 **MySQL / MongoDB / ClickHouse**,该开关挂在**连接节点**上(系统库是顶层)。 + - Redis 不涉及系统对象。 +- [x] **连接节点**展示对应数据库的品牌图标(MySQL / PostgreSQL / MongoDB / Redis 均有原生 SVG;ClickHouse fallback 到通用数据库图标)。 +- [x] **节点颜色区分**:不同节点类型用 5 种色盘区分,便于快速视觉定位。 +- [x] **侧边栏宽度**可拖拽调整,范围 180–480px,默认 256px。 + +#### 2.4.3 右键菜单(按节点类型 × 数据源) + +| 菜单对象 | Postgres | MySQL | ClickHouse | MongoDB | Redis | +|---|---|---|---|---|---| +| 连接 | New Query / + New Database / Refresh | New Query / + New Database / Refresh | New Query / + New Database / Refresh | New Query / + New Database / Refresh | New Query / Refresh | +| 数据库 | New Query / + New Table / Export Database / Rename Database / Delete / Refresh | New Query / + New Table / Export Database / Delete / Refresh | New Query / + New Table / Export Database / Delete / Refresh | New Query / + New Collection / Delete / Refresh | New Query / Refresh | +| Schema | New Query / New Table / Refresh | — | — | — | — | +| 表文件夹 | New Table / Refresh | 同左 | 同左 | — | — | +| 视图文件夹 | Refresh | 同左 | 同左 | — | — | +| 表 | Export Data / Duplicate / Design / Rename / Clear / Delete / Refresh | 同左 | Export Data / Clear / Delete / Refresh **(无 Design / Rename / Duplicate)** | — | — | +| 视图 | Export Data / Refresh | 同左 | 同左 | — | — | +| 集合 | — | — | — | Export Collection / Drop Collection / Refresh | — | +| Keys 文件夹 | — | — | — | — | New Key / Refresh | +| Key | — | — | — | — | Export Key / Delete Key / Refresh | + +> **ClickHouse 表能力的显著差异**:没有"设计表"、"重命名表"、"复制表"三个入口——因为底层方言的 `ALTER` 语法与 InnoDB/Postgres 差异较大,不在结构化表单的覆盖范围内。ClickHouse 的这些操作用户需要回到查询编辑器手写 DDL。 +> +> **系统对象开关**:在存在系统 Schema/库的场景下,右键菜单末尾还会追加 `Show / Hide System Objects` 项——MySQL / MongoDB / ClickHouse 挂在**连接**节点,Postgres 挂在**数据库**节点(参见 2.4.2)。 + +#### 2.4.4 Tab 工作区 + +- [x] **四种 Tab 类型**:`查询编辑器 / SQL 表 / Mongo 集合 / Redis Key 详情`。 +- [x] **Tab 复用规则**:同一张表/集合/Key 重复点击会复用已有 Tab;**查询编辑器每次都是新 Tab**(分析师经常并行多个草稿)。 +- [x] **Tab 右键菜单**:关闭 / 关闭其他 / 关闭全部。 +- [x] **写操作触发全树刷新**:写入(DDL/DML)成功后自动刷新对象树的相关节点。 +- [x] **活动栏切换**:左侧 `Connections` 与 `Analysis` 两个顶层模式,状态独立保留。 + +**明确不做**:工作区多 pane 分屏、Tab 拖拽重排序、收藏/固定节点。 + +--- + +### 2.5 功能柱 ③(共通)数据浏览与编辑的通用能力 + +以下能力在 SQL 表、MongoDB 集合、Redis Key 三种数据视图中**结构相同、交互一致**: + +- [x] **分页**:页码跳转、每页行数(默认 50)切换、总数显示、首页/上一页/下一页/末页。 +- [x] **行内查找(FindBar)**:`Cmd/Ctrl+F` 打开的全页搜索栏,基于当前页可见数据做**客户端匹配**;支持回车跳到下一个命中、Shift+回车跳到上一个、Esc 关闭、实时高亮并自动滚动到当前命中。 +- [x] **导出**:顶部导出按钮,支持 CSV / JSON / SQL / Excel 四种格式;导出过程有进度提示,部分失败会列出失败条目。 +- [x] **刷新按钮**:手动强制重新拉取。 +- [x] **查询跳转**:顶部"Query"按钮一键打开预填了当前对象访问语句的查询编辑器(SQL 预填 `SELECT * FROM ...`;Mongo 预填 `db.collection.find({})`;Redis 预填空白编辑器,保留当前数据库上下文)。 +- [x] **出图入口**:顶部"图表"按钮一键把当前视图的数据喂给图表创建对话框(见 2.12)。 +- [x] **加载/错误态**:统一的 loading spinner;错误态附带 Retry 按钮;网络异常不会把已有数据清空。 + +> 这些是"骨架共通能力";**每种数据源独有的编辑语义**分别见 2.6 / 2.7 / 2.8。 + +--- + +### 2.6 功能柱 ③a SQL 数据视图(Postgres / MySQL / ClickHouse) + +**数据网格** + +- [x] 真实表格布局(行号列 + 可隐藏数据列 + 固定表头)。 +- [x] **列头**呈现:列名 + `PK` 徽章 + `FK` 徽章 + 简化后的类型(例如 `character varying` → `char`、`timestamp without time zone` → `timestamp`)+ 排序指示 + 列操作下拉菜单 + 列宽拖拽手柄。 +- [x] **排序**:任意列升/降序切换;支持"清除排序"。同时只排一列。 +- [x] **列可见性**:筛选弹窗里有"可见列"勾选组;隐藏后在空列头显示"{N} 列已隐藏"。 +- [x] **列宽拖拽**:拖动列头右侧手柄即可调整;已调整过的列会固定宽度,未调整的列自适应。 + +**筛选(服务端 WHERE)** + +- [x] 筛选弹窗里可添加任意多条字段级条件,以胶囊形式展示在筛选栏,每条可单独删除。 +- [x] **支持运算符**:`= / != / <> / > / < / >= / <= / LIKE / NOT LIKE / IN / NOT IN / IS / IS NOT / IS NULL / IS NOT NULL`(最后两个不需要填值)。 +- [x] 筛选条件与排序组合时都推送到后端执行,不是前端过滤。 + +**编辑(Changeset 管线)** + +- [x] **未提交修改集**:所有 insert / update / delete 先收集在本地;UI 以颜色差异(蓝:新增、绿:更新、红:删除)标记未提交变更。 +- [x] **主键保护**:主键列只读(无论是否有 FK 约束),但**新增行可编辑所有列**。 +- [x] **Undo 栈**:`Cmd/Ctrl+Z` 逐步撤销本地修改;Undo 按钮禁用状态同步。 +- [x] **批量标记删除**:多选行后点击"Mark selected for delete"整批转为删除状态。 +- [x] **预览对话框**(Preview Pending Changes):弹出展示将要执行的 SQL 语句清单 + "{updates} 行修改 / {inserts} 行插入 / {deletes} 行删除"摘要。**SQL 仅用于显示,不可手动编辑**。 +- [x] **提交对话框**(Submit Changes):含二次确认的摘要;提交后若部分失败,成功的变更从 pending 集中移除、失败的保留,继续显示错误详情。 +- [x] **只读模式**:后端返回 `disableUpdate` 标记时(视图或不可写存储),隐藏所有编辑按钮。 + +**安全保护** + +- [x] **丢弃确认**:有未提交修改时切换页码、排序、筛选、列可见性、刷新等任何会重新拉数据的动作,会弹出"Discard pending changes?"对话框,用户必须显式确认或取消。 +- [x] **关闭拦截**:有未提交修改时关闭浏览器标签/刷新页面会触发 `beforeunload` 浏览器原生确认。 +- [x] **删除行二次确认**:单行删除需要**键入表名字面量**才能启用删除按钮。 +- [x] **表切换重置**:切到另一张表时自动重置所有本地状态(筛选、排序、搜索、可见列、未提交修改)——不会串用。 + +--- + +### 2.7 功能柱 ③b MongoDB 集合视图 + +**文档展示** + +- [x] 文档以**卡片列表**形式展示(不是表格),每张卡片是一个 pretty-printed 的 JSON(去掉外层大括号以节省空间)。 +- [x] 卡片支持双击打开完整文档编辑对话框。 +- [x] 改动态以卡片背景色区分:**蓝 = 新增、绿 = 修改、红 = 删除(带删除线)**。 +- [x] 空集合态展示"No documents found"占位文案。 + +**文档级操作** + +- [x] **Add Document** 模态:内嵌 JSON 编辑器,空文档(无字段)被拒绝;新增文档显示在列表顶部的"pending inserts"区。 +- [x] **Edit Document** 模态:展示原始 JSON 并允许整体替换。 +- [x] **批量删除**:勾选多个卡片后一次标记删除。 +- [x] **Undo**:逐步回退最近一次 changeset 修改。 + +**Changeset 管线与安全保护** + +- [x] 与 SQL 表结构一致的 **未提交修改集 + 预览 + 提交 + 部分失败恢复** 流水线。 +- [x] 预览对话框用 **mongo shell 命令**形式展示(`db.collection.updateOne({...}, {...})` / `insertOne(...)` / `deleteOne(...)`),方便 DBA 复制到 mongosh 复核。 +- [x] **丢弃确认**:有未提交修改时切换分页、筛选、搜索词会弹出"Discard pending changes?"。 +- [x] **关闭拦截**:`beforeunload` 浏览器原生确认。 + +**筛选(服务端)** + +- [x] **字段发现**:从当前页最多 50 个文档里提取所有顶层字段名,作为筛选字段候选。 +- [x] **每个字段只能出现一次**(防止同字段冲突条件);新增条件时自动排除已使用的字段。 +- [x] **支持运算符(8 种)**:`$eq` / `$ne` / `$regex`(显示为"Contains")/ `$gt` / `$lt` / `$gte` / `$lte` / `$in`(逗号分隔)。 +- [x] **不支持嵌套/分组条件**:如果外部传入了嵌套条件会给出 "Only flat atomic filters are supported" 的提示并忽略嵌套部分。 + +**导出** + +- [x] 独立的 `Export Collection` 对话框:支持 CSV / JSON / SQL / Excel 格式、可选 MongoDB 筛选表达式(例 `{ "status": "active" }`)、可选行数上限。 + +--- + +### 2.8 功能柱 ③c Redis Key 视图 + +**类型自动识别与专用渲染** + +- [x] 打开一个 Key 时根据返回列自动识别类型: + - 含 `field` → **Hash** + - 含 `member` → **Sorted Set** + - 含 `index` + 可更新 → **List** + - 含 `index` + 只读 → **Set** + - 其他 → **String** +- [x] 渲染为同一张数据表格,列组合随类型变化(例如 Hash 展示 `field / value`、ZSet 展示 `member / score`、List 展示 `index / value`)。 + +**行内编辑** + +- [x] **点击单元格进入编辑态**:Enter 提交、Esc 取消、Tab 提交并跳到下一列。 +- [x] **哪些列可编辑**: + - `index`(List/Set)不可编辑。 + - `field`(Hash)和 `member`(ZSet)不可直接改名(属于 Key 标识部分)。 + - 其他列均可直接修改。 +- [x] **ZSet score 特殊处理**:修改 score 时直接调用 ZADD 更新该 member 的分值。 +- [x] **错误处理**:提交失败时顶部红色横幅展示错误,数据保留。 + +**新增与删除** + +- [x] **Add Entry**:在顶部加一行可填字段的空行,支持 Enter 提交 / Esc 取消;按类型预填对应字段(例如 Hash 需填 `field` + `value`,ZSet 需填 `member` + `score` 默认 `0`)。 +- [x] **String 类型不可新增 entry**(整个 Key 就是一个值),只能在 Key 层面编辑。 +- [x] **批量删除**:多选行后点击删除,**List 类型按 index 从大到小逐条删除**(避免 index 漂移)。 +- [x] **删除前确认对话框**:显示"删除 {N} 条"的二次确认。 + +**排序** + +- [x] 任意列升/降序切换;同时只排一列。 + +**创建 Key(New Key 模态)** + +- [x] 5 种类型**全部可新建**,每种类型有专用表单子编辑器: + - **String**:单行值输入。 + - **Hash**:可增减的 `field / value` 表。 + - **List**:可增减的值列表。 + - **Set**:可增减的 member 列表。 + - **ZSet**:可增减的 `score / member` 表(score 数字输入)。 +- [x] Key 名称必填;至少填一个值才能提交。 + +**编辑 Key(Edit Key 模态)** + +- [x] **只支持 String 类型**的整体替换编辑;其他类型要求用户回到**行内编辑**(详情视图)完成。 +- [x] 进入 Edit Key 模态时 Key 名称与类型只读。 + +**导出 / 删除 Key** + +- [x] 独立的 `Export Key` 对话框:CSV / JSON / SQL / Excel;可选行数上限。 +- [x] `Delete Key` 对话框:**键入 Key 名字面量**才能启用删除按钮。 + +**Redis 视图没有的能力**(和 SQL/MongoDB 的差异) + +- [x] **没有筛选(FilterBar)**:Redis Key 详情视图只有 FindBar 客户端搜索,没有服务端字段级筛选。 +- [x] **没有 changeset / 未提交修改集**:Redis 的编辑是**即时提交**(inline commit),无预览对话框、无 Undo 栈、无 `beforeunload` 拦截。 +- [x] **没有列可见性控制**。 + +--- + +### 2.9 功能柱 ④ 结构变更(DDL) + +**用户故事** + +- **US-4.1** 作为 DBA,我希望通过**表单**而非手写 SQL 就能完成常见的库/表/列/索引/外键建改。 +- **US-4.2** 作为 DBA,在执行有风险的结构变更之前我希望能看到将要执行的语句。 + +**数据库级操作(按数据源)** + +| 操作 | Postgres | MySQL | ClickHouse | MongoDB | Redis | +|---|---|---|---|---|---| +| 创建数据库 | ✅ `CREATE DATABASE` | ✅ | ✅ | ⚠️ 需先创建第一个集合 | ❌ | +| 重命名数据库 | ✅ `ALTER DATABASE RENAME` | ❌ | ❌ | ❌ | ❌ | +| 删除数据库 | ✅ `DROP DATABASE` | ✅ | ✅ | ✅ `dropDatabase()` | ❌ | +| 整库导出(ZIP) | ✅(按 schema 分子目录) | ✅ | ✅ | ❌ | ❌ | + +> 不支持的操作(如 MongoDB 重命名库、ClickHouse 重命名库 via UI)**都会返回一条明确的错误信息**,而不是静默失败或按钮消失。 + +**表/集合/Key 级别的结构化表单** + +**SQL 数据源的"建表"表单**(Create Table Modal): + +- [x] 字段:表名 + 列列表。 +- [x] 每列:`name` + `type`(从预设类型表选,含 INT / BIGINT / VARCHAR(...) / TEXT / DECIMAL / DATE / DATETIME / TIMESTAMP / JSON / BLOB 等 22+ 个常用类型)+ `PK` 勾选 + `Null` 勾选。 +- [x] 默认预填一行 `id INT PRIMARY KEY NOT NULL`。 +- [x] 可任意增删列。 + +**SQL 数据源的"改表"表单**(Edit Table Modal,MySQL / Postgres): + +- [x] 三个 Tab:**Fields / Indexes / Foreign Keys**。 +- [x] **Fields Tab**:列出所有现有列,可新增、修改类型/可空性、标记删除(hover 出现删除按钮)。 +- [x] **Indexes Tab**:新增/修改/删除索引,字段用 MultiSelect 选列、可勾选 Unique。 +- [x] **Foreign Keys Tab**:新增/修改/删除外键,含 `name / column / ref table / ref column / ON DELETE / ON UPDATE`。 +- [x] 表单校验:列名必填、索引名必填、索引至少选一列、外键所有字段必填。 +- [x] 提交后显示"X 变更成功、Y 变更失败"的分项结果。 +- [x] **ClickHouse 不支持 Edit Table**(只能新建,不能改)。 + +**清空表**(Clear Data,SQL 专有): + +- [x] 两种模式二选一: + - **Fast (TRUNCATE)**:重置自增计数、跳过行级触发器、大表最快。 + - **Safe (DELETE)**:保留自增计数、触发行级触发器、大表较慢。 + - ClickHouse 的 `DELETE` 模式实际翻译为 `ALTER TABLE ... DELETE WHERE 1=1`(ClickHouse 特有语法)。 + +**复制表**(Duplicate Table,仅 MySQL / Postgres): + +- [x] 可选 **Structure Only** 或 **Structure and Data** 两种模式。 +- [x] 方言自动适配:MySQL 用 `CREATE TABLE x LIKE y`;Postgres 用 `CREATE TABLE x (LIKE y INCLUDING ALL)`。 + +**重命名表**(Rename Table,仅 MySQL / Postgres): + +- [x] 简单表单:当前名(只读)+ 新名。 + +**删除表 / 删除 Key / Drop Collection**: + +- [x] 所有破坏性操作都要求**用户键入对象名字面量**才能启用删除按钮(防手滑)。 +- [x] 弹窗展示"此操作不可撤销"警告文案。 + +**MongoDB 集合操作**: + +- [x] `Create Collection`:表单填集合名。 +- [x] `Drop Collection`:键入名字面量确认。 + +**Redis Key 操作**: + +- [x] 详见 2.8 功能柱 ③c 的 "Create Key" 与 "Delete Key" 段落。 + +**明确不做** + +- ER 图 / 可视化关系图。 +- DDL 变更历史 / 版本对比。 +- 数据库备份与恢复。 +- ClickHouse 的 Edit Table(当前走手写 SQL)。 + +--- + +### 2.10 功能柱 ⑤(共通)查询编辑器的通用能力 + +> 编辑器根据当前连接类型**自动切换语言模式**,但顶层交互是一套。 + +- [x] **Monaco 代码编辑器内核**:行号、语法高亮、括号匹配、多光标、查找替换均由 Monaco 提供。 +- [x] **数据库 / Schema 选择器**:编辑器右上角。Postgres 显示 Database + Schema 两个选择器(默认 `public`);其他数据源只显示 Database。切换数据库后 Schema 选择重置。 +- [x] **运行快捷键**:`Ctrl+Enter`(macOS `⌘↩`)。 +- [x] **多语句批量执行**:按数据源选对应的拆分器(见下三节)。 +- [x] **分条结果卡片**:每条语句独立一张卡片,含成功/失败图标、结果行数、对应 SQL 原文(多语句时显示)、单条耗时。 +- [x] **汇总统计**:整体执行时间 + "X 条成功 / Y 条失败"。 +- [x] **结果/消息 Tab 切换**:上半区是原始结果表,下半区"Message" Tab 列所有执行信息;失败时自动切到 Message Tab。 +- [x] **结果区可拖拽**:上下分割条,高度 `40px ~ 80%` 可调。 +- [x] **写操作触发全树刷新**:写入成功时自动刷新对象树。 +- [x] **读操作结果回传图表**:最后一条成功的读查询会作为数据源喂给图表创建流程。 + +--- + +### 2.11 功能柱 ⑤a SQL 编辑器 + +**语言与格式化** + +- [x] **SQL 语法模式**:Monaco 内置 SQL 高亮。 +- [x] **一键格式化**:`Shift+Alt+F` 调用 `sql-formatter`;格式化失败时**保留原文**,不破坏用户输入。 + +**智能补全(基于 schema 元数据)** + +- [x] 编辑器挂载后自动拉取当前 schema 的所有表名 + 列名批量元数据,注册 Monaco 补全提供者。 +- [x] **上下文感知**: + - `FROM / JOIN / INTO / UPDATE / TABLE / INNER / LEFT / RIGHT / OUTER / FULL / CROSS` 后 → 提示**表名**。 + - `SELECT / WHERE / SET / ON / AND / OR / GROUP / ORDER / HAVING / BY` 后 → 提示**列名**。 + - 其他位置 → 提示 SQL 关键字(80+ 个常用关键字,涵盖 DML/DDL/函数/类型)。 +- [x] **点号补全**:输入 `tableName.` 时只提示该表的列。 +- [x] **schema 限定**:`"schema"."table".` 形式也能识别。 +- [x] **PK / FK 标识**:补全项展示列的主键/外键属性。 + +**多语句拆分** + +- [x] 按分号拆分,但**正确识别并跳过**: + - 单引号字符串(含 `\` 转义) + - 双引号标识符 + - 反引号(MySQL 标识符) + - `--` 单行注释 + - `/* ... */` 块注释 + +**安全拦截:独立事务控制语句** + +- [x] **在批量执行中**,独立出现的以下语句会被拦截并给出提示文案(而不是发到后端): + - `BEGIN / BEGIN WORK / BEGIN TRANSACTION / START TRANSACTION` + - `COMMIT / COMMIT WORK / COMMIT TRANSACTION` + - `END / END WORK / END TRANSACTION` + - `ABORT / ABORT WORK / ABORT TRANSACTION` +- [x] **`ROLLBACK` 各变体不拦截**——因为它对故障恢复有用且不会造成"挂起的事务"副作用。 +- [x] 提示文案解释"事务语句必须作为完整脚本块的一部分,请把整段事务写在一起执行"。 + +--- + +### 2.12 功能柱 ⑤b MongoDB 命令编辑器 + +**语言模式** + +- [x] **JavaScript 语法高亮**(因为 mongo shell 是 JS 子集)。 +- [x] **无内建补全**(不像 SQL 那样挂载 schema 元数据)。 + +**多语句拆分** + +- [x] 按分号拆分,正确处理: + - 单/双/反引号字符串(含 `\` 转义) + - `//` 行注释 + - `/* ... */` 块注释 + - `{} / [] / ()` 嵌套深度(括号内的分号不视为分隔符) + +**支持的语句形态**(严格白名单) + +- [x] 只接受以下形态的 shell 命令: + - `db.collection.method(...)` + - `db.getCollection("name").method(...)` + - `db.method(...)`(如 `db.dropDatabase()`) +- [x] **非命令形态立即拦截**:变量声明、函数定义、循环、纯表达式(`const x = 1`、`for (...)` 等)会在执行前给出明确错误: + - 提示信息:"The MongoDB editor only supports single `db.collection.method(...)` calls. JavaScript constructs such as variables, functions, and loops are not supported — use mongosh for full scripts. Offending statement: {...}" +- [x] **每条语句独立判定**:一个批里有合法和非法语句时,合法的会继续执行、非法的只给单条错误,不影响其他语句。 + +**读操作识别** + +- [x] 识别为"读"的方法:`.find` / `.findOne` / `.countDocuments` / `.aggregate` / `.distinct` / `.getIndexes`。其他 `.insert` / `.update` / `.delete` / `.drop` 等被视为写操作,执行成功后触发对象树刷新。 + +--- + +### 2.13 功能柱 ⑤c Redis 命令编辑器 + +**语言模式** + +- [x] **纯文本**(Monaco 的 `plaintext` 模式,无高亮也无补全)。 + +**多语句拆分** + +- [x] **按换行拆分**:每行一条命令。 +- [x] **注释行**:以 `#` 或 `//` 开头的整行被视为注释并丢弃(避免复制教程时把示例说明误发到 Redis)。 +- [x] **命令值内的 `#` 保留**:Redis 值可合法包含 `#`,不会被误当注释。 + +**安全拦截:不支持的命令** + +Redis 编辑器以"每条命令一个独立连接"的模型工作,因此对"依赖连接状态"的命令在执行前就拦截并中止整批,错误信息解释原因: + +| 被拦截的命令 | 拦截原因 | +|---|---| +| `MULTI` / `EXEC` / `DISCARD` | 事务依赖同一连接的状态 | +| `WATCH` / `UNWATCH` | 乐观锁同样依赖连接状态 | +| `SUBSCRIBE` / `PSUBSCRIBE` / `UNSUBSCRIBE` / `PUNSUBSCRIBE` | Pub/Sub 会阻塞连接 | +| `MONITOR` | 长阻塞命令,阻塞连接 | + +**读操作识别** + +- [x] 识别为"读"的命令前缀(29 个):`GET / MGET / HGET / HGETALL / HMGET / HKEYS / HVALS / HLEN / LRANGE / LLEN / LINDEX / SMEMBERS / SCARD / SISMEMBER / ZRANGE / ZREVRANGE / ZRANGEBYSCORE / ZSCORE / ZCARD / KEYS / SCAN / EXISTS / TYPE / TTL / PTTL / DBSIZE / INFO / STRLEN / PING / ECHO / CONFIG`。 +- [x] 其他命令被视为写操作,成功后触发对象树刷新。 + +--- + +### 2.14 功能柱 ⑥ 仪表盘与图表 + +**用户故事** + +- **US-6.1** 作为分析师,我把一条 SQL 查询结果一键保存成图表(柱 / 线 / 饼 / 面积),并把多张图挂到同一个仪表盘上。 +- **US-6.2** 作为分析师,我拖拽调整图表的位置和大小,布局自动持久化。 +- **US-6.3** 作为团队成员,仪表盘可以按分钟自动刷新或手动点 Refresh。 +- **US-6.4** 作为临时访客,打开仪表盘要**立刻**看到上一次的数据而不是等查询重新跑完。 + +**仪表盘管理** + +- [x] **列表**:左侧 Dashboard Sidebar 按**创建时间倒序**展示所有仪表盘。 +- [x] **创建 / 重命名 / 修改描述 / 删除** 完整 CRUD;名称必填、去空格去大小写后不允许重名。 +- [x] **刷新策略**二选一:**按需刷新**(默认,仅手动点 Refresh) / **每分钟自动刷新**(后台 60s 轮询)。 +- [x] 删除前弹出二次确认对话框。 +- [x] 切换仪表盘时自动刷新一次数据。 +- [x] 仪表盘描述(可选)展示在顶部标题下方的小字区。 + +**图表组件** + +- [x] 当前只支持 **`chart` 一种 widget 类型**。 +- [x] **支持的图表类型**:柱状图 / 折线图 / 饼图 / 面积图(面积图本质是 line + `areaStyle`)。 +- [x] **配置项**: + - **X 轴字段**(单选) + - **Y 轴字段**(多选;饼图只取第一个) + - **图例 / 网格线 / 数据标签** 三个独立开关 + - **排序**:按**数据原顺序** / 按 **X 轴值** / 按 **Y 轴值**,均可升/降序 +- [x] 饼图自动渲染为环形(内径 40% 外径 70%)并带圆角分隔样式。 + +**图表创建流程** + +- [x] **入口 A**:仪表盘页面的"+"按钮 → 从零配置(需要先打开"数据配置"子视图选数据源 + 写查询)。 +- [x] **入口 B**:从 SQL 表 / Mongo 集合 / Redis Key 详情视图的"图表"按钮 → 数据(columns/rows)和查询语句已预填。 +- [x] **入口 C**:SQL 编辑器执行成功后,查询结果会作为图表创建流程的数据源备选。 +- [x] 对话框左侧:**数据配置 + 目标仪表盘选择**(目标可以选现有仪表盘,**也可以选"新建仪表盘"直接创建并挂上**);右侧:**实时预览**。 +- [x] 编辑现有图表时复用同一个对话框。 +- [x] 保存时同时持久化 **图表配置 + 数据快照**;保存后若是从入口 B/C 进入还会**自动切换到 Analysis 活动栏并打开目标仪表盘**。 + +**布局与交互** + +- [x] **拖拽 + 缩放**:基于 12 列网格系统,任意图表可拖到任意位置并调整大小,自动吸附到网格。 +- [x] **响应式布局**:5 个断点(lg / md / sm / xs / xxs),窄屏自动调整为少列或单列布局。 +- [x] **布局持久化**:拖拽结束后一次批量写入,不会每次移动都发请求。 +- [x] **图表右键菜单**:Maximize / Export PNG / Settings / Delete(Delete 走二次确认对话框)。 +- [x] **图表标题双击可重命名**:回车确认、Esc 取消。 +- [x] **单图表最大化**:全屏查看,适合向别人演示某张图。 +- [x] **图表导出 PNG**:通过右键菜单,带 2x 清晰度。 + +**快照(Snapshot)机制** + +- [x] 每次刷新后把"**图表配置 + 数据 + 执行时间戳**"缓存到图表上。 +- [x] 下次打开仪表盘时**先显示快照**(秒开),然后在后台静默刷新最新数据。 +- [x] 刷新状态三态:`loading / success / error`;快照仍在时用"stale"标记提示用户"下面的数据正在刷新"。 +- [x] 刷新失败时图表下方展示红色错误文案,快照数据仍显示(不会变成空图)。 +- [x] 仪表盘级刷新对其下所有图表**并发**触发。 + +**无数据态** + +- [x] **空仪表盘**:显示居中占位图 + "Dashboard is Empty" + "Add Chart"按钮。 +- [x] **单图表无数据**:显示"No chart data"占位文案。 +- [x] **未选中任何仪表盘**:显示"No Dashboard Selected"引导文案。 + +**明确不做** + +- 表格 / 文本 / 图片 / 单值卡片 / 过滤器等其他 widget 类型(仅图表)。 +- 仪表盘导出 / 分享链接 / 权限控制。 +- 下钻 / 图表间联动筛选 / 参数化仪表盘。 +- 柱 / 线 / 饼 / 面积之外的图表类型(散点、雷达、地图、热力图、桑基图等)。 +- 自定义刷新频率(只有按需和每分钟两档)。 + +--- + +### 2.15 横向能力 + +- [x] **中英双语**:界面语言支持中文和英文,通过 URL 参数 `?lang=en|zh` 切换或自动匹配浏览器语言;所有面向用户的文案都走国际化管线,不存在硬编码中英文。 +- [x] **响应式布局**:活动栏、对象树、工作区、结果面板均可拖拽调整比例;首次打开有合理默认尺寸。 +- [x] **统一的确认与错误对话框**:所有二次确认、错误提示走同一套视觉语言,降低学习成本。 +- [x] **图表容错**:单个图表内部异常不会炸掉整个仪表盘。 + +--- + +## 3. 产品边界 + +### 3.1 数据源能力矩阵 + +| 能力 | Postgres | MySQL | ClickHouse | MongoDB | Redis | +|---|---|---|---|---|---| +| **登录** | ✅ Sealos 免配置 | ✅ | ✅ | ✅ | ✅ | +| **对象浏览** | ✅ 含 Schema 层 | ✅ | ✅ | ✅ Collection | ✅ Key | +| **数据视图** | ✅ 表格 | ✅ 表格 | ✅ 表格 | ✅ JSON 卡片 | ✅ 类型化表格(5 种) | +| **字段级筛选** | ✅ 15+ 运算符 | ✅ | ✅ | ✅ 8 个 Mongo 运算符 | ❌ | +| **列可见性 / 列宽拖拽** | ✅ | ✅ | ✅ | — | ✅(列宽) | +| **Changeset 编辑(预览 + 提交)** | ✅ | ✅ | ✅ | ✅ | ❌(即时提交) | +| **Undo 栈** | ✅ | ✅ | ✅ | ✅ | ❌ | +| **`beforeunload` 拦截** | ✅ | ✅ | ✅ | ✅ | ❌ | +| **创建数据库** | ✅ | ✅ | ✅ | ⚠️ 需先建集合 | ❌ | +| **重命名数据库** | ✅ | ❌ | ❌ | ❌ | ❌ | +| **删除数据库** | ✅ | ✅ | ✅ | ✅ | ❌ | +| **整库导出 (ZIP)** | ✅ 按 schema 分组 | ✅ | ✅ | ❌ | ❌ | +| **建表 / 建集合 / 建 Key** | ✅ | ✅ | ✅ | ✅(集合) | ✅(5 种类型) | +| **改表(Fields / Indexes / FKs)** | ✅ | ✅ | ❌ | — | — | +| **重命名表** | ✅ | ✅ | ❌ | — | — | +| **复制表**(结构 / 结构+数据) | ✅ | ✅ | ❌ | — | — | +| **清空表**(TRUNCATE/DELETE 二选一) | ✅ | ✅ | ✅ | — | — | +| **删除表 / Drop Collection / Delete Key** | ✅ | ✅ | ✅ | ✅ | ✅ | +| **查询编辑器** | ✅ SQL + 补全 | ✅ | ✅ | ✅ JS 模式 | ✅ 纯文本 | +| **查询结果出图** | ✅ | ✅ | ✅ | ✅ | ✅ | +| **仪表盘** | ✅ | ✅ | ✅ | ✅ | ✅ | + +### 3.2 交付方式 + +- **嵌入式 Web 应用**:用户通过 Sealos 控制台跳转进入,无需本地安装。 +- **单实例会话**:每次会话绑定一个数据库实例;切换实例需回到 Sealos 控制台重新进入。 +- **浏览器兼容**:面向现代浏览器(Chromium / Firefox / Safari 最新版)。 +- **零运维**:产品作为 Sealos 套件的一部分部署,用户侧没有任何运维成本。 + +### 3.3 安全与隐私(用户视角) + +- **凭证在传输中加密**:从 Sealos 控制台跳转时,凭证以加密形式附在 URL 上,不会以明文出现在浏览器历史或日志里。 +- **凭证只保存在用户本地浏览器**:不集中存储;关闭浏览器标签后可主动清除。 +- **写操作强制二次确认**: + - SQL 表 / MongoDB 集合:Changeset 预览 + 提交双对话框。 + - 结构变更:表单提交前展示将要执行的语句。 + - 破坏性操作(Delete Row / Delete Table / Drop Collection / Delete Key / Delete Database):要求**键入对象名字面量**才能启用确认按钮。 +- **危险命令前置拦截**:Redis 订阅/事务、SQL 独立事务控制、MongoDB 非命令语句都在前端执行前拦截。 +- **关闭拦截**:SQL 表和 MongoDB 集合在有未提交修改时触发浏览器原生 `beforeunload` 确认。 +- **权限由数据库账号决定**:DataFlow 不重新设计权限体系;用户能做什么完全取决于 Sealos 下发的数据库账号权限。 + +### 3.4 已知的安全边界(产品侧诚实披露) + +- 浏览器本地存储的凭证为**明文**。任何能在该浏览器执行脚本的攻击者(XSS)都有可能读取;建议不在共享机器上长时间保留会话。 +- 仪表盘绑定的查询**不区分只读/可写**:带写操作的查询会在仪表盘刷新时被反复执行。建议团队通过**数据库账号本身的权限**限制仪表盘作者的可执行范围。 +- 产品本身不内置审计日志。用户操作的可追溯性依赖数据库实例层面的审计能力。 + +--- + +## 4. 当前限制与已知风险 + +> 下列条目描述**今天用户可能感知到的约束**,供产品侧在向客户介绍时使用,不是工程 backlog。 + +### 4.1 性能/规模类 + +| # | 限制 | 具体表现 | +|---|---|---| +| R1 | **行内查找是前端匹配** | `Cmd/Ctrl+F` 只在**当前页可见数据**内匹配,不跨分页、不走后端;因此想"全表搜一个字符串"需要用筛选条件或查询编辑器。 | +| R2 | **大 schema 补全首屏稍慢** | 打开 SQL 编辑器时会一次性拉该 schema 下所有表的列信息用于补全;含数百张表的 schema 首次加载需要几秒(加载后缓存)。 | +| R3 | **仪表盘一次性并发刷新** | 含很多图表的仪表盘在点 Refresh 时会同时触发所有图表的查询,可能短时压满数据源。 | +| R4 | **首屏资源体积较大** | 首屏加载包含完整代码编辑器静态资源,进入"仅查看仪表盘"的用户也会加载。 | +| R5 | **MongoDB 字段发现是采样** | 筛选器候选字段从**当前页最多 50 个文档**里采样,schema-less 数据里不常见的字段可能不在下拉列表里。 | + +### 4.2 功能/体验类 + +| # | 限制 | 具体表现 | +|---|---|---| +| R6 | **单实例假设** | 每次会话只绑定一个数据库实例;不能并行操作两个实例。 | +| R7 | **查询不持久化** | 查询编辑器的文本在 Tab 关闭后丢失,没有查询历史或收藏。 | +| R8 | **Redis 编辑没有预览 / Undo** | Redis Key 的行内编辑是即时提交,没有 changeset 预览、没有 Undo、没有关闭拦截——和 SQL/Mongo 的编辑体验不对称。 | +| R9 | **Redis 详情视图没有筛选** | 只有客户端 FindBar,没有服务端字段级筛选(与 SQL/Mongo 不对称)。 | +| R10 | **ClickHouse 无"改表"能力** | Design / Rename / Duplicate 三个入口在 ClickHouse 上不可用(对应方言 ALTER 语法不在结构化表单覆盖范围内),需要回到查询编辑器手写 DDL。 | +| R11 | **MongoDB 无"重命名库"** | 底层不支持,DataFlow 返回明确错误。 | +| R12 | **MongoDB 无整库导出** | 只能逐集合导出。 | +| R13 | **Redis 不能通过 UI 建数据库** | Redis 的逻辑库(0–15)是固定的,不能新建。 | +| R14 | **Redis Edit Key 模态只支持 String** | 其他类型只能通过详情视图的行内编辑修改。 | +| R15 | **仪表盘 widget 只有图表一种** | 没有表格、文本、图片、单值卡片、过滤器等组件。 | +| R16 | **图表类型仅 4 种** | 柱 / 线 / 饼 / 面积;没有散点、雷达、地图等高级可视化。 | +| R17 | **刷新频率只有两档** | 按需 or 每分钟;没有 5/15/30 分钟或 cron。 | +| R18 | **MongoDB 编辑器无补全** | SQL 编辑器基于 schema 元数据做补全,但 MongoDB 编辑器没有等价能力。 | + +### 4.3 生态类 + +| # | 限制 | 具体表现 | +|---|---|---| +| R19 | **脱离 Sealos 无法新建连接** | 产品当前只走 Sealos 跳转入口,不提供"手动填连接串"的备用登录方式。 | +| R20 | **无数据导入能力(从文件)** | 产品支持导出为 CSV / JSON / SQL / Excel,但没有"上传 CSV 建表并导入"的反向能力。 | +| R21 | **无 AI 辅助** | 产品当前不提供自然语言生成 SQL / 解释 SQL / 数据洞察等 AI 能力。 | + +--- + +## 5. 下阶段候选计划 + +> **性质**:以下为**候选项 (candidates)**,**非承诺清单**。不含时间线、不含版本号、不含资源分配。列出目的是为产品决策会议提供一张"可以讨论的菜单"。 +> **分类依据**:按"落地路径"分为四类——A 类是产品后台已具备能力但 UI 未接入;B–D 类是全新增量。 +> **与 §4 的关系**:标注 `(→ R##)` 的条目对应 §4 中的一条已知限制。 + +### 5.1 A 类 · 激活后台已具备的能力 + +此类候选的共同特征是:**核心数据通路已经存在**,用户侧只欠一套工作流和交互。因此落地成本显著低于 B–D 类,是"投入产出比"最高的一档。 + +| # | 候选 | 用户价值 | 备注 | +|---|---|---|---| +| **A1** | **AI 对话查询** | 自然语言 → SQL;SQL 解释;查询结果一句话洞察 | 覆盖多个 LLM 供应商;对数据分析师与非 DBA 工程师价值最高 | +| **A2** | **文件导入**(CSV / Excel / SQL 脚本) | 补齐"只导出不导入"的对称缺口;支持字段映射预览、追加/覆盖/Upsert 三种模式 | 对应 §4 的 **R20** | +| **A3** | **Mock 数据生成** | 一键为表生成测试数据;能沿外键依赖生成一致数据 | 面向 Dev/Test 场景;可与 A2 联动(生成后导出为种子文件)| +| **A4** | **表关系图** | 以节点-边图形式浏览表之间的外键关系 | 大 schema 下的"探索式浏览"体验补强 | +| **A5** | **查询建议** | 打开一个 schema 时自动推荐常用查询模板 | 降低新用户的冷启动成本 | + +### 5.2 B 类 · 填补用户可见的体验缺口 + +此类候选直接对应 §4 中的已知限制,属于"已知痛点修复"。 + +| # | 候选 | 对应限制 | 说明 | +|---|---|---|---| +| **B1** | 查询历史与收藏 | → R7 | 关 Tab 不丢 SQL;常用查询可 pin | +| **B2** | 更多 widget 类型 | → R15 | 优先级:**表格 widget** > 单值卡片 > 过滤器 > 文本/图片 | +| **B3** | 更多图表类型 | → R16 | 散点 / 雷达 / 热力图 / 地图等 | +| **B4** | 自定义刷新频率 | → R17 | 5 / 15 / 30 分钟粒度;或 cron 表达式 | +| **B5** | Redis 编辑的 changeset 化 | → R8 | 给 Redis Key 详情视图加上和 SQL/Mongo 对称的预览 + 提交 + Undo + 关闭拦截流水线 | +| **B6** | Redis 详情服务端筛选 | → R9 | 给 Redis Key 视图加字段级筛选(类似 Hash 字段、ZSet score 范围等) | +| **B7** | ClickHouse 高级 DDL | → R10 | 补齐复杂 `ALTER TABLE` 语法的结构化表单 | +| **B8** | MongoDB 整库导出 | → R12 | 多集合一次打包 | +| **B9** | MongoDB 编辑器补全 | → R18 | 基于集合采样字段做补全 | +| **B10** | Tab 拖拽重排序 | — | 小改动、高频使用 | + +### 5.3 C 类 · 性能与规模 + +面向"数据量变大后才暴露"的可用性瓶颈。 + +| # | 候选 | 对应限制 | 说明 | +|---|---|---|---| +| **C1** | 行内查找改为服务端全表搜索 | → R1 | 大表(>1 万行)可用性的硬需求 | +| **C2** | Schema 补全元数据按需加载 | → R2 | 大 schema 首屏不再等几秒 | +| **C3** | 仪表盘刷新限流 / 串行化 | → R3 | 含 ≥10 个图表的仪表盘不会瞬时打满数据源 | +| **C4** | 编辑器资源按需加载 | → R4 | 首屏体积下降,非编辑器页面更快可交互 | +| **C5** | MongoDB 筛选字段全库发现 | → R5 | 不再从 50 个文档里采样,而是从完整 schema 元数据拿 | + +### 5.4 D 类 · 生态扩展(战略级方向决策) + +此类候选涉及产品定位变化,落地前需要先做方向性决策——**不是"做或不做"的工程问题,而是"产品是否愿意走这一步"的商业问题**。 + +| # | 候选 | 说明 | +|---|---|---| +| **D1** | **手动连接串登录**(→ R19) | 脱离 Sealos 上下文独立可用;对开源用户友好,但会模糊"Sealos 嵌入式工作台"的产品定位 | +| **D2** | **多实例会话**(→ R6) | 同一标签页并行操作两个以上实例;对 DBA 价值高,但需要重构对象树与 Tab 体系 | +| **D3** | **审计日志** | 记录谁在什么时间对什么对象做了什么写操作;企业客户刚需,也是"安全边界"的关键补强(见 §3.4)| +| **D4** | **仪表盘查询的只读性标记** | 在保存图表时识别查询是否包含 DML/DDL 并强制二次确认;补 §3.4 第二条风险 | +| **D5** | **仪表盘分享与权限** | 仪表盘链接分享、只读访问、看板级权限;配合 D3 形成企业版基础 | + +### 5.5 推荐优先级 Top 5 + +在不定时间线的前提下,如果按"**落地成本 × 用户价值**"综合权衡,建议下阶段优先评估: + +| 排序 | 候选 | 理由 | +|---|---|---| +| ⭐⭐⭐⭐⭐ | **A2 文件导入** | 后端已就绪 + 对称缺口 + 客户高频询问 | +| ⭐⭐⭐⭐⭐ | **A1 AI 对话查询** | 后端已就绪 + 产品差异化最强的一张牌 | +| ⭐⭐⭐⭐ | **B2 表格 widget** | 仪表盘场景中最高频的"不是图表但需要的东西" | +| ⭐⭐⭐⭐ | **B1 查询历史** | 低成本小痛点,每天被用户感知 | +| ⭐⭐⭐ | **B5 Redis 编辑 changeset 化** | 消除三种数据源的编辑体验不对称性,对 Redis 重度用户价值明显 | + +### 5.6 未纳入候选的方向 + +以下方向**明确不在当前考虑范围内**,列出是为了让决策讨论有完整的"我们不做 X"上下文: + +- **多租户 SaaS 化**(把 DataFlow 从 Sealos 内嵌组件改为独立 SaaS 产品)——产品定位冲突。 +- **OLAP 建模层 / 语义层**(dbt-like)——与仪表盘的轻量定位不符,应交给专业 BI 工具。 +- **代码协作**(类似 Google Docs 的多人同时编辑 SQL)——受众过窄。 +- **Notebook 化工作区**(类似 Hex / Mode)——与当前"Tab + 仪表盘"的交互范式冲突,若要走这条路需要产品形态级重构。 + +--- + +## 6. 术语表 + +- **工作区 (Workspace)**:DataFlow 的主内容区,包含对象树、Tab 栏和当前 Tab 的视图。 +- **活动区 (Activity Bar)**:最左侧的图标栏,用于在 Connections(数据库操作)和 Analysis(仪表盘)两种模式间切换。 +- **对象树 (Object Tree)**:左侧用于浏览数据库对象层级的可展开树。 +- **Tab**:工作区顶部的标签,一个 Tab 对应一个查询编辑器、一张表、一个集合或一个 Redis Key。 +- **数据视图 (Data View)**:SQL 表 / MongoDB 集合 / Redis Key 三种展开视图的统称。 +- **Changeset / 未提交修改集**:SQL 表 / MongoDB 集合的本地未提交编辑集合,可在一次预览中统一提交或放弃。 +- **Changeset 预览**:提交未提交修改前展示"将要发生什么"的对话框;本产品的安全核心机制之一。 +- **丢弃确认 (Discard Guard)**:有未提交修改时切换分页/排序/筛选等操作前弹出的确认对话框。 +- **关闭拦截 (beforeunload)**:有未提交修改时关闭标签/刷新浏览器触发的原生确认。 +- **仪表盘 (Dashboard)**:由一到多个图表组成的可视化看板,可被反复打开、自动刷新。 +- **图表 (Widget / Chart)**:仪表盘上的一个可视化单元;当前等同于"由一条查询驱动的图表"。 +- **快照 (Snapshot)**:图表最近一次执行的结果缓存,保证再次打开仪表盘时可以秒级显示。 +- **Stale 标记**:快照仍在但新一轮刷新正在进行时的状态提示。 +- **Find Bar**:基于 `Cmd/Ctrl+F` 的客户端查找栏,匹配当前页可见数据。 +- **Filter Bar / Filter Modal**:基于字段级条件的服务端筛选(SQL / MongoDB 有;Redis 无)。 + +--- + +## 7. 文档维护 + +- **最后同步版本**:`0.1.0` +- **下次同步建议的触发点**:新增数据源类型、新增图表类型、新增 widget 类型、新增登录方式、新增导入能力、新增 AI 能力、数据源能力矩阵(§3.1)中任意单元格变化。 diff --git a/dataflow/dataflow/docs/sealos-encoded-url-bootstrap-chain-execution.md b/dataflow/dataflow/docs/sealos-encoded-url-bootstrap-chain-execution.md new file mode 100644 index 0000000..c334514 --- /dev/null +++ b/dataflow/dataflow/docs/sealos-encoded-url-bootstrap-chain-execution.md @@ -0,0 +1,472 @@ +# Sealos Kubeconfig Bootstrap Execution Plan + +**Date**: 2026-04-15 +**Source**: `dataflow/docs/sealos-encoded-url-bootstrap-chain.md` +**Status**: Draft execution plan +**Scope**: `dataflow/` frontend bootstrap, `core/` auth/session, local dev tooling, staged rollout + +## 1. Review Outcome + +The proposal is directionally correct and should proceed, but the execution plan must absorb the following corrections before implementation starts: + +1. Persist a bootstrap descriptor in addition to the opaque auth session. + The proposed `AuthSession` shape keeps `sessionToken`, display metadata, and expiry, but it drops `resourceName`. Once the frontend removes bootstrap params from the URL, it can no longer satisfy the documented "401 -> bootstrap once again" behavior without a separately stored descriptor. + +2. Treat `BootstrapSealosSession` as a public GraphQL operation. + The current auth middleware allows `Login`, `LoginWithProfile`, and a fixed set of public operations before credentials exist. The new bootstrap mutation must be added to that allowlist or the request will be rejected before the server session can be created. + +3. Expand the frontend scope beyond the file list in the proposal. + The design requires two runtime behaviors that are not covered by the current file-level impact section: + - one-shot rebootstrap on `401 Unauthorized` + - runtime locale sync from Sealos `CHANGE_I18N` + Both require changes in Apollo client wiring and i18n bootstrap flow, not only auth storage. + +4. Include dependency and tooling work in phase scope. + The repo currently has no Kubernetes client dependency in `core/go.mod`, still injects `VITE_WHODB_AES_KEY` during container builds, and still generates local Sealos URLs with encrypted `credential`. Those updates belong in the rollout plan, not in a later cleanup bucket. + +## 2. Execution Assumptions + +This plan uses the following assumptions so implementation stays narrow and avoids speculative fallback logic: + +1. Upstream Sealos startup URLs will provide `resourceName` before the feature flag is enabled in production. +2. Phase 1 will not implement backend inference from `host` or `port` back to a Kubernetes resource name. +3. Manual login remains on the existing inline-credential path in phase 1. +4. `WHODB_SESSION_DSN` reuses `WHODB_METADATA_DSN` when unset; this fallback is accepted for phase 1 rollout. +5. Session TTL stays environment-driven and defaults to `24h`. + +If any of these assumptions becomes false, update the design first rather than adding compatibility code ad hoc. + +## 3. Deliverables + +Implementation is complete only when all of the following exist: + +1. A backend GraphQL mutation that accepts Sealos bootstrap input, resolves DB credentials from Kubernetes, verifies connectivity, and returns an opaque session token. +2. A server-side session store that encrypts `engine.Credentials`, stores only `sha256(token)`, and supports expiry lookup. +3. Frontend bootstrap that uses Sealos session data plus URL metadata, then persists only session summary plus bootstrap descriptor. +4. Request auth that sends `Authorization: Bearer session:` and passes database overrides separately. +5. One-shot 401 recovery in Sealos mode. +6. Updated local dev tooling so the new flow can be exercised without the old AES credential path. + +## 4. File Inventory + +### Backend + +- Modify: `core/go.mod` +- Modify: `core/src/env/env.go` +- Modify: `core/graph/schema.graphqls` +- Modify: `core/graph/schema.resolvers.go` +- Modify: `core/src/auth/auth.go` +- Modify: `core/src/auth/auth_middleware_test.go` +- Modify: `core/src/auth/auth_test.go` +- Create: `core/src/session/models.go` +- Create: `core/src/session/repository.go` +- Create: `core/src/session/crypto.go` +- Create: `core/src/session/service.go` +- Create: `core/src/session/service_test.go` +- Create: `core/src/sealos/types.go` +- Create: `core/src/sealos/resolver.go` +- Create: `core/src/sealos/resolver_test.go` +- Create: `core/graph/sealos_bootstrap_test.go` + +### Frontend + +- Modify: `dataflow/package.json` +- Modify: `dataflow/src/main.tsx` +- Modify: `dataflow/src/config/auth-store.ts` +- Modify: `dataflow/src/config/auth-headers.ts` +- Modify: `dataflow/src/config/graphql-client.ts` +- Modify: `dataflow/src/config/sealos.ts` +- Modify: `dataflow/src/stores/useAuthStore.ts` +- Modify: `dataflow/src/stores/useConnectionStore.ts` +- Modify: `dataflow/src/i18n/I18nProvider.tsx` +- Modify: `dataflow/src/i18n/url-params.ts` +- Modify: `dataflow/src/i18n/locales/en/common.ts` +- Modify: `dataflow/src/i18n/locales/zh/common.ts` +- Create: `dataflow/src/stores/useSealosStore.ts` +- Create: `dataflow/src/graphql/mutations/bootstrap-sealos-session.graphql` +- Modify: `dataflow/src/generated/graphql.tsx` via codegen +- Create: `dataflow/src/test/auth-headers.test.ts` +- Create: `dataflow/src/test/url-params.test.ts` +- Create: `dataflow/src/test/useAuthStore.sealos.test.ts` + +### Tooling and Dev Support + +- Modify: `dev/generate-sealos-url.mjs` +- Modify: `core/Dockerfile` + +## 5. Execution Sequence + +### Step 1. Add backend session primitives + +**Goal:** Create the server-owned auth session layer before touching the frontend flow. + +**Work:** + +1. Add session configuration declarations to `core/src/env/env.go`. + Required values: + - `WHODB_SESSION_DSN` + - `WHODB_SESSION_ENCRYPTION_KEY` + - `WHODB_SESSION_TTL` + - `WHODB_SEALOS_BOOTSTRAP_ENABLED` + +2. Add Kubernetes client dependencies in `core/go.mod`. + Keep all Kubernetes-specific code inside `core/src/sealos/`; do not leak client-specific types into shared auth or plugin code. + +3. Implement `core/src/session/`. + Recommended responsibilities: + - `models.go`: GORM model for `auth_sessions` + - `repository.go`: create/get/revoke/update-last-seen methods + - `crypto.go`: AES-GCM encrypt/decrypt helpers plus token hashing + - `service.go`: open DB, run `AutoMigrate`, create/load/revoke sessions, parse TTL + +4. Reuse the same metadata-store pattern as `core/src/dashboard`, but keep the session package isolated. + Do not mix dashboard and auth session tables in the same package. + +**Exit criteria:** + +1. A session can be created from `engine.Credentials`, loaded by raw token, and rejected when expired or revoked. +2. Unit tests cover encrypt/decrypt roundtrip, hashing, TTL handling, and repository persistence. + +### Step 2. Implement Sealos secret resolution and bootstrap mutation + +**Goal:** Move Sealos bootstrap from frontend credential decryption to backend Kubernetes secret lookup. + +**Work:** + +1. Implement `core/src/sealos/`. + Recommended responsibilities: + - `types.go`: bootstrap input normalization and resolved secret payload + - `resolver.go`: kubeconfig client construction, namespace resolution, Secret lookup, dbType-to-secret-key mapping, host normalization, host/port assertion + + This package should be treated as a `dbprovider` compatibility layer, not as a fresh interpretation of Sealos behavior. + + Required parity with current `dbprovider` behavior: + - resolve namespace as `context.namespace || ns-` + - keep Secret naming as `-conn-credential` + - keep Secret key mapping aligned with `dbprovider` + - normalize Secret host as: + - use as-is when it already contains `.svc` + - otherwise append `..svc` + - ensure host assertion compares against that normalized host value + + When building the Kubernetes client from `kubeconfig`, preserve compatibility with the current Sealos runtime behavior instead of assuming raw kubeconfig loading is sufficient in every deployment environment. + +2. Add new schema types and mutation in `core/graph/schema.graphqls`: + - `SealosBootstrapInput` + - `AuthSessionPayload` + - `BootstrapSealosSession` + +3. Run gqlgen after schema changes: + - `cd core && go generate ./...` + +4. Implement the resolver in `core/graph/schema.resolvers.go`. + Resolver sequence: + - reject if feature flag is off + - validate required input + - resolve Secret from Kubernetes + - build `engine.Credentials` + - verify availability with the existing plugin path + - create server session + - return session summary plus opaque token + +5. Add the new mutation to the public-operation allowlist in `core/src/auth/auth.go`. + +**Exit criteria:** + +1. `BootstrapSealosSession` works without a prior DataFlow auth token. +2. Resolver tests cover success, unsupported `dbType`, missing Secret fields, host mismatch, port mismatch, and database connectivity failure. +3. Compatibility tests prove the WhoDB Sealos resolver produces the same effective namespace, Secret name, normalized host, and port as the current `dbprovider` logic for the same kubeconfig and Secret fixture. + +### Step 3. Extend auth middleware for session tokens + +**Goal:** Support `Bearer session:` while keeping the old inline path intact. + +**Work:** + +1. Update `core/src/auth/auth.go` to branch before base64 decoding: + - if header starts with `Bearer session:`, resolve server session + - otherwise keep the existing base64 JSON logic + +2. Apply database override from `X-WhoDB-Database` only in the new session branch. + The override should mutate the in-memory `engine.Credentials` used for that request, not the stored session row. + +3. Keep `Login` and `LoginWithProfile` behavior unchanged in phase 1. + +4. Add middleware tests for: + - valid session token + - expired token + - revoked token + - missing token + - database override + - compatibility with legacy inline credentials + +**Exit criteria:** + +1. Existing auth tests still pass. +2. New session tokens can access the same plugin-backed operations as legacy inline credentials. + +### Step 4. Replace frontend Sealos auth bootstrap + +**Goal:** The frontend should bootstrap with Sealos session data and stop storing DB username/password. + +**Work:** + +1. Add the Sealos bootstrap mutation file: + - `dataflow/src/graphql/mutations/bootstrap-sealos-session.graphql` + +2. Regenerate frontend GraphQL types: + - `cd dataflow && pnpm run generate` + +3. Replace `AuthCredentials` in `dataflow/src/config/auth-store.ts` with two persisted records: + - `AuthSessionSummary` + - `BootstrapDescriptor` + + Recommended shape: + + ```ts + type AuthSessionSummary = { + sessionToken: string; + type: string; + hostname: string; + port: string; + database: string; + displayName: string; + expiresAt: string; + }; + + type BootstrapDescriptor = { + dbType: string; + resourceName: string; + databaseName: string; + host?: string; + port?: string; + namespace?: string; + fingerprint: string; + }; + ``` + +4. Update `dataflow/src/config/sealos.ts`. + Keep: + - dbType mapping + - default database helpers + - bootstrap param parsing + + Remove: + - AES decrypt helper + - all references to `VITE_WHODB_AES_KEY` + +5. Rewrite `dataflow/src/stores/useAuthStore.ts`. + New responsibilities: + - parse Sealos bootstrap params + - obtain Sealos `kubeconfig` + - call `BootstrapSealosSession` + - persist session summary plus bootstrap descriptor + - remove bootstrap params only after successful bootstrap + - restore existing session summary when no bootstrap params are present + - expose `rebootstrap()` for one-shot retry + +6. Stop storing DB credentials in `sessionStorage`. + `dataflow/src/config/auth-store.ts` should never persist `Username`, `Password`, or `Advanced`. + +**Exit criteria:** + +1. No frontend storage record contains DB credentials. +2. Refresh within the same tab restores the opaque session summary and the bootstrap descriptor. + +### Step 5. Update request auth and 401 recovery + +**Goal:** Carry only the opaque token on normal requests and support one automatic rebootstrap in Sealos mode. + +**Work:** + +1. Update `dataflow/src/config/auth-headers.ts`. + New behavior: + - `Authorization: Bearer session:` + - `X-WhoDB-Database: ` only when a per-request override is present + +2. Update `dataflow/src/config/graphql-client.ts`. + Add a guarded retry path in `errorLink`: + - if a request fails with `401` + - and the app is in Sealos mode + - and the request has not already been retried + - call `useAuthStore.getState().rebootstrap()` + - replay the request once + +3. Keep non-Sealos failures on the existing error path. + +4. Add frontend tests for: + - auth header formatting + - database override header + - one-shot 401 recovery + - no infinite retry loop + +**Exit criteria:** + +1. Normal GraphQL requests no longer send base64-encoded DB credentials in the Authorization header. +2. A single expired server session causes at most one rebootstrap attempt. + +### Step 6. Add Sealos runtime store and i18n synchronization + +**Goal:** Match the design requirement for `getSession()`, `getLanguage()`, and runtime language changes. + +**Work:** + +1. Add the Sealos Desktop SDK dependency in `dataflow/package.json`. + Use the same SDK surface assumed by the design doc: + - `createSealosApp()` + - `sealosApp.getSession()` + - `sealosApp.getLanguage()` + - `CHANGE_I18N` event subscription + +2. Create `dataflow/src/stores/useSealosStore.ts`. + Responsibilities: + - initialize Sealos SDK + - load `getSession()` + - load `getLanguage()` + - expose `loading`, `session`, `language`, `isInSealosDesktop` + - subscribe to `CHANGE_I18N` + +3. Update `dataflow/src/main.tsx`. + Replace eager `useAuthStore.getState().initialize()` with an app bootstrap component that waits for: + - Sealos initialization + - auth initialization + +4. Update `dataflow/src/i18n/I18nProvider.tsx`. + The provider currently receives a fixed locale prop at mount time. Make locale runtime-updatable so Sealos language change events can trigger UI updates without full page reload. + +5. Add or update i18n keys in: + - `dataflow/src/i18n/locales/en/common.ts` + - `dataflow/src/i18n/locales/zh/common.ts` + + Required additions: + - bootstrap failure states + - unsupported dbType + - retry-in-progress state + - rebootstrap failed state + +**Exit criteria:** + +1. Sealos language changes update the UI at runtime. +2. All new user-facing strings are localized through the existing TypeScript locale files. + +### Step 7. Rework connection summary derivation + +**Goal:** Remove frontend assumptions that a live connection object always carries DB credentials. + +**Work:** + +1. Update `dataflow/src/stores/useConnectionStore.ts` to derive the single Sealos connection from `AuthSessionSummary`, not from `AuthCredentials`. +2. Audit the `Connection` interface. + Current repo usage only writes `user` and `password` inside `deriveConnection()`. Remove those fields unless a real UI requirement is discovered during implementation. +3. Keep database switching behavior driven by request context and `X-WhoDB-Database`, not by mutating stored auth state. + +**Exit criteria:** + +1. Sidebar and editor flows still render correctly with token-only auth state. +2. No frontend component requires DB password to render or execute a query. + +### Step 8. Update tooling and rollout support + +**Goal:** Make the new path testable locally and remove the old AES bootstrap path when the flag is ready. + +**Work:** + +1. Update `dev/generate-sealos-url.mjs`. + Phase 1 local generator should emit: + - `dbType` + - `resourceName` + - `host` + - `port` + - `databaseName` + - `lang` + - `theme` + + It should no longer generate encrypted `credential` blobs for the new path. + +2. Update `core/Dockerfile`. + Phase 1: + - stop relying on `WHODB_AES_KEY` for new flow builds + + Phase 3 cleanup: + - remove the remaining `VITE_WHODB_AES_KEY` injection entirely + +3. Add staging environment wiring for: + - `WHODB_SESSION_DSN` + - `WHODB_SESSION_ENCRYPTION_KEY` + - `WHODB_SESSION_TTL` + - `WHODB_SEALOS_BOOTSTRAP_ENABLED` + +4. Keep old manual login routes available until Sealos rollout is stable. + +**Exit criteria:** + +1. Local and staging environments can exercise the new bootstrap path without the old AES URL format. +2. Phase 1 can be feature-flagged on for Sealos only. + +## 6. Rollout Gates + +Move from one phase to the next only when the previous gate is green. + +### Gate A. Backend ready + +1. Session repository and crypto tests pass. +2. Bootstrap resolver works in unit tests. +3. Auth middleware supports both token formats. + +### Gate B. Frontend ready + +1. No DB credentials remain in `sessionStorage`. +2. New bootstrap flow works from a Sealos URL with `resourceName`. +3. One-shot 401 recovery works in tests. + +### Gate C. Staging ready + +1. Feature flag enabled only in staging. +2. Local URL generator and environment setup are updated. +3. End-to-end bootstrap succeeds against a real Sealos-managed database. + +### Gate D. Cleanup ready + +1. Upstream no longer sends `credential`. +2. Sealos consumers have switched to `resourceName` and `databaseName`. +3. `VITE_WHODB_AES_KEY` can be removed from docs, scripts, and Docker build args. + +## 7. Verification Checklist + +Run these checks before calling the migration complete: + +### Frontend + +```bash +cd dataflow +pnpm run generate +pnpm run typecheck +pnpm run build +pnpm run test +pnpm run lint +``` + +### Backend + +```bash +cd core +go generate ./... +go test ./... +go build ./... +``` + +### Manual validation + +1. Start from a Sealos bootstrap URL that contains `resourceName` and does not contain `credential`. +2. Confirm the frontend stores only opaque session data in `sessionStorage`. +3. Confirm later GraphQL requests send `Bearer session:` rather than base64 credentials. +4. Confirm a forced expired session produces exactly one automatic rebootstrap attempt. +5. Confirm changing Sealos language updates the UI without a full page reload. +6. Confirm manual login still works unchanged. + +## 8. Explicit Non-Goals for This Execution Plan + +1. Migrating manual login to server-side sessions. +2. Inferring `resourceName` from legacy `host` or `port`. +3. Storing kubeconfig on the server. +4. Changing database plugin behavior or adding shared `dbType` switches outside Sealos-specific code. diff --git a/dataflow/dataflow/docs/sealos-encoded-url-bootstrap-chain.md b/dataflow/dataflow/docs/sealos-encoded-url-bootstrap-chain.md new file mode 100644 index 0000000..99d723d --- /dev/null +++ b/dataflow/dataflow/docs/sealos-encoded-url-bootstrap-chain.md @@ -0,0 +1,778 @@ +# DataFlow Sealos Kubeconfig Bootstrap and Server Session Design + +**Date**: 2026-04-15 +**Status**: Proposed +**Scope**: `dataflow/` frontend bootstrap, `core/` backend auth/session, Sealos Desktop startup integration +**Supersedes**: the older "encoded credential in URL" bootstrap design + +## 1. Summary + +DataFlow will stop accepting encrypted database credentials from the startup URL. + +The new Sealos flow is: + +1. DataFlow frontend runs inside Sealos Desktop and obtains `session.kubeconfig`. +2. The startup URL carries only database bootstrap metadata such as `dbType`, `resourceName`, `host`, `port`, and `databaseName`. +3. Frontend sends `kubeconfig + bootstrap metadata` to a new backend bootstrap endpoint. +4. Backend uses `kubeconfig` to read the database Secret from Kubernetes, resolves the real database username/password, and verifies the connection. +5. Backend stores the resolved database credentials in a server-side session backed by Postgres. +6. Frontend keeps only an opaque `sessionToken` and uses it for all later requests. + +This keeps DataFlow compatible with the Sealos/Desktop model already used by `dbprovider`, but removes the current requirement for the frontend to persist database username/password and resend them on every GraphQL request. + +## 2. Why This Change Exists + +The current bootstrap chain has two separate security and complexity problems: + +1. Startup URL contains an encrypted `credential` blob that still represents real database credentials. +2. After login, frontend stores plaintext database credentials in memory and `sessionStorage`, then sends them in the `Authorization` header on every request. + +That model creates avoidable exposure and couples the client directly to the database credential lifecycle. + +The new design changes the trust boundary: + +- Frontend may temporarily hold `kubeconfig`, consistent with Sealos `dbprovider`. +- Frontend no longer needs to hold database username/password after bootstrap. +- Backend becomes the owner of the active database session. + +## 3. Design Goals + +### 3.1 Goals + +1. Remove `credential` from Sealos startup URLs. +2. Remove frontend-side persistence of database username/password in Sealos mode. +3. Reuse Sealos Desktop session access via `getSession()`. +4. Keep bootstrap simple enough to reason about and debug locally. +5. Preserve current database plugin behavior after credentials are resolved. +6. Use the existing Postgres-backed metadata infrastructure pattern for session persistence. +7. Allow incremental rollout with compatibility for existing login paths. + +### 3.2 Non-Goals + +1. Replacing all authentication flows in one step. +2. Changing database plugin connection semantics. +3. Introducing a general-purpose OAuth/session framework for the whole product. +4. Storing `kubeconfig` long term on the server. +5. Solving every upstream Sealos authorization policy concern inside DataFlow. + +## 4. Key Assumptions + +This design assumes the following upstream contract: + +1. DataFlow is opened from Sealos Desktop, so frontend can call `sealosApp.getSession()`. +2. `session.kubeconfig` is available in the Sealos session object. +3. The provided `kubeconfig` is scoped enough for the current user or current resource. +4. The startup URL can provide a stable Kubernetes resource identifier, not only `host/port`. + +If upstream cannot provide a stable resource identifier, DataFlow can temporarily infer resource lookup from existing fields, but that should be treated as transitional compatibility, not the final contract. + +## 5. Current State + +Today the Sealos-specific bootstrap path works roughly like this: + +1. URL contains `dbType`, `credential`, `host`, `port`, `dbName`. +2. Frontend decrypts `credential` using `VITE_WHODB_AES_KEY`. +3. Frontend builds `LoginCredentials`. +4. Frontend calls `Login`. +5. Frontend stores resolved database credentials in `sessionStorage`. +6. Every later request sends the full credentials payload in `Authorization`. + +This means the frontend remains the holder of the database secret for the entire session. + +## 6. Target State + +The target flow is: + +1. URL contains only non-secret bootstrap metadata. +2. Frontend gets Sealos `session.kubeconfig`. +3. Frontend calls a new backend bootstrap mutation. +4. Backend resolves the real DB Secret from Kubernetes. +5. Backend verifies database connectivity. +6. Backend writes encrypted credentials into a Postgres-backed session store. +7. Frontend receives only `sessionToken` and metadata needed for UI display. +8. Every later request sends only the opaque `sessionToken`. + +## 7. Architecture Overview + +```mermaid +flowchart TD + A[Sealos Desktop opens DataFlow URL] --> B[Frontend root provider initializes Sealos SDK] + B --> C[Frontend calls getSession and gets kubeconfig] + C --> D[Frontend parses dbType/resourceName/host/port/databaseName] + D --> E[BootstrapSealosSession mutation] + E --> F[Backend builds K8s client from kubeconfig] + F --> G[Backend reads Kubernetes Secret] + G --> H[Backend builds engine.Credentials] + H --> I[Backend verifies DB connectivity] + I --> J[Backend encrypts credentials and stores auth session in Postgres] + J --> K[Backend returns opaque sessionToken] + K --> L[Frontend stores only session token and UI summary] + L --> M[Later GraphQL requests carry Authorization Bearer session:token] + M --> N[AuthMiddleware loads and decrypts credentials from session store] + N --> O[Existing database plugins run unchanged] +``` + +## 8. Startup URL Contract + +### 8.1 Final URL Shape + +The final Sealos startup URL should carry only bootstrap metadata: + +```text +https://dataflow.example.com + ?dbType=postgresql + &resourceName=my-db + &host=my-db-postgresql.ns-demo.svc + &port=5432 + &databaseName=postgres + &lang=zh + &theme=dark +``` + +### 8.2 Parameter Definitions + +| Param | Required | Meaning | +| --- | --- | --- | +| `dbType` | yes | Sealos/KubeBlocks database type | +| `resourceName` | yes | Kubernetes database resource name used to locate Secret | +| `host` | no | Expected DB host, used as an assertion | +| `port` | no | Expected DB port, used as an assertion | +| `databaseName` | no | Logical database name for the client session | +| `lang` | no | Frontend locale | +| `theme` | no | Frontend theme | + +### 8.3 Transitional Compatibility + +For rollout compatibility: + +1. `dbName` may still be accepted as an alias for `databaseName`. +2. If `resourceName` is missing, backend may temporarily infer a resource identifier from legacy inputs. +3. `credential` will be ignored once the new flow is enabled. + +## 9. Frontend Design + +### 9.1 Root Sealos Provider + +Frontend should add a single root Sealos provider/store responsible for: + +1. calling `createSealosApp()` +2. fetching `getSession()` +3. fetching `getLanguage()` +4. exposing `session`, `language`, `loading`, and `isInSealosDesktop` +5. handling `EVENT_NAME.CHANGE_I18N` + +This follows the same root integration pattern already documented in the local Sealos app builder references. + +Frontend auth bootstrap in Sealos mode should wait for this root provider/store to finish its initial session load before deciding whether to call `BootstrapSealosSession`. + +The same root provider/store must also propagate runtime language changes into the frontend i18n layer. Locale cannot be treated as a search-param-only boot value once `EVENT_NAME.CHANGE_I18N` is supported. + +### 9.2 Bootstrap State + +Frontend auth state in Sealos mode should change from database credentials to two persisted records: + +```ts +type AuthSession = { + sessionToken: string; + type: string; + hostname: string; + port: string; + database: string; + displayName: string; + expiresAt: string; +}; + +type BootstrapDescriptor = { + dbType: string; + resourceName: string; + databaseName: string; + host?: string; + port?: string; + namespace?: string; + fingerprint: string; +}; +``` + +`AuthSession` is the opaque authenticated session returned by the backend. + +`BootstrapDescriptor` is not authentication state. It is the minimum non-secret bootstrap input the frontend must retain so it can bootstrap the same database target again after the URL params have been removed. + +Frontend should no longer store: + +1. `Username` +2. `Password` +3. inline `Advanced` credentials that came from bootstrap + +### 9.3 Frontend Bootstrap Sequence + +1. Parse URL search params. +2. If Sealos bootstrap params are present: + 1. load Sealos session + 2. get `session.kubeconfig` + 3. build `BootstrapDescriptor` + 4. call `BootstrapSealosSession` + 5. persist returned `AuthSession` together with `BootstrapDescriptor` + 6. remove bootstrap params from URL +3. If no Sealos bootstrap params are present: + 1. restore existing `AuthSession` + 2. restore existing `BootstrapDescriptor` when present + 3. continue normal rendering + +On `401 Unauthorized` in Sealos mode: + +1. frontend should discard the expired `AuthSession` +2. fetch a fresh Sealos `session.kubeconfig` +3. re-run bootstrap using the stored `BootstrapDescriptor` +4. replace the previous `AuthSession` only if rebootstrap succeeds + +### 9.4 Frontend Request Model + +All authenticated GraphQL requests should use: + +```text +Authorization: Bearer session: +``` + +If a request needs a database override, send it separately: + +```text +X-WhoDB-Database: analytics +``` + +That removes the need to re-encode the entire credentials object for every request. + +### 9.4.1 Frontend Session Retry Model + +The frontend should handle expired or invalid DataFlow database sessions in a shared request layer such as the Apollo client error path. + +Required behavior: + +1. if a Sealos-mode request returns `401 Unauthorized`, frontend should attempt rebootstrap once +2. rebootstrap should use the stored `BootstrapDescriptor` plus a fresh Sealos `session.kubeconfig` +3. if rebootstrap succeeds, frontend should replay the failed request once +4. if rebootstrap fails, frontend should surface an auth/bootstrap error state +5. a single request must never trigger more than one automatic rebootstrap attempt + +This retry behavior belongs in shared request plumbing, not in individual feature components. + +### 9.4.2 Frontend Runtime I18n Model + +Frontend locale resolution in Sealos mode should have two inputs: + +1. initial locale from URL search params or Sealos `getLanguage()` +2. runtime locale updates from `EVENT_NAME.CHANGE_I18N` + +The i18n provider must support changing locale after initial render so the app can respond to Sealos language changes without a full page reload. + +### 9.5 Session Replacement Rules + +Two different sessions exist in this design: + +1. **Sealos Desktop session** + - carries user identity and `kubeconfig` + - usually remains stable while the same Sealos user is active +2. **DataFlow database session** + - represents one resolved database target inside DataFlow + - must be replaced when the bootstrap target changes + +Frontend should compute and persist a bootstrap fingerprint as part of `BootstrapDescriptor`: + +```text +:::: +``` + +Replacement behavior: + +1. If a new bootstrap fingerprint differs from the stored fingerprint in the same tab, frontend must discard the current DataFlow session and run bootstrap again. +2. If only presentation params such as `lang` or `theme` change, frontend must keep the current DataFlow session. +3. If a second tab opens a different database target, that tab should create its own DataFlow session without mutating the first tab. + +This means: + +1. Sealos Desktop session usually stays the same. +2. DataFlow database session is scoped to one bootstrap target and changes when the target changes. + +## 10. Backend Bootstrap Design + +### 10.1 New GraphQL Mutation + +Add a dedicated mutation for Sealos bootstrap instead of overloading `Login`. + +```graphql +input SealosBootstrapInput { + kubeconfig: String! + dbType: String! + resourceName: String! + databaseName: String + host: String + port: String + namespace: String +} + +type AuthSessionPayload { + sessionToken: String! + expiresAt: String! + type: String! + hostname: String! + port: String! + database: String! + displayName: String! +} + +extend type Mutation { + BootstrapSealosSession(input: SealosBootstrapInput!): AuthSessionPayload! +} +``` + +This mutation is public in the same sense that `Login` is currently public: it does not require an existing DataFlow session. It does, however, require a valid Sealos `kubeconfig` in the input. + +Because the current backend auth gate uses an explicit allowlist of unauthenticated GraphQL operation names, implementation must add `BootstrapSealosSession` to that allowlist. Otherwise the request will be rejected before the bootstrap mutation can create a server session. + +### 10.2 Backend Bootstrap Sequence + +```mermaid +sequenceDiagram + participant FE as Frontend + participant GQL as GraphQL Resolver + participant K8S as K8s Secret Resolver + participant DB as DB Plugin + participant S as Session Store + + FE->>GQL: BootstrapSealosSession(kubeconfig, dbType, resourceName, host, port, databaseName) + GQL->>K8S: Build client from kubeconfig + K8S->>K8S: Resolve namespace + K8S->>K8S: Read Secret resourceName + "-conn-credential" + K8S-->>GQL: username/password/host/port + GQL->>GQL: Assert resolved host/port match requested host/port when present + GQL->>DB: IsAvailable(engine.Credentials) + DB-->>GQL: success/failure + GQL->>S: Create encrypted server session + S-->>GQL: sessionToken + expiresAt + GQL-->>FE: AuthSessionPayload +``` + +## 11. Kubernetes Secret Resolution + +The backend should reuse the same Secret conventions already used by Sealos `dbprovider`. + +### 11.1 Secret Naming + +Default Secret name: + +```text +-conn-credential +``` + +### 11.2 Key Mapping + +The key mapping should be equivalent to the logic in Sealos `dbprovider`: + +| dbType | username key | password key | host key | port key | +| --- | --- | --- | --- | --- | +| `postgresql` | `username` | `password` | `host` | `port` | +| `mongodb` | `username` | `password` | `host` | `port` | +| `apecloud-mysql` | `username` | `password` | `host` | `port` | +| `redis` | `username` | `password` | `host` | `port` | +| `clickhouse` | `username` | `password` | `host` | `port` | + +### 11.3 Namespace Resolution + +Namespace resolution order: + +1. explicit `input.namespace` +2. current namespace from the kubeconfig context +3. fallback derived from the kubeconfig user if needed + +This should intentionally match the current Sealos `dbprovider` behavior: + +1. load the user kubeconfig from Sealos session data +2. prefer the namespace in the current kubeconfig context +3. if the kubeconfig context has no namespace, fall back to `ns-` + +### 11.4 dbprovider Compatibility Contract + +Backend Sealos bootstrap should intentionally mirror the current `dbprovider` compatibility behavior for Kubernetes access and Secret parsing. + +Required compatibility points: + +1. kubeconfig loading must follow the same effective cluster resolution model used by `dbprovider` +2. namespace resolution must match `dbprovider` fallback semantics +3. Secret name must remain `-conn-credential` +4. Secret key mapping must remain aligned with `dbprovider` +5. resolved host normalization must match `dbprovider` + +In particular, host normalization should use the same rule as `dbprovider`: + +1. if Secret `host` already contains `.svc`, use it as-is +2. otherwise append `..svc` + +This compatibility requirement exists so the same `resourceName` and same Sealos session produce the same effective Secret lookup and the same normalized `host` value in both `dbprovider` and DataFlow bootstrap. + +### 11.5 Assertion Rules + +If frontend sent `host` or `port`, backend must validate them against the Secret-derived values: + +1. If `host` is present and does not match resolved host, fail bootstrap. +2. If `port` is present and does not match resolved port, fail bootstrap. + +Host assertion must compare against the `dbprovider`-compatible normalized host value described above, not against an independently reformatted hostname. + +This prevents accidental or inconsistent bootstrap state. + +## 12. Server Session Store + +### 12.1 Why a Server Session Exists + +The server session is the key architectural change. + +Without it, backend would still need to send database username/password back to the client or require `kc` on every later request. + +The session store lets backend own the resolved DB credentials after bootstrap. + +### 12.2 Storage Backend + +Use the existing Postgres metadata-store pattern: + +1. `WHODB_SESSION_DSN` if set +2. otherwise fallback to `WHODB_METADATA_DSN` + +Implementation should follow the same `gorm + AutoMigrate + env DSN` pattern already used by `core/src/dashboard`. + +This fallback is accepted for rollout. In phase 1, sharing the same Postgres instance between dashboard metadata and auth sessions is an acceptable default. Deployments that need stricter isolation may override it by setting `WHODB_SESSION_DSN` explicitly. + +### 12.3 Table Shape + +Recommended schema: + +```sql +create table auth_sessions ( + id uuid primary key, + token_hash text not null unique, + source text not null, + sealos_user_id text, + k8s_username text, + nsid text, + namespace text not null, + resource_name text, + db_type text not null, + host text not null, + port text not null, + database_name text not null, + credentials_nonce bytea not null, + credentials_ciphertext bytea not null, + expires_at timestamptz not null, + created_at timestamptz not null default now(), + last_seen_at timestamptz not null default now(), + revoked_at timestamptz +); +``` + +### 12.4 Storage Rules + +1. Never store plaintext `sessionToken`. +2. Store `sha256(token)` as `token_hash`. +3. Encrypt the serialized `engine.Credentials` blob before storage. +4. Never store `kubeconfig`. +5. Support TTL-based cleanup. + +### 12.5 Token Format + +Raw client token: + +```text + +``` + +Request header: + +```text +Authorization: Bearer session: +``` + +The `session:` prefix makes the auth middleware route explicit and keeps rollout simpler while old inline-credentials auth still exists. + +### 12.6 Session Scope + +Each DataFlow server session is scoped to exactly one bootstrap target. + +Implications: + +1. a session created for database A must not silently be reused for database B +2. a same-tab bootstrap to a different target must replace the previous DataFlow session +3. backend may let the old session expire naturally, or may revoke it immediately after a successful replacement bootstrap + +## 13. Session Encryption + +Use AES-GCM with a dedicated server-side key: + +- env var: `WHODB_SESSION_ENCRYPTION_KEY` +- key stored only on the backend + +Encrypted payload should be the JSON form of: + +```json +{ + "Type": "Postgres", + "Hostname": "db.ns.svc", + "Username": "postgres", + "Password": "secret", + "Database": "postgres", + "Advanced": [ + { "Key": "Port", "Value": "5432" } + ] +} +``` + +## 14. Auth Middleware Changes + +The existing `AuthMiddleware` must support two auth modes during rollout: + +1. new server session mode +2. old inline credentials mode + +It must also keep a small unauthenticated allowlist for public GraphQL operations such as `Login`, `LoginWithProfile`, and `BootstrapSealosSession`. + +### 14.1 New Flow + +If `Authorization` starts with `Bearer session:`: + +1. extract opaque token +2. compute `token_hash` +3. load session from Postgres +4. reject if expired or revoked +5. decrypt stored credentials +6. apply `X-WhoDB-Database` override when present +7. inject `engine.Credentials` into request context + +### 14.2 Compatibility Flow + +If `Authorization` does not use `session:`: + +1. keep the current base64 credentials parsing logic +2. keep `Login` compatible for existing clients + +This allows Sealos to migrate first without breaking manual login or legacy consumers. + +## 15. Changes to Existing Login Semantics + +### 15.1 Phase 1 + +Keep `Login(credentials)` as-is for manual and legacy flows. + +Only Sealos bootstrap uses the new mutation and session store. + +### 15.2 Phase 2 + +Optionally move manual login to the same server-session model: + +1. user submits plaintext DB credentials once +2. backend validates connection +3. backend creates session store entry +4. frontend keeps only `sessionToken` + +That phase is desirable, but not required for the Sealos migration. + +## 16. Frontend File-Level Impact + +Primary frontend files affected: + +1. `dataflow/src/main.tsx` + - ensure bootstrap waits on Sealos session initialization or renders a bootstrap loading state + - mount the root Sealos bootstrap flow before auth-dependent rendering +2. `dataflow/src/stores/useAuthStore.ts` + - replace `decryptSealosCredential` path with `BootstrapSealosSession` +3. `dataflow/src/config/auth-store.ts` + - store `AuthSession` and `BootstrapDescriptor` instead of database credentials +4. `dataflow/src/config/auth-headers.ts` + - emit `Bearer session:` +5. `dataflow/src/config/graphql-client.ts` + - perform one-shot `401` rebootstrap and request replay in shared request plumbing +6. `dataflow/src/config/sealos.ts` + - remove AES decrypt logic + - keep dbType mapping and bootstrap param parsing +7. `dataflow/src/i18n/I18nProvider.tsx` + - support locale changes after initial render +8. `dataflow/src/i18n/url-params.ts` + - remove `credential` from bootstrap key handling + - add `resourceName` and `databaseName` +9. `dataflow/src/stores/useConnectionStore.ts` + - derive display-only connection info from session summary +10. `dataflow/src/stores/useSealosStore.ts` or equivalent root Sealos provider/store + - own `getSession()`, `getLanguage()`, and `CHANGE_I18N` subscriptions + +## 17. Backend File-Level Impact + +Primary backend files affected: + +1. `core/graph/schema.graphqls` + - add `SealosBootstrapInput` and `AuthSessionPayload` + - add `BootstrapSealosSession` +2. `core/graph/schema.resolvers.go` + - implement bootstrap resolver +3. `core/src/auth/auth.go` + - support `session:` auth mode + - treat `BootstrapSealosSession` as a public unauthenticated operation in the existing allowlist +4. `core/src/auth/login.go` + - no immediate change required in phase 1 +5. `core/src/session/` + - new package for repository, crypto, models, service +6. `core/src/sealos/` + - new package for kubeconfig parsing, K8s client setup, Secret resolution +7. `core/go.mod` + - add Kubernetes client dependencies required by `core/src/sealos/` + +## 17.1 Tooling and Dev File-Level Impact + +Additional files affected for rollout and local verification: + +1. `dev/generate-sealos-url.mjs` + - stop generating encrypted `credential` for the new Sealos flow + - emit `resourceName` and `databaseName` instead +2. `core/Dockerfile` + - stop making the new bootstrap flow depend on `WHODB_AES_KEY` + - keep any temporary legacy compatibility explicit during rollout +3. deployment or staging environment config + - add `WHODB_SESSION_DSN`, `WHODB_SESSION_ENCRYPTION_KEY`, `WHODB_SESSION_TTL`, and `WHODB_SEALOS_BOOTSTRAP_ENABLED` + +## 18. Environment Variables + +Add: + +| Env Var | Purpose | +| --- | --- | +| `WHODB_SESSION_DSN` | Postgres DSN for auth session storage | +| `WHODB_SESSION_ENCRYPTION_KEY` | AES-GCM key for stored DB credentials | +| `WHODB_SESSION_TTL` | Session lifetime (defaults to `24h`) | +| `WHODB_SEALOS_BOOTSTRAP_ENABLED` | Feature flag for the new Sealos bootstrap flow | + +Deprecated after rollout: + +| Env Var | Reason | +| --- | --- | +| `VITE_WHODB_AES_KEY` | no longer needed when URL does not carry encrypted DB credentials | + +## 19. Error Handling + +### 19.1 Bootstrap Errors + +Return explicit bootstrap failures for: + +1. missing or malformed `kubeconfig` +2. unsupported `dbType` +3. missing `resourceName` +4. Secret not found +5. Secret missing required fields +6. resolved host/port mismatch +7. database connectivity verification failure + +### 19.2 Session Errors + +Return `401 Unauthorized` when: + +1. session token is invalid +2. session token is expired +3. session token is revoked + +Frontend should treat `401` in Sealos mode as: + +1. try bootstrap once again if Sealos session still exists +2. otherwise show an auth/bootstrap error state + +The automatic retry should happen in shared request plumbing and must be limited to one retry per failed request. + +## 20. Security Properties + +This design improves security over the current flow in the following ways: + +1. startup URL no longer contains database credentials, even encrypted +2. frontend no longer stores database username/password for the session lifetime +3. backend no longer trusts frontend to repeatedly provide live DB credentials +4. backend stores only encrypted credentials +5. server never stores `kubeconfig` +6. logs can be hardened around one opaque session identifier instead of a whole credentials payload + +This design does **not** guarantee: + +1. that upstream `kubeconfig` is perfectly scoped +2. that Sealos authorization policy is enforced entirely inside DataFlow + +Those remain shared responsibilities between Sealos and DataFlow. + +## 21. Rollout Plan + +### Phase 0: Prepare + +1. Add Sealos root provider on the frontend. +2. Add server session store package. +3. Add Kubernetes client dependencies and `core/src/sealos/`. +4. Add `BootstrapSealosSession`. +5. Add new auth middleware branch for `session:`. +6. Add staging and local environment wiring for `WHODB_SESSION_*` and `WHODB_SEALOS_BOOTSTRAP_ENABLED`. +7. Update local Sealos bootstrap tooling so the new flow can be exercised without encrypted URL credentials. + +### Phase 1: Sealos Dual Stack + +1. Keep old `Login(credentials)` flow. +2. Enable new Sealos bootstrap by feature flag. +3. Frontend Sealos path uses bootstrap mutation and server sessions. +4. Legacy paths continue using inline credentials. +5. Local and staging environments use the updated URL generator and session env wiring. +6. Container and build wiring must support the new flow without relying on `VITE_WHODB_AES_KEY`. + +### Phase 2: Upstream URL Switch + +1. Stop sending `credential` in startup URLs. +2. Start sending `resourceName` and `databaseName`. +3. Remove frontend AES decrypt path. +4. Keep rollback compatibility limited to the old login flow, not the old Sealos URL format. + +### Phase 3: Cleanup + +1. remove `credential` URL parsing +2. remove `VITE_WHODB_AES_KEY` +3. remove remaining legacy Docker/build wiring for AES URL credentials +4. update all Sealos docs and dev scripts +5. optionally migrate manual login to server sessions too + +## 22. Testing Strategy + +### 22.1 Backend Unit Tests + +1. session token hashing +2. session crypto encrypt/decrypt roundtrip +3. session repository create/get/revoke/expire +4. auth middleware session branch +5. database override via `X-WhoDB-Database` +6. Secret resolution for supported db types +7. bootstrap resolver success/failure cases + +### 22.2 Frontend Unit Tests + +1. bootstrap param parsing +2. Sealos bootstrap mutation call path +3. auth store persistence with `AuthSession` +4. auth header construction with `session:` +5. expired session recovery behavior + +### 22.3 Integration Tests + +1. fake K8s Secret -> bootstrap -> session creation -> GraphQL query +2. invalid Secret -> bootstrap failure +3. expired session -> 401 +4. host/port mismatch -> bootstrap failure + +## 23. Open Items + +These should be finalized before implementation starts: + +1. Whether upstream startup URL can always provide `resourceName`. +2. Whether `host/port` should remain mandatory assertions or become optional diagnostics. + +## 24. Recommendation + +Proceed with this architecture and implement it in two steps: + +1. Sealos bootstrap moves to `kubeconfig -> backend Secret lookup -> Postgres session`. +2. Legacy manual login remains unchanged until the Sealos path is stable. + +That gives DataFlow a clean Sealos integration model, aligns with how Sealos `dbprovider` already acquires `kubeconfig`, and removes the current need for the frontend to retain and resend database plaintext credentials. diff --git a/dataflow/dataflow/docs/sealos-kubeblocks-bootstrap-source-map.md b/dataflow/dataflow/docs/sealos-kubeblocks-bootstrap-source-map.md new file mode 100644 index 0000000..45bdcdf --- /dev/null +++ b/dataflow/dataflow/docs/sealos-kubeblocks-bootstrap-source-map.md @@ -0,0 +1,181 @@ +# Sealos KubeBlocks Bootstrap Source Map + +**Date**: 2026-04-16 +**Purpose**: Record the real Kubernetes Secret and Service sources used for WhoDB Sealos bootstrap per database type. + +## Summary + +KubeBlocks does not expose one uniform bootstrap shape across all database types. + +WhoDB bootstrap must not assume every database uses: + +1. secret name: `-conn-credential` +2. secret fields: `username`, `password`, `host`, `port` + +That assumption works for some database types, but it is incorrect for Redis, ClickHouse, and current user MongoDB clusters on the observed Sealos clusters. + +## PostgreSQL / MySQL + +These types continue to use the simple `conn-credential` pattern: + +1. Secret name: `-conn-credential` +2. Expected fields: + - `username` + - `password` + - `host` + - `port` +3. Host normalization: + - if `host` already contains `.svc`, use it as-is + - otherwise append `..svc` + +## MongoDB + +MongoDB has two observed bootstrap shapes. + +Legacy/system MongoDB resources can use the simple `conn-credential` pattern: + +1. Secret name: `-conn-credential` +2. Expected fields: + - `username` + - `password` + - `host` + - `port` + +Current user MongoDB resources do **not** expose `-conn-credential`. + +For a MongoDB resource `test-db` in namespace `ns-f94dz1z8`, the observed Kubernetes objects are: + +1. Account Secret: + - `test-db-mongodb-account-root` +2. Services: + - `test-db-mongodb` + - `test-db-mongodb-mongodb` + - `test-db-mongodb-mongodb-ro` + +Observed account Secret fields: + +1. `username` +2. `password` + +Observed missing fields: + +1. `host` +2. `port` + +MongoDB bootstrap mapping for WhoDB: + +1. first try legacy `-conn-credential` +2. if that Secret is not found, read username/password from `-mongodb-account-root` +3. host/port: + - resolve from the ClusterIP service for the same resource that exposes port `27017` + - prefer the service labeled `kubeblocks.io/role=primary` +4. normalized host: + - `..svc` +5. database: + - default to `admin` when not explicitly provided + +## Redis + +Redis does **not** use `-conn-credential` for bootstrap auth in the observed Sealos cluster. + +For a Redis resource `test-stacks` in namespace `ns-admin`, the observed Kubernetes objects are: + +1. Cluster name: + - `test-stacks` +2. Account Secret: + - `test-stacks-redis-account-default` +3. Service: + - `test-stacks-redis-redis` + +Observed account Secret fields: + +1. `username` +2. `password` + +Observed missing fields: + +1. `host` +2. `port` + +Redis bootstrap mapping for WhoDB: + +1. username/password: + - read from `-redis-account-default` +2. host/port: + - resolve from the ClusterIP service for the same resource that exposes port `6379` +3. normalized host: + - `..svc` +4. database: + - optional Redis DB index; default empty string unless explicitly provided + +## ClickHouse + +ClickHouse uses `-conn-credential`, but its fields do **not** match the generic SQL pattern. + +For a ClickHouse resource `test-house` in namespace `ns-admin`, the observed Kubernetes objects are: + +1. Secret: + - `test-house-conn-credential` +2. Service: + - `test-house-clickhouse` + +Observed Secret fields: + +1. `username` +2. `admin-password` +3. `endpoint` +4. `tcpEndpoint` +5. `mysqlEndpoint` +6. `pgEndpoint` + +Observed missing generic fields: + +1. `password` +2. `host` +3. `port` + +ClickHouse bootstrap mapping for WhoDB: + +1. username: + - read from `username` +2. password: + - read from `admin-password` +3. host/port: + - parse from `tcpEndpoint` + - fallback to the ClickHouse service and native TCP port `9000` if needed +4. normalized host: + - if the host already contains `.svc`, use it as-is + - otherwise append `..svc` +5. database: + - default to `default` when not explicitly provided +6. username fallback: + - if username is empty, WhoDB should use `default` +7. password: + - may be empty; do not reject bootstrap only because password is empty + +## Implications For WhoDB + +WhoDB Sealos bootstrap should use per-database resolution rules: + +1. PostgreSQL / MySQL: + - generic `conn-credential` parsing +2. MongoDB: + - legacy `conn-credential` parsing when available + - account Secret + primary service lookup when `conn-credential` is absent +3. Redis: + - account Secret + service lookup +4. ClickHouse: + - `conn-credential`, but parse `admin-password` and `tcpEndpoint` + +## Implications For dbprovider + +`dbprovider` should not prefetch database Secrets before opening WhoDB. + +It should only pass bootstrap metadata such as: + +1. `resourceName` +2. `dbType` +3. `theme` +4. `lang` + +WhoDB is responsible for resolving the real Secret and endpoint details from Sealos session kubeconfig. diff --git a/dataflow/dataflow/docs/semantic-test-contract.md b/dataflow/dataflow/docs/semantic-test-contract.md new file mode 100644 index 0000000..1a26c98 --- /dev/null +++ b/dataflow/dataflow/docs/semantic-test-contract.md @@ -0,0 +1,140 @@ +# DataFlow Semantic Test Contract + +## 1. Module Info + +- Module: DataFlow frontend automation semantics +- Page: standalone login, database workspace, SQL editor/table detail, MongoDB collection detail, Redis key detail, analysis dashboard +- Version: initial contract for semantic tag coverage +- Owner: frontend maintainers + +## 2. Page Entries + +| Page | Route | Notes | +| --- | --- | --- | +| Standalone login | SPA root when auth status is unauthenticated | Manual database session creation | +| Database workspace | SPA root after auth, connections activity tab | Sidebar tree, tabs, SQL editor, SQL/Mongo/Redis detail views | +| Analysis dashboard | SPA root after auth, analysis activity tab | Dashboard list, editor canvas, chart widgets | + +## 3. Semantic Tags + +| Element | Code location | data-testid | Type | Business semantics | data-qa-* | Operable | Assertable | Evidence source | Risk | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Auth bootstrap loading | `src/main.tsx` | `auth.bootstrap.loading` | state | Session bootstrap is loading | `module=auth`, `object=session`, `state=loading` | No | Yes | auth store state | Low | +| Auth bootstrap error | `src/main.tsx` | `auth.bootstrap.error` | error | Session bootstrap failed | `module=auth`, `object=session`, `state=error`, `error-code=bootstrap_failed` | No | Yes | auth store state | Medium | +| Standalone login disabled | `src/main.tsx` | `auth.standalone.disabled` | state | Manual login is disabled by runtime config | `module=auth`, `object=standalone-login`, `state=disabled`, `disabled-reason=standalone_login_disabled` | No | Yes | auth store state | Low | +| Standalone login page | `src/components/auth/StandaloneLogin.tsx` | `auth.standalone.page` | panel | Manual database connection entry | `module=auth`, `object=standalone-login`, `state=ready/submitting/error` | No | Yes | auth component state | Medium | +| Standalone login form | `src/components/auth/StandaloneLogin.tsx` | `auth.standalone.form` | action | Creates standalone session | `object=standalone-session`, `action=create`, `state=ready/submitting` | Yes | Yes | form submit handler | Medium | +| Standalone login fields | `src/components/auth/StandaloneLogin.tsx` | `auth.standalone.*-input`, `auth.standalone.database-type-select` | field | Host, port, username, password, database, type | `field=host/port/username/password/database/database_type`, `disabled-reason=submitting` | Yes | Yes | form state | Medium | +| Standalone login submit | `src/components/auth/StandaloneLogin.tsx` | `auth.standalone.submit-button` | action | Submits connection credentials | `action=create`, `state=ready/submitting`, `disabled-reason=submitting` | Yes | Yes | form submit handler | Medium | +| Standalone login error | `src/components/auth/StandaloneLogin.tsx` | `auth.standalone.error` | error | Session creation failed | `state=error`, `error-code=standalone_session_create_failed` | No | Yes | submit catch branch | Medium | +| App shell | `src/components/layout/MainLayout.tsx` | `layout.shell` | panel | Root workspace shell | `module=layout`, `object=app-shell`, `state=connections/analysis` | No | Yes | layout store | Low | +| Activity bar | `src/components/layout/ActivityBar.tsx` | `layout.activity-bar`, `layout.activity.tab` | action | Switches workspace mode | `object=activity-tab`, `action=switch`, `resource-id=connections/analysis`, `state=active/inactive` | Yes | Yes | layout store | Low | +| Sidebar region | `src/components/layout/MainLayout.tsx` | `layout.sidebar-region`, `layout.sidebar-resize-handle` | panel/action | Current sidebar and resize handle | `object=sidebar`, `action=resize`, `state=connections/analysis` | Yes | Yes | layout state | Low | +| Main region | `src/components/layout/MainLayout.tsx` | `layout.main-region` | panel | Current main workspace | `object=main`, `state=connections/analysis` | No | Yes | layout state | Low | +| Database sidebar | `src/components/sidebar/Sidebar.tsx` | `database.sidebar`, `database.sidebar.tree` | panel | Connection object tree | `module=database`, `object=connection-tree`, `state=ready/empty/loading` | No | Yes | connection store | Medium | +| Database tree node | `src/components/sidebar/SidebarTree/SidebarTree.Node.tsx` | `database.sidebar.tree-node` | item | Connection/database/schema/table/view/collection/Redis key node | `object=sidebar-node`, `resource-type`, `resource-id`, `connection-id`, `database`, `schema`, `state=selected/idle expanded/collapsed/leaf loading` | Yes | Yes | tree node data | Medium | +| Database tree toggle | `src/components/sidebar/SidebarTree/SidebarTree.Node.tsx` | `database.sidebar.tree-node-toggle` | action | Expand or collapse tree node | `action=expand/collapse`, `resource-type`, `resource-id`, `state=expanded/collapsed/loading` | Yes | Yes | tree node state | Low | +| Tab bar item | `src/components/layout/TabBar.tsx` | `layout.tab.item` | item/action | Open workspace tab | `object=tab`, `action=activate`, `tab-type`, `resource-id`, `connection-id`, `database`, `schema`, `state=active/inactive dirty` | Yes | Yes | tab store | Medium | +| Tab close/new query | `src/components/layout/TabBar.tsx` | `layout.tab.close-button`, `layout.tab.new-query-button` | action | Closes a tab or creates query tab | `action=close/create-query`, `resource-id=tab id`, `disabled-reason=not_ready` | Yes | Yes | tab store | Medium | +| Tab content | `src/components/layout/TabContent.tsx` | `layout.tab-content.panel`, `layout.tab-content.empty` | panel/state | Active tab render area | `tab-type`, `resource-id`, `connection-id`, `database`, `schema`, `state=active/inactive/empty` | No | Yes | tab store | Medium | +| SQL editor view | `src/components/editor/SQLEditorView.tsx` | `sql.editor.view` | panel | Query editor and execution result workspace | `module=sql`, `object=editor`, `connection-id`, `database`, `schema`, `state=ready/executing/completed/error`, `loading=true/false` | No | Yes | editor state, GraphQL query | High | +| SQL editor actions | `src/components/editor/SQLEditorView.tsx` | `sql.editor.run-button`, `sql.editor.format-button`, `sql.editor.create-chart-button` | action | Run, format, or chart query result | `object=query/query-result`, `action=execute/format/create-chart`, `risk=query_execution`, `disabled-reason=executing/empty_query/not_ready` | Yes | Yes | handlers and state | High | +| SQL execution context | `src/components/editor/SQLEditorView.tsx` | `sql.editor.database-select`, `sql.editor.schema-select`, `sql.editor.*-option` | field/item | Chooses database/schema for execution | `object=execution-context`, `field=database/schema`, `resource-type=database/schema`, `resource-id` | Yes | Yes | database/schema queries | Medium | +| SQL result pane | `src/components/editor/SQLEditorView.tsx` | `sql.editor.result-pane`, `sql.editor.result-set`, `sql.editor.result-table`, `sql.editor.message-item` | state/item/error | Execution output, rows, and messages | `object=query-result/statement-result/result-table/statement-message`, `state=empty/loading/success/error`, `row-count`, `error-code=query_execution_failed` | No | Yes | RawExecute result | High | +| SQL result row/cell | `src/components/editor/SQLEditorView.tsx` | `sql.editor.result-row`, `sql.editor.result-cell` | item | Query result evidence | `object=result-row/result-cell`, `field`, `row-index` | No | Yes | RawExecute result rows | Medium | +| SQL table detail | `src/components/database/sql/TableDetailView.tsx` | `sql.table.detail` | panel | Table data detail | `connection-id`, `database`, `schema`, `resource-type=table`, `resource-id=tableName`, `state=ready/loading/error` | No | Yes | table provider state | High | +| SQL table toolbar | `src/components/database/sql/TableView/TableView.Toolbar.tsx` | `sql.table.toolbar` | panel | Table operation toolbar | `object=table-toolbar`, `resource-id=tableName`, `state=ready/loading/dirty` | No | Yes | changeset state | High | +| SQL table actions | `src/components/database/sql/TableView/TableView.Toolbar.tsx` | `sql.table.*-button` | action | Refresh, row add/delete, undo, preview, submit, export, query, chart | `action=refresh/create/mark-delete/undo/preview/submit/export/open-query/create-chart`, `risk=resource_mutation`, `disabled-reason` | Yes | Yes | table actions | High | +| SQL table grid | `src/components/database/sql/TableView/TableView.DataGrid.tsx` | `sql.table.grid`, `sql.table.grid-scroll`, `sql.table.grid-loading` | state/panel | Table rows and loading state | `object=table-grid`, `state=ready/empty/loading`, `row-count` | No | Yes | table provider rows | High | +| SQL table row/cell | `src/components/database/sql/TableView/TableView.DataGrid.tsx` | `sql.table.row`, `sql.table.row-selector`, `sql.table.cell`, `sql.table.cell-editor` | item/field/action | Editable table rows and cells | `object=table-row/table-cell`, `field`, `resource-id=rowKey`, `state=ready/selected/inserted/deleted/editable/editing/changed/read_only`, `disabled-reason=read_only/primary_key/row_deleted` | Yes | Yes | rendered row state | High | +| Shared data view filter/error | `src/components/database/shared/*` | `data-view.filter-button`, `data-view.error`, `data-view.retry-button` | action/error | Data filter and load failure retry | `module=data-view`, `object=filter/data-load`, `action=open/retry`, `state=active/inactive/error`, `error-code=data_load_failed` | Yes | Yes | shared data view state | Medium | +| MongoDB collection detail | `src/components/database/mongodb/CollectionDetailView.tsx` | `mongodb.collection.detail`, `mongodb.collection.detail-loading` | panel/state | Collection document detail | `connection-id`, `database`, `resource-type=collection`, `resource-id=collectionName`, `state=ready/loading/error` | No | Yes | collection provider state | High | +| MongoDB toolbar actions | `src/components/database/mongodb/CollectionView/CollectionView.Toolbar.tsx` | `mongodb.collection.*-button`, `mongodb.collection.view-toggle-button` | action | Refresh, switch collection view, add/delete document, undo, preview, submit, export, query, chart | `action=refresh/switch-to-table/switch-to-json/create/mark-delete/undo/preview/submit/export/open-query/create-chart`, `state=table/json`, `risk=resource_mutation`, `disabled-reason` | Yes | Yes | collection actions and view mode | High | +| MongoDB document list/card | `src/components/database/mongodb/CollectionView/CollectionView.DocumentList.tsx` | `mongodb.collection.document-list-region`, `mongodb.collection.document-card`, `mongodb.collection.edit-document-button`, `mongodb.collection.document-list-empty` | item/action/state | Documents, edit action, empty list | `object=document/document-list`, `state=ready/selected/insert/update/delete/empty`, `resource-type=document`, `resource-id=rowKey` | Yes | Yes | document changeset state | High | +| Redis key detail | `src/components/database/redis/RedisKeyDetailView.tsx` | `redis.key.detail`, `redis.key.detail-loading` | panel/state | Redis key value detail | `connection-id`, `database`, `resource-type=redis_key`, `resource-id=keyName`, `key-type`, `state=ready/loading/mutating/error` | No | Yes | Redis key rows | High | +| Redis key toolbar actions | `src/components/database/redis/RedisKeyDetailView.tsx` | `redis.key.*-button` | action | Refresh, add/delete row, export, query, chart | `action=refresh/create/delete/export/open-query/create-chart`, `risk=resource_mutation`, `disabled-reason` | Yes | Yes | Redis key handlers | High | +| Redis grid row/cell | `src/components/database/redis/RedisKeyDetailView.tsx` | `redis.key.grid`, `redis.key.row`, `redis.key.row-selector`, `redis.key.cell`, `redis.key.cell-editor`, `redis.key.new-row`, `redis.key.new-row-input`, `redis.key.empty`, `redis.key.error` | item/field/state/error | Redis value rows and inline editing | `object=key-grid/key-row/key-cell`, `field`, `resource-id=keyName:index`, `state=ready/selected/editable/editing/read_only/creating/empty/error`, `error-code=redis_key_operation_failed` | Yes | Yes | Redis row state | High | +| Analysis view | `src/components/analysis/AnalysisView.tsx` | `analysis.view`, `analysis.view.error`, `analysis.dashboard.empty` | panel/state/error | Dashboard workspace loading and empty/error state | `module=analysis`, `object=dashboard-view/dashboard`, `resource-type=dashboard`, `resource-id`, `state=active/empty/error`, `error-code=dashboard_load_failed` | No | Yes | analysis definition store | Medium | +| Dashboard sidebar | `src/components/dashboard-sidebar/DashboardSidebar.tsx` | `analysis.dashboard.sidebar`, `analysis.dashboard.create-button`, `analysis.dashboard.list-item`, `analysis.dashboard.item-menu-button` | panel/action/item | Dashboard list and actions | `object=dashboard`, `action=create/open/open-menu`, `resource-type=dashboard`, `resource-id`, `state=ready/empty/active/inactive` | Yes | Yes | dashboard store | Medium | +| Dashboard editor | `src/components/analysis/editor/DashboardEditor.tsx` | `analysis.dashboard.editor`, `analysis.dashboard.toolbar`, `analysis.dashboard.refresh-button`, `analysis.dashboard.add-widget-button`, `analysis.dashboard.editor-empty`, `analysis.dashboard.empty-add-widget-button` | panel/action/state | Dashboard editor and widget creation | `object=dashboard-editor/dashboard/widget`, `action=refresh/create`, `resource-type=dashboard`, `resource-id`, `state=ready/empty` | Yes | Yes | dashboard store | Medium | +| Dashboard canvas/widget | `src/components/analysis/editor/EditorCanvas.tsx`, `src/components/analysis/editor/DashboardWidget.tsx` | `analysis.dashboard.canvas`, `analysis.dashboard.widget-layout`, `analysis.dashboard.widget`, `analysis.dashboard.widget-title`, `analysis.dashboard.widget-title-input`, `analysis.dashboard.widget-menu-button`, `analysis.dashboard.widget-error` | item/action/field/error | Widget layout, title editing, menu, runtime error | `object=widget/widget-layout`, `field=title`, `action=open-menu`, `resource-type=widget`, `resource-id`, `state=idle/loading/success/error/editing`, `error-code=widget_query_failed` | Yes | Yes | widget definition/runtime stores | Medium | + +## 4. State Enums + +| Element | `data-qa-state` values | Notes | +| --- | --- | --- | +| Auth/session | `loading`, `error`, `disabled`, `ready`, `submitting` | Authentication and standalone session lifecycle | +| Layout/activity/tab | `connections`, `analysis`, `active`, `inactive`, `dirty`, `empty` | Shell navigation state | +| Sidebar node | `selected`, `idle`, `expanded`, `collapsed`, `leaf`, `loading` | Combined as a space-separated state string when multiple apply | +| SQL editor/result | `ready`, `executing`, `completed`, `loading`, `success`, `error`, `empty` | Query execution and result display | +| SQL/Mongo/Redis mutation surfaces | `ready`, `loading`, `dirty`, `mutating`, `inserted`, `deleted`, `changed`, `selected`, `editable`, `editing`, `read_only`, `creating`, `table`, `json` | Table/document/key editing and MongoDB collection view mode | +| Analysis dashboard/widget | `ready`, `empty`, `active`, `inactive`, `editable`, `read_only`, `idle`, `loading`, `success`, `error`, `editing` | Dashboard and widget runtime | + +## 5. Disabled Reason Enums + +| disabled reason | Meaning | Automation expectation | +| --- | --- | --- | +| `submitting` | Login submission in progress | Wait for form state to settle | +| `loading` | Data is loading | Wait before clicking refresh-dependent action | +| `executing` | Query is running | Wait for result pane | +| `empty_query` | Format action has no query text | Fill editor before format | +| `not_ready` | Required result/context is unavailable | Produce prerequisite state first | +| `no_databases` | Database selector has no options | Verify database metadata loading | +| `database_required` | Schema selector requires selected database | Select database first | +| `no_schemas` | Schema selector has no options | Verify schema metadata | +| `no_selection` | Row/document delete needs selection | Select rows/documents first | +| `no_pending_undo` | Undo stack is empty | Create a pending change first | +| `no_pending_changes` | Preview/submit needs pending changes | Create a pending change first | +| `read_only` | Cell/row cannot be edited | Do not attempt edit action | +| `primary_key` | Primary key cell is not editable | Assert non-editable primary key | +| `row_deleted` | Deleted row cell cannot be edited | Undo or skip edit | +| `pending_row_active` | Redis new row already open | Complete or cancel active row | +| `mutating` | Redis mutation in progress | Wait for mutation completion | +| `standalone_login_disabled` | Runtime configuration disables manual login | Do not attempt standalone login | + +## 6. Error Code Enums + +| error code | Meaning | Automation expectation | +| --- | --- | --- | +| `bootstrap_failed` | Auth bootstrap failed | Capture session/bootstrap error evidence | +| `standalone_session_create_failed` | Manual session creation failed | Assert failure and error region | +| `query_execution_failed` | SQL editor statement failed | Assert message or result-set error item | +| `data_load_failed` | Shared data detail load failed | Retry may be available | +| `redis_key_operation_failed` | Redis key operation failed | Capture Redis detail error banner | +| `dashboard_load_failed` | Dashboard metadata load failed | Assert analysis error surface | +| `widget_query_failed` | Analysis widget query/runtime failed | Assert widget error state | + +## 7. Resource Binding + +| Element | Resource fields | Notes | +| --- | --- | --- | +| Activity tab | `data-qa-resource-type=activity-tab`, `data-qa-resource-id` | Stable IDs: `connections`, `analysis` | +| Sidebar nodes | `data-qa-resource-type`, `data-qa-resource-id`, `data-qa-connection-id`, `data-qa-database`, `data-qa-schema` | Node IDs come from tree data; test ID remains stable across instances | +| Tabs and tab panels | `data-qa-resource-type=tab`, `data-qa-resource-id`, `data-qa-tab-type`, `data-qa-connection-id`, `data-qa-database`, `data-qa-schema` | Tab ID is resource binding, not part of `data-testid` | +| SQL table detail | `data-qa-resource-type=table`, `data-qa-resource-id=tableName`, plus connection/database/schema | Table identity for API correlation | +| SQL table rows/cells | `data-qa-resource-type=table-row`, `data-qa-resource-id=rowKey`, `data-qa-field` | Row key is data-source row key or pending-row key | +| MongoDB collection | `data-qa-resource-type=collection`, `data-qa-resource-id=collectionName`, plus connection/database | Collection identity | +| MongoDB document cards | `data-qa-resource-type=document`, `data-qa-resource-id=rowKey` | Row key is changeset/document position key, not Mongo `_id` | +| Redis key detail | `data-qa-resource-type=redis_key`, `data-qa-resource-id=keyName`, `data-qa-key-type` | Key name is visible resource identity | +| Redis rows/cells | `data-qa-resource-type=redis_key_row`, `data-qa-resource-id=keyName:index`, `data-qa-field` | Index-based binding reflects Redis row display position | +| Dashboards/widgets | `data-qa-resource-type=dashboard/widget`, `data-qa-resource-id` | Store IDs for dashboard and widget correlation | + +## 8. Coverage Notes + +| Requirement element | Coverage state | Notes | +| --- | --- | --- | +| Core user action buttons | covered | Login, activity switch, tabs, query run/format, table/collection/Redis operations, dashboard/widget operations | +| Automation-facing fields | covered | Login fields, SQL database/schema selectors, editable table/Redis cells, widget title edit | +| Key states | covered | Loading/empty/error/ready/dirty/executing/mutating/selected/editing states | +| Disabled reasons | covered | Common disabled action reasons use stable enum values | +| Error surfaces | covered | Auth, data load, SQL query, Redis operation, analysis dashboard/widget errors | +| Resource rows/cards/detail roots | covered | Sidebar nodes, tabs, table rows, Mongo documents, Redis rows, dashboards/widgets | +| Pure layout/decorative elements | skipped | Icons, spacing wrappers, separators, visual-only containers intentionally remain untagged | + +## 9. Change Rules + +- New core operations must add a stable `data-testid` and relevant `data-qa-*` semantics. +- Repeated business objects must keep a shared stable `data-testid`; instance identity belongs in `data-qa-resource-*`. +- Do not add dynamic IDs, array indexes, visible text, CSS class names, or component library internals to `data-testid`. +- New disabled or error branches must use stable enum strings and update this document. +- Renaming or deleting a tag in this file is a test-contract change and must be called out in review. diff --git a/dataflow/dataflow/eslint.config.mjs b/dataflow/dataflow/eslint.config.mjs new file mode 100644 index 0000000..5dcbb61 --- /dev/null +++ b/dataflow/dataflow/eslint.config.mjs @@ -0,0 +1,11 @@ +import { defineConfig, globalIgnores } from "eslint/config"; + +const eslintConfig = defineConfig([ + globalIgnores([ + ".next/**", + "build/**", + "node_modules/**", + ]), +]); + +export default eslintConfig; diff --git a/dataflow/dataflow/index.html b/dataflow/dataflow/index.html new file mode 100644 index 0000000..dbd6885 --- /dev/null +++ b/dataflow/dataflow/index.html @@ -0,0 +1,14 @@ + + + + + + + + DataFlow AI Analyst + + +
+ + + diff --git a/dataflow/dataflow/package.json b/dataflow/dataflow/package.json new file mode 100644 index 0000000..2b37b97 --- /dev/null +++ b/dataflow/dataflow/package.json @@ -0,0 +1,76 @@ +{ + "name": "dataflow-ai-analyst", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22.0.0", + "pnpm": ">=10.0.0" + }, + "packageManager": "pnpm@10.29.3", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint", + "typecheck": "tsc --noEmit", + "generate": "graphql-codegen --config codegen.yml", + "postinstall": "mkdir -p public/monaco-editor/min && cp -r node_modules/monaco-editor/min/vs public/monaco-editor/min/" + }, + "dependencies": { + "@apollo/client": "3.13.8", + "@kubernetes/client-node": "^0.21.0", + "@labring/sealos-desktop-sdk": "0.1.23", + "@monaco-editor/react": "^4.7.0", + "@types/react-grid-layout": "^1.3.6", + "@types/react-resizable": "^3.0.8", + "@types/uuid": "^10.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "echarts": "^6.0.0", + "echarts-for-react": "^3.0.5", + "geist": "^1.7.0", + "graphql": "16.11.0", + "jszip": "^3.10.1", + "lucide-react": "^0.555.0", + "radix-ui": "^1.4.3", + "react": "19.2.0", + "react-dom": "19.2.0", + "react-grid-layout": "^1.5.2", + "react-resizable": "^3.1.3", + "sql-formatter": "^15.7.2", + "tailwind-merge": "^3.4.0", + "tw-animate-css": "^1.4.0", + "uuid": "^13.0.0", + "xlsx": "^0.18.5", + "zustand": "^5.0.9" + }, + "devDependencies": { + "@csstools/postcss-color-mix-function": "^4.0.6", + "@csstools/postcss-oklab-function": "^5.0.5", + "@graphql-codegen/cli": "5.0.7", + "@graphql-codegen/typescript": "4.1.6", + "@graphql-codegen/typescript-operations": "4.6.1", + "@graphql-codegen/typescript-react-apollo": "4.3.3", + "@tailwindcss/vite": "^4", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4.7.0", + "eslint": "^9", + "jsdom": "^25.0.1", + "monaco-editor": "^0.55.1", + "postcss": "^8.5.15", + "postcss-media-minmax": "^5.0.0", + "postcss-nesting": "^14.0.0", + "postcss-value-parser": "^4.2.0", + "tailwindcss": "^4", + "typescript": "^5", + "vite": "^7", + "vitest": "^4.1.2" + } +} diff --git a/dataflow/dataflow/pnpm-lock.yaml b/dataflow/dataflow/pnpm-lock.yaml new file mode 100644 index 0000000..b03667e --- /dev/null +++ b/dataflow/dataflow/pnpm-lock.yaml @@ -0,0 +1,9638 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@apollo/client': + specifier: 3.13.8 + version: 3.13.8(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.11.0)(ws@8.20.0))(graphql@16.11.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@kubernetes/client-node': + specifier: ^0.21.0 + version: 0.21.0 + '@labring/sealos-desktop-sdk': + specifier: 0.1.23 + version: 0.1.23(@kubernetes/client-node@0.21.0) + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@types/react-grid-layout': + specifier: ^1.3.6 + version: 1.3.6 + '@types/react-resizable': + specifier: ^3.0.8 + version: 3.0.8 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + echarts: + specifier: ^6.0.0 + version: 6.0.0 + echarts-for-react: + specifier: ^3.0.5 + version: 3.0.6(echarts@6.0.0)(react@19.2.0) + geist: + specifier: ^1.7.0 + version: 1.7.0(next@16.2.1(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) + graphql: + specifier: 16.11.0 + version: 16.11.0 + jszip: + specifier: ^3.10.1 + version: 3.10.1 + lucide-react: + specifier: ^0.555.0 + version: 0.555.0(react@19.2.0) + radix-ui: + specifier: ^1.4.3 + version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: + specifier: 19.2.0 + version: 19.2.0 + react-dom: + specifier: 19.2.0 + version: 19.2.0(react@19.2.0) + react-grid-layout: + specifier: ^1.5.2 + version: 1.5.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-resizable: + specifier: ^3.1.3 + version: 3.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + sql-formatter: + specifier: ^15.7.2 + version: 15.7.2 + tailwind-merge: + specifier: ^3.4.0 + version: 3.5.0 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + uuid: + specifier: ^13.0.0 + version: 13.0.0 + xlsx: + specifier: ^0.18.5 + version: 0.18.5 + zustand: + specifier: ^5.0.9 + version: 5.0.12(@types/react@19.2.14)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) + devDependencies: + '@csstools/postcss-color-mix-function': + specifier: ^4.0.6 + version: 4.0.6(postcss@8.5.15) + '@csstools/postcss-oklab-function': + specifier: ^5.0.5 + version: 5.0.5(postcss@8.5.15) + '@graphql-codegen/cli': + specifier: 5.0.7 + version: 5.0.7(@types/node@20.19.37)(graphql@16.11.0)(typescript@5.9.3) + '@graphql-codegen/typescript': + specifier: 4.1.6 + version: 4.1.6(graphql@16.11.0) + '@graphql-codegen/typescript-operations': + specifier: 4.6.1 + version: 4.6.1(graphql@16.11.0) + '@graphql-codegen/typescript-react-apollo': + specifier: 4.3.3 + version: 4.3.3(graphql@16.11.0) + '@tailwindcss/vite': + specifier: ^4 + version: 4.2.2(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@types/node': + specifier: ^20 + version: 20.19.37 + '@types/react': + specifier: ^19 + version: 19.2.14 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^4.7.0 + version: 4.7.0(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + eslint: + specifier: ^9 + version: 9.39.4(jiti@2.6.1) + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 + postcss: + specifier: ^8.5.15 + version: 8.5.15 + postcss-media-minmax: + specifier: ^5.0.0 + version: 5.0.0(postcss@8.5.15) + postcss-nesting: + specifier: ^14.0.0 + version: 14.0.0(postcss@8.5.15) + postcss-value-parser: + specifier: ^4.2.0 + version: 4.2.0 + tailwindcss: + specifier: ^4 + version: 4.2.1 + typescript: + specifier: ^5 + version: 5.9.3 + vite: + specifier: ^7 + version: 7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vitest: + specifier: ^4.1.2 + version: 4.1.2(@types/node@20.19.37)(jsdom@25.0.1)(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + +packages: + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@apollo/client@3.13.8': + resolution: {integrity: sha512-YM9lQpm0VfVco4DSyKooHS/fDTiKQcCHfxr7i3iL6a0kP/jNO5+4NFK6vtRDxaYisd5BrwOZHLJpPBnvRVpKPg==} + peerDependencies: + graphql: ^15.0.0 || ^16.0.0 + graphql-ws: ^5.5.5 || ^6.0.3 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc + subscriptions-transport-ws: ^0.9.0 || ^0.11.0 + peerDependenciesMeta: + graphql-ws: + optional: true + react: + optional: true + react-dom: + optional: true + subscriptions-transport-ws: + optional: true + + '@ardatan/relay-compiler@12.0.0': + resolution: {integrity: sha512-9anThAaj1dQr6IGmzBMcfzOQKTa5artjuPmw8NYK/fiGEMjADbSguBY2FMDykt+QhilR3wc9VA/3yVju7JHg7Q==} + hasBin: true + peerDependencies: + graphql: '*' + + '@ardatan/relay-compiler@13.0.0': + resolution: {integrity: sha512-ite4+xng5McO8MflWCi0un0YmnorTujsDnfPfhzYzAgoJ+jkI1pZj6jtmTl8Jptyi1H+Pa0zlatJIsxDD++ETA==} + peerDependencies: + graphql: '*' + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-proposal-class-properties@7.18.6': + resolution: {integrity: sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-object-rest-spread@7.20.7': + resolution: {integrity: sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-flow@7.28.6': + resolution: {integrity: sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-assertions@7.28.6': + resolution: {integrity: sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-arrow-functions@7.27.1': + resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoped-functions@7.27.1': + resolution: {integrity: sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.28.6': + resolution: {integrity: sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-classes@7.28.6': + resolution: {integrity: sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.28.6': + resolution: {integrity: sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.28.5': + resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-flow-strip-types@7.27.1': + resolution: {integrity: sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.27.1': + resolution: {integrity: sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.27.1': + resolution: {integrity: sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.27.1': + resolution: {integrity: sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.27.1': + resolution: {integrity: sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.27.1': + resolution: {integrity: sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.27.7': + resolution: {integrity: sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.27.1': + resolution: {integrity: sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@7.28.0': + resolution: {integrity: sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.28.6': + resolution: {integrity: sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.27.1': + resolution: {integrity: sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.28.6': + resolution: {integrity: sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.27.1': + resolution: {integrity: sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@4.1.7': + resolution: {integrity: sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@csstools/postcss-color-mix-function@4.0.6': + resolution: {integrity: sha512-gWXS+Qx4GnIq4lRvBcabAcGF5IBkfcbZ7uoUvQw/3+JYDQVkD8SoKIFRoYhwKAOm+zTDJRYLMsjxWieBh9xOZw==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-oklab-function@5.0.5': + resolution: {integrity: sha512-A+Nkzj2ODvQboM5FlqEcp0iqilyVo78f9FMx/3cHrRrEBqCymSXvf8sa1cTY54lJoUVI3Sn9XysgvYaVIAuIYg==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-progressive-custom-properties@5.1.0': + resolution: {integrity: sha512-lt/4yHy2GdKcGVpK4OGhBdSIq+z2PXynSusSRggn/T4y7uFurYAhdHqo/aYM+xI37vNb8rJlEKchqKKvVCXROQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-progressive-custom-properties@5.1.1': + resolution: {integrity: sha512-b9+lusgVDTHXRBgYKE9s0RKUyEn6Q/knmMDmYmSrkZjtpb0Nd71pBtgVMf9tSpOIb0K8hyzeyy6JyKFqg5rBJw==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/selector-resolve-nested@4.0.0': + resolution: {integrity: sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@csstools/selector-specificity@6.0.0': + resolution: {integrity: sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@csstools/utilities@3.0.0': + resolution: {integrity: sha512-etDqA/4jYvOGBM6yfKCOsEXfH96BKztZdgGmGqKi2xHnDe0ILIBraRspwgYatJH9JsCZ5HCGoCst8w18EKOAdg==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + + '@envelop/core@5.5.1': + resolution: {integrity: sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw==} + engines: {node: '>=18.0.0'} + + '@envelop/instrumentation@1.0.0': + resolution: {integrity: sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw==} + engines: {node: '>=18.0.0'} + + '@envelop/types@5.2.1': + resolution: {integrity: sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==} + engines: {node: '>=18.0.0'} + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@graphql-codegen/add@5.0.3': + resolution: {integrity: sha512-SxXPmramkth8XtBlAHu4H4jYcYXM/o3p01+psU+0NADQowA8jtYkK6MW5rV6T+CxkEaNZItfSmZRPgIuypcqnA==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/cli@5.0.7': + resolution: {integrity: sha512-h/sxYvSaWtxZxo8GtaA8SvcHTyViaaPd7dweF/hmRDpaQU1o3iU3EZxlcJ+oLTunU0tSMFsnrIXm/mhXxI11Cw==} + engines: {node: '>=16'} + hasBin: true + peerDependencies: + '@parcel/watcher': ^2.1.0 + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + '@parcel/watcher': + optional: true + + '@graphql-codegen/client-preset@4.8.3': + resolution: {integrity: sha512-QpEsPSO9fnRxA6Z66AmBuGcwHjZ6dYSxYo5ycMlYgSPzAbyG8gn/kWljofjJfWqSY+T/lRn+r8IXTH14ml24vQ==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + graphql-sock: ^1.0.0 + peerDependenciesMeta: + graphql-sock: + optional: true + + '@graphql-codegen/core@4.0.2': + resolution: {integrity: sha512-IZbpkhwVqgizcjNiaVzNAzm/xbWT6YnGgeOLwVjm4KbJn3V2jchVtuzHH09G5/WkkLSk2wgbXNdwjM41JxO6Eg==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/gql-tag-operations@4.0.17': + resolution: {integrity: sha512-2pnvPdIG6W9OuxkrEZ6hvZd142+O3B13lvhrZ48yyEBh2ujtmKokw0eTwDHtlXUqjVS0I3q7+HB2y12G/m69CA==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/plugin-helpers@3.1.2': + resolution: {integrity: sha512-emOQiHyIliVOIjKVKdsI5MXj312zmRDwmHpyUTZMjfpvxq/UVAHUJIVdVf+lnjjrI+LXBTgMlTWTgHQfmICxjg==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/plugin-helpers@5.1.1': + resolution: {integrity: sha512-28GHODK2HY1NhdyRcPP3sCz0Kqxyfiz7boIZ8qIxFYmpLYnlDgiYok5fhFLVSZihyOpCs4Fa37gVHf/Q4I2FEg==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/schema-ast@4.1.0': + resolution: {integrity: sha512-kZVn0z+th9SvqxfKYgztA6PM7mhnSZaj4fiuBWvMTqA+QqQ9BBed6Pz41KuD/jr0gJtnlr2A4++/0VlpVbCTmQ==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/typed-document-node@5.1.2': + resolution: {integrity: sha512-jaxfViDqFRbNQmfKwUY8hDyjnLTw2Z7DhGutxoOiiAI0gE/LfPe0LYaVFKVmVOOD7M3bWxoWfu4slrkbWbUbEw==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/typescript-operations@4.6.1': + resolution: {integrity: sha512-k92laxhih7s0WZ8j5WMIbgKwhe64C0As6x+PdcvgZFMudDJ7rPJ/hFqJ9DCRxNjXoHmSjnr6VUuQZq4lT1RzCA==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + graphql-sock: ^1.0.0 + peerDependenciesMeta: + graphql-sock: + optional: true + + '@graphql-codegen/typescript-react-apollo@4.3.3': + resolution: {integrity: sha512-ecuzzqoZEHCtlxaEXL1LQTrfzVYwNNtbVUBHc/KQDfkJIQZon+dG5ZXOoJ4BpbRA2L99yTx+TZc2VkpOVfSypw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/typescript@4.1.6': + resolution: {integrity: sha512-vpw3sfwf9A7S+kIUjyFxuvrywGxd4lmwmyYnnDVjVE4kSQ6Td3DpqaPTy8aNQ6O96vFoi/bxbZS2BW49PwSUUA==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/visitor-plugin-common@2.13.8': + resolution: {integrity: sha512-IQWu99YV4wt8hGxIbBQPtqRuaWZhkQRG2IZKbMoSvh0vGeWb3dB0n0hSgKaOOxDY+tljtOf9MTcUYvJslQucMQ==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-codegen/visitor-plugin-common@5.8.0': + resolution: {integrity: sha512-lC1E1Kmuzi3WZUlYlqB4fP6+CvbKH9J+haU1iWmgsBx5/sO2ROeXJG4Dmt8gP03bI2BwjiwV5WxCEMlyeuzLnA==} + engines: {node: '>=16'} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + '@graphql-hive/signal@1.0.0': + resolution: {integrity: sha512-RiwLMc89lTjvyLEivZ/qxAC5nBHoS2CtsWFSOsN35sxG9zoo5Z+JsFHM8MlvmO9yt+MJNIyC5MLE1rsbOphlag==} + engines: {node: '>=18.0.0'} + + '@graphql-hive/signal@2.0.0': + resolution: {integrity: sha512-Pz8wB3K0iU6ae9S1fWfsmJX24CcGeTo6hE7T44ucmV/ALKRj+bxClmqrYcDT7v3f0d12Rh4FAXBb6gon+WkDpQ==} + engines: {node: '>=20.0.0'} + + '@graphql-tools/apollo-engine-loader@8.0.28': + resolution: {integrity: sha512-MzgDrUuoxp6dZeo54zLBL3cEJKJtM3N/2RqK0rbPxPq5X2z6TUA7EGg8vIFTUkt5xelAsUrm8/4ai41ZDdxOng==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/batch-execute@10.0.7': + resolution: {integrity: sha512-vKo9XUiy2sc5tzMupXoxZbu5afVY/9yJ0+yLrM5Dhh38yHYULf3z9VC1eAwW0kj8pWpOo8d8CV3jpleGwv83PA==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/batch-execute@9.0.19': + resolution: {integrity: sha512-VGamgY4PLzSx48IHPoblRw0oTaBa7S26RpZXt0Y4NN90ytoE0LutlpB2484RbkfcTjv9wa64QD474+YP1kEgGA==} + engines: {node: '>=18.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/code-file-loader@8.1.28': + resolution: {integrity: sha512-BL3Ft/PFlXDE5nNuqA36hYci7Cx+8bDrPDc8X3VSpZy9iKFBY+oQ+IwqnEHCkt8OSp2n2V0gqTg4u3fcQP1Kwg==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/delegate@10.2.23': + resolution: {integrity: sha512-xrPtl7f1LxS+B6o+W7ueuQh67CwRkfl+UKJncaslnqYdkxKmNBB4wnzVcW8ZsRdwbsla/v43PtwAvSlzxCzq2w==} + engines: {node: '>=18.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/delegate@12.0.12': + resolution: {integrity: sha512-/vgLWhIwm+Mgo5VUOJQj6EOpaxXRQmA7mk8j6/8vBbPi56LoYA/UPRygcpEnm9EuXTspFKCTBil+xqThU3EmqQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/documents@1.0.1': + resolution: {integrity: sha512-aweoMH15wNJ8g7b2r4C4WRuJxZ0ca8HtNO54rkye/3duxTkW4fGBEutCx03jCIr5+a1l+4vFJNP859QnAVBVCA==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-common@0.0.4': + resolution: {integrity: sha512-SEH/OWR+sHbknqZyROCFHcRrbZeUAyjCsgpVWCRjqjqRbiJiXq6TxNIIOmpXgkrXWW/2Ev4Wms6YSGJXjdCs6Q==} + engines: {node: '>=18.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-common@0.0.6': + resolution: {integrity: sha512-JAH/R1zf77CSkpYATIJw+eOJwsbWocdDjY+avY7G+P5HCXxwQjAjWVkJI1QJBQYjPQDVxwf1fmTZlIN3VOadow==} + engines: {node: '>=18.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-common@1.0.6': + resolution: {integrity: sha512-23/K5C+LSlHDI0mj2SwCJ33RcELCcyDUgABm1Z8St7u/4Z5+95i925H/NAjUyggRjiaY8vYtNiMOPE49aPX1sg==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-graphql-ws@2.0.7': + resolution: {integrity: sha512-J27za7sKF6RjhmvSOwOQFeNhNHyP4f4niqPnerJmq73OtLx9Y2PGOhkXOEB0PjhvPJceuttkD2O1yMgEkTGs3Q==} + engines: {node: '>=18.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-graphql-ws@3.1.5': + resolution: {integrity: sha512-WXRsfwu9AkrORD9nShrd61OwwxeQ5+eXYcABRR3XPONFIS8pWQfDJGGqxql9/227o/s0DV5SIfkBURb5Knzv+A==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-http@1.3.3': + resolution: {integrity: sha512-LIy+l08/Ivl8f8sMiHW2ebyck59JzyzO/yF9SFS4NH6MJZUezA1xThUXCDIKhHiD56h/gPojbkpcFvM2CbNE7A==} + engines: {node: '>=18.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-http@3.1.1': + resolution: {integrity: sha512-Le57fMdN7nGBp8XddkpGreCzcPGCZ+uHs1kPw/xMKPJMsn/vZUHbWJ0FY0cb0jfE3OVhbMIjjSe/OHlG3Mm3zw==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor-legacy-ws@1.1.25': + resolution: {integrity: sha512-6uf4AEXO0QMxJ7AWKVPqEZXgYBJaiz5vf29X0boG8QtcqWy8mqkXKWLND2Swdx0SbEx0efoGFcjuKufUcB0ASQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/executor@1.5.1': + resolution: {integrity: sha512-n94Qcu875Mji9GQ52n5UbgOTxlgvFJicBPYD+FRks9HKIQpdNPjkkrKZUYNG51XKa+bf03rxNflm4+wXhoHHrA==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/git-loader@8.0.32': + resolution: {integrity: sha512-H5HTp2vevv0rRMEnCJBVmVF8md3LpJI1C1+d6OtzvmuONJ8mOX2mkf9rtoqwiztynVegaDUekvMFsc9k5iE2WA==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/github-loader@8.0.22': + resolution: {integrity: sha512-uQ4JNcNPsyMkTIgzeSbsoT9hogLjYrZooLUYd173l5eUGUi49EAcsGdiBCKaKfEjanv410FE8hjaHr7fjSRkJw==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/graphql-file-loader@8.1.12': + resolution: {integrity: sha512-Nma7gBgJoUbqXWTmdHjouo36tjzewA8MptVcHoH7widzkciaUVzBhriHzqICFB/dVxig//g9MX8s1XawZo7UAg==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/graphql-tag-pluck@8.3.27': + resolution: {integrity: sha512-CJ0WVXhGYsfFngpRrAAcjRHyxSDHx4dEz2W15bkwvt9he/AWhuyXm07wuGcoLrl0q0iQp1BiRjU7D8SxWZo3JQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/import@7.1.12': + resolution: {integrity: sha512-QSsdPsdJ7yCgQ5XODyKYpC7NlB9R1Koi0R3418PT7GiRm+9O8gYXSs/23dumcOnpiLrnf4qR2aytBn1+JOAhnA==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/json-file-loader@8.0.26': + resolution: {integrity: sha512-kwy9IFi5QtXXTLBgWkvA1RqsZeJDn0CxsTbhNlziCzmga9fNo7qtZ18k9FYIq3EIoQQlok+b7W7yeyJATA2xhw==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/load@8.1.8': + resolution: {integrity: sha512-gxO662b64qZSToK3N6XUxWG5E6HOUjlg5jEnmGvD4bMtGJ0HwEe/BaVZbBQemCfLkxYjwRIBiVfOY9o0JyjZJg==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/merge@9.1.7': + resolution: {integrity: sha512-Y5E1vTbTabvcXbkakdFUt4zUIzB1fyaEnVmIWN0l0GMed2gdD01TpZWLUm4RNAxpturvolrb24oGLQrBbPLSoQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/optimize@1.4.0': + resolution: {integrity: sha512-dJs/2XvZp+wgHH8T5J2TqptT9/6uVzIYvA6uFACha+ufvdMBedkfR4b4GbT8jAKLRARiqRTxy3dctnwkTM2tdw==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/optimize@2.0.0': + resolution: {integrity: sha512-nhdT+CRGDZ+bk68ic+Jw1OZ99YCDIKYA5AlVAnBHJvMawSx9YQqQAIj4refNc1/LRieGiuWvhbG3jvPVYho0Dg==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/prisma-loader@8.0.17': + resolution: {integrity: sha512-fnuTLeQhqRbA156pAyzJYN0KxCjKYRU5bz1q/SKOwElSnAU4k7/G1kyVsWLh7fneY78LoMNH5n+KlFV8iQlnyg==} + engines: {node: '>=16.0.0'} + deprecated: 'This package was intended to be used with an older versions of Prisma.\nThe newer versions of Prisma has a different approach to GraphQL integration.\nTherefore, this package is no longer needed and has been deprecated and removed.\nLearn more: https://www.prisma.io/graphql' + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/relay-operation-optimizer@6.5.18': + resolution: {integrity: sha512-mc5VPyTeV+LwiM+DNvoDQfPqwQYhPV/cl5jOBjTgSniyaq8/86aODfMkrE2OduhQ5E00hqrkuL2Fdrgk0w1QJg==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/relay-operation-optimizer@7.1.1': + resolution: {integrity: sha512-va+ZieMlz6Fj18xUbwyQkZ34PsnzIdPT6Ccy1BNOQw1iclQwk52HejLMZeE/4fH+4cu80Q2HXi5+FjCKpmnJCg==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/schema@10.0.31': + resolution: {integrity: sha512-ZewRgWhXef6weZ0WiP7/MV47HXiuFbFpiDUVLQl6mgXsWSsGELKFxQsyUCBos60Qqy1JEFAIu3Ns6GGYjGkqkQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/url-loader@8.0.33': + resolution: {integrity: sha512-Fu626qcNHcqAj8uYd7QRarcJn5XZ863kmxsg1sm0fyjyfBJnsvC7ddFt6Hayz5kxVKfsnjxiDfPMXanvsQVBKw==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/url-loader@9.0.6': + resolution: {integrity: sha512-QdJI3f7ANDMYfYazRgJzzybznjOrQAOuDXweC9xmKgPZoTqNxEAsatiy69zcpTf6092taJLyrqRH6R7xUTzf4A==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/utils@10.11.0': + resolution: {integrity: sha512-iBFR9GXIs0gCD+yc3hoNswViL1O5josI33dUqiNStFI/MHLCEPduasceAcazRH77YONKNiviHBV8f7OgcT4o2Q==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/utils@11.0.0': + resolution: {integrity: sha512-bM1HeZdXA2C3LSIeLOnH/bcqSgbQgKEDrjxODjqi3y58xai2TkNrtYcQSoWzGbt9VMN1dORGjR7Vem8SPnUFQA==} + engines: {node: '>=16.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/utils@9.2.1': + resolution: {integrity: sha512-WUw506Ql6xzmOORlriNrD6Ugx+HjVgYxt9KCXD9mHAak+eaXSwuGGPyE60hy9xaDEoXKBsG7SkG69ybitaVl6A==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/wrap@10.1.4': + resolution: {integrity: sha512-7pyNKqXProRjlSdqOtrbnFRMQAVamCmEREilOXtZujxY6kYit3tvWWSjUrcIOheltTffoRh7EQSjpy2JDCzasg==} + engines: {node: '>=18.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-tools/wrap@11.1.12': + resolution: {integrity: sha512-PJ0tuiGbEOOZAJk2/pTKyzMEbwBncPBfO7Z84tCPzM/CAR4ZlAXbXjaXOw4fdi0ReUDyOG06Z8DGgEQjr68dKw==} + engines: {node: '>=20.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@graphql-typed-document-node/core@3.2.0': + resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@kubernetes/client-node@0.21.0': + resolution: {integrity: sha512-yYRbgMeyQbvZDHt/ZqsW3m4lRefzhbbJEuj8sVXM+bufKrgmzriA2oq7lWPH/k/LQIicAME9ixPUadTrxIF6dQ==} + + '@labring/sealos-desktop-sdk@0.1.23': + resolution: {integrity: sha512-Ri5TEgJj9hHgmEofbCqlKFzDoHcaJeJQWLo0srq0xLyM4D4pFJ4lyL9tNB0N/7SSZCN4cVGbi1rFOIoghFID3Q==} + peerDependencies: + '@kubernetes/client-node': ^0.18.1 + + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@next/env@16.2.1': + resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==} + + '@next/swc-darwin-arm64@16.2.1': + resolution: {integrity: sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.1': + resolution: {integrity: sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.1': + resolution: {integrity: sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@16.2.1': + resolution: {integrity: sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@16.2.1': + resolution: {integrity: sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@16.2.1': + resolution: {integrity: sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@16.2.1': + resolution: {integrity: sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.1': + resolution: {integrity: sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-accessible-icon@1.1.7': + resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.7': + resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.10': + resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.1.8': + resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.15': + resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.16': + resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.14': + resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.1.8': + resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.1.3': + resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.7': + resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.15': + resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.11': + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.1.11': + resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@repeaterjs/repeater@3.0.6': + resolution: {integrity: sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.2': + resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/caseless@0.12.5': + resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@20.19.37': + resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react-grid-layout@1.3.6': + resolution: {integrity: sha512-Cw7+sb3yyjtmxwwJiXtEXcu5h4cgs+sCGkHwHXsFmPyV30bf14LeD/fa2LwQovuD2HWxCcjIdNhDlcYGj95qGA==} + + '@types/react-resizable@3.0.8': + resolution: {integrity: sha512-Pcvt2eGA7KNXldt1hkhVhAgZ8hK41m0mp89mFgQi7LAAEZiaLgm4fHJ5zbJZ/4m2LVaAyYrrRRv1LHDcrGQanA==} + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/request@2.48.13': + resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/expect@4.1.2': + resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} + + '@vitest/mocker@4.1.2': + resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.2': + resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} + + '@vitest/runner@4.1.2': + resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} + + '@vitest/snapshot@4.1.2': + resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} + + '@vitest/spy@4.1.2': + resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} + + '@vitest/utils@4.1.2': + resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + + '@whatwg-node/disposablestack@0.0.6': + resolution: {integrity: sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/fetch@0.10.13': + resolution: {integrity: sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/node-fetch@0.8.5': + resolution: {integrity: sha512-4xzCl/zphPqlp9tASLVeUhB5+WJHbuWGYpfoC2q1qh5dw0AqZBW7L27V5roxYWijPxj4sspRAAoOH3d2ztaHUQ==} + engines: {node: '>=18.0.0'} + + '@whatwg-node/promise-helpers@1.3.2': + resolution: {integrity: sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==} + engines: {node: '>=16.0.0'} + + '@wry/caches@1.0.1': + resolution: {integrity: sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==} + engines: {node: '>=8'} + + '@wry/context@0.7.4': + resolution: {integrity: sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==} + engines: {node: '>=8'} + + '@wry/equality@0.5.7': + resolution: {integrity: sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==} + engines: {node: '>=8'} + + '@wry/trie@0.5.0': + resolution: {integrity: sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==} + engines: {node: '>=8'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + auto-bind@4.0.0: + resolution: {integrity: sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ==} + engines: {node: '>=8'} + + aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + + axios@1.15.0: + resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + + babel-plugin-syntax-trailing-function-commas@7.0.0-beta.0: + resolution: {integrity: sha512-Xj9XuRuz3nTSbaTXWv3itLOcxyF4oPD8douBBmj7U9BBC6nEBYfyOJYQMf/8PJAFotC62UY5dFfIGEPr7WswzQ==} + + babel-preset-fbjs@3.4.0: + resolution: {integrity: sha512-9ywCsCvo1ojrw0b+XYk7aFvTH6D9064t0RIL1rtMf3nsa02Xw41MS7sZw216Im35xj/UY0PDBQsa1brUDDF1Ow==} + peerDependencies: + '@babel/core': ^7.0.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.8: + resolution: {integrity: sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + byline@5.0.0: + resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} + engines: {node: '>=0.10.0'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001780: + resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} + + capital-case@1.0.4: + resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} + + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + change-case-all@1.0.15: + resolution: {integrity: sha512-3+GIFhk3sNuvFAJKU46o26OdzudQlPNBCu1ZQi3cMeMHhty1bhDxu2WrEilVNYaGvqUtR1VSigFcJOiS13dRhQ==} + + change-case@4.1.2: + resolution: {integrity: sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-truncate@2.1.0: + resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} + engines: {node: '>=8'} + + cli-width@3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + constant-case@3.0.4: + resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + + cross-inspect@1.0.1: + resolution: {integrity: sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==} + engines: {node: '>=16.0.0'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + dataloader@2.2.3: + resolution: {integrity: sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + debounce@1.2.1: + resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dependency-graph@0.11.0: + resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==} + engines: {node: '>= 0.6.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + + dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + + echarts-for-react@3.0.6: + resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==} + peerDependencies: + echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + react: ^15.0.0 || >=16.0.0 + + echarts@6.0.0: + resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==} + + electron-to-chromium@1.5.321: + resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-equals@4.0.3: + resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fbjs-css-vars@1.0.2: + resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} + + fbjs@3.0.5: + resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + + form-data@2.3.3: + resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} + engines: {node: '>= 0.12'} + + form-data@2.5.6: + resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==} + engines: {node: '>= 0.12'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + geist@1.7.0: + resolution: {integrity: sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==} + peerDependencies: + next: '>=13.2.0' + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphql-config@5.1.6: + resolution: {integrity: sha512-fCkYnm4Kdq3un0YIM4BCZHVR5xl0UeLP6syxxO7KAstdY7QVyVvTHP0kRPDYEP1v08uwtJVgis5sj3IOTLOniQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + cosmiconfig-toml-loader: ^1.0.0 + graphql: ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + cosmiconfig-toml-loader: + optional: true + + graphql-request@6.1.0: + resolution: {integrity: sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==} + peerDependencies: + graphql: 14 - 16 + + graphql-tag@2.12.6: + resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==} + engines: {node: '>=10'} + peerDependencies: + graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + graphql-ws@6.0.7: + resolution: {integrity: sha512-yoLRW+KRlDmnnROdAu7sX77VNLC0bsFoZyGQJLy1cF+X/SkLg/fWkRGrEEYQK8o2cafJ2wmEaMqMEZB3U3DYDg==} + engines: {node: '>=20'} + peerDependencies: + '@fastify/websocket': ^10 || ^11 + crossws: ~0.3 + graphql: ^15.10.1 || ^16 + ws: ^8 + peerDependenciesMeta: + '@fastify/websocket': + optional: true + crossws: + optional: true + ws: + optional: true + + graphql@16.11.0: + resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + har-schema@2.0.0: + resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==} + engines: {node: '>=4'} + + har-validator@5.1.5: + resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==} + engines: {node: '>=6'} + deprecated: this library is no longer supported + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + header-case@2.0.4: + resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http-signature@1.2.0: + resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} + engines: {node: '>=0.8', npm: '>=1.3.7'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + immutable@3.7.6: + resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==} + engines: {node: '>=0.8.0'} + + immutable@5.1.5: + resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-from@4.0.0: + resolution: {integrity: sha512-P9J71vT5nLlDeV8FHs5nNxaLbrpfAV5cF5srvbZfpwpcJoM/xZR3hiv+q+SAnuSmuGbXMWud063iIMx/V/EWZQ==} + engines: {node: '>=12.2'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inquirer@8.2.7: + resolution: {integrity: sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==} + engines: {node: '>=12.0.0'} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + + is-absolute@1.0.0: + resolution: {integrity: sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==} + engines: {node: '>=0.10.0'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-lower-case@2.0.2: + resolution: {integrity: sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-relative@1.0.0: + resolution: {integrity: sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==} + engines: {node: '>=0.10.0'} + + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + + is-unc-path@1.0.0: + resolution: {integrity: sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==} + engines: {node: '>=0.10.0'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-upper-case@2.0.2: + resolution: {integrity: sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json-to-pretty-yaml@1.2.2: + resolution: {integrity: sha512-rvm6hunfCcqegwYaG5T4yKJWxc9FXFgBVrcTZ4XfSVRwa5HA/Xs+vB/Eo9treYYHCeNM0nrSUr82V/M31Urc7A==} + engines: {node: '>= 0.2.0'} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonpath-plus@8.1.0: + resolution: {integrity: sha512-qVTiuKztFGw0dGhYi3WNqvddx3/SHtyDT0xJaeyz4uP0d1tkpG+0y5uYQ4OcIo1TLAz3PE/qDOW9F0uDt3+CTw==} + engines: {node: '>=14.0.0'} + hasBin: true + + jsprim@1.4.2: + resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} + engines: {node: '>=0.6.0'} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + listr2@4.0.5: + resolution: {integrity: sha512-juGHV1doQdpNT3GSTs9IUN43QJb7KHdF9uqg7Vufs/tG9VTzpFphqF4pm/ICdAABGQxsyNn9CiYA3StkI6jpwA==} + engines: {node: '>=12'} + peerDependencies: + enquirer: '>= 2.3.0 < 3' + peerDependenciesMeta: + enquirer: + optional: true + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-update@4.0.0: + resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==} + engines: {node: '>=10'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lower-case-first@2.0.2: + resolution: {integrity: sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==} + + lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lucide-react@0.555.0: + resolution: {integrity: sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + map-cache@0.2.2: + resolution: {integrity: sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==} + engines: {node: '>=0.10.0'} + + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + meros@1.3.2: + resolution: {integrity: sha512-Q3mobPbvEx7XbwhnC1J1r60+5H6EZyNccdzSz0eGexJRwouUtTZxPVRGdqKtxlpD84ScK4+tIGldkqDtCKdI0A==} + engines: {node: '>=13'} + peerDependencies: + '@types/node': '>=13' + peerDependenciesMeta: + '@types/node': + optional: true + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + + moo@0.5.3: + resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + + next@16.2.1: + resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + + normalize-path@2.1.1: + resolution: {integrity: sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==} + engines: {node: '>=0.10.0'} + + nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + oidc-token-hash@5.2.0: + resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==} + engines: {node: ^10.13.0 || >=12.0.0} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + openid-client@5.7.1: + resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} + + optimism@0.18.1: + resolution: {integrity: sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + param-case@3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-filepath@1.0.2: + resolution: {integrity: sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==} + engines: {node: '>=0.8'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + + path-case@3.0.4: + resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-root-regex@0.1.2: + resolution: {integrity: sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==} + engines: {node: '>=0.10.0'} + + path-root@0.1.1: + resolution: {integrity: sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==} + engines: {node: '>=0.10.0'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss-media-minmax@5.0.0: + resolution: {integrity: sha512-yDUvFf9QdFZTuCUg0g0uNSHVlJ5X1lSzDZjPSFaiCWvjgsvu8vEVxtahPrLMinIDEEGnx6cBe6iqdx5YWz08wQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + postcss: ^8.1.0 + + postcss-nesting@14.0.0: + resolution: {integrity: sha512-YGFOfVrjxYfeGTS5XctP1WCI5hu8Lr9SmntjfRC+iX5hCihEO+QZl9Ra+pkjqkgoVdDKvb2JccpElcowhZtzpw==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-selector-parser@7.1.4: + resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.5.5: + resolution: {integrity: sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + radix-ui@1.4.3: + resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + + react-dom@19.2.0: + resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} + peerDependencies: + react: ^19.2.0 + + react-draggable@4.5.0: + resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + + react-grid-layout@1.5.3: + resolution: {integrity: sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g==} + deprecated: 'Ships stray files that were never in the repo: an ip_fetcher binary and its curl-sample source (1.5.0-1.5.3), plus a yarn-error.log containing local paths (1.5.3). Upgrade to 1.5.4, which is byte-identical apart from removing them. See https://github.com/react-grid-layout/react-grid-layout/issues/2269' + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-resizable@3.1.3: + resolution: {integrity: sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==} + peerDependencies: + react: '>= 16.3' + react-dom: '>= 16.3' + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + engines: {node: '>=0.10.0'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + rehackt@0.1.0: + resolution: {integrity: sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==} + peerDependencies: + '@types/react': '*' + react: '*' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + + relay-runtime@12.0.0: + resolution: {integrity: sha512-QU6JKr1tMsry22DXNy9Whsq5rmvwr3LSZiiWV/9+DFpuTWvp+WFhobWMc8TC4OjKFfNhEZy7mOiqUAn5atQtug==} + + remedial@1.0.8: + resolution: {integrity: sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg==} + + remove-trailing-separator@1.1.0: + resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==} + + remove-trailing-spaces@1.0.9: + resolution: {integrity: sha512-xzG7w5IRijvIkHIjDk65URsJJ7k4J95wmcArY5PRcmjldIOl7oTvG8+X2Ag690R7SfwiOcHrWZKVc1Pp5WIOzA==} + + request@2.88.2: + resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} + engines: {node: '>= 6'} + deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfc4648@1.5.4: + resolution: {integrity: sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + scuid@1.1.0: + resolution: {integrity: sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + sentence-case@3.0.4: + resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signedsource@1.0.0: + resolution: {integrity: sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww==} + + size-sensor@1.0.3: + resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slice-ansi@3.0.0: + resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} + engines: {node: '>=8'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + sponge-case@1.0.1: + resolution: {integrity: sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA==} + + sql-formatter@15.7.2: + resolution: {integrity: sha512-b0BGoM81KFRVSpZFwPpIPU5gng4YD8DI/taLD96NXCFRf5af3FzSE4aSwjKmxcyTmf/MfPu91j75883nRrWDBw==} + hasBin: true + + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + + sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + + stream-buffers@3.0.3: + resolution: {integrity: sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==} + engines: {node: '>= 0.10.0'} + + string-env-interpolation@1.0.1: + resolution: {integrity: sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + swap-case@2.0.2: + resolution: {integrity: sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==} + + symbol-observable@4.0.0: + resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} + engines: {node: '>=0.10'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + sync-fetch@0.6.0: + resolution: {integrity: sha512-IELLEvzHuCfc1uTsshPK58ViSdNqXxlml1U+fmwJIKLYKOr/rAtBrorE2RYm5IHaMpDNlmC0fr1LAvdXvyheEQ==} + engines: {node: '>=18'} + + sync-fetch@0.6.0-2: + resolution: {integrity: sha512-c7AfkZ9udatCuAy9RSfiGPpeOKKUAUK5e1cXadLOGUjasdxqYqAK0jTNkM/FSEyJ3a5Ra27j/tw/PS0qLmaF/A==} + engines: {node: '>=18'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + timeout-signal@2.0.0: + resolution: {integrity: sha512-YBGpG4bWsHoPvofT6y/5iqulfXIiIErl5B0LdtHT1mGXDFTAhhRrbUpTvBgYbovr+3cKblya2WAOcpoy90XguA==} + engines: {node: '>=16'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + title-case@3.0.3: + resolution: {integrity: sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tough-cookie@2.5.0: + resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} + engines: {node: '>=0.8'} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + ts-invariant@0.10.3: + resolution: {integrity: sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==} + engines: {node: '>=8'} + + ts-log@2.2.7: + resolution: {integrity: sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg==} + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + tslib@2.4.1: + resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} + + tslib@2.6.3: + resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-parser-js@1.0.41: + resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} + hasBin: true + + unc-path-regex@0.1.2: + resolution: {integrity: sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==} + engines: {node: '>=0.10.0'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unixify@1.0.0: + resolution: {integrity: sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg==} + engines: {node: '>=0.10.0'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + upper-case-first@2.0.2: + resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==} + + upper-case@2.0.2: + resolution: {integrity: sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + + uuid@3.4.0: + resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.2: + resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.2 + '@vitest/browser-preview': 4.1.2 + '@vitest/browser-webdriverio': 4.1.2 + '@vitest/ui': 4.1.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zen-observable-ts@1.2.5: + resolution: {integrity: sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==} + + zen-observable@0.8.15: + resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==} + + zrender@6.0.0: + resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==} + + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@adobe/css-tools@4.4.4': {} + + '@apollo/client@3.13.8(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.11.0)(ws@8.20.0))(graphql@16.11.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.11.0) + '@wry/caches': 1.0.1 + '@wry/equality': 0.5.7 + '@wry/trie': 0.5.0 + graphql: 16.11.0 + graphql-tag: 2.12.6(graphql@16.11.0) + hoist-non-react-statics: 3.3.2 + optimism: 0.18.1 + prop-types: 15.8.1 + rehackt: 0.1.0(@types/react@19.2.14)(react@19.2.0) + symbol-observable: 4.0.0 + ts-invariant: 0.10.3 + tslib: 2.3.0 + zen-observable-ts: 1.2.5 + optionalDependencies: + graphql-ws: 6.0.7(graphql@16.11.0)(ws@8.20.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + transitivePeerDependencies: + - '@types/react' + + '@ardatan/relay-compiler@12.0.0(graphql@16.11.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/runtime': 7.29.2 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + babel-preset-fbjs: 3.4.0(@babel/core@7.29.0) + chalk: 4.1.2 + fb-watchman: 2.0.2 + fbjs: 3.0.5 + glob: 7.2.3 + graphql: 16.11.0 + immutable: 3.7.6 + invariant: 2.2.4 + nullthrows: 1.1.1 + relay-runtime: 12.0.0 + signedsource: 1.0.0 + yargs: 15.4.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@ardatan/relay-compiler@13.0.0(graphql@16.11.0)': + dependencies: + '@babel/runtime': 7.29.2 + graphql: 16.11.0 + immutable: 5.1.5 + invariant: 2.2.4 + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-proposal-class-properties@7.18.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-object-rest-spread@7.20.7(@babel/core@7.29.0)': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-flow@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.28.6 + + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) + + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/css-tokenizer@4.0.0': {} + + '@csstools/postcss-color-mix-function@4.0.6(postcss@8.5.15)': + dependencies: + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.1(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-oklab-function@5.0.5(postcss@8.5.15)': + dependencies: + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-progressive-custom-properties@5.1.0(postcss@8.5.15)': + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-progressive-custom-properties@5.1.1(postcss@8.5.15)': + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + '@csstools/selector-resolve-nested@4.0.0(postcss-selector-parser@7.1.4)': + dependencies: + postcss-selector-parser: 7.1.4 + + '@csstools/selector-specificity@6.0.0(postcss-selector-parser@7.1.4)': + dependencies: + postcss-selector-parser: 7.1.4 + + '@csstools/utilities@3.0.0(postcss@8.5.15)': + dependencies: + postcss: 8.5.15 + + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@envelop/core@5.5.1': + dependencies: + '@envelop/instrumentation': 1.0.0 + '@envelop/types': 5.2.1 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@envelop/instrumentation@1.0.0': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@envelop/types@5.2.1': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + dependencies: + eslint: 9.39.4(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@fastify/busboy@3.2.0': {} + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@floating-ui/utils@0.2.11': {} + + '@graphql-codegen/add@5.0.3(graphql@16.11.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 5.1.1(graphql@16.11.0) + graphql: 16.11.0 + tslib: 2.6.3 + + '@graphql-codegen/cli@5.0.7(@types/node@20.19.37)(graphql@16.11.0)(typescript@5.9.3)': + dependencies: + '@babel/generator': 7.29.1 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@graphql-codegen/client-preset': 4.8.3(graphql@16.11.0) + '@graphql-codegen/core': 4.0.2(graphql@16.11.0) + '@graphql-codegen/plugin-helpers': 5.1.1(graphql@16.11.0) + '@graphql-tools/apollo-engine-loader': 8.0.28(graphql@16.11.0) + '@graphql-tools/code-file-loader': 8.1.28(graphql@16.11.0) + '@graphql-tools/git-loader': 8.0.32(graphql@16.11.0) + '@graphql-tools/github-loader': 8.0.22(@types/node@20.19.37)(graphql@16.11.0) + '@graphql-tools/graphql-file-loader': 8.1.12(graphql@16.11.0) + '@graphql-tools/json-file-loader': 8.0.26(graphql@16.11.0) + '@graphql-tools/load': 8.1.8(graphql@16.11.0) + '@graphql-tools/prisma-loader': 8.0.17(@types/node@20.19.37)(graphql@16.11.0) + '@graphql-tools/url-loader': 8.0.33(@types/node@20.19.37)(graphql@16.11.0) + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + '@whatwg-node/fetch': 0.10.13 + chalk: 4.1.2 + cosmiconfig: 8.3.6(typescript@5.9.3) + debounce: 1.2.1 + detect-indent: 6.1.0 + graphql: 16.11.0 + graphql-config: 5.1.6(@types/node@20.19.37)(graphql@16.11.0)(typescript@5.9.3) + inquirer: 8.2.7(@types/node@20.19.37) + is-glob: 4.0.3 + jiti: 1.21.7 + json-to-pretty-yaml: 1.2.2 + listr2: 4.0.5 + log-symbols: 4.1.0 + micromatch: 4.0.8 + shell-quote: 1.8.3 + string-env-interpolation: 1.0.1 + ts-log: 2.2.7 + tslib: 2.8.1 + yaml: 2.8.3 + yargs: 17.7.2 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - cosmiconfig-toml-loader + - crossws + - encoding + - enquirer + - graphql-sock + - supports-color + - typescript + - utf-8-validate + + '@graphql-codegen/client-preset@4.8.3(graphql@16.11.0)': + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.28.6 + '@graphql-codegen/add': 5.0.3(graphql@16.11.0) + '@graphql-codegen/gql-tag-operations': 4.0.17(graphql@16.11.0) + '@graphql-codegen/plugin-helpers': 5.1.1(graphql@16.11.0) + '@graphql-codegen/typed-document-node': 5.1.2(graphql@16.11.0) + '@graphql-codegen/typescript': 4.1.6(graphql@16.11.0) + '@graphql-codegen/typescript-operations': 4.6.1(graphql@16.11.0) + '@graphql-codegen/visitor-plugin-common': 5.8.0(graphql@16.11.0) + '@graphql-tools/documents': 1.0.1(graphql@16.11.0) + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.11.0) + graphql: 16.11.0 + tslib: 2.6.3 + + '@graphql-codegen/core@4.0.2(graphql@16.11.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 5.1.1(graphql@16.11.0) + '@graphql-tools/schema': 10.0.31(graphql@16.11.0) + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + graphql: 16.11.0 + tslib: 2.6.3 + + '@graphql-codegen/gql-tag-operations@4.0.17(graphql@16.11.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 5.1.1(graphql@16.11.0) + '@graphql-codegen/visitor-plugin-common': 5.8.0(graphql@16.11.0) + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + auto-bind: 4.0.0 + graphql: 16.11.0 + tslib: 2.6.3 + + '@graphql-codegen/plugin-helpers@3.1.2(graphql@16.11.0)': + dependencies: + '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + change-case-all: 1.0.15 + common-tags: 1.8.2 + graphql: 16.11.0 + import-from: 4.0.0 + lodash: 4.17.23 + tslib: 2.4.1 + + '@graphql-codegen/plugin-helpers@5.1.1(graphql@16.11.0)': + dependencies: + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + change-case-all: 1.0.15 + common-tags: 1.8.2 + graphql: 16.11.0 + import-from: 4.0.0 + lodash: 4.17.23 + tslib: 2.6.3 + + '@graphql-codegen/schema-ast@4.1.0(graphql@16.11.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 5.1.1(graphql@16.11.0) + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + graphql: 16.11.0 + tslib: 2.6.3 + + '@graphql-codegen/typed-document-node@5.1.2(graphql@16.11.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 5.1.1(graphql@16.11.0) + '@graphql-codegen/visitor-plugin-common': 5.8.0(graphql@16.11.0) + auto-bind: 4.0.0 + change-case-all: 1.0.15 + graphql: 16.11.0 + tslib: 2.6.3 + + '@graphql-codegen/typescript-operations@4.6.1(graphql@16.11.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 5.1.1(graphql@16.11.0) + '@graphql-codegen/typescript': 4.1.6(graphql@16.11.0) + '@graphql-codegen/visitor-plugin-common': 5.8.0(graphql@16.11.0) + auto-bind: 4.0.0 + graphql: 16.11.0 + tslib: 2.6.3 + + '@graphql-codegen/typescript-react-apollo@4.3.3(graphql@16.11.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.11.0) + '@graphql-codegen/visitor-plugin-common': 2.13.8(graphql@16.11.0) + auto-bind: 4.0.0 + change-case-all: 1.0.15 + graphql: 16.11.0 + tslib: 2.8.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@graphql-codegen/typescript@4.1.6(graphql@16.11.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 5.1.1(graphql@16.11.0) + '@graphql-codegen/schema-ast': 4.1.0(graphql@16.11.0) + '@graphql-codegen/visitor-plugin-common': 5.8.0(graphql@16.11.0) + auto-bind: 4.0.0 + graphql: 16.11.0 + tslib: 2.6.3 + + '@graphql-codegen/visitor-plugin-common@2.13.8(graphql@16.11.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 3.1.2(graphql@16.11.0) + '@graphql-tools/optimize': 1.4.0(graphql@16.11.0) + '@graphql-tools/relay-operation-optimizer': 6.5.18(graphql@16.11.0) + '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + auto-bind: 4.0.0 + change-case-all: 1.0.15 + dependency-graph: 0.11.0 + graphql: 16.11.0 + graphql-tag: 2.12.6(graphql@16.11.0) + parse-filepath: 1.0.2 + tslib: 2.4.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@graphql-codegen/visitor-plugin-common@5.8.0(graphql@16.11.0)': + dependencies: + '@graphql-codegen/plugin-helpers': 5.1.1(graphql@16.11.0) + '@graphql-tools/optimize': 2.0.0(graphql@16.11.0) + '@graphql-tools/relay-operation-optimizer': 7.1.1(graphql@16.11.0) + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + auto-bind: 4.0.0 + change-case-all: 1.0.15 + dependency-graph: 0.11.0 + graphql: 16.11.0 + graphql-tag: 2.12.6(graphql@16.11.0) + parse-filepath: 1.0.2 + tslib: 2.6.3 + + '@graphql-hive/signal@1.0.0': {} + + '@graphql-hive/signal@2.0.0': {} + + '@graphql-tools/apollo-engine-loader@8.0.28(graphql@16.11.0)': + dependencies: + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + '@whatwg-node/fetch': 0.10.13 + graphql: 16.11.0 + sync-fetch: 0.6.0 + tslib: 2.8.1 + + '@graphql-tools/batch-execute@10.0.7(graphql@16.11.0)': + dependencies: + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + '@whatwg-node/promise-helpers': 1.3.2 + dataloader: 2.2.3 + graphql: 16.11.0 + tslib: 2.8.1 + + '@graphql-tools/batch-execute@9.0.19(graphql@16.11.0)': + dependencies: + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + '@whatwg-node/promise-helpers': 1.3.2 + dataloader: 2.2.3 + graphql: 16.11.0 + tslib: 2.8.1 + + '@graphql-tools/code-file-loader@8.1.28(graphql@16.11.0)': + dependencies: + '@graphql-tools/graphql-tag-pluck': 8.3.27(graphql@16.11.0) + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + globby: 11.1.0 + graphql: 16.11.0 + tslib: 2.8.1 + unixify: 1.0.0 + transitivePeerDependencies: + - supports-color + + '@graphql-tools/delegate@10.2.23(graphql@16.11.0)': + dependencies: + '@graphql-tools/batch-execute': 9.0.19(graphql@16.11.0) + '@graphql-tools/executor': 1.5.1(graphql@16.11.0) + '@graphql-tools/schema': 10.0.31(graphql@16.11.0) + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + dataloader: 2.2.3 + dset: 3.1.4 + graphql: 16.11.0 + tslib: 2.8.1 + + '@graphql-tools/delegate@12.0.12(graphql@16.11.0)': + dependencies: + '@graphql-tools/batch-execute': 10.0.7(graphql@16.11.0) + '@graphql-tools/executor': 1.5.1(graphql@16.11.0) + '@graphql-tools/schema': 10.0.31(graphql@16.11.0) + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + dataloader: 2.2.3 + graphql: 16.11.0 + tslib: 2.8.1 + + '@graphql-tools/documents@1.0.1(graphql@16.11.0)': + dependencies: + graphql: 16.11.0 + lodash.sortby: 4.7.0 + tslib: 2.8.1 + + '@graphql-tools/executor-common@0.0.4(graphql@16.11.0)': + dependencies: + '@envelop/core': 5.5.1 + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + graphql: 16.11.0 + + '@graphql-tools/executor-common@0.0.6(graphql@16.11.0)': + dependencies: + '@envelop/core': 5.5.1 + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + graphql: 16.11.0 + + '@graphql-tools/executor-common@1.0.6(graphql@16.11.0)': + dependencies: + '@envelop/core': 5.5.1 + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + graphql: 16.11.0 + + '@graphql-tools/executor-graphql-ws@2.0.7(graphql@16.11.0)': + dependencies: + '@graphql-tools/executor-common': 0.0.6(graphql@16.11.0) + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + '@whatwg-node/disposablestack': 0.0.6 + graphql: 16.11.0 + graphql-ws: 6.0.7(graphql@16.11.0)(ws@8.20.0) + isomorphic-ws: 5.0.0(ws@8.20.0) + tslib: 2.8.1 + ws: 8.20.0 + transitivePeerDependencies: + - '@fastify/websocket' + - bufferutil + - crossws + - utf-8-validate + + '@graphql-tools/executor-graphql-ws@3.1.5(graphql@16.11.0)': + dependencies: + '@graphql-tools/executor-common': 1.0.6(graphql@16.11.0) + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + '@whatwg-node/disposablestack': 0.0.6 + graphql: 16.11.0 + graphql-ws: 6.0.7(graphql@16.11.0)(ws@8.20.0) + isows: 1.0.7(ws@8.20.0) + tslib: 2.8.1 + ws: 8.20.0 + transitivePeerDependencies: + - '@fastify/websocket' + - bufferutil + - crossws + - utf-8-validate + + '@graphql-tools/executor-http@1.3.3(@types/node@20.19.37)(graphql@16.11.0)': + dependencies: + '@graphql-hive/signal': 1.0.0 + '@graphql-tools/executor-common': 0.0.4(graphql@16.11.0) + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.11.0 + meros: 1.3.2(@types/node@20.19.37) + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/node' + + '@graphql-tools/executor-http@3.1.1(@types/node@20.19.37)(graphql@16.11.0)': + dependencies: + '@graphql-hive/signal': 2.0.0 + '@graphql-tools/executor-common': 1.0.6(graphql@16.11.0) + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.11.0 + meros: 1.3.2(@types/node@20.19.37) + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/node' + + '@graphql-tools/executor-legacy-ws@1.1.25(graphql@16.11.0)': + dependencies: + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + '@types/ws': 8.18.1 + graphql: 16.11.0 + isomorphic-ws: 5.0.0(ws@8.20.0) + tslib: 2.8.1 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@graphql-tools/executor@1.5.1(graphql@16.11.0)': + dependencies: + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.11.0) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.11.0 + tslib: 2.8.1 + + '@graphql-tools/git-loader@8.0.32(graphql@16.11.0)': + dependencies: + '@graphql-tools/graphql-tag-pluck': 8.3.27(graphql@16.11.0) + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + graphql: 16.11.0 + is-glob: 4.0.3 + micromatch: 4.0.8 + tslib: 2.8.1 + unixify: 1.0.0 + transitivePeerDependencies: + - supports-color + + '@graphql-tools/github-loader@8.0.22(@types/node@20.19.37)(graphql@16.11.0)': + dependencies: + '@graphql-tools/executor-http': 1.3.3(@types/node@20.19.37)(graphql@16.11.0) + '@graphql-tools/graphql-tag-pluck': 8.3.27(graphql@16.11.0) + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.11.0 + sync-fetch: 0.6.0-2 + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/node' + - supports-color + + '@graphql-tools/graphql-file-loader@8.1.12(graphql@16.11.0)': + dependencies: + '@graphql-tools/import': 7.1.12(graphql@16.11.0) + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + globby: 11.1.0 + graphql: 16.11.0 + tslib: 2.8.1 + unixify: 1.0.0 + + '@graphql-tools/graphql-tag-pluck@8.3.27(graphql@16.11.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + graphql: 16.11.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@graphql-tools/import@7.1.12(graphql@16.11.0)': + dependencies: + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + graphql: 16.11.0 + resolve-from: 5.0.0 + tslib: 2.8.1 + + '@graphql-tools/json-file-loader@8.0.26(graphql@16.11.0)': + dependencies: + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + globby: 11.1.0 + graphql: 16.11.0 + tslib: 2.8.1 + unixify: 1.0.0 + + '@graphql-tools/load@8.1.8(graphql@16.11.0)': + dependencies: + '@graphql-tools/schema': 10.0.31(graphql@16.11.0) + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + graphql: 16.11.0 + p-limit: 3.1.0 + tslib: 2.8.1 + + '@graphql-tools/merge@9.1.7(graphql@16.11.0)': + dependencies: + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + graphql: 16.11.0 + tslib: 2.8.1 + + '@graphql-tools/optimize@1.4.0(graphql@16.11.0)': + dependencies: + graphql: 16.11.0 + tslib: 2.8.1 + + '@graphql-tools/optimize@2.0.0(graphql@16.11.0)': + dependencies: + graphql: 16.11.0 + tslib: 2.6.3 + + '@graphql-tools/prisma-loader@8.0.17(@types/node@20.19.37)(graphql@16.11.0)': + dependencies: + '@graphql-tools/url-loader': 8.0.33(@types/node@20.19.37)(graphql@16.11.0) + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + '@types/js-yaml': 4.0.9 + '@whatwg-node/fetch': 0.10.13 + chalk: 4.1.2 + debug: 4.4.3 + dotenv: 16.6.1 + graphql: 16.11.0 + graphql-request: 6.1.0(graphql@16.11.0) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + jose: 5.10.0 + js-yaml: 4.1.1 + lodash: 4.17.23 + scuid: 1.1.0 + tslib: 2.8.1 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - crossws + - encoding + - supports-color + - utf-8-validate + + '@graphql-tools/relay-operation-optimizer@6.5.18(graphql@16.11.0)': + dependencies: + '@ardatan/relay-compiler': 12.0.0(graphql@16.11.0) + '@graphql-tools/utils': 9.2.1(graphql@16.11.0) + graphql: 16.11.0 + tslib: 2.8.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@graphql-tools/relay-operation-optimizer@7.1.1(graphql@16.11.0)': + dependencies: + '@ardatan/relay-compiler': 13.0.0(graphql@16.11.0) + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + graphql: 16.11.0 + tslib: 2.6.3 + + '@graphql-tools/schema@10.0.31(graphql@16.11.0)': + dependencies: + '@graphql-tools/merge': 9.1.7(graphql@16.11.0) + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + graphql: 16.11.0 + tslib: 2.8.1 + + '@graphql-tools/url-loader@8.0.33(@types/node@20.19.37)(graphql@16.11.0)': + dependencies: + '@graphql-tools/executor-graphql-ws': 2.0.7(graphql@16.11.0) + '@graphql-tools/executor-http': 1.3.3(@types/node@20.19.37)(graphql@16.11.0) + '@graphql-tools/executor-legacy-ws': 1.1.25(graphql@16.11.0) + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + '@graphql-tools/wrap': 10.1.4(graphql@16.11.0) + '@types/ws': 8.18.1 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.11.0 + isomorphic-ws: 5.0.0(ws@8.20.0) + sync-fetch: 0.6.0-2 + tslib: 2.8.1 + ws: 8.20.0 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - crossws + - utf-8-validate + + '@graphql-tools/url-loader@9.0.6(@types/node@20.19.37)(graphql@16.11.0)': + dependencies: + '@graphql-tools/executor-graphql-ws': 3.1.5(graphql@16.11.0) + '@graphql-tools/executor-http': 3.1.1(@types/node@20.19.37)(graphql@16.11.0) + '@graphql-tools/executor-legacy-ws': 1.1.25(graphql@16.11.0) + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + '@graphql-tools/wrap': 11.1.12(graphql@16.11.0) + '@types/ws': 8.18.1 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.11.0 + isomorphic-ws: 5.0.0(ws@8.20.0) + sync-fetch: 0.6.0 + tslib: 2.8.1 + ws: 8.20.0 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - crossws + - utf-8-validate + + '@graphql-tools/utils@10.11.0(graphql@16.11.0)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.11.0) + '@whatwg-node/promise-helpers': 1.3.2 + cross-inspect: 1.0.1 + graphql: 16.11.0 + tslib: 2.8.1 + + '@graphql-tools/utils@11.0.0(graphql@16.11.0)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.11.0) + '@whatwg-node/promise-helpers': 1.3.2 + cross-inspect: 1.0.1 + graphql: 16.11.0 + tslib: 2.6.3 + + '@graphql-tools/utils@9.2.1(graphql@16.11.0)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.11.0) + graphql: 16.11.0 + tslib: 2.8.1 + + '@graphql-tools/wrap@10.1.4(graphql@16.11.0)': + dependencies: + '@graphql-tools/delegate': 10.2.23(graphql@16.11.0) + '@graphql-tools/schema': 10.0.31(graphql@16.11.0) + '@graphql-tools/utils': 10.11.0(graphql@16.11.0) + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.11.0 + tslib: 2.8.1 + + '@graphql-tools/wrap@11.1.12(graphql@16.11.0)': + dependencies: + '@graphql-tools/delegate': 12.0.12(graphql@16.11.0) + '@graphql-tools/schema': 10.0.31(graphql@16.11.0) + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.11.0 + tslib: 2.8.1 + + '@graphql-typed-document-node/core@3.2.0(graphql@16.11.0)': + dependencies: + graphql: 16.11.0 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.9.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@inquirer/external-editor@1.0.3(@types/node@20.19.37)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 20.19.37 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@kubernetes/client-node@0.21.0': + dependencies: + '@types/js-yaml': 4.0.9 + '@types/node': 20.19.37 + '@types/request': 2.48.13 + '@types/ws': 8.18.1 + byline: 5.0.0 + isomorphic-ws: 5.0.0(ws@8.20.0) + js-yaml: 4.1.1 + jsonpath-plus: 8.1.0 + request: 2.88.2 + rfc4648: 1.5.4 + stream-buffers: 3.0.3 + tar: 7.5.22 + tslib: 2.8.1 + ws: 8.20.0 + optionalDependencies: + openid-client: 5.7.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@labring/sealos-desktop-sdk@0.1.23(@kubernetes/client-node@0.21.0)': + dependencies: + '@kubernetes/client-node': 0.21.0 + axios: 1.15.0 + dayjs: 1.11.23 + js-cookie: 3.0.5 + js-yaml: 4.1.1 + uuid: 9.0.1 + transitivePeerDependencies: + - debug + + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.55.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@next/env@16.2.1': {} + + '@next/swc-darwin-arm64@16.2.1': + optional: true + + '@next/swc-darwin-x64@16.2.1': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.1': + optional: true + + '@next/swc-linux-arm64-musl@16.2.1': + optional: true + + '@next/swc-linux-x64-gnu@16.2.1': + optional: true + + '@next/swc-linux-x64-musl@16.2.1': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.1': + optional: true + + '@next/swc-win32-x64-msvc@16.2.1': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/rect': 1.1.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + use-sync-external-store: 1.6.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/rect@1.1.1': {} + + '@repeaterjs/repeater@3.0.6': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.0': + optional: true + + '@rollup/rollup-android-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-x64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.0': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/vite@4.2.2(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': + dependencies: + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/caseless@0.12.5': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/js-yaml@4.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@20.19.37': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react-grid-layout@1.3.6': + dependencies: + '@types/react': 19.2.14 + + '@types/react-resizable@3.0.8': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/request@2.48.13': + dependencies: + '@types/caseless': 0.12.5 + '@types/node': 20.19.37 + '@types/tough-cookie': 4.0.5 + form-data: 2.5.6 + + '@types/tough-cookie@4.0.5': {} + + '@types/trusted-types@2.0.7': + optional: true + + '@types/uuid@10.0.0': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.37 + + '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.1.2': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.2(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + + '@vitest/pretty-format@4.1.2': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.2': + dependencies: + '@vitest/utils': 4.1.2 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + '@vitest/utils': 4.1.2 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.2': {} + + '@vitest/utils@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@whatwg-node/disposablestack@0.0.6': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/fetch@0.10.13': + dependencies: + '@whatwg-node/node-fetch': 0.8.5 + urlpattern-polyfill: 10.1.0 + + '@whatwg-node/node-fetch@0.8.5': + dependencies: + '@fastify/busboy': 3.2.0 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + + '@whatwg-node/promise-helpers@1.3.2': + dependencies: + tslib: 2.6.3 + + '@wry/caches@1.0.1': + dependencies: + tslib: 2.3.0 + + '@wry/context@0.7.4': + dependencies: + tslib: 2.3.0 + + '@wry/equality@0.5.7': + dependencies: + tslib: 2.3.0 + + '@wry/trie@0.5.0': + dependencies: + tslib: 2.3.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + adler-32@1.3.1: {} + + agent-base@7.1.4: {} + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + array-union@2.1.0: {} + + asap@2.0.6: {} + + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + assert-plus@1.0.0: {} + + assertion-error@2.0.1: {} + + astral-regex@2.0.0: {} + + asynckit@0.4.0: {} + + auto-bind@4.0.0: {} + + aws-sign2@0.7.0: {} + + aws4@1.13.2: {} + + axios@1.15.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + + babel-plugin-syntax-trailing-function-commas@7.0.0-beta.0: {} + + babel-preset-fbjs@3.4.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.29.0) + '@babel/plugin-proposal-object-rest-spread': 7.20.7(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) + babel-plugin-syntax-trailing-function-commas: 7.0.0-beta.0 + transitivePeerDependencies: + - supports-color + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.8: {} + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.8 + caniuse-lite: 1.0.30001780 + electron-to-chromium: 1.5.321 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + byline@5.0.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + camel-case@4.1.2: + dependencies: + pascal-case: 3.1.2 + tslib: 2.8.1 + + camelcase@5.3.1: {} + + caniuse-lite@1.0.30001780: {} + + capital-case@1.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + upper-case-first: 2.0.2 + + caseless@0.12.0: {} + + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + change-case-all@1.0.15: + dependencies: + change-case: 4.1.2 + is-lower-case: 2.0.2 + is-upper-case: 2.0.2 + lower-case: 2.0.2 + lower-case-first: 2.0.2 + sponge-case: 1.0.1 + swap-case: 2.0.2 + title-case: 3.0.3 + upper-case: 2.0.2 + upper-case-first: 2.0.2 + + change-case@4.1.2: + dependencies: + camel-case: 4.1.2 + capital-case: 1.0.4 + constant-case: 3.0.4 + dot-case: 3.0.4 + header-case: 2.0.4 + no-case: 3.0.4 + param-case: 3.0.4 + pascal-case: 3.1.2 + path-case: 3.0.4 + sentence-case: 3.0.4 + snake-case: 3.0.4 + tslib: 2.8.1 + + chardet@2.1.1: {} + + chownr@3.0.0: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clean-stack@2.2.0: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + cli-truncate@2.1.0: + dependencies: + slice-ansi: 3.0.0 + string-width: 4.2.3 + + cli-width@3.0.0: {} + + client-only@0.0.1: {} + + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + clsx@2.1.1: {} + + codepage@1.15.0: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@2.20.3: {} + + common-tags@1.8.2: {} + + concat-map@0.0.1: {} + + constant-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + upper-case: 2.0.2 + + convert-source-map@2.0.0: {} + + core-util-is@1.0.2: {} + + core-util-is@1.0.3: {} + + cosmiconfig@8.3.6(typescript@5.9.3): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.9.3 + + crc-32@1.2.2: {} + + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + cross-inspect@1.0.1: + dependencies: + tslib: 2.6.3 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css.escape@1.5.1: {} + + cssesc@3.0.0: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + dashdash@1.14.1: + dependencies: + assert-plus: 1.0.0 + + data-uri-to-buffer@4.0.1: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + dataloader@2.2.3: {} + + dayjs@1.11.23: {} + + debounce@1.2.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decamelize@1.2.0: {} + + decimal.js@10.6.0: {} + + deep-is@0.1.4: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + delayed-stream@1.0.0: {} + + dependency-graph@0.11.0: {} + + dequal@2.0.3: {} + + detect-indent@6.1.0: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + discontinuous-range@1.0.0: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dot-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + dotenv@16.6.1: {} + + dset@3.1.4: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecc-jsbn@0.1.2: + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + + echarts-for-react@3.0.6(echarts@6.0.0)(react@19.2.0): + dependencies: + echarts: 6.0.0 + fast-deep-equal: 3.1.3 + react: 19.2.0 + size-sensor: 1.0.3 + + echarts@6.0.0: + dependencies: + tslib: 2.3.0 + zrender: 6.0.0 + + electron-to-chromium@1.5.321: {} + + emoji-regex@8.0.0: {} + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + entities@6.0.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.0.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + escalade@3.2.0: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.39.4(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + expect-type@1.3.0: {} + + extend@3.0.2: {} + + extsprintf@1.3.0: {} + + fast-deep-equal@3.1.3: {} + + fast-equals@4.0.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fbjs-css-vars@1.0.2: {} + + fbjs@3.0.5: + dependencies: + cross-fetch: 3.2.0 + fbjs-css-vars: 1.0.2 + loose-envify: 1.4.0 + object-assign: 4.1.1 + promise: 7.3.1 + setimmediate: 1.0.5 + ua-parser-js: 1.0.41 + transitivePeerDependencies: + - encoding + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + follow-redirects@1.16.0: {} + + forever-agent@0.6.1: {} + + form-data@2.3.3: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + + form-data@2.5.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + safe-buffer: 5.2.1 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + frac@1.1.2: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + geist@1.7.0(next@16.2.1(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)): + dependencies: + next: 16.2.1(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + getpass@0.1.7: + dependencies: + assert-plus: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@14.0.0: {} + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphql-config@5.1.6(@types/node@20.19.37)(graphql@16.11.0)(typescript@5.9.3): + dependencies: + '@graphql-tools/graphql-file-loader': 8.1.12(graphql@16.11.0) + '@graphql-tools/json-file-loader': 8.0.26(graphql@16.11.0) + '@graphql-tools/load': 8.1.8(graphql@16.11.0) + '@graphql-tools/merge': 9.1.7(graphql@16.11.0) + '@graphql-tools/url-loader': 9.0.6(@types/node@20.19.37)(graphql@16.11.0) + '@graphql-tools/utils': 11.0.0(graphql@16.11.0) + cosmiconfig: 8.3.6(typescript@5.9.3) + graphql: 16.11.0 + jiti: 2.6.1 + minimatch: 10.2.4 + string-env-interpolation: 1.0.1 + tslib: 2.8.1 + transitivePeerDependencies: + - '@fastify/websocket' + - '@types/node' + - bufferutil + - crossws + - typescript + - utf-8-validate + + graphql-request@6.1.0(graphql@16.11.0): + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.11.0) + cross-fetch: 3.2.0 + graphql: 16.11.0 + transitivePeerDependencies: + - encoding + + graphql-tag@2.12.6(graphql@16.11.0): + dependencies: + graphql: 16.11.0 + tslib: 2.3.0 + + graphql-ws@6.0.7(graphql@16.11.0)(ws@8.20.0): + dependencies: + graphql: 16.11.0 + optionalDependencies: + ws: 8.20.0 + + graphql@16.11.0: {} + + har-schema@2.0.0: {} + + har-validator@5.1.5: + dependencies: + ajv: 6.14.0 + har-schema: 2.0.0 + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + header-case@2.0.4: + dependencies: + capital-case: 1.0.4 + tslib: 2.8.1 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + http-signature@1.2.0: + dependencies: + assert-plus: 1.0.0 + jsprim: 1.4.2 + sshpk: 1.18.0 + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + immediate@3.0.6: {} + + immutable@3.7.6: {} + + immutable@5.1.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-from@4.0.0: {} + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + inquirer@8.2.7(@types/node@20.19.37): + dependencies: + '@inquirer/external-editor': 1.0.3(@types/node@20.19.37) + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + figures: 3.2.0 + lodash: 4.17.23 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.8.2 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 6.2.0 + transitivePeerDependencies: + - '@types/node' + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + + is-absolute@1.0.0: + dependencies: + is-relative: 1.0.0 + is-windows: 1.0.2 + + is-arrayish@0.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-lower-case@2.0.2: + dependencies: + tslib: 2.8.1 + + is-number@7.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-relative@1.0.0: + dependencies: + is-unc-path: 1.0.0 + + is-typedarray@1.0.0: {} + + is-unc-path@1.0.0: + dependencies: + unc-path-regex: 0.1.2 + + is-unicode-supported@0.1.0: {} + + is-upper-case@2.0.2: + dependencies: + tslib: 2.8.1 + + is-windows@1.0.2: {} + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + isomorphic-ws@5.0.0(ws@8.20.0): + dependencies: + ws: 8.20.0 + + isows@1.0.7(ws@8.20.0): + dependencies: + ws: 8.20.0 + + isstream@0.1.2: {} + + jiti@1.21.7: {} + + jiti@2.6.1: {} + + jose@4.15.9: + optional: true + + jose@5.10.0: {} + + js-cookie@3.0.5: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsbn@0.1.1: {} + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.20.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema@0.4.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json-stringify-safe@5.0.1: {} + + json-to-pretty-yaml@1.2.2: + dependencies: + remedial: 1.0.8 + remove-trailing-spaces: 1.0.9 + + json5@2.2.3: {} + + jsonpath-plus@8.1.0: {} + + jsprim@1.4.2: + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + listr2@4.0.5: + dependencies: + cli-truncate: 2.1.0 + colorette: 2.0.20 + log-update: 4.0.0 + p-map: 4.0.0 + rfdc: 1.4.1 + rxjs: 7.8.2 + through: 2.3.8 + wrap-ansi: 7.0.0 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lodash.sortby@4.7.0: {} + + lodash@4.17.23: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-update@4.0.0: + dependencies: + ansi-escapes: 4.3.2 + cli-cursor: 3.1.0 + slice-ansi: 4.0.0 + wrap-ansi: 6.2.0 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lower-case-first@2.0.2: + dependencies: + tslib: 2.8.1 + + lower-case@2.0.2: + dependencies: + tslib: 2.8.1 + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + optional: true + + lucide-react@0.555.0(react@19.2.0): + dependencies: + react: 19.2.0 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + map-cache@0.2.2: {} + + marked@14.0.0: {} + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + meros@1.3.2(@types/node@20.19.37): + optionalDependencies: + '@types/node': 20.19.37 + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-fn@2.1.0: {} + + min-indent@1.0.1: {} + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + + moo@0.5.3: {} + + ms@2.1.3: {} + + mute-stream@0.0.8: {} + + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} + + nearley@2.20.1: + dependencies: + commander: 2.20.3 + moo: 0.5.3 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + + next@16.2.1(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@next/env': 16.2.1 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.8 + caniuse-lite: 1.0.30001780 + postcss: 8.4.31 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.0) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.1 + '@next/swc-darwin-x64': 16.2.1 + '@next/swc-linux-arm64-gnu': 16.2.1 + '@next/swc-linux-arm64-musl': 16.2.1 + '@next/swc-linux-x64-gnu': 16.2.1 + '@next/swc-linux-x64-musl': 16.2.1 + '@next/swc-win32-arm64-msvc': 16.2.1 + '@next/swc-win32-x64-msvc': 16.2.1 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + no-case@3.0.4: + dependencies: + lower-case: 2.0.2 + tslib: 2.8.1 + + node-domexception@1.0.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-int64@0.4.0: {} + + node-releases@2.0.36: {} + + normalize-path@2.1.1: + dependencies: + remove-trailing-separator: 1.1.0 + + nullthrows@1.1.1: {} + + nwsapi@2.2.23: {} + + oauth-sign@0.9.0: {} + + object-assign@4.1.1: {} + + object-hash@2.2.0: + optional: true + + obug@2.1.1: {} + + oidc-token-hash@5.2.0: + optional: true + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + openid-client@5.7.1: + dependencies: + jose: 4.15.9 + lru-cache: 6.0.0 + object-hash: 2.2.0 + oidc-token-hash: 5.2.0 + optional: true + + optimism@0.18.1: + dependencies: + '@wry/caches': 1.0.1 + '@wry/context': 0.7.4 + '@wry/trie': 0.5.0 + tslib: 2.3.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + p-try@2.2.0: {} + + pako@1.0.11: {} + + param-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-filepath@1.0.2: + dependencies: + is-absolute: 1.0.0 + map-cache: 0.2.2 + path-root: 0.1.1 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + pascal-case@3.1.2: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + path-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-root-regex@0.1.2: {} + + path-root@0.1.1: + dependencies: + path-root-regex: 0.1.2 + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + performance-now@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.3: {} + + postcss-media-minmax@5.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-nesting@14.0.0(postcss@8.5.15): + dependencies: + '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.4) + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.4) + postcss: 8.5.15 + postcss-selector-parser: 7.1.4 + + postcss-selector-parser@7.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + process-nextick-args@2.0.1: {} + + promise@7.3.1: + dependencies: + asap: 2.0.6 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-from-env@2.1.0: {} + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + punycode@2.3.1: {} + + qs@6.5.5: {} + + queue-microtask@1.2.3: {} + + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + railroad-diagrams@1.0.0: {} + + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + + react-dom@19.2.0(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + + react-draggable@4.5.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + clsx: 2.1.1 + prop-types: 15.8.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + react-grid-layout@1.5.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + clsx: 2.1.1 + fast-equals: 4.0.3 + prop-types: 15.8.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-draggable: 4.5.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-resizable: 3.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + resize-observer-polyfill: 1.5.1 + + react-is@16.13.1: {} + + react-is@17.0.2: {} + + react-refresh@0.17.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.0): + dependencies: + react: 19.2.0 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.0) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.0): + dependencies: + react: 19.2.0 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.0) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.0) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.0) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.14 + + react-resizable@3.1.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + prop-types: 15.8.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-draggable: 4.5.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.0): + dependencies: + get-nonce: 1.0.1 + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react@19.2.0: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + rehackt@0.1.0(@types/react@19.2.14)(react@19.2.0): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.0 + + relay-runtime@12.0.0: + dependencies: + '@babel/runtime': 7.29.2 + fbjs: 3.0.5 + invariant: 2.2.4 + transitivePeerDependencies: + - encoding + + remedial@1.0.8: {} + + remove-trailing-separator@1.1.0: {} + + remove-trailing-spaces@1.0.9: {} + + request@2.88.2: + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 2.3.3 + har-validator: 5.1.5 + http-signature: 1.2.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + oauth-sign: 0.9.0 + performance-now: 2.1.0 + qs: 6.5.5 + safe-buffer: 5.2.1 + tough-cookie: 2.5.0 + tunnel-agent: 0.6.0 + uuid: 3.4.0 + + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + + resize-observer-polyfill@1.5.1: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + ret@0.1.15: {} + + reusify@1.1.0: {} + + rfc4648@1.5.4: {} + + rfdc@1.4.1: {} + + rollup@4.60.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 + fsevents: 2.3.3 + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + run-async@2.4.1: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + scuid@1.1.0: {} + + semver@6.3.1: {} + + semver@7.7.4: + optional: true + + sentence-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + upper-case-first: 2.0.2 + + set-blocking@2.0.0: {} + + setimmediate@1.0.5: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.3: {} + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signedsource@1.0.0: {} + + size-sensor@1.0.3: {} + + slash@3.0.0: {} + + slice-ansi@3.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + snake-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + source-map-js@1.2.1: {} + + sponge-case@1.0.1: + dependencies: + tslib: 2.8.1 + + sql-formatter@15.7.2: + dependencies: + argparse: 2.0.1 + nearley: 2.20.1 + + ssf@0.11.2: + dependencies: + frac: 1.1.2 + + sshpk@1.18.0: + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + + stackback@0.0.2: {} + + state-local@1.0.7: {} + + std-env@4.0.0: {} + + stream-buffers@3.0.3: {} + + string-env-interpolation@1.0.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.0): + dependencies: + client-only: 0.0.1 + react: 19.2.0 + optionalDependencies: + '@babel/core': 7.29.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + swap-case@2.0.2: + dependencies: + tslib: 2.8.1 + + symbol-observable@4.0.0: {} + + symbol-tree@3.2.4: {} + + sync-fetch@0.6.0: + dependencies: + node-fetch: 3.3.2 + timeout-signal: 2.0.0 + whatwg-mimetype: 4.0.0 + + sync-fetch@0.6.0-2: + dependencies: + node-fetch: 3.3.2 + timeout-signal: 2.0.0 + whatwg-mimetype: 4.0.0 + + tailwind-merge@3.5.0: {} + + tailwindcss@4.2.1: {} + + tailwindcss@4.2.2: {} + + tapable@2.3.0: {} + + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + through@2.3.8: {} + + timeout-signal@2.0.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.0.4: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.1.0: {} + + title-case@3.0.3: + dependencies: + tslib: 2.8.1 + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tough-cookie@2.5.0: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@0.0.3: {} + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-invariant@0.10.3: + dependencies: + tslib: 2.3.0 + + ts-log@2.2.7: {} + + tslib@2.3.0: {} + + tslib@2.4.1: {} + + tslib@2.6.3: {} + + tslib@2.8.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + tw-animate-css@1.4.0: {} + + tweetnacl@0.14.5: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.21.3: {} + + typescript@5.9.3: {} + + ua-parser-js@1.0.41: {} + + unc-path-regex@0.1.2: {} + + undici-types@6.21.0: {} + + unixify@1.0.0: + dependencies: + normalize-path: 2.1.1 + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + upper-case-first@2.0.2: + dependencies: + tslib: 2.8.1 + + upper-case@2.0.2: + dependencies: + tslib: 2.8.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + urlpattern-polyfill@10.1.0: {} + + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.0): + dependencies: + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.0): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sync-external-store@1.6.0(react@19.2.0): + dependencies: + react: 19.2.0 + + util-deprecate@1.0.2: {} + + uuid@13.0.0: {} + + uuid@3.4.0: {} + + uuid@9.0.1: {} + + verror@1.10.0: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + + vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3): + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.15 + rollup: 4.60.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.37 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + yaml: 2.8.3 + + vitest@4.1.2(@types/node@20.19.37)(jsdom@25.0.1)(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.37 + jsdom: 25.0.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + web-streams-polyfill@3.3.3: {} + + webidl-conversions@3.0.1: {} + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-module@2.0.1: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wmf@1.0.2: {} + + word-wrap@1.2.5: {} + + word@0.3.0: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@8.20.0: {} + + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + y18n@4.0.3: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: + optional: true + + yallist@5.0.0: {} + + yaml-ast-parser@0.0.43: {} + + yaml@2.8.3: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs-parser@21.1.1: {} + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zen-observable-ts@1.2.5: + dependencies: + zen-observable: 0.8.15 + + zen-observable@0.8.15: {} + + zrender@6.0.0: + dependencies: + tslib: 2.3.0 + + zustand@5.0.12(@types/react@19.2.14)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.0 + use-sync-external-store: 1.6.0(react@19.2.0) diff --git a/dataflow/dataflow/postcss.config.cjs b/dataflow/dataflow/postcss.config.cjs new file mode 100644 index 0000000..9d0b0bb --- /dev/null +++ b/dataflow/dataflow/postcss.config.cjs @@ -0,0 +1,274 @@ +/** @type {import('postcss-load-config').Config} */ +const postcss = require('postcss'); +const valueParser = require('postcss-value-parser'); + +const propertyInjectPlugin = () => { + return { + Once(root) { + const fallbackRules = []; + + root.walkAtRules('property', (rule) => { + let varName = null; + let initialValue = null; + + rule.walkDecls((decl) => { + if (decl.prop === 'initial-value') { + varName = rule.params.trim(); + initialValue = decl.value; + } + }); + + if (varName && initialValue) { + fallbackRules.push({ prop: varName, value: initialValue }); + } + }); + + if (fallbackRules.length > 0) { + const fallbackAtRule = postcss.atRule({ + name: 'supports', + params: 'not (background: paint(something))', + }); + const fallbackRootRule = postcss.rule({ selector: ':root' }); + + fallbackRules.forEach(({ prop, value }) => { + fallbackRootRule.append({ prop, value }); + }); + + fallbackAtRule.append(fallbackRootRule); + + let lastImportIndex = -1; + root.nodes.forEach((node, i) => { + if (node.type === 'atrule' && node.name === 'import') { + lastImportIndex = i; + } + }); + + if (lastImportIndex === -1) { + root.prepend(fallbackAtRule); + } else { + root.insertAfter(root.nodes[lastImportIndex], fallbackAtRule); + } + } + + root.walkDecls((decl) => { + if (!decl.value) return; + + decl.value = decl.value.replaceAll(/\bto\s+(left|right)\s+in\s+[\w-]+/g, (_, direction) => { + return `to ${direction}`; + }); + }); + }, + postcssPlugin: 'postcss-property-polyfill', + }; +}; + +propertyInjectPlugin.postcss = true; + +const colorMixVarResolverPlugin = () => { + return { + Once(root) { + const cssVariables = {}; + + root.walkRules((rule) => { + if (!rule.selectors) return; + + const isRootOrHost = rule.selectors.some((selector) => { + return selector.includes(':root') || selector.includes(':host'); + }); + + if (isRootOrHost) { + rule.walkDecls((decl) => { + if (decl.prop.startsWith('--')) { + cssVariables[decl.prop] = decl.value.trim(); + } + }); + } + }); + + root.walkDecls((decl) => { + const originalValue = decl.value; + if (!originalValue || !originalValue.includes('color-mix(')) return; + + const parsed = valueParser(originalValue); + let modified = false; + + parsed.walk((node) => { + if (node.type === 'function' && node.value === 'color-mix') { + node.nodes.forEach((childNode) => { + if (childNode.type === 'function' && childNode.value === 'var' && childNode.nodes.length > 0) { + const varName = childNode.nodes[0]?.value; + if (!varName) return; + + const resolvedVarName = cssVariables[varName] === undefined ? 'black' : cssVariables[varName]; + + // No trailing space: it would produce ` ` (double space) inside + // color-mix(), which @csstools/postcss-color-mix-function fails to parse, leaving + // the color-mix() uncomputed. + childNode.type = 'word'; + childNode.value = resolvedVarName; + childNode.nodes = []; + modified = true; + } + }); + } + }); + + if (modified) { + decl.value = parsed.toString(); + } + }); + }, + postcssPlugin: 'postcss-color-mix-var-resolver', + }; +}; + +colorMixVarResolverPlugin.postcss = true; + +const transformShortcutPlugin = () => { + return { + Once(root) { + const defaults = { + rotate: [0, 0, 1, '0deg'], + scale: [1, 1, 1], + translate: [0, 0, 0], + }; + + const fallbackAtRule = postcss.atRule({ + name: 'supports', + params: 'not (translate: 0)', + }); + + root.walkRules((rule) => { + let hasTransformShorthand = false; + const transformFunctions = []; + + rule.walkDecls((decl) => { + if (/^(rotate|scale|translate)$/.test(decl.prop)) { + hasTransformShorthand = true; + + const newValues = [...defaults[decl.prop]]; + const value = decl.value.replaceAll(/\)\s*var\(/g, ') var('); + const userValues = postcss.list.space(value); + + if (decl.prop === 'rotate' && userValues.length === 1) { + newValues.splice(-1, 1, ...userValues); + } else { + newValues.splice(0, userValues.length, ...userValues); + } + + transformFunctions.push(`${decl.prop}3d(${newValues.join(',')})`); + } + }); + + if (hasTransformShorthand && transformFunctions.length > 0) { + const fallbackRule = postcss.rule({ selector: rule.selector }); + + fallbackRule.append({ + prop: 'transform', + value: transformFunctions.join(' '), + }); + + fallbackAtRule.append(fallbackRule); + } + }); + + if (fallbackAtRule.nodes && fallbackAtRule.nodes.length > 0) { + root.append(fallbackAtRule); + } + }, + postcssPlugin: 'postcss-transform-shortcut', + }; +}; + +transformShortcutPlugin.postcss = true; + +const addSpaceForEmptyVarFallback = () => { + return { + OnceExit(root) { + root.walkDecls((decl) => { + if (!decl.value || !decl.value.includes('var(')) { + return; + } + + const parsed = valueParser(decl.value); + let changed = false; + + parsed.walk((node) => { + if (node.type === 'function' && node.value === 'var') { + const commaIndex = node.nodes.findIndex((n) => { + return n.type === 'div' && n.value === ','; + }); + + if (commaIndex === -1) return; + + const fallbackNodes = node.nodes.slice(commaIndex + 1); + const fallbackText = fallbackNodes.map((n) => n.value).join('').trim(); + + if (fallbackText === '') { + const commaNode = node.nodes[commaIndex]; + if (commaNode.value === ',') { + commaNode.value = ', '; + changed = true; + } + } + } + }); + + if (changed) { + decl.value = parsed.toString(); + } + }); + }, + postcssPlugin: 'postcss-add-space-for-empty-var-fallback', + }; +}; + +addSpaceForEmptyVarFallback.postcss = true; + +// Tailwind v4 emits opacity-modified colors (e.g. `bg-warning/5`) as a solid `var(--color)` +// base declaration plus an `@supports (color: color-mix(...))` block holding the real +// translucent value. Browsers without color-mix() (Chrome < 111) fail the @supports test and +// fall back to the solid base, so the color renders at full opacity. Unwrap these guards so the +// resolved value applies unconditionally and wins by source order over the solid base. Runs as +// Once — before @csstools/postcss-color-mix-function below — because that plugin deliberately +// skips declarations nested inside a color-mix() feature query, so the guard must be gone first. +const flattenColorMixSupports = () => { + return { + postcssPlugin: 'postcss-flatten-color-mix-supports', + Once(root) { + root.walkAtRules('supports', (atRule) => { + if (!atRule.params.includes('color-mix(')) { + return; + } + + if (atRule.nodes && atRule.nodes.length > 0) { + atRule.replaceWith(...atRule.nodes); + } else { + atRule.remove(); + } + }); + }, + }; +}; + +flattenColorMixSupports.postcss = true; + +const config = { + plugins: [ + propertyInjectPlugin(), + colorMixVarResolverPlugin(), + // Unwrap Tailwind's `@supports (color-mix())` guards before computing color-mix() below. + flattenColorMixSupports(), + // Resolve color-mix() to a static rgb()/rgba(). Runs before postcss-oklab-function: oklab + // splits each color into an rgb + color(display-p3) pair, and color-mix-function leaves the + // display-p3 half as an uncomputed color-mix(), so the color-mix() must be collapsed first. + require('@csstools/postcss-color-mix-function')({ preserve: false }), + transformShortcutPlugin(), + addSpaceForEmptyVarFallback(), + require('postcss-media-minmax'), + require('@csstools/postcss-oklab-function'), + require('postcss-nesting'), + ], +}; + +module.exports = config; diff --git a/dataflow/dataflow/public/favicon.svg b/dataflow/dataflow/public/favicon.svg new file mode 100644 index 0000000..acb9c23 --- /dev/null +++ b/dataflow/dataflow/public/favicon.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dataflow/dataflow/public/fonts/geist-mono/GeistMono-Variable.woff2 b/dataflow/dataflow/public/fonts/geist-mono/GeistMono-Variable.woff2 new file mode 100644 index 0000000..dbdb8c2 Binary files /dev/null and b/dataflow/dataflow/public/fonts/geist-mono/GeistMono-Variable.woff2 differ diff --git a/dataflow/dataflow/public/fonts/geist-sans/Geist-Variable.woff2 b/dataflow/dataflow/public/fonts/geist-sans/Geist-Variable.woff2 new file mode 100644 index 0000000..b2f0121 Binary files /dev/null and b/dataflow/dataflow/public/fonts/geist-sans/Geist-Variable.woff2 differ diff --git a/dataflow/dataflow/public/images/empty_box.png b/dataflow/dataflow/public/images/empty_box.png new file mode 100644 index 0000000..b447c75 Binary files /dev/null and b/dataflow/dataflow/public/images/empty_box.png differ diff --git a/dataflow/dataflow/public/images/mongodb.svg b/dataflow/dataflow/public/images/mongodb.svg new file mode 100644 index 0000000..f92c67c --- /dev/null +++ b/dataflow/dataflow/public/images/mongodb.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/dataflow/dataflow/public/images/mysql.svg b/dataflow/dataflow/public/images/mysql.svg new file mode 100644 index 0000000..b0fc90f --- /dev/null +++ b/dataflow/dataflow/public/images/mysql.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/dataflow/dataflow/public/images/postgresql.svg b/dataflow/dataflow/public/images/postgresql.svg new file mode 100644 index 0000000..d9f1121 --- /dev/null +++ b/dataflow/dataflow/public/images/postgresql.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/dataflow/dataflow/public/images/redis.svg b/dataflow/dataflow/public/images/redis.svg new file mode 100644 index 0000000..11dc7b1 --- /dev/null +++ b/dataflow/dataflow/public/images/redis.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/dataflow/dataflow/skills-lock.json b/dataflow/dataflow/skills-lock.json new file mode 100644 index 0000000..5728809 --- /dev/null +++ b/dataflow/dataflow/skills-lock.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "skills": { + "frontend-design": { + "source": "anthropics/skills", + "sourceType": "github", + "computedHash": "063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67" + }, + "implement-design": { + "source": "figma/mcp-server-guide", + "sourceType": "github", + "computedHash": "2e87927b93592fbc1cf48a58d6359d2894ad5e7611f7e824233bf4f6bb84db61" + }, + "ui-ux-pro-max": { + "source": "nextlevelbuilder/ui-ux-pro-max-skill", + "sourceType": "github", + "computedHash": "0a413bf988d06481f69bb81df2070741c3ba12dd9f1be2706d57f259c905992d" + }, + "vercel-composition-patterns": { + "source": "vercel-labs/agent-skills", + "sourceType": "github", + "computedHash": "f98931159fa9c7fed043bcd18a891a46dcf89ababa38df13a4c5b7b30dc0ce07" + }, + "vercel-react-best-practices": { + "source": "vercel-labs/agent-skills", + "sourceType": "github", + "computedHash": "afeca00ac2f492d88fdd010e73d91719a5b57c564bb4a73325b0bd77e11323ab" + } + } +} diff --git a/dataflow/dataflow/src/components/analysis/AnalysisView.tsx b/dataflow/dataflow/src/components/analysis/AnalysisView.tsx new file mode 100644 index 0000000..016ed8c --- /dev/null +++ b/dataflow/dataflow/src/components/analysis/AnalysisView.tsx @@ -0,0 +1,65 @@ +import { useEffect } from "react"; +import { DashboardEditor } from "./editor"; +import { useAnalysisDefinitionStore } from "@/stores/analysisDefinitionStore"; +import { useI18n } from '@/i18n/useI18n' +import { LayoutDashboard } from 'lucide-react'; + +export function AnalysisView() { + const activeDashboardId = useAnalysisDefinitionStore(state => state.activeDashboardId); + const isInitialized = useAnalysisDefinitionStore(state => state.isInitialized); + const loadError = useAnalysisDefinitionStore(state => state.loadError); + const initializeFromAPI = useAnalysisDefinitionStore(state => state.initializeFromAPI); + const { t } = useI18n() + + useEffect(() => { + if (!isInitialized) { + void initializeFromAPI(); + } + }, [isInitialized, initializeFromAPI]); + + if (loadError) { + return ( +
+
+

{t('analysis.dashboard.emptyTitle')}

+

{loadError}

+
+
+ ); + } + + return ( +
+ {activeDashboardId ? ( + + ) : ( +
+ +

{t('analysis.dashboard.emptyTitle')}

+

{t('analysis.dashboard.emptyDescription')}

+
+ )} +
+ ); +} diff --git a/dataflow/dataflow/src/components/analysis/chart-create/ChartConfigPanel.tsx b/dataflow/dataflow/src/components/analysis/chart-create/ChartConfigPanel.tsx new file mode 100644 index 0000000..aa17ae9 --- /dev/null +++ b/dataflow/dataflow/src/components/analysis/chart-create/ChartConfigPanel.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { ChevronDown, Check } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { cn } from '@/lib/utils'; +import type { ChartType, SortTarget, SortOrder } from '../chart-utils'; +import { useChartCreateCtx } from './ChartCreateProvider'; +import { useI18n } from '@/i18n/useI18n' + +/** Right-column configuration panel for chart creation: type, axes, options, and sort settings. */ +export function ChartConfigPanel() { + const { t } = useI18n() + const { chartConfig: config, queryData, handleConfigChange: onConfigChange, setActiveView } = useChartCreateCtx() + const columns = queryData?.columns ?? [] + + const chartTypeLabels: Record = { + bar: t('analysis.chart.type.bar'), + line: t('analysis.chart.type.line'), + pie: t('analysis.chart.type.pie'), + area: t('analysis.chart.type.area'), + }; + + const yAxisAvailableColumns = columns.filter(col => col !== config.xAxisColumn); + + const toggleYAxisColumn = (col: string) => { + const current = config.yAxisColumns; + const next = current.includes(col) + ? current.filter(c => c !== col) + : [...current, col]; + onConfigChange({ yAxisColumns: next }); + }; + + return ( +
+ + {/* 1. Data Configuration button */} +
+ + {queryData && ( +

+ {t('analysis.chart.dataStatus', { + columns: queryData.columns.length, + rows: queryData.rows.length, + })} +

+ )} +
+ + {/* 2. Chart Type selector */} +
+ + +
+ + {/* 3. X-Axis selector */} +
+ + +
+ + {/* 4. Y-Axis multi-select */} +
+ + + + + + + {yAxisAvailableColumns.map((col) => ( + toggleYAxisColumn(col)} + > + {col} + + ))} + + +
+ + {/* 5. Chart Options */} +
+ + {( + [ + { key: 'showLegend', label: t('analysis.chart.options.legend') }, + { key: 'showGridLines', label: t('analysis.chart.options.gridLines') }, + { key: 'showDataLabels', label: t('analysis.chart.options.dataLabels') }, + ] as const + ).map(({ key, label }) => ( +
+ onConfigChange({ options: { ...config.options, [key]: !config.options[key] } })} + /> + +
+ ))} +
+ + {/* 6. Sort By */} +
+ + onConfigChange({ sortBy: value as SortTarget })} + > + {( + [ + { value: 'data', label: t('analysis.chart.sort.dataOrder') }, + { value: 'xAxis', label: t('analysis.chart.sort.xAxisValue') }, + { value: 'yAxis', label: t('analysis.chart.sort.yAxisValue') }, + ] as { value: SortTarget; label: string }[] + ).map(({ value, label }) => ( +
+
+ + +
+ + {/* Sort order sub-options when xAxis or yAxis selected */} + {config.sortBy === value && value !== 'data' && ( + onConfigChange({ sortOrder: v as SortOrder })} + className="ml-6 mt-3 flex gap-2" + > + {( + [ + { value: 'asc', label: t('analysis.chart.sort.ascending') }, + { value: 'desc', label: t('analysis.chart.sort.descending') }, + ] as { value: SortOrder; label: string }[] + ).map(order => ( +
+ + +
+ ))} +
+ )} +
+ ))} +
+
+ +
+ ); +} diff --git a/dataflow/dataflow/src/components/analysis/chart-create/ChartCreateModal.tsx b/dataflow/dataflow/src/components/analysis/chart-create/ChartCreateModal.tsx new file mode 100644 index 0000000..e12cbb0 --- /dev/null +++ b/dataflow/dataflow/src/components/analysis/chart-create/ChartCreateModal.tsx @@ -0,0 +1,179 @@ +import { BarChart3, ChevronLeft, Plus } from 'lucide-react' +import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { SafeECharts } from '@/components/ui/SafeECharts' +import { SQLEditorView } from '@/components/editor/SQLEditorView' +import { ChartConfigPanel } from './ChartConfigPanel' +import { ChartCreateProvider, useChartCreateCtx, type ChartCreateInitialData } from './ChartCreateProvider' +import { useAnalysisDefinitionStore, type ChartWidgetDefinition } from '@/stores/analysisDefinitionStore' +import { useI18n } from '@/i18n/useI18n' + +interface ChartCreateModalProps { + open: boolean + onOpenChange: (open: boolean) => void + editComponentId?: string | null + initialQuery?: string + initialData?: ChartCreateInitialData +} + +/** Modal for creating or editing chart widgets with two views: chart configuration and SQL data. */ +export function ChartCreateModal({ open, onOpenChange, editComponentId, initialQuery, initialData }: ChartCreateModalProps) { + const { t } = useI18n() + const dashboards = useAnalysisDefinitionStore(state => state.dashboards) + const activeDashboardId = useAnalysisDefinitionStore(state => state.activeDashboardId) + + const dashboard = dashboards.find(d => d.id === activeDashboardId) + const editComponent: ChartWidgetDefinition | null = editComponentId + ? dashboard?.widgets.find(c => c.id === editComponentId) ?? null + : null + + return ( + + + onOpenChange(false)} + > + + + + + ) +} + +/** Routes to the active wizard view. */ +function ChartCreateContent() { + const { activeView } = useChartCreateCtx() + return activeView === 'chart-config' ? : +} + +function ChartConfigView() { + const { t } = useI18n() + const { + title, setTitle, previewOption, canSave, handleSave, isEditing, + dashboards, selectedDashboardId, setSelectedDashboardId, + } = useChartCreateCtx() + + return ( + <> + + + + {isEditing ? t('analysis.chart.edit') : t('analysis.chart.create')} + + + + {/* Two-column content */} +
+ {/* Left column: Title + Dashboard + Chart Preview */} +
+
+ setTitle(e.target.value)} + placeholder={t('analysis.chart.titlePlaceholder')} + className="flex-1" + /> + {!isEditing && ( + + )} +
+
+ {previewOption ? ( + + ) : ( +
+ +

{t('analysis.chart.previewHint')}

+
+ )} +
+
+ + {/* Right column: Config Panel */} +
+ +
+
+ + {/* Footer */} +
+ + + + +
+ + ) +} + +function DataConfigView() { + const { t } = useI18n() + const { setActiveView, sqlQuery, setSqlQuery, handleQueryResults, editorContext } = useChartCreateCtx() + + return ( + <> + + + {t('analysis.chart.dataConfiguration')} + + + + + {/* Embedded SQL Editor. + tabId uses a sentinel value -- SQLEditorView calls updateTab() internally + for database/schema changes, but this is a no-op since no tab with this ID + exists in the tab store. Database/schema state is tracked by the editor's + own local state (selectedDatabase, selectedSchema). */} +
+ +
+ + ) +} diff --git a/dataflow/dataflow/src/components/analysis/chart-create/ChartCreateProvider.tsx b/dataflow/dataflow/src/components/analysis/chart-create/ChartCreateProvider.tsx new file mode 100644 index 0000000..40f9ccb --- /dev/null +++ b/dataflow/dataflow/src/components/analysis/chart-create/ChartCreateProvider.tsx @@ -0,0 +1,308 @@ +import { createContext, use, useRef, useState, useEffect, useCallback, type ReactNode } from 'react' + +import { useAnalysisDefinitionStore, type DashboardDefinition, type ChartWidgetDefinition, type WidgetLayout } from '@/stores/analysisDefinitionStore' +import { useAnalysisRuntimeStore } from '@/stores/analysisRuntimeStore' +import { useConnectionStore } from '@/stores/useConnectionStore' +import { useLayoutStore } from '@/stores/useLayoutStore' +import { useTabStore } from '@/stores/useTabStore' +import { useWorkspaceTabLeaveGuard } from '@/components/layout/useWorkspaceTabLeaveGuard' +import { + DEFAULT_CHART_CONFIG, + buildEChartsOption, + toWidgetConfig, + fromWidgetConfig, + type ChartConfig, + type QueryData, +} from '../chart-utils' + +type ModalView = 'chart-config' | 'data-config' + +/** Pre-loaded data and connection context from a data view toolbar. */ +export interface ChartCreateInitialData { + connectionId: string + databaseName: string + schemaName?: string + query: string + columns: string[] + rows: Record[] +} + +interface ChartCreateCtxValue { + activeView: ModalView + title: string + chartConfig: ChartConfig + queryData: QueryData | null + sqlQuery: string + isEditing: boolean + canSave: boolean + previewOption: ReturnType + editorContext: { connectionId: string; databaseName: string; schemaName?: string } | null + dashboards: DashboardDefinition[] + selectedDashboardId: string + setSelectedDashboardId: (id: string) => void + setActiveView: (view: ModalView) => void + setTitle: (title: string) => void + handleConfigChange: (updates: Partial) => void + setSqlQuery: (sql: string) => void + handleQueryResults: ( + columns: string[], + rows: Record[], + ctx: { database?: string; schema?: string }, + ) => void + handleSave: () => void +} + +const ChartCreateCtx = createContext(null) + +export function useChartCreateCtx(): ChartCreateCtxValue { + const ctx = use(ChartCreateCtx) + if (!ctx) throw new Error('useChartCreateCtx must be used within ChartCreateProvider') + return ctx +} + +interface ChartCreateProviderProps { + editComponent?: ChartWidgetDefinition | null + initialQuery?: string + initialData?: ChartCreateInitialData + onClose: () => void + children: ReactNode +} + +function getNextWidgetLayout(widgets: ChartWidgetDefinition[]): WidgetLayout { + const width = 4 + const height = 6 + + if (widgets.length === 0) { + return { i: crypto.randomUUID(), x: 0, y: 0, w: width, h: height } + } + + const sorted = [...widgets].sort((a, b) => { + if (a.layout.y !== b.layout.y) return a.layout.y - b.layout.y + return a.layout.x - b.layout.x + }) + const last = sorted[sorted.length - 1]! + + const maxY = widgets.reduce((currentMax, widget) => ( + Math.max(currentMax, widget.layout.y + widget.layout.h) + ), 0) + + if (last.layout.x + last.layout.w + width <= 12) { + return { + i: crypto.randomUUID(), + x: last.layout.x + last.layout.w, + y: last.layout.y, + w: width, + h: height, + } + } + + return { i: crypto.randomUUID(), x: 0, y: maxY, w: width, h: height } +} + +export function ChartCreateProvider({ editComponent, initialQuery, initialData, onClose, children }: ChartCreateProviderProps) { + const addWidget = useAnalysisDefinitionStore(state => state.addWidget) + const updateWidget = useAnalysisDefinitionStore(state => state.updateWidget) + const createDashboard = useAnalysisDefinitionStore(state => state.createDashboard) + const dashboards = useAnalysisDefinitionStore(state => state.dashboards) + const activeDashboardId = useAnalysisDefinitionStore(state => state.activeDashboardId) + const isInitialized = useAnalysisDefinitionStore(state => state.isInitialized) + const initializeFromAPI = useAnalysisDefinitionStore(state => state.initializeFromAPI) + const openDashboard = useAnalysisDefinitionStore(state => state.openDashboard) + const { connections } = useConnectionStore() + const tabs = useTabStore(state => state.tabs) + const { confirmWorkspaceTabLeave, leaveGuardDialog } = useWorkspaceTabLeaveGuard() + + useEffect(() => { + if (!isInitialized) void initializeFromAPI() + }, [isInitialized, initializeFromAPI]) + + const isEditing = !!editComponent + const initialQueryData = editComponent ? fromWidgetConfig(editComponent) : null + + const [activeView, setActiveView] = useState('chart-config') + const [title, setTitle] = useState(editComponent?.title ?? '') + const [chartConfig, setChartConfig] = useState( + editComponent?.visualization?.chartConfig ?? DEFAULT_CHART_CONFIG, + ) + const chartConfigRef = useRef(editComponent?.visualization?.chartConfig ?? DEFAULT_CHART_CONFIG) + const [queryData, setQueryData] = useState( + initialQueryData ?? (initialData ? { + columns: initialData.columns, + rows: initialData.rows, + query: initialData.query, + database: initialData.databaseName, + schema: initialData.schemaName, + } : null), + ) + const initialSql = editComponent?.query ?? initialData?.query ?? initialQuery ?? '' + const [sqlQuery, setSqlQueryState] = useState(initialSql) + const sqlQueryRef = useRef(initialSql) + const [selectedDashboardId, setSelectedDashboardId] = useState(null) + const effectiveDashboardId = selectedDashboardId ?? activeDashboardId ?? dashboards[0]?.id ?? '__new__' + + const connection = connections[0] + const editorContext = initialData + ? { connectionId: initialData.connectionId, databaseName: initialData.databaseName, schemaName: initialData.schemaName } + : connection + ? { connectionId: connection.id, databaseName: connection.database } + : null + + const setSqlQuery = useCallback((sql: string) => { + sqlQueryRef.current = sql + setSqlQueryState(sql) + }, []) + + const handleConfigChange = useCallback((updates: Partial) => { + const prev = chartConfigRef.current + const next = { + ...prev, + ...updates, + options: updates.options ? { ...prev.options, ...updates.options } : prev.options, + } + if (updates.xAxisColumn && updates.xAxisColumn !== prev.xAxisColumn) { + next.yAxisColumns = next.yAxisColumns.filter(col => col !== updates.xAxisColumn) + } + chartConfigRef.current = next + setChartConfig(next) + }, []) + + const handleQueryResults = useCallback(( + columns: string[], + rows: Record[], + ctx: { database?: string; schema?: string }, + ) => { + setQueryData({ + columns, + rows, + query: sqlQueryRef.current, + database: ctx.database, + schema: ctx.schema, + }) + setChartConfig(prev => { + const colSet = new Set(columns) + const base = chartConfigRef.current + const next = { + ...prev, + ...base, + xAxisColumn: colSet.has(base.xAxisColumn) ? base.xAxisColumn : '', + yAxisColumns: base.yAxisColumns.filter(column => colSet.has(column)), + } + chartConfigRef.current = next + return next + }) + }, []) + + const canSave = title.trim() !== '' + && queryData !== null + && chartConfig.xAxisColumn !== '' + && chartConfig.yAxisColumns.length > 0 + + const previewOption = buildEChartsOption(chartConfig, queryData) + + const handleSave = useCallback(async () => { + if (!queryData || !title.trim()) return + + if (!editComponent && initialData) { + const confirmed = await confirmWorkspaceTabLeave(tabs) + if (!confirmed) return + } + + const { config, data } = toWidgetConfig(chartConfig, queryData) + const executedAt = new Date().toISOString() + const payload = { + title: title.trim(), + query: queryData.query, + queryContext: { database: queryData.database, schema: queryData.schema }, + visualization: { chartConfig }, + snapshot: { config, data, executedAt }, + } + + let widgetId = editComponent?.id + let targetId: string | undefined + if (editComponent) { + await updateWidget(editComponent.id, { + ...payload, + layout: editComponent.layout, + sortOrder: editComponent.sortOrder, + }) + } else if (effectiveDashboardId) { + targetId = effectiveDashboardId + let targetWidgets: ChartWidgetDefinition[] = [] + if (targetId === '__new__') { + const created = await createDashboard(title.trim()) + targetId = created.id + } else { + targetWidgets = dashboards.find(d => d.id === targetId)?.widgets ?? [] + } + const widget = await addWidget(targetId, { + ...payload, + layout: getNextWidgetLayout(targetWidgets), + sortOrder: targetWidgets.length, + }) + widgetId = widget.id + } + + if (widgetId) { + useAnalysisRuntimeStore.setState(state => ({ + widgetStatesById: { + ...state.widgetStatesById, + [widgetId]: { + status: 'success', + config, + data, + executedAt, + isStale: false, + }, + }, + })) + } + + onClose() + + if (!editComponent && initialData && targetId) { + openDashboard(targetId) + useLayoutStore.getState().setActiveTab('analysis') + } + }, [ + queryData, + title, + editComponent, + initialData, + chartConfig, + updateWidget, + effectiveDashboardId, + confirmWorkspaceTabLeave, + tabs, + createDashboard, + dashboards, + addWidget, + onClose, + openDashboard, + ]) + + return ( + + {children} + {leaveGuardDialog} + + ) +} diff --git a/dataflow/dataflow/src/components/analysis/chart-create/index.ts b/dataflow/dataflow/src/components/analysis/chart-create/index.ts new file mode 100644 index 0000000..647ca36 --- /dev/null +++ b/dataflow/dataflow/src/components/analysis/chart-create/index.ts @@ -0,0 +1,2 @@ +export { ChartCreateModal } from './ChartCreateModal' +export type { ChartCreateInitialData } from './ChartCreateProvider' diff --git a/dataflow/dataflow/src/components/analysis/chart-utils.ts b/dataflow/dataflow/src/components/analysis/chart-utils.ts new file mode 100644 index 0000000..8245d84 --- /dev/null +++ b/dataflow/dataflow/src/components/analysis/chart-utils.ts @@ -0,0 +1,313 @@ +export type ChartType = 'bar' | 'line' | 'pie' | 'area'; + +export type SortTarget = 'data' | 'xAxis' | 'yAxis'; +export type SortOrder = 'asc' | 'desc'; + +export interface ChartOptions { + showLegend: boolean; + showGridLines: boolean; + showDataLabels: boolean; +} + +export interface ChartConfig { + chartType: ChartType; + xAxisColumn: string; + yAxisColumns: string[]; + options: ChartOptions; + sortBy: SortTarget; + sortOrder: SortOrder; +} + +export interface QueryData { + columns: string[]; + rows: Record[]; + query: string; + database?: string; + schema?: string; +} + +export const DEFAULT_CHART_CONFIG: ChartConfig = { + chartType: 'bar', + xAxisColumn: '', + yAxisColumns: [], + options: { + showLegend: true, + showGridLines: true, + showDataLabels: false, + }, + sortBy: 'data', + sortOrder: 'asc', +}; + +/** Sort query rows based on chart config sort settings. */ +export function sortQueryRows( + rows: Record[], + config: ChartConfig, +): Record[] { + if (config.sortBy === 'data' || !rows.length) return rows; + + const column = config.sortBy === 'xAxis' + ? config.xAxisColumn + : config.yAxisColumns[0]; + + if (!column) return rows; + + const sorted = [...rows].sort((a, b) => { + const va = a[column]; + const vb = b[column]; + const na = Number(va); + const nb = Number(vb); + if (!isNaN(na) && !isNaN(nb)) return na - nb; + return String(va ?? '').localeCompare(String(vb ?? '')); + }); + + return config.sortOrder === 'desc' ? sorted.reverse() : sorted; +} + +/** Build an ECharts option from chart config + query data (for preview). */ +export function buildEChartsOption( + config: ChartConfig, + queryData: QueryData | null, +): any | null { + if (!queryData || !config.xAxisColumn || config.yAxisColumns.length === 0) { + return null; + } + + const sortedRows = sortQueryRows(queryData.rows, config); + const xAxisData = sortedRows.map(row => String(row[config.xAxisColumn] ?? '')); + + const isPie = config.chartType === 'pie'; + + if (isPie) { + const yCol = config.yAxisColumns[0]; + return { + tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, + legend: config.options.showLegend + ? { orient: 'horizontal', bottom: 0 } + : undefined, + series: [{ + type: 'pie', + radius: ['40%', '70%'], + itemStyle: { borderRadius: 5, borderColor: '#fff', borderWidth: 2 }, + data: sortedRows.map((row, i) => ({ + value: Number(row[yCol]) || 0, + name: xAxisData[i], + })), + label: { + show: config.options.showDataLabels, + formatter: '{b}', + }, + }], + }; + } + + // Non-pie charts (bar, line, area) + const series = config.yAxisColumns.map(col => { + const seriesType = config.chartType === 'area' ? 'line' : config.chartType; + return { + name: col, + type: seriesType, + data: sortedRows.map(row => Number(row[col]) || 0), + ...(config.chartType === 'area' ? { areaStyle: { opacity: 0.2 } } : {}), + ...(config.chartType === 'bar' ? { itemStyle: { borderRadius: [4, 4, 0, 0] } } : {}), + symbol: seriesType === 'line' ? 'circle' : undefined, + symbolSize: 6, + label: config.options.showDataLabels + ? { show: true, position: 'top' } + : undefined, + }; + }); + + return { + tooltip: { trigger: 'axis' }, + legend: config.options.showLegend + ? { data: config.yAxisColumns, bottom: 0 } + : undefined, + grid: { left: '3%', right: '4%', bottom: config.options.showLegend ? '12%' : '3%', top: '10%', containLabel: true }, + xAxis: { + type: 'category', + data: xAxisData, + axisLine: { show: false }, + axisTick: { show: false }, + }, + yAxis: { + type: 'value', + splitLine: { show: config.options.showGridLines, lineStyle: { type: 'dashed' } }, + }, + series, + }; +} + +/** + * Build an ECharts option from a stored widget config. + * Single source of truth for chart rendering — used by DashboardWidget, + * MaximizeChartModal, and any other component that renders a saved chart. + * + * Reads `config.chartConfig.options` (legend, grid lines, data labels) when + * available (charts created via ChartCreateModal), with sensible defaults + * for legacy charts that lack it. + */ +export function buildWidgetChartOption(config: any): any | null { + if (!config?.series) return null; + + const chartOpts = config.chartConfig as ChartConfig | undefined; + const showLegend = chartOpts?.options?.showLegend ?? false; + const showGridLines = chartOpts?.options?.showGridLines ?? true; + const showDataLabels = chartOpts?.options?.showDataLabels ?? false; + + const isPie = config.type === 'pie'; + + if (isPie) { + return { + tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, + legend: showLegend ? { orient: 'horizontal', bottom: 0 } : undefined, + series: config.series.map((s: any) => ({ + name: s.name, + type: 'pie', + radius: ['40%', '70%'], + itemStyle: { borderRadius: 5, borderColor: '#fff', borderWidth: 2 }, + data: Array.isArray(s.data) + ? s.data.map((d: any, i: number) => { + if (typeof d === 'object' && d.value !== undefined) return d; + return { value: d, name: config.xAxis?.[i] || `Item ${i + 1}` }; + }) + : [], + label: { show: showDataLabels, formatter: '{b}' }, + emphasis: { + itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' }, + }, + })), + }; + } + + // Non-pie charts (bar, line, area) + const isHorizontal = config.direction === 'horizontal'; + const useDualAxis = config.series.some((s: any) => s.yAxisIndex === 1); + + const series = config.series.map((s: any) => { + let seriesType = s.type || config.type || 'bar'; + let extra: any = {}; + if (seriesType === 'area') { + seriesType = 'line'; + extra = { areaStyle: { opacity: 0.2 } }; + } + return { + ...s, + type: seriesType, + ...extra, + itemStyle: seriesType === 'bar' + ? { borderRadius: isHorizontal ? [0, 4, 4, 0] : [4, 4, 0, 0], ...s.itemStyle } + : s.itemStyle, + symbol: seriesType === 'line' ? 'circle' : undefined, + symbolSize: 6, + label: showDataLabels + ? { show: true, position: isHorizontal ? 'right' : 'top' } + : undefined, + }; + }); + + const option: any = { + tooltip: { trigger: 'axis' }, + legend: showLegend + ? { data: config.series.map((s: any) => s.name), bottom: 0 } + : undefined, + grid: { + left: '3%', right: '4%', + bottom: showLegend ? '12%' : '3%', + top: '10%', containLabel: true, + }, + series, + }; + + if (isHorizontal) { + option.xAxis = { type: 'value', splitLine: { show: showGridLines, lineStyle: { type: 'dashed' } } }; + option.yAxis = { type: 'category', data: config.xAxis || [], axisLine: { show: false }, axisTick: { show: false } }; + } else { + option.xAxis = { type: 'category', data: config.xAxis || [], axisLine: { show: false }, axisTick: { show: false } }; + option.yAxis = useDualAxis + ? [{ type: 'value', splitLine: { show: showGridLines, lineStyle: { type: 'dashed' } } }, { type: 'value' }] + : { type: 'value', splitLine: { show: showGridLines, lineStyle: { type: 'dashed' } } }; + } + + return option; +} + +/** + * Reconstruct QueryData from a stored widget component for editing. + * Reverses `toWidgetConfig` by reading xAxis labels and series data + * back into column/row form that the chart config panel needs. + */ +export function fromWidgetConfig(component: { + visualization?: { chartConfig?: ChartConfig }; + snapshot?: { config?: any }; + query?: string; + queryContext?: { database?: string; schema?: string }; +}): QueryData | null { + const chartConfig = component.visualization?.chartConfig as ChartConfig | undefined; + const storedConfig = component.snapshot?.config; + if (!chartConfig || !storedConfig?.series) return null; + + const xAxisColumn = chartConfig.xAxisColumn; + const xAxisData: string[] = storedConfig.xAxis || []; + const series: any[] = storedConfig.series; + const isPie = storedConfig.type === 'pie'; + + const columns = [xAxisColumn, ...chartConfig.yAxisColumns]; + const rows: Record[] = xAxisData.map((xVal, i) => { + const row: Record = { [xAxisColumn]: xVal }; + for (const s of series) { + if (isPie) { + const pieItem = s.data?.[i]; + row[s.name] = pieItem?.value ?? pieItem ?? 0; + } else { + row[s.name] = s.data?.[i] ?? 0; + } + } + return row; + }); + + return { + columns, + rows, + query: component.query || '', + database: component.queryContext?.database, + schema: component.queryContext?.schema, + }; +} + +/** + * Convert ChartConfig + QueryData into the config/data shape + * that DashboardWidget's WidgetContent expects for type='chart'. + * + * WidgetContent expects: + * config.type: 'bar' | 'line' | 'pie' | 'area' + * config.series: Array<{ name, type, data, areaStyle? }> + * config.xAxis: string[] (category labels) + */ +export function toWidgetConfig( + config: ChartConfig, + queryData: QueryData, +): { config: any; data: any } { + const sortedRows = sortQueryRows(queryData.rows, config); + const xAxisData = sortedRows.map(row => String(row[config.xAxisColumn] ?? '')); + + const series = config.yAxisColumns.map(col => { + const seriesType = config.chartType === 'area' ? 'line' : config.chartType; + return { + name: col, + type: seriesType, + data: sortedRows.map(row => Number(row[col]) || 0), + ...(config.chartType === 'area' ? { areaStyle: { opacity: 0.2 } } : {}), + }; + }); + + return { + config: { + type: config.chartType, + series, + xAxis: xAxisData, + chartConfig: config, + }, + data: {}, + }; +} diff --git a/dataflow/dataflow/src/components/analysis/editor/DashboardEditor.tsx b/dataflow/dataflow/src/components/analysis/editor/DashboardEditor.tsx new file mode 100644 index 0000000..f31e33f --- /dev/null +++ b/dataflow/dataflow/src/components/analysis/editor/DashboardEditor.tsx @@ -0,0 +1,205 @@ +import { useCallback, useEffect, useRef } from "react"; +import { Plus, Layout, RefreshCw } from "lucide-react"; + +import { useAnalysisDefinitionStore } from "@/stores/analysisDefinitionStore"; +import { useAnalysisRuntimeStore } from "@/stores/analysisRuntimeStore"; +import { useAnalysisUIStore } from "@/stores/analysisUiStore"; +import { Button } from "@/components/ui/Button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useI18n } from '@/i18n/useI18n' + +import { EditorCanvas } from "./EditorCanvas"; +import { ChartCreateModal } from "../chart-create/ChartCreateModal"; +import { MaximizeChartModal } from "./MaximizeChartModal"; +import { DeleteComponentModal } from "./DeleteComponentModal"; + +export function DashboardEditor() { + const { t } = useI18n() + const dashboards = useAnalysisDefinitionStore(state => state.dashboards); + const activeDashboardId = useAnalysisDefinitionStore(state => state.activeDashboardId); + const refreshDashboard = useAnalysisRuntimeStore(state => state.refreshDashboard); + + const isChartModalOpen = useAnalysisUIStore(state => state.isChartModalOpen); + const editingWidgetId = useAnalysisUIStore(state => state.editingWidgetId); + const maximizedWidgetId = useAnalysisUIStore(state => state.maximizedWidgetId); + const deletingWidgetId = useAnalysisUIStore(state => state.deletingWidgetId); + const openCreateChartModal = useAnalysisUIStore(state => state.openCreateChartModal); + const openEditChartModal = useAnalysisUIStore(state => state.openEditChartModal); + const setChartModalOpen = useAnalysisUIStore(state => state.setChartModalOpen); + const setMaximizedWidgetId = useAnalysisUIStore(state => state.setMaximizedWidgetId); + const setDeletingWidgetId = useAnalysisUIStore(state => state.setDeletingWidgetId); + + const dashboard = dashboards.find(d => d.id === activeDashboardId); + const dashboardId = dashboard?.id; + + const handleRefresh = useCallback(async () => { + if (!dashboardId) return; + await refreshDashboard(dashboardId); + }, [dashboardId, refreshDashboard]); + + const handleRefreshRef = useRef(handleRefresh); + handleRefreshRef.current = handleRefresh; + + useEffect(() => { + if (!dashboardId) return; + void handleRefresh(); + }, [dashboardId, handleRefresh]); + + useEffect(() => { + if (dashboard?.refreshRule !== 'by-minute') return; + const id = setInterval(() => { void handleRefreshRef.current() }, 60_000); + return () => clearInterval(id); + }, [dashboard?.refreshRule]); + + if (!dashboard) return null; + + const handleEditComponent = (id: string) => { + openEditChartModal(id); + }; + + return ( +
0 ? 'ready' : 'empty'} + data-qa-resource-type="dashboard" + data-qa-resource-id={dashboard.id} + > +
+
+
+
+ {dashboard.name} +
+ {dashboard.description && ( +
+ {dashboard.description} +
+ )} +
+
+ + +
+ + + + + {t('analysis.dashboard.refresh')} + + + + + + {t('analysis.chart.add')} + +
+
+
+ +
0 ? 'ready' : 'empty'} + > + {dashboard.widgets.length > 0 ? ( + + ) : ( +
+
+ +
+

+ {t('analysis.editor.emptyTitle')} +

+

+ {t('analysis.editor.emptyDescription')} +

+ +
+ )} +
+ + { + if (!open) { + setChartModalOpen(false); + } + }} + editComponentId={editingWidgetId} + /> + + { if (!open) setMaximizedWidgetId(null) }} + componentId={maximizedWidgetId} + /> + + { if (!open) setDeletingWidgetId(null) }} + componentId={deletingWidgetId ?? ''} + onSuccess={() => setDeletingWidgetId(null)} + /> +
+ ); +} diff --git a/dataflow/dataflow/src/components/analysis/editor/DashboardWidget.tsx b/dataflow/dataflow/src/components/analysis/editor/DashboardWidget.tsx new file mode 100644 index 0000000..cbed355 --- /dev/null +++ b/dataflow/dataflow/src/components/analysis/editor/DashboardWidget.tsx @@ -0,0 +1,270 @@ +import React, { useState, useRef, useEffect } from "react"; +import { createPortal } from "react-dom"; +import type { ChartWidgetDefinition } from "@/stores/analysisDefinitionStore"; +import { useAnalysisDefinitionStore } from "@/stores/analysisDefinitionStore"; +import { useAnalysisRuntimeStore } from "@/stores/analysisRuntimeStore"; +import { cn } from "@/lib/utils"; +import { GripHorizontal, MoreVertical, Trash2, Maximize2, Settings, ImageDown } from "lucide-react"; +import { SafeECharts, NativeEChartsHandle } from "@/components/ui/SafeECharts"; +import { buildWidgetChartOption } from "../chart-utils"; +import { downloadBlob } from "@/utils/export-utils"; +import { ContextMenu } from "../../ui/ContextMenu"; +import { useI18n } from '@/i18n/useI18n' + +interface DashboardWidgetProps { + widget: ChartWidgetDefinition; + isReadOnly: boolean; + isSelected: boolean; + onEdit?: (id: string) => void; + onMaximize?: (id: string) => void; + onDelete?: (id: string) => void; + onSelect: (id: string) => void; +} + +export function DashboardWidget({ + widget, + isReadOnly, + isSelected, + onEdit, + onMaximize, + onDelete, + onSelect +}: DashboardWidgetProps) { + const { t } = useI18n() + const runtimeState = useAnalysisRuntimeStore(state => state.widgetStatesById[widget.id]); + const updateWidget = useAnalysisDefinitionStore(state => state.updateWidget); + const [contextMenu, setContextMenu] = useState<{ + x: number; y: number; + side: "top" | "right" | "bottom" | "left"; + align: "start" | "end"; + } | null>(null); + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(widget.title); + const renameInputRef = useRef(null); + const chartRef = useRef(null); + + useEffect(() => { + if (isRenaming) { + renameInputRef.current?.focus(); + renameInputRef.current?.select(); + } + }, [isRenaming]); + + const commitRename = () => { + const trimmed = renameValue.trim(); + if (trimmed && trimmed !== widget.title) { + void updateWidget(widget.id, { title: trimmed }); + } else { + setRenameValue(widget.title); + } + setIsRenaming(false); + }; + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY, side: "bottom", align: "start" }); + }; + + const handleExportPNG = async () => { + setContextMenu(null); + const blob = await chartRef.current?.exportPNG(2); + if (!blob) return; + downloadBlob(blob, `${widget.title || t('analysis.defaultTitle.chart')}.png`); + }; + + const menuItems = [ + { + label: t('analysis.widget.maximize'), + icon: , + onClick: () => onMaximize?.(widget.id) + }, + ...(widget.type === 'chart' ? [{ + label: t('analysis.chart.exportPng'), + icon: , + onClick: handleExportPNG + }] : []), + { + label: t('analysis.widget.settings'), + icon: , + onClick: () => onEdit?.(widget.id) + }, + { + label: t('analysis.widget.delete'), + icon: , + danger: true, + onClick: () => onDelete?.(widget.id) + } + ]; + + return ( +
{ + e.stopPropagation(); + onSelect(widget.id); + }} + onContextMenu={!isReadOnly ? handleContextMenu : undefined} + > + {/* Widget Header */} +
+
{ + if (isReadOnly) return; + setRenameValue(widget.title); + setIsRenaming(true); + }} + > + {!isReadOnly && isRenaming ? ( + setRenameValue(e.target.value)} + onBlur={commitRename} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') { + setRenameValue(widget.title); + setIsRenaming(false); + } + }} + onMouseDown={(e) => e.stopPropagation()} + className="text-sm text-foreground bg-transparent outline-none w-full" + /> + ) : ( + {widget.title} + )} +
+ + {!isReadOnly && ( + + )} + + {!isReadOnly && ( + + )} +
+ + {/* Widget Content */} +
+
+ +
+
+ + {runtimeState?.status === 'error' && ( +
+ {runtimeState.error} +
+ )} + + + {/* Portal Context Menu — must escape react-grid-layout's CSS transform */} + {contextMenu && createPortal( + setContextMenu(null)} + />, + document.body + )} +
+ ); +} + +function WidgetContent({ + widget, + chartRef, + runtimeState, +}: { + widget: ChartWidgetDefinition; + chartRef: React.RefObject; + runtimeState?: { status: 'idle' | 'loading' | 'success' | 'error'; config?: any; isStale: boolean }; +}) { + const { t } = useI18n() + switch (widget.type) { + case 'chart': { + const config = runtimeState?.status === 'success' && !runtimeState.isStale + ? runtimeState.config + : widget.snapshot?.config; + const option = buildWidgetChartOption(config); + if (!option) return
{t('analysis.chart.noData')}
; + return ( + + ); + } + + default: + return ( +
+ {t('analysis.widget.unknownType', { type: widget.type })} +
+ ); + } +} diff --git a/dataflow/dataflow/src/components/analysis/editor/DeleteComponentModal.tsx b/dataflow/dataflow/src/components/analysis/editor/DeleteComponentModal.tsx new file mode 100644 index 0000000..d258bda --- /dev/null +++ b/dataflow/dataflow/src/components/analysis/editor/DeleteComponentModal.tsx @@ -0,0 +1,82 @@ +import { useCallback, type ReactNode } from 'react' +import { Trash2 } from 'lucide-react' +import { useAnalysisDefinitionStore } from '@/stores/analysisDefinitionStore' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { ModalForm } from '@/components/ui/ModalForm' +import { useI18n } from '@/i18n/useI18n' + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +/** Owns business logic for deleting a dashboard component. */ +function DeleteComponentProvider({ + componentId, + onSuccess, + children, +}: { + componentId: string + onSuccess?: () => void + children: ReactNode +}) { + const { t } = useI18n() + const deleteWidget = useAnalysisDefinitionStore(state => state.deleteWidget) + + const handleSubmit = useCallback(async () => { + await deleteWidget(componentId) + onSuccess?.() + }, [deleteWidget, componentId, onSuccess]) + + return ( + + {children} + + ) +} + +// --------------------------------------------------------------------------- +// Modal +// --------------------------------------------------------------------------- + +interface DeleteComponentModalProps { + open: boolean + onOpenChange: (open: boolean) => void + componentId: string + onSuccess?: () => void +} + +/** Modal for confirming deletion of a dashboard component. */ +export function DeleteComponentModal({ + open, + onOpenChange, + componentId, + onSuccess, +}: DeleteComponentModalProps) { + const handleSuccess = useCallback(() => { + onSuccess?.() + onOpenChange(false) + }, [onSuccess, onOpenChange]) + + return ( + + + + + + + + + + + + + ) +} diff --git a/dataflow/dataflow/src/components/analysis/editor/EditorCanvas.tsx b/dataflow/dataflow/src/components/analysis/editor/EditorCanvas.tsx new file mode 100644 index 0000000..21b5e50 --- /dev/null +++ b/dataflow/dataflow/src/components/analysis/editor/EditorCanvas.tsx @@ -0,0 +1,102 @@ +import { useMemo } from "react"; +import { Responsive, WidthProvider } from "react-grid-layout"; +import { useAnalysisDefinitionStore, type DashboardDefinition } from "@/stores/analysisDefinitionStore"; +import { useAnalysisUIStore } from "@/stores/analysisUiStore"; +import { DashboardWidget } from "./DashboardWidget"; + +// Import RGL styles +import "react-grid-layout/css/styles.css"; +import "react-resizable/css/styles.css"; + +const ResponsiveGridLayout = WidthProvider(Responsive); + +interface EditorCanvasProps { + dashboard: DashboardDefinition; + isReadOnly: boolean; + onEditComponent?: (id: string) => void; + onMaximizeComponent?: (id: string) => void; + onDeleteComponent?: (id: string) => void; +} + +export function EditorCanvas({ dashboard, isReadOnly, onEditComponent, onMaximizeComponent, onDeleteComponent }: EditorCanvasProps) { + const updateWidgetLayouts = useAnalysisDefinitionStore(state => state.updateWidgetLayouts); + const selectedWidgetId = useAnalysisUIStore(state => state.selectedWidgetId); + const setSelectedWidgetId = useAnalysisUIStore(state => state.setSelectedWidgetId); + + const layouts = useMemo(() => { + return { + lg: dashboard.widgets.map(c => c.layout), + md: dashboard.widgets.map((c, i) => ({ + ...c.layout, + w: 5, + h: 6, // Fixed height for consistency + x: (i % 2) * 5, + y: Math.floor(i / 2) * 6 + })), + sm: dashboard.widgets.map((c, i) => ({ + ...c.layout, + w: 3, + h: 6, // Fixed height for consistency + x: (i % 2) * 3, + y: Math.floor(i / 2) * 6 + })), + xs: dashboard.widgets.map((c, i) => ({ ...c.layout, w: 4, x: 0, y: i })), // 1 col + xxs: dashboard.widgets.map((c, i) => ({ ...c.layout, w: 2, x: 0, y: i })) // 1 col + }; + }, [dashboard.widgets]); + + const handleLayoutChange = (layout: any[]) => { + if (!isReadOnly) { + void updateWidgetLayouts(dashboard.id, layout); + } + }; + + return ( +
setSelectedWidgetId(null)} + data-testid="analysis.dashboard.canvas" + data-qa-module="analysis" + data-qa-object="dashboard-canvas" + data-qa-resource-type="dashboard" + data-qa-resource-id={dashboard.id} + data-qa-state={isReadOnly ? 'read_only' : 'editable'} + > + + {dashboard.widgets.map(widget => ( +
+ +
+ ))} +
+
+ ); +} diff --git a/dataflow/dataflow/src/components/analysis/editor/MaximizeChartModal.tsx b/dataflow/dataflow/src/components/analysis/editor/MaximizeChartModal.tsx new file mode 100644 index 0000000..aad1191 --- /dev/null +++ b/dataflow/dataflow/src/components/analysis/editor/MaximizeChartModal.tsx @@ -0,0 +1,104 @@ +import { useAnalysisDefinitionStore } from '@/stores/analysisDefinitionStore' +import { useAnalysisRuntimeStore } from '@/stores/analysisRuntimeStore' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { SafeECharts } from '@/components/ui/SafeECharts' +import { buildWidgetChartOption } from '../chart-utils' +import { cn } from '@/lib/utils' +import { useI18n } from '@/i18n/useI18n' + +interface MaximizeChartModalProps { + open: boolean + onOpenChange: (open: boolean) => void + componentId: string | null +} + +/** Full-screen modal for viewing a dashboard component at maximum size. */ +export function MaximizeChartModal({ open, onOpenChange, componentId }: MaximizeChartModalProps) { + const activeDashboardId = useAnalysisDefinitionStore(state => state.activeDashboardId) + const dashboards = useAnalysisDefinitionStore(state => state.dashboards) + const widgetStatesById = useAnalysisRuntimeStore(state => state.widgetStatesById) + const dashboard = dashboards.find(d => d.id === activeDashboardId) + const component = dashboard?.widgets.find(c => c.id === componentId) + const runtimeState = componentId ? widgetStatesById[componentId] : undefined + const renderConfig = runtimeState?.status === 'success' && !runtimeState.isStale + ? runtimeState.config + : component?.snapshot?.config + + return ( + + + {component && ( + <> + +
+ {component.title} + {component.description && ( + {component.description} + )} +
+
+
+ +
+ + )} +
+
+ ) +} + +function MaximizedContent({ component, renderConfig }: { component: { type: string; title: string; snapshot?: { data?: any } }; renderConfig?: any }) { + const { t } = useI18n() + switch (component.type) { + case 'chart': { + const chartOption = buildWidgetChartOption(renderConfig) + if (!chartOption) return null + return + } + case 'table': + if (!component.snapshot?.data?.rows) return null + return ( +
+ + + + {(component.snapshot?.data?.columns ?? []).map((col: string, i: number) => ( + + ))} + + + + {(component.snapshot?.data?.rows ?? []).map((row: any, i: number) => ( + + {(component.snapshot?.data?.columns ?? []).map((col: string, j: number) => ( + + ))} + + ))} + +
{col}
{row[col]}
+
+ ) + case 'text': + return ( +
+ ) + case 'stats': + return ( +
+
{component.snapshot?.data?.value || '0'}
+
+ {t('analysis.widget.statsComparison', { trend: component.snapshot?.data?.trend || '0%' })} +
+
+ ) + default: + return null + } +} diff --git a/dataflow/dataflow/src/components/analysis/editor/index.ts b/dataflow/dataflow/src/components/analysis/editor/index.ts new file mode 100644 index 0000000..7b9b199 --- /dev/null +++ b/dataflow/dataflow/src/components/analysis/editor/index.ts @@ -0,0 +1 @@ +export { DashboardEditor } from './DashboardEditor' diff --git a/dataflow/dataflow/src/components/auth/StandaloneLogin.tsx b/dataflow/dataflow/src/components/auth/StandaloneLogin.tsx new file mode 100644 index 0000000..9958632 --- /dev/null +++ b/dataflow/dataflow/src/components/auth/StandaloneLogin.tsx @@ -0,0 +1,245 @@ +import * as React from 'react' +import type { LoginCredentials } from '@graphql' +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { Label } from '@/components/ui/label' +import { cn } from '@/lib/utils' +import { useI18n } from '@/i18n/useI18n' +import { useAuthStore } from '@/stores/useAuthStore' + +type StandaloneDatabaseType = 'Postgres' | 'MySQL' | 'MongoDB' | 'Redis' | 'ClickHouse' + +interface DatabaseDefaults { + port: string + database: string +} + +interface StandaloneLoginFormProps { + onSubmit: (credentials: LoginCredentials) => Promise +} + +const databaseTypes: StandaloneDatabaseType[] = ['Postgres', 'MySQL', 'MongoDB', 'Redis', 'ClickHouse'] + +const databaseDefaults: Record = { + Postgres: { port: '5432', database: 'postgres' }, + MySQL: { port: '3306', database: '' }, + MongoDB: { port: '27017', database: 'admin' }, + Redis: { port: '6379', database: '' }, + ClickHouse: { port: '9000', database: 'default' }, +} + +/** Renders the standalone login entry backed by the auth store. */ +export function StandaloneLogin() { + const createStandaloneSession = useAuthStore((state) => state.createStandaloneSession) + return +} + +/** Renders the manual database connection form for standalone login. */ +export function StandaloneLoginForm({ onSubmit }: StandaloneLoginFormProps) { + const { t } = useI18n() + const [type, setType] = React.useState('Postgres') + const [host, setHost] = React.useState('localhost') + const [port, setPort] = React.useState(databaseDefaults.Postgres.port) + const [username, setUsername] = React.useState('') + const [password, setPassword] = React.useState('') + const [database, setDatabase] = React.useState(databaseDefaults.Postgres.database) + const [portEdited, setPortEdited] = React.useState(false) + const [databaseEdited, setDatabaseEdited] = React.useState(false) + const [submitting, setSubmitting] = React.useState(false) + const [error, setError] = React.useState(null) + + const handleTypeChange = (nextType: StandaloneDatabaseType) => { + setType(nextType) + const defaults = databaseDefaults[nextType] + if (!portEdited) { + setPort(defaults.port) + } + if (!databaseEdited) { + setDatabase(defaults.database) + } + } + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + setSubmitting(true) + setError(null) + + try { + await onSubmit({ + Type: type, + Hostname: host, + Username: username, + Password: password, + Database: database, + Advanced: [{ Key: 'Port', Value: port }], + }) + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : String(submitError)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+

{t('standaloneLogin.title')}

+
+ +
+
+ + +
+ +
+
+ + setHost(event.target.value)} + disabled={submitting} + data-testid="auth.standalone.host-input" + data-qa-module="auth" + data-qa-object="standalone-session" + data-qa-field="host" + data-qa-disabled-reason={submitting ? 'submitting' : undefined} + /> +
+
+ + { + setPortEdited(true) + setPort(event.target.value) + }} + disabled={submitting} + data-testid="auth.standalone.port-input" + data-qa-module="auth" + data-qa-object="standalone-session" + data-qa-field="port" + data-qa-disabled-reason={submitting ? 'submitting' : undefined} + /> +
+
+ +
+ + setUsername(event.target.value)} + disabled={submitting} + data-testid="auth.standalone.username-input" + data-qa-module="auth" + data-qa-object="standalone-session" + data-qa-field="username" + data-qa-disabled-reason={submitting ? 'submitting' : undefined} + /> +
+ +
+ + setPassword(event.target.value)} + disabled={submitting} + data-testid="auth.standalone.password-input" + data-qa-module="auth" + data-qa-object="standalone-session" + data-qa-field="password" + data-qa-disabled-reason={submitting ? 'submitting' : undefined} + /> +
+ +
+ + { + setDatabaseEdited(true) + setDatabase(event.target.value) + }} + disabled={submitting} + data-testid="auth.standalone.database-input" + data-qa-module="auth" + data-qa-object="standalone-session" + data-qa-field="database" + data-qa-disabled-reason={submitting ? 'submitting' : undefined} + /> +
+
+ + {error ? ( +
+ {t('standaloneLogin.error', { message: error })} +
+ ) : null} + + +
+
+ ) +} diff --git a/dataflow/dataflow/src/components/dashboard-sidebar/DashboardFormModal.tsx b/dataflow/dataflow/src/components/dashboard-sidebar/DashboardFormModal.tsx new file mode 100644 index 0000000..866c9e4 --- /dev/null +++ b/dataflow/dataflow/src/components/dashboard-sidebar/DashboardFormModal.tsx @@ -0,0 +1,190 @@ +import { createContext, use, useState, useCallback, type ReactNode } from 'react' +import { Plus, Settings } from 'lucide-react' +import { useAnalysisDefinitionStore } from '@/stores/analysisDefinitionStore' +import type { DashboardDefinition as Dashboard, RefreshRule } from '@/stores/analysisDefinitionStore' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { Input } from '@/components/ui/Input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { ModalForm, useModalForm } from '@/components/ui/ModalForm' +import { useI18n } from '@/i18n/useI18n' + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +interface DashboardFormCtxValue { + name: string + setName: (v: string) => void + description: string + setDescription: (v: string) => void + refreshRule: RefreshRule + setRefreshRule: (v: RefreshRule) => void + isEditMode: boolean + originalName: string +} + +const DashboardFormCtx = createContext(null) + +function useDashboardFormCtx(): DashboardFormCtxValue { + const ctx = use(DashboardFormCtx) + if (!ctx) throw new Error('useDashboardFormCtx must be used within DashboardFormProvider') + return ctx +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +/** Owns business logic for creating or editing a dashboard. */ +function DashboardFormProvider({ + dashboard, + onSuccess, + children, +}: { + dashboard?: Dashboard + onSuccess?: () => void + children: ReactNode +}) { + const { t } = useI18n() + const createDashboard = useAnalysisDefinitionStore(state => state.createDashboard) + const updateDashboard = useAnalysisDefinitionStore(state => state.updateDashboard) + const isDashboardNameExists = useAnalysisDefinitionStore(state => state.isDashboardNameExists) + const isEditMode = !!dashboard + + const [name, setName] = useState(dashboard?.name ?? '') + const [description, setDescription] = useState(dashboard?.description ?? '') + const [refreshRule, setRefreshRule] = useState(dashboard?.refreshRule ?? 'on-demand') + + const handleSubmit = useCallback(async () => { + if (!name.trim()) return + if (isDashboardNameExists(name, dashboard?.id)) { + throw new Error(t('analysis.dashboard.nameExists')) + } + if (isEditMode) { + await updateDashboard(dashboard.id, { name, description: description || '', refreshRule }) + } else { + await createDashboard(name, description || undefined, refreshRule) + } + onSuccess?.() + }, [name, description, refreshRule, isDashboardNameExists, dashboard, isEditMode, createDashboard, updateDashboard, onSuccess, t]) + + const title = isEditMode ? t('analysis.dashboard.edit') : t('analysis.dashboard.create') + const icon = isEditMode ? Settings : Plus + + return ( + + + {children} + + + ) +} + +// --------------------------------------------------------------------------- +// Subcomponents +// --------------------------------------------------------------------------- + +/** Form fields for dashboard name, description, and refresh rule. */ +function DashboardFormFields() { + const { t } = useI18n() + const { name, setName, description, setDescription, refreshRule, setRefreshRule } = useDashboardFormCtx() + const { state, actions } = useModalForm() + + return ( +
+
+ + { setName(e.target.value); actions.closeAlert() }} + placeholder={t('analysis.dashboard.namePlaceholder')} + maxLength={15} + disabled={state.isSubmitting} + autoFocus + /> +
+ +
+ + setDescription(e.target.value)} + placeholder={t('analysis.dashboard.descriptionPlaceholder')} + disabled={state.isSubmitting} + /> +
+ +
+ + +
+
+ ) +} + +/** Submit button disabled when name is empty (or unchanged in edit mode). */ +function DashboardFormSubmitButton() { + const { t } = useI18n() + const { name } = useDashboardFormCtx() + return +} + +// --------------------------------------------------------------------------- +// Modal +// --------------------------------------------------------------------------- + +interface DashboardFormModalProps { + open: boolean + onOpenChange: (open: boolean) => void + /** When provided, the modal operates in edit mode. */ + dashboard?: Dashboard +} + +/** Unified modal for creating or editing a dashboard. */ +export function DashboardFormModal({ open, onOpenChange, dashboard }: DashboardFormModalProps) { + const handleSuccess = useCallback(() => { + onOpenChange(false) + }, [onOpenChange]) + + return ( + + + + + + + + + + + + + + ) +} diff --git a/dataflow/dataflow/src/components/dashboard-sidebar/DashboardSidebar.tsx b/dataflow/dataflow/src/components/dashboard-sidebar/DashboardSidebar.tsx new file mode 100644 index 0000000..a689082 --- /dev/null +++ b/dataflow/dataflow/src/components/dashboard-sidebar/DashboardSidebar.tsx @@ -0,0 +1,176 @@ +import { useState } from "react"; +import { useAnalysisDefinitionStore } from "@/stores/analysisDefinitionStore"; +import { useAnalysisUIStore } from "@/stores/analysisUiStore"; +import type { DashboardDefinition as Dashboard } from "@/stores/analysisDefinitionStore"; +import { Plus, LayoutDashboard, Edit2, Trash2, MoreVertical } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/Button"; +import { ContextMenu } from "@/components/ui/ContextMenu"; +import { DashboardFormModal } from './DashboardFormModal' +import { DeleteDashboardModal } from './DeleteDashboardModal' +import { useI18n } from '@/i18n/useI18n' +import { Separator } from "../ui/separator"; + +export function DashboardSidebar() { + const { t } = useI18n() + const dashboards = useAnalysisDefinitionStore(state => state.dashboards); + const activeDashboardId = useAnalysisDefinitionStore(state => state.activeDashboardId); + const openDashboard = useAnalysisDefinitionStore(state => state.openDashboard); + const openCreateChartModal = useAnalysisUIStore(state => state.openCreateChartModal); + + // Context Menu State + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null); + + // Modal State + const [createOpen, setCreateOpen] = useState(false) + const [editTarget, setEditTarget] = useState(null) + const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null) + + const sortedDashboards = [...dashboards].sort((a, b) => ( + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + )); + + const handleContextMenu = (e: React.MouseEvent, id: string) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, id }); + }; + + return ( +
0 ? 'ready' : 'empty'} + > + {/* Header */} +
+ {t('analysis.dashboard.listTitle')} +
+ + {/* Content */} +
+
+ {/* Create Button */} + + + {/* Separator */} + + + {/* List */} + {sortedDashboards.map(dashboard => ( +
openDashboard(dashboard.id)} + onContextMenu={(e) => handleContextMenu(e, dashboard.id)} + data-testid="analysis.dashboard.list-item" + data-qa-module="analysis" + data-qa-object="dashboard" + data-qa-action="open" + data-qa-state={activeDashboardId === dashboard.id ? 'active' : 'inactive'} + data-qa-resource-type="dashboard" + data-qa-resource-id={dashboard.id} + className={cn( + "group flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-colors text-sm select-none", + activeDashboardId === dashboard.id + ? "bg-input text-accent-foreground" + : "text-foreground hover:bg-input" + )} + > + + {dashboard.name} + +
+ ))} +
+
+ + {/* Context Menu */} + {contextMenu && ( + setContextMenu(null)} + items={[ + { + label: t('analysis.chart.add'), + icon: , + onClick: () => { + const dashboard = dashboards.find(d => d.id === contextMenu.id); + if (dashboard) { + openDashboard(dashboard.id); + openCreateChartModal(); + } + } + }, + { separator: true }, + { + label: t('analysis.dashboard.edit'), + icon: , + onClick: () => { + const d = dashboards.find(d => d.id === contextMenu.id); + if (d) setEditTarget(d); + setContextMenu(null); + } + }, + { separator: true }, + { + label: t('analysis.dashboard.delete'), + icon: , + danger: true, + onClick: () => { + const d = dashboards.find(d => d.id === contextMenu.id); + if (d) setDeleteTarget({ id: d.id, name: d.name }); + setContextMenu(null); + } + } + ]} + /> + )} + + + { if (!open) setEditTarget(null) }} + dashboard={editTarget ?? undefined} + /> + { if (!open) setDeleteTarget(null) }} + dashboardId={deleteTarget?.id ?? ''} + dashboardName={deleteTarget?.name ?? ''} + /> +
+ ); +} diff --git a/dataflow/dataflow/src/components/dashboard-sidebar/DeleteDashboardModal.tsx b/dataflow/dataflow/src/components/dashboard-sidebar/DeleteDashboardModal.tsx new file mode 100644 index 0000000..0a245b9 --- /dev/null +++ b/dataflow/dataflow/src/components/dashboard-sidebar/DeleteDashboardModal.tsx @@ -0,0 +1,83 @@ +import { useCallback, type ReactNode } from 'react' +import { Trash2 } from 'lucide-react' +import { useAnalysisDefinitionStore } from '@/stores/analysisDefinitionStore' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { ModalForm } from '@/components/ui/ModalForm' +import { useI18n } from '@/i18n/useI18n' + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +/** Owns business logic for deleting a dashboard. */ +function DeleteDashboardProvider({ + dashboardId, + dashboardName, + onSuccess, + children, +}: { + dashboardId: string + dashboardName: string + onSuccess?: () => void + children: ReactNode +}) { + const { t } = useI18n() + const deleteDashboard = useAnalysisDefinitionStore(state => state.deleteDashboard) + + const handleSubmit = useCallback(async () => { + await deleteDashboard(dashboardId) + onSuccess?.() + }, [deleteDashboard, dashboardId, onSuccess]) + + return ( + + {children} + + ) +} + +// --------------------------------------------------------------------------- +// Modal +// --------------------------------------------------------------------------- + +interface DeleteDashboardModalProps { + open: boolean + onOpenChange: (open: boolean) => void + dashboardId: string + dashboardName: string +} + +/** Modal for confirming deletion of a dashboard. */ +export function DeleteDashboardModal({ + open, + onOpenChange, + dashboardId, + dashboardName, +}: DeleteDashboardModalProps) { + const handleSuccess = useCallback(() => { + onOpenChange(false) + }, [onOpenChange]) + + return ( + + + + + + + + + + + + + ) +} diff --git a/dataflow/dataflow/src/components/dashboard-sidebar/index.ts b/dataflow/dataflow/src/components/dashboard-sidebar/index.ts new file mode 100644 index 0000000..09d5d1c --- /dev/null +++ b/dataflow/dataflow/src/components/dashboard-sidebar/index.ts @@ -0,0 +1 @@ +export { DashboardSidebar } from './DashboardSidebar' diff --git a/dataflow/dataflow/src/components/database/CreateDatabaseModal.tsx b/dataflow/dataflow/src/components/database/CreateDatabaseModal.tsx new file mode 100644 index 0000000..8f3cd1f --- /dev/null +++ b/dataflow/dataflow/src/components/database/CreateDatabaseModal.tsx @@ -0,0 +1,195 @@ +import { createContext, use, useState, useCallback, type ReactNode } from 'react' +import { Database } from 'lucide-react' +import { useConnectionStore } from '@/stores/useConnectionStore' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { Input } from '@/components/ui/Input' +import { ModalForm, useModalForm } from '@/components/ui/ModalForm' +import { useI18n } from '@/i18n/useI18n' +import { resolveSchemaParam } from '@/utils/database-features' + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +interface CreateDatabaseCtxValue { + dbName: string + setDbName: (v: string) => void + initialCollectionName: string + setInitialCollectionName: (v: string) => void + isMongoConnection: boolean +} + +const CreateDatabaseCtx = createContext(null) + +function useCreateDatabaseCtx(): CreateDatabaseCtxValue { + const ctx = use(CreateDatabaseCtx) + if (!ctx) throw new Error('useCreateDatabaseCtx must be used within CreateDatabaseProvider') + return ctx +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +/** Owns business logic for creating a new database. */ +function CreateDatabaseProvider({ + connectionId, + onSuccess, + children, +}: { + connectionId: string + onSuccess?: () => void + children: ReactNode +}) { + const { t } = useI18n() + const { connections, createDatabase, createTable } = useConnectionStore() + const [dbName, setDbName] = useState('') + const [initialCollectionName, setInitialCollectionName] = useState('') + const connection = connections.find((item) => item.id === connectionId) + const isMongoConnection = connection?.type === 'MONGODB' + + const handleSubmit = useCallback(async () => { + if (!dbName) return + if (isMongoConnection) { + if (!initialCollectionName) return + const schemaParam = resolveSchemaParam(connection?.type, dbName) + const result = await createTable(dbName, schemaParam, initialCollectionName, []) + if (result.success) { + onSuccess?.() + } else { + throw new Error(result.message ?? t('common.unknownError')) + } + return + } + + const result = await createDatabase(dbName) + if (result.success) { + onSuccess?.() + } else { + throw new Error(result.message ?? t('common.unknownError')) + } + }, [ + connection?.type, + createDatabase, + createTable, + dbName, + initialCollectionName, + isMongoConnection, + onSuccess, + t, + ]) + + return ( + + + {children} + + + ) +} + +// --------------------------------------------------------------------------- +// Subcomponents +// --------------------------------------------------------------------------- + +/** Input field for the new database name. */ +function CreateDatabaseFields() { + const { t } = useI18n() + const { + dbName, + setDbName, + initialCollectionName, + setInitialCollectionName, + isMongoConnection, + } = useCreateDatabaseCtx() + const { state } = useModalForm() + + return ( +
+
+ + setDbName(e.target.value)} + placeholder={t('database.namePlaceholder')} + disabled={state.isSubmitting} + /> +
+ + {isMongoConnection && ( +
+ + setInitialCollectionName(e.target.value)} + placeholder={t('mongodb.collection.namePlaceholder')} + disabled={state.isSubmitting} + /> +
+ )} +
+ ) +} + +/** Submit button disabled when database name is empty. */ +function CreateSubmitButton() { + const { t } = useI18n() + const { dbName, initialCollectionName, isMongoConnection } = useCreateDatabaseCtx() + const isDisabled = !dbName || (isMongoConnection && !initialCollectionName) + return +} + +// --------------------------------------------------------------------------- +// Modal +// --------------------------------------------------------------------------- + +interface CreateDatabaseModalProps { + open: boolean + onOpenChange: (open: boolean) => void + connectionId: string + onSuccess?: () => void +} + +/** Modal for creating a new database. */ +export function CreateDatabaseModal({ + open, + onOpenChange, + connectionId, + onSuccess, +}: CreateDatabaseModalProps) { + const handleSuccess = useCallback(() => { + onSuccess?.() + onOpenChange(false) + }, [onSuccess, onOpenChange]) + + return ( + + + + + + + + + + + + + + ) +} diff --git a/dataflow/dataflow/src/components/database/DeleteDatabaseModal.tsx b/dataflow/dataflow/src/components/database/DeleteDatabaseModal.tsx new file mode 100644 index 0000000..fef224a --- /dev/null +++ b/dataflow/dataflow/src/components/database/DeleteDatabaseModal.tsx @@ -0,0 +1,161 @@ +import { createContext, use, useState, useCallback, type ReactNode } from 'react' +import { AlertTriangle } from 'lucide-react' +import { useConnectionStore } from '@/stores/useConnectionStore' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { Input } from '@/components/ui/Input' +import { ModalForm, useModalForm } from '@/components/ui/ModalForm' +import { useI18n } from '@/i18n/useI18n' + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +interface DeleteDatabaseCtxValue { + confirmName: string + setConfirmName: (v: string) => void + databaseName: string + canDelete: boolean +} + +const DeleteDatabaseCtx = createContext(null) + +function useDeleteDatabaseCtx(): DeleteDatabaseCtxValue { + const ctx = use(DeleteDatabaseCtx) + if (!ctx) throw new Error('useDeleteDatabaseCtx must be used within DeleteDatabaseProvider') + return ctx +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +/** Owns business logic for deleting a database. */ +function DeleteDatabaseProvider({ + connectionId, + databaseName, + onSuccess, + children, +}: { + connectionId: string + databaseName: string + onSuccess?: () => void + children: ReactNode +}) { + const { t } = useI18n() + const { deleteDatabase } = useConnectionStore() + const [confirmName, setConfirmName] = useState('') + const canDelete = confirmName === databaseName + + const handleSubmit = useCallback(async () => { + if (!canDelete) return + const result = await deleteDatabase(databaseName) + if (result.success) { + onSuccess?.() + } else { + throw new Error(result.message ?? t('common.unknownError')) + } + }, [canDelete, databaseName, deleteDatabase, onSuccess, t]) + + return ( + + + {children} + + + ) +} + +// --------------------------------------------------------------------------- +// Subcomponents +// --------------------------------------------------------------------------- + +/** Warning banner explaining the destructive action. */ +function DeleteDatabaseWarning() { + const { t } = useI18n() + const { databaseName } = useDeleteDatabaseCtx() + + return ( +
+

{t('database.delete.warningTitle')}

+

{t('database.delete.warningMessage', { databaseName })}

+
+ ) +} + +/** Confirmation input — user must type the database name to enable deletion. */ +function DeleteDatabaseConfirmation() { + const { t } = useI18n() + const { confirmName, setConfirmName, databaseName } = useDeleteDatabaseCtx() + const { state } = useModalForm() + + return ( +
+ + setConfirmName(e.target.value)} + placeholder={databaseName} + disabled={state.isSubmitting} + /> +
+ ) +} + +/** Submit button disabled until confirmation name matches. */ +function DeleteSubmitButton() { + const { t } = useI18n() + const { canDelete } = useDeleteDatabaseCtx() + return +} + +// --------------------------------------------------------------------------- +// Modal +// --------------------------------------------------------------------------- + +interface DeleteDatabaseModalProps { + open: boolean + onOpenChange: (open: boolean) => void + connectionId: string + databaseName: string + onSuccess?: () => void +} + +/** Modal for deleting a database with confirmation. */ +export function DeleteDatabaseModal({ + open, + onOpenChange, + connectionId, + databaseName, + onSuccess, +}: DeleteDatabaseModalProps) { + const handleSuccess = useCallback(() => { + onSuccess?.() + onOpenChange(false) + }, [onSuccess, onOpenChange]) + + return ( + + + + + + + + + + + + + + + ) +} diff --git a/dataflow/dataflow/src/components/database/EditDatabaseModal.tsx b/dataflow/dataflow/src/components/database/EditDatabaseModal.tsx new file mode 100644 index 0000000..9142f29 --- /dev/null +++ b/dataflow/dataflow/src/components/database/EditDatabaseModal.tsx @@ -0,0 +1,149 @@ +import { createContext, use, useState, useCallback, type ReactNode } from 'react' +import { Database } from 'lucide-react' +import { useConnectionStore } from '@/stores/useConnectionStore' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { Input } from '@/components/ui/Input' +import { ModalForm, useModalForm } from '@/components/ui/ModalForm' +import { useI18n } from '@/i18n/useI18n' + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +interface EditDatabaseCtxValue { + newName: string + setNewName: (v: string) => void + databaseName: string +} + +const EditDatabaseCtx = createContext(null) + +function useEditDatabaseCtx(): EditDatabaseCtxValue { + const ctx = use(EditDatabaseCtx) + if (!ctx) throw new Error('useEditDatabaseCtx must be used within EditDatabaseProvider') + return ctx +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +/** Owns business logic for renaming a database. */ +function EditDatabaseProvider({ + databaseName, + onSuccess, + children, +}: { + databaseName: string + onSuccess?: () => void + children: ReactNode +}) { + const { t } = useI18n() + const { renameDatabase } = useConnectionStore() + const [newName, setNewName] = useState(databaseName) + + const handleSubmit = useCallback(async () => { + if (!newName.trim() || newName === databaseName) return + const result = await renameDatabase(databaseName, newName) + if (result.success) { + onSuccess?.() + } else { + throw new Error(result.message ?? t('common.unknownError')) + } + }, [newName, databaseName, renameDatabase, onSuccess, t]) + + return ( + + + {children} + + + ) +} + +// --------------------------------------------------------------------------- +// Subcomponents +// --------------------------------------------------------------------------- + +/** Shows current database name (disabled) and new name input. */ +function EditDatabaseFields() { + const { t } = useI18n() + const { newName, setNewName, databaseName } = useEditDatabaseCtx() + const { state } = useModalForm() + + return ( +
+
+ + +
+
+ + setNewName(e.target.value)} + placeholder={t('database.rename.newNamePlaceholder')} + disabled={state.isSubmitting} + autoFocus + /> +
+
+ ) +} + +/** Submit button disabled when name is empty or unchanged. */ +function EditSubmitButton() { + const { t } = useI18n() + const { newName, databaseName } = useEditDatabaseCtx() + return +} + +// --------------------------------------------------------------------------- +// Modal +// --------------------------------------------------------------------------- + +interface EditDatabaseModalProps { + open: boolean + onOpenChange: (open: boolean) => void + databaseName: string + onSuccess?: () => void +} + +/** Modal for renaming a database. */ +export function EditDatabaseModal({ + open, + onOpenChange, + databaseName, + onSuccess, +}: EditDatabaseModalProps) { + const handleSuccess = useCallback(() => { + onSuccess?.() + onOpenChange(false) + }, [onSuccess, onOpenChange]) + + return ( + + + + + + + + + + + + + + ) +} diff --git a/dataflow/dataflow/src/components/database/ExportDatabaseModal.tsx b/dataflow/dataflow/src/components/database/ExportDatabaseModal.tsx new file mode 100644 index 0000000..cdd640e --- /dev/null +++ b/dataflow/dataflow/src/components/database/ExportDatabaseModal.tsx @@ -0,0 +1,320 @@ +import { createContext, use, useCallback, useState, type ReactNode } from 'react' +import { Database, FileJson, FileSpreadsheet, FileCode, FileText } from 'lucide-react' +import { SqlDataExportMode, useCreateSqlDataExportMutation, useRawExecuteLazyQuery } from '@/generated/graphql' +import { toCSV, toJSON, toExcel, downloadBlob } from '@/utils/export-utils' +import { fetchExportDownloadBlob } from '@/utils/export-download' +import JSZip from 'jszip' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { ModalForm, useModalForm } from '@/components/ui/ModalForm' +import { FormatSelector, type FormatOption } from '@/components/database/shared/FormatSelector' +import { ExportProgress, ExportFooter } from '@/components/database/shared/ExportProgress' +import { useI18n } from '@/i18n/useI18n' +import { useConnectionStore } from '@/stores/useConnectionStore' +import { buildStorageUnitReference } from '@/utils/ddl-sql' +import { resolveSchemaParam } from '@/utils/database-features' +import { + buildDatabaseExportPlan, + filterDatabaseExportUnits, + formatDatabaseExportEntryName, + formatDatabaseExportTargetName, +} from '@/utils/database-export' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +type ExportFormat = 'csv' | 'json' | 'sql' | 'excel' + +const FORMAT_OPTIONS: FormatOption[] = [ + { id: 'sql', label: 'SQL', icon: FileCode }, + { id: 'json', label: 'JSON', icon: FileJson }, + { id: 'csv', label: 'CSV', icon: FileText }, + { id: 'excel', label: 'Excel', icon: FileSpreadsheet }, +] + +function isViewStorageUnit(type: string): boolean { + return type.toLowerCase().includes('view') +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +interface ExportDatabaseCtxValue { + format: ExportFormat + setFormat: (v: ExportFormat) => void + isSuccess: boolean + statusText: string + handleExport: () => void +} + +const ExportDatabaseCtx = createContext(null) + +/** Hook to access ExportDatabaseModal domain state. Throws if used outside the provider. */ +function useExportDatabaseCtx(): ExportDatabaseCtxValue { + const ctx = use(ExportDatabaseCtx) + if (!ctx) throw new Error('useExportDatabaseCtx must be used within ExportDatabaseProvider') + return ctx +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +/** Wraps ModalForm.Provider (complex mode, no onSubmit) and domain context for database ZIP export. */ +function ExportDatabaseProvider({ + connectionId, + databaseName, + schema, + children, +}: { + connectionId: string + databaseName: string + schema: string + children: ReactNode +}) { + const { t } = useI18n() + return ( + + + {children} + + + ) +} + +/** + * Inner bridge that owns domain state and multi-table export logic. + * Fetches table list via GraphQL, iterates each table, converts to selected format, + * bundles into ZIP via JSZip, triggers download. Partial failures surface as an info alert. + */ +function ExportDatabaseBridge({ + connectionId, + databaseName, + schema, + children, +}: { + connectionId: string + databaseName: string + schema: string + children: ReactNode +}) { + const { t } = useI18n() + const [format, setFormat] = useState('sql') + const [isSuccess, setIsSuccess] = useState(false) + const [statusText, setStatusText] = useState('') + const { actions } = useModalForm() + const [executeQuery] = useRawExecuteLazyQuery({ fetchPolicy: 'no-cache' }) + const [createSQLDataExport] = useCreateSqlDataExportMutation() + const connections = useConnectionStore((s) => s.connections) + const fetchSchemas = useConnectionStore((s) => s.fetchSchemas) + const fetchTables = useConnectionStore((s) => s.fetchTables) + const systemSchemas = useConnectionStore((s) => s.systemSchemas) + const showSystemObjectsFor = useConnectionStore((s) => s.showSystemObjectsFor) + + const handleExport = useCallback(async () => { + actions.setSubmitting(true) + actions.closeAlert() + setIsSuccess(false) + setStatusText(t('database.export.fetchingTableList')) + + try { + const connectionType = connections.find((connection) => connection.id === connectionId)?.type + const databaseNodeId = `${connectionId}-${databaseName}` + const includeSystemObjects = showSystemObjectsFor.has(databaseNodeId) + const allSchemas = connectionType === 'POSTGRES' + ? await fetchSchemas(connectionId, databaseName) + : [] + const schemasToExport = buildDatabaseExportPlan({ + connectionType, + fallbackSchema: schema, + allSchemas, + systemSchemas, + includeSystemSchemas: includeSystemObjects, + }) + const exportTargets: Array<{ schema: string; tableName: string }> = [] + + for (const schemaName of schemasToExport) { + const tables = filterDatabaseExportUnits( + await fetchTables(connectionId, databaseName, schemaName), + includeSystemObjects, + ) + for (const table of tables) { + if (format === 'sql' && isViewStorageUnit(table.type)) continue + exportTargets.push({ schema: schemaName, tableName: table.name }) + } + } + + if (exportTargets.length === 0) throw new Error(t('database.export.noTablesFound')) + + const zip = new JSZip() + const failedTables: string[] = [] + + for (let i = 0; i < exportTargets.length; i++) { + const target = exportTargets[i] + const targetLabel = formatDatabaseExportTargetName(connectionType, target.schema, target.tableName) + setStatusText(t('database.export.exportingTable', { + current: i + 1, + total: exportTargets.length, + tableName: targetLabel, + })) + + try { + if (format === 'sql') { + const graphqlSchema = resolveSchemaParam(connectionType, databaseName, target.schema) + const { data } = await createSQLDataExport({ + variables: { + input: { + Schema: graphqlSchema, + StorageUnit: target.tableName, + Mode: SqlDataExportMode.Insert, + }, + }, + context: { database: databaseName }, + }) + + if (!data?.CreateSQLDataExport) { + failedTables.push(targetLabel) + continue + } + + const blob = await fetchExportDownloadBlob(data.CreateSQLDataExport, databaseName) + zip.file(formatDatabaseExportEntryName(connectionType, target.schema, target.tableName, format), blob) + continue + } + + const qualifiedName = buildStorageUnitReference(connectionType, target.tableName, target.schema) + const { data, error } = await executeQuery({ + variables: { query: `SELECT * FROM ${qualifiedName}` }, + context: { database: databaseName }, + }) + + if (error || !data?.RawExecute) { + failedTables.push(targetLabel) + continue + } + + const { Columns, Rows } = data.RawExecute + let blob: Blob + + switch (format) { + case 'csv': blob = toCSV(Columns, Rows); break + case 'json': blob = toJSON(Columns, Rows); break + case 'excel': blob = toExcel(target.tableName, Columns, Rows); break + } + + zip.file(formatDatabaseExportEntryName(connectionType, target.schema, target.tableName, format), blob) + } catch { + failedTables.push(targetLabel) + } + } + + setStatusText(t('database.export.generatingZip')) + + const zipBlob = await zip.generateAsync({ type: 'blob' }) + downloadBlob(zipBlob, `export_${databaseName}.zip`) + + setIsSuccess(true) + + if (failedTables.length > 0) { + actions.setAlert({ + type: 'info', + title: t('database.export.partialExportTitle'), + message: t('database.export.partialExportMessage', { + successful: exportTargets.length - failedTables.length, + total: exportTargets.length, + failedTables: failedTables.join(', '), + }), + }) + } + } catch (err: any) { + actions.setAlert({ + type: 'error', + title: t('database.export.failed'), + message: err.message || t('common.unknownError'), + }) + } finally { + actions.setSubmitting(false) + } + }, [ + actions, + connectionId, + connections, + createSQLDataExport, + databaseName, + executeQuery, + fetchSchemas, + fetchTables, + format, + schema, + showSystemObjectsFor, + systemSchemas, + t, + ]) + + return ( + + {children} + + ) +} + +// --------------------------------------------------------------------------- +// Subcomponents +// --------------------------------------------------------------------------- + +/** Format selector and progress display for database export. */ +function ExportDatabaseFields() { + const { format, setFormat, isSuccess, statusText } = useExportDatabaseCtx() + const { state } = useModalForm() + const disabled = state.isSubmitting || isSuccess + + return ( +
+ + +
+ ) +} + +/** Footer bridge: reads isSuccess and handleExport from domain context, delegates to shared ExportFooter. */ +function ExportDatabaseFooterBridge() { + const { isSuccess, handleExport } = useExportDatabaseCtx() + return +} + +// --------------------------------------------------------------------------- +// Modal +// --------------------------------------------------------------------------- + +interface ExportDatabaseModalProps { + open: boolean + onOpenChange: (open: boolean) => void + connectionId: string + databaseName: string + schema: string +} + +/** Modal for exporting all tables in a database as a ZIP archive. */ +export function ExportDatabaseModal({ + open, + onOpenChange, + connectionId, + databaseName, + schema, +}: ExportDatabaseModalProps) { + return ( + + + + + + + + + + + ) +} diff --git a/dataflow/dataflow/src/components/database/import/DatabaseImportModal.tsx b/dataflow/dataflow/src/components/database/import/DatabaseImportModal.tsx new file mode 100644 index 0000000..38401c8 --- /dev/null +++ b/dataflow/dataflow/src/components/database/import/DatabaseImportModal.tsx @@ -0,0 +1,1470 @@ +import { createContext, use, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react' +import { Database, FileCode, FileSpreadsheet, Loader2, Upload } from 'lucide-react' +import { isApolloError } from '@apollo/client' +import { Dialog, DialogClose, DialogContent, DialogFooter } from '@/components/ui/dialog' +import { Button } from '@/components/ui/Button' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/Input' +import { Textarea } from '@/components/ui/Textarea' +import { ModalForm, useModalForm } from '@/components/ui/ModalForm' +import { ImportFilePicker } from '@/components/database/shared/import/ImportFilePicker' +import { ImportModeSelect } from '@/components/database/shared/import/ImportModeSelect' +import { ImportNotice } from '@/components/database/shared/import/ImportNotice' +import { ImportPreviewTable } from '@/components/database/shared/import/ImportPreviewTable' +import { ImportSourceOptions } from '@/components/database/shared/import/ImportSourceOptions' +import { ImportTargetModeToggle } from '@/components/database/shared/import/ImportTargetModeToggle' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { useI18n } from '@/i18n/useI18n' +import { cn } from '@/lib/utils' +import { useConnectionStore } from '@/stores/useConnectionStore' +import { resolveSchemaParam } from '@/utils/database-features' +import { getColumnTypeOptions, getDefaultTextColumnType, type ColumnTypeOption } from '@/utils/database-types' +import { + buildImportSqlInput, + buildImportTableFileInput, + buildTableFileOptions, + getTableFileFormat, + hasSqlScriptSource, + isAcceptedSqlFile, + type SqlScriptSource, + type TableFileSource, +} from './sql-import-utils' +import { + ImportFileFormat, + ImportMode, + ImportTargetMode, + useGetDatabaseMetadataQuery, + useImportPreviewMutation, + useImportSqlMutation, + useImportTableFileMutation, + type ImportColumnMapping, + type ImportNewTableColumnInput, + type ImportPreviewMutation, +} from '@graphql' + +type ImportMethod = 'sql' | 'tableFile' +type SourceKind = 'file' | 'text' +type TableTargetMode = 'existing' | 'new' +type TableImportPreview = ImportPreviewMutation['ImportPreview'] + +interface DatabaseImportContextValue { + method: ImportMethod + sourceKind: SourceKind + tableTargetMode: TableTargetMode + source: SqlScriptSource | null + tableSource: TableFileSource | null + tablePreview: TableImportPreview | null + tableSheetOptions: string[] + targetDatabase: string + databaseOptions: string[] + tableName?: string | null + targetTableLabel: string + tableImportMode: ImportMode + csvDelimiter: string + excelSheet: string + useHeaderMapping: boolean + allowAutoGenerated: boolean + newTableName: string + newTableColumns: ImportNewTableColumnInput[] + typeOptions: ColumnTypeOption[] + metadataLoading: boolean + metadataFailed: boolean + newTableValidationError: string | null + previewLoading: boolean + result: ImportResultState | null + sqlFileInputRef: React.RefObject + tableFileInputRef: React.RefObject + setMethod: (method: ImportMethod) => void + setSourceKind: (kind: SourceKind) => void + setTableTargetMode: (mode: TableTargetMode) => void + setTargetDatabase: (database: string) => void + setTableImportMode: (mode: ImportMode) => void + setCsvDelimiter: (delimiter: string) => void + setExcelSheet: (sheet: string) => void + setUseHeaderMapping: (useHeaderMapping: boolean) => void + setAllowAutoGenerated: (allowAutoGenerated: boolean) => void + setNewTableName: (name: string) => void + updateNewTableColumn: (index: number, field: keyof ImportNewTableColumnInput, value: string | boolean) => void + handleFileChange: (event: ChangeEvent) => void + handleTableFileChange: (event: ChangeEvent) => void + handleScriptChange: (script: string) => void + convertFileToText: () => void + loadTablePreview: () => Promise + executeImport: () => Promise +} + +interface ImportResultState { + status: boolean + detail?: string | null + message?: string | null +} + +interface DatabaseImportProviderProps { + connectionId: string + databaseName: string + schema?: string | null + tableName?: string | null + onSuccess?: (context: { databaseName: string; schema?: string | null; tableName?: string | null }) => void + children: ReactNode +} + +const CSV_DELIMITER_OPTIONS = [ + { value: '', labelKey: 'database.import.table.csvDelimiter.auto' }, + { value: ',', labelKey: 'database.import.table.csvDelimiter.comma' }, + { value: ';', labelKey: 'database.import.table.csvDelimiter.semicolon' }, + { value: '|', labelKey: 'database.import.table.csvDelimiter.pipe' }, +] as const + +const TABLE_IMPORT_MODES = [ + { value: ImportMode.Append, labelKey: 'database.import.table.mode.append' }, + { value: ImportMode.Upsert, labelKey: 'database.import.table.mode.upsert' }, + { value: ImportMode.Overwrite, labelKey: 'database.import.table.mode.overwrite' }, +] as const + +const DatabaseImportContext = createContext(null) + +function formatImportTargetLabel(databaseName: string, schema?: string | null, tableName?: string | null): string { + if (tableName) { + return schema ? `${databaseName}.${schema}.${tableName}` : `${databaseName}.${tableName}` + } + + return schema ? `${databaseName}.${schema}` : databaseName +} + +function useDatabaseImport(): DatabaseImportContextValue { + const context = use(DatabaseImportContext) + if (!context) throw new Error('useDatabaseImport must be used within DatabaseImportProvider') + return context +} + +/** Provides state and execution behavior for the shared Database Import modal. */ +function DatabaseImportProvider({ + connectionId, + databaseName, + schema, + tableName, + onSuccess, + children, +}: DatabaseImportProviderProps) { + const { t } = useI18n() + const [method, setMethodState] = useState('tableFile') + const [sourceKind, setSourceKindState] = useState('file') + const [tableTargetMode, setTableTargetModeState] = useState(tableName ? 'existing' : 'new') + const [source, setSource] = useState(null) + const [tableSource, setTableSource] = useState(null) + const [tablePreview, setTablePreview] = useState(null) + const [tableSheetOptions, setTableSheetOptions] = useState([]) + const [targetDatabase, setTargetDatabase] = useState(databaseName) + const [databaseOptions, setDatabaseOptions] = useState([databaseName]) + const [tableImportMode, setTableImportMode] = useState(ImportMode.Append) + const [csvDelimiter, setCsvDelimiter] = useState('') + const [excelSheet, setExcelSheet] = useState('') + const [useHeaderMapping, setUseHeaderMapping] = useState(true) + const [allowAutoGenerated, setAllowAutoGenerated] = useState(false) + const [newTableName, setNewTableName] = useState('') + const [newTableColumns, setNewTableColumns] = useState([]) + const [result, setResult] = useState(null) + const sqlFileInputRef = useRef(null) + const tableFileInputRef = useRef(null) + const fetchDatabases = useConnectionStore((state) => state.fetchDatabases) + const connections = useConnectionStore((state) => state.connections) + const connection = connections.find((item) => item.id === connectionId) + const { actions } = useModalForm() + const [importSql] = useImportSqlMutation() + const [importPreview, { loading: previewLoading }] = useImportPreviewMutation() + const [importTableFile] = useImportTableFileMutation() + const { data: metadataData, loading: metadataLoading, error: metadataError } = useGetDatabaseMetadataQuery({ + context: { database: databaseName }, + }) + const metadata = metadataData?.DatabaseMetadata ?? null + const typeOptions = useMemo(() => getColumnTypeOptions(metadata), [metadata]) + const defaultTextType = useMemo(() => getDefaultTextColumnType(metadata), [metadata]) + const metadataFailed = Boolean(metadataError) || (!metadataLoading && typeOptions.length === 0) + + const targetTableSchema = resolveSchemaParam(connection?.type, databaseName, schema ?? undefined) + const targetTableLabel = formatImportTargetLabel(databaseName, schema, tableName) + const newTableValidationError = useMemo( + () => getNewTableValidationError(newTableName, newTableColumns), + [newTableColumns, newTableName], + ) + + useEffect(() => { + setTargetDatabase(databaseName) + setDatabaseOptions([databaseName]) + }, [databaseName]) + + useEffect(() => { + if (!tableName && tableTargetMode === 'existing') { + setTableTargetModeState('new') + } + }, [tableName, tableTargetMode]) + + useEffect(() => { + let cancelled = false + + void fetchDatabases(connectionId) + .then((databases) => { + if (cancelled) return + const nextOptions = databases.includes(databaseName) + ? databases + : [databaseName, ...databases] + setDatabaseOptions([...new Set(nextOptions)]) + }) + .catch(() => { + if (!cancelled) { + actions.setAlert({ + type: 'error', + title: t('database.import.loadDatabasesFailed'), + message: t('database.import.loadDatabasesFailedMessage'), + }) + } + }) + + return () => { cancelled = true } + }, [actions, connectionId, databaseName, fetchDatabases, t]) + + const setMethod = useCallback((nextMethod: ImportMethod) => { + setMethodState(nextMethod) + setResult(null) + actions.closeAlert() + + if (nextMethod === 'sql') { + setTableSource(null) + setTablePreview(null) + setTableSheetOptions([]) + setAllowAutoGenerated(false) + if (tableFileInputRef.current) tableFileInputRef.current.value = '' + return + } + + setSource(null) + setSourceKindState('file') + setTargetDatabase(databaseName) + if (sqlFileInputRef.current) sqlFileInputRef.current.value = '' + }, [actions, databaseName]) + + const setTableTargetMode = useCallback((nextMode: TableTargetMode) => { + if (nextMode === 'existing' && !tableName) return + setTableTargetModeState(nextMode) + setResult(null) + setTablePreview(null) + setTableSheetOptions([]) + setAllowAutoGenerated(false) + actions.closeAlert() + }, [actions, tableName]) + + const setSourceKind = useCallback((kind: SourceKind) => { + setSourceKindState(kind) + setSource(null) + setResult(null) + actions.closeAlert() + if (kind === 'file' && sqlFileInputRef.current) { + sqlFileInputRef.current.value = '' + } + }, [actions]) + + const handleFileChange = useCallback(async (event: ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + actions.closeAlert() + setResult(null) + + if (!isAcceptedSqlFile(file)) { + setSource(null) + actions.setAlert({ + type: 'error', + title: t('database.import.invalidFileTitle'), + message: t('database.import.invalidFileMessage'), + }) + event.target.value = '' + return + } + + try { + const preview = await file.text() + setSourceKindState('file') + setSource({ kind: 'file', file, filename: file.name, preview }) + } catch { + setSource(null) + actions.setAlert({ + type: 'error', + title: t('database.import.readFileFailedTitle'), + message: t('database.import.readFileFailedMessage'), + }) + event.target.value = '' + } + }, [actions, t]) + + const handleTableFileChange = useCallback((event: ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + actions.closeAlert() + setResult(null) + + const format = getTableFileFormat(file.name) + if (!format) { + setTableSource(null) + setTablePreview(null) + setTableSheetOptions([]) + actions.setAlert({ + type: 'error', + title: t('database.import.invalidFileTitle'), + message: t('database.import.table.invalidFileMessage'), + }) + event.target.value = '' + return + } + + setExcelSheet('') + setTablePreview(null) + setTableSheetOptions([]) + setTableSource({ kind: 'tableFile', file, filename: file.name, format }) + }, [actions, t]) + + const handleScriptChange = useCallback((script: string) => { + setSourceKindState('text') + setSource({ kind: 'text', script }) + setResult(null) + actions.closeAlert() + }, [actions]) + + const convertFileToText = useCallback(() => { + if (!source || source.kind !== 'file') return + setSourceKindState('text') + setSource({ kind: 'text', script: source.preview }) + setResult(null) + actions.closeAlert() + if (sqlFileInputRef.current) sqlFileInputRef.current.value = '' + }, [actions, source]) + + const loadTablePreview = useCallback(async () => { + if (!tableSource) return + if (tableTargetMode === 'existing' && !tableName) return + + actions.closeAlert() + setResult(null) + setAllowAutoGenerated(false) + setTablePreview(null) + if (tableTargetMode === 'new') { + setNewTableName('') + setNewTableColumns([]) + } + + const input = tableTargetMode === 'existing' + ? { + TargetMode: ImportTargetMode.ExistingTable, + ExistingTable: { + Schema: targetTableSchema, + StorageUnit: tableName ?? '', + UseHeaderMapping: useHeaderMapping, + }, + Options: buildTableFileOptions(tableSource, csvDelimiter, excelSheet), + File: tableSource.file, + } + : { + TargetMode: ImportTargetMode.NewTable, + NewTable: { + Schema: targetTableSchema, + }, + Options: buildTableFileOptions(tableSource, csvDelimiter, excelSheet), + File: tableSource.file, + } + + try { + const { data, errors } = await importPreview({ + variables: { input }, + context: { database: databaseName }, + }) + + if (errors?.length) { + actions.setAlert({ + type: 'error', + title: t('database.import.table.previewFailedTitle'), + message: errors[0].message, + }) + return + } + + const preview = data?.ImportPreview + if (!preview) { + actions.setAlert({ + type: 'error', + title: t('database.import.table.previewFailedTitle'), + message: t('database.import.table.previewFailedMessage'), + }) + return + } + + setTablePreview(preview) + setTableSheetOptions(preview.Sheets) + if (tableTargetMode === 'new' && preview.NewTable) { + setNewTableName(preview.NewTable.TableName) + setNewTableColumns(preview.NewTable.Columns.map((column) => ({ + SourceColumn: column.SourceColumn, + TargetColumn: column.TargetColumn, + Type: column.Type || defaultTextType, + Nullable: column.Nullable, + Primary: column.Primary, + Skip: column.Skip, + }))) + } + } catch (error) { + actions.setAlert({ + type: 'error', + title: t('database.import.table.previewFailedTitle'), + message: error instanceof Error ? error.message : t('database.import.table.previewFailedMessage'), + }) + } + }, [ + actions, + csvDelimiter, + databaseName, + defaultTextType, + excelSheet, + importPreview, + t, + tableName, + tableSource, + tableTargetMode, + targetTableSchema, + useHeaderMapping, + ]) + + useEffect(() => { + if (!tableSource) { + setTablePreview(null) + return + } + if (tableTargetMode === 'existing' && !tableName) return + void loadTablePreview() + }, [loadTablePreview, tableName, tableSource, tableTargetMode]) + + const executeSqlImport = useCallback(async () => { + if (!source || !hasSqlScriptSource(source)) return + + const { data, errors } = await importSql({ + variables: { input: buildImportSqlInput(source) }, + context: { database: targetDatabase }, + }) + + if (errors?.length) { + actions.setAlert({ + type: 'error', + title: t('database.import.failedTitle'), + message: errors[0].message, + }) + return + } + + const importResult = data?.ImportSQL + if (!importResult) { + actions.setAlert({ + type: 'error', + title: t('database.import.failedTitle'), + message: t('database.import.failedMessage'), + }) + return + } + + setResult({ status: importResult.Status, detail: importResult.Detail, message: importResult.Message }) + if (importResult.Status) { + onSuccess?.(targetDatabase === databaseName + ? { databaseName: targetDatabase, schema, tableName } + : { databaseName: targetDatabase, schema: null, tableName: null }) + } + }, [actions, databaseName, importSql, onSuccess, schema, source, t, tableName, targetDatabase]) + + const executeTableFileImport = useCallback(async () => { + if (!tableSource || !tablePreview) return + if (tableTargetMode === 'existing' && (!tableName || !tablePreview.Mapping?.length)) return + if (tableTargetMode === 'new' && (!tablePreview.NewTable || newTableValidationError || metadataLoading || metadataFailed)) return + + const input = tableTargetMode === 'existing' + ? buildImportTableFileInput({ + targetMode: ImportTargetMode.ExistingTable, + source: tableSource, + schema: targetTableSchema, + storageUnit: tableName ?? '', + mode: tableImportMode, + mapping: tablePreview.Mapping?.map((item): ImportColumnMapping => ({ + SourceColumn: item.SourceColumn, + TargetColumn: item.TargetColumn, + Skip: false, + })) ?? [], + delimiter: csvDelimiter, + sheet: excelSheet, + allowAutoGenerated, + }) + : buildImportTableFileInput({ + targetMode: ImportTargetMode.NewTable, + source: tableSource, + schema: targetTableSchema, + tableName: newTableName.trim(), + columns: newTableColumns, + delimiter: csvDelimiter, + sheet: excelSheet, + }) + + const { data, errors } = await importTableFile({ + variables: { + input, + }, + context: { database: databaseName }, + }) + + if (errors?.length) { + actions.setAlert({ + type: 'error', + title: t('database.import.failedTitle'), + message: errors[0].message, + }) + return + } + + const importResult = data?.ImportTableFile + if (!importResult) { + actions.setAlert({ + type: 'error', + title: t('database.import.failedTitle'), + message: t('database.import.table.failedMessage'), + }) + return + } + + setResult({ status: importResult.Status, detail: importResult.Detail, message: importResult.Message }) + if (importResult.Status) { + onSuccess?.({ + databaseName, + schema, + tableName: tableTargetMode === 'new' ? newTableName.trim() : tableName, + }) + } + }, [ + actions, + allowAutoGenerated, + csvDelimiter, + databaseName, + excelSheet, + importTableFile, + metadataFailed, + metadataLoading, + newTableColumns, + newTableName, + newTableValidationError, + onSuccess, + schema, + t, + tableImportMode, + tableName, + tablePreview, + tableSource, + tableTargetMode, + targetTableSchema, + ]) + + const executeImport = useCallback(async () => { + actions.setSubmitting(true) + actions.closeAlert() + setResult(null) + + try { + if (method === 'tableFile') { + await executeTableFileImport() + } else { + await executeSqlImport() + } + } catch (error) { + const networkError = error instanceof Error && isApolloError(error) ? error.networkError : null + const requestTooLarge = method === 'sql' && networkError && 'statusCode' in networkError && networkError.statusCode === 413 + actions.setAlert({ + type: 'error', + title: t('database.import.failedTitle'), + message: requestTooLarge ? t('database.import.error.requestTooLarge') : error instanceof Error ? error.message : t(method === 'tableFile' + ? 'database.import.table.failedMessage' + : 'database.import.failedMessage'), + }) + } finally { + actions.setSubmitting(false) + } + }, [actions, executeSqlImport, executeTableFileImport, method, t]) + + const updateNewTableColumn = useCallback((index: number, field: keyof ImportNewTableColumnInput, value: string | boolean) => { + setNewTableColumns((prev) => prev.map((column, columnIndex) => { + if (columnIndex !== index) return column + if (field === 'Primary') { + return { ...column, Primary: value === true, Nullable: value === true ? false : column.Nullable } + } + if (field === 'Nullable' && column.Primary) return column + if (field === 'Skip') { + return { ...column, Skip: value === true, Primary: value === true ? false : column.Primary } + } + return { ...column, [field]: value } + })) + }, []) + + const contextValue = useMemo(() => ({ + method, + sourceKind, + tableTargetMode, + source, + tableSource, + tablePreview, + tableSheetOptions, + targetDatabase, + databaseOptions, + tableName, + targetTableLabel, + tableImportMode, + csvDelimiter, + excelSheet, + useHeaderMapping, + allowAutoGenerated, + newTableName, + newTableColumns, + typeOptions, + metadataLoading, + metadataFailed, + newTableValidationError, + previewLoading, + result, + sqlFileInputRef, + tableFileInputRef, + setMethod, + setSourceKind, + setTableTargetMode, + setTargetDatabase, + setTableImportMode, + setCsvDelimiter, + setExcelSheet, + setUseHeaderMapping, + setAllowAutoGenerated, + setNewTableName, + updateNewTableColumn, + handleFileChange, + handleTableFileChange, + handleScriptChange, + convertFileToText, + loadTablePreview, + executeImport, + }), [ + allowAutoGenerated, + convertFileToText, + csvDelimiter, + databaseOptions, + excelSheet, + executeImport, + handleFileChange, + handleScriptChange, + handleTableFileChange, + loadTablePreview, + metadataFailed, + metadataLoading, + method, + newTableColumns, + newTableName, + newTableValidationError, + previewLoading, + result, + setMethod, + setSourceKind, + setTableTargetMode, + source, + sourceKind, + tableImportMode, + tableSheetOptions, + tableTargetMode, + tableName, + tablePreview, + tableSource, + targetDatabase, + targetTableLabel, + typeOptions, + updateNewTableColumn, + useHeaderMapping, + ]) + + return ( + + {children} + + ) +} + +function DatabaseImportFields() { + const { method } = useDatabaseImport() + + return ( +
+ + {method === 'tableFile' ? ( + + ) : ( + <> + + + + )} + +
+ ) +} + +function ImportMethodSelector() { + const { t } = useI18n() + const { method, setMethod } = useDatabaseImport() + const { state } = useModalForm() + const disabled = state.isSubmitting + + return ( +
+ {t('database.import.method')} +
+ + +
+
+ ) +} + +function TargetDatabaseSelect() { + const { t } = useI18n() + const { targetDatabase, setTargetDatabase, databaseOptions } = useDatabaseImport() + const { state } = useModalForm() + + return ( +
+ + +
+ ) +} + +function SqlSourceSelector() { + const { t } = useI18n() + const { sourceKind, setSourceKind, source } = useDatabaseImport() + const { state } = useModalForm() + + return ( +
+ {t('database.import.source')} +
+ + +
+ {sourceKind === 'file' ? : } + {!hasSqlScriptSource(source) && ( +

{t('database.import.emptySourceHint')}

+ )} +
+ ) +} + +function SqlFileSource() { + const { t } = useI18n() + const { source, sqlFileInputRef, handleFileChange, convertFileToText } = useDatabaseImport() + const { state } = useModalForm() + const fileSource = source?.kind === 'file' ? source : null + + return ( +
+ + {fileSource && ( +
+
+ + {t('database.import.selectedFile', { filename: fileSource.filename })} + + +
+