From ca6b05ef5152c494eb22f3f29d2950679dc896e8 Mon Sep 17 00:00:00 2001 From: Neelesh Salian Date: Wed, 2 Sep 2026 20:53:43 -0700 Subject: [PATCH 1/2] Add type conformance fixtures, runners, and CI --- .github/workflows/conformance-go.yml | 74 ++++ .github/workflows/conformance-nightly.yml | 288 ++++++++++++++ .github/workflows/conformance-python.yml | 76 ++++ .github/workflows/conformance-rust.yml | 75 ++++ .github/workflows/license_check.yml | 32 ++ .github/workflows/validate-fixtures.yml | 50 +++ .gitignore | 18 + CONTRIBUTING.md | 83 ++++ README.md | 25 +- dev/build-status-matrix.py | 171 ++++++++ dev/check-license | 78 ++++ dev/check-runner-deps.sh | 45 +++ dev/ci-report.sh | 77 ++++ dev/release/rat_exclude_files.txt | 51 +++ dev/requirements.txt | 19 + dev/schema/cases.base.schema.json | 46 +++ dev/schema/cases.types.schema.json | 21 + dev/validate-fixtures.py | 139 +++++++ runners/README.md | 71 ++++ runners/go/.gitignore | 2 + runners/go/README.md | 51 +++ runners/go/go.mod | 24 ++ runners/go/go.sum | 193 ++++++++++ runners/go/main.go | 343 +++++++++++++++++ runners/java/.gitignore | 2 + runners/java/README.md | 52 +++ runners/java/build.gradle | 69 ++++ .../java/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 8 + runners/java/gradlew | 253 ++++++++++++ runners/java/gradlew.bat | 95 +++++ runners/java/settings.gradle | 20 + .../iceberg/conformance/VerifyTypes.java | 364 ++++++++++++++++++ runners/python/.gitignore | 3 + runners/python/README.md | 56 +++ runners/python/runner.py | 256 ++++++++++++ runners/rust/.gitignore | 7 + runners/rust/Cargo.toml | 33 ++ runners/rust/README.md | 60 +++ runners/rust/rust-toolchain.toml | 6 + runners/rust/src/main.rs | 329 ++++++++++++++++ table-spec/manifest.json | 10 + table-spec/types/README.md | 110 ++++++ table-spec/types/geospatial/cases.json | 52 +++ table-spec/types/nested/cases.json | 67 ++++ table-spec/types/primitive/cases.json | 33 ++ table-spec/types/variant/cases.json | 5 + 47 files changed, 3938 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/conformance-go.yml create mode 100644 .github/workflows/conformance-nightly.yml create mode 100644 .github/workflows/conformance-python.yml create mode 100644 .github/workflows/conformance-rust.yml create mode 100644 .github/workflows/license_check.yml create mode 100644 .github/workflows/validate-fixtures.yml create mode 100644 CONTRIBUTING.md create mode 100755 dev/build-status-matrix.py create mode 100755 dev/check-license create mode 100755 dev/check-runner-deps.sh create mode 100755 dev/ci-report.sh create mode 100644 dev/release/rat_exclude_files.txt create mode 100644 dev/requirements.txt create mode 100644 dev/schema/cases.base.schema.json create mode 100644 dev/schema/cases.types.schema.json create mode 100755 dev/validate-fixtures.py create mode 100644 runners/README.md create mode 100644 runners/go/.gitignore create mode 100644 runners/go/README.md create mode 100644 runners/go/go.mod create mode 100644 runners/go/go.sum create mode 100644 runners/go/main.go create mode 100644 runners/java/.gitignore create mode 100644 runners/java/README.md create mode 100644 runners/java/build.gradle create mode 100644 runners/java/gradle/wrapper/gradle-wrapper.jar create mode 100644 runners/java/gradle/wrapper/gradle-wrapper.properties create mode 100755 runners/java/gradlew create mode 100644 runners/java/gradlew.bat create mode 100644 runners/java/settings.gradle create mode 100644 runners/java/src/main/java/org/apache/iceberg/conformance/VerifyTypes.java create mode 100644 runners/python/.gitignore create mode 100644 runners/python/README.md create mode 100755 runners/python/runner.py create mode 100644 runners/rust/.gitignore create mode 100644 runners/rust/Cargo.toml create mode 100644 runners/rust/README.md create mode 100644 runners/rust/rust-toolchain.toml create mode 100644 runners/rust/src/main.rs create mode 100644 table-spec/manifest.json create mode 100644 table-spec/types/README.md create mode 100644 table-spec/types/geospatial/cases.json create mode 100644 table-spec/types/nested/cases.json create mode 100644 table-spec/types/primitive/cases.json create mode 100644 table-spec/types/variant/cases.json diff --git a/.github/workflows/conformance-go.yml b/.github/workflows/conformance-go.yml new file mode 100644 index 0000000..fa22b46 --- /dev/null +++ b/.github/workflows/conformance-go.yml @@ -0,0 +1,74 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +# Conformance: the Go runner against apache/iceberg-go main HEAD (latest commit). +# One implementation per workflow. Go/Rust/Python track main; Java tracks the +# nightly SNAPSHOT. +name: "Conformance (Go)" + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + conformance-go: + name: iceberg-go type conformance + runs-on: ubuntu-24.04 + # Non-blocking: report a divergence, do not fail this repo's CI. + continue-on-error: true + steps: + - name: Checkout fixtures + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.25.9' + cache-dependency-path: runners/go/go.sum + - name: Run type conformance + id: run + working-directory: runners/go + # Build against iceberg-go main HEAD; the go.mod edit is ephemeral, not committed. + run: | + set +e + go get github.com/apache/iceberg-go@main || { echo "code=2" >> "$GITHUB_OUTPUT"; exit 2; } + go mod tidy || { echo "code=2" >> "$GITHUB_OUTPUT"; exit 2; } + resolved="iceberg-go@$(go list -m github.com/apache/iceberg-go | awk '{print $2}')" + echo "resolved=$resolved" >> "$GITHUB_OUTPUT" + go build -o /tmp/conformance-go . || { echo "code=2" >> "$GITHUB_OUTPUT"; exit 2; } + /tmp/conformance-go 2>&1 | tee "$GITHUB_WORKSPACE/runner-output.txt" + code=${PIPESTATUS[0]} + echo "code=$code" >> "$GITHUB_OUTPUT" + # Report-only: only a runner ERROR (2) reddens the check, not a divergence. + if [ "$code" = "2" ]; then exit 1; fi + - name: Report results (step summary + annotations) + if: always() + run: > + bash "$GITHUB_WORKSPACE/dev/ci-report.sh" + iceberg-go "$GITHUB_WORKSPACE/runner-output.txt" + main "${{ steps.run.outputs.code }}" + "${{ steps.run.outputs.resolved }}" diff --git a/.github/workflows/conformance-nightly.yml b/.github/workflows/conformance-nightly.yml new file mode 100644 index 0000000..447ad03 --- /dev/null +++ b/.github/workflows/conformance-nightly.yml @@ -0,0 +1,288 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +# Nightly conformance: run all four runners once a day, render the aggregate +# matrix into the run summary, and refresh the README status badges. Go/Rust/Python +# track their apache/main HEAD; Java tracks the latest published SNAPSHOT (resolved +# at run time), timed after apache/iceberg publishes it at 00:00 UTC. +# Report-only: every job is non-blocking and nothing here gates or commits. +name: "Conformance (Nightly)" + +on: + schedule: + - cron: '0 6 * * *' # 06:00 UTC daily, after the 00:00 UTC Java SNAPSHOT publish + workflow_dispatch: + +concurrency: + group: conformance-nightly + cancel-in-progress: false + +permissions: + contents: read + +jobs: + nightly-go: + name: iceberg-go (nightly) + runs-on: ubuntu-24.04 + continue-on-error: true + steps: + - name: Checkout fixtures + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.25.9' + cache-dependency-path: runners/go/go.sum + - name: Run type conformance + id: run + working-directory: runners/go + run: | + set +e + mkdir -p /tmp/status + out=/tmp/status/iceberg-go.txt + go get github.com/apache/iceberg-go@main && go mod tidy + if ! go build -o /tmp/conformance-go . ; then + echo "ERROR: go build failed (see step log)" > "$out" + echo "code=2" >> "$GITHUB_OUTPUT"; exit 0 + fi + echo "resolved=iceberg-go@$(go list -m github.com/apache/iceberg-go | awk '{print $2}')" >> "$GITHUB_OUTPUT" + /tmp/conformance-go 2>&1 | tee "$out" + echo "code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + - name: Report results (step summary + annotations) + if: always() + run: > + bash "$GITHUB_WORKSPACE/dev/ci-report.sh" + iceberg-go /tmp/status/iceberg-go.txt + nightly "${{ steps.run.outputs.code }}" + "${{ steps.run.outputs.resolved }}" + - name: Upload per-impl result + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: status-iceberg-go + path: /tmp/status/iceberg-go.txt + if-no-files-found: error + + nightly-python: + name: pyiceberg (nightly) + runs-on: ubuntu-24.04 + continue-on-error: true + steps: + - name: Checkout fixtures + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + - name: Install pyiceberg (main HEAD) + run: uv pip install --system "pyiceberg @ git+https://github.com/apache/iceberg-python.git@main" + - name: Run type conformance + id: run + working-directory: runners/python + run: | + set +e + mkdir -p /tmp/status + out=/tmp/status/pyiceberg.txt + python runner.py 2>&1 | tee "$out" + echo "code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + - name: Report results (step summary + annotations) + if: always() + run: > + bash "$GITHUB_WORKSPACE/dev/ci-report.sh" + pyiceberg /tmp/status/pyiceberg.txt + nightly "${{ steps.run.outputs.code }}" + pyiceberg@main + - name: Upload per-impl result + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: status-pyiceberg + path: /tmp/status/pyiceberg.txt + if-no-files-found: error + + nightly-rust: + name: iceberg-rust (nightly) + runs-on: ubuntu-24.04 + continue-on-error: true + steps: + - name: Checkout fixtures + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Rust toolchain + working-directory: runners/rust + run: rustup show && rustup update stable + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2 + with: + workspaces: runners/rust + - name: Run type conformance + id: run + working-directory: runners/rust + run: | + set +e + mkdir -p /tmp/status + out=/tmp/status/iceberg-rust.txt + cargo add iceberg --git https://github.com/apache/iceberg-rust --branch main --no-default-features + if ! cargo build --quiet ; then + echo "ERROR: cargo build failed (see step log)" > "$out" + echo "code=2" >> "$GITHUB_OUTPUT"; exit 0 + fi + cargo run --quiet 2>&1 | tee "$out" + echo "code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + - name: Report results (step summary + annotations) + if: always() + run: > + bash "$GITHUB_WORKSPACE/dev/ci-report.sh" + iceberg-rust /tmp/status/iceberg-rust.txt + nightly "${{ steps.run.outputs.code }}" + iceberg-rust@main + - name: Upload per-impl result + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: status-iceberg-rust + path: /tmp/status/iceberg-rust.txt + if-no-files-found: error + + nightly-java: + name: iceberg-java (nightly SNAPSHOT) + runs-on: ubuntu-24.04 + continue-on-error: true + steps: + - name: Checkout fixtures + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: zulu + java-version: '17' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + - name: Run type conformance + id: run + working-directory: runners/java + # Resolve the latest iceberg-api SNAPSHOT at run time (pinned fallback), then + # run the launcher directly so the runner's own exit code survives. + run: | + set +e + mkdir -p /tmp/status + out=/tmp/status/iceberg-java.txt + meta="https://repository.apache.org/content/repositories/snapshots/org/apache/iceberg/iceberg-api/maven-metadata.xml" + version="$(curl -fsSL "$meta" | grep -o '[^<]*' | sed -e 's/<[^>]*>//g' | head -1)" + if [ -z "$version" ]; then + version="1.12.0-SNAPSHOT" + echo "WARN: could not resolve latest SNAPSHOT from $meta; using fallback $version" >&2 + fi + echo "resolved=iceberg-api:$version" >> "$GITHUB_OUTPUT" + if ! ./gradlew installDist --quiet -PicebergVersion="$version" ; then + echo "ERROR: gradle installDist failed (see step log)" > "$out" + echo "code=2" >> "$GITHUB_OUTPUT"; exit 0 + fi + build/install/conformance-java/bin/conformance-java 2>&1 | tee "$out" + echo "code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + - name: Report results (step summary + annotations) + if: always() + run: > + bash "$GITHUB_WORKSPACE/dev/ci-report.sh" + iceberg-java /tmp/status/iceberg-java.txt + nightly "${{ steps.run.outputs.code }}" + "${{ steps.run.outputs.resolved }}" + - name: Upload per-impl result + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: status-iceberg-java + path: /tmp/status/iceberg-java.txt + if-no-files-found: error + + render: + name: render status matrix + needs: [nightly-go, nightly-python, nightly-rust, nightly-java] + if: always() + runs-on: ubuntu-24.04 + steps: + - name: Checkout fixtures + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Collect per-impl results + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: status-* + merge-multiple: true + path: /tmp/status + - name: Render matrix into the run summary + run: | + python3 dev/build-status-matrix.py /tmp/status /tmp/STATUS.md + cat /tmp/STATUS.md >> "$GITHUB_STEP_SUMMARY" + - name: Publish aggregate results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: nightly-status + path: /tmp/status/*.txt + if-no-files-found: error + + badges: + name: update ${{ matrix.impl }} badge + needs: render + # Never on a pull_request: the gist PAT must not be reachable from fork code. + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + continue-on-error: true + strategy: + matrix: + impl: [iceberg-go, iceberg-rust, pyiceberg, iceberg-java] + steps: + - name: Checkout (for build-status-matrix.py) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Collect results + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: nightly-status + path: /tmp/status + - name: Compute badge fields + id: fields + env: + IMPL: ${{ matrix.impl }} + run: | + python3 dev/build-status-matrix.py --json /tmp/status /tmp/badges + f="/tmp/badges/${IMPL}.json" + echo "message=$(python3 -c "import json;print(json.load(open('$f'))['message'])")" >> "$GITHUB_OUTPUT" + echo "color=$(python3 -c "import json;print(json.load(open('$f'))['color'])")" >> "$GITHUB_OUTPUT" + - name: Update gist badge + uses: schneegans/dynamic-badges-action@28b0fa8bdeb46170ac397105ece0c1fe58f68910 # v1.9.0 + with: + auth: ${{ secrets.GIST_TOKEN }} + gistID: __GIST_ID__ # TODO: the secret gist's id (github.com//) + filename: ${{ matrix.impl }}.json + label: ${{ matrix.impl }} + message: ${{ steps.fields.outputs.message }} + color: ${{ steps.fields.outputs.color }} diff --git a/.github/workflows/conformance-python.yml b/.github/workflows/conformance-python.yml new file mode 100644 index 0000000..9050d1a --- /dev/null +++ b/.github/workflows/conformance-python.yml @@ -0,0 +1,76 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +# Conformance: the Python runner against apache/iceberg-python main HEAD (latest +# commit). One implementation per workflow. Go/Rust/Python track main; Java +# tracks the nightly SNAPSHOT. +name: "Conformance (Python)" + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + conformance-python: + name: pyiceberg type conformance + runs-on: ubuntu-24.04 + # Non-blocking: report a divergence, do not fail this repo's CI. + continue-on-error: true + steps: + - name: Checkout fixtures + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + - name: Install pyiceberg (main HEAD) + id: install + run: | + uv pip install --system "pyiceberg @ git+https://github.com/apache/iceberg-python.git@main" + echo "resolved=pyiceberg@main" >> "$GITHUB_OUTPUT" + - name: Run type conformance + id: run + working-directory: runners/python + run: | + set +e + python runner.py 2>&1 | tee "$GITHUB_WORKSPACE/runner-output.txt" + code=${PIPESTATUS[0]} + echo "code=$code" >> "$GITHUB_OUTPUT" + # Report-only: only a runner ERROR (2) reddens the check, not a divergence. + if [ "$code" = "2" ]; then exit 1; fi + - name: Report results (step summary + annotations) + if: always() + run: > + bash "$GITHUB_WORKSPACE/dev/ci-report.sh" + pyiceberg "$GITHUB_WORKSPACE/runner-output.txt" + main "${{ steps.run.outputs.code }}" + "${{ steps.install.outputs.resolved }}" diff --git a/.github/workflows/conformance-rust.yml b/.github/workflows/conformance-rust.yml new file mode 100644 index 0000000..4bb5c97 --- /dev/null +++ b/.github/workflows/conformance-rust.yml @@ -0,0 +1,75 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +# Conformance: the Rust runner against apache/iceberg-rust main HEAD (latest +# commit). One implementation per workflow. Go/Rust/Python track main; Java +# tracks the nightly SNAPSHOT. +name: "Conformance (Rust)" + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + conformance-rust: + name: iceberg-rust type conformance + runs-on: ubuntu-24.04 + # Non-blocking: report a divergence, do not fail this repo's CI. + continue-on-error: true + steps: + - name: Checkout fixtures + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Rust toolchain + working-directory: runners/rust + # rust-toolchain.toml (channel = stable) is the source of truth. + run: rustup show && rustup update stable + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2 + with: + workspaces: runners/rust + - name: Run type conformance + id: run + working-directory: runners/rust + # Build against iceberg-rust main HEAD; the Cargo.toml edit is ephemeral, not committed. + run: | + set +e + cargo add iceberg --git https://github.com/apache/iceberg-rust --branch main --no-default-features || { echo "code=2" >> "$GITHUB_OUTPUT"; exit 2; } + cargo build --quiet || { echo "code=2" >> "$GITHUB_OUTPUT"; exit 2; } + echo "resolved=iceberg-rust@main" >> "$GITHUB_OUTPUT" + cargo run --quiet 2>&1 | tee "$GITHUB_WORKSPACE/runner-output.txt" + code=${PIPESTATUS[0]} + echo "code=$code" >> "$GITHUB_OUTPUT" + # Report-only: only a runner ERROR (2) reddens the check, not a divergence. + if [ "$code" = "2" ]; then exit 1; fi + - name: Report results (step summary + annotations) + if: always() + run: > + bash "$GITHUB_WORKSPACE/dev/ci-report.sh" + iceberg-rust "$GITHUB_WORKSPACE/runner-output.txt" + main "${{ steps.run.outputs.code }}" + "${{ steps.run.outputs.resolved }}" diff --git a/.github/workflows/license_check.yml b/.github/workflows/license_check.yml new file mode 100644 index 0000000..e873f1c --- /dev/null +++ b/.github/workflows/license_check.yml @@ -0,0 +1,32 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +name: "Run License Check" +on: pull_request + +permissions: + contents: read + +jobs: + rat: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: | + dev/check-license diff --git a/.github/workflows/validate-fixtures.yml b/.github/workflows/validate-fixtures.yml new file mode 100644 index 0000000..957cdcb --- /dev/null +++ b/.github/workflows/validate-fixtures.yml @@ -0,0 +1,50 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +name: "Validate Fixtures" + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + validate: + name: Validate fixture cases + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.12' + - name: Install validator deps + run: python3 -m pip install -r dev/requirements.txt + - name: Validate cases.json + run: python3 dev/validate-fixtures.py + - name: Check runner deps are release-clean + run: bash dev/check-runner-deps.sh diff --git a/.gitignore b/.gitignore index 9d61ad5..9f761ab 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,21 @@ *~ .idea/ .vscode/ + +# Apache RAT jar downloaded by dev/check-license +/lib/ + +# Build artifacts +runners/rust/target/ +runners/java/.gradle/ +runners/java/build/ +runners/python/.venv/ +__pycache__/ +*.py[cod] + +# Vendored implementation checkouts (cloned at runtime by dev/run-local.sh, never committed) +runners/go/iceberg-go/ +runners/rust/iceberg-rust/ +runners/python/iceberg-python/ +runners/java/iceberg-src/ +runners/**/*.bak diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2a3349f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,83 @@ + + +# Contributing + +## Repository layout + +``` +table-spec///cases.json spec-derived fixtures, one answer key +table-spec/manifest.json the surfaces an implementation can subscribe to +runners// one runner per language, reads fixtures, reports PASS/FAIL/UNSUPPORTED +dev/ validation, reporting, and check scripts +dev/schema/ JSON Schemas the fixtures are validated against +``` + +A surface (for example `types`) is a group of cases with one assertion contract, +documented in its own `README.md` (see `table-spec/types/README.md`). A subdirectory +under a surface is a unit an implementation can opt into or skip. + +## Two tiers + +Conformance runs at two altitudes, and a surface belongs to whichever fits. + +- **Central runner (this repo).** Surfaces whose expected answer is derivable from + the spec with no implementation-specific knowledge (the type surface is the clear + case) are exercised by the runners here and reported by the nightly matrix and the + README badges. This tier needs no buy-in from an implementation to produce a signal. +- **Submodule subscription (the implementation).** Surfaces whose assertion needs an + implementation's own internals are better owned by that implementation: it pins this + repository, selects the surfaces it wants, and runs them in its own CI, pre-merge. + +Both tiers read the same fixtures. The answer key is single-sourced here regardless of +which tier runs it. + +## Fixture format + +Each `cases.json` is a JSON object with a `cases` array. Every case has a unique `id`, +a boolean `valid`, and an `input`; a valid case also has `decoded`. The exact per-case +shape is defined by the JSON Schemas in `dev/schema/` and described in each surface's +`README.md`. Validate locally with: + +``` +python3 dev/validate-fixtures.py +``` + +## Adding or correcting a fixture + +1. Add or edit the case in the surface's `cases.json`. Derive `input`/`decoded` from + `format/spec.md`, not from any single implementation. +2. If you add a new subdirectory or surface, list it in `table-spec/manifest.json`. +3. Run `python3 dev/validate-fixtures.py` (schema, unique ids, manifest consistency). + +## Subscribing from an implementation + +1. Pin this repository at a commit (a git submodule, or a sparse checkout of the + `table-spec/` paths you care about). Bump the pin deliberately. +2. Read `table-spec/manifest.json` to see the available surfaces and subdirectories. +3. Run the surfaces you opted into. Either run this repo's runner scoped to them: + + ``` + conformance- --surface types # a whole surface + conformance- --surface types/primitive # one subdirectory + ``` + + (or set `CONFORMANCE_SURFACES="types types/geospatial"`), or read the fixtures and + assert against them with your own glue. With no `--surface`, a runner reads every + subscribed surface. diff --git a/README.md b/README.md index 91bd140..1af0637 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,15 @@ A verification framework for [Apache Iceberg](https://iceberg.apache.org/) implementations: language-neutral conformance fixtures, and the runners that exercise them. +## Status + +Nightly type-surface conformance per implementation (informational, non-gating): + +![iceberg-go](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com///raw/iceberg-go.json) +![iceberg-rust](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com///raw/iceberg-rust.json) +![pyiceberg](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com///raw/pyiceberg.json) +![iceberg-java](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com///raw/iceberg-java.json) + ## Motivation The Iceberg [specification](https://iceberg.apache.org/spec/) is prose. Each @@ -48,13 +57,21 @@ file format writer version are out of scope. Any spec-valid encoding is valid. Adoption is by self-election and is incremental. An implementation pins this repository to a commit, runs the surfaces it opts into, and bumps that pin -deliberately. There is no central conformance gate and no pass/fail matrix -across implementations. +deliberately. There is no central conformance gate. The nightly status matrix +and the README badges are informational only - they report divergences and +never block any implementation's CI. + +Conformance runs at two tiers: spec-derivable surfaces (such as types) are +exercised by the runners here and reported nightly; surfaces that need an +implementation's own internals are subscribed to and run by that implementation +in its own CI. Both tiers read the same fixtures. See +[`CONTRIBUTING.md`](CONTRIBUTING.md). ## Contributing -TODO: `CONTRIBUTING.md`, covering repository layout, fixture format, and how to -add or correct a fixture. +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the repository layout, the two-tier +model, the fixture format, and how to add or correct a fixture or subscribe from +an implementation. ## License diff --git a/dev/build-status-matrix.py b/dev/build-status-matrix.py new file mode 100755 index 0000000..b8e5180 --- /dev/null +++ b/dev/build-status-matrix.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +"""Aggregate runner outputs into a cross-implementation STATUS.md matrix. + +Reads a directory of runner-output files named .txt (each the +captured stdout of a conformance runner) and writes a Markdown status matrix. +It reports counts and divergences; it does not block. + +Usage: + dev/build-status-matrix.py [] render the matrix + dev/build-status-matrix.py --json per-impl badge JSON +""" + +import glob +import json +import os +import re +import sys + +TOTALS_RE = re.compile( + r"TOTALS:\s*(\d+)\s*cases\s*\|\s*PASS=(\d+)\s*FAIL=(\d+)\s*UNSUPPORTED=(\d+)\s*SKIP=(\d+)" +) + +HEADER = """ + +# Type-surface conformance status + +Generated by `dev/build-status-matrix.py` from the type-surface runners +(`table-spec/types/`). It reports; it does not block: + +- **PASS** - matches the spec-derived fixture. +- **FAIL** - a genuine divergence from the spec-derived fixture. +- **UNSUPPORTED** - a type the implementation has not built yet (a skip-list candidate). +""" + + +def parse(path): + fails = unsup = "" + totals = None + with open(path, encoding="utf-8", errors="replace") as fh: + for line in fh: + m = TOTALS_RE.search(line) + if m: + totals = tuple(int(x) for x in m.groups()) # cases,pass,fail,unsup,skip + elif line.startswith("FAIL ids:"): + fails = line.split(":", 1)[1].strip() + elif line.startswith("UNSUPPORTED ids:"): + unsup = line.split(":", 1)[1].strip() + return totals, fails, unsup + + +def badge_fields(totals): + """(message, color) for a shields.io endpoint badge from a parsed TOTALS tuple. + + Four distinct states so a crash is never a false green: green all-pass, + yellow unsupported-only, red any FAIL, lightgrey no report (runner errored). + """ + if totals is None: + return "no report", "lightgrey" + cases, p, f, u, _s = totals + message = f"{p}/{cases} passed" + if f > 0: + return message, "red" + if u > 0: + return message, "yellow" + return message, "brightgreen" + + +def write_badges(outdir, jsondir): + os.makedirs(jsondir, exist_ok=True) + n = 0 + for path in sorted(glob.glob(os.path.join(outdir, "*.txt"))): + impl = os.path.splitext(os.path.basename(path))[0] + totals, _fails, _unsup = parse(path) + message, color = badge_fields(totals) + blob = {"schemaVersion": 1, "label": impl, "message": message, "color": color} + with open(os.path.join(jsondir, impl + ".json"), "w", encoding="utf-8") as fh: + json.dump(blob, fh) + n += 1 + print(f"wrote {n} badge json file(s) to {jsondir}") + return 0 + + +def main(): + if len(sys.argv) >= 2 and sys.argv[1] == "--json": + if len(sys.argv) < 4: + print("usage: build-status-matrix.py --json ", file=sys.stderr) + return 2 + return write_badges(sys.argv[2], sys.argv[3]) + if len(sys.argv) < 2: + print("usage: build-status-matrix.py []", file=sys.stderr) + return 2 + outdir = sys.argv[1] + dest = sys.argv[2] if len(sys.argv) > 2 else None + + rows = [] + divergences = [] + for path in sorted(glob.glob(os.path.join(outdir, "*.txt"))): + impl = os.path.splitext(os.path.basename(path))[0] + totals, fails, unsup = parse(path) + if totals is None: + rows.append((impl, "-", "-", "-", "-", "no TOTALS line found")) + continue + cases, p, f, u, s = totals + if f > 0: + note = f"{f} divergence(s)" + elif u > 0: + note = f"conformant; {u} unsupported" + else: + note = "conformant" + rows.append((impl, p, f, u, s, note)) + if fails: + divergences.append(f"- **{impl}** FAIL: {fails}") + if unsup: + divergences.append(f"- **{impl}** UNSUPPORTED: {unsup}") + + lines = [HEADER, "", "| Implementation | Pass | Fail | Unsupported | Skip | Status |", + "|---|---|---|---|---|---|"] + for impl, p, f, u, s, note in rows: + lines.append(f"| {impl} | {p} | {f} | {u} | {s} | {note} |") + lines.append("") + lines.append("## Divergences and gaps") + lines.append("") + lines.extend(divergences or ["None reported."]) + lines.append("") + text = "\n".join(lines) + "\n" + + if dest: + with open(dest, "w", encoding="utf-8") as fh: + fh.write(text) + print(f"wrote {dest} ({len(rows)} implementations)") + else: + sys.stdout.write(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/dev/check-license b/dev/check-license new file mode 100755 index 0000000..cdbfd6a --- /dev/null +++ b/dev/check-license @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + +acquire_rat_jar () { + + URL="https://repo.maven.apache.org/maven2/org/apache/rat/apache-rat/${RAT_VERSION}/apache-rat-${RAT_VERSION}.jar" + + JAR="$rat_jar" + + # Download rat launch jar if it hasn't been downloaded yet + if [ ! -f "$JAR" ]; then + # Download + printf "Attempting to fetch rat\n" + JAR_DL="${JAR}.part" + if [ $(command -v curl) ]; then + curl -L --silent "${URL}" > "$JAR_DL" && mv "$JAR_DL" "$JAR" + elif [ $(command -v wget) ]; then + wget --quiet ${URL} -O "$JAR_DL" && mv "$JAR_DL" "$JAR" + else + printf "You do not have curl or wget installed, please install rat manually.\n" + exit -1 + fi + fi + + unzip -tq "$JAR" &> /dev/null + if [ $? -ne 0 ]; then + # We failed to download + rm "$JAR" + printf "Our attempt to download rat locally to ${JAR} failed. Please install rat manually.\n" + exit -1 + fi +} + +# Go to the project root directory +FWDIR="$(cd "`dirname "$0"`"/..; pwd)" +cd "$FWDIR" + +if test -x "$JAVA_HOME/bin/java"; then + declare java_cmd="$JAVA_HOME/bin/java" +else + declare java_cmd=java +fi + +export RAT_VERSION=0.17 +export rat_jar="$FWDIR"/lib/apache-rat-${RAT_VERSION}.jar +mkdir -p "$FWDIR"/lib + +[[ -f "$rat_jar" ]] || acquire_rat_jar || { + echo "Download failed. Obtain the rat jar manually and place it at $rat_jar" + exit 1 +} + +# --input-exclude-std GIT skips .git and anything .gitignore'd. Without it RAT +# flags the .git pointer *file* that git worktrees use, since its built-in SCM +# exclusion only covers .git directories. +$java_cmd -jar "$rat_jar" \ + --input-exclude-file "$FWDIR"/dev/release/rat_exclude_files.txt \ + --input-exclude-std GIT IDEA MAC \ + --input-include-std HIDDEN_DIR \ + --output-style missing-headers \ + --log-level ERROR \ + -- "$FWDIR" || exit 1 + +echo "RAT checks passed." diff --git a/dev/check-runner-deps.sh b/dev/check-runner-deps.sh new file mode 100755 index 0000000..c0ad5e8 --- /dev/null +++ b/dev/check-runner-deps.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# +# Assert the committed runner dependency files target published releases, not a +# local source override. Testing against apache/main uses a `replace` / +# `[patch.crates-io]` / `--include-build`; if one of those leaks into a commit, +# CI would build against a path that does not exist on the runner. This fails +# fast with a clear message instead. +set -uo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" +fail=0 + +check() { # + if grep -nE "$2" "$root/$1" >/dev/null 2>&1; then + echo "FAIL: $1 - $3" + grep -nE "$2" "$root/$1" | sed 's/^/ /' + fail=1 + fi +} + +check runners/go/go.mod '^[[:space:]]*replace[[:space:]]' "committed 'replace' - local override leaked" +check runners/rust/Cargo.toml '^\[patch|iceberg[[:space:]]*=[[:space:]]*\{[^}]*path' "committed patch/path dep - local override leaked" +check runners/java/build.gradle 'mavenLocal|includeBuild' "committed local build reference - leaked" + +if [ "$fail" -eq 0 ]; then + echo "runner deps are release-clean" +else + exit 1 +fi diff --git a/dev/ci-report.sh b/dev/ci-report.sh new file mode 100755 index 0000000..588ca9e --- /dev/null +++ b/dev/ci-report.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# +# Render a runner's result into per-run CI visibility: a Step Summary table, +# inline annotations, and a one-line verdict. Reporting only - it never changes +# the job status. +# +# Usage: ci-report.sh [] +# lane a label for the run shown in the report (release, main, ...) +# exit-code the runner's exit: 0 PASS, 1 FAIL (divergence), 2 ERROR (setup) +# resolved-ver what was actually tested (jar version, commit SHA, ...); provenance +set -uo pipefail + +impl="${1:?usage: ci-report.sh []}" +out="${2:?usage: ci-report.sh []}" +lane="${3:-release}" +# An empty/missing code means the run step was skipped (a setup step before it +# failed), so default to ERROR - never PASS. +code="${4:-2}" +resolved="${5:-unknown}" + +totals="$(grep -m1 '^TOTALS:' "$out" 2>/dev/null || echo 'TOTALS: (runner produced no summary - see the step log)')" +fails="$(grep -m1 '^FAIL ids:' "$out" 2>/dev/null || true)" +unsup="$(grep -m1 '^UNSUPPORTED ids:' "$out" 2>/dev/null || true)" + +case "$code" in + 0) verdict="PASS" ;; + 1) verdict="FAIL" ;; + *) verdict="ERROR" ;; # 2 = runner setup/parse error, or an unexpected code +esac + +# A conformance FAIL is a real divergence (error, but still non-blocking via the +# job's continue-on-error); a runner ERROR is a setup failure, not a verdict. +if [ "$verdict" = "FAIL" ]; then level="error"; else level="warning"; fi + +# Step Summary (rendered on the run page). No-op locally if the var is unset. +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "### ${impl} - types conformance (${lane} lane)" + echo + echo "- result: **${verdict}**" + echo "- tested against: \`${resolved}\`" + echo + echo '```' + echo "${totals}" + [ -n "${fails}" ] && echo "${fails}" + [ -n "${unsup}" ] && echo "${unsup}" + echo '```' + echo + } >> "${GITHUB_STEP_SUMMARY}" +fi + +# Inline annotations (surface on the PR/commit without failing the build). +case "$verdict" in + FAIL) echo "::${level} title=${impl} ${lane} divergence::${fails:-conformance FAIL} (tested ${resolved})" ;; + ERROR) echo "::warning title=${impl} ${lane} runner error::runner exited ${code} before a verdict - see the step log" ;; +esac +[ -n "${unsup}" ] && echo "::notice title=${impl} unsupported (skip-list candidate)::${unsup}" + +# Always echo the one-line verdict to the step log. +echo "RESULT: ${impl} [${lane}] ${verdict} (${resolved}) - ${totals}" diff --git a/dev/release/rat_exclude_files.txt b/dev/release/rat_exclude_files.txt new file mode 100644 index 0000000..8386c92 --- /dev/null +++ b/dev/release/rat_exclude_files.txt @@ -0,0 +1,51 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +.gitignore +LICENSE +NOTICE +lib + +# Go module bookkeeping files carry no license header by convention. +# RAT --input-exclude-file patterns are root-anchored, so subdir files need **/ globs. +**/go.mod +**/go.sum + +# Fixture data. JSON and JSON Lines have no comment syntax, so they cannot +# carry an Apache header; the surface README states the corpus is AL2-licensed. +**/*.json +**/*.jsonl + +# Gradle wrapper binary shipped with the Java runner so CI needs no preinstalled +# gradle; it is a binary jar and carries no license header. +runners/java/gradle/wrapper/gradle-wrapper.jar + +# Generated Gradle wrapper properties and the Rust toolchain pin: no header slot. +**/gradle-wrapper.properties +**/rust-toolchain.toml + +# Local build artifacts and checked-out implementations under test. All gitignored +# and absent from a clean CI checkout, but RAT's SCM exclusion does not honor nested +# .gitignore files during a dirty-tree local scan, so exclude them explicitly. +**/target/** +**/.gradle/** +**/build/** +runners/go/iceberg-go/** +runners/rust/iceberg-rust/** +runners/python/iceberg-python/** +runners/java/iceberg-src/** +runners/python/.venv/** diff --git a/dev/requirements.txt b/dev/requirements.txt new file mode 100644 index 0000000..382d9eb --- /dev/null +++ b/dev/requirements.txt @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +# Dependencies for the dev/ tooling (fixture validation). +jsonschema>=4.18 diff --git a/dev/schema/cases.base.schema.json b/dev/schema/cases.base.schema.json new file mode 100644 index 0000000..5d2b90b --- /dev/null +++ b/dev/schema/cases.base.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://iceberg.apache.org/verification/cases.base.schema.json", + "title": "Conformance cases (base)", + "description": "Structure shared by every table-spec surface's cases.json.", + "type": "object", + "required": ["cases"], + "properties": { + "cases": { + "type": "array", + "items": { "$ref": "#/$defs/case" } + } + }, + "$defs": { + "case": { + "type": "object", + "required": ["id", "valid", "input"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "valid": { "type": "boolean" }, + "input": true, + "decoded": true, + "canonical": { "type": "string" }, + "clause": { "type": "string" }, + "spec_ref": { "type": "string" } + }, + "allOf": [ + { + "$comment": "a valid case must carry decoded", + "if": { "properties": { "valid": { "const": true } }, "required": ["valid"] }, + "then": { "required": ["decoded"] } + }, + { + "$comment": "an invalid case must not carry decoded", + "if": { "properties": { "valid": { "const": false } }, "required": ["valid"] }, + "then": { "not": { "required": ["decoded"] } } + }, + { + "$comment": "canonical is only allowed on a valid case", + "if": { "required": ["canonical"] }, + "then": { "properties": { "valid": { "const": true } }, "required": ["valid"] } + } + ] + } + } +} diff --git a/dev/schema/cases.types.schema.json b/dev/schema/cases.types.schema.json new file mode 100644 index 0000000..bd010dc --- /dev/null +++ b/dev/schema/cases.types.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://iceberg.apache.org/verification/cases.types.schema.json", + "title": "Conformance cases (types surface extension)", + "description": "Extra constraints for table-spec/types: every case cites the spec. Applied on top of cases.base.schema.json by dev/validate-fixtures.py.", + "type": "object", + "required": ["cases"], + "properties": { + "cases": { + "type": "array", + "items": { + "type": "object", + "required": ["clause", "spec_ref"], + "properties": { + "clause": { "type": "string", "minLength": 1 }, + "spec_ref": { "type": "string", "minLength": 1 } + } + } + } + } +} diff --git a/dev/validate-fixtures.py b/dev/validate-fixtures.py new file mode 100755 index 0000000..cb61636 --- /dev/null +++ b/dev/validate-fixtures.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +"""Validate the conformance fixtures against the per-surface JSON Schemas. + +Also checks ids are globally unique and every fixture dir is listed in the manifest. +""" + +import glob +import json +import os +import sys + +import jsonschema + +HERE = os.path.dirname(os.path.abspath(__file__)) +SCHEMA_DIR = os.path.join(HERE, "schema") + + +def _load_schema(name): + with open(os.path.join(SCHEMA_DIR, name), encoding="utf-8") as fh: + return json.load(fh) + + +def _rel(path): + return path.replace(os.sep, "/") + + +def _is_types_surface(path): + p = _rel(path) + return "/table-spec/types/" in p or p.startswith("table-spec/types/") + + +def _check_manifest(root, files, errors): + """Every fixture dir must be listed in the manifest, and every listed dir must exist.""" + mpath = os.path.join(root, "table-spec", "manifest.json") + if not os.path.exists(mpath): + errors.append(f"{mpath}: missing surfaces manifest") + return + try: + with open(mpath, encoding="utf-8") as fh: + manifest = json.load(fh) + except json.JSONDecodeError as e: + errors.append(f"{mpath}: invalid JSON: {e}") + return + surfaces = manifest.get("surfaces") if isinstance(manifest, dict) else None + if not isinstance(surfaces, list): + errors.append(f"{mpath}: 'surfaces' must be an array") + return + + listed = set() + for s in surfaces: + spath = s.get("path") if isinstance(s, dict) else None + if not isinstance(spath, str) or not spath: + errors.append(f"{mpath}: a surface is missing a string 'path'") + continue + if not os.path.isdir(os.path.join(root, spath)): + errors.append(f"{mpath}: surface path '{spath}' does not exist") + for sd in s.get("subdirs") or []: + listed.add(f"{spath}/{sd}") + if not os.path.exists(os.path.join(root, spath, sd, "cases.json")): + errors.append(f"{mpath}: '{spath}/{sd}' is listed but has no cases.json") + + for path in files: + d = _rel(os.path.dirname(os.path.relpath(path, root))) + if d not in listed: + errors.append(f"{path}: fixture dir '{d}' is not listed in {mpath}") + + +def main(): + root = sys.argv[1] if len(sys.argv) > 1 else "." + base_validator = jsonschema.Draft202012Validator(_load_schema("cases.base.schema.json")) + types_validator = jsonschema.Draft202012Validator(_load_schema("cases.types.schema.json")) + + files = sorted(glob.glob(f"{root}/table-spec/**/cases.json", recursive=True)) + if not files: + print("no cases.json files found", file=sys.stderr) + return 1 + + errors = [] + seen_ids = {} + total = 0 + for path in files: + try: + with open(path, encoding="utf-8") as fh: + doc = json.load(fh) + except json.JSONDecodeError as e: + errors.append(f"{path}: invalid JSON: {e}") + continue + + validators = [base_validator] + if _is_types_surface(path): + validators.append(types_validator) + for validator in validators: + for err in sorted(validator.iter_errors(doc), key=str): + where = "/".join(str(p) for p in err.absolute_path) or "(root)" + errors.append(f"{path}: {where}: {err.message}") + + cases = doc.get("cases") if isinstance(doc, dict) else None + if isinstance(cases, list): + for i, case in enumerate(cases): + if isinstance(case, dict) and isinstance(case.get("id"), str) and case["id"]: + cid = case["id"] + if cid in seen_ids: + errors.append(f"{path}[{i}]: duplicate id '{cid}' (first seen at {seen_ids[cid]})") + else: + seen_ids[cid] = f"{path}[{i}]" + total += len(cases) + print(f" {path}: {len(cases)} cases") + + _check_manifest(root, files, errors) + + if errors: + print(f"\nFAILED: {len(errors)} problem(s) in {len(files)} file(s):", file=sys.stderr) + for e in errors: + print(f" {e}", file=sys.stderr) + return 1 + + print(f"\nOK: {total} cases across {len(files)} file(s), {len(seen_ids)} unique ids.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/runners/README.md b/runners/README.md new file mode 100644 index 0000000..2869a10 --- /dev/null +++ b/runners/README.md @@ -0,0 +1,71 @@ + + +# Runners + +A runner is a small program in one language that checks that implementation +against the fixtures. Each is self-contained under `runners//`. Go, +Rust, and Python each have an isolated push workflow +(`.github/workflows/conformance-.yml`) and Java runs in the nightly +workflow, so a break in one language's runner cannot redden another's. + +Every runner does the same thing; only the parser it calls differs: + +1. Read every `table-spec/**/cases.json` (a JSON object with a `cases` array). +2. For each case, parse `input` with the implementation's own type parser. +3. Apply the surface's assertion (see `table-spec/types/README.md`): + - `valid: false` -> the parser must reject `input`. + - `valid: true` -> parse succeeds and the decoded shape equals `decoded`; if + the case has `canonical`, the re-serialized type must also equal it byte for byte. +4. Report a type the implementation does not model as UNSUPPORTED. +5. Exit non-zero if any case FAILs. UNSUPPORTED does not fail the run. + +The three outcomes are distinct on purpose: FAIL is a genuine divergence from the +spec-derived expectation; UNSUPPORTED is a type the implementation has not built +yet (a skip-list candidate); ERROR is a build or setup failure, not a verdict. + +## How a runner reaches its implementation + +In CI, Go, Rust, and Python test the implementation's `apache/main` HEAD; Java +tracks the latest published `iceberg-api` SNAPSHOT (resolved at run time). The +committed dependency files pin a release only as a baseline; each workflow moves +the dependency to the tested source at run time, and nothing is committed. + +| Language | Runner | Dependency under test | +| --- | --- | --- | +| Go | `runners/go/` | `apache/iceberg-go` main, via `go get ...@main` | +| Java | `runners/java/` | the latest `org.apache.iceberg:iceberg-api` SNAPSHOT (nightly) | +| Python | `runners/python/` | `apache/iceberg-python` main, via `pip install` from git | +| Rust | `runners/rust/` | `apache/iceberg-rust` main, via a git dependency | + +C++ has no runner yet. + +### Nightly lane + +`conformance-nightly.yml` runs all four daily and renders the status matrix (and +README badges). Go/Rust/Python track their `apache/main`; Java tracks the resolved +latest SNAPSHOT. Report-only, non-gating. + +## Push and pull + +These runners are the push model: this repository runs them in CI against each +implementation. A consumer may instead vendor the fixtures and run an equivalent +check in its own CI (the pull model). Where runners should ultimately live - +here or in each implementation's repo - is still an open question for the +community; they are kept here for now so the push-model CI has something to run. diff --git a/runners/go/.gitignore b/runners/go/.gitignore new file mode 100644 index 0000000..d173d36 --- /dev/null +++ b/runners/go/.gitignore @@ -0,0 +1,2 @@ +# The implementation under test is checked out (CI) or symlinked (local) here. +/iceberg-go/ diff --git a/runners/go/README.md b/runners/go/README.md new file mode 100644 index 0000000..3d82d5d --- /dev/null +++ b/runners/go/README.md @@ -0,0 +1,51 @@ + + +# Go conformance runner + +Reference runner that checks [iceberg-go](https://github.com/apache/iceberg-go) +against the type-surface fixtures. It reads every `table-spec/**/cases.json`, +parses each `input` with iceberg-go's own type parser, and applies the assertion +contract in `table-spec/types/README.md` (`valid`/reject; decoded-shape compare, +no bytes, plus byte-exact `canonical` when the case has it). It exits non-zero if any case FAILs. + +## Where this fits (open question) + +The community has not yet decided whether runners live in this repository or in +each implementation's own repo. This runner is committed here so the push-model +CI has something to execute; a consumer may equally vendor the fixtures and run +an equivalent check in its own CI (the pull model). See the proposal. + +## Running it + +The runner depends on the `iceberg-go` module pinned in `go.mod`; `go run .` +resolves it. CI tests `apache/main` HEAD via `go get github.com/apache/iceberg-go@main`; +run that locally to check the same. + +```sh +# from runners/go (latest release) +go mod tidy +go run . +``` + +`go run .` discovers the repository root (the directory containing `table-spec/`) +by walking up, so it works from any working directory. Output is one line per +case plus totals; the exit code is non-zero on any FAIL. + +A type iceberg-go does not model is reported UNSUPPORTED, not FAIL. diff --git a/runners/go/go.mod b/runners/go/go.mod new file mode 100644 index 0000000..2e0cba6 --- /dev/null +++ b/runners/go/go.mod @@ -0,0 +1,24 @@ +module github.com/apache/iceberg-verification/runners/go + +go 1.25.9 + +require github.com/apache/iceberg-go v0.6.0 + +require ( + github.com/apache/arrow-go/v18 v18.7.0 // indirect + github.com/geoarrow/geoarrow-go v0.0.0-20260403143023-f54751c3e3a1 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/google/flatbuffers v25.12.19+incompatible // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/stretchr/objx v0.5.3 // indirect + github.com/stretchr/testify v1.12.1 // indirect + github.com/twmb/avro v1.8.0 // indirect + github.com/twmb/murmur3 v1.1.8 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/runners/go/go.sum b/runners/go/go.sum new file mode 100644 index 0000000..84f1786 --- /dev/null +++ b/runners/go/go.sum @@ -0,0 +1,193 @@ +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.23.0 h1:6Gg1CMgpgubRG7DGz5Vf1pcoNo8RfiRiRAPS4crTp54= +cloud.google.com/go/auth v0.23.0/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= +cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY= +cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= +cloud.google.com/go/storage v1.65.0 h1:McbFt5j+hTNx+dkFuzq7teakIKcpqGp/cJZRxMyfvAc= +cloud.google.com/go/storage v1.65.0/go.mod h1:UsS9OgFg/XHOSYakQ8ZtLWWeyGkk1WnmD/GsGfN0BHM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0 h1:irsmOWwkp0KCTTNS5e2hdFeIvSQClQo2No3IaNmL3Vw= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0/go.mod h1:GWcBkQj3MqN7ozHKLaCCAuNLiXoIGv2RtanfAwSjY/Y= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/apache/arrow-go/v18 v18.7.0 h1:Vw/i+cJyebUofT7JlqFpe65LrmwxULn166jjwStM4HY= +github.com/apache/arrow-go/v18 v18.7.0/go.mod h1:PM6IigLJkdMwIpeHXnymo+xZ52f42a9EYiLtRel4p/A= +github.com/apache/iceberg-go v0.6.0/go.mod h1:kESfDlyaW/6hK0WW6TzA4EWps+0NMhhrDYE4qajVjlo= +github.com/apache/thrift v0.24.0 h1:zy31L1a49QTNB2bG1BBfMXol3yJrTH975G3pPubQVLQ= +github.com/apache/thrift v0.24.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs= +github.com/aws/aws-sdk-go-v2 v1.43.7 h1:msCzvkeYJA9ehbV8mRRmkZLo/zJg/+yDVLNtflg83hQ= +github.com/aws/aws-sdk-go-v2 v1.43.7/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 h1:LAfOuhAH331fmOjTQpAaOlH+Ftn7RzSDJ2VFwjdMMy4= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18/go.mod h1:4e5xhuXHx1e4U9EthvbPP1r/DIMp5c2823OL8karzcM= +github.com/aws/aws-sdk-go-v2/config v1.32.38 h1:n4yPHBjtQ3BrIIUyk0/LAqf/BL2iv0Tw6XZcMRzM0ps= +github.com/aws/aws-sdk-go-v2/config v1.32.38/go.mod h1:dencYsOS1R7rBy8zehCvwBYzdxxL4Q/nRK7In03wjN8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.37 h1:FJ8Iz4/xISMB/rwLlgfWujfGDFWr0oneQgtA6KPcYLY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.37/go.mod h1:Q6pWOgVUp49x4g5QVi29wHofUoICnZ+Zq4jHbRN/7ec= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.38 h1:Nqo2jU1wz5rnBM9XQyXfVD1RP8txkbP3EDx8hR/hbCE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.38/go.mod h1:PzJFHhjR2vWFKHe8HmY5Lxhvwyxnr5MERtk0nDxWNbk= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.15 h1:wOcO1DYL4upopcLvwXQT8znMa3YThRqx9V0hkS11Bbg= +github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.3.15/go.mod h1:cPFhZ6seWlrpYh3+5dJs3xXHReDY48PSPwav6Zlj0QM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38 h1:MBMg0zJ6i4TkAJ0dVFLKKn2cOkY6FkicmUDM67BRr6g= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38/go.mod h1:9MWuJbyiUyj6eA7W1/zm1zuePDPSB3g+xcgRQeMWsXc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38 h1:lHm4jPf3k1Lz5ZWc+Vcn3MKVwym+26kWCba9FkJ4f0Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38/go.mod h1:Rn+P2XR+FbyZzjmWKjg/KUZNxmGfr5oZwh5jQiE+CzI= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39 h1:vo4xvMRs/F6h1E52qsgLqCQgWIQXgIJUauG6rlZEh4U= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39/go.mod h1:jB03R1ij/A+OE2e1dz6vgj076gd7vlYcfstAzj3HcnU= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 h1:OvYZOB3qA6zvfdRFiRFRzVSiElMYrz3GdntkXZxlp1o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17/go.mod h1:JgR/2Ew50ACfIWau1oeMRX59tMtC0kM+PYQGEaT04cY= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31 h1:uZOinZb+h7lZw8IYzP1z1IuEnueB76/EFkcf/fEW4Ag= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31/go.mod h1:NRtwAM/p5VRt03TlEUs0pH3TeWamWdf4YyJpSrzPYLc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 h1:H/5TI1jqaHsNoDQ60UwvPvJBg4GURkinXI3Qga29t2w= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38/go.mod h1:PTVFf+XH++7NJOky+RLBYQx0QA5NcaeEYFQ2fsi0nwo= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 h1:HLPAVrlLDaN2boN0xJx7MgaQDNEO3Q+c9L6kl/8m47Q= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39/go.mod h1:Pg/dVfsNkm1hsIDK/gMvCKtmyNfNTV12mrgHqVE/6Oo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3 h1:IKoCZqfWfZzSBi16QFQ+QcbQ3LRQ7QgB1S5tDAyPBQQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3/go.mod h1:RBpRcXiM4s2pOInVs32GsBonnje+fiAj4mcrStRmlCA= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.7 h1:YcczQ6zNH/ojIzD/ikDrO+RfW06wmdMp18d4NH5hXY4= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.7/go.mod h1:nl9RVnb9ulgAYzOkjLq1NyFxmWcnH2maCUEuOdESy98= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.7 h1:P+bMNiA93gyuYT3Oh+4dWtvrnGcu2bd9Uy5hRJM8BNo= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.7/go.mod h1:zy+397isDFLvleg9H18Zq2MGzMso7uKyJyzR7DWSgFk= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.7 h1:WWkehGZ4nWtOKLMy0yi8+RqzzVqAGe60hGaxwF06JAw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.7/go.mod h1:T8AI4SbQYm9ybcVmki2T3n7Qg1g3kfWoeQlNwNYOyO8= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.7 h1:yU/9y2r7s9kSUPbHXbpQTa4LA8kt+CMgpu1OBrhx8p4= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.7/go.mod h1:0lQTDEBArMevQXpxu443LVGjKxxEeSsSnrw9n8YiTMg= +github.com/aws/smithy-go v1.27.9 h1:flT/ACSU1ksz3V+8wj8kN8DOB9tsc/ggWPTJXIieRpw= +github.com/aws/smithy-go v1.27.9/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +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/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/geoarrow/geoarrow-go v0.0.0-20260403143023-f54751c3e3a1 h1:8VcjuP5pKZ717K4zOLS9Lm4Zsn8JwD9X+CQGFCiRT2E= +github.com/geoarrow/geoarrow-go v0.0.0-20260403143023-f54751c3e3a1/go.mod h1:XMOIOA5J96jEzAOpBROPyz0n14sHsooeF+y2FtGdrCY= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/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/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= +github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +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/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/googleapis/enterprise-certificate-proxy v0.3.20 h1:t/xL64VUoN69MuMRQuJETqYGOw4Z9mSRJK9epIEtwFk= +github.com/googleapis/enterprise-certificate-proxy v0.3.20/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w= +github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= +github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= +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/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/pierrec/lz4/v4 v4.1.29 h1:CDQY6qZOLI4DW0Nx6R1vRrifrCeQHnNXkMb0hZWXFjg= +github.com/pierrec/lz4/v4 v4.1.29/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4= +github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/twmb/avro v1.8.0 h1:UMWLg+nH4P3yad5Om7yFSohYLy2RG1s7BcFFiOvmK9Q= +github.com/twmb/avro v1.8.0/go.mod h1:X0fT1dY2xcbV4YuCE4mYro+qljHl4kUF5uA/2z1rgSk= +github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= +github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +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/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw= +go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q= +gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= +golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.293.0 h1:p9XIWOf63U4OgYx120ZwVU8+vl4XTPmWfgVPnmOAS9w= +google.golang.org/api v0.293.0/go.mod h1:6n5tjEB1gzwniZTepZ0g5u+wM7Bof5GeULCx/zh8ZE0= +google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94 h1:YJjbgu+dkp5kUJLfpMyCLfBIWZb/FcJyuLeo1gVBOuo= +google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:RRHjglSYABVCWpQ7USCpdfhcd9t4PkajvVwyynZizTc= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea h1:kVhQEPTpKQahD5+JSBTfBB19wcgQTTjAIn45MBqnyHk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/runners/go/main.go b/runners/go/main.go new file mode 100644 index 0000000..8dce8a8 --- /dev/null +++ b/runners/go/main.go @@ -0,0 +1,343 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +// Command conformance-go is the reference runner that checks iceberg-go against +// the type-surface conformance fixtures. It reads every table-spec/**/cases.json, +// parses each `input` with iceberg-go's own type parser, and applies the case +// assertion contract: +// +// valid=false -> the parser must return an error +// valid=true -> parse ok and the decoded shape == decoded; a type iceberg-go +// does not model is reported UNSUPPORTED (not a failure) +// +// The process exits non-zero if any case FAILs. +package main + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + + "github.com/apache/iceberg-go" +) + +// surfaceRoots are the directories under the repo root that hold cases. +var surfaceRoots = []string{"table-spec"} + +type testCase struct { + ID string `json:"id"` + Input json.RawMessage `json:"input"` + Valid bool `json:"valid"` + Decoded json.RawMessage `json:"decoded"` + Canonical *string `json:"canonical"` + source string +} + +// caseFile is the on-disk shape of a cases.json file. +type caseFile struct { + Cases []testCase `json:"cases"` +} + +// findRepoRoot walks up from start until it finds a directory containing +// table-spec/, so the runner works from any working directory. +func findRepoRoot(start string) (string, error) { + dir := start + for { + if fi, err := os.Stat(filepath.Join(dir, "table-spec")); err == nil && fi.IsDir() { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("could not locate repo root (no table-spec/) above %s", start) + } + dir = parent + } +} + +// surfaceMatch reports whether rel is under one of the requested surfaces (all if empty). +func surfaceMatch(rel string, surfaces []string) bool { + if len(surfaces) == 0 { + return true + } + r := filepath.ToSlash(rel) + for _, s := range surfaces { + prefix := filepath.ToSlash(filepath.Join("table-spec", s)) + if r == prefix || strings.HasPrefix(r, prefix+"/") { + return true + } + } + return false +} + +// loadCases reads every cases.json under the surface roots, filtered to surfaces (all if none). +func loadCases(root string, surfaces []string) ([]testCase, error) { + var cases []testCase + for _, sr := range surfaceRoots { + base := filepath.Join(root, sr) + if _, err := os.Stat(base); os.IsNotExist(err) { + continue + } + err := filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || d.Name() != "cases.json" { + return nil + } + rel, _ := filepath.Rel(root, path) + if !surfaceMatch(rel, surfaces) { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + var cf caseFile + if err := json.Unmarshal(data, &cf); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + for _, c := range cf.Cases { + c.source = rel + cases = append(cases, c) + } + return nil + }) + if err != nil { + return nil, err + } + } + sort.SliceStable(cases, func(i, j int) bool { return cases[i].ID < cases[j].ID }) + return cases, nil +} + +// parseType routes an Appendix-C type value (a JSON string for a primitive/geo +// type, or a JSON object for a nested type) through iceberg-go's public JSON +// parse path (NestedField.UnmarshalJSON -> typeIFace.UnmarshalJSON) by embedding +// it as the `type` of a field. +func parseType(input json.RawMessage) (iceberg.Type, error) { + fieldJSON := fmt.Sprintf(`{"id":1,"name":"f","required":true,"type":%s}`, string(input)) + var nf iceberg.NestedField + if err := json.Unmarshal([]byte(fieldJSON), &nf); err != nil { + return nil, err + } + return nf.Type, nil +} + +// decodedShape maps an iceberg-go type to the fixture's language-neutral +// `decoded` shape. Numbers are float64 to match json-decoded expectations. +// supported=false means iceberg-go has no such type. +func decodedShape(t iceberg.Type) (map[string]any, bool) { + switch v := t.(type) { + case iceberg.BooleanType: + return map[string]any{"type": "boolean"}, true + case iceberg.Int32Type: + return map[string]any{"type": "int"}, true + case iceberg.Int64Type: + return map[string]any{"type": "long"}, true + case iceberg.Float32Type: + return map[string]any{"type": "float"}, true + case iceberg.Float64Type: + return map[string]any{"type": "double"}, true + case iceberg.DateType: + return map[string]any{"type": "date"}, true + case iceberg.TimeType: + return map[string]any{"type": "time"}, true + case iceberg.TimestampType: + return map[string]any{"type": "timestamp"}, true + case iceberg.TimestampTzType: + return map[string]any{"type": "timestamptz"}, true + case iceberg.TimestampNsType: + return map[string]any{"type": "timestamp_ns"}, true + case iceberg.TimestampTzNsType: + return map[string]any{"type": "timestamptz_ns"}, true + case iceberg.StringType: + return map[string]any{"type": "string"}, true + case iceberg.UUIDType: + return map[string]any{"type": "uuid"}, true + case iceberg.BinaryType: + return map[string]any{"type": "binary"}, true + case iceberg.UnknownType: + return map[string]any{"type": "unknown"}, true + case iceberg.VariantType: + return map[string]any{"type": "variant"}, true + case iceberg.FixedType: + return map[string]any{"type": "fixed", "length": float64(v.Len())}, true + case iceberg.DecimalType: + return map[string]any{"type": "decimal", "precision": float64(v.Precision()), "scale": float64(v.Scale())}, true + case iceberg.GeometryType: + return map[string]any{"type": "geometry", "crs": v.CRS()}, true + case iceberg.GeographyType: + return map[string]any{"type": "geography", "crs": v.CRS(), "algorithm": v.Algorithm()}, true + case *iceberg.StructType: + fields := []any{} + for _, f := range v.Fields() { + child, ok := decodedShape(f.Type) + if !ok { + return nil, false + } + fields = append(fields, map[string]any{ + "id": float64(f.ID), "name": f.Name, "required": f.Required, "type": child, + }) + } + return map[string]any{"type": "struct", "fields": fields}, true + case *iceberg.ListType: + child, ok := decodedShape(v.Element) + if !ok { + return nil, false + } + return map[string]any{ + "type": "list", "element-id": float64(v.ElementID), + "element-required": v.ElementRequired, "element": child, + }, true + case *iceberg.MapType: + key, ok := decodedShape(v.KeyType) + if !ok { + return nil, false + } + val, ok := decodedShape(v.ValueType) + if !ok { + return nil, false + } + return map[string]any{ + "type": "map", "key-id": float64(v.KeyID), "key": key, + "value-id": float64(v.ValueID), "value-required": v.ValueRequired, "value": val, + }, true + default: + return nil, false + } +} + +func main() { + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintln(os.Stderr, "getwd:", err) + os.Exit(2) + } + root := cwd + var surfaces []string + args := os.Args[1:] + for i := 0; i < len(args); i++ { + switch a := args[i]; { + case a == "--surface": + if i+1 < len(args) { + surfaces = append(surfaces, args[i+1]) + i++ + } + case strings.HasPrefix(a, "--surface="): + surfaces = append(surfaces, strings.TrimPrefix(a, "--surface=")) + default: + root = a + } + } + if env := os.Getenv("CONFORMANCE_SURFACES"); env != "" { + surfaces = append(surfaces, strings.FieldsFunc(env, func(r rune) bool { + return r == ',' || r == ' ' + })...) + } + root, err = findRepoRoot(root) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + + cases, err := loadCases(root, surfaces) + if err != nil { + fmt.Fprintln(os.Stderr, "load cases:", err) + os.Exit(2) + } + if len(cases) == 0 { + fmt.Fprintln(os.Stderr, "no cases found under", root) + os.Exit(2) + } + + var pass, fail, unsupported int + var failIDs, unsupportedIDs []string + + for _, c := range cases { + typ, perr := parseType(c.Input) + + if !c.Valid { + if perr != nil { + fmt.Printf("%-32s PASS (rejected: %v)\n", c.ID, perr) + pass++ + } else { + fmt.Printf("%-32s FAIL (expected reject, parsed as %q)\n", c.ID, typ.Type()) + fail++ + failIDs = append(failIDs, c.ID) + } + continue + } + + if perr != nil { + fmt.Printf("%-32s FAIL (expected accept, parse error: %v)\n", c.ID, perr) + fail++ + failIDs = append(failIDs, c.ID) + continue + } + + actual, supported := decodedShape(typ) + if !supported { + fmt.Printf("%-32s UNSUPPORTED (no iceberg-go mapping for %T)\n", c.ID, typ) + unsupported++ + unsupportedIDs = append(unsupportedIDs, c.ID) + continue + } + + var expected map[string]any + if err := json.Unmarshal(c.Decoded, &expected); err != nil { + fmt.Printf("%-32s FAIL (bad 'decoded' in fixture: %v)\n", c.ID, err) + fail++ + failIDs = append(failIDs, c.ID) + continue + } + if !reflect.DeepEqual(actual, expected) { + fmt.Printf("%-32s FAIL (decoded mismatch: expected %v, actual %v)\n", c.ID, expected, actual) + fail++ + failIDs = append(failIDs, c.ID) + continue + } + + if c.Canonical != nil { + if got := typ.Type(); got != *c.Canonical { + fmt.Printf("%-32s FAIL (canonical mismatch: expected %q, actual %q)\n", c.ID, *c.Canonical, got) + fail++ + failIDs = append(failIDs, c.ID) + continue + } + } + + fmt.Printf("%-32s PASS\n", c.ID) + pass++ + } + + fmt.Printf("\nTOTALS: %d cases | PASS=%d FAIL=%d UNSUPPORTED=%d SKIP=0\n", + len(cases), pass, fail, unsupported) + if len(failIDs) > 0 { + fmt.Printf("FAIL ids: %v\n", failIDs) + } + if len(unsupportedIDs) > 0 { + fmt.Printf("UNSUPPORTED ids: %v\n", unsupportedIDs) + } + if fail > 0 { + os.Exit(1) + } +} diff --git a/runners/java/.gitignore b/runners/java/.gitignore new file mode 100644 index 0000000..abc78ba --- /dev/null +++ b/runners/java/.gitignore @@ -0,0 +1,2 @@ +/.gradle/ +/build/ diff --git a/runners/java/README.md b/runners/java/README.md new file mode 100644 index 0000000..3706e07 --- /dev/null +++ b/runners/java/README.md @@ -0,0 +1,52 @@ + + +# Java conformance runner + +Reference runner that checks [Apache Iceberg (Java)](https://github.com/apache/iceberg) +against the type-surface fixtures. It reads every `table-spec/**/cases.json`, +parses each `input` with the published readers - `org.apache.iceberg.types.Types.fromTypeName` +for a string (primitive/geospatial) type, `org.apache.iceberg.SchemaParser.fromJson` +for an object (nested) type - and applies the assertion +contract in `table-spec/types/README.md` (`valid`/reject; decoded-shape compare, +no bytes, plus byte-exact `canonical` when the case has it). It exits non-zero if any case FAILs. See +[`runners/README.md`](../README.md) for the shared contract. + +## How it reaches the implementation + +Unlike the Go and Rust runners, this one does not build Iceberg from source: it +depends on the released `org.apache.iceberg:iceberg-api` jar (the primitive/geospatial +parser) and `org.apache.iceberg:iceberg-core` jar (the nested-type reader +`SchemaParser`), version in `build.gradle` (currently 1.11.0), plus +`jackson-databind` to read the fixtures, all from Maven Central. CI tests the +latest dev build via the ASF snapshot: `./gradlew installDist -PicebergVersion=-SNAPSHOT`. + +## Running it + +```sh +# from runners/java +./gradlew run +``` + +The bundled Gradle wrapper needs no preinstalled Gradle; a JDK 17 is enough. The +runner discovers the repository root (the directory containing `table-spec/`) by +walking up, so it works from any working directory. Output is one line per case +plus totals; the exit code is non-zero on any FAIL. + +A type Java does not model is reported UNSUPPORTED, not FAIL. diff --git a/runners/java/build.gradle b/runners/java/build.gradle new file mode 100644 index 0000000..4ae1577 --- /dev/null +++ b/runners/java/build.gradle @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +plugins { + id 'application' + id 'com.diffplug.spotless' version '8.9.0' +} + +// Release lane pins a published iceberg-api. The nightly lane passes +// -PicebergVersion=-SNAPSHOT to test dev HEAD via the ASF snapshot. +def icebergVersion = (project.findProperty('icebergVersion') ?: '1.11.0').toString() + +repositories { + mavenCentral() + // Only consulted for SNAPSHOTs (the nightly lane); releases resolve from Central. + if (icebergVersion.endsWith('SNAPSHOT')) { + maven { url = uri('https://repository.apache.org/content/repositories/snapshots') } + } +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +dependencies { + // The implementation under test. iceberg-api carries the primitive/geo type + // parser (org.apache.iceberg.types.Types.fromTypeName); iceberg-core carries + // the nested-type JSON reader (org.apache.iceberg.SchemaParser.fromJson). + // dev/run-local.sh overrides these with --include-build onto a clone of apache/main. + implementation "org.apache.iceberg:iceberg-api:${icebergVersion}" + implementation "org.apache.iceberg:iceberg-core:${icebergVersion}" + // Only used to read the cases.json fixtures. + implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.2' +} + +application { + mainClass = 'org.apache.iceberg.conformance.VerifyTypes' +} + +// Google Java Format, matching Apache Iceberg. Run `./gradlew spotlessApply`. +spotless { + java { + googleJavaFormat("1.22.0") + removeUnusedImports() + trimTrailingWhitespace() + endWithNewline() + } +} + +// `./gradlew run` walks up to the repo root (the directory containing +// table-spec/) on its own, so no args are needed. diff --git a/runners/java/gradle/wrapper/gradle-wrapper.jar b/runners/java/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +if [ ! -e $APP_HOME/gradle/wrapper/gradle-wrapper.jar ]; then + curl -o $APP_HOME/gradle/wrapper/gradle-wrapper.jar https://raw.githubusercontent.com/gradle/gradle/v8.14.5/gradle/wrapper/gradle-wrapper.jar +fi + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/runners/java/gradlew.bat b/runners/java/gradlew.bat new file mode 100644 index 0000000..07742d4 --- /dev/null +++ b/runners/java/gradlew.bat @@ -0,0 +1,95 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=.\ + +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/runners/java/settings.gradle b/runners/java/settings.gradle new file mode 100644 index 0000000..8a899a2 --- /dev/null +++ b/runners/java/settings.gradle @@ -0,0 +1,20 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +rootProject.name = 'conformance-java' diff --git a/runners/java/src/main/java/org/apache/iceberg/conformance/VerifyTypes.java b/runners/java/src/main/java/org/apache/iceberg/conformance/VerifyTypes.java new file mode 100644 index 0000000..7a834f6 --- /dev/null +++ b/runners/java/src/main/java/org/apache/iceberg/conformance/VerifyTypes.java @@ -0,0 +1,364 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.iceberg.conformance; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.iceberg.SchemaParser; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; + +/** + * Reference runner that checks Apache Iceberg (Java) against the type-surface conformance fixtures. + * It reads every {@code table-spec/**}/cases.json and parses each {@code input} with the published + * reader: a string input via {@link Types#fromTypeName(String)}, an object input (a nested type) via + * {@link org.apache.iceberg.SchemaParser#fromJson(String)}. It then applies the assertion contract: + * + *
+ *   valid=false            -> the parser must throw (PASS); returning a type is a FAIL
+ *   valid=true             -> parse ok and decoded shape == decoded (PASS); a mismatch is a FAIL
+ *   valid=true, not modeled -> the parser throws because Java does not model the type (UNSUPPORTED)
+ * 
+ * + *

