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-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 }}" 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 0000000..1b33c55 Binary files /dev/null and b/runners/java/gradle/wrapper/gradle-wrapper.jar differ diff --git a/runners/java/gradle/wrapper/gradle-wrapper.properties b/runners/java/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a4cf193 --- /dev/null +++ b/runners/java/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=6f74b601422d6d6fc4e1f9a1ab6522f642c2fdcbc15ae33ebd30ba3d7198e854 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/runners/java/gradlew b/runners/java/gradlew new file mode 100755 index 0000000..30403b3 --- /dev/null +++ b/runners/java/gradlew @@ -0,0 +1,253 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + 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" } + ] +}