Java currently models every table-spec type, so the UNSUPPORTED path is dormant; the + * classification matches the Go and Rust runners regardless. The process exits 0 when no case + * fails, 1 on any FAIL, and 2 on a setup error such as a missing tree or an unreadable fixture. + */ +public final class VerifyTypes { + + // Directories under the repo root that hold cases. + private static final String[] SURFACE_ROOTS = {"table-spec"}; + + private VerifyTypes() {} + + public static void main(String[] args) { + Path start = Paths.get(System.getProperty("user.dir")); + List surfaces = new ArrayList<>(); + for (int i = 0; i < args.length; i++) { + String a = args[i]; + if (a.equals("--surface") && i + 1 < args.length) { + surfaces.add(args[++i]); + } else if (a.startsWith("--surface=")) { + surfaces.add(a.substring("--surface=".length())); + } else { + start = Paths.get(a); + } + } + String env = System.getenv("CONFORMANCE_SURFACES"); + if (env != null) { + for (String s : env.split("[,\\s]+")) { + if (!s.isEmpty()) { + surfaces.add(s); + } + } + } + Path root = findRepoRoot(start.toAbsolutePath()); + if (root == null) { + System.err.println("Could not locate repo root (no table-spec/) above " + start); + System.exit(2); + } + + List cases; + try { + cases = loadCases(root, surfaces); + } catch (IOException e) { + // A fixture-read failure is a setup error (exit 2), not a conformance FAIL. + System.err.println("Setup error reading fixtures: " + e.getMessage()); + System.exit(2); + return; + } + if (cases.isEmpty()) { + System.err.println("No cases found under " + root); + System.exit(2); + } + + int pass = 0; + int fail = 0; + int unsupported = 0; + List failIds = new ArrayList<>(); + List unsupportedIds = new ArrayList<>(); + + for (JsonNode node : cases) { + String id = node.get("id").asText(); + JsonNode input = node.get("input"); + boolean valid = node.get("valid").asBoolean(); + + Type parsed = null; + RuntimeException parseError = null; + try { + parsed = parseType(input); + } catch (RuntimeException e) { + parseError = e; + } + + if (!valid) { + if (parseError != null) { + System.out.printf("%-32s PASS (rejected: %s)%n", id, parseError.getMessage()); + pass++; + } else { + System.out.printf("%-32s FAIL (expected reject, parsed as \"%s\")%n", id, parsed); + fail++; + failIds.add(id); + } + continue; + } + + // valid=true: a parser throw means Java does not model this type, not a failure. + if (parseError != null) { + System.out.printf("%-32s UNSUPPORTED (not modeled: %s)%n", id, parseError.getMessage()); + unsupported++; + unsupportedIds.add(id); + continue; + } + + JsonNode decoded = node.get("decoded"); + String mismatch = decodedMismatch(parsed, decoded); + if (mismatch != null) { + System.out.printf( + "%-32s FAIL (decoded mismatch: expected %s, actual %s [%s])%n", + id, decoded, describe(parsed), mismatch); + fail++; + failIds.add(id); + continue; + } + + // Optional canonical check: when a case carries a "canonical" field, the re-serialized type + // string must match it. Cases without the field remain decode-only. + if (node.hasNonNull("canonical")) { + String canonical = node.get("canonical").asText(); + String actual = parsed.toString(); + if (!actual.equals(canonical)) { + System.out.printf( + "%-32s FAIL (Canonical mismatch: expected \"%s\", actual \"%s\")%n", + id, canonical, actual); + fail++; + failIds.add(id); + continue; + } + } + + System.out.printf("%-32s PASS%n", id); + pass++; + } + + System.out.printf( + "%nTOTALS: %d cases | PASS=%d FAIL=%d UNSUPPORTED=%d SKIP=0%n", + cases.size(), pass, fail, unsupported); + if (!failIds.isEmpty()) { + System.out.println("FAIL ids: " + failIds); + } + if (!unsupportedIds.isEmpty()) { + System.out.println("UNSUPPORTED ids: " + unsupportedIds); + } + if (fail > 0) { + System.exit(1); + } + } + + /** + * Parses a type from its Appendix-C JSON: a bare string for primitive and geospatial types (via + * {@link Types#fromTypeName(String)}), or an object for a nested type, wrapped as the single field + * of a schema and parsed via {@link SchemaParser#fromJson(String)}. The wrapper field uses the + * highest non-reserved field id (2147483447) so it never collides with the ids inside a nested + * type under test, which Iceberg would otherwise reject as duplicate schema ids. + */ + private static Type parseType(JsonNode input) { + if (input.isTextual()) { + return Types.fromTypeName(input.asText()); + } + String schemaJson = + "{\"type\":\"struct\",\"schema-id\":0,\"fields\":[{\"id\":2147483447,\"name\":\"f\"," + + "\"required\":true,\"type\":" + + input + + "}]}"; + return SchemaParser.fromJson(schemaJson).columns().get(0).type(); + } + + /** Walks up from {@code start} until it finds a directory containing table-spec/. */ + private static Path findRepoRoot(Path start) { + for (Path dir = start; dir != null; dir = dir.getParent()) { + if (Files.isDirectory(dir.resolve("table-spec"))) { + return dir; + } + } + return null; + } + + /** True if rel is under one of the requested surfaces (all if empty). */ + private static boolean surfaceMatch(String rel, List surfaces) { + if (surfaces.isEmpty()) { + return true; + } + for (String s : surfaces) { + String prefix = "table-spec/" + s.replaceAll("/+$", ""); + if (rel.equals(prefix) || rel.startsWith(prefix + "/")) { + return true; + } + } + return false; + } + + /** Reads every cases.json under the surface roots, sorted by case id. */ + private static List loadCases(Path root, List surfaces) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + List cases = new ArrayList<>(); + for (String surface : SURFACE_ROOTS) { + Path base = root.resolve(surface); + if (!Files.isDirectory(base)) { + continue; + } + List files; + try (Stream walk = Files.walk(base)) { + files = + walk.filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().equals("cases.json")) + .collect(Collectors.toList()); + } + for (Path file : files) { + String rel = root.relativize(file).toString().replace(java.io.File.separatorChar, '/'); + if (!surfaceMatch(rel, surfaces)) { + continue; + } + JsonNode document = mapper.readTree(file.toFile()); + JsonNode array = document.get("cases"); + if (array == null || !array.isArray()) { + throw new IOException("Missing \"cases\" array in " + file); + } + for (JsonNode node : array) { + cases.add(node); + } + } + } + cases.sort(Comparator.comparing(node -> node.get("id").asText())); + return cases; + } + + /** + * Compares a parsed type against the language-neutral {@code decoded} shape. Returns null on + * match, or a short reason on mismatch. + */ + private static String decodedMismatch(Type type, JsonNode decoded) { + String kind = decoded.get("type").asText(); + switch (kind) { + case "decimal": + if (!(type instanceof Types.DecimalType)) { + return "Not a decimal"; + } + Types.DecimalType d = (Types.DecimalType) type; + return d.precision() == decoded.get("precision").asInt() + && d.scale() == decoded.get("scale").asInt() + ? null + : "Precision/scale"; + case "fixed": + if (!(type instanceof Types.FixedType)) { + return "Not a fixed"; + } + return ((Types.FixedType) type).length() == decoded.get("length").asInt() ? null : "Length"; + case "geometry": + if (!(type instanceof Types.GeometryType)) { + return "Not a geometry"; + } + return ((Types.GeometryType) type).crs().equals(decoded.get("crs").asText()) ? null : "Crs"; + case "geography": + if (!(type instanceof Types.GeographyType)) { + return "Not a geography"; + } + Types.GeographyType g = (Types.GeographyType) type; + return g.crs().equals(decoded.get("crs").asText()) + && g.algorithm().toString().equals(decoded.get("algorithm").asText()) + ? null + : "Crs/algorithm"; + case "struct": + if (!(type instanceof Types.StructType)) { + return "Not a struct"; + } + List fields = ((Types.StructType) type).fields(); + JsonNode fieldsNode = decoded.get("fields"); + if (fields.size() != fieldsNode.size()) { + return "Field count"; + } + for (int i = 0; i < fields.size(); i++) { + Types.NestedField f = fields.get(i); + JsonNode fn = fieldsNode.get(i); + if (f.fieldId() != fn.get("id").asInt()) { + return "Field id"; + } + if (!f.name().equals(fn.get("name").asText())) { + return "Field name"; + } + if (f.isRequired() != fn.get("required").asBoolean()) { + return "Field required"; + } + String sub = decodedMismatch(f.type(), fn.get("type")); + if (sub != null) { + return "field[" + i + "]: " + sub; + } + } + return null; + case "list": + if (!(type instanceof Types.ListType)) { + return "Not a list"; + } + Types.ListType lt = (Types.ListType) type; + if (lt.elementId() != decoded.get("element-id").asInt()) { + return "element-id"; + } + if (lt.isElementRequired() != decoded.get("element-required").asBoolean()) { + return "element-required"; + } + return decodedMismatch(lt.elementType(), decoded.get("element")); + case "map": + if (!(type instanceof Types.MapType)) { + return "Not a map"; + } + Types.MapType mt = (Types.MapType) type; + if (mt.keyId() != decoded.get("key-id").asInt()) { + return "key-id"; + } + if (mt.valueId() != decoded.get("value-id").asInt()) { + return "value-id"; + } + if (mt.isValueRequired() != decoded.get("value-required").asBoolean()) { + return "value-required"; + } + String keyMismatch = decodedMismatch(mt.keyType(), decoded.get("key")); + if (keyMismatch != null) { + return "key: " + keyMismatch; + } + return decodedMismatch(mt.valueType(), decoded.get("value")); + default: + // Primitive: the language-neutral name equals the canonical type string. + return type.toString().equals(kind) ? null : "Primitive name"; + } + } + + private static String describe(Type type) { + return type.getClass().getSimpleName() + "(\"" + type + "\")"; + } +} diff --git a/runners/python/.gitignore b/runners/python/.gitignore new file mode 100644 index 0000000..d0ceff3 --- /dev/null +++ b/runners/python/.gitignore @@ -0,0 +1,3 @@ +# A local virtualenv and Python bytecode cache created when running the runner. +/.venv/ +__pycache__/ diff --git a/runners/python/README.md b/runners/python/README.md new file mode 100644 index 0000000..71f9efe --- /dev/null +++ b/runners/python/README.md @@ -0,0 +1,56 @@ + + +# Python conformance runner + +Reference runner that checks [pyiceberg](https://github.com/apache/iceberg-python) +against the type-surface fixtures. It reads every `table-spec/**/cases.json`, +parses each `input` with pyiceberg's own type parser +(`pyiceberg.types.IcebergType.model_validate`), and applies the assertion +contract in `runners/README.md` and `table-spec/types/README.md` (`valid`/reject; +decoded-shape compare, no bytes, plus byte-exact `canonical` when the case has it). It exits non-zero if any case FAILs. + +See `runners/README.md` for the shared runner contract; this README only covers +how to run the Python one. + +## Running it + +The runner depends on an installed `pyiceberg`. Install a released build and run +it: + +```sh +# from runners/python, using uv +uv venv --python 3.12 +source .venv/bin/activate +uv pip install pyiceberg + +# or with plain pip +python -m venv .venv && source .venv/bin/activate +pip install pyiceberg + +python runner.py +``` + +`python runner.py` discovers the repository root (the directory containing +`table-spec/`) by walking up, so it works from any working directory. Output is +one line per case plus totals; the exit code is non-zero on any FAIL. + +A type pyiceberg does not model is reported UNSUPPORTED, not FAIL. +Today `variant` is UNSUPPORTED: pyiceberg has no variant type, so its parser +rejects the `variant` keyword outright rather than a specific malformed form. diff --git a/runners/python/runner.py b/runners/python/runner.py new file mode 100755 index 0000000..9b5fd1f --- /dev/null +++ b/runners/python/runner.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +"""Reference runner that checks pyiceberg against the type-surface fixtures. + +It reads every table-spec/**/cases.json (each a JSON object {"cases": [...]}), +parses each `input` with pyiceberg's own type parser +(pyiceberg.types.IcebergType.model_validate), and applies the case contract: + + valid=false -> the parser must raise (PASS); if it parses, FAIL + valid=true -> parse ok and the decoded shape == `decoded`; + if the parser raises for a type pyiceberg does + not model at all, the case is UNSUPPORTED, not a + failure + +The process exits non-zero if any case FAILs. +""" + +import json +import os +import sys + +from pyiceberg.types import ( + DecimalType, + FixedType, + IcebergType, + ListType, + MapType, + PrimitiveType, + StructType, +) + +# Geometry/Geography were added after some pyiceberg releases; tolerate their +# absence so the runner still loads. Those cases then parse-fail and are handled +# by the usual UNSUPPORTED/FAIL logic. +try: + from pyiceberg.types import GeographyType, GeometryType +except ImportError: + GeometryType = GeographyType = None + +# Directory under the repo root that holds the type-surface cases. +SURFACE_ROOT = "table-spec" + +# Keywords pyiceberg's handle_primitive_type validator recognizes. Mirrors the +# branches in pyiceberg/types.py: if a type keyword is NOT here, pyiceberg lacks +# the type entirely (UNSUPPORTED) rather than rejecting a specific form (FAIL). +EXACT_KEYWORDS = { + "boolean", "string", "int", "long", "float", "double", + "timestamp", "timestamptz", "timestamp_ns", "timestamptz_ns", + "date", "time", "uuid", "binary", "unknown", +} +PREFIX_KEYWORDS = ("fixed", "decimal", "geometry", "geography") + + +def find_repo_root(start): + """Walk up from start until a directory containing table-spec/ is found.""" + d = os.path.abspath(start) + while True: + if os.path.isdir(os.path.join(d, SURFACE_ROOT)): + return d + parent = os.path.dirname(d) + if parent == d: + raise RuntimeError( + f"could not locate repo root (no {SURFACE_ROOT}/) above {start}" + ) + d = parent + + +def surface_match(rel, surfaces): + """True if rel is under one of the requested surfaces (all if empty).""" + if not surfaces: + return True + r = rel.replace(os.sep, "/") + for s in surfaces: + prefix = f"{SURFACE_ROOT}/{s}".rstrip("/") + if r == prefix or r.startswith(prefix + "/"): + return True + return False + + +def load_cases(root, surfaces=None): + """Read every cases.json under the surface root, filtered to surfaces (all if none), sorted by id.""" + cases = [] + base = os.path.join(root, SURFACE_ROOT) + for dirpath, _dirnames, filenames in os.walk(base): + if "cases.json" not in filenames: + continue + path = os.path.join(dirpath, "cases.json") + if not surface_match(os.path.relpath(path, root), surfaces): + continue + with open(path) as f: + doc = json.load(f) + for c in doc["cases"]: + c["_source"] = os.path.relpath(path, root) + cases.append(c) + cases.sort(key=lambda c: c["id"]) + return cases + + +def is_recognized(inp): + """True if pyiceberg models this type at all (else UNSUPPORTED).""" + if isinstance(inp, dict): + return inp.get("type") in ("struct", "list", "map") + if inp in EXACT_KEYWORDS: + return True + return any(inp.startswith(p) for p in PREFIX_KEYWORDS) + + +def decoded_shape(t): + """Map a pyiceberg type object to the fixture's language-neutral shape. + + Every value is read from the parsed object `t`; only the tag/key names are + literal (the neutral vocabulary shared with the fixtures). + """ + if isinstance(t, DecimalType): + return {"type": "decimal", "precision": t.precision, "scale": t.scale} + if isinstance(t, FixedType): + return {"type": "fixed", "length": len(t)} + if GeographyType is not None and isinstance(t, GeographyType): + return {"type": "geography", "crs": t.crs, "algorithm": t.algorithm} + if GeometryType is not None and isinstance(t, GeometryType): + return {"type": "geometry", "crs": t.crs} + if isinstance(t, StructType): + return {"type": "struct", "fields": [ + {"id": f.field_id, "name": f.name, "required": f.required, + "type": decoded_shape(f.field_type)} for f in t.fields]} + if isinstance(t, ListType): + return {"type": "list", "element-id": t.element_id, + "element-required": t.element_required, + "element": decoded_shape(t.element_type)} + if isinstance(t, MapType): + return {"type": "map", "key-id": t.key_id, "key": decoded_shape(t.key_type), + "value-id": t.value_id, "value-required": t.value_required, + "value": decoded_shape(t.value_type)} + if isinstance(t, PrimitiveType): + return {"type": json.loads(t.model_dump_json())} + raise TypeError(f"unmapped type object: {t!r}") + + +def main(): + start = os.getcwd() + surfaces = [] + argv = sys.argv[1:] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--surface" and i + 1 < len(argv): + surfaces.append(argv[i + 1]) + i += 2 + continue + if a.startswith("--surface="): + surfaces.append(a[len("--surface="):]) + else: + start = a + i += 1 + env = os.environ.get("CONFORMANCE_SURFACES", "") + surfaces += [s for s in env.replace(",", " ").split() if s] + try: + root = find_repo_root(start) + except RuntimeError as e: + print(e, file=sys.stderr) + sys.exit(2) + + cases = load_cases(root, surfaces) + if not cases: + print(f"no cases found under {root}", file=sys.stderr) + sys.exit(2) + + results = [] # (id, status, detail) + for c in cases: + cid = c["id"] + inp = c["input"] + valid = c["valid"] + + parsed_obj = None + err = None + try: + parsed_obj = IcebergType.model_validate(inp) + except Exception as e: # noqa: BLE001 - see valid-branch handling below + err = e + + if not valid: + if err is not None: + results.append((cid, "PASS", f"rejected: {type(err).__name__}")) + else: + results.append((cid, "FAIL", + f"expected reject, parsed as {decoded_shape(parsed_obj)!r}")) + continue + + if err is not None: + if not is_recognized(inp): + results.append((cid, "UNSUPPORTED", + f"pyiceberg lacks type ({type(err).__name__})")) + else: + results.append((cid, "FAIL", + f"expected accept, parse raised {type(err).__name__}: {err}")) + continue + + got = decoded_shape(parsed_obj) + want = c["decoded"] + if got != want: + results.append((cid, "FAIL", f"decoded mismatch: expected {want}, actual {got}")) + continue + + if "canonical" in c: + want_canon = c["canonical"] + got_canon = json.loads(parsed_obj.model_dump_json()) + if got_canon != want_canon: + results.append((cid, "FAIL", + f"canonical mismatch: expected {want_canon!r}, actual {got_canon!r}")) + continue + + results.append((cid, "PASS", "")) + + width = max(len(cid) for cid, _, _ in results) + for cid, status, detail in results: + line = f"{cid.ljust(width)} {status}" + if detail and status != "PASS": + line += f" ({detail})" + print(line) + + counts = {"PASS": 0, "FAIL": 0, "UNSUPPORTED": 0, "SKIP": 0} + for _, status, _ in results: + counts[status] = counts.get(status, 0) + 1 + + print(f"\nTOTALS: {len(results)} cases | " + f"PASS={counts['PASS']} FAIL={counts['FAIL']} " + f"UNSUPPORTED={counts['UNSUPPORTED']} SKIP=0") + + fail_ids = [cid for cid, s, _ in results if s == "FAIL"] + unsupported_ids = [cid for cid, s, _ in results if s == "UNSUPPORTED"] + if fail_ids: + print(f"FAIL ids: {fail_ids}") + if unsupported_ids: + print(f"UNSUPPORTED ids: {unsupported_ids}") + + sys.exit(1 if fail_ids else 0) + + +if __name__ == "__main__": + main() diff --git a/runners/rust/.gitignore b/runners/rust/.gitignore new file mode 100644 index 0000000..0e280cc --- /dev/null +++ b/runners/rust/.gitignore @@ -0,0 +1,7 @@ +# The implementation under test is checked out (CI) or symlinked (local) here. +# No trailing slash, so a local symlink is ignored as well as a checked-out dir. +/iceberg-rust +/target/ +# Resolved against the ephemeral ./iceberg-rust above, so it is not committed; +# CI regenerates it against the freshly checked-out implementation. +/Cargo.lock diff --git a/runners/rust/Cargo.toml b/runners/rust/Cargo.toml new file mode 100644 index 0000000..c18ddc1 --- /dev/null +++ b/runners/rust/Cargo.toml @@ -0,0 +1,33 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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] +name = "conformance-rust" +version = "0.0.0" +edition = "2021" +publish = false + +# Depends on the published iceberg crate (latest release). default-features = +# false keeps the type parser without pulling storage backends. dev/run-local.sh +# overrides this with a [patch.crates-io] onto a clone of apache/main. +[dependencies] +iceberg = { version = "0.10.1", default-features = false } +serde_json = "1" + +[[bin]] +name = "conformance-rust" +path = "src/main.rs" diff --git a/runners/rust/README.md b/runners/rust/README.md new file mode 100644 index 0000000..65111e9 --- /dev/null +++ b/runners/rust/README.md @@ -0,0 +1,60 @@ + + +# Rust conformance runner + +Reference runner that checks [iceberg-rust](https://github.com/apache/iceberg-rust) +against the type-surface fixtures. It reads every `table-spec/**/cases.json`, +deserializes each `input` into iceberg-rust's own `Type` with `serde_json`, and +applies the assertion contract in `table-spec/types/README.md` (`valid`/reject; +decoded-shape compare, no bytes, plus byte-exact `canonical` when the case has it). +It exits non-zero if any case FAILs. See [`../README.md`](../README.md) for the +shared contract that every language runner follows. + +## Running it + +The runner depends on the `iceberg` crate pinned in `Cargo.toml`; `cargo run` +resolves it from crates.io. CI tests `apache/main` via a git dependency: +`cargo add iceberg --git https://github.com/apache/iceberg-rust --branch main --no-default-features`. + +```sh +# from runners/rust (latest release) +cargo run +``` + +`cargo run` discovers the repository root (the directory containing `table-spec/`) +by walking up, so it works from any working directory. Output is one line per +case plus totals; the exit code is non-zero on any FAIL. + +## Expected outcomes + +A type iceberg-rust's `Type` enum does not model is reported UNSUPPORTED, not +FAIL. The shipped fixtures exercise this for `unknown`, `geometry`, and +`geography` (iceberg-rust has `struct` / `list` / `map` / `variant` and the v3 +`timestamp_ns` / `timestamptz_ns`, but not `unknown` or the geospatial types). + +Two cases FAIL today, and these are real iceberg-rust divergences from the +spec-derived expectation, not runner bugs: + +- `decimal-precision-over-38` - iceberg-rust accepts `decimal(39, 0)`; the spec + caps precision at 38. +- `fixed-unterminated` - iceberg-rust accepts the unterminated `fixed[16`. + +Both should stay red until iceberg-rust tightens its parser (or the community +decides otherwise). diff --git a/runners/rust/rust-toolchain.toml b/runners/rust/rust-toolchain.toml new file mode 100644 index 0000000..1518fcd --- /dev/null +++ b/runners/rust/rust-toolchain.toml @@ -0,0 +1,6 @@ +# Pin the runner-dir toolchain to current stable. iceberg-rust main tracks a +# recent MSRV (it required rustc 1.95 as of iceberg 0.10.0), so the runner must +# build with a stable new enough for whatever main currently requires. rustup +# auto-installs this channel on first cargo invocation in this directory. +[toolchain] +channel = "stable" diff --git a/runners/rust/src/main.rs b/runners/rust/src/main.rs new file mode 100644 index 0000000..abf6368 --- /dev/null +++ b/runners/rust/src/main.rs @@ -0,0 +1,329 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +//! Reference runner that checks iceberg-rust against the type-surface conformance +//! fixtures. It reads every table-spec/**/cases.json (a JSON object with a top +//! level `cases` array), deserializes each `input` type string into iceberg-rust's +//! own `Type` via serde_json, and applies the surface's assertion contract: +//! +//! valid=false -> deserialization must return an error (PASS); Ok is FAIL +//! valid=true -> deserialization must succeed and the decoded shape +//! must equal `decoded` +//! +//! UNSUPPORTED is decided post-parse: if a valid case fails to deserialize AND the +//! expected `decoded` type is one iceberg-rust's `Type` enum cannot represent +//! (unknown / geometry / geography), the case is reported UNSUPPORTED, not FAIL. +//! The process exits 0 (no FAIL), 1 (any FAIL), or 2 (setup / fixture-load error). + +use std::path::{Path, PathBuf}; + +use iceberg::spec::{PrimitiveType, Type}; +use serde_json::{json, Value}; + +// The directory under the repo root that holds the fixture cases. +const SURFACE_ROOT: &str = "table-spec"; + +// Type names iceberg-rust's `Type` enum does not model. When a valid case fails +// to parse and its expected `decoded.type` is one of these, it is reported +// UNSUPPORTED (a skip-list candidate), not FAIL. iceberg-rust models struct / +// list / map / variant, but no unknown / geometry / geography. +fn is_unsupported_type(name: &str) -> bool { + matches!(name, "unknown" | "geometry" | "geography") +} + +// find_repo_root walks up from `start` until it finds a directory containing +// table-spec/, so the runner works from any working directory. +fn find_repo_root(start: &Path) -> Option { + let mut dir = start.to_path_buf(); + loop { + if dir.join(SURFACE_ROOT).is_dir() { + return Some(dir); + } + if !dir.pop() { + return None; + } + } +} + +// collect_case_files recursively finds every cases.json under `dir`. +fn collect_case_files(dir: &Path, out: &mut Vec) -> std::io::Result<()> { + for entry in std::fs::read_dir(dir)? { + let path = entry?.path(); + if path.is_dir() { + collect_case_files(&path, out)?; + } else if path.file_name().and_then(|n| n.to_str()) == Some("cases.json") { + out.push(path); + } + } + Ok(()) +} + +// surface_match reports whether rel is under one of the requested surfaces (all if empty). +fn surface_match(rel: &str, surfaces: &[String]) -> bool { + if surfaces.is_empty() { + return true; + } + let r = rel.replace('\\', "/"); + surfaces.iter().any(|s| { + let prefix = format!("{SURFACE_ROOT}/{}", s.trim_end_matches('/')); + r == prefix || r.starts_with(&format!("{prefix}/")) + }) +} + +// Map a decoded iceberg-rust Type to the surface's language-neutral `decoded` shape. +fn decoded_to_shape(ty: &Type) -> Value { + match ty { + Type::Primitive(p) => match p { + PrimitiveType::Boolean => json!({"type": "boolean"}), + PrimitiveType::Int => json!({"type": "int"}), + PrimitiveType::Long => json!({"type": "long"}), + PrimitiveType::Float => json!({"type": "float"}), + PrimitiveType::Double => json!({"type": "double"}), + PrimitiveType::Decimal { precision, scale } => { + json!({"type": "decimal", "precision": precision, "scale": scale}) + } + PrimitiveType::Date => json!({"type": "date"}), + PrimitiveType::Time => json!({"type": "time"}), + PrimitiveType::Timestamp => json!({"type": "timestamp"}), + PrimitiveType::Timestamptz => json!({"type": "timestamptz"}), + PrimitiveType::TimestampNs => json!({"type": "timestamp_ns"}), + PrimitiveType::TimestamptzNs => json!({"type": "timestamptz_ns"}), + PrimitiveType::String => json!({"type": "string"}), + PrimitiveType::Uuid => json!({"type": "uuid"}), + PrimitiveType::Fixed(l) => json!({"type": "fixed", "length": l}), + PrimitiveType::Binary => json!({"type": "binary"}), + }, + Type::Struct(s) => { + let fields: Vec = s + .fields() + .iter() + .map(|f| { + json!({ + "id": f.id, + "name": f.name, + "required": f.required, + "type": decoded_to_shape(f.field_type.as_ref()), + }) + }) + .collect(); + json!({"type": "struct", "fields": fields}) + } + Type::List(l) => json!({ + "type": "list", + "element-id": l.element_field.id, + "element-required": l.element_field.required, + "element": decoded_to_shape(l.element_field.field_type.as_ref()), + }), + Type::Map(m) => json!({ + "type": "map", + "key-id": m.key_field.id, + "key": decoded_to_shape(m.key_field.field_type.as_ref()), + "value-id": m.value_field.id, + "value-required": m.value_field.required, + "value": decoded_to_shape(m.value_field.field_type.as_ref()), + }), + Type::Variant(_) => json!({"type": "variant"}), + } +} + +// serialized_wire_form re-serializes a parsed Type back to its language-neutral +// wire string via serde (e.g. "fixed[16]"). iceberg-rust serializes a Type to a +// bare JSON string for primitives, so we take serde_json::to_value and read it as +// a str; Display is NOT used (it emits the wrong "fixed(16)" form). +fn serialized_wire_form(ty: &Type) -> Option { + serde_json::to_value(ty) + .ok()? + .as_str() + .map(|s| s.to_string()) +} + +// load_cases reads every cases.json under the surface root, flattening the +// top level `cases` array of each file and tagging cases with their source +// path for diagnostics. Returns Err on any fixture-load / parse-of-file error. +fn load_cases(root: &Path, surfaces: &[String]) -> Result, String> { + let mut files = Vec::new(); + collect_case_files(&root.join(SURFACE_ROOT), &mut files) + .map_err(|e| format!("walk {SURFACE_ROOT}: {e}"))?; + files.sort(); + + let mut cases: Vec<(String, Value)> = Vec::new(); + for file in &files { + let rel = file + .strip_prefix(root) + .unwrap_or(file) + .display() + .to_string(); + if !surface_match(&rel, surfaces) { + continue; + } + let raw = std::fs::read_to_string(file).map_err(|e| format!("{rel}: read: {e}"))?; + let doc: Value = serde_json::from_str(&raw).map_err(|e| format!("{rel}: parse: {e}"))?; + let arr = doc + .get("cases") + .and_then(|c| c.as_array()) + .ok_or_else(|| format!("{rel}: missing top-level \"cases\" array"))?; + for case in arr { + cases.push((rel.clone(), case.clone())); + } + } + Ok(cases) +} + +fn run() -> Result { + let cwd = std::env::current_dir().map_err(|e| format!("getcwd: {e}"))?; + let mut start = cwd; + let mut surfaces: Vec = Vec::new(); + let args: Vec = std::env::args().skip(1).collect(); + let mut i = 0; + while i < args.len() { + let a = &args[i]; + if a == "--surface" { + if i + 1 < args.len() { + surfaces.push(args[i + 1].clone()); + i += 1; + } + } else if let Some(v) = a.strip_prefix("--surface=") { + surfaces.push(v.to_string()); + } else { + start = PathBuf::from(a); + } + i += 1; + } + if let Ok(env) = std::env::var("CONFORMANCE_SURFACES") { + surfaces.extend( + env.split(|c| c == ',' || c == ' ') + .filter(|s| !s.is_empty()) + .map(String::from), + ); + } + let root = find_repo_root(&start).ok_or_else(|| { + format!("could not locate repo root (no {SURFACE_ROOT}/) above {start:?}") + })?; + + let mut cases = load_cases(&root, &surfaces)?; + cases.sort_by(|a, b| a.1["id"].as_str().cmp(&b.1["id"].as_str())); + + if cases.is_empty() { + return Err(format!("no cases found under {}", root.display())); + } + + let mut pass = 0usize; + let mut fail = 0usize; + let mut unsupported = 0usize; + let mut fail_ids: Vec = Vec::new(); + let mut unsupported_ids: Vec = Vec::new(); + + for (src, case) in &cases { + let id = case["id"] + .as_str() + .ok_or_else(|| format!("{src}: case missing string \"id\""))? + .to_string(); + let valid = case["valid"] + .as_bool() + .ok_or_else(|| format!("{src}: case {id}: missing bool \"valid\""))?; + let input = case["input"].clone(); + + let parsed: Result = serde_json::from_value::(input); + + if !valid { + match parsed { + Err(e) => { + println!("{id:<32} PASS (rejected: {e})"); + pass += 1; + } + Ok(ty) => { + println!("{id:<32} FAIL (expected reject, parsed as {ty:?})"); + fail += 1; + fail_ids.push(id); + } + } + continue; + } + + // valid == true: parse must succeed and the decoded shape must match. + let expected = &case["decoded"]; + match parsed { + Ok(ty) => { + let actual = decoded_to_shape(&ty); + if &actual == expected { + // Optional canonical (serialize) check: when the case carries a + // "canonical" wire string, re-serializing the parsed Type must + // reproduce it. Absent the field, decode-only PASS as before. + if let Some(expected_canon) = case["canonical"].as_str() { + let actual_canon = serialized_wire_form(&ty); + if actual_canon.as_deref() == Some(expected_canon) { + println!("{id:<32} PASS"); + pass += 1; + } else { + println!( + "{id:<32} FAIL (canonical mismatch: expected {expected_canon:?}, actual {actual_canon:?})" + ); + fail += 1; + fail_ids.push(id); + } + } else { + println!("{id:<32} PASS"); + pass += 1; + } + } else { + println!( + "{id:<32} FAIL (decoded mismatch: expected {expected}, actual {actual})" + ); + fail += 1; + fail_ids.push(id); + } + } + Err(e) => { + // A parse failure on a valid case is UNSUPPORTED only when the + // expected type is one iceberg-rust's Type enum cannot model. + let name = expected["type"].as_str().unwrap_or(""); + if is_unsupported_type(name) { + println!("{id:<32} UNSUPPORTED (no iceberg-rust Type for {name:?})"); + unsupported += 1; + unsupported_ids.push(id); + } else { + println!("{id:<32} FAIL (expected accept, parse error: {e})"); + fail += 1; + fail_ids.push(id); + } + } + } + } + + println!( + "\nTOTALS: {} cases | PASS={pass} FAIL={fail} UNSUPPORTED={unsupported} SKIP=0", + cases.len() + ); + if !fail_ids.is_empty() { + println!("FAIL ids: {fail_ids:?}"); + } + if !unsupported_ids.is_empty() { + println!("UNSUPPORTED ids: {unsupported_ids:?}"); + } + + Ok(if fail > 0 { 1 } else { 0 }) +} + +fn main() { + match run() { + Ok(code) => std::process::exit(code), + Err(e) => { + eprintln!("{e}"); + std::process::exit(2); + } + } +} diff --git a/table-spec/manifest.json b/table-spec/manifest.json new file mode 100644 index 0000000..3b67fab --- /dev/null +++ b/table-spec/manifest.json @@ -0,0 +1,10 @@ +{ + "surfaces": [ + { + "name": "types", + "path": "table-spec/types", + "readme": "table-spec/types/README.md", + "subdirs": ["primitive", "variant", "nested", "geospatial"] + } + ] +} diff --git a/table-spec/types/README.md b/table-spec/types/README.md new file mode 100644 index 0000000..7936ca3 --- /dev/null +++ b/table-spec/types/README.md @@ -0,0 +1,110 @@ + + +# Type decoding + +Parsing a type string produces the same type in every implementation. This +surface pins each type the spec defines and the parse rules that attach to it. + +## Assertion + +``` +parse(input) == decoded +``` + +`input` is a type string, or a JSON object for a nested type; `decoded` is the +language-neutral shape below. Bytes are not compared - each implementation maps +its own type object to `decoded`, so the comparison does not depend on one +language's representation. + +- `valid: true` - the parser succeeds and the decoded type equals `decoded`. A + type an implementation does not model is UNSUPPORTED, not a failure. If the case + also carries `canonical`, re-serializing the parsed type must equal it byte for + byte (the write direction). +- `valid: false` - the parser must reject `input`. A rejection passes; a + successful parse fails. + +`canonical` is present only where the spec pins one spelling. `decimal` has two +blessed forms (`decimal(9,2)` and `decimal(9, 2)`), so its cases have no +`canonical` and are compared by `decoded` alone. + +## Scope + +Each type in isolation, per the Primitive Types table and Appendix C. The full +schema document (schema-id, identifier-field-ids, field ordering) is the `schema` +surface. Whether a type is legal at a given format version is not decided here, +because a type in isolation carries no version. + +## Inputs + +- `primitive/` - every v1/v2 primitive (`boolean`, `int`, `long`, `float`, + `double`, `date`, `time`, `timestamp`, `timestamptz`, `string`, `uuid`, + `binary`, `decimal`, `fixed`) plus the v3 additions `timestamp_ns`, + `timestamptz_ns`, `unknown`. +- `variant/` - `variant` (v3). +- `nested/` - `struct`, `list`, `map`, including nesting (a `struct` field whose + type is a `list`). +- `geospatial/` - `geometry` and `geography` (v3), with explicit and default CRS. + +## Case format + +One `cases.json` per directory, a JSON object with a `cases` array: + +| field | meaning | +| --- | --- | +| `id` | unique case id | +| `valid` | `true` if the parser must accept `input`, `false` if it must reject it | +| `input` | the type string (`"decimal(9,2)"`), or a JSON object for a nested type | +| `decoded` | the decoded shape; present only when `valid` is `true` | +| `canonical` | the exact re-serialized string; present only where the spec pins one spelling | +| `clause` | the spec rule this case pins | +| `spec_ref` | anchor into `format/spec.md` | + +`decoded` is language-neutral: + +- primitive: `{"type": ""}`, e.g. `{"type": "int"}` +- `decimal`: `{"type": "decimal", "precision": P, "scale": S}` +- `fixed`: `{"type": "fixed", "length": L}` +- `geometry`: `{"type": "geometry", "crs": C}`; `geography` adds `"algorithm": A` +- `struct`: `{"type": "struct", "fields": [{"id", "name", "required", "type"}, ...]}` +- `list`: `{"type": "list", "element-id", "element-required", "element"}` +- `map`: `{"type": "map", "key-id", "key", "value-id", "value-required", "value"}` + +A nested type's child `type` values are the same shape, recursively. + +## Provenance + +`input` and `decoded` are derived from `format/spec.md` (the Primitive Types +table and Appendix C), cross-checked against Apache Iceberg Java. There is no +binary artifact; Java is a cross-check, not the source of the inputs. + +## Left out on purpose + +Inputs the spec neither permits nor forbids, so no answer can be spec-derived: + +- `scale > precision`, e.g. `decimal(5, 10)`. +- lower bounds, e.g. `decimal(0, 0)` / `fixed[0]`. +- internal whitespace around every parameter, e.g. `decimal( 9 , 2 )`. The one + spaced case we do ship, `decimal(9, 2)`, is a *recommended* (SHOULD) accept, + not a hard requirement: the spec says readers *should*, not *must*, accept + optional whitespace, so an implementation that rejects it is still conformant. +- keyword case, e.g. `DECIMAL(9,2)`. +- geospatial CRS *quoting*, e.g. `geometry('OGC:CRS84')`. The spec's canonical + form is unquoted (`geometry(OGC:CRS84)`), which the shipped cases use; whether + the quoted form is also accepted is unpinned. diff --git a/table-spec/types/geospatial/cases.json b/table-spec/types/geospatial/cases.json new file mode 100644 index 0000000..5d59090 --- /dev/null +++ b/table-spec/types/geospatial/cases.json @@ -0,0 +1,52 @@ +{ + "cases": [ + { + "id": "geometry-crs84", + "valid": true, + "input": "geometry(OGC:CRS84)", + "decoded": {"type": "geometry", "crs": "OGC:CRS84"}, + "clause": "geometry(C) with explicit CRS; the canonical serialized form is unquoted \"geometry()\"", + "spec_ref": "format/spec.md#appendix-c-json-serialization" + }, + { + "id": "geometry-srid", + "valid": true, + "input": "geometry(srid:4326)", + "decoded": {"type": "geometry", "crs": "srid:4326"}, + "clause": "geometry(C) example from Appendix C is the unquoted \"geometry(srid:4326)\"", + "spec_ref": "format/spec.md#appendix-c-json-serialization" + }, + { + "id": "geometry-default-crs", + "valid": true, + "input": "geometry", + "decoded": {"type": "geometry", "crs": "OGC:CRS84"}, + "clause": "geometry(C): if C is not specified, C is OGC:CRS84", + "spec_ref": "format/spec.md#primitive-types" + }, + { + "id": "geography-crs84-spherical", + "valid": true, + "input": "geography(OGC:CRS84, spherical)", + "decoded": {"type": "geography", "crs": "OGC:CRS84", "algorithm": "spherical"}, + "clause": "geography(C, A); the canonical serialized form is unquoted \"geography(, )\"", + "spec_ref": "format/spec.md#appendix-c-json-serialization" + }, + { + "id": "geography-default", + "valid": true, + "input": "geography", + "decoded": {"type": "geography", "crs": "OGC:CRS84", "algorithm": "spherical"}, + "clause": "geography(C, A): if not specified, C is OGC:CRS84 and A is spherical", + "spec_ref": "format/spec.md#primitive-types" + }, + { + "id": "geography-vincenty", + "valid": true, + "input": "geography(OGC:CRS84, vincenty)", + "decoded": {"type": "geography", "crs": "OGC:CRS84", "algorithm": "vincenty"}, + "clause": "geography edge-interpolation algorithm A is one of spherical, vincenty, thomas, andoyer, karney", + "spec_ref": "format/spec.md#appendix-g-geospatial-notes" + } + ] +} diff --git a/table-spec/types/nested/cases.json b/table-spec/types/nested/cases.json new file mode 100644 index 0000000..4645fc5 --- /dev/null +++ b/table-spec/types/nested/cases.json @@ -0,0 +1,67 @@ +{ + "cases": [ + { + "id": "struct-single-field", + "valid": true, + "input": {"type": "struct", "fields": [{"id": 1, "name": "a", "required": true, "type": "int"}]}, + "decoded": {"type": "struct", "fields": [{"id": 1, "name": "a", "required": true, "type": {"type": "int"}}]}, + "clause": "struct is a tuple of typed fields; each field has an integer id, a name, a required flag, and a type", + "spec_ref": "format/spec.md#nested-types" + }, + { + "id": "struct-empty", + "valid": true, + "input": {"type": "struct", "fields": []}, + "decoded": {"type": "struct", "fields": []}, + "clause": "a struct's fields array may be empty (Appendix C struct serialization)", + "spec_ref": "format/spec.md#appendix-c-json-serialization" + }, + { + "id": "struct-optional-field", + "valid": true, + "input": {"type": "struct", "fields": [{"id": 1, "name": "a", "required": false, "type": "string"}]}, + "decoded": {"type": "struct", "fields": [{"id": 1, "name": "a", "required": false, "type": {"type": "string"}}]}, + "clause": "each struct field can be optional or required (required=false permits null values)", + "spec_ref": "format/spec.md#nested-types" + }, + { + "id": "list-required-element", + "valid": true, + "input": {"type": "list", "element-id": 2, "element-required": true, "element": "string"}, + "decoded": {"type": "list", "element-id": 2, "element-required": true, "element": {"type": "string"}}, + "clause": "a list has an element type; the element field has an integer id and a required flag", + "spec_ref": "format/spec.md#appendix-c-json-serialization" + }, + { + "id": "list-optional-element", + "valid": true, + "input": {"type": "list", "element-id": 2, "element-required": false, "element": "long"}, + "decoded": {"type": "list", "element-id": 2, "element-required": false, "element": {"type": "long"}}, + "clause": "list elements can be optional or required", + "spec_ref": "format/spec.md#nested-types" + }, + { + "id": "map-string-double", + "valid": true, + "input": {"type": "map", "key-id": 3, "key": "string", "value-id": 4, "value-required": false, "value": "double"}, + "decoded": {"type": "map", "key-id": 3, "key": {"type": "string"}, "value-id": 4, "value-required": false, "value": {"type": "double"}}, + "clause": "a map has a key type and a value type; keys are required, values may be optional or required", + "spec_ref": "format/spec.md#appendix-c-json-serialization" + }, + { + "id": "struct-nested-list", + "valid": true, + "input": {"type": "struct", "fields": [{"id": 1, "name": "tags", "required": true, "type": {"type": "list", "element-id": 2, "element-required": true, "element": "string"}}]}, + "decoded": {"type": "struct", "fields": [{"id": 1, "name": "tags", "required": true, "type": {"type": "list", "element-id": 2, "element-required": true, "element": {"type": "string"}}}]}, + "clause": "fields may be any type, including nested types (a struct field whose type is a list)", + "spec_ref": "format/spec.md#nested-types" + }, + { + "id": "struct-field-missing-id", + "valid": false, + "input": {"type": "struct", "fields": [{"name": "a", "required": true, "type": "int"}]}, + "clause": "each field in a struct has an integer id; a field without an id is invalid", + "spec_ref": "format/spec.md#nested-types" + } + ] +} diff --git a/table-spec/types/primitive/cases.json b/table-spec/types/primitive/cases.json new file mode 100644 index 0000000..b41a30b --- /dev/null +++ b/table-spec/types/primitive/cases.json @@ -0,0 +1,33 @@ +{ + "cases": [ + { "id": "boolean", "valid": true, "input": "boolean", "decoded": { "type": "boolean" }, "canonical": "boolean", "clause": "Primitive Types: boolean; Appendix C canonical string", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "int", "valid": true, "input": "int", "decoded": { "type": "int" }, "canonical": "int", "clause": "Primitive Types: int; Appendix C canonical string", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "long", "valid": true, "input": "long", "decoded": { "type": "long" }, "canonical": "long", "clause": "Primitive Types: long; Appendix C canonical string", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "float", "valid": true, "input": "float", "decoded": { "type": "float" }, "canonical": "float", "clause": "Primitive Types: float; Appendix C canonical string", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "double", "valid": true, "input": "double", "decoded": { "type": "double" }, "canonical": "double", "clause": "Primitive Types: double; Appendix C canonical string", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "date", "valid": true, "input": "date", "decoded": { "type": "date" }, "canonical": "date", "clause": "Primitive Types: date; Appendix C canonical string", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "time", "valid": true, "input": "time", "decoded": { "type": "time" }, "canonical": "time", "clause": "Primitive Types: time; Appendix C canonical string", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "timestamp", "valid": true, "input": "timestamp", "decoded": { "type": "timestamp" }, "canonical": "timestamp", "clause": "Primitive Types: timestamp; Appendix C canonical string", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "timestamptz", "valid": true, "input": "timestamptz", "decoded": { "type": "timestamptz" }, "canonical": "timestamptz", "clause": "Primitive Types: timestamptz; Appendix C canonical string", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "timestamp_ns", "valid": true, "input": "timestamp_ns", "decoded": { "type": "timestamp_ns" }, "canonical": "timestamp_ns", "clause": "Primitive Types: timestamp_ns added in v3; Appendix C canonical string", "spec_ref": "format/spec.md#primitive-types" }, + { "id": "timestamptz_ns", "valid": true, "input": "timestamptz_ns", "decoded": { "type": "timestamptz_ns" }, "canonical": "timestamptz_ns", "clause": "Primitive Types: timestamptz_ns added in v3; Appendix C canonical string", "spec_ref": "format/spec.md#primitive-types" }, + { "id": "string", "valid": true, "input": "string", "decoded": { "type": "string" }, "canonical": "string", "clause": "Primitive Types: string; Appendix C canonical string", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "uuid", "valid": true, "input": "uuid", "decoded": { "type": "uuid" }, "canonical": "uuid", "clause": "Primitive Types: uuid; Appendix C canonical string", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "binary", "valid": true, "input": "binary", "decoded": { "type": "binary" }, "canonical": "binary", "clause": "Primitive Types: binary; Appendix C canonical string", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "unknown", "valid": true, "input": "unknown", "decoded": { "type": "unknown" }, "canonical": "unknown", "clause": "Primitive Types: unknown added in v3; Appendix C canonical string", "spec_ref": "format/spec.md#primitive-types" }, + { "id": "fixed-1", "valid": true, "input": "fixed[1]", "decoded": { "type": "fixed", "length": 1 }, "canonical": "fixed[1]", "clause": "Appendix C: fixed canonical string is fixed[]", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "fixed-16", "valid": true, "input": "fixed[16]", "decoded": { "type": "fixed", "length": 16 }, "canonical": "fixed[16]", "clause": "Appendix C: fixed canonical string is fixed[]", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "decimal-9-2", "valid": true, "input": "decimal(9,2)", "decoded": { "type": "decimal", "precision": 9, "scale": 2 }, "clause": "Appendix C: both decimal(9,2) and decimal(9, 2) are canonical, so no byte-exact form is pinned", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "decimal-9-2-spaced", "valid": true, "input": "decimal(9, 2)", "decoded": { "type": "decimal", "precision": 9, "scale": 2 }, "clause": "Appendix C: the spaced decimal(9, 2) form parses to the same decimal", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "decimal-38-0", "valid": true, "input": "decimal(38,0)", "decoded": { "type": "decimal", "precision": 38, "scale": 0 }, "clause": "Primitive Types: decimal precision must be 38 or less (38 is the maximum)", "spec_ref": "format/spec.md#primitive-types" }, + { "id": "decimal-precision-over-38", "valid": false, "input": "decimal(39,0)", "clause": "Primitive Types: decimal precision must be 38 or less", "spec_ref": "format/spec.md#primitive-types" }, + { "id": "decimal-missing-scale", "valid": false, "input": "decimal(9)", "clause": "Appendix C: decimal is written decimal(P,S); scale is required", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "decimal-empty-params", "valid": false, "input": "decimal()", "clause": "Appendix C: decimal requires precision and scale", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "decimal-non-numeric", "valid": false, "input": "decimal(a,b)", "clause": "Appendix C: decimal precision and scale are integers", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "fixed-empty-length", "valid": false, "input": "fixed[]", "clause": "Appendix C: fixed is written fixed[]; length is required", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "fixed-unterminated", "valid": false, "input": "fixed[16", "clause": "Appendix C: fixed[] must be closed with a bracket", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "fixed-non-numeric", "valid": false, "input": "fixed[abc]", "clause": "Appendix C: fixed length is an integer", "spec_ref": "format/spec.md#appendix-c-json-serialization" }, + { "id": "empty-type", "valid": false, "input": "", "clause": "Primitive Types: the empty string is not a type name", "spec_ref": "format/spec.md#primitive-types" }, + { "id": "unknown-type-name", "valid": false, "input": "notatype", "clause": "Primitive Types: only the listed type names are valid", "spec_ref": "format/spec.md#primitive-types" } + ] +} diff --git a/table-spec/types/variant/cases.json b/table-spec/types/variant/cases.json new file mode 100644 index 0000000..ace69ac --- /dev/null +++ b/table-spec/types/variant/cases.json @@ -0,0 +1,5 @@ +{ + "cases": [ + { "id": "variant", "valid": true, "input": "variant", "decoded": { "type": "variant" }, "canonical": "variant", "clause": "Semi-structured Types: variant added in v3; Appendix C canonical string", "spec_ref": "format/spec.md#appendix-c-json-serialization" } + ] +} From c5f6babd13bc603d2f729e32bfaadad9c84ff086 Mon Sep 17 00:00:00 2001 From: Neelesh Salian Date: Fri, 11 Sep 2026 11:14:18 -0700 Subject: [PATCH 2/2] Temp per push to see if this runs --- .github/workflows/conformance-java.yml | 79 ++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 .github/workflows/conformance-java.yml diff --git a/.github/workflows/conformance-java.yml b/.github/workflows/conformance-java.yml new file mode 100644 index 0000000..c0ae3e9 --- /dev/null +++ b/.github/workflows/conformance-java.yml @@ -0,0 +1,79 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +# Conformance: the Java runner against the latest iceberg-api SNAPSHOT (resolved at +# run time). Go/Rust/Python track main; Java tracks the SNAPSHOT. +name: "Conformance (Java)" + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ github.workflow }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + conformance-java: + name: iceberg-java type conformance + runs-on: ubuntu-24.04 + # Non-blocking: report a divergence, do not fail this repo's CI. + continue-on-error: true + steps: + - name: Checkout fixtures + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: zulu + java-version: '17' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + - name: Run type conformance + id: run + working-directory: runners/java + # Resolve the latest iceberg-api SNAPSHOT at run time (pinned fallback), then + # run the launcher directly so the runner's own exit code survives. + run: | + set +e + meta="https://repository.apache.org/content/repositories/snapshots/org/apache/iceberg/iceberg-api/maven-metadata.xml" + version="$(curl -fsSL "$meta" | grep -o '[^<]*' | sed -e 's/<[^>]*>//g' | head -1)" + if [ -z "$version" ]; then + version="1.12.0-SNAPSHOT" + echo "WARN: could not resolve latest SNAPSHOT from $meta; using fallback $version" >&2 + fi + echo "resolved=iceberg-api:$version" >> "$GITHUB_OUTPUT" + ./gradlew installDist --quiet -PicebergVersion="$version" || { echo "code=2" >> "$GITHUB_OUTPUT"; exit 2; } + build/install/conformance-java/bin/conformance-java 2>&1 | tee "$GITHUB_WORKSPACE/runner-output.txt" + code=${PIPESTATUS[0]} + echo "code=$code" >> "$GITHUB_OUTPUT" + # Report-only: only a runner ERROR (2) reddens the check, not a divergence. + if [ "$code" = "2" ]; then exit 1; fi + - name: Report results (step summary + annotations) + if: always() + run: > + bash "$GITHUB_WORKSPACE/dev/ci-report.sh" + iceberg-java "$GITHUB_WORKSPACE/runner-output.txt" + snapshot "${{ steps.run.outputs.code }}" + "${{ steps.run.outputs.resolved }}"