diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e134332f3..4ee2eef50c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,12 +57,15 @@ jobs: cpp: ${{ steps.filter.outputs.cpp }} cpp_code: ${{ steps.filter.outputs.cpp_code }} java_code: ${{ steps.filter.outputs.java_code }} + android: ${{ steps.filter.outputs.android }} python: ${{ steps.filter.outputs.python }} graalvm: ${{ steps.filter.outputs.graalvm }} + graalvm_kotlin: ${{ steps.filter.outputs.graalvm_kotlin }} rust: ${{ steps.filter.outputs.rust }} swift: ${{ steps.filter.outputs.swift }} javascript: ${{ steps.filter.outputs.javascript }} kotlin: ${{ steps.filter.outputs.kotlin }} + kotlin_benchmark: ${{ steps.filter.outputs.kotlin_benchmark }} scala: ${{ steps.filter.outputs.scala }} steps: - uses: actions/checkout@v5 @@ -78,12 +81,15 @@ jobs: echo "cpp=true" >> "$GITHUB_OUTPUT" echo "cpp_code=true" >> "$GITHUB_OUTPUT" echo "java_code=true" >> "$GITHUB_OUTPUT" + echo "android=true" >> "$GITHUB_OUTPUT" echo "python=true" >> "$GITHUB_OUTPUT" echo "graalvm=true" >> "$GITHUB_OUTPUT" + echo "graalvm_kotlin=true" >> "$GITHUB_OUTPUT" echo "rust=true" >> "$GITHUB_OUTPUT" echo "swift=true" >> "$GITHUB_OUTPUT" echo "javascript=true" >> "$GITHUB_OUTPUT" echo "kotlin=true" >> "$GITHUB_OUTPUT" + echo "kotlin_benchmark=true" >> "$GITHUB_OUTPUT" echo "scala=true" >> "$GITHUB_OUTPUT" exit 0 fi @@ -121,6 +127,12 @@ jobs: echo "java_code=false" >> "$GITHUB_OUTPUT" fi + if grep -Eq '^(\.github/workflows/ci\.yml$|ci/run_ci\.(py|sh)$|ci/tasks/(common|kotlin)\.py$|java/(pom\.xml$|fory-(core|json|annotation-processor)/)|kotlin/|integration_tests/(kotlin_json_corpus|android_tests)/)' <<< "$changed_files"; then + echo "android=true" >> "$GITHUB_OUTPUT" + else + echo "android=false" >> "$GITHUB_OUTPUT" + fi + if grep -Eq '^(python/)' <<< "$changed_files"; then echo "python=true" >> "$GITHUB_OUTPUT" else @@ -133,6 +145,12 @@ jobs: echo "graalvm=false" >> "$GITHUB_OUTPUT" fi + if grep -Eq '^(\.github/workflows/ci\.yml$|ci/run_ci\.(py|sh)$|ci/tasks/(common|kotlin)\.py$|java/(pom\.xml$|fory-(core|json|annotation-processor)/)|kotlin/|integration_tests/(kotlin_json_corpus|graalvm_kotlin_tests)/)' <<< "$changed_files"; then + echo "graalvm_kotlin=true" >> "$GITHUB_OUTPUT" + else + echo "graalvm_kotlin=false" >> "$GITHUB_OUTPUT" + fi + if grep -Eq '^(rust/)' <<< "$changed_files"; then echo "rust=true" >> "$GITHUB_OUTPUT" else @@ -151,12 +169,18 @@ jobs: echo "javascript=false" >> "$GITHUB_OUTPUT" fi - if grep -Eq '^(java/|kotlin/)' <<< "$changed_files"; then + if grep -Eq '^(\.github/workflows/(ci\.yml|release-jvm-snapshot\.yaml)$|ci/run_ci\.(py|sh)$|ci/tasks/(common|java|kotlin)\.py$|ci/(release|test_release)\.py$|java/(pom\.xml$|fory-(core|json|annotation-processor)/)|kotlin/|integration_tests/(kotlin_json_corpus|graalvm_kotlin_tests|jpms_tests|grpc_tests/kotlin|idl_tests/kotlin)/)' <<< "$changed_files"; then echo "kotlin=true" >> "$GITHUB_OUTPUT" else echo "kotlin=false" >> "$GITHUB_OUTPUT" fi + if grep -Eq '^(\.github/workflows/ci\.yml$|ci/run_ci\.(py|sh)$|ci/tasks/(common|kotlin)\.py$|java/(pom\.xml$|fory-(core|json|annotation-processor)/)|kotlin/|benchmarks/kotlin/)' <<< "$changed_files"; then + echo "kotlin_benchmark=true" >> "$GITHUB_OUTPUT" + else + echo "kotlin_benchmark=false" >> "$GITHUB_OUTPUT" + fi + if grep -Eq '^(java/|scala/)' <<< "$changed_files"; then echo "scala=true" >> "$GITHUB_OUTPUT" else @@ -287,7 +311,7 @@ jobs: android: name: Android Instrumented Tests API ${{ matrix.api-level }} needs: changes - if: needs.changes.outputs.java_code == 'true' + if: needs.changes.outputs.android == 'true' runs-on: ubuntu-latest strategy: fail-fast: false @@ -300,6 +324,10 @@ jobs: with: java-version: 17 distribution: "temurin" + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: 3.11 - name: Cache Maven local repository uses: actions/cache@v4 with: @@ -311,17 +339,15 @@ jobs: uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e with: gradle-version: "8.13" - - name: Install Fory Java artifacts - run: | - cd java - mvn -T16 --no-transfer-progress -pl fory-json,fory-annotation-processor -am install -DskipTests -Dmaven.javadoc.skip=true -Dmaven.source.skip=true + - name: Install Fory Java and Kotlin JSON artifacts + run: python ./ci/run_ci.py kotlin --task install - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: Run Android instrumented tests - uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 with: api-level: ${{ matrix.api-level }} arch: x86_64 @@ -329,6 +355,7 @@ jobs: working-directory: integration_tests/android_tests script: | yes | sdkmanager "platforms;android-${{ matrix.api-level }}" "build-tools;35.0.0" + gradle --no-daemon --stacktrace verifyKotlinJsonRules if [ "${{ matrix.api-level }}" = "26" ]; then gradle --no-daemon --stacktrace -PforyTestBuildType=debug connectedCheck; fi gradle --no-daemon --stacktrace -PforyTestBuildType=release connectedCheck - name: Upload Android Test Report @@ -623,6 +650,32 @@ jobs: shell: bash run: ./ci/run_ci.sh graalvm_json_tests + graalvm_kotlin_json: + name: GraalVM Kotlin JSON CI + needs: changes + if: needs.changes.outputs.graalvm_kotlin == 'true' + runs-on: ubuntu-latest + strategy: + matrix: + java-version: ["17", "25"] + steps: + - uses: actions/checkout@v5 + - uses: graalvm/setup-graalvm@6f3fa030c4b8f77c1f554a860f593a654538fa38 # 1.5.6 + with: + java-version: ${{ matrix.java-version }} + distribution: "graalvm" + github-token: ${{ secrets.GITHUB_TOKEN }} + native-image-job-reports: "true" + - name: Cache Maven local repository + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + - name: Build and run Kotlin JSON native image + run: python ./ci/run_ci.py kotlin --task native-json + kotlin: name: Kotlin CI needs: changes @@ -632,7 +685,7 @@ jobs: MY_VAR: "PATH" strategy: matrix: - java-version: ["8", "11", "17", "21"] + java-version: ["8", "11", "17", "21", "25", "26"] steps: - uses: actions/checkout@v5 - name: Set up JDK ${{ matrix.java-version }} @@ -647,15 +700,50 @@ jobs: key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: | ${{ runner.os }}-maven- - - name: Set up Python 3.8 + - name: Set up Python 3.11 uses: actions/setup-python@v5 with: - python-version: 3.8 - - name: Install fory java - run: python ./ci/run_ci.py java --install-jdks --install-fory + python-version: 3.11 - name: Run Kotlin CI run: python ./ci/run_ci.py kotlin + kotlin_json_benchmark: + name: Kotlin JSON Benchmark Correctness + needs: changes + if: needs.changes.outputs.kotlin_benchmark == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: "temurin" + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: 3.11 + - name: Cache Maven local repository + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + - name: Set up Gradle 9.3.0 + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e + with: + gradle-version: "9.3.0" + - name: Install Fory Java and Kotlin JSON artifacts + run: python ./ci/run_ci.py kotlin --task install + - name: Verify benchmark correctness and Moshi adapters + run: gradle --no-daemon -p benchmarks/kotlin test verifyGeneratedJsonArtifacts jmhClasses + - name: Install benchmark report dependencies + run: python -m pip install -r benchmarks/kotlin/requirements.txt + - name: Verify benchmark reports + working-directory: benchmarks/kotlin + run: python -m unittest test_benchmark_report.py + scala: name: Scala CI needs: changes diff --git a/.github/workflows/release-java-snapshot.yaml b/.github/workflows/release-jvm-snapshot.yaml similarity index 79% rename from .github/workflows/release-java-snapshot.yaml rename to .github/workflows/release-jvm-snapshot.yaml index d82dcc6a06..9e66af4753 100644 --- a/.github/workflows/release-java-snapshot.yaml +++ b/.github/workflows/release-jvm-snapshot.yaml @@ -18,21 +18,24 @@ # All `uses:` action pins in this workflow must come from the Apache action allowlist: # https://github.com/apache/infrastructure-actions/blob/main/actions.yml -name: Publish Fory Java Snapshot +name: Publish Fory JVM Snapshot on: push: branches: - main - - release-java-snapshot + - release-jvm-snapshot + +permissions: + contents: read jobs: - publish-java: + publish-jvm: runs-on: ubuntu-latest if: github.repository == 'apache/fory' steps: - uses: actions/checkout@v5 - - name: Set up Maven Central Repository + - name: Set up Apache snapshot repository uses: actions/setup-java@v4 with: java-version: "25" @@ -42,8 +45,11 @@ jobs: server-id: apache.snapshots.https server-username: NEXUS_USERNAME server-password: NEXUS_PASSWORD - - name: Publish Fory Java Snapshot - run: python ./ci/run_ci.py java --version 25 --release + - uses: sbt/setup-sbt@9d56cf12e9b58d219605e1d8bfe69a8395fedde0 # v1.5.1 + with: + disk-cache: false + - name: Publish Java, Kotlin, and Scala snapshots + run: python ./ci/release.py publish_jvm --mode snapshot env: NEXUS_USERNAME: ${{ secrets.NEXUS_USER }} NEXUS_PASSWORD: ${{ secrets.NEXUS_PW }} diff --git a/benchmarks/kotlin/.gitignore b/benchmarks/kotlin/.gitignore new file mode 100644 index 0000000000..b3653f78aa --- /dev/null +++ b/benchmarks/kotlin/.gitignore @@ -0,0 +1,4 @@ +.gradle/ +build/ +reports/ +__pycache__/ diff --git a/benchmarks/kotlin/README.md b/benchmarks/kotlin/README.md new file mode 100644 index 0000000000..30a2d116f1 --- /dev/null +++ b/benchmarks/kotlin/README.md @@ -0,0 +1,90 @@ +# Kotlin JSON Benchmarks + +This standalone Gradle/JMH project compares Fory JSON Kotlin, kotlinx.serialization, Moshi, and +Jackson Kotlin on one immutable Eishay `MediaContent` model. It is a repository benchmark project, +not a published Fory artifact. + +The model uses only `val` properties and has no public zero-argument constructor. All four +libraries consume the same model and fixture; no library-specific data transfer object or measured +model conversion is used. The fixture SHA-256 is +`8faba2f57ab397f319aced5cf1e8411a76785557d4c7d1703ec9d540354310a1`. + +## Compared operations + +The suite contains exactly 16 methods: four libraries multiplied by String serialization, UTF-8 +byte serialization, String deserialization, and UTF-8 byte deserialization. + +Each library uses a retained declared-type or generated serializer API: + +- Fory retains `jsonTypeRef()`, disables asynchronous compilation for deterministic + setup, enables null emission, and warms all four generated paths before measurement. +- kotlinx.serialization retains `MediaContent.serializer()` and uses its String and stream APIs. +- Moshi retains its KSP-generated adapter and uses its String and Okio buffer APIs. +- Jackson Kotlin retains one `ObjectReader` and one `ObjectWriter` and uses its direct String and + byte APIs. + +The final byte materialization required by a library remains inside its measured byte-serialization +method. Deserialization likewise includes any fresh in-memory stream or buffer required by that +library. No byte method routes through a prebuilt String. + +## Correctness gates + +Before timing, setup verifies that every library: + +- decodes the exact fixture from String and UTF-8 bytes to the independent expected object; +- emits structurally equivalent JSON from String and byte APIs; and +- round-trips its own String and byte output. + +The Gradle build also fails unless the Moshi KSP adapter is present for every object model. Fory +uses its normal HotSpot metadata path in this benchmark; Android retention rules do not participate +in the measured runtime. + +## Build and run + +Install the current `fory-json-kotlin` artifact in Maven local first. Use Gradle 9.3.0 and a JDK 17 +or later toolchain. Install the pinned Python report dependencies before running the report tests +or producing charts: + +```bash +python -m pip install -r requirements.txt +``` + +Build the correctness and JMH artifacts: + +```bash +gradle --no-daemon test verifyGeneratedJsonArtifacts jmhJar writeBenchmarkClasspath +``` + +Run the paired process-isolated scheduler: + +```bash +python run_json_benchmark.py --rounds 6 --output-dir reports/json +``` + +Each round launches 16 separate JVM processes and runs one exact method per process. For every +operation, Fory and one comparator are adjacent; the selected comparator and AB/BA direction rotate +across rounds. Only those adjacent AB/BA launches contribute to Fory/comparator ratios. The report +computes each ratio inside its round before calculating the median and median absolute deviation. +The round count must be a multiple of three; the six-round default gives every comparator one AB +and one BA adjacency for each operation. + +Use `--prepare-only` for CI correctness and source-generation checks without performance timing. +When comparing two Fory revisions at the same Maven coordinate, resolve each revision from a +separate Maven repository and build one JMH JAR from each isolated classpath. Supply the second JMH +JAR and generated classpath manifest with `--comparison-jmh-jar` and +`--comparison-classpath-file`, plus its commit with `--comparison-commit`. The runner rejects a +shared Fory artifact path, verifies that the immutable model, fixture, benchmark methods, and JMH +case list are identical, records both artifact and dependency-set hashes plus the executed JMH JAR +hash for every launch, and alternates current/comparison Fory launches in adjacent AB/BA pairs. If +the comparison revision lacks this exact module, API, or benchmark surface, do not report a +revision ratio. + +Excluded runs are retained. Use `--session-id` when predeclaring deterministic run IDs, then supply +a CSV with `run_id,reason` columns through `--exclusions`; the raw sample remains present with +`included=false` and the reason. A failed process is also retained and fails the overall run. A +completed raw CSV can instead be reviewed, marked with exclusions, and passed directly to +`benchmark_report.py` without deleting any launch. + +See the [published Kotlin JSON benchmark report](../../docs/benchmarks/json/kotlin/README.md). The +published page explicitly remains pending until a complete measured run is available; the tooling +does not synthesize results. diff --git a/benchmarks/kotlin/benchmark_report.py b/benchmarks/kotlin/benchmark_report.py new file mode 100644 index 0000000000..185b4ed0bb --- /dev/null +++ b/benchmarks/kotlin/benchmark_report.py @@ -0,0 +1,454 @@ +#!/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 paired Kotlin JSON JMH samples and generate the published report.""" + +from __future__ import annotations + +import argparse +import csv +import math +import statistics +import sys +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Mapping + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.ticker import FuncFormatter + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from plot_style import ( # noqa: E402 + BAR_EDGE_COLOR, + apply_benchmark_style, + format_markdown_with_prettier, + format_throughput_label, + format_throughput_tick, + save_benchmark_figure, + style_throughput_axis, +) + +apply_benchmark_style(plt) + +LIBRARIES = ("fory", "kotlinx", "moshi", "jackson") +COMPARATORS = ("kotlinx", "moshi", "jackson") +OPERATIONS = ( + "string_serialization", + "utf8_bytes_serialization", + "string_deserialization", + "utf8_bytes_deserialization", +) +LABELS = { + "fory": "fory-json-kotlin", + "kotlinx": "kotlinx.serialization", + "moshi": "Moshi", + "jackson": "Jackson Kotlin", +} +COLORS = { + "fory": "#FF6F01", + "kotlinx": "#4C78A8", + "moshi": "#55BCC2", + "jackson": "#8C6BB1", +} +CHART_NAMES = {operation: f"{operation}_throughput.png" for operation in OPERATIONS} +SETTINGS_FIELDS = ( + "jdk_version", + "jmh_version", + "kotlin_version", + "forks", + "threads", + "warmup_iterations", + "warmup_time", + "measurement_iterations", + "measurement_time", + "gradle_version", + "fory_version", + "kotlinx_version", + "moshi_version", + "jackson_version", + "ksp_version", + "jmh_plugin_version", +) + + +@dataclass(frozen=True) +class Aggregate: + median: float + mad: float + count: int + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--samples", required=True) + parser.add_argument("--output-dir", required=True) + return parser.parse_args() + + +def read_samples(path: Path) -> list[dict[str, str]]: + with path.open(newline="", encoding="utf-8") as source: + rows = list(csv.DictReader(source)) + if not rows: + raise ValueError(f"No benchmark samples in {path}") + return rows + + +def ops_per_second(value: str | float, unit: str) -> float: + score = float(value) + multiplier = { + "ops/s": 1.0, + "ops/ms": 1_000.0, + "ops/us": 1_000_000.0, + "ops/ns": 1_000_000_000.0, + }.get(unit) + if multiplier is None: + raise ValueError(f"Unsupported JMH throughput unit: {unit}") + throughput = score * multiplier + if not math.isfinite(throughput) or throughput <= 0: + raise ValueError(f"Invalid JMH throughput: {value} {unit}") + return throughput + + +def included_samples(rows: Iterable[Mapping[str, str]]) -> list[Mapping[str, str]]: + included = [] + for row in rows: + if row.get("included", "").lower() != "true": + if not row.get("exclusion_reason"): + raise ValueError( + "Every excluded sample must record an exclusion reason" + ) + continue + if row.get("return_code") != "0": + raise ValueError("A failed benchmark process cannot be included") + if row.get("library") not in LIBRARIES: + raise ValueError(f"Unknown library: {row.get('library')}") + if row.get("operation") not in OPERATIONS: + raise ValueError(f"Unknown operation: {row.get('operation')}") + if not row.get("score"): + raise ValueError("Included samples must have a score") + included.append(row) + if not included: + raise ValueError("No included benchmark samples") + return included + + +def validate_settings(rows: Iterable[Mapping[str, str]]) -> dict[str, str]: + values = list(rows) + metadata: dict[str, str] = {} + for field in SETTINGS_FIELDS: + distinct = {row.get(field, "") for row in values} + if "" in distinct or len(distinct) != 1: + raise ValueError(f"Missing or mixed benchmark setting: {field}") + metadata[field] = next(iter(distinct)) + for field in ( + "source_commit", + "fory_commit", + "fory_artifact_sha256", + "dependency_set_sha256", + "benchmark_jar_sha256", + "benchmark_date", + "platform", + "hardware", + ): + distinct = {row.get(field, "") for row in values} + if "" in distinct or len(distinct) != 1: + raise ValueError(f"Missing or mixed benchmark identity: {field}") + metadata[field] = next(iter(distinct)) + return metadata + + +def median_mad(values: Iterable[float]) -> Aggregate: + samples = list(values) + if not samples: + raise ValueError("Cannot aggregate an empty sample set") + median = statistics.median(samples) + mad = statistics.median(abs(value - median) for value in samples) + return Aggregate(median, mad, len(samples)) + + +def aggregate_absolute( + rows: Iterable[Mapping[str, str]], +) -> dict[tuple[str, str], Aggregate]: + grouped: dict[tuple[str, str], list[float]] = defaultdict(list) + for row in rows: + grouped[(row["operation"], row["library"])].append( + ops_per_second(row["score"], row["score_unit"]) + ) + missing = [ + f"{library}/{operation}" + for operation in OPERATIONS + for library in LIBRARIES + if (operation, library) not in grouped + ] + if missing: + raise ValueError("Missing included benchmark cases: " + ", ".join(missing)) + return {key: median_mad(values) for key, values in grouped.items()} + + +def aggregate_ratios( + rows: Iterable[Mapping[str, str]], +) -> dict[tuple[str, str], Aggregate]: + by_round: dict[tuple[str, str, str, str], float] = {} + for row in rows: + order = row.get("order", "") + if order == "unpaired": + continue + if order not in ("AB", "BA"): + raise ValueError(f"Unknown benchmark pair order: {order}") + comparator = row.get("adjacent_comparator", "") + if comparator not in COMPARATORS: + raise ValueError(f"Unknown adjacent comparator: {comparator}") + library = row["library"] + if library not in ("fory", comparator): + raise ValueError( + f"Paired launch {library} does not match comparator {comparator}" + ) + key = (row["round_id"], row["operation"], comparator, library) + if key in by_round: + raise ValueError(f"Duplicate included launch for {'/'.join(key)}") + by_round[key] = ops_per_second(row["score"], row["score_unit"]) + + ratios: dict[tuple[str, str], list[float]] = defaultdict(list) + rounds = sorted({key[0] for key in by_round}) + for round_id in rounds: + for operation in OPERATIONS: + for comparator in COMPARATORS: + fory = by_round.get((round_id, operation, comparator, "fory")) + other = by_round.get((round_id, operation, comparator, comparator)) + if fory is not None and other is not None: + ratios[(operation, comparator)].append(fory / other) + missing = [ + f"fory/{comparator}/{operation}" + for operation in OPERATIONS + for comparator in COMPARATORS + if not ratios[(operation, comparator)] + ] + if missing: + raise ValueError("Missing within-round pairs: " + ", ".join(missing)) + return {key: median_mad(values) for key, values in ratios.items()} + + +def write_summary( + absolute: Mapping[tuple[str, str], Aggregate], + ratios: Mapping[tuple[str, str], Aggregate], + output: Path, +) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + fields = ( + "kind", + "operation", + "library", + "comparator", + "median", + "mad", + "unit", + "sample_count", + ) + with output.open("w", newline="", encoding="utf-8") as target: + writer = csv.DictWriter(target, fieldnames=fields) + writer.writeheader() + for operation in OPERATIONS: + for library in LIBRARIES: + result = absolute[(operation, library)] + writer.writerow( + { + "kind": "absolute", + "operation": operation, + "library": library, + "comparator": "", + "median": f"{result.median:.12g}", + "mad": f"{result.mad:.12g}", + "unit": "ops/s", + "sample_count": result.count, + } + ) + for comparator in COMPARATORS: + result = ratios[(operation, comparator)] + writer.writerow( + { + "kind": "paired_ratio", + "operation": operation, + "library": "fory", + "comparator": comparator, + "median": f"{result.median:.12g}", + "mad": f"{result.mad:.12g}", + "unit": "ratio", + "sample_count": result.count, + } + ) + + +def render_chart( + operation: str, + absolute: Mapping[tuple[str, str], Aggregate], + output: Path, +) -> None: + figure, axis = plt.subplots(figsize=(8.2, 5.2)) + x = np.arange(len(LIBRARIES), dtype=float) + values = [absolute[(operation, library)].median for library in LIBRARIES] + errors = [absolute[(operation, library)].mad for library in LIBRARIES] + bars = axis.bar( + x, + values, + width=0.62, + yerr=errors, + capsize=2.5, + color=[COLORS[library] for library in LIBRARIES], + edgecolor=BAR_EDGE_COLOR, + linewidth=0.8, + ) + axis.bar_label( + bars, + labels=[format_throughput_label(value) for value in values], + padding=3, + fontsize=8, + ) + upper = max(value + error for value, error in zip(values, errors)) + axis.set_ylim(0, upper * 1.18 if upper else 1) + axis.set_xticks(x) + axis.set_xticklabels([LABELS[library] for library in LIBRARIES]) + axis.set_ylabel("Throughput (ops/sec)") + axis.set_title(operation.replace("_", " ").title()) + axis.yaxis.set_major_formatter(FuncFormatter(format_throughput_tick)) + style_throughput_axis(axis) + figure.tight_layout() + save_benchmark_figure(figure, output) + plt.close(figure) + + +def percent(ratio: float) -> str: + return f"{(ratio - 1.0) * 100:.1f}%" + + +def render_report( + metadata: Mapping[str, str], + absolute: Mapping[tuple[str, str], Aggregate], + ratios: Mapping[tuple[str, str], Aggregate], + excluded_count: int, + output: Path, +) -> None: + lines = [ + "# Kotlin JSON Benchmark Report\n\n", + "This report compares Fory JSON Kotlin, kotlinx.serialization, Moshi, and Jackson Kotlin " + "on the same immutable `MediaContent` model and Eishay JSON fixture. Every value below " + "comes from an isolated one-library, one-operation JMH process.\n\n", + f"- Benchmark date: `{metadata['benchmark_date']}`\n", + f"- Source commit: `{metadata['source_commit']}`\n", + f"- Fory artifact commit: `{metadata['fory_commit']}`\n", + f"- Fory artifact SHA-256: `{metadata['fory_artifact_sha256']}`\n", + f"- Dependency-set SHA-256: `{metadata['dependency_set_sha256']}`\n", + f"- Executed JMH JAR SHA-256: `{metadata['benchmark_jar_sha256']}`\n", + f"- Platform: {metadata['platform']}\n", + f"- Hardware: {metadata['hardware']}\n", + f"- JDK: `{metadata['jdk_version']}`; Kotlin: `{metadata['kotlin_version']}`; JMH: `{metadata['jmh_version']}`\n", + f"- Gradle: `{metadata['gradle_version']}`; JMH Gradle plugin: `{metadata['jmh_plugin_version']}`\n", + f"- Moshi codegen KSP Gradle plugin: `{metadata['ksp_version']}`\n", + f"- Dependencies: Fory `{metadata['fory_version']}`, kotlinx.serialization `{metadata['kotlinx_version']}`, Moshi `{metadata['moshi_version']}`, Jackson Kotlin `{metadata['jackson_version']}`\n", + f"- Forks: {metadata['forks']}; threads: {metadata['threads']}\n", + f"- Warmup: {metadata['warmup_iterations']} iterations × `{metadata['warmup_time']}`\n", + f"- Measurement: {metadata['measurement_iterations']} iterations × `{metadata['measurement_time']}`\n", + f"- Excluded launches: {excluded_count}; exclusions remain in the raw sample file with reasons\n", + "- Mode: throughput; higher is better; dispersion is median absolute deviation\n\n", + "The fixture SHA-256 is " + "`8faba2f57ab397f319aced5cf1e8411a76785557d4c7d1703ec9d540354310a1`. " + "All model properties are immutable. Setup verifies fixture decoding, each library's " + "String and byte round trips, and structural equality of all eight encoded outputs before " + "measurement. Fory uses a retained `jsonTypeRef()` and synchronous code " + "generation.\n\n", + "String methods exclude UTF-8 conversion. For byte serialization, kotlinx.serialization " + "materializes a fresh `ByteArrayOutputStream`, Moshi materializes a fresh Okio `Buffer`, " + "and Jackson uses its direct byte API. Their final byte materialization cost is included. " + "Byte deserialization likewise includes each required in-memory stream or buffer.\n\n", + ] + for operation in OPERATIONS: + title = operation.replace("_", " ").title() + lines.extend( + [ + f"## {title}\n\n", + f"![Kotlin JSON {title} throughput]({CHART_NAMES[operation]})\n\n", + "| Library | Median ops/sec | MAD | Samples |\n", + "| --- | ---: | ---: | ---: |\n", + ] + ) + for library in LIBRARIES: + result = absolute[(operation, library)] + lines.append( + f"| {LABELS[library]} | {result.median:,.0f} | " + f"{result.mad:,.0f} | {result.count} |\n" + ) + lines.extend( + [ + "\nPaired Fory ratios use only adjacent AB/BA launches and are calculated inside " + "each round before taking the median.\n\n", + "| Comparator | Median Fory/comparator ratio | MAD | Relative Fory difference | Paired rounds |\n", + "| --- | ---: | ---: | ---: | ---: |\n", + ] + ) + for comparator in COMPARATORS: + result = ratios[(operation, comparator)] + lines.append( + f"| {LABELS[comparator]} | {result.median:.3f}× | " + f"{result.mad:.3f} | {percent(result.median)} | {result.count} |\n" + ) + lines.append("\n") + lines.extend( + [ + "## Raw data\n\n", + "- [Per-launch JMH samples](data/jmh_samples.csv)\n", + "- [Absolute and paired aggregates](data/summary.csv)\n\n", + "The local run directory retains the JMH JSON and process logs referenced by the raw " + "rows. Checked-in results are evidence for the recorded environment, not a guarantee " + "for another workload or machine.\n", + ] + ) + output.write_text("".join(lines), encoding="utf-8") + format_markdown_with_prettier(output) + + +def generate(samples: Path, output_dir: Path) -> None: + rows = read_samples(samples) + included = included_samples(rows) + metadata = validate_settings(included) + absolute = aggregate_absolute(included) + ratios = aggregate_ratios(included) + output_dir.mkdir(parents=True, exist_ok=True) + data_dir = output_dir / "data" + data_dir.mkdir(parents=True, exist_ok=True) + published_samples = data_dir / "jmh_samples.csv" + published_samples.write_bytes(samples.read_bytes()) + write_summary(absolute, ratios, data_dir / "summary.csv") + for operation in OPERATIONS: + render_chart(operation, absolute, output_dir / CHART_NAMES[operation]) + render_report( + metadata, + absolute, + ratios, + len(rows) - len(included), + output_dir / "README.md", + ) + + +def main() -> None: + args = parse_args() + generate(Path(args.samples), Path(args.output_dir)) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/kotlin/build.gradle.kts b/benchmarks/kotlin/build.gradle.kts new file mode 100644 index 0000000000..33d40c7454 --- /dev/null +++ b/benchmarks/kotlin/build.gradle.kts @@ -0,0 +1,119 @@ +/* + * 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. + */ + +import java.net.URLClassLoader + +plugins { + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.kotlin.plugin.serialization") + id("com.google.devtools.ksp") + id("me.champeau.jmh") +} + +group = "org.apache.fory.benchmark" +version = "1.0-SNAPSHOT" + +val foryVersion: String by project +val kotlinxSerializationVersion: String by project +val moshiVersion: String by project +val jacksonVersion: String by project +val benchmarkJmhVersion = providers.gradleProperty("jmhVersion").get() + +dependencies { + implementation("org.apache.fory:fory-json-kotlin:$foryVersion") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion") + implementation("com.squareup.moshi:moshi:$moshiVersion") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") + + ksp("com.squareup.moshi:moshi-kotlin-codegen:$moshiVersion") + + testImplementation("org.junit.jupiter:junit-jupiter:5.14.1") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.14.1") +} + +kotlin { + jvmToolchain(17) + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) + } +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.test { + useJUnitPlatform() +} + +jmh { + jmhVersion = benchmarkJmhVersion + benchmarkMode = listOf("thrpt") + timeUnit = "s" + warmupIterations = 3 + iterations = 5 + fork = 1 + threads = 1 + timeOnIteration = "2s" + warmup = "2s" + resultFormat = "JSON" +} + +val verifyGeneratedJsonArtifacts = tasks.register("verifyGeneratedJsonArtifacts") { + dependsOn(tasks.named("classes")) + doLast { + val runtimeFiles = sourceSets.main.get().runtimeClasspath.files + URLClassLoader(runtimeFiles.map { it.toURI().toURL() }.toTypedArray(), null).use { loader -> + val modelNames = + listOf( + "org.apache.fory.benchmark.json.MediaContent", + "org.apache.fory.benchmark.json.Media", + "org.apache.fory.benchmark.json.Image", + ) + for (modelName in modelNames) { + loader.loadClass(modelName + "JsonAdapter") + } + } + } +} + +tasks.named("check") { + dependsOn(verifyGeneratedJsonArtifacts) +} + +tasks.matching { it.name == "compileJmhKotlin" || it.name == "jmhClasses" }.configureEach { + dependsOn(verifyGeneratedJsonArtifacts) +} + +tasks.register("writeBenchmarkClasspath") { + dependsOn(tasks.named("jar")) + val output = layout.buildDirectory.file("benchmark-runtime-classpath.txt") + outputs.file(output) + doLast { + val files = + (sourceSets.main.get().runtimeClasspath.files + + configurations.getByName("kspKotlinProcessorClasspath").files + + tasks.named("jar").get().archiveFile.get().asFile) + .filter { it.isFile } + .distinctBy { it.canonicalPath } + .sortedBy { it.canonicalPath } + output.get().asFile.writeText(files.joinToString("\n", postfix = "\n") { it.canonicalPath }) + } +} diff --git a/benchmarks/kotlin/gradle.properties b/benchmarks/kotlin/gradle.properties new file mode 100644 index 0000000000..fba95f734a --- /dev/null +++ b/benchmarks/kotlin/gradle.properties @@ -0,0 +1,16 @@ +# 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. + +foryVersion=1.7.0-SNAPSHOT +kotlinVersion=2.3.20 +kspVersion=2.3.8 +kotlinxSerializationVersion=1.11.0 +moshiVersion=1.15.2 +jacksonVersion=2.22.1 +jmhVersion=1.37 +jmhPluginVersion=0.7.3 +gradleVersion=9.3.0 +org.gradle.caching=true +org.gradle.jvmargs=-Xmx2g diff --git a/benchmarks/kotlin/requirements.txt b/benchmarks/kotlin/requirements.txt new file mode 100644 index 0000000000..b8538cda73 --- /dev/null +++ b/benchmarks/kotlin/requirements.txt @@ -0,0 +1,7 @@ +# 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. + +matplotlib==3.10.7 +numpy==1.26.4 diff --git a/benchmarks/kotlin/run_json_benchmark.py b/benchmarks/kotlin/run_json_benchmark.py new file mode 100644 index 0000000000..fb06aa4d7d --- /dev/null +++ b/benchmarks/kotlin/run_json_benchmark.py @@ -0,0 +1,795 @@ +#!/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. + +"""Run isolated paired Kotlin JSON JMH rounds and retain every launch.""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import os +import platform +import re +import shutil +import subprocess +import uuid +import zipfile +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from typing import Any, Iterable, Mapping + +import benchmark_report + +BENCHMARK_CLASS = "org.apache.fory.benchmark.json.MediaContentBenchmark" + + +def load_versions() -> dict[str, str]: + path = Path(__file__).resolve().parent / "gradle.properties" + versions = {} + for line in path.read_text(encoding="utf-8").splitlines(): + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + versions[key] = value + required = ( + "foryVersion", + "kotlinVersion", + "kspVersion", + "kotlinxSerializationVersion", + "moshiVersion", + "jacksonVersion", + "jmhVersion", + "jmhPluginVersion", + "gradleVersion", + ) + missing = [key for key in required if key not in versions] + if missing: + raise ValueError("Missing benchmark versions: " + ", ".join(missing)) + return versions + + +VERSIONS = load_versions() +KOTLIN_VERSION = VERSIONS["kotlinVersion"] +JMH_VERSION = VERSIONS["jmhVersion"] +REVISION_SURFACE_ENTRIES = ( + "org/apache/fory/benchmark/json/MediaContent.class", + "org/apache/fory/benchmark/json/Media.class", + "org/apache/fory/benchmark/json/Image.class", + "org/apache/fory/benchmark/json/Player.class", + "org/apache/fory/benchmark/json/ImageSize.class", + "org/apache/fory/benchmark/json/BenchmarkCodecs.class", + "org/apache/fory/benchmark/json/MediaContentFixture.class", + "org/apache/fory/benchmark/json/MediaContentBenchmark.class", + "org/apache/fory/benchmark/json/BenchmarkState.class", + "META-INF/BenchmarkList", + "data/eishay.json", +) +SAMPLE_FIELDS = ( + "source_commit", + "fory_commit", + "comparison_commit", + "fory_artifact_sha256", + "comparison_artifact_sha256", + "dependency_set_sha256", + "comparison_dependency_set_sha256", + "benchmark_jar_sha256", + "benchmark_date", + "platform", + "hardware", + "gradle_version", + "fory_version", + "kotlinx_version", + "moshi_version", + "jackson_version", + "ksp_version", + "jmh_plugin_version", + "run_id", + "variant", + "round_id", + "pair_id", + "adjacent_comparator", + "library", + "operation", + "position", + "order", + "jdk_version", + "jmh_version", + "kotlin_version", + "forks", + "threads", + "warmup_iterations", + "warmup_time", + "measurement_iterations", + "measurement_time", + "score", + "score_unit", + "score_error", + "score_confidence_low", + "score_confidence_high", + "raw_data_json", + "raw_log_path", + "result_json_path", + "return_code", + "included", + "exclusion_reason", +) + + +@dataclass(frozen=True) +class Launch: + round_index: int + position: int + library: str + operation: str + adjacent_comparator: str + order: str + + @property + def method(self) -> str: + operation = "".join(word.title() for word in self.operation.split("_")) + return f"{self.library}{operation[0].upper()}{operation[1:]}" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--rounds", type=int, default=6) + parser.add_argument("--warmup-iterations", type=int, default=3) + parser.add_argument("--measurement-iterations", type=int, default=5) + parser.add_argument("--warmup-time", default="2s") + parser.add_argument("--measurement-time", default="2s") + parser.add_argument("--forks", type=int, default=1) + parser.add_argument("--threads", type=int, default=1) + parser.add_argument("--output-dir", default="reports/json") + parser.add_argument("--jmh-jar") + parser.add_argument("--classpath-file") + parser.add_argument("--comparison-classpath-file") + parser.add_argument("--comparison-jmh-jar") + parser.add_argument("--fory-commit") + parser.add_argument("--comparison-commit", default="not-applicable") + parser.add_argument("--exclusions") + parser.add_argument("--session-id") + parser.add_argument("--skip-build", action="store_true") + parser.add_argument("--prepare-only", action="store_true") + args = parser.parse_args() + for field in ( + "rounds", + "warmup_iterations", + "measurement_iterations", + "forks", + "threads", + ): + if getattr(args, field) <= 0: + parser.error(f"--{field.replace('_', '-')} must be positive") + if args.rounds % len(benchmark_report.COMPARATORS) != 0: + parser.error("--rounds must be a multiple of 3 to balance comparator pairs") + comparison_values = ( + bool(args.comparison_classpath_file), + bool(args.comparison_jmh_jar), + args.comparison_commit != "not-applicable", + ) + if len(set(comparison_values)) != 1: + parser.error( + "--comparison-classpath-file, --comparison-jmh-jar, and " + "--comparison-commit must be supplied together" + ) + if args.session_id and re.fullmatch(r"[A-Za-z0-9_.-]+", args.session_id) is None: + parser.error( + "--session-id may contain only letters, digits, dot, underscore, and hyphen" + ) + return args + + +def schedule(round_index: int) -> tuple[Launch, ...]: + launches: list[Launch] = [] + position = 1 + for operation_index, operation in enumerate(benchmark_report.OPERATIONS): + comparator_index = (round_index + operation_index) % len( + benchmark_report.COMPARATORS + ) + adjacent = benchmark_report.COMPARATORS[comparator_index] + remaining = [ + library for library in benchmark_report.COMPARATORS if library != adjacent + ] + is_ab = (round_index + operation_index) % 2 == 0 + if is_ab: + order = ["fory", adjacent, *remaining] + order_name = "AB" + else: + order = [*reversed(remaining), adjacent, "fory"] + order_name = "BA" + for library in order: + launches.append( + Launch( + round_index=round_index, + position=position, + library=library, + operation=operation, + adjacent_comparator=adjacent, + order=order_name if library in ("fory", adjacent) else "unpaired", + ) + ) + position += 1 + if len(launches) != 16: + raise AssertionError("Each round must have exactly 16 isolated launches") + return tuple(launches) + + +def revision_schedule(round_index: int) -> tuple[tuple[Launch, str], ...]: + launches = [] + position = 1 + for operation_index, operation in enumerate(benchmark_report.OPERATIONS): + variants = ( + ("current", "comparison") + if (round_index + operation_index) % 2 == 0 + else ("comparison", "current") + ) + order = "AB" if variants[0] == "current" else "BA" + for variant in variants: + launches.append( + ( + Launch( + round_index=round_index, + position=position, + library="fory", + operation=operation, + adjacent_comparator="fory-revision", + order=order, + ), + variant, + ) + ) + position += 1 + if len(launches) != 8: + raise AssertionError("Each Fory revision round must have eight launches") + return tuple(launches) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def classpath_identity(path: Path) -> tuple[str, str]: + files = [Path(line) for line in path.read_text(encoding="utf-8").splitlines()] + missing = [str(file) for file in files if not file.is_file()] + if missing: + raise ValueError("Missing classpath artifacts: " + ", ".join(missing)) + artifact_hashes = [(file.name, sha256(file)) for file in files] + fory = [ + value + for name, value in artifact_hashes + if name.startswith("fory-json-kotlin-") and "-ksp-" not in name + ] + if len(fory) != 1: + raise ValueError( + "Expected one fory-json-kotlin runtime artifact, found " + str(len(fory)) + ) + digest = hashlib.sha256() + for name, value in sorted(artifact_hashes): + digest.update(name.encode()) + digest.update(b"\0") + digest.update(value.encode()) + digest.update(b"\n") + return fory[0], digest.hexdigest() + + +def validate_isolated_artifacts( + current: Path, comparison: Path +) -> tuple[str, str, str, str]: + if current.resolve() == comparison.resolve(): + raise ValueError( + "Fory revision comparison requires separate classpath manifests" + ) + current_files = { + Path(line).resolve() + for line in current.read_text(encoding="utf-8").splitlines() + } + comparison_files = { + Path(line).resolve() + for line in comparison.read_text(encoding="utf-8").splitlines() + } + current_fory = { + file + for file in current_files + if file.name.startswith("fory-json-kotlin-") and "-ksp-" not in file.name + } + comparison_fory = { + file + for file in comparison_files + if file.name.startswith("fory-json-kotlin-") and "-ksp-" not in file.name + } + if len(current_fory) != 1 or len(comparison_fory) != 1: + raise ValueError( + "Each comparison classpath must contain one Fory Kotlin JSON artifact" + ) + if current_fory == comparison_fory: + raise ValueError("Fory revisions resolved to the same artifact path") + current_fory_hash, current_dependency_hash = classpath_identity(current) + comparison_fory_hash, comparison_dependency_hash = classpath_identity(comparison) + return ( + current_fory_hash, + current_dependency_hash, + comparison_fory_hash, + comparison_dependency_hash, + ) + + +def validate_revision_surface(current: Path, comparison: Path) -> None: + with ( + zipfile.ZipFile(current) as current_jar, + zipfile.ZipFile(comparison) as comparison_jar, + ): + for entry in REVISION_SURFACE_ENTRIES: + try: + current_bytes = current_jar.read(entry) + comparison_bytes = comparison_jar.read(entry) + except KeyError as error: + raise ValueError( + f"Missing revision benchmark surface entry: {entry}" + ) from error + if current_bytes != comparison_bytes: + raise ValueError( + "Fory revisions do not share the exact benchmark surface: " + entry + ) + + +def git_commit(root: Path) -> str: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=root, + text=True, + capture_output=True, + check=True, + ) + return result.stdout.strip() + + +def hardware_identity() -> str: + processor = platform.processor().strip() + if platform.system() == "Darwin": + result = subprocess.run( + ["sysctl", "-n", "machdep.cpu.brand_string"], + text=True, + capture_output=True, + check=False, + ) + if result.returncode == 0 and result.stdout.strip(): + processor = result.stdout.strip() + elif platform.system() == "Linux": + cpu_info = Path("/proc/cpuinfo") + if cpu_info.is_file(): + for line in cpu_info.read_text( + encoding="utf-8", errors="replace" + ).splitlines(): + name, separator, value = line.partition(":") + if separator and name.strip() in ("model name", "Hardware"): + processor = value.strip() + break + return ( + f"architecture={platform.machine()}; " + f"processor={processor or 'unknown'}; " + f"logical_cpus={os.cpu_count() or 'unknown'}" + ) + + +def prepare(benchmark_dir: Path, output_dir: Path) -> tuple[Path, Path]: + log = output_dir / "prepare.log" + command = [ + "gradle", + "--no-daemon", + "test", + "verifyGeneratedJsonArtifacts", + "jmhJar", + "writeBenchmarkClasspath", + ] + with log.open("w", encoding="utf-8") as target: + result = subprocess.run( + command, + cwd=benchmark_dir, + stdout=target, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(f"Benchmark preparation failed; see {log}") + jars = sorted((benchmark_dir / "build" / "libs").glob("*-jmh.jar")) + if len(jars) != 1: + raise ValueError(f"Expected one JMH jar, found {len(jars)}") + classpath = benchmark_dir / "build" / "benchmark-runtime-classpath.txt" + if not classpath.is_file(): + raise ValueError(f"Missing generated classpath manifest: {classpath}") + return jars[0], classpath + + +def load_exclusions(path: Path | None) -> dict[str, str]: + if path is None: + return {} + with path.open(newline="", encoding="utf-8") as source: + rows = list(csv.DictReader(source)) + exclusions = {} + for row in rows: + run_id = row.get("run_id", "") + reason = row.get("reason", "") + if not run_id or not reason: + raise ValueError("Exclusions require non-empty run_id and reason columns") + if run_id in exclusions: + raise ValueError(f"Duplicate exclusion for {run_id}") + exclusions[run_id] = reason + return exclusions + + +def read_jmh_result(path: Path, expected_method: str) -> Mapping[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, list) or len(value) != 1: + raise ValueError(f"Expected one JMH result in {path}") + benchmark = value[0] + if not str(benchmark.get("benchmark", "")).endswith("." + expected_method): + raise ValueError( + f"Unexpected benchmark in {path}: {benchmark.get('benchmark')}" + ) + return benchmark + + +def write_samples(path: Path, rows: Iterable[Mapping[str, object]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="", encoding="utf-8") as target: + writer = csv.DictWriter(target, fieldnames=SAMPLE_FIELDS) + writer.writeheader() + writer.writerows(rows) + + +def java_version() -> str: + result = subprocess.run( + ["java", "-version"], + text=True, + capture_output=True, + check=False, + ) + output = result.stderr or result.stdout + first_line = output.splitlines()[0] if output else "" + match = re.search(r'version "([^"]+)"', first_line) + if result.returncode != 0 or match is None: + raise ValueError("Unable to determine the benchmark JDK from java -version") + return match.group(1) + + +def jdk_version(result: Mapping[str, Any], fallback: object) -> str: + return str(result.get("jdkVersion") or fallback) + + +def launch_row( + launch: Launch, + session_id: str, + jar: Path, + raw_dir: Path, + common: Mapping[str, object], + args: argparse.Namespace, + exclusions: Mapping[str, str], + variant: str = "current", +) -> dict[str, object]: + run_id = ( + f"{session_id}-r{launch.round_index + 1:02d}-" + f"p{launch.position:02d}-{variant}-{launch.library}-{launch.operation}" + ) + result_path = raw_dir / f"{run_id}.json" + log_path = raw_dir / f"{run_id}.log" + if result_path.exists() or log_path.exists(): + raise ValueError(f"Refusing to overwrite retained raw launch {run_id}") + selector = f"{BENCHMARK_CLASS}\\.{launch.method}$" + command = [ + "java", + "-jar", + str(jar), + selector, + "-f", + str(args.forks), + "-wi", + str(args.warmup_iterations), + "-i", + str(args.measurement_iterations), + "-t", + str(args.threads), + "-w", + args.warmup_time, + "-r", + args.measurement_time, + "-bm", + "thrpt", + "-tu", + "s", + "-rf", + "json", + "-rff", + str(result_path), + ] + with log_path.open("w", encoding="utf-8") as target: + process = subprocess.run( + command, + stdout=target, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + + reason = exclusions.get(run_id, "") + result: Mapping[str, Any] = {} + score = "" + score_unit = "" + score_error = "" + confidence_low = "" + confidence_high = "" + raw_data = "" + if process.returncode == 0: + try: + result = read_jmh_result(result_path, launch.method) + metric = result["primaryMetric"] + score = metric["score"] + score_unit = metric["scoreUnit"] + score_error = metric.get("scoreError", "") + confidence = metric.get("scoreConfidence", []) + if isinstance(confidence, list) and len(confidence) == 2: + confidence_low, confidence_high = confidence + raw_data = json.dumps(metric.get("rawData", []), separators=(",", ":")) + except (OSError, ValueError, KeyError, TypeError) as error: + detail = f"invalid JMH result: {error}" + reason = f"{reason}; {detail}" if reason else detail + else: + reason = reason or f"process exited with {process.returncode}" + + return { + **common, + "run_id": run_id, + "variant": variant, + "round_id": f"round-{launch.round_index + 1:02d}", + "pair_id": ( + f"round-{launch.round_index + 1:02d}:{launch.operation}:" + f"{launch.adjacent_comparator}" + ), + "adjacent_comparator": launch.adjacent_comparator, + "library": launch.library, + "operation": launch.operation, + "position": launch.position, + "order": launch.order, + "jdk_version": jdk_version(result, common["jdk_version"]), + "jmh_version": result.get("jmhVersion", JMH_VERSION), + "kotlin_version": KOTLIN_VERSION, + "forks": args.forks, + "threads": args.threads, + "warmup_iterations": args.warmup_iterations, + "warmup_time": args.warmup_time, + "measurement_iterations": args.measurement_iterations, + "measurement_time": args.measurement_time, + "score": score, + "score_unit": score_unit, + "score_error": score_error, + "score_confidence_low": confidence_low, + "score_confidence_high": confidence_high, + "raw_data_json": raw_data, + "raw_log_path": str(Path("raw") / log_path.name), + "result_json_path": str(Path("raw") / result_path.name), + "return_code": process.returncode, + "included": str(process.returncode == 0 and not reason).lower(), + "exclusion_reason": reason, + } + + +def write_revision_summary(rows: Iterable[Mapping[str, object]], output: Path) -> None: + by_round: dict[tuple[str, str, str], float] = {} + for row in rows: + if row["included"] != "true": + continue + key = (str(row["round_id"]), str(row["operation"]), str(row["variant"])) + if key in by_round: + raise ValueError(f"Duplicate Fory revision launch for {'/'.join(key)}") + by_round[key] = benchmark_report.ops_per_second( + str(row["score"]), str(row["score_unit"]) + ) + fields = ( + "operation", + "median_current_comparison_ratio", + "mad", + "paired_rounds", + ) + with output.open("w", newline="", encoding="utf-8") as target: + writer = csv.DictWriter(target, fieldnames=fields) + writer.writeheader() + rounds = sorted({key[0] for key in by_round}) + for operation in benchmark_report.OPERATIONS: + ratios = [] + for round_id in rounds: + current = by_round.get((round_id, operation, "current")) + comparison = by_round.get((round_id, operation, "comparison")) + if current is not None and comparison is not None: + ratios.append(current / comparison) + if not ratios: + raise ValueError(f"Missing Fory revision pairs for {operation}") + result = benchmark_report.median_mad(ratios) + writer.writerow( + { + "operation": operation, + "median_current_comparison_ratio": f"{result.median:.12g}", + "mad": f"{result.mad:.12g}", + "paired_rounds": result.count, + } + ) + + +def main() -> None: + args = parse_args() + benchmark_dir = Path(__file__).resolve().parent + root = benchmark_dir.parents[1] + output_dir = Path(args.output_dir) + if not output_dir.is_absolute(): + output_dir = benchmark_dir / output_dir + output_dir.mkdir(parents=True, exist_ok=True) + sample_path = output_dir / "jmh_samples.csv" + if not args.prepare_only and sample_path.exists(): + raise ValueError( + f"Refusing to overwrite retained benchmark samples: {sample_path}" + ) + revision_path = output_dir / "revision_samples.csv" + if not args.prepare_only and args.comparison_jmh_jar and revision_path.exists(): + raise ValueError( + f"Refusing to overwrite retained revision samples: {revision_path}" + ) + + if args.skip_build: + if not args.jmh_jar or not args.classpath_file: + raise ValueError("--skip-build requires --jmh-jar and --classpath-file") + jar = Path(args.jmh_jar) + classpath = Path(args.classpath_file) + else: + jar, classpath = prepare(benchmark_dir, output_dir) + + if args.prepare_only: + print(f"Prepared {jar}") + return + + comparison_hash = "not-applicable" + comparison_dependency_hash = "not-applicable" + if args.comparison_classpath_file: + ( + fory_hash, + dependency_hash, + comparison_hash, + comparison_dependency_hash, + ) = validate_isolated_artifacts(classpath, Path(args.comparison_classpath_file)) + else: + fory_hash, dependency_hash = classpath_identity(classpath) + + source_commit = git_commit(root) + common: dict[str, object] = { + "source_commit": source_commit, + "fory_commit": args.fory_commit or source_commit, + "comparison_commit": args.comparison_commit, + "fory_artifact_sha256": fory_hash, + "comparison_artifact_sha256": comparison_hash, + "dependency_set_sha256": dependency_hash, + "comparison_dependency_set_sha256": comparison_dependency_hash, + "benchmark_date": date.today().isoformat(), + "platform": platform.platform(), + "hardware": hardware_identity(), + "jdk_version": java_version(), + "gradle_version": VERSIONS["gradleVersion"], + "fory_version": VERSIONS["foryVersion"], + "kotlinx_version": VERSIONS["kotlinxSerializationVersion"], + "moshi_version": VERSIONS["moshiVersion"], + "jackson_version": VERSIONS["jacksonVersion"], + "ksp_version": VERSIONS["kspVersion"], + "jmh_plugin_version": VERSIONS["jmhPluginVersion"], + } + exclusions = load_exclusions(Path(args.exclusions) if args.exclusions else None) + raw_dir = output_dir / "raw" + raw_dir.mkdir(parents=True, exist_ok=True) + retained_jar = output_dir / "artifacts" / jar.name + retained_jar.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(jar, retained_jar) + current_common = {**common, "benchmark_jar_sha256": sha256(retained_jar)} + retained_comparison_jar = None + comparison_common = None + if args.comparison_jmh_jar: + comparison_jar = Path(args.comparison_jmh_jar) + if comparison_jar.resolve() == jar.resolve(): + raise ValueError("Fory revision comparison requires separate JMH jars") + validate_revision_surface(jar, comparison_jar) + retained_comparison_jar = ( + output_dir / "artifacts" / ("comparison-" + comparison_jar.name) + ) + shutil.copy2(comparison_jar, retained_comparison_jar) + comparison_common = { + **common, + "benchmark_jar_sha256": sha256(retained_comparison_jar), + } + + rows: list[dict[str, object]] = [] + session_id = args.session_id or uuid.uuid4().hex[:12] + for round_index in range(args.rounds): + for launch in schedule(round_index): + print( + f"Round {round_index + 1}/{args.rounds}, " + f"case {launch.position}/16: {launch.library} {launch.operation}", + flush=True, + ) + rows.append( + launch_row( + launch, + session_id, + retained_jar, + raw_dir, + current_common, + args, + exclusions, + ) + ) + write_samples(sample_path, rows) + + failures = [row for row in rows if row["return_code"] != 0 or not row["score"]] + if failures: + raise RuntimeError( + f"{len(failures)} benchmark processes failed; retained samples in {sample_path}" + ) + if retained_comparison_jar is not None: + assert comparison_common is not None + revision_rows: list[dict[str, object]] = [] + revision_session = "revision-" + session_id + for round_index in range(args.rounds): + for launch, variant in revision_schedule(round_index): + revision_rows.append( + launch_row( + launch, + revision_session, + retained_jar + if variant == "current" + else retained_comparison_jar, + raw_dir, + current_common if variant == "current" else comparison_common, + args, + exclusions, + variant, + ) + ) + write_samples(revision_path, revision_rows) + revision_failures = [ + row for row in revision_rows if row["return_code"] != 0 or not row["score"] + ] + if revision_failures: + raise RuntimeError( + f"{len(revision_failures)} Fory revision processes failed; " + f"retained samples in {revision_path}" + ) + write_revision_summary(revision_rows, output_dir / "revision_summary.csv") + rows.extend(revision_rows) + unknown_exclusions = set(exclusions) - {str(row["run_id"]) for row in rows} + if unknown_exclusions: + raise ValueError( + "Exclusions did not match a launch: " + + ", ".join(sorted(unknown_exclusions)) + ) + benchmark_report.generate(sample_path, output_dir / "report") + print(f"Kotlin JSON benchmark report: {output_dir / 'report' / 'README.md'}") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/kotlin/settings.gradle.kts b/benchmarks/kotlin/settings.gradle.kts new file mode 100644 index 0000000000..3c7692802f --- /dev/null +++ b/benchmarks/kotlin/settings.gradle.kts @@ -0,0 +1,52 @@ +/* + * 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. + */ + +import org.gradle.util.GradleVersion + +pluginManagement { + val kotlinVersion = providers.gradleProperty("kotlinVersion").get() + val kspVersion = providers.gradleProperty("kspVersion").get() + val jmhPluginVersion = providers.gradleProperty("jmhPluginVersion").get() + plugins { + id("org.jetbrains.kotlin.jvm") version kotlinVersion + id("org.jetbrains.kotlin.plugin.serialization") version kotlinVersion + id("com.google.devtools.ksp") version kspVersion + id("me.champeau.jmh") version jmhPluginVersion + } + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + providers.gradleProperty("foryMavenRepository").orNull?.let { maven { url = uri(it) } } + mavenLocal() + mavenCentral() + } +} + +val requiredGradle = providers.gradleProperty("gradleVersion").get() +check(GradleVersion.current() == GradleVersion.version(requiredGradle)) { + "Kotlin JSON benchmarks require Gradle $requiredGradle, found ${GradleVersion.current()}" +} + +rootProject.name = "fory-kotlin-json-benchmarks" diff --git a/benchmarks/kotlin/src/jmh/kotlin/org/apache/fory/benchmark/json/MediaContentBenchmark.kt b/benchmarks/kotlin/src/jmh/kotlin/org/apache/fory/benchmark/json/MediaContentBenchmark.kt new file mode 100644 index 0000000000..c24f3d3b27 --- /dev/null +++ b/benchmarks/kotlin/src/jmh/kotlin/org/apache/fory/benchmark/json/MediaContentBenchmark.kt @@ -0,0 +1,194 @@ +/* + * 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.fory.benchmark.json + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.concurrent.TimeUnit +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream +import okio.Buffer +import org.openjdk.jmh.annotations.Benchmark +import org.openjdk.jmh.annotations.BenchmarkMode +import org.openjdk.jmh.annotations.Fork +import org.openjdk.jmh.annotations.Measurement +import org.openjdk.jmh.annotations.Mode +import org.openjdk.jmh.annotations.OutputTimeUnit +import org.openjdk.jmh.annotations.Scope +import org.openjdk.jmh.annotations.Setup +import org.openjdk.jmh.annotations.State +import org.openjdk.jmh.annotations.Threads +import org.openjdk.jmh.annotations.Warmup + +@State(Scope.Thread) +open class BenchmarkState { + lateinit var codecs: BenchmarkCodecs + lateinit var expected: MediaContent + lateinit var fixtureString: String + lateinit var fixtureBytes: ByteArray + + @Setup + fun setup() { + codecs = BenchmarkCodecs() + expected = MediaContentFixture.expected() + fixtureBytes = MediaContentFixture.bytes() + fixtureString = MediaContentFixture.text(fixtureBytes) + verifyFixtureReads() + verifyEncodedTreesAndRoundTrips() + warmForyPaths() + } + + private fun verifyFixtureReads() { + check(codecs.foryFromString(fixtureString) == expected) + check(codecs.foryFromBytes(fixtureBytes) == expected) + check(codecs.kotlinxFromString(fixtureString) == expected) + check(codecs.kotlinxFromBytes(fixtureBytes) == expected) + check(codecs.moshiFromString(fixtureString) == expected) + check(codecs.moshiFromBytes(fixtureBytes) == expected) + check(codecs.jacksonFromString(fixtureString) == expected) + check(codecs.jacksonFromBytes(fixtureBytes) == expected) + } + + private fun verifyEncodedTreesAndRoundTrips() { + val foryString = codecs.foryToString(expected) + val foryBytes = codecs.foryToBytes(expected) + val kotlinxString = codecs.kotlinxToString(expected) + val kotlinxBytes = codecs.kotlinxToBytes(expected) + val moshiString = codecs.moshiToString(expected) + val moshiBytes = codecs.moshiToBytes(expected) + val jacksonString = codecs.jacksonToString(expected) + val jacksonBytes = codecs.jacksonToBytes(expected) + + val expectedTree = codecs.tree(foryString) + for (tree in + listOf( + codecs.tree(foryBytes), + codecs.tree(kotlinxString), + codecs.tree(kotlinxBytes), + codecs.tree(moshiString), + codecs.tree(moshiBytes), + codecs.tree(jacksonString), + codecs.tree(jacksonBytes) + )) { + check(tree == expectedTree) { "JSON libraries emitted structurally different output" } + } + + check(codecs.foryFromString(foryString) == expected) + check(codecs.foryFromBytes(foryBytes) == expected) + check(codecs.kotlinxFromString(kotlinxString) == expected) + check(codecs.kotlinxFromBytes(kotlinxBytes) == expected) + check(codecs.moshiFromString(moshiString) == expected) + check(codecs.moshiFromBytes(moshiBytes) == expected) + check(codecs.jacksonFromString(jacksonString) == expected) + check(codecs.jacksonFromBytes(jacksonBytes) == expected) + } + + private fun warmForyPaths() { + repeat(32) { + codecs.foryToString(expected) + codecs.foryToBytes(expected) + codecs.foryFromString(fixtureString) + codecs.foryFromBytes(fixtureBytes) + } + } +} + +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +@Warmup(iterations = 3, time = 2) +@Measurement(iterations = 5, time = 2) +@Fork(1) +@Threads(1) +@OptIn(ExperimentalSerializationApi::class) +open class MediaContentBenchmark { + @Benchmark + fun foryStringSerialization(state: BenchmarkState): String = + state.codecs.fory.toJson(state.expected, state.codecs.foryType) + + @Benchmark + fun foryUtf8BytesSerialization(state: BenchmarkState): ByteArray = + state.codecs.fory.toJsonBytes(state.expected, state.codecs.foryType) + + @Benchmark + fun foryStringDeserialization(state: BenchmarkState): MediaContent = + state.codecs.fory.fromJson(state.fixtureString, state.codecs.foryType) + + @Benchmark + fun foryUtf8BytesDeserialization(state: BenchmarkState): MediaContent = + state.codecs.fory.fromJson(state.fixtureBytes, state.codecs.foryType) + + @Benchmark + fun kotlinxStringSerialization(state: BenchmarkState): String = + state.codecs.kotlinx.encodeToString(state.codecs.kotlinxSerializer, state.expected) + + @Benchmark + fun kotlinxUtf8BytesSerialization(state: BenchmarkState): ByteArray { + val output = ByteArrayOutputStream() + state.codecs.kotlinx.encodeToStream(state.codecs.kotlinxSerializer, state.expected, output) + return output.toByteArray() + } + + @Benchmark + fun kotlinxStringDeserialization(state: BenchmarkState): MediaContent = + state.codecs.kotlinx.decodeFromString(state.codecs.kotlinxSerializer, state.fixtureString) + + @Benchmark + fun kotlinxUtf8BytesDeserialization(state: BenchmarkState): MediaContent = + state.codecs.kotlinx.decodeFromStream( + state.codecs.kotlinxSerializer, + ByteArrayInputStream(state.fixtureBytes), + ) + + @Benchmark + fun moshiStringSerialization(state: BenchmarkState): String = + state.codecs.moshiAdapter.toJson(state.expected) + + @Benchmark + fun moshiUtf8BytesSerialization(state: BenchmarkState): ByteArray { + val buffer = Buffer() + state.codecs.moshiAdapter.toJson(buffer, state.expected) + return buffer.readByteArray() + } + + @Benchmark + fun moshiStringDeserialization(state: BenchmarkState): MediaContent? = + state.codecs.moshiAdapter.fromJson(state.fixtureString) + + @Benchmark + fun moshiUtf8BytesDeserialization(state: BenchmarkState): MediaContent? = + state.codecs.moshiAdapter.fromJson(Buffer().write(state.fixtureBytes)) + + @Benchmark + fun jacksonStringSerialization(state: BenchmarkState): String = + state.codecs.jacksonWriter.writeValueAsString(state.expected) + + @Benchmark + fun jacksonUtf8BytesSerialization(state: BenchmarkState): ByteArray = + state.codecs.jacksonWriter.writeValueAsBytes(state.expected) + + @Benchmark + fun jacksonStringDeserialization(state: BenchmarkState): MediaContent = + state.codecs.jacksonReader.readValue(state.fixtureString) + + @Benchmark + fun jacksonUtf8BytesDeserialization(state: BenchmarkState): MediaContent = + state.codecs.jacksonReader.readValue(state.fixtureBytes) +} diff --git a/benchmarks/kotlin/src/main/kotlin/org/apache/fory/benchmark/json/BenchmarkCodecs.kt b/benchmarks/kotlin/src/main/kotlin/org/apache/fory/benchmark/json/BenchmarkCodecs.kt new file mode 100644 index 0000000000..5db19df537 --- /dev/null +++ b/benchmarks/kotlin/src/main/kotlin/org/apache/fory/benchmark/json/BenchmarkCodecs.kt @@ -0,0 +1,110 @@ +/* + * 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.fory.benchmark.json + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.ObjectReader +import com.fasterxml.jackson.databind.ObjectWriter +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream +import okio.Buffer +import org.apache.fory.json.ForyJson +import org.apache.fory.json.kotlin.ForyJsonKotlin +import org.apache.fory.json.kotlin.jsonTypeRef +import org.apache.fory.reflect.TypeRef + +@OptIn(ExperimentalSerializationApi::class) +class BenchmarkCodecs { + val foryType: TypeRef = jsonTypeRef() + val fory: ForyJson = + ForyJsonKotlin.builder().withAsyncCompilation(false).writeNullFields(true).build() + + val kotlinx: Json = Json { + encodeDefaults = true + explicitNulls = true + } + val kotlinxSerializer: KSerializer = MediaContent.serializer() + + val moshiAdapter: JsonAdapter = + Moshi.Builder().build().adapter(MediaContent::class.java).serializeNulls() + + val jackson: ObjectMapper = + jacksonObjectMapper().setDefaultPropertyInclusion(JsonInclude.Include.ALWAYS) + val jacksonReader: ObjectReader = jackson.readerFor(MediaContent::class.java) + val jacksonWriter: ObjectWriter = jackson.writerFor(MediaContent::class.java) + + fun foryToString(value: MediaContent): String = fory.toJson(value, foryType) + + fun foryToBytes(value: MediaContent): ByteArray = fory.toJsonBytes(value, foryType) + + fun foryFromString(value: String): MediaContent = fory.fromJson(value, foryType) + + fun foryFromBytes(value: ByteArray): MediaContent = fory.fromJson(value, foryType) + + fun kotlinxToString(value: MediaContent): String = + kotlinx.encodeToString(kotlinxSerializer, value) + + fun kotlinxToBytes(value: MediaContent): ByteArray { + val output = ByteArrayOutputStream() + kotlinx.encodeToStream(kotlinxSerializer, value, output) + return output.toByteArray() + } + + fun kotlinxFromString(value: String): MediaContent = + kotlinx.decodeFromString(kotlinxSerializer, value) + + fun kotlinxFromBytes(value: ByteArray): MediaContent = + kotlinx.decodeFromStream(kotlinxSerializer, ByteArrayInputStream(value)) + + fun moshiToString(value: MediaContent): String = moshiAdapter.toJson(value) + + fun moshiToBytes(value: MediaContent): ByteArray { + val buffer = Buffer() + moshiAdapter.toJson(buffer, value) + return buffer.readByteArray() + } + + fun moshiFromString(value: String): MediaContent = checkNotNull(moshiAdapter.fromJson(value)) + + fun moshiFromBytes(value: ByteArray): MediaContent = + checkNotNull(moshiAdapter.fromJson(Buffer().write(value))) + + fun jacksonToString(value: MediaContent): String = jacksonWriter.writeValueAsString(value) + + fun jacksonToBytes(value: MediaContent): ByteArray = jacksonWriter.writeValueAsBytes(value) + + fun jacksonFromString(value: String): MediaContent = jacksonReader.readValue(value) + + fun jacksonFromBytes(value: ByteArray): MediaContent = jacksonReader.readValue(value) + + fun tree(value: String): JsonNode = jackson.readTree(value) + + fun tree(value: ByteArray): JsonNode = jackson.readTree(value) +} diff --git a/benchmarks/kotlin/src/main/kotlin/org/apache/fory/benchmark/json/MediaContent.kt b/benchmarks/kotlin/src/main/kotlin/org/apache/fory/benchmark/json/MediaContent.kt new file mode 100644 index 0000000000..b19042bfeb --- /dev/null +++ b/benchmarks/kotlin/src/main/kotlin/org/apache/fory/benchmark/json/MediaContent.kt @@ -0,0 +1,71 @@ +/* + * 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.fory.benchmark.json + +import com.squareup.moshi.JsonClass +import kotlinx.serialization.Serializable +import org.apache.fory.json.annotation.JsonType + +@JsonType +@Serializable +@JsonClass(generateAdapter = true) +data class MediaContent(val images: List, val media: Media) + +@JsonType +@Serializable +@JsonClass(generateAdapter = true) +data class Media( + val bitrate: Int, + val duration: Long, + val format: String, + val height: Int, + val persons: List, + val player: Player, + val size: Long, + val title: String, + val uri: String, + val width: Int, + val copyright: String? = null, + val hasBitrate: Boolean = false, +) + +@JsonType +@Serializable +@JsonClass(generateAdapter = true) +data class Image( + val height: Int, + val size: ImageSize, + val title: String, + val uri: String, + val width: Int, + val media: Media? = null, +) + +@Serializable +enum class Player { + JAVA, + FLASH +} + +@Serializable +enum class ImageSize { + SMALL, + LARGE +} diff --git a/benchmarks/kotlin/src/main/kotlin/org/apache/fory/benchmark/json/MediaContentFixture.kt b/benchmarks/kotlin/src/main/kotlin/org/apache/fory/benchmark/json/MediaContentFixture.kt new file mode 100644 index 0000000000..d980ebf3f9 --- /dev/null +++ b/benchmarks/kotlin/src/main/kotlin/org/apache/fory/benchmark/json/MediaContentFixture.kt @@ -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. + */ + +package org.apache.fory.benchmark.json + +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +object MediaContentFixture { + const val SHA256: String = "8faba2f57ab397f319aced5cf1e8411a76785557d4c7d1703ec9d540354310a1" + + fun bytes(): ByteArray { + val stream = + checkNotNull(javaClass.classLoader.getResourceAsStream("data/eishay.json")) { + "Missing data/eishay.json" + } + val bytes = stream.use { it.readBytes() } + check(sha256(bytes) == SHA256) { "Eishay fixture SHA-256 does not match $SHA256" } + return bytes + } + + fun text(bytes: ByteArray): String = String(bytes, StandardCharsets.UTF_8) + + fun expected(): MediaContent = + MediaContent( + images = + listOf( + Image( + height = 768, + size = ImageSize.LARGE, + title = "Javaone Keynote", + uri = "http://javaone.com/keynote_large.jpg", + width = 1024, + ), + Image( + height = 240, + size = ImageSize.SMALL, + title = "Javaone Keynote", + uri = "http://javaone.com/keynote_small.jpg", + width = 320, + ), + ), + media = + Media( + bitrate = 262144, + duration = 18000000, + format = "video/mpg4", + height = 480, + persons = listOf("Bill Gates", "Steve Jobs"), + player = Player.JAVA, + size = 58982400, + title = "Javaone Keynote", + uri = "http://javaone.com/keynote.mpg", + width = 640, + ), + ) + + private fun sha256(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } +} diff --git a/benchmarks/kotlin/src/main/resources/data/eishay.json b/benchmarks/kotlin/src/main/resources/data/eishay.json new file mode 100644 index 0000000000..a96a3a898a --- /dev/null +++ b/benchmarks/kotlin/src/main/resources/data/eishay.json @@ -0,0 +1,30 @@ +{"images": [{ + "height":768, + "size":"LARGE", + "title":"Javaone Keynote", + "uri":"http://javaone.com/keynote_large.jpg", + "width":1024 + }, { + "height":240, + "size":"SMALL", + "title":"Javaone Keynote", + "uri":"http://javaone.com/keynote_small.jpg", + "width":320 + } + ], + "media": { + "bitrate":262144, + "duration":18000000, + "format":"video/mpg4", + "height":480, + "persons": [ + "Bill Gates", + "Steve Jobs" + ], + "player":"JAVA", + "size":58982400, + "title":"Javaone Keynote", + "uri":"http://javaone.com/keynote.mpg", + "width":640 + } +} diff --git a/benchmarks/kotlin/src/test/kotlin/org/apache/fory/benchmark/json/BenchmarkCorrectnessTest.kt b/benchmarks/kotlin/src/test/kotlin/org/apache/fory/benchmark/json/BenchmarkCorrectnessTest.kt new file mode 100644 index 0000000000..ab16b905b8 --- /dev/null +++ b/benchmarks/kotlin/src/test/kotlin/org/apache/fory/benchmark/json/BenchmarkCorrectnessTest.kt @@ -0,0 +1,86 @@ +/* + * 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.fory.benchmark.json + +import java.lang.reflect.Modifier +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test + +class BenchmarkCorrectnessTest { + @Test + fun modelsHaveNoPublicZeroArgConstructor() { + for (type in listOf(MediaContent::class.java, Media::class.java, Image::class.java)) { + assertFalse( + type.constructors.any { Modifier.isPublic(it.modifiers) && it.parameterCount == 0 }, + "${type.name} must not regress to a mutable Java-style model", + ) + } + } + + @Test + fun allLibrariesUseEquivalentShapes() { + val codecs = BenchmarkCodecs() + val expected = MediaContentFixture.expected() + val fixtureBytes = MediaContentFixture.bytes() + val fixtureString = MediaContentFixture.text(fixtureBytes) + + assertEquals(expected, codecs.foryFromString(fixtureString)) + assertEquals(expected, codecs.foryFromBytes(fixtureBytes)) + assertEquals(expected, codecs.kotlinxFromString(fixtureString)) + assertEquals(expected, codecs.kotlinxFromBytes(fixtureBytes)) + assertEquals(expected, codecs.moshiFromString(fixtureString)) + assertEquals(expected, codecs.moshiFromBytes(fixtureBytes)) + assertEquals(expected, codecs.jacksonFromString(fixtureString)) + assertEquals(expected, codecs.jacksonFromBytes(fixtureBytes)) + + val foryString = codecs.foryToString(expected) + val foryBytes = codecs.foryToBytes(expected) + val kotlinxString = codecs.kotlinxToString(expected) + val kotlinxBytes = codecs.kotlinxToBytes(expected) + val moshiString = codecs.moshiToString(expected) + val moshiBytes = codecs.moshiToBytes(expected) + val jacksonString = codecs.jacksonToString(expected) + val jacksonBytes = codecs.jacksonToBytes(expected) + val tree = codecs.tree(foryString) + + for (actual in + listOf( + codecs.tree(foryBytes), + codecs.tree(kotlinxString), + codecs.tree(kotlinxBytes), + codecs.tree(moshiString), + codecs.tree(moshiBytes), + codecs.tree(jacksonString), + codecs.tree(jacksonBytes) + )) { + assertEquals(tree, actual) + } + + assertEquals(expected, codecs.foryFromString(foryString)) + assertEquals(expected, codecs.foryFromBytes(foryBytes)) + assertEquals(expected, codecs.kotlinxFromString(kotlinxString)) + assertEquals(expected, codecs.kotlinxFromBytes(kotlinxBytes)) + assertEquals(expected, codecs.moshiFromString(moshiString)) + assertEquals(expected, codecs.moshiFromBytes(moshiBytes)) + assertEquals(expected, codecs.jacksonFromString(jacksonString)) + assertEquals(expected, codecs.jacksonFromBytes(jacksonBytes)) + } +} diff --git a/benchmarks/kotlin/test_benchmark_report.py b/benchmarks/kotlin/test_benchmark_report.py new file mode 100644 index 0000000000..d63b011f5d --- /dev/null +++ b/benchmarks/kotlin/test_benchmark_report.py @@ -0,0 +1,402 @@ +# 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. + +from __future__ import annotations + +import csv +import io +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import benchmark_report +import run_json_benchmark + + +class KotlinJsonBenchmarkTest(unittest.TestCase): + def sample_rows(self) -> list[dict[str, str]]: + rows = [] + scores = {"fory": 400.0, "kotlinx": 200.0, "moshi": 100.0, "jackson": 50.0} + for round_index in range(3): + for launch in run_json_benchmark.schedule(round_index): + rows.append( + { + "source_commit": "a" * 40, + "fory_commit": "b" * 40, + "comparison_commit": "not-applicable", + "fory_artifact_sha256": "c" * 64, + "comparison_artifact_sha256": "not-applicable", + "dependency_set_sha256": "d" * 64, + "comparison_dependency_set_sha256": "not-applicable", + "benchmark_jar_sha256": "e" * 64, + "benchmark_date": "2026-08-14", + "platform": "test-platform", + "hardware": "test-hardware", + "gradle_version": "9.3.0", + "fory_version": "1.7.0-SNAPSHOT", + "kotlinx_version": "1.11.0", + "moshi_version": "1.15.2", + "jackson_version": "2.22.1", + "ksp_version": "2.3.8", + "jmh_plugin_version": "0.7.3", + "run_id": f"run-r{round_index}-{launch.position}", + "variant": "current", + "round_id": f"round-{round_index}", + "pair_id": f"pair-{round_index}-{launch.operation}", + "adjacent_comparator": launch.adjacent_comparator, + "library": launch.library, + "operation": launch.operation, + "position": str(launch.position), + "order": launch.order, + "jdk_version": "26.0.1", + "jmh_version": "1.37", + "kotlin_version": "2.3.20", + "forks": "1", + "threads": "1", + "warmup_iterations": "3", + "warmup_time": "2s", + "measurement_iterations": "5", + "measurement_time": "2s", + "score": str(scores[launch.library] + round_index), + "score_unit": "ops/ms", + "score_error": "1.0", + "score_confidence_low": "0.0", + "score_confidence_high": "2.0", + "raw_data_json": "[[1.0,2.0]]", + "raw_log_path": f"raw/{launch.position}.log", + "result_json_path": f"raw/{launch.position}.json", + "return_code": "0", + "included": "true", + "exclusion_reason": "", + } + ) + return rows + + def write_rows(self, path: Path, rows: list[dict[str, str]]) -> None: + with path.open("w", newline="", encoding="utf-8") as target: + writer = csv.DictWriter(target, fieldnames=run_json_benchmark.SAMPLE_FIELDS) + writer.writeheader() + writer.writerows(rows) + + def test_round_has_sixteen_isolated_cases(self) -> None: + paired_cases = [] + for round_index in range(6): + launches = run_json_benchmark.schedule(round_index) + self.assertEqual(len(launches), 16) + self.assertEqual( + {(launch.library, launch.operation) for launch in launches}, + { + (library, operation) + for library in benchmark_report.LIBRARIES + for operation in benchmark_report.OPERATIONS + }, + ) + for operation in benchmark_report.OPERATIONS: + cases = [launch for launch in launches if launch.operation == operation] + fory_index = next( + i for i, launch in enumerate(cases) if launch.library == "fory" + ) + paired_index = next( + i + for i, launch in enumerate(cases) + if launch.library == launch.adjacent_comparator + ) + self.assertEqual(abs(fory_index - paired_index), 1) + fory = cases[fory_index] + paired_cases.append( + (fory.operation, fory.adjacent_comparator, fory.order) + ) + self.assertEqual(len(paired_cases), 24) + self.assertEqual( + set(paired_cases), + { + (operation, comparator, order) + for operation in benchmark_report.OPERATIONS + for comparator in benchmark_report.COMPARATORS + for order in ("AB", "BA") + }, + ) + + def test_round_count_balances_pairs(self) -> None: + with ( + mock.patch.object( + sys, + "argv", + ["run_json_benchmark.py", "--rounds", "5"], + ), + mock.patch("sys.stderr", new=io.StringIO()), + self.assertRaises(SystemExit), + ): + run_json_benchmark.parse_args() + with mock.patch.object( + sys, + "argv", + ["run_json_benchmark.py", "--rounds", "3"], + ): + self.assertEqual(run_json_benchmark.parse_args().rounds, 3) + + def test_revision_round_order(self) -> None: + first = run_json_benchmark.revision_schedule(0) + second = run_json_benchmark.revision_schedule(1) + self.assertEqual(len(first), 8) + self.assertEqual(len(second), 8) + for operation in benchmark_report.OPERATIONS: + first_pair = [ + variant for launch, variant in first if launch.operation == operation + ] + second_pair = [ + variant for launch, variant in second if launch.operation == operation + ] + self.assertEqual(first_pair, list(reversed(second_pair))) + + def test_launch_runs_one_exact_method(self) -> None: + launch = run_json_benchmark.schedule(0)[0] + args = SimpleNamespace( + forks=1, + warmup_iterations=1, + measurement_iterations=1, + threads=1, + warmup_time="100ms", + measurement_time="100ms", + ) + + def run(command: list[str], **_: object) -> SimpleNamespace: + result_path = Path(command[command.index("-rff") + 1]) + result_path.write_text( + '[{"benchmark":"' + + run_json_benchmark.BENCHMARK_CLASS + + "." + + launch.method + + '","jdkVersion":"26","jmhVersion":"1.37",' + '"primaryMetric":{"score":1.0,"scoreUnit":"ops/s",' + '"scoreError":0.1,"scoreConfidence":[0.9,1.1],' + '"rawData":[[1.0]]}}]\n', + encoding="utf-8", + ) + return SimpleNamespace(returncode=0) + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with mock.patch.object( + run_json_benchmark.subprocess, "run", side_effect=run + ) as call: + row = run_json_benchmark.launch_row( + launch, + "session", + root / "benchmarks.jar", + root, + {"jdk_version": "26"}, + args, + {}, + ) + self.assertEqual(call.call_count, 1) + command = call.call_args.args[0] + self.assertEqual( + command[3], + f"{run_json_benchmark.BENCHMARK_CLASS}\\.{launch.method}$", + ) + self.assertEqual(row["raw_data_json"], "[[1.0]]") + + def test_launch_preserves_raw_files(self) -> None: + launch = run_json_benchmark.schedule(0)[0] + args = SimpleNamespace( + forks=1, + warmup_iterations=1, + measurement_iterations=1, + threads=1, + warmup_time="100ms", + measurement_time="100ms", + ) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + run_id = "session-r01-p01-current-fory-string_serialization" + (root / f"{run_id}.log").write_text("retained", encoding="utf-8") + with ( + mock.patch.object(run_json_benchmark.subprocess, "run") as run, + self.assertRaisesRegex(ValueError, "retained raw launch"), + ): + run_json_benchmark.launch_row( + launch, + "session", + root / "benchmarks.jar", + root, + {}, + args, + {}, + ) + run.assert_not_called() + + def test_macos_cpu_model(self) -> None: + result = SimpleNamespace(returncode=0, stdout="Apple M4 Pro\n") + with ( + mock.patch.object( + run_json_benchmark.platform, "system", return_value="Darwin" + ), + mock.patch.object( + run_json_benchmark.platform, "machine", return_value="arm64" + ), + mock.patch.object( + run_json_benchmark.platform, "processor", return_value="arm" + ), + mock.patch.object(run_json_benchmark.os, "cpu_count", return_value=14), + mock.patch.object( + run_json_benchmark.subprocess, "run", return_value=result + ), + ): + identity = run_json_benchmark.hardware_identity() + self.assertEqual( + identity, + "architecture=arm64; processor=Apple M4 Pro; logical_cpus=14", + ) + + def test_java_version(self) -> None: + result = SimpleNamespace( + returncode=0, + stdout="", + stderr='openjdk version "26.0.1" 2026-04-21\n', + ) + with mock.patch.object( + run_json_benchmark.subprocess, + "run", + return_value=result, + ): + self.assertEqual(run_json_benchmark.java_version(), "26.0.1") + + def test_revision_summary_ratios(self) -> None: + rows: list[dict[str, object]] = [] + for round_index in range(3): + for launch, variant in run_json_benchmark.revision_schedule(round_index): + rows.append( + { + "round_id": f"round-{round_index}", + "operation": launch.operation, + "variant": variant, + "included": "true", + "score": 200 + round_index if variant == "current" else 100, + "score_unit": "ops/s", + } + ) + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "summary.csv" + run_json_benchmark.write_revision_summary(rows, output) + with output.open(encoding="utf-8") as source: + summary = list(csv.DictReader(source)) + self.assertEqual(len(summary), 4) + self.assertEqual(summary[0]["median_current_comparison_ratio"], "2.01") + + def test_unit_and_ratio_aggregation(self) -> None: + included = benchmark_report.included_samples(self.sample_rows()) + absolute = benchmark_report.aggregate_absolute(included) + ratios = benchmark_report.aggregate_ratios(included) + self.assertEqual(absolute[("string_serialization", "fory")].median, 401_000) + self.assertAlmostEqual( + ratios[("string_serialization", "kotlinx")].median, + 2.0, + ) + self.assertEqual(ratios[("string_serialization", "kotlinx")].count, 1) + with self.assertRaisesRegex(ValueError, "Invalid JMH throughput"): + benchmark_report.ops_per_second("nan", "ops/s") + + def test_exclusion_reasons(self) -> None: + rows = self.sample_rows() + rows[0]["included"] = "false" + rows[0]["exclusion_reason"] = "background load" + included = benchmark_report.included_samples(rows) + self.assertEqual(len(included), len(rows) - 1) + rows[1]["included"] = "false" + with self.assertRaisesRegex(ValueError, "exclusion reason"): + benchmark_report.included_samples(rows) + + def test_rejects_mixed_and_missing_settings(self) -> None: + rows = self.sample_rows() + included = benchmark_report.included_samples(rows) + rows[0]["threads"] = "2" + with self.assertRaisesRegex(ValueError, "threads"): + benchmark_report.validate_settings(included) + rows = [row for row in self.sample_rows() if row["library"] != "moshi"] + included = benchmark_report.included_samples(rows) + with self.assertRaisesRegex(ValueError, "moshi"): + benchmark_report.aggregate_absolute(included) + + def test_artifact_isolation(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + current_repo = root / "current-repo" + baseline_repo = root / "baseline-repo" + current_repo.mkdir() + baseline_repo.mkdir() + current_artifact = current_repo / "fory-json-kotlin-1.7.0-SNAPSHOT.jar" + baseline_artifact = baseline_repo / "fory-json-kotlin-1.7.0-SNAPSHOT.jar" + current_artifact.write_bytes(b"current") + baseline_artifact.write_bytes(b"baseline") + current = root / "current.txt" + baseline = root / "baseline.txt" + current.write_text(str(current_artifact) + "\n", encoding="utf-8") + baseline.write_text(str(baseline_artifact) + "\n", encoding="utf-8") + hashes = run_json_benchmark.validate_isolated_artifacts(current, baseline) + self.assertNotEqual(hashes[0], hashes[2]) + self.assertNotEqual(hashes[1], hashes[3]) + with self.assertRaisesRegex(ValueError, "separate"): + run_json_benchmark.validate_isolated_artifacts(current, current) + + def test_revision_surface_must_be_identical(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + current = root / "current.jar" + comparison = root / "comparison.jar" + for jar, changed in ((current, False), (comparison, False)): + with zipfile.ZipFile(jar, "w") as target: + for entry in run_json_benchmark.REVISION_SURFACE_ENTRIES: + target.writestr( + entry, b"changed" if changed else entry.encode() + ) + run_json_benchmark.validate_revision_surface(current, comparison) + with zipfile.ZipFile(comparison, "w") as target: + for entry in run_json_benchmark.REVISION_SURFACE_ENTRIES: + target.writestr( + entry, + b"changed" + if entry.endswith("MediaContent.class") + else entry.encode(), + ) + with self.assertRaisesRegex(ValueError, "benchmark surface"): + run_json_benchmark.validate_revision_surface(current, comparison) + + def test_report_outputs(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + samples = root / "samples.csv" + report = root / "report" + self.write_rows(samples, self.sample_rows()) + benchmark_report.generate(samples, report) + for chart in benchmark_report.CHART_NAMES.values(): + self.assertTrue((report / chart).is_file()) + readme = (report / "README.md").read_text(encoding="utf-8") + self.assertIn("Per-launch JMH samples", readme) + self.assertIn("Median Fory/comparator ratio", readme) + self.assertTrue((report / "data" / "jmh_samples.csv").is_file()) + self.assertTrue((report / "data" / "summary.csv").is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/ci/deploy.sh b/ci/deploy.sh index 6e2e9a9535..4e926ecabc 100755 --- a/ci/deploy.sh +++ b/ci/deploy.sh @@ -74,22 +74,6 @@ bump_javascript_version() { python "$ROOT/ci/release.py" bump_version -l javascript -version "$1" } -deploy_jars() { - local java_version java_major - java_version=$(java -version 2>&1 | awk -F '"' '/version/ {print $2; exit}') - if [[ "$java_version" == 1.* ]]; then - java_major=$(echo "$java_version" | cut -d. -f2) - else - java_major=$(echo "$java_version" | cut -d. -f1) - fi - if [[ "$java_major" -lt 25 ]]; then - echo "Java releases must run on JDK25+ so MR-JAR entries are packaged" - exit 1 - fi - cd "$ROOT/java" - mvn -T10 clean deploy --no-transfer-progress -DskipTests -Prelease -} - build_pyfory() { echo "$($PYTHON_CMD -V), path $(which "$PYTHON_CMD")" install_pyarrow @@ -158,22 +142,5 @@ install_pyarrow() { fi } -deploy_scala() { - echo "Start to build jars" - sbt +publishSigned - echo "Start to prepare upload" - sbt sonatypePrepare - echo "Start to upload jars" - sbt sonatypeBundleUpload - echo "Deploy scala jars succeed!" -} - -case "$1" in -java) # Deploy jars to maven repository. - deploy_jars - ;; -*) - echo "Execute command $*" - "$@" - ;; -esac +echo "Execute command $*" +"$@" diff --git a/ci/release.py b/ci/release.py index aad936b520..8aa67ab340 100644 --- a/ci/release.py +++ b/ci/release.py @@ -55,17 +55,45 @@ MAVEN_RELEASE_CMD = ( "mvn -T10 clean deploy --no-transfer-progress -DskipTests -Papache-release" ) -SCALA_RELEASE_CMDS = ( +MAVEN_SNAPSHOT_CMD = ( + "mvn -T10 clean deploy --no-transfer-progress -DskipTests " + "-Dgpg.skip=true -Psnapshot-publication" +) +SCALA_RELEASE_COMMANDS = ( "sbt clean", "sbt 'project fory-scala' +publishSigned", "sbt 'project fory-json-scala' +publishSigned", "sbt sonatypePrepare", "sbt sonatypeBundleUpload", ) +SCALA_SNAPSHOT_COMMANDS = ( + "sbt clean", + "sbt 'project fory-scala' +publish", + "sbt 'project fory-json-scala' +publish", +) +JVM_PUBLICATION_MODES = ("release", "snapshot") +JVM_PUBLICATION_CREDENTIALS = ("NEXUS_USERNAME", "NEXUS_PASSWORD") +KOTLIN_PUBLIC_ARTIFACTS = ( + "fory-kotlin", + "fory-kotlin-ksp", + "fory-json-kotlin", + "fory-json-kotlin-ksp", +) +KOTLIN_MODULE_NAMES = { + "fory-json-kotlin": "org.apache.fory.json.kotlin", + "fory-json-kotlin-ksp": "org.apache.fory.json.kotlin.ksp", +} +KOTLIN_SERVICE_PROVIDERS = { + "fory-kotlin-ksp": "org.apache.fory.kotlin.ksp.ForyKotlinSymbolProcessorProvider", + "fory-json-kotlin-ksp": ( + "org.apache.fory.json.kotlin.ksp.ForyJsonKotlinSymbolProcessorProvider" + ), +} RELEASE_DOC_ROOTS = ( "README.md", "java/README.md", "java/fory-json/README.md", + "kotlin/README.md", "rust/README.md", "scala/README.md", "scala/fory-scala/README.md", @@ -196,55 +224,79 @@ def verify(v): logger.info("Verified checksum successfully") -def publish_jvm(languages="all"): - """Publish Java, Kotlin, and Scala artifacts.""" +def publish_jvm(languages="all", mode="release"): + """Publish Java, Kotlin, and Scala artifacts through one ordered JVM owner.""" langs = _jvm_release_langs(languages) + _require_publication_authority(mode) _ensure_openjdk25() + if "java" not in langs: + _verify_fory_core_mr_jar() for lang in langs: if lang == "java": - _publish_java() + _publish_java(mode) _verify_fory_core_mr_jar() elif lang == "kotlin": - _publish_kotlin() + _publish_kotlin(mode) + verify_kotlin_artifacts() elif lang == "scala": - _publish_scala() + _publish_scala(mode) else: raise NotImplementedError(f"Unsupported JVM release language: {lang}") - _verify_fory_core_mr_jar() - - -def publish_java(): - publish_jvm("java") - - -def publish_kotlin(): - publish_jvm("kotlin") - - -def publish_scala(): - publish_jvm("scala") def _jvm_release_langs(languages): if languages in (None, "", "all"): return list(JVM_RELEASE_LANGS) - langs = [lang.strip() for lang in languages.split(",") if lang.strip()] - unsupported = [lang for lang in langs if lang not in JVM_RELEASE_LANGS] + selected = {lang.strip() for lang in languages.split(",") if lang.strip()} + if not selected: + raise ValueError("JVM release language selection is empty") + unsupported = sorted(selected.difference(JVM_RELEASE_LANGS)) if unsupported: raise ValueError(f"Unsupported JVM release language(s): {unsupported}") - return langs + return [lang for lang in JVM_RELEASE_LANGS if lang in selected] -def _publish_java(): - _run_release_cmd(MAVEN_RELEASE_CMD, "java") +def _require_publication_authority(mode): + if mode not in JVM_PUBLICATION_MODES: + raise ValueError(f"Unsupported JVM publication mode: {mode}") + missing = [name for name in JVM_PUBLICATION_CREDENTIALS if not os.environ.get(name)] + if missing: + raise RuntimeError(f"JVM {mode} publication requires: {', '.join(missing)}") + if mode == "release" and not _has_gpg_secret_key(): + raise RuntimeError("JVM release publication requires a GPG secret key") + os.environ["SONATYPE_USERNAME"] = os.environ["NEXUS_USERNAME"] + os.environ["SONATYPE_PASSWORD"] = os.environ["NEXUS_PASSWORD"] -def _publish_kotlin(): - _run_release_cmd(MAVEN_RELEASE_CMD, "kotlin") +def _has_gpg_secret_key(): + gpg = shutil.which("gpg") + if not gpg: + return False + result = subprocess.run( + [gpg, "--batch", "--list-secret-keys", "--with-colons"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + check=False, + ) + return result.returncode == 0 and any( + line.startswith("sec:") for line in result.stdout.splitlines() + ) -def _publish_scala(): - for command in SCALA_RELEASE_CMDS: +def _publish_java(mode="release"): + command = MAVEN_RELEASE_CMD if mode == "release" else MAVEN_SNAPSHOT_CMD + _run_release_cmd(command, "java") + + +def _publish_kotlin(mode="release"): + command = MAVEN_RELEASE_CMD if mode == "release" else MAVEN_SNAPSHOT_CMD + _run_release_cmd(command, "kotlin") + + +def _publish_scala(mode="release"): + commands = SCALA_RELEASE_COMMANDS if mode == "release" else SCALA_SNAPSHOT_COMMANDS + for command in commands: _run_release_cmd(command, "scala") @@ -481,12 +533,103 @@ def _verify_fory_core_mr_jar(): if not re.search(feature_declaration, javap.stdout): raise RuntimeError(f"{FORY_CORE_FEATURE} must remain a non-public final class") logger.info( - "Verified fory-core Multi-Release release jars: %s, %s", - jar_path, - sources_jar_path, + "Verified fory-core Multi-Release jars: %s, %s", jar_path, sources_jar_path ) +def verify_kotlin_artifacts(): + """Open every public Kotlin artifact and validate its publication surface.""" + version = _read_kotlin_version() + for artifact in KOTLIN_PUBLIC_ARTIFACTS: + module_dir = os.path.join(PROJECT_ROOT_DIR, "kotlin", artifact) + target_dir = os.path.join(module_dir, "target") + binary_path = os.path.join(target_dir, f"{artifact}-{version}.jar") + sources_path = os.path.join(target_dir, f"{artifact}-{version}-sources.jar") + javadoc_path = os.path.join(target_dir, f"{artifact}-{version}-javadoc.jar") + pom_path = os.path.join(module_dir, "pom.xml") + for path in (binary_path, sources_path, javadoc_path, pom_path): + if not os.path.exists(path): + raise FileNotFoundError(f"Missing Kotlin publication artifact: {path}") + ET.parse(pom_path) + with zipfile.ZipFile(binary_path) as binary: + binary_names = binary.namelist() + for required in ( + "META-INF/LICENSE", + "META-INF/NOTICE", + "META-INF/DEPENDENCIES", + ): + if required not in binary_names: + raise RuntimeError(f"{binary_path} is missing {required}") + if not any(name.endswith(".class") for name in binary_names): + raise RuntimeError(f"{binary_path} contains no classes") + for name in binary_names: + if not name.endswith(".class"): + continue + class_bytes = binary.read(name) + major_version = int.from_bytes(class_bytes[6:8], "big") + if major_version != 52: + raise RuntimeError( + f"{binary_path}!/{name} is JVM class version {major_version}, expected 52" + ) + module_name = KOTLIN_MODULE_NAMES.get(artifact) + if module_name: + manifest = binary.read("META-INF/MANIFEST.MF").decode("utf-8") + if f"Automatic-Module-Name: {module_name}" not in manifest: + raise RuntimeError( + f"{binary_path} is missing Automatic-Module-Name: {module_name}" + ) + provider = KOTLIN_SERVICE_PROVIDERS.get(artifact) + if provider: + service_path = ( + "META-INF/services/" + "com.google.devtools.ksp.processing.SymbolProcessorProvider" + ) + if service_path not in binary_names: + raise RuntimeError(f"{binary_path} is missing {service_path}") + providers = binary.read(service_path).decode("utf-8").splitlines() + providers = [line.strip() for line in providers if line.strip()] + if providers != [provider]: + raise RuntimeError( + f"{binary_path}!/{service_path} must contain only {provider}; " + f"found {providers}" + ) + with zipfile.ZipFile(sources_path) as sources: + source_names = set(sources.namelist()) + expected_sources = set() + for source_root in ("src/main/kotlin", "src/main/java"): + root = os.path.join(module_dir, source_root) + if not os.path.isdir(root): + continue + for directory, _, files in os.walk(root): + for filename in files: + if not filename.endswith((".kt", ".java")): + continue + expected_sources.add( + os.path.relpath( + os.path.join(directory, filename), root + ).replace(os.sep, "/") + ) + if not expected_sources: + raise RuntimeError( + f"{module_dir} contains no authored Kotlin or Java sources" + ) + packaged_sources = { + name for name in source_names if name.endswith((".kt", ".java")) + } + if packaged_sources != expected_sources: + raise RuntimeError( + f"{sources_path} has an incomplete or stale source surface: " + f"{sorted(packaged_sources ^ expected_sources)}" + ) + with zipfile.ZipFile(javadoc_path) as javadocs: + javadoc_names = javadocs.namelist() + if "index.html" not in javadoc_names or not any( + name.endswith(".html") and name != "index.html" for name in javadoc_names + ): + raise RuntimeError(f"{javadoc_path} contains no Kotlin API documentation") + logger.info("Verified Kotlin publication artifacts for %s", artifact) + + def _fory_core_jar_path(classifier=None): version = _read_java_version() classifier_suffix = f"-{classifier}" if classifier else "" @@ -511,6 +654,18 @@ def _read_java_version(): return version +def _read_kotlin_version(): + pom = os.path.join(PROJECT_ROOT_DIR, "kotlin", "pom.xml") + root = ET.parse(pom).getroot() + namespace = {"m": "http://maven.apache.org/POM/4.0.0"} + artifact = root.findtext("m:artifactId", namespaces=namespace) + packaging = root.findtext("m:packaging", namespaces=namespace) + version = root.findtext("m:version", namespaces=namespace) + if artifact != "fory-kotlin-parent" or packaging != "pom" or not version: + raise ValueError("Cannot find kotlin/fory-kotlin-parent version") + return version + + def bump_version(**kwargs): new_version = kwargs["version"] langs = kwargs["l"] @@ -696,11 +851,34 @@ def bump_kotlin_version(new_version): for p in [ "kotlin/fory-kotlin", "kotlin/fory-kotlin-ksp", + "kotlin/fory-json-kotlin", + "kotlin/fory-json-kotlin-ksp", "kotlin/fory-kotlin-tests", + "integration_tests/kotlin_json_corpus", + "integration_tests/graalvm_kotlin_tests", "integration_tests/grpc_tests/kotlin", "integration_tests/idl_tests/kotlin", ]: _bump_version(p, "pom.xml", new_version, _update_pom_parent_version) + for file in ["build.gradle", "README.md"]: + _bump_version( + "integration_tests/android_tests", + file, + new_version, + _update_android_kotlin_version, + ) + _bump_version( + "benchmarks/kotlin", + "gradle.properties", + new_version, + _update_kotlin_benchmark_version, + ) + for path, file in [ + ("kotlin/fory-json-kotlin", "README.md"), + ("kotlin/fory-json-kotlin-ksp", "README.md"), + ("docs/json", "kotlin.md"), + ]: + _bump_version(path, file, new_version, _update_release_doc_lines) def bump_cpp_version(new_version): @@ -844,6 +1022,24 @@ def _update_android_tests_dependency_version(lines, new_version): return lines +def _update_android_kotlin_version(lines, new_version): + for index, line in enumerate(lines): + lines[index] = re.sub( + r"(org\.apache\.fory:(?:fory-json-kotlin(?:-ksp)?|kotlin-json-corpus):)[^'`)\s]+", + r"\g<1>" + new_version, + line, + ) + return lines + + +def _update_kotlin_benchmark_version(lines, new_version): + for index, line in enumerate(lines): + if line.startswith("foryVersion="): + lines[index] = f"foryVersion={new_version}\n" + return lines + raise ValueError("No foryVersion entry found in Kotlin benchmark properties") + + def _update_scala_version(lines, v): v = _normalize_java_version(v) for index, line in enumerate(lines): @@ -1435,30 +1631,23 @@ def _parse_args(): default="all", help="comma separated JVM languages: java,kotlin,scala", ) - publish_jvm_parser.set_defaults(func=publish_jvm) - - publish_java_parser = subparsers.add_parser( - "publish_java", - description="Publish Java artifacts", - ) - publish_java_parser.set_defaults(func=publish_java) - - publish_kotlin_parser = subparsers.add_parser( - "publish_kotlin", - description="Publish Kotlin artifacts", + publish_jvm_parser.add_argument( + "--mode", + choices=JVM_PUBLICATION_MODES, + default="release", + help="release stages signed artifacts; snapshot publishes unsigned snapshots", ) - publish_kotlin_parser.set_defaults(func=publish_kotlin) + publish_jvm_parser.set_defaults(func=publish_jvm) - publish_scala_parser = subparsers.add_parser( - "publish_scala", - description="Publish Scala artifacts", + verify_kotlin_parser = subparsers.add_parser( + "verify_kotlin_artifacts", + description="Verify Kotlin binary, source, API documentation, and POM artifacts", ) - publish_scala_parser.set_defaults(func=publish_scala) + verify_kotlin_parser.set_defaults(func=verify_kotlin_artifacts) args = parser.parse_args() arg_dict = dict(vars(args)) del arg_dict["func"] - print(arg_dict) args.func(**arg_dict) diff --git a/ci/run_ci.py b/ci/run_ci.py index f0a84c1b65..4fefc99280 100644 --- a/ci/run_ci.py +++ b/ci/run_ci.py @@ -54,23 +54,21 @@ # USE_PYTHON_RUST - Rust implementation # USE_PYTHON_JAVASCRIPT - JavaScript implementation # USE_PYTHON_JAVA - Java implementation -# USE_PYTHON_KOTLIN - Kotlin implementation # USE_PYTHON_PYTHON - Python implementation # USE_PYTHON_GO - Go implementation # USE_PYTHON_FORMAT - Format implementation # # By default, JavaScript, Rust, and C++ use the Python implementation, -# while Java, Kotlin, Python, Go, and Format use the shell script implementation. +# while Java, Python, Go, and Format use the shell script implementation. # Environment variables to control which languages use the Python implementation # Default to the status quo before migration: # - JavaScript, Rust, and C++ use Python implementation -# - Java, Kotlin, Python, Go, and Format use shell script implementation +# - Java, Python, Go, and Format use shell script implementation USE_PYTHON_CPP = os.environ.get("USE_PYTHON_CPP", "1") == "1" USE_PYTHON_RUST = os.environ.get("USE_PYTHON_RUST", "1") == "1" USE_PYTHON_JAVASCRIPT = os.environ.get("USE_PYTHON_JAVASCRIPT", "1") == "1" USE_PYTHON_JAVA = os.environ.get("USE_PYTHON_JAVA", "0") == "1" -USE_PYTHON_KOTLIN = os.environ.get("USE_PYTHON_KOTLIN", "0") == "1" USE_PYTHON_PYTHON = os.environ.get("USE_PYTHON_PYTHON", "0") == "1" USE_PYTHON_GO = os.environ.get("USE_PYTHON_GO", "0") == "1" USE_PYTHON_FORMAT = os.environ.get("USE_PYTHON_FORMAT", "0") == "1" @@ -324,11 +322,6 @@ def parse_args(): default=None, help="Java version to use for testing", ) - java_parser.add_argument( - "--release", - action="store_true", - help="Release to Maven Central", - ) java_parser.add_argument( "--install-jdks", action="store_true", @@ -348,6 +341,12 @@ def parse_args(): help="Run Kotlin CI", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) + kotlin_parser.add_argument( + "--task", + choices=("tests", "install", "install-kotlin", "native-json"), + default="tests", + help="Kotlin CI task to execute", + ) kotlin_parser.set_defaults(func=kotlin.run) # Python subparser @@ -407,13 +406,8 @@ def parse_args(): return # Map Python version argument to shell script command version = arg_dict.get("version", "17") - release = arg_dict.get("release", False) - - if release: - logging.info("Release mode requested - using Python implementation") - func(**arg_dict) # For windows_java21 on Windows, use the Python implementation directly - elif version == "windows_java21" and is_windows(): + if version == "windows_java21" and is_windows(): logging.info( "Using Python implementation for windows_java21 on Windows" ) @@ -448,10 +442,7 @@ def parse_args(): else: run_shell_script("javascript") elif command == "kotlin": - if USE_PYTHON_KOTLIN: - func() - else: - run_shell_script("kotlin") + func(**arg_dict) elif command == "python": if USE_PYTHON_PYTHON: func() diff --git a/ci/run_ci.sh b/ci/run_ci.sh index fe0e9dad9d..ebdab570b5 100755 --- a/ci/run_ci.sh +++ b/ci/run_ci.sh @@ -24,13 +24,12 @@ # USE_PYTHON_RUST=0 # Use shell script for Rust # USE_PYTHON_JAVASCRIPT=0 # Use shell script for JavaScript # USE_PYTHON_JAVA=0 # Use shell script for Java -# USE_PYTHON_KOTLIN=0 # Use shell script for Kotlin # USE_PYTHON_PYTHON=0 # Use shell script for Python # USE_PYTHON_GO=0 # Use shell script for Go # USE_PYTHON_FORMAT=0 # Use shell script for Format # # By default, JavaScript, Rust, and C++ use the Python implementation, -# while Java, Kotlin, Python, Go, and Format use the shell script implementation. +# while Java, Python, Go, and Format use the shell script implementation. set -e set -x @@ -178,6 +177,7 @@ install_jdk25_fory_artifacts() { cd "$ROOT"/benchmarks/java mvn -T10 -B --no-transfer-progress -Pjmh -DskipTests install unset JDK_JAVA_OPTIONS + python "$ROOT/ci/run_ci.py" kotlin --task install-kotlin echo "Verify JPMS tests on JDK25" cd "$ROOT"/integration_tests/jpms_tests mvn -T10 -B --no-transfer-progress clean test @@ -257,6 +257,7 @@ jdk17_plus_tests() { fi if [[ "$java_major" -ge 25 ]]; then unset JDK_JAVA_OPTIONS + python "$ROOT/ci/run_ci.py" kotlin --task install-kotlin echo "Executing JDK${java_major} JPMS tests" cd "$ROOT/integration_tests/jpms_tests" mvn -T10 --batch-mode --no-transfer-progress clean test @@ -268,37 +269,6 @@ jdk17_plus_tests() { echo "Executing fory java tests succeeds" } -kotlin_tests() { - echo "Executing fory kotlin tests" - cd "$ROOT/kotlin" - set +e - # The KSP Maven plugin discovers processors from JAR artifacts. Build and install the - # processor first so the generated-test module does not see the reactor classes directory as - # its processor artifact. - mvn -T16 --batch-mode --no-transfer-progress -pl fory-kotlin,fory-kotlin-ksp -am -DskipTests install - testcode=$? - if [[ $testcode -ne 0 ]]; then - exit $testcode - fi - java_version=$(java -version 2>&1 | awk -F '"' '/version/ {print $2; exit}') - if [[ "$java_version" == 1.* ]]; then - java_major=$(echo "$java_version" | cut -d. -f2) - else - java_major=$(echo "$java_version" | cut -d. -f1) - fi - if [[ "$java_major" -ge 17 ]]; then - mvn -T16 --batch-mode --no-transfer-progress test -DfailIfNoTests=false - else - echo "Skipping fory-kotlin-tests on JDK < 17 because ksp-maven-plugin requires Java 17+" - mvn -T16 --batch-mode --no-transfer-progress -pl fory-kotlin,fory-kotlin-ksp -am test -DfailIfNoTests=false - fi - testcode=$? - if [[ $testcode -ne 0 ]]; then - exit $testcode - fi - echo "Executing fory kotlin tests succeeds" -} - windows_java21_test() { java -version echo "Executing fory java tests" @@ -348,9 +318,6 @@ case $1 in java26) jdk17_plus_tests ;; - kotlin) - kotlin_tests - ;; windows_java21) windows_java21_test ;; diff --git a/ci/tasks/java.py b/ci/tasks/java.py index 86bd15ecfc..7be7feacc1 100644 --- a/ci/tasks/java.py +++ b/ci/tasks/java.py @@ -19,7 +19,7 @@ import os import subprocess import re -from . import common +from . import common, kotlin def get_jdk_major_version(): @@ -151,6 +151,7 @@ def install_jdk25_fory_artifacts(): common.exec_cmd("mvn -T10 -B --no-transfer-progress -Pjmh -DskipTests install") logging.info("Verify JPMS tests on JDK25") os.environ.pop("JDK_JAVA_OPTIONS", None) + kotlin.install_artifacts(include_corpus=True) common.cd_project_subdir("integration_tests/jpms_tests") common.exec_cmd("mvn -T10 -B --no-transfer-progress clean test") finally: @@ -264,6 +265,7 @@ def run_jdk17_plus(java_version="17"): common.exec_cmd("mvn -T10 --batch-mode --no-transfer-progress clean install") os.environ.pop("JDK_JAVA_OPTIONS", None) logging.info(f"Executing JDK{java_version} JPMS tests") + kotlin.install_artifacts(include_corpus=True) common.cd_project_subdir("integration_tests/jpms_tests") common.exec_cmd("mvn -T10 --batch-mode --no-transfer-progress clean test") else: @@ -393,47 +395,13 @@ def run_graalvm_json_tests(): run_graalvm_tests("org.apache.fory.graalvm.ForyJsonExample") -def run_release(): - """Release to Maven Central.""" - logging.info("Starting release to Maven Central with Java") - java_major = get_jdk_major_version() - if java_major is None or java_major < 25: - raise RuntimeError( - "Java releases must run on JDK25+ so MR-JAR entries are packaged" - ) - common.cd_project_subdir("java") - - previous_jdk_options = os.environ.get("JDK_JAVA_OPTIONS") - os.environ["JDK_JAVA_OPTIONS"] = " ".join(jdk25_javac_options()) - try: - # Clean and install without tests first - logging.info("Cleaning and installing dependencies") - common.exec_cmd("mvn -T10 -B --no-transfer-progress clean install -DskipTests") - - # Deploy to Maven Central - logging.info("Deploying to Maven Central") - common.exec_cmd( - "mvn -T10 -B --no-transfer-progress clean deploy -Dgpg.skip -DskipTests -Papache-release" - ) - finally: - if previous_jdk_options is None: - os.environ.pop("JDK_JAVA_OPTIONS", None) - else: - os.environ["JDK_JAVA_OPTIONS"] = previous_jdk_options - - logging.info("Release to Maven Central completed successfully") - - -def run(version=None, release=False, install_jdks=False, install_fory=False): +def run(version=None, install_jdks=False, install_fory=False): """Run Java CI tasks based on the specified Java version.""" if install_jdks: globals()["install_jdks"]() if install_fory: globals()["install_fory"]() - if release: - logging.info("Release mode enabled - will release to Maven Repository") - run_release() - elif version == "8": + if version == "8": run_java8() elif version == "11": run_java11() diff --git a/ci/tasks/kotlin.py b/ci/tasks/kotlin.py index 1e81eed01a..f201371827 100644 --- a/ci/tasks/kotlin.py +++ b/ci/tasks/kotlin.py @@ -16,11 +16,73 @@ # under the License. import logging +import os import re import subprocess +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path + from . import common +PRODUCTION_MODULES = "fory-kotlin,fory-kotlin-ksp,fory-json-kotlin,fory-json-kotlin-ksp" +LOW_JDK_MODULES = "fory-kotlin,fory-kotlin-ksp,fory-json-kotlin" +CORPUS_MODULE_NAME = "org.apache.fory.integration.kotlin.json.corpus" +CORPUS_PACKAGE = "org.apache.fory.integration.kotlin.json.corpus" +CORPUS_RULE_MODELS = ( + "PlatformAccount", + "PlatformAnnotated", + "PlatformBox", + "PlatformBuiltins", + "PlatformCase", + "PlatformCaseManifest", + "PlatformCircle", + "PlatformCodecSlots", + "PlatformEnvelope", + "PlatformGenericKey", + "PlatformKotlinProfile", + "PlatformMarker", + "PlatformNode", + "PlatformNullableText", + "PlatformNulls", + "PlatformOrdinary", + "PlatformPositiveId", + "PlatformPropertyNumber", + "PlatformRoot", + "PlatformShapeMarker", + "PlatformUnitHolder", + "PlatformUnlistedShape", + "PlatformValueHolder", + "PlatformWrappedData", + "PlatformWrappedMarker", + "PlatformWrappedNumber", + "PlatformInvalidPropertyShape", + "PlatformPropertyShape", + "PlatformWrappedShape", +) +CORPUS_MIXIN_TARGETS = { + "PlatformCodecSlotsMixin": "PlatformCodecSlots", + "PlatformJavaProfileMixin": "PlatformJavaProfile", + "PlatformKotlinProfileMixin": "PlatformKotlinProfile", +} +CORPUS_CODEC_TYPES = ( + "PlatformContentStringCodec", + "PlatformElementStringCodec", + "PlatformIntKeyCodec", + "PlatformMapValueStringCodec", + "PlatformWholeStringCodec", +) +PLATFORM_BUILTINS_PARAMETERS = ( + "kotlin.Pair,kotlin.Triple,byte,short,int,long,byte[],short[],int[],long[]," + "java.util.Map,java.util.Map,java.util.Map,java.util.Map,long,long,long,long,long," + "kotlin.time.Instant,kotlin.time.Instant,kotlin.time.Instant,kotlin.time.Instant," + "kotlin.uuid.Uuid,kotlin.Unit,kotlin.Unit,java.lang.Void,kotlin.ranges.IntRange," + "kotlin.ranges.UIntRange,kotlin.ranges.IntProgression,kotlin.ranges.ULongProgression," + "kotlin.time.TimedValue" +) + + def java_major_version(): """Return the active Java runtime's major version.""" version_output = subprocess.check_output( @@ -35,29 +97,255 @@ def java_major_version(): return int(version.split(".")[0]) -def run(): - """Run Kotlin CI tasks.""" - logging.info("Executing fory kotlin tests") - common.cd_project_subdir("kotlin") +def install_java_json(include_jpms=False): + """Install the Java artifacts consumed by Kotlin JSON modules.""" + modules = "fory-json,fory-annotation-processor" + test_option = "-Dmaven.test.skip=true" + if include_jpms: + modules = ( + "fory-json,fory-format,fory-test-core,fory-testsuite," + "fory-annotation-processor" + ) + # The JDK25 multi-release verifier is test-owned and must still compile. + test_option = "-DskipTests" + common.cd_project_subdir("java") + common.exec_cmd( + "mvn -T16 --batch-mode --no-transfer-progress " + f"-pl {modules} -am install {test_option} " + "-Dmaven.javadoc.skip=true -Dmaven.source.skip=true" + ) - # The KSP Maven plugin discovers processors from JAR artifacts. Build and install the - # processor first so the generated-test module does not see the reactor classes directory as - # its processor artifact. + +def install_artifacts(include_corpus=True, modules=PRODUCTION_MODULES): + """Install Kotlin production artifacts and the shared JSON corpus.""" + common.cd_project_subdir("kotlin") common.exec_cmd( "mvn -T16 --batch-mode --no-transfer-progress " - "-pl fory-kotlin,fory-kotlin-ksp -am -DskipTests install" + f"-pl {modules} -am clean install -DskipTests " + "-Ddokka.skip=true -Dmaven.source.skip=true" ) - if java_major_version() >= 17: + if include_corpus: + common.cd_project_subdir("integration_tests/kotlin_json_corpus") + common.exec_cmd( + "mvn -T16 --batch-mode --no-transfer-progress clean install " + "-DskipTests -Ddokka.skip=true -Dmaven.source.skip=true" + ) + verify_corpus_artifact() + + +def verify_corpus_artifact(): + """Verify exact KSP consumer-rule packaging in the shared platform corpus JAR.""" + module_dir = Path( + common.PROJECT_ROOT_DIR, "integration_tests", "kotlin_json_corpus" + ) + version = _kotlin_version() + jar_path = module_dir / "target" / f"kotlin-json-corpus-{version}.jar" + if not jar_path.is_file(): + raise FileNotFoundError(f"Missing Kotlin JSON corpus artifact: {jar_path}") + + expected_rules = { + f"META-INF/proguard/fory-json-{CORPUS_PACKAGE}.{model}.pro" + for model in CORPUS_RULE_MODELS + } + expected_rules.update( + f"META-INF/proguard/fory-json-mixin-{CORPUS_PACKAGE}.{model}.pro" + for model in CORPUS_MIXIN_TARGETS + ) + with zipfile.ZipFile(jar_path) as jar: + names = set(jar.namelist()) + manifest = jar.read("META-INF/MANIFEST.MF").decode("utf-8") + expected_module = f"Automatic-Module-Name: {CORPUS_MODULE_NAME}" + if expected_module not in manifest: + raise RuntimeError(f"{jar_path} is missing {expected_module}") + case_manifest = f"{CORPUS_PACKAGE.replace('.', '/')}/cases.json" + if case_manifest not in names: + raise RuntimeError(f"{jar_path} is missing {case_manifest}") + missing = sorted(expected_rules - names) + if missing: + raise RuntimeError(f"{jar_path} is missing consumer rules: {missing}") + actual_rules = { + name + for name in names + if name.startswith("META-INF/proguard/fory-json-") and name.endswith(".pro") + } + if actual_rules != expected_rules: + raise RuntimeError( + f"{jar_path} has unexpected consumer rules: " + f"{sorted(actual_rules ^ expected_rules)}" + ) + for model in CORPUS_RULE_MODELS: + name = f"META-INF/proguard/fory-json-{CORPUS_PACKAGE}.{model}.pro" + _verify_rule(jar, name, model) + _verify_builtin_creator_rule(jar) + for mixin, target in CORPUS_MIXIN_TARGETS.items(): + name = f"META-INF/proguard/fory-json-mixin-{CORPUS_PACKAGE}.{mixin}.pro" + _verify_rule(jar, name, mixin) + _verify_rule(jar, name, target) + for name in ( + f"META-INF/proguard/fory-json-{CORPUS_PACKAGE}.PlatformCodecSlots.pro", + f"META-INF/proguard/fory-json-mixin-{CORPUS_PACKAGE}.PlatformCodecSlotsMixin.pro", + ): + for codec in CORPUS_CODEC_TYPES: + _verify_codec_rule(jar, name, codec) + + +def _verify_rule(jar, name, model): + lines = jar.read(name).decode("utf-8").splitlines() + exact_keep = f"-keep,allowoptimization class {CORPUS_PACKAGE}.{model}" + if exact_keep not in lines: + raise RuntimeError( + f"{jar.filename}!/{name} does not retain exact model {model}" + ) + + +def _verify_codec_rule(jar, name, codec): + text = jar.read(name).decode("utf-8") + lines = text.splitlines() + codec_type = f"{CORPUS_PACKAGE}.{codec}" + class_rule = f"-keep,allowoptimization,allowobfuscation class {codec_type}" + member_rule = f"-keepclassmembers class {codec_type} {{\n public ();\n}}" + if class_rule not in lines or member_rule not in text: + raise RuntimeError( + f"{jar.filename}!/{name} does not retain the public constructor of {codec}" + ) + + +def _verify_builtin_creator_rule(jar): + name = f"META-INF/proguard/fory-json-{CORPUS_PACKAGE}.PlatformBuiltins.pro" + text = jar.read(name).decode("utf-8") + owner = f"{CORPUS_PACKAGE}.PlatformBuiltins" + header = f"-keepclassmembers class {owner} {{\n" + if text.count(header) != 1: + raise RuntimeError(f"{jar.filename}!/{name} must have one exact member block") + start = text.index(header) + len(header) + end = text.index("}\n", start) + block = text[start:end] + constructors = ( + f" ({PLATFORM_BUILTINS_PARAMETERS});", + " (" + f"{PLATFORM_BUILTINS_PARAMETERS},kotlin.jvm.internal.DefaultConstructorMarker);", + ) + if any(block.count(constructor) != 1 for constructor in constructors): + raise RuntimeError( + f"{jar.filename}!/{name} does not retain both exact Kotlin constructors" + ) + + +def _kotlin_version(): + pom = Path(common.PROJECT_ROOT_DIR, "kotlin", "pom.xml") + root = ET.parse(pom).getroot() + namespace = {"m": "http://maven.apache.org/POM/4.0.0"} + version = root.findtext("m:version", namespaces=namespace) + if not version: + raise ValueError(f"Cannot find Kotlin parent version in {pom}") + return version + + +def run_tests(): + """Run the Kotlin JVM matrix for the active JDK.""" + logging.info("Executing fory kotlin tests") + os.environ.setdefault("ENABLE_FORY_DEBUG_OUTPUT", "1") + major = java_major_version() + install_java_json(include_jpms=major == 25) + modules = PRODUCTION_MODULES if major >= 17 else LOW_JDK_MODULES + install_artifacts(include_corpus=major >= 17, modules=modules) + common.cd_project_subdir("kotlin") + if major >= 17: common.exec_cmd( "mvn -T16 --batch-mode --no-transfer-progress test -DfailIfNoTests=false" ) + common.exec_cmd("mvn -T16 --batch-mode --no-transfer-progress spotless:check") + common.cd_project_subdir("integration_tests/kotlin_json_corpus") + common.exec_cmd( + "mvn -T16 --batch-mode --no-transfer-progress clean test " + "-DfailIfNoTests=false" + ) else: logging.info( - "Skipping fory-kotlin-tests on JDK < 17 because ksp-maven-plugin requires Java 17+" + "Skipping KSP generation tests on JDK < 17 because ksp-maven-plugin requires Java 17+" + ) + common.exec_cmd( + "mvn -T16 --batch-mode --no-transfer-progress " + f"-pl {LOW_JDK_MODULES} -am test -DfailIfNoTests=false" ) + if major == 25: + common.cd_project_subdir("kotlin") common.exec_cmd( "mvn -T16 --batch-mode --no-transfer-progress " - "-pl fory-kotlin,fory-kotlin-ksp -am test -DfailIfNoTests=false" + f"-pl {PRODUCTION_MODULES} -am package -DskipTests " + "-Dgpg.skip=true -Papache-release" ) + common.cd_project_subdir("") + common.exec_cmd("python ci/release.py verify_kotlin_artifacts") + common.cd_project_subdir("integration_tests/jpms_tests") + common.exec_cmd("mvn -T10 --batch-mode --no-transfer-progress clean test") logging.info("Executing fory kotlin tests succeeds") + + +def run_native_json(): + """Build and execute the dedicated Kotlin JSON Native Image fixture.""" + os.environ.setdefault("ENABLE_FORY_DEBUG_OUTPUT", "1") + install_java_json() + install_artifacts(include_corpus=True) + common.cd_project_subdir("integration_tests/graalvm_kotlin_tests") + common.exec_cmd( + "mvn --batch-mode --no-transfer-progress -DskipTests=true -Pnative clean package" + ) + common.exec_cmd("./target/main") + expect_native_failure( + "org.apache.fory.graalvm.kotlin.CodegenDisabledMain", + "codegen-disabled ForyJson", + ) + expect_native_failure( + "org.apache.fory.graalvm.kotlin.InvalidPropertyMain", + "Inline JSON subtype requires the default object representation", + ) + + +def expect_native_failure(main_class, expected_text): + """Require one invalid provider image to fail during hosted analysis.""" + project_dir = Path( + common.PROJECT_ROOT_DIR, "integration_tests", "graalvm_kotlin_tests" + ) + command = [ + "mvn", + "--batch-mode", + "--no-transfer-progress", + "-DskipTests=true", + "-Pnative", + f"-DmainClass={main_class}", + "clean", + "package", + ] + logging.info("Expecting Native Image analysis failure for %s", main_class) + result = subprocess.run( + command, + cwd=project_dir, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + if result.returncode == 0: + raise RuntimeError(f"Native Image unexpectedly accepted {main_class}") + if expected_text not in result.stdout: + raise RuntimeError( + f"Native Image failed for the wrong reason ({main_class}):\n" + + result.stdout[-12000:] + ) + + +def run(task="tests"): + """Run the selected Kotlin CI task.""" + if task == "tests": + run_tests() + elif task == "install": + install_java_json() + install_artifacts(include_corpus=True) + elif task == "install-kotlin": + install_artifacts(include_corpus=True) + elif task == "native-json": + run_native_json() + else: + raise ValueError(f"Unsupported Kotlin CI task: {task}") diff --git a/ci/test_release.py b/ci/test_release.py index e34809152e..81fe98182f 100644 --- a/ci/test_release.py +++ b/ci/test_release.py @@ -83,6 +83,143 @@ def test_publishes_each_module(self, run_release_cmd): run_release_cmd.call_args_list, ) + @mock.patch.object(release, "_run_release_cmd") + def test_snapshot_has_no_signing_or_staging(self, run_release_cmd): + release._publish_java("snapshot") + release._publish_kotlin("snapshot") + release._publish_scala("snapshot") + + commands = [call.args[0] for call in run_release_cmd.call_args_list] + self.assertIn("-Dgpg.skip=true", commands[0]) + self.assertIn("-Dgpg.skip=true", commands[1]) + self.assertIn("-Psnapshot-publication", commands[0]) + self.assertIn("-Psnapshot-publication", commands[1]) + self.assertFalse(any("publishSigned" in command for command in commands)) + self.assertFalse(any("sonatypePrepare" in command for command in commands)) + self.assertFalse(any("sonatypeBundleUpload" in command for command in commands)) + + +class JvmPublicationTest(unittest.TestCase): + def test_language_order(self): + self.assertEqual( + ["java", "scala"], release._jvm_release_langs("scala,java,scala") + ) + + @mock.patch.dict(release.os.environ, {}, clear=True) + def test_missing_credentials(self): + with self.assertRaisesRegex(RuntimeError, "NEXUS_USERNAME, NEXUS_PASSWORD"): + release._require_publication_authority("snapshot") + + @mock.patch.object(release, "_has_gpg_secret_key", return_value=False) + @mock.patch.dict( + release.os.environ, + {"NEXUS_USERNAME": "user", "NEXUS_PASSWORD": "password"}, + clear=True, + ) + def test_release_signing_authority(self, _has_gpg_secret_key): + with self.assertRaisesRegex(RuntimeError, "GPG secret key"): + release._require_publication_authority("release") + + @mock.patch.dict( + release.os.environ, + { + "NEXUS_USERNAME": "nexus-user", + "NEXUS_PASSWORD": "nexus-password", + "SONATYPE_USERNAME": "stale-user", + "SONATYPE_PASSWORD": "stale-password", + }, + clear=True, + ) + def test_snapshot_owns_scala_credentials(self): + release._require_publication_authority("snapshot") + + self.assertEqual("nexus-user", release.os.environ["SONATYPE_USERNAME"]) + self.assertEqual("nexus-password", release.os.environ["SONATYPE_PASSWORD"]) + + @mock.patch.object(release, "verify_kotlin_artifacts") + @mock.patch.object(release, "_verify_fory_core_mr_jar") + @mock.patch.object(release, "_publish_scala") + @mock.patch.object(release, "_publish_kotlin") + @mock.patch.object(release, "_publish_java") + @mock.patch.object(release, "_ensure_openjdk25") + @mock.patch.object(release, "_require_publication_authority") + def test_snapshot_reactor_order( + self, + require_authority, + ensure_openjdk25, + publish_java, + publish_kotlin, + publish_scala, + verify_core, + verify_kotlin, + ): + release.publish_jvm(mode="snapshot") + + require_authority.assert_called_once_with("snapshot") + ensure_openjdk25.assert_called_once_with() + publish_java.assert_called_once_with("snapshot") + publish_kotlin.assert_called_once_with("snapshot") + publish_scala.assert_called_once_with("snapshot") + verify_kotlin.assert_called_once_with() + verify_core.assert_called_once_with() + + +class KotlinVersionTest(unittest.TestCase): + def test_android_dependencies(self): + lines = [ + "implementation 'org.apache.fory:fory-json-kotlin:1.7.0-SNAPSHOT'\n", + "ksp 'org.apache.fory:fory-json-kotlin-ksp:1.7.0-SNAPSHOT'\n", + "implementation 'org.apache.fory:kotlin-json-corpus:1.7.0-SNAPSHOT'\n", + "`org.apache.fory:fory-json-kotlin:1.7.0-SNAPSHOT`\n", + "`org.apache.fory:fory-json-kotlin-ksp:1.7.0-SNAPSHOT`\n", + ] + + self.assertEqual( + [ + "implementation 'org.apache.fory:fory-json-kotlin:1.8.0-SNAPSHOT'\n", + "ksp 'org.apache.fory:fory-json-kotlin-ksp:1.8.0-SNAPSHOT'\n", + "implementation 'org.apache.fory:kotlin-json-corpus:1.8.0-SNAPSHOT'\n", + "`org.apache.fory:fory-json-kotlin:1.8.0-SNAPSHOT`\n", + "`org.apache.fory:fory-json-kotlin-ksp:1.8.0-SNAPSHOT`\n", + ], + release._update_android_kotlin_version(lines, "1.8.0-SNAPSHOT"), + ) + + def test_benchmark_version(self): + lines = ["kotlinVersion=2.3.20\n", "foryVersion=1.7.0-SNAPSHOT\n"] + + self.assertEqual( + ["kotlinVersion=2.3.20\n", "foryVersion=1.8.0-SNAPSHOT\n"], + release._update_kotlin_benchmark_version(lines, "1.8.0-SNAPSHOT"), + ) + + @mock.patch.object(release, "_bump_version") + def test_kotlin_version_paths(self, bump_version): + release.bump_kotlin_version("1.8.0-SNAPSHOT") + + paths = {(call.args[0], call.args[1]) for call in bump_version.call_args_list} + expected_paths = { + ("kotlin", "pom.xml"), + ("kotlin/fory-kotlin", "pom.xml"), + ("kotlin/fory-kotlin-ksp", "pom.xml"), + ("kotlin/fory-json-kotlin", "pom.xml"), + ("kotlin/fory-json-kotlin-ksp", "pom.xml"), + ("kotlin/fory-kotlin-tests", "pom.xml"), + ("integration_tests/kotlin_json_corpus", "pom.xml"), + ("integration_tests/graalvm_kotlin_tests", "pom.xml"), + ("integration_tests/grpc_tests/kotlin", "pom.xml"), + ("integration_tests/idl_tests/kotlin", "pom.xml"), + ("integration_tests/android_tests", "build.gradle"), + ("integration_tests/android_tests", "README.md"), + ("benchmarks/kotlin", "gradle.properties"), + ("kotlin/fory-json-kotlin", "README.md"), + ("kotlin/fory-json-kotlin-ksp", "README.md"), + ("docs/json", "kotlin.md"), + } + self.assertEqual(expected_paths, paths) + self.assertEqual(len(expected_paths), len(bump_version.call_args_list)) + self.assertIn("kotlin/README.md", release.RELEASE_DOC_ROOTS) + if __name__ == "__main__": unittest.main() diff --git a/docs/benchmarks/index.md b/docs/benchmarks/index.md index 0b38f7ef8d..fd940fac3e 100644 --- a/docs/benchmarks/index.md +++ b/docs/benchmarks/index.md @@ -49,6 +49,14 @@ The Java benchmark section compares Fory against popular Java serialization fram For additional benchmark notes, raw data, and the complete Java benchmark README, see [Java Benchmarks](object-serialization/native/java/README.md). +## Kotlin JSON Benchmark + +The Kotlin JSON harness compares Fory JSON Kotlin, kotlinx.serialization, Moshi, and Jackson Kotlin +with one immutable model and 16 isolated String/UTF-8 operations. The +[Kotlin JSON report](json/kotlin/README.md) is intentionally marked pending until a complete +measured run is published; no placeholder performance result is inferred from another language or +project. + ## Python Benchmark Fory Python demonstrates strong performance compared to `pickle` and Protobuf across object and list workloads. diff --git a/docs/benchmarks/json/kotlin/README.md b/docs/benchmarks/json/kotlin/README.md new file mode 100644 index 0000000000..4e3349178f --- /dev/null +++ b/docs/benchmarks/json/kotlin/README.md @@ -0,0 +1,28 @@ +# Kotlin JSON Benchmark Report + +Measured results are pending. This page will be replaced only by a complete run of the repository +Kotlin JSON benchmark harness; no performance number or chart is inferred from the Java, Scala, or +historical external benchmark projects. + +The source-aligned harness is in +[`benchmarks/kotlin`](https://github.com/apache/fory/tree/main/benchmarks/kotlin). +It compares Fory JSON Kotlin, kotlinx.serialization, Moshi, and Jackson Kotlin with: + +- one immutable `MediaContent` model with no public zero-argument constructor; +- the Eishay fixture whose SHA-256 is + `8faba2f57ab397f319aced5cf1e8411a76785557d4c7d1703ec9d540354310a1`; +- String serialization, UTF-8 byte serialization, String deserialization, and UTF-8 byte + deserialization for each library; +- one library and one exact operation per JVM process; +- retained declared-type serializers, adapters, readers, and writers prepared outside timing; +- fixture decode, own-output round-trip, and all-output JSON-tree equivalence checks before timing; + and +- adjacent within-round Fory/comparator AB/BA ratios summarized by median and median absolute + deviation. + +The eventual measured report will record the source and Fory artifact commits, artifact, +dependency-set, and executed JMH JAR hashes, date, hardware, operating system, JDK, Kotlin, JMH, +Moshi codegen KSP plugin, and library versions, fork/thread/warmup/measurement settings, byte +materialization costs, exclusions, per-launch raw samples, paired aggregates, and four +operation-specific charts. Excluded or failed launches remain in the raw data with their reason and +return code. diff --git a/docs/index.md b/docs/index.md index 49ca9e7074..e15eb44972 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,13 +31,13 @@ rows, standard JSON, schema-driven models, and Fory-backed gRPC services. ## Capabilities -| Capability | Use it for | Documentation | -| --------------------- | ---------------------------------------------------- | ----------------------------------------------------- | -| Object Serialization | Reconstruct xlang or language-native object graphs | [Object Serialization](object-serialization/index.md) | -| Row Format | Random and partial access to trusted analytical data | [Row Format](row-format/index.md) | -| Fory JSON | High-throughput standard JSON mapped to Java objects | [Fory JSON](json/index.md) | -| Fory IDL and compiler | Generate language-native models from a shared schema | [Compiler](compiler/index.md) | -| Fory gRPC | Generate gRPC companions that marshal Fory models | [Fory gRPC](grpc/index.md) | +| Capability | Use it for | Documentation | +| --------------------- | ----------------------------------------------------- | ----------------------------------------------------- | +| Object Serialization | Reconstruct xlang or language-native object graphs | [Object Serialization](object-serialization/index.md) | +| Row Format | Random and partial access to trusted analytical data | [Row Format](row-format/index.md) | +| Fory JSON | High-throughput standard JSON for Java, Kotlin, Scala | [Fory JSON](json/index.md) | +| Fory IDL and compiler | Generate language-native models from a shared schema | [Compiler](compiler/index.md) | +| Fory gRPC | Generate gRPC companions that marshal Fory models | [Fory gRPC](grpc/index.md) | ## Language guides @@ -50,6 +50,9 @@ Binary Object Serialization provides multi-page language guides for [Dart](object-serialization/dart/index.md), [Scala](object-serialization/scala/index.md), and [Kotlin](object-serialization/kotlin/index.md). +Fory JSON has language guides for [Kotlin](json/kotlin.md) and [Scala](json/scala.md) in addition +to its Java API documentation. + ## Reference and development - Wire and type-system details remain in the separate [Specification](specification/xlang_serialization_spec.md) surface. diff --git a/docs/introduction/choose-a-format.md b/docs/introduction/choose-a-format.md index f2fb2683c6..44bd117613 100644 --- a/docs/introduction/choose-a-format.md +++ b/docs/introduction/choose-a-format.md @@ -19,12 +19,12 @@ license: | limitations under the License. --- -| Format or mode | Data model | Use it when | Interoperability | Start here | -| -------------- | ------------------------------------ | ------------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------- | -| Xlang binary | Portable object graphs | Data crosses language boundaries | Shared wire format across supported Fory implementations | [Cross-language guide](../object-serialization/xlang.md) | -| Native binary | Language-native object graphs | Producer and consumer use one Fory implementation family | One Fory implementation family only | [Object Serialization](../object-serialization/index.md) | -| Row Format | Random-access binary rows | You need random field access or analytics-style partial reads | Standard Row is shared by Java, Python, C++, and Rust; Compact is Java-only | [Row Format guide](../row-format/index.md) | -| Fory JSON | Standard JSON mapped to Java objects | Java applications need high-performance standard JSON | Standard JSON text | [Fory JSON guide](../json/index.md) | +| Format or mode | Data model | Use it when | Interoperability | Start here | +| -------------- | ----------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------- | +| Xlang binary | Portable object graphs | Data crosses language boundaries | Shared wire format across supported Fory implementations | [Cross-language guide](../object-serialization/xlang.md) | +| Native binary | Language-native object graphs | Producer and consumer use one Fory implementation family | One Fory implementation family only | [Object Serialization](../object-serialization/index.md) | +| Row Format | Random-access binary rows | You need random field access or analytics-style partial reads | Standard Row is shared by Java, Python, C++, and Rust; Compact is Java-only | [Row Format guide](../row-format/index.md) | +| Fory JSON | Standard JSON mapped to JVM objects | Java, Kotlin, or Scala applications need standard JSON | Standard JSON text | [Fory JSON guide](../json/index.md) | Xlang and native are sibling modes of Object Serialization. Use them when the receiver needs to reconstruct an object graph. Row Format and Fory JSON are separate formats, not additional Object @@ -61,7 +61,7 @@ coordination across every reader and writer. Choose xlang or native mode when you need to reconstruct object graphs. Choose Row Format for trusted analytical data that benefits from random field access. Choose Fory JSON for standard JSON -in Java applications. Use Fory IDL and the compiler when multiple teams need one schema-first +in Java, Kotlin, or Scala applications. Use Fory IDL and the compiler when multiple teams need one schema-first contract; it generates models that use the relevant Fory capability. ## Related capabilities diff --git a/docs/introduction/support-matrix.md b/docs/introduction/support-matrix.md index 953619cd93..16e8151445 100644 --- a/docs/introduction/support-matrix.md +++ b/docs/introduction/support-matrix.md @@ -28,7 +28,7 @@ does not imply support for every Fory capability. | Native object serialization | Java, Python, C++, Go, Rust, Scala, Kotlin | One Fory implementation family only | | Standard Row Format | Java, Python, C++, Rust | Shared Standard Row layout | | Compact Row Format | Java | Java-only compact layout | -| Fory JSON | Java | Standard JSON text | +| Fory JSON | Java, Kotlin, Scala | Standard JSON text | | Fory compiler output | Java, Python, C++, Go, Rust, JavaScript/TypeScript, C#, Swift, Dart, Scala, Kotlin | Generated models use supported Fory APIs | | Fory gRPC | Java, Python, C++, Go, Rust, JavaScript/TypeScript, C#, Dart, Scala, Kotlin | Peers must use matching generated Fory service contracts | diff --git a/docs/json/android.md b/docs/json/android.md index 33bea7ad0e..e0277f8840 100644 --- a/docs/json/android.md +++ b/docs/json/android.md @@ -1,6 +1,6 @@ --- title: Android -sidebar_position: 8 +sidebar_position: 9 id: android license: | Licensed to the Apache Software Foundation (ASF) under one or more @@ -23,6 +23,9 @@ Fory JSON supports ordinary classes on Android API level 26 and later through th `fory-json` artifact. Runtime JSON code generation and asynchronous compilation are disabled automatically, so `ForyJson.builder().build()` uses the interpreted object mapper. +Kotlin applications use the ordinary `fory-json-kotlin` runtime, which reads Kotlin/JVM metadata +directly. KSP is needed only when R8 or ProGuard can rename or remove Kotlin model members. + ## Installation and Codec Model Add Fory JSON to the application: @@ -33,6 +36,36 @@ dependencies { } ``` +For Kotlin, apply Kotlin 2.3.20 and add the Kotlin JSON runtime: + +```kotlin +plugins { + kotlin("android") version "2.3.20" +} + +dependencies { + implementation("org.apache.fory:fory-json-kotlin:${foryVersion}") +} +``` + +If the application enables R8 or ProGuard, also apply KSP 2.3.8 and add the retention-rule +processor: + +```kotlin +plugins { + id("com.google.devtools.ksp") version "2.3.8" +} + +dependencies { + ksp("org.apache.fory:fory-json-kotlin-ksp:${foryVersion}") +} +``` + +Create the runtime with `ForyJsonKotlin.builder()`. For a minified build, annotate every required +Kotlin source model with `@JsonType`. For a third-party Kotlin target, declare a source-owned exact +`@JsonMixin` instead. KSP emits exact R8 and ProGuard retention resources; it does not generate +codecs or construction operations. Ensure those resources are packaged in the final application. + ## Custom Codecs `@JsonCodec` has the same declaration behavior on Android and the JVM. It supports complete values, @@ -60,7 +93,7 @@ Child codecs act on one direct level only. For example, `elementCodec` on `Money `Money[]`, and `elementCodec` on `AtomicReferenceArray` handles each `Money`. Use a complete `value` codec when deeper custom behavior is required. -## Generated Access and R8 Rules +## Java Generated Access and R8 Rules Add the annotation processor and mark application object models with `JsonType` to generate direct field, getter, setter, Record constructor, `JsonCreator`, and `JsonValidator` operations together @@ -108,19 +141,19 @@ ForyJson json = ForyJson.builder().registerMixin(ThirdPartyInvoiceMixin.class).build(); ``` -Compile every non-empty Mixin source with `fory-annotation-processor`. The processor emits exact -R8 rules and any pair-specific target operations that the built `ForyJson` instance can use. Registered codecs, -effective type codecs, and built-in mappings keep their normal codec-selection precedence. An empty Mixin -produces no generated output. +Compile a non-empty Java-source Mixin for a Java target with `fory-annotation-processor`. The +processor emits exact R8 rules and any pair-specific target operations that the built `ForyJson` +instance can use. Registered codecs, effective type codecs, and built-in mappings keep their normal +codec-selection precedence. An empty Mixin produces no generated output. A Mixin may place `JsonValidator` on a public abstract zero-argument `void` method that exactly matches a public target method. The generated pair calls that target method directly. The target does not need `JsonType` solely for a Mixin validator. The target does not need `JsonType` merely because it has a Mixin. `JsonMixin` is itself the -processor entry point for the pair. If a target also uses `JsonType`, the built `ForyJson` instance selects the -pair-specific companion for a non-empty registered Mixin instead of combining the overlay with the -target's direct companion. +processor entry point for the pair. If a target also uses `JsonType`, the built `ForyJson` instance +selects the pair-specific generated Java operations for a non-empty registered Mixin instead of +combining the overlay with the target's directly generated operations. Only one source is enabled for an exact target in one built `ForyJson` instance. A later registration for that target replaces an earlier registration on the builder, and `build()` snapshots the selected @@ -129,6 +162,9 @@ only the last registered source. Use the processor-generated R8 rules for non-empty Mixins instead of broad package keep rules. +If either the Mixin source or its exact target is Kotlin, process the source request with +`fory-json-kotlin-ksp` instead. KSP emits only the exact retention rules for the target and Mixin. + ## Reflection-Based Models Ordinary non-Record classes that omit `JsonType` can supply equivalent exact rules themselves unless @@ -154,13 +190,21 @@ validator: The same exact-rule approach supports every `JsonCodec` member; it is not limited to complete-value codecs. `JsonType` is not required for codec selection on an ordinary class. -For `@JsonType` models, generated operations and R8 rules also cover effective `JsonValidator` -methods, `JsonValue` fields and effective methods, fixed `JsonRawValue` and `JsonBase64` fields and -getters, `JsonFormat` date/time fields, their runtime annotations, and the Base64 codec constructor. -Without `@JsonType`, the value, raw, Base64, format, and codec annotations still work through -reflection, but a release-minified application must keep the exact annotated members, annotation -attributes, and codec constructor itself. A `JsonValue` method may use a non-JavaBean name, so its -manual rule must name that method explicitly. `JsonFormat` keeps the same direct-field and +This reflection-based section applies to Java models. Kotlin immutable models use validated +Kotlin/JVM metadata on both the JVM and Android. For a minified Android build, use the Kotlin KSP +processor to emit exact rules instead of writing broad package keep rules. + +For Java `@JsonType` models, generated operations and R8 rules also cover effective +`JsonValidator` methods, `JsonValue` fields and effective methods, fixed `JsonRawValue` and +`JsonBase64` fields and getters, `JsonFormat` date/time fields, their runtime annotations, and the +Base64 codec constructor. Without `@JsonType`, those annotations still work through reflection, +but a release-minified application must keep the exact annotated members, annotation attributes, +and codec constructor itself. A `JsonValue` method may use a non-JavaBean name, so its manual rule +must name that method explicitly. + +Kotlin models use the same effective annotations through metadata. The KSP retention rules preserve +the corresponding constructors, accessors, fields, annotations, generic signatures, and compiler +default operations in a minified build. `JsonFormat` keeps the same direct-field and one-wrapper-level behavior as on the JVM, including `timezone` for `Instant`, `ZonedDateTime`, and `OffsetDateTime`. @@ -170,15 +214,16 @@ classes follow the normal creator rules instead. Retain every field and method u or use an application codec when a model cannot satisfy those requirements. `JsonUnwrapped` supports mutable classes, creator-backed classes, and Records through their normal property and construction paths. When the containing model and its unwrapped children use `JsonType`, their -generated companions supply those operations. +generated Java operations or Kotlin metadata supply those operations. A minified Kotlin build must +also package the exact KSP retention rules for every Kotlin model in the path. ## Records Android-desugared Records require processor-generated operations from either a direct `@JsonType` declaration or a compiled exact `@JsonMixin` pair. Manual R8 rules alone cannot reconstruct Record component order because Android does not provide the Java Record reflection APIs. This also applies -to a Record whose complete representation is a `JsonValue` String: the generated companion -identifies the propagated component accessor and calls an annotated one-String canonical -constructor directly. Generated child codecs act on one level exactly as they do on the JVM. Every -Record in a `JsonUnwrapped` path needs its own direct `JsonType` declaration or compiled exact -`JsonMixin` pair. Use a complete value codec for deeper nested behavior. +to a Record whose complete representation is a `JsonValue` String: the generated Java operations +identify the propagated component accessor and call an annotated one-String canonical constructor +directly. Generated child codecs act on one level exactly as they do on the JVM. Every Record in a +`JsonUnwrapped` path needs its own direct `JsonType` declaration or compiled exact `JsonMixin` pair. +Use a complete value codec for deeper nested behavior. diff --git a/docs/json/annotations.md b/docs/json/annotations.md index 9b91c3d898..40bb7fb910 100644 --- a/docs/json/annotations.md +++ b/docs/json/annotations.md @@ -23,21 +23,51 @@ Fory JSON provides these mapping and validation annotations in `org.apache.fory.json.annotation`: `JsonAnyGetter`, `JsonAnyProperty`, `JsonAnySetter`, `JsonBase64`, `JsonCodec`, `JsonCreator`, `JsonFormat`, `JsonIgnore`, `JsonProperty`, `JsonPropertyOrder`, `JsonRawValue`, `JsonSubTypes`, `JsonUnwrapped`, -`JsonValidator`, and `JsonValue`. `JsonType` is a separate build-time generation marker. They are +`JsonValidator`, and `JsonValue`. `JsonType` is a separate build-time model marker. They are Fory JSON APIs, not Jackson, Gson, or Fory binary-protocol compatibility annotations. -`JsonType` asks the annotation processor to generate direct property and creator operations plus -exact retention rules on the JVM and Android. It is not inherited, so annotate each eligible -concrete model that needs a generated companion on those platforms. A directly annotated -`JsonValue` Record also receives a companion for its value accessor and canonical constructor. -Ordinary unannotated classes may still use reflection; on Android they need application-authored -exact R8 rules. Android-desugared Records require processor-generated operations from either a -direct `JsonType` declaration or a compiled exact `JsonMixin` pair. Outside Native Image, a -directly annotated model that uses the default object codec fails during codec creation if its -generated companion is missing. GraalVM Native Image discovers `JsonType` directly and does not use -annotation-processor output. -See the [GraalVM guide](graalvm.md) and -[Android guide](android.md) for the platform workflows. +`JsonType` is not inherited, so mark each eligible concrete model that must participate in a +platform build workflow. For Java source, the Fory annotation processor generates direct property +and creator operations plus exact retention rules on the JVM and Android. A directly annotated +`JsonValue` Record also receives generated value-access and canonical-constructor operations. +Ordinary unannotated Java classes may still use reflection; on Android they need +application-authored exact R8 rules. Android-desugared Records require processor-generated +operations from either a direct `JsonType` declaration or a compiled exact `JsonMixin` pair. +Outside Native Image, a directly annotated Java model that uses the default object codec fails +during codec creation if its generated Java operations are missing. + +Kotlin/JVM models are mapped from validated Kotlin metadata and do not require generated +construction operations. In Android builds that use R8 or ProGuard, Kotlin KSP emits exact +retention rules for Kotlin `@JsonType` models and source-owned Kotlin Mixins; it does not generate +codecs or construction operations. GraalVM Native Image discovers reachable Java and Kotlin +`JsonType` declarations directly, and provider-selected configurations generate codecs while the +image is built. See the [GraalVM guide](graalvm.md) and [Android guide](android.md) for the platform +workflows. + +## Kotlin use-site targets + +Kotlin annotations merge into the same logical property as their Java field, accessor, or selected +constructor parameter. Use explicit targets so behavior does not depend on Kotlin's default-target +policy: + +| Kotlin site | Logical declaration | +| ------------ | ---------------------------------------------------------- | +| `@field:` | backing field | +| `@get:` | getter | +| `@set:` | setter | +| `@param:` | selected constructor parameter | +| `@setparam:` | setter value parameter for supported parameter annotations | + +`@property:` is unsupported because Fory JSON annotations do not target Kotlin-only property +metadata. `@setparam:JsonProperty` is rejected because setter-parameter naming is not a JSON +property-name contract. `@setparam:JsonIgnore`, `@setparam:JsonCodec`, and +`@setparam:JsonUnwrapped` apply to the exact one-argument setter property. An effective +`@set:JsonCodec` is also supported directly. + +`JsonProperty` members merge individually when their explicit values agree; conflicting names, +indexes, or inclusion policies fail. `JsonIgnore` read/write directions merge monotonically, and +repeated `JsonCodec` declarations must be identical. Mixin replacement or removal happens before +this merge. See [Kotlin](kotlin.md#annotations-and-use-site-targets) for an idiomatic example. ## Mixins @@ -96,9 +126,10 @@ A `JsonCodec` supplied by a Mixin is the target's effective annotation. An exact `registerCodec` registration still wins, while the effective type annotation wins over a built-in mapping. -On Android, compile non-empty Mixins with the Fory annotation processor so required generated -operations and platform configuration are available. GraalVM Native Image discovers reachable -Mixins directly. See the platform guides linked above. +On Android, compile a Java-source Mixin for a Java target with the Fory annotation processor. If +either the Mixin source or its exact target is Kotlin, apply the Kotlin KSP processor instead so the +pair's exact retention rules are packaged. GraalVM Native Image discovers reachable Mixins +directly. See the platform guides linked above. ## `JsonProperty` diff --git a/docs/json/custom-codecs.md b/docs/json/custom-codecs.md index f8d1dbfe36..195281507e 100644 --- a/docs/json/custom-codecs.md +++ b/docs/json/custom-codecs.md @@ -179,6 +179,31 @@ The child members have these meanings: | `keyCodec` | `Map` | JSON member name for `K` | | `valueCodec` | `Map` | direct `V` value | +### Kotlin occurrences + +Kotlin uses the same codec registrations and `JsonCodec` annotation. Apply property annotations to +an explicit supported use site, for example: + +```kotlin +import org.apache.fory.json.annotation.JsonCodec + +data class Ledger( + @field:JsonCodec(value = MoneyCodec::class) + val total: Money, + @field:JsonCodec(elementCodec = MoneyCodec::class) + val entries: List, +) +``` + +The complete-value codec owns the whole JSON value. Child codecs leave the standard array, +collection, Optional/atomic, or map representation in control. Kotlin nullability is still the +declared occurrence contract around a selected application codec: after a non-null JSON token, the +codec must return the exact declared type and must not return null for a non-null occurrence. + +An unsigned or eligible value-class map key can use the built-in member-name mapping without an +annotation. Use an explicit `keyCodec` or whole-map codec when the key has a different tagged text +shape. An exact application registration still takes precedence over the Kotlin module defaults. + A custom Map-key codec converts between the declared key and a JSON member name: ```java diff --git a/docs/json/getting-started.md b/docs/json/getting-started.md index 76a8e96d0c..ace829c8c7 100644 --- a/docs/json/getting-started.md +++ b/docs/json/getting-started.md @@ -44,6 +44,33 @@ implementation("org.apache.fory:fory-json:1.6.0") Use the same version for every Fory module in one application. +### Kotlin + +Kotlin/JVM applications add the optional Kotlin JSON runtime and use its single builder entry: + +```kotlin title="build.gradle.kts" +dependencies { + implementation("org.apache.fory:fory-json-kotlin:1.7.0-SNAPSHOT") +} +``` + +```kotlin +import org.apache.fory.json.kotlin.ForyJsonKotlin +import org.apache.fory.json.kotlin.jsonTypeRef + +data class User(val id: Long, val name: String) + +val json = ForyJsonKotlin.builder().build() +val userType = jsonTypeRef() +val text = json.toJson(User(7, "Alice"), userType) +val decoded = json.fromJson(text, userType) +``` + +The runtime reads Kotlin/JVM metadata directly. Add `fory-json-kotlin-ksp` only to Android builds +that use R8 or ProGuard; it emits exact retention rules for Kotlin `@JsonType` models and +source-owned exact Mixins. GraalVM Native Image uses the normal `@ForyJsonProvider` workflow. The +complete setup and Kotlin type behavior are in the [Kotlin JSON guide](kotlin.md). + ### JDK 25 and later On JDK 25 and later, opening `java.lang.invoke` to Fory core is also recommended. It avoids @@ -61,6 +88,7 @@ For a module-path application: ``` The JPMS module name of Fory JSON is `org.apache.fory.json`. +The Kotlin integration module name is `org.apache.fory.json.kotlin`. ## Quick start diff --git a/docs/json/graalvm.md b/docs/json/graalvm.md index f4d96d0cc6..e3b240ebdf 100644 --- a/docs/json/graalvm.md +++ b/docs/json/graalvm.md @@ -1,6 +1,6 @@ --- title: GraalVM Native Image -sidebar_position: 9 +sidebar_position: 10 id: graalvm license: | Licensed to the Apache Software Foundation (ASF) under one or more @@ -21,8 +21,9 @@ license: | ## Reachable Models -Fory JSON has its own Native Image Feature and does not use the Fory annotation processor. Add -`@JsonType` to each reachable concrete object model that the native executable reads or writes: +Fory JSON has one Native Image Feature. Java models are discovered from reachable annotations; the +Feature does not use the Java annotation processor. Add `@JsonType` to each reachable concrete +Java object model that the native executable reads or writes: ```java import org.apache.fory.json.ForyJson; @@ -95,10 +96,40 @@ needed, and the provider package does not need to be exported or opened to Fory. methods and fields are not supported. Only configurations returned by a provider receive generated codecs. The default configuration is -not generated implicitly. If a codegen-enabled `ForyJson` configuration was not included, Fory JSON -uses its prepared interpreted codecs and logs one process-wide warning recommending a reachable -`@ForyJsonProvider`. `withCodegen(false)` explicitly selects interpreted codecs and does not request -generated-codec lookup. Asynchronous compilation is disabled in a native executable. +not generated implicitly. If a codegen-enabled `ForyJson` configuration was not included, ordinary +Java models and complete value codecs use their prepared interpreted codecs, and Fory JSON logs one +process-wide warning recommending a reachable `@ForyJsonProvider`. Language-module object models +that require hosted capabilities fail before reading or writing a value. `withCodegen(false)` +explicitly selects interpreted codecs and does not request generated-codec lookup. Asynchronous +compilation is disabled in a native executable. + +### Kotlin configurations + +Kotlin Native Image support uses the same Feature and provider API. Add the Kotlin runtime, then +return a codegen-enabled configuration that installs `ForyJsonKotlin`: + +```kotlin +import org.apache.fory.json.ForyJson +import org.apache.fory.json.annotation.ForyJsonProvider +import org.apache.fory.json.kotlin.ForyJsonKotlin + +@ForyJsonProvider +class JsonConfigs { + fun api(): ForyJson = ForyJsonKotlin.builder().build() +} +``` + +Annotate each reachable concrete Kotlin model with `@JsonType`, or register an exact reachable +Mixin for a third-party target. Fory reads and validates Kotlin metadata while building the image, +then generates the provider-selected codecs. A provider configuration with disabled code +generation or an unsupported metadata ABI fails image construction. A Kotlin-enabled runtime +configuration that was not returned by a provider fails before it reads or writes a Kotlin object; +it never falls back to reflective construction. + +An exact generic Kotlin root is available only when its complete binding is reached through a +property, constructor argument, container/map child, or closed subtype of a provider-selected +concrete root. Keep using `jsonTypeRef()` at the direct root call; no public root registry or +reflection configuration is needed. ## Mixins @@ -146,7 +177,8 @@ by a class name resolved at runtime is not reachable; `JsonSubTypes.Type.className` is therefore unsupported in a native image. Do not add application reflection configuration as a replacement for the generated configuration. -The native executable resolves the same effective annotations as the JVM. +The native executable resolves the same effective annotations as the JVM. Kotlin applications use +the provider workflow above and must also avoid package-wide opens or reflection configuration. ## Annotations and Custom Codecs diff --git a/docs/json/index.md b/docs/json/index.md index fe8438ac20..97a436f060 100644 --- a/docs/json/index.md +++ b/docs/json/index.md @@ -19,9 +19,10 @@ license: | limitations under the License. --- -Fory JSON is Apache Fory's thread-safe JSON codec for Java and Scala. It provides interpreted and -runtime-generated codecs for Java objects, records, immutable creator-based classes, common JDK -types, generic containers, Scala models and collections, and custom complete-value codecs. +Fory JSON is Apache Fory's thread-safe JSON codec for Java, Kotlin, and Scala. It provides +interpreted and generated codecs for Java objects, records, immutable creator-based classes, +common JDK types, generic containers, Kotlin models and semantic types, Scala models and +collections, and custom complete-value codecs. Fory JSON is a separate data format from Fory's binary native and xlang protocols. Use it when a system must exchange ordinary JSON with browsers, APIs, logs, configuration, or another JSON @@ -30,18 +31,19 @@ reference identity, circular graphs, or Fory's binary-only features. ## Documentation map -| Goal | Page | -| -------------------------------------------------------------- | ------------------------------------- | -| First runnable JSON round trip | [Getting Started](getting-started.md) | -| Understand Java object mapping and configuration | [Object Mapping](object-mapping.md) | -| Configure properties, creators, values, validators, and mixins | [Annotations](annotations.md) | -| Extend complete values, children, and map keys | [Custom Codecs](custom-codecs.md) | -| Package and distribute reusable JSON extensions | [Modules](modules.md) | -| Use case classes, Scala collections, and Scala enums | [Scala](scala.md) | -| Deploy on Android | [Android](android.md) | -| Build a GraalVM native image | [GraalVM Native Image](graalvm.md) | -| Decode input safely | [Security](security.md) | -| Diagnose failures | [Troubleshooting](troubleshooting.md) | +| Goal | Page | +| --------------------------------------------------------------- | ------------------------------------- | +| First runnable JSON round trip | [Getting Started](getting-started.md) | +| Understand Java object mapping and configuration | [Object Mapping](object-mapping.md) | +| Configure properties, creators, values, validators, and mixins | [Annotations](annotations.md) | +| Extend complete values, children, and map keys | [Custom Codecs](custom-codecs.md) | +| Package and distribute reusable JSON extensions | [Modules](modules.md) | +| Use data classes, Kotlin nullability, defaults, and value types | [Kotlin](kotlin.md) | +| Use case classes, Scala collections, and Scala enums | [Scala](scala.md) | +| Deploy on Android | [Android](android.md) | +| Build a GraalVM native image | [GraalVM Native Image](graalvm.md) | +| Decode input safely | [Security](security.md) | +| Diagnose failures | [Troubleshooting](troubleshooting.md) | ## Performance diff --git a/docs/json/kotlin.md b/docs/json/kotlin.md new file mode 100644 index 0000000000..bdffae408d --- /dev/null +++ b/docs/json/kotlin.md @@ -0,0 +1,377 @@ +--- +title: Kotlin +sidebar_position: 8 +id: kotlin +license: | + 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. +--- + +Fory JSON Kotlin maps Kotlin/JVM types to ordinary JSON while preserving Kotlin constructor +defaults, nullability, value types, and generic arguments. It is an optional module layered on +Fory JSON; it does not change Fory's binary protocols. + +## Installation + +The runtime supports Kotlin/JVM metadata ABI 2.3 and is built with Kotlin 2.3.20. Use the same Fory +version for every module: + +```kotlin title="build.gradle.kts" +plugins { + kotlin("jvm") version "2.3.20" +} + +dependencies { + implementation("org.apache.fory:fory-json-kotlin:1.7.0-SNAPSHOT") +} +``` + +The runtime reads Kotlin/JVM metadata directly; do not add `kotlin-reflect`. KSP is not required for +JVM applications or unminified Android builds. Android applications that use R8 or ProGuard should +also apply KSP and add the retention-rule processor: + +```kotlin title="build.gradle.kts" +plugins { + id("com.google.devtools.ksp") version "2.3.8" +} + +dependencies { + ksp("org.apache.fory:fory-json-kotlin-ksp:1.7.0-SNAPSHOT") +} +``` + +Annotate every Kotlin model that needs exact minification retention with `@JsonType`. For a +third-party target, annotate a source-owned exact `@JsonMixin` instead. The processor emits only +the exact R8 and ProGuard rules for those declarations; it does not generate codecs or change the +JSON mapping. + +## Quick start + +Use `ForyJsonKotlin.builder()` to install the Kotlin module. Retain a `jsonTypeRef()` for every +declared Kotlin root whose nullability, unsigned identity, value-class identity, or generic +arguments matter: + +```kotlin +import org.apache.fory.json.kotlin.ForyJsonKotlin +import org.apache.fory.json.kotlin.jsonTypeRef + +data class Account( + val id: ULong, + val name: String, + val nickname: String? = null, +) + +val json = ForyJsonKotlin.builder().build() +val accountType = jsonTypeRef() + +val text = json.toJson(Account(7u, "Alice"), accountType) +val decoded = json.fromJson(text, accountType) +``` + +`jsonTypeRef()` is a type token, not a codec lookup. Construct it once and reuse it. A Java +`Class` or ordinary Java `TypeRef` cannot express distinctions such as `List`, `UInt`, or +a logical value class lowered to a primitive carrier. + +The builder can also install the module explicitly: + +```kotlin +import org.apache.fory.json.ForyJson +import org.apache.fory.json.kotlin.ForyJsonKotlin + +val json = ForyJson.builder().withModule(ForyJsonKotlin).build() +``` + +There is no automatic classpath installation or Kotlin-specific encode/decode alias. + +## Immutable classes and compiler defaults + +An ordinary or data class is mapped as a named JSON object. Fory selects one valid public Kotlin +constructor and reconstructible properties; `copy` and `componentN` functions do not define the +schema. A compiler-generated default is used only when its JSON member is absent: + +```kotlin +data class Request( + val id: Long, + val label: String? = "new", + val retries: Int = 3, +) +``` + +The primary constructor is automatic. A public secondary constructor or target-owned public static +factory is used only when explicitly selected by `JsonCreator`; a companion factory qualifies only +through its real outer-class `@JvmStatic` bridge. `@JvmOverloads` artifacts are not separate +creator candidates, and a selected static factory cannot have compiler-default parameters. +Private/protected, vararg, executable-generic, context-parameter, local, +anonymous, `inner`, or synthetic construction requires an exact application codec. + +For this model: + +- `{"id":1}` invokes both compiler defaults. +- `{"id":1,"label":null}` passes an explicit null and does not invoke the `label` default. +- a missing `id` fails before constructor invocation. +- `{"id":1,"retries":null}` fails; null never asks Kotlin to use a default. + +Normal body `var` properties preserve their initializer when absent and are assigned after +construction when present. A `lateinit` property is required. Automatic creator and deferred +properties must be reconstructible in both read and write directions. + +A body `val`, computed or delegated property, getter-only property, or delegated `var` must be +ignored or handled by an exact custom codec. Present deferred setters run in fixed property order +after construction, followed by validators; input member order does not choose application call +order. Kotlin class instances are always constructed normally, so primary-constructor initialization and +validation are not bypassed. + +Fory must be able to read its own output under the same configuration. Consequently, nullable +constructor parameters and nullable deferred properties are emitted explicitly when null, even +when the builder's general Java default is to omit null fields. An explicit +`JsonProperty.Include.NON_NULL` on such a property is rejected if omission could fail or invoke a +different compiler default. + +## Nullability + +Kotlin occurrence nullability is enforced at roots, properties, container elements, map values, +and generic children: + +| Declaration | Missing member | Explicit JSON `null` | +| --------------------------------- | --------------------- | -------------------- | +| `val value: String` | Fails | Fails | +| `val value: String?` | Fails | Passes null | +| `val value: String = expression` | Evaluates the default | Fails | +| `val value: String? = expression` | Evaluates the default | Passes null | + +`List` accepts null elements; `List` rejects them. Map keys must be non-null. +Platform/unknown nullability is not guessed for automatic Kotlin construction. + +Transparent wrappers must have one unambiguous null meaning. For example, non-null +`Optional` uses JSON null for `Optional.empty()`, so `Optional?` and `Optional` are +rejected. `AtomicReference` and value classes follow the same injective-shape rule. Use an exact +custom tagged codec when two logical states would otherwise share JSON null. + +An application codec selected by registration or `@JsonCodec` remains trusted application code. +For a non-null JSON token, it must return the exact declared type and honor Kotlin occurrence +nullability. + +## Annotations and use-site targets + +Kotlin annotations feed the same logical-property merge used by Java. Prefer explicit use-site +targets: + +```kotlin +import org.apache.fory.json.annotation.JsonCodec +import org.apache.fory.json.annotation.JsonIgnore +import org.apache.fory.json.annotation.JsonProperty +import org.apache.fory.json.annotation.JsonType + +@JsonType +data class Profile( + @param:JsonProperty("user_id") + val id: Long, + + @field:JsonIgnore + val localCacheKey: String? = null, + + @field:JsonProperty(include = JsonProperty.Include.ALWAYS) + val nickname: String? = null, + + @field:JsonCodec(elementCodec = AccountIdCodec::class) + val accountIds: List, +) +``` + +Supported physical sites are `@field:`, `@get:`, `@set:`, selected constructor `@param:`, and the +documented parameter annotations on `@setparam:`. `@property:` is not supported because Fory JSON +annotations do not target Kotlin-only property metadata. `@setparam:JsonProperty` is rejected; +property naming belongs on the field, accessor, or selected constructor parameter. Bare and +`@all:` annotations are honored only where they produce one of the supported JVM sites. + +Repeated `JsonProperty` members merge only when explicit values agree. `JsonIgnore` directions +merge monotonically. Repeated `JsonCodec` declarations must be identical. A `@set:JsonCodec` is a +supported setter declaration; it does not need to be rewritten as `@setparam:`. + +See [Annotations](annotations.md) for the complete mapping and [Custom Codecs](custom-codecs.md) +for complete-value, element, content, key, and map-value codecs. + +## Generics and collections + +Use complete declared types: + +```kotlin +import org.apache.fory.json.kotlin.jsonTypeRef + +val accountsType = jsonTypeRef>() +val accounts = json.fromJson("[null,{\"id\":7,\"name\":\"Alice\"}]", accountsType) +``` + +Raw generic types, `in` projections, and star projections are rejected for typed restoration. An +`out X` projection is accepted only when it normalizes to one exact final or closed readable +schema. Recursive generic models are supported when recursion returns to the same exact binding; +an active raw declaration that expands to a different binding is rejected. + +Kotlin read-only and mutable collection interfaces use the normal JSON collection/map behavior. +Read-only does not mean immutable: standard interfaces materialize as `ArrayList`, +`LinkedHashSet`, or `LinkedHashMap`. Declare public interfaces instead of JDK/Kotlin private empty, +singleton, unmodifiable, or builder implementation classes. `Iterable`, `Sequence`, iterators, +`EnumEntries`, and a directly declared `Map.Entry` are not automatic value schemas. + +Unsigned and eligible non-null value-class map keys use JSON object member names through the +normal map codec. A value-class key chain must terminate in String, enum, signed integer, or +unsigned integer semantics. Floating, Boolean, nullable, and arbitrary object keys require an +explicit key or whole-map codec. + +## Value classes, objects, and closed hierarchies + +A user value class has the transparent JSON shape of its underlying value: + +```kotlin +import org.apache.fory.json.kotlin.jsonTypeRef + +@JvmInline +value class AccountId(val value: ULong) + +val idType = jsonTypeRef() +val id = json.fromJson("18446744073709551615", idType) +``` + +Fory executes the compiler's validated constructor operation, so value-class initialization checks +still run. If both the outer value and its underlying value can be null, transparent JSON is +ambiguous and automatic mapping is rejected. Use a tagged exact codec for that case. + +A stateless `object` or `data object` uses strict `{}` and returns the canonical singleton. A +stateful object and a companion object require an exact custom codec. `Unit` also uses `{}`; +`Nothing` is rejected, while `Nothing?` accepts only JSON null through its explicit Kotlin type +token. + +Sealed classes are not discovered from input or package metadata. Declare the complete logical-name +table with `JsonSubTypes`; only listed exact runtime classes are accepted. The input contains a +logical subtype name, never a JVM class name. See [Annotations](annotations.md#jsonsubtypes) for +the available property and wrapper shapes. + +## Supported Kotlin types + +Java/JDK scalar, temporal, Optional, atomic, array, collection, map, enum, and JSON tree types keep +their normal Fory JSON representation when used from Kotlin: + +| Core family | Kotlin-visible behavior | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Any` / `Any?` | natural JSON Boolean, number, String, array, object, or null; dynamic runtime dispatch on writes | +| signed scalars and boxes | `Boolean`, `Byte`, `Short`, `Int`, `Long`, `Float`, `Double`, `Char`, and `Number` use the core scalar codecs; non-finite floating values use quoted core forms | +| text | `String`, exact `CharSequence`, `StringBuilder`, and `StringBuffer` use String shapes | +| arbitrary/reduced-precision number | `BigInteger`, `BigDecimal`, Fory `Float16`, and `BFloat16` use their core numeric shapes and limits | +| enum | quoted enum constant name | +| Java/Kotlin arrays | normal JSON arrays; `ByteArray` is numeric unless `JsonBase64` selects binary; unsigned semantic arrays are listed below | +| Optional and atomic | `Optional`, primitive Optionals, atomic scalars/references, and atomic arrays keep their transparent core shapes subject to the nullability rules above | +| quoted JDK values | `Currency`, `File`, `URI`, `Path`, `Pattern`, `UUID`, `Locale`, `Charset`, and `TimeZone` keep their core String shapes | +| legacy date/time | `Date`, `Calendar`, and available `java.sql.Date`, `Time`, and `Timestamp` keep their epoch-millisecond shapes | +| Java time | `LocalDate`, `LocalTime`, `LocalDateTime`, `Instant`, `java.time.Duration`, `ZoneOffset`, `ZoneId`, `ZonedDateTime`, `Year`, `YearMonth`, `MonthDay`, `Period`, `OffsetTime`, `OffsetDateTime`, and supported chronology dates keep their exact core text grammars | +| other core values | `BitSet`, `ByteBuffer`, `JsonArray`, and `JsonObject` keep their normal core shapes | +| collections/maps | supported Java/Kotlin `Collection` and `Map` interfaces and implementations use core array/object shapes; supported Guava immutable carriers remain optional | +| map keys | String, enum, signed `Byte`/`Short`/`Int`/`Long`, and the Kotlin unsigned/value-class additions below; Boolean, floating, nullable, and arbitrary object keys need an explicit key or whole-map codec | +| fixed rejections | `Class`, URL/network/socket/address families, unsupported JDK internal collection implementations, and unregistered `Number`/`CharSequence` subclasses remain rejected; an application may explicitly own a permitted custom representation subject to the normal type and security checks | + +See [Object Mapping](object-mapping.md#supported-java-types) for the detailed core shapes. +Kotlin-specific behavior is: + +| Type family | Automatic JSON shape or decision | +| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | +| ordinary, data, and nested classes | named JSON object | +| `inner` class, ordinary abstract class/interface | closed `JsonSubTypes` or exact custom codec only | +| sealed class/interface | explicit closed `JsonSubTypes` table only | +| enum | quoted enum name | +| stateless `object`, `data object`, `Unit` | strict `{}` | +| stateful object, companion object | exact custom codec only | +| value class | transparent underlying value; exact binding required | +| `Nothing?` / `Nothing` | null-only / rejected | +| `Pair`, `Triple` | named objects with `first`, `second`, and `third` | +| `Result`, `Lazy`, Kotlin standard property delegates | exact custom codec only | +| signed primitives, primitive arrays, `Array` | normal core number/Boolean/character and array shapes | +| `UByte`, `UShort`, `UInt`, `ULong` and their arrays | unsigned decimal numbers and arrays | +| read-only and mutable collections/maps, Kotlin `ArrayDeque` | normal core arrays/maps | +| `Map.Entry`, private collection carriers, `Iterable`, `Sequence`, iterators, `EnumEntries` | rejected automatically | +| `CharRange`, signed/unsigned integer ranges | `{"start":...,"endInclusive":...}` | +| corresponding progressions | `{"first":...,"last":...,"step":...}` | +| `ClosedRange`, `OpenEndRange`, abstract/open/floating ranges | rejected automatically | +| `kotlin.time.Duration` | quoted canonical Kotlin ISO duration | +| `kotlin.time.Instant` | quoted canonical Kotlin ISO instant | +| `TimedValue` | `{"value":...,"duration":...}` | +| `DurationUnit`, `RegexOption` | quoted enum name | +| clocks, time sources/marks, Regex/match state, Random | exact custom codec only | +| `kotlin.uuid.Uuid` | quoted canonical dashed UUID | +| complete generic class / declaration-site variance | exact substituted schema | +| `out X` / `in X` / star projection | exact final-or-closed `X` only / rejected / rejected | +| recursive generic | same exact recursive binding only; active expansion to another binding is rejected | +| typealias | its fully expanded type | +| function/suspend function, reflection types, coroutine/flow/channel state | rejected | +| eligible third-party immutable Kotlin model | automatic on the JVM; register an exact Mixin when applying annotation overlays | + +Kotlin experimental opt-in requirements for time and UUID APIs still apply to application source. +The Fory artifact's supported compiler and metadata boundary does not turn an experimental Kotlin +API into a cross-version Kotlin guarantee. + +## Security + +`jsonTypeRef()`, annotations, Mixins, and codec registrations are application-declared schema. +JSON input cannot select an arbitrary class, constructor, compiler default, object, companion, +module, codec, or callable. A sealed hierarchy accepts only the logical subtype names in its +declared `@JsonSubTypes` table. + +Kotlin arrays, collections, maps, and objects use the same `maxDepth`, graph-memory, input-buffer, +field-name-cache, and type-checker controls as the core JSON runtime. There are no Kotlin-specific +collection or workspace limits to configure. Model constructors, compiler defaults, validators, +and custom codecs remain trusted application code and may have application-defined allocation or +side effects. + +See [Security](security.md) before decoding untrusted input. + +## KSP, GraalVM, and Android + +KSP is optional except in Android builds where R8 or ProGuard can rename or remove model members. +For those builds, `@JsonType` and source-owned exact Mixins ask KSP to emit exact retention rules. +KSP owns a Mixin request when either the Mixin source or its exact target is Kotlin. It does not +generate a Kotlin codec or select a different JSON schema. + +On GraalVM Native Image, use the existing `@ForyJsonProvider` workflow, install +`ForyJsonKotlin`, and enable code generation in the returned configuration. Annotate each reachable +concrete Kotlin model with `@JsonType`, or register an exact reachable Mixin for a third-party +target. Fory reads the Kotlin metadata and prepares generated codecs while building the image. +Only exact generic bindings reachable through provider-selected concrete roots are available. Do +not add reflection configuration or package-wide opens. + +On Android, use API 26 or later. The runtime reads Kotlin metadata in both debug and release builds. +If a release build enables R8 or ProGuard, apply KSP to the application module and mark every +required source model with `@JsonType` or provide a source-owned exact Mixin. Package the generated +retention rules and do not add package-wide keep rules. Runtime JSON code generation remains +disabled. + +Kotlin/Native, Kotlin/JS, and Kotlin/Wasm are not supported by this JVM module. + +See [GraalVM Native Image](graalvm.md), [Android](android.md), and +[Troubleshooting](troubleshooting.md) for platform diagnostics. + +## Troubleshooting + +| Symptom | Action | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| unsupported Kotlin metadata | Compile the model with the supported Kotlin 2.3 compiler and verify that packaged JVM members match the model | +| missing member or JSON null fails | Check the exact occurrence in `jsonTypeRef()`; absence may use a declared default, while null requires a nullable declaration | +| raw, star, or projected generic fails | Supply one complete declared type; `in` and star projections do not define reconstructible schemas | +| model fails only after Android minification | Apply KSP, mark the source with `@JsonType` or an exact Mixin, and verify that its generated retention rules are packaged | +| model is unavailable in Native Image | Return a codegen-enabled `ForyJsonKotlin` configuration from a reachable provider and make the exact model binding reachable from that config | + +The general [Troubleshooting](troubleshooting.md) page covers syntax, limits, custom codecs, +subtypes, and root-operation failures shared with Java and Scala. + +The source-aligned four-library benchmark methodology and publication status are in the +[Kotlin JSON benchmark report](../benchmarks/json/kotlin/README.md). No Kotlin result is inferred +from the Java or Scala benchmark. diff --git a/docs/json/modules.md b/docs/json/modules.md index 0e573b8efa..9eb4dd4898 100644 --- a/docs/json/modules.md +++ b/docs/json/modules.md @@ -74,6 +74,21 @@ Application registrations made directly on `ForyJsonBuilder` take precedence ove registrations. Conflicting module registrations fail during `build()` instead of depending on installation order. +## Kotlin Module + +`ForyJsonKotlin` is the optional module for Kotlin/JVM models. Prefer its builder when an +application uses Kotlin models: + +```kotlin +import org.apache.fory.json.kotlin.ForyJsonKotlin + +val json = ForyJsonKotlin.builder().build() +``` + +This is equivalent to `ForyJson.builder().withModule(ForyJsonKotlin)`. It does not scan the +classpath or register application models. Exact application codec registrations retain normal +precedence. See [Kotlin](kotlin.md) for type tokens and optional Android minification setup. + ## Module Identity `moduleKey()` identifies the module configuration for generated-code reuse and conflict checking. diff --git a/docs/json/object-mapping.md b/docs/json/object-mapping.md index 1563bc04d4..4e32d2032e 100644 --- a/docs/json/object-mapping.md +++ b/docs/json/object-mapping.md @@ -100,6 +100,19 @@ use ordinary-constructor side effects as a deserialization completion hook: when constructor runs, property assignment happens afterward, and constructor-bypassing paths do not run it at all. +## Kotlin object mapping + +Install `fory-json-kotlin` and use `ForyJsonKotlin.builder()` for Kotlin/JVM classes. Kotlin +ordinary and data classes use their selected constructor, exact property types, compiler defaults, +and declared nullability; they do not use Java's constructor-bypassing fallback. A default applies +only when the member is missing. An explicit JSON null remains a present value and is rejected for +a non-null parameter. + +Use `jsonTypeRef()` for generic, nullable, unsigned, and value-class roots. Standard arrays, +collections, and maps continue to use their normal Fory JSON representation. The complete language +type table, singleton/value-class behavior, and omission rules are in the +[Kotlin guide](kotlin.md). + ## Supported Java types The following groups have built-in mappings. Exact wire representations are stable JSON values, but diff --git a/docs/json/security.md b/docs/json/security.md index 1c6580a9db..267c9f56dd 100644 --- a/docs/json/security.md +++ b/docs/json/security.md @@ -1,6 +1,6 @@ --- title: Security -sidebar_position: 10 +sidebar_position: 11 id: security license: | Licensed to the Apache Software Foundation (ASF) under one or more @@ -19,12 +19,18 @@ license: | limitations under the License. --- -Use Fory JSON with untrusted input only after defining which Java types may be +Use Fory JSON with untrusted input only after defining which JVM types may be materialized and which resource limits the endpoint will enforce. Fory JSON does not derive arbitrary Java class names from JSON input, but annotations, declared target types, and custom codecs still define an application-controlled object surface. +Kotlin type tokens and metadata are trusted schema declarations, not input authority. The Kotlin +module validates the logical type, physical JVM carrier, and constructor/default operations before +parsing. JSON input cannot select a class, constructor, compiler default target, object, companion, +module, codec, or callable. A closed `JsonSubTypes` value selects only a logical name from the +application-declared finite table. + ## Type Policy And Class Loading Fory JSON always applies its fixed disallow list. Add an application allow-list @@ -80,9 +86,9 @@ storage is reserved in 1024-item batches before each batch's final child and at the tail. Repeated set elements and duplicate or overwritten map members are therefore charged for every input occurrence. A reference array is charged even when every element is a leaf, and an object is charged when all its -properties are leaves. `AtomicReference`, `AtomicReferenceArray`, and generic -`Optional` values include wrapper and reference storage; primitive -optionals and atomic primitive values are leaves. +properties are leaves. `AtomicReference`, `AtomicReferenceArray`, and generic `Optional` values +include wrapper and reference storage. An allocated primitive Optional or atomic primitive wrapper +is also charged once; a cached empty Optional singleton is not a new graph owner. Dedicated leaf codecs are excluded from graph accounting: null, strings, characters, booleans, numeric values including arbitrary-precision numbers, @@ -104,6 +110,18 @@ parsing storage, custom-codec allocations that the codec does not reserve, or unrelated process memory. Actual memory use can therefore exceed the configured budget. +Kotlin does not add separate collection, input, or workspace limits. Arrays, collections, maps, +and ordinary objects use the same core depth and graph-memory accounting as Java and Scala. +Interpreted constructor argument arrays are fixed from trusted model metadata, not an +input-declared count, and are not retained in the decoded graph. Singleton and `Unit` reads return +existing instances. A boxed value-class result is charged once when that wrapper is materialized. + +Compiler defaults, model constructors, validators, and application codecs are trusted application +code. Their internal allocation and side effects are not sandboxed or charged by the graph budget. +Their exceptions still fail the root operation and clear root parsing state, but a later trailing- +input failure cannot undo code that already ran. Validate side effects accordingly when decoding +untrusted input. + ## External Controls And Verification Fory JSON does not authenticate, authorize, encrypt, sign, or impose an HTTP diff --git a/docs/json/troubleshooting.md b/docs/json/troubleshooting.md index 3315d59fca..1937faa663 100644 --- a/docs/json/troubleshooting.md +++ b/docs/json/troubleshooting.md @@ -1,6 +1,6 @@ --- title: Troubleshooting -sidebar_position: 11 +sidebar_position: 12 id: troubleshooting license: | Licensed to the Apache Software Foundation (ASF) under one or more @@ -19,22 +19,27 @@ license: | limitations under the License. --- -| Symptom | Likely cause and action | -| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ForyJsonException` while parsing | Invalid JSON grammar, type mismatch, unsupported mapping, depth or graph-memory violation, validator failure, or trailing content | -| `InsecureException` | Fory's disallow list or the configured `JsonTypeChecker` rejected a class | -| `IllegalArgumentException` from a builder | Check the configured depth, graph-memory, concurrency, retained-buffer, and cached-field-name limits | -| Declared write is rejected | The value is not assignable to the declared type, the type contains a wildcard/type variable, or null was supplied for a primitive | -| Immutable value is not populated | Use a record, a valid `JsonCreator`, or an exact custom codec | -| `JsonValue` read fails | Add one plain `String` `JsonCreator`, or register an exact custom codec | -| Raw JSON output is invalid | Supply exactly one trusted, complete JSON value to the `JsonRawValue` property | -| Ordinary object cannot be constructed | Add a usable no-argument constructor, use a record or `JsonCreator`, or register a custom codec; Android and GraalVM native image are stricter | -| Ordinary accessor annotation fails | The method is not an eligible public JavaBean accessor, or field mode is enabled | -| Any annotation fails | Use exactly one field-backed form or one valid method-backed pair with resolved `Map` types; method annotations require non-field mode | -| Codec annotation fails | Resolve same-node or hierarchy conflicts, remove a hidden nested override, or use a public no-argument codec class | -| Subtype is rejected | The base is not declared on the write, the runtime class is not an exact table entry, or the input wire shape differs from the configured inclusion | -| Collection cannot be read | Target a supported interface/common implementation or register a custom codec | -| OutputStream write fails | The underlying `IOException` is wrapped as the cause of `ForyJsonException` | +| Symptom | Likely cause and action | +| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ForyJsonException` while parsing | Invalid JSON grammar, type mismatch, unsupported mapping, depth or graph-memory violation, validator failure, or trailing content | +| `InsecureException` | Fory's disallow list or the configured `JsonTypeChecker` rejected a class | +| `IllegalArgumentException` from a builder | Check the configured depth, graph-memory, concurrency, retained-buffer, and cached-field-name limits | +| Declared write is rejected | The value is not assignable to the declared type, the type contains a wildcard/type variable, or null was supplied for a primitive | +| Immutable value is not populated | Use a record, a valid `JsonCreator`, or an exact custom codec | +| `JsonValue` read fails | Add one plain `String` `JsonCreator`, or register an exact custom codec | +| Raw JSON output is invalid | Supply exactly one trusted, complete JSON value to the `JsonRawValue` property | +| Ordinary object cannot be constructed | Add a usable no-argument constructor, use a record or `JsonCreator`, or register a custom codec; Android and GraalVM native image are stricter | +| Ordinary accessor annotation fails | The method is not an eligible public JavaBean accessor, or field mode is enabled | +| Any annotation fails | Use exactly one field-backed form or one valid method-backed pair with resolved `Map` types; method annotations require non-field mode | +| Codec annotation fails | Resolve same-node or hierarchy conflicts, remove a hidden nested override, or use a public no-argument codec class | +| Subtype is rejected | The base is not declared on the write, the runtime class is not an exact table entry, or the input wire shape differs from the configured inclusion | +| Collection cannot be read | Target a supported interface/common implementation or register a custom codec | +| OutputStream write fails | The underlying `IOException` is wrapped as the cause of `ForyJsonException` | +| Kotlin null or missing member fails | Check the exact `jsonTypeRef`, constructor default, and nullable occurrence; null does not request a compiler default | +| Raw/star/projected Kotlin generic fails | Supply a complete `jsonTypeRef()`; `in` and star projections cannot reconstruct one exact schema | +| Unsupported Kotlin metadata | Compile the model with a supported Kotlin 2.3 compiler and ensure its validated JVM members match the metadata | +| Kotlin model fails after Android shrinking | Apply KSP, annotate the source model or exact Mixin, and verify that the generated exact retention rules are packaged | +| Kotlin model is absent in Native Image | Install `ForyJsonKotlin` from a reachable `ForyJsonProvider`, enable code generation, and make the exact binding reachable from that configuration | Fory JSON mapping, syntax, codec, depth, graph-memory, validator, and output failures use `ForyJsonException`. User codec code may still throw its own runtime exception. Creator and diff --git a/docs/start/index.md b/docs/start/index.md index cfc88ef686..1cd285b5d2 100644 --- a/docs/start/index.md +++ b/docs/start/index.md @@ -61,7 +61,7 @@ round trip for an application project, and the next capability-specific steps: | -------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------- | -------------------------------------------------------- | | Object Serialization | Reconstruct object graphs, including shared references and schema changes | All languages | [Object Serialization](../object-serialization/index.md) | | Row Format | Trusted analytical data with zero-copy, random, or partial field access | Java, Python, C++, Rust | [Row Format](../row-format/index.md) | -| Fory JSON | High-performance standard JSON mapping | Java | [Fory JSON](../json/index.md) | +| Fory JSON | High-performance standard JSON mapping | Java, Kotlin, Scala | [Fory JSON](../json/index.md) | | Fory IDL | Generate native models and serializers from Fory, protobuf, or FlatBuffers IDL | All languages | [Fory IDL and Compiler](../compiler/index.md) | | Fory gRPC | Use generated models over normal gRPC transports with Fory-encoded messages | Java, Python, C++, Go, Rust, JavaScript, C#, Dart, Scala, Kotlin | [Fory gRPC](../grpc/index.md) | diff --git a/docs/start/kotlin.md b/docs/start/kotlin.md index 8e5b246fca..39014fa597 100644 --- a/docs/start/kotlin.md +++ b/docs/start/kotlin.md @@ -19,8 +19,8 @@ license: | limitations under the License. --- -Fory Kotlin provides binary Object Serialization, generated models, Fory gRPC, -and Android support. It runs on Fory Java and supports Java 8 and later. +Fory Kotlin provides binary Object Serialization, standard JSON mapping, generated models, Fory +gRPC, and Android support. It runs on Fory Java and supports Java 8 and later. ## Verify the Toolchain @@ -73,6 +73,34 @@ within the JVM Fory implementation family. Continue with [xlang](../object-serialization/kotlin/basic-serialization.md#cross-language-interoperability), or [native mode](../object-serialization/kotlin/native.md). +## Standard JSON + +Fory JSON is a separate text format from binary Object Serialization. Add its optional Kotlin +module when interoperating with ordinary JSON APIs, browsers, logs, or other JSON libraries: + +```kotlin title="build.gradle.kts" +dependencies { + implementation("org.apache.fory:fory-json-kotlin:1.7.0-SNAPSHOT") +} +``` + +```kotlin +import org.apache.fory.json.kotlin.ForyJsonKotlin +import org.apache.fory.json.kotlin.jsonTypeRef + +data class User(val id: Long, val name: String) + +val json = ForyJsonKotlin.builder().build() +val userType = jsonTypeRef() +val text = json.toJson(User(1, "Alice"), userType) +val decoded = json.fromJson(text, userType) +``` + +The runtime reads Kotlin/JVM metadata directly. Add `fory-json-kotlin-ksp` only to Android builds +that use R8 or ProGuard; it emits exact retention rules for Kotlin `@JsonType` models and +source-owned exact Mixins. Native Image uses the normal `@ForyJsonProvider` workflow. Continue with +[Kotlin JSON](../json/kotlin.md). + ## Other Capabilities - **Fory IDL and Compiler** generates Kotlin models and registration helpers through KSP. See [Compiler Getting Started](../compiler/getting-started.md) and the [Kotlin generated-code guide](../compiler/generated-code/kotlin.md). diff --git a/integration_tests/android_tests/README.md b/integration_tests/android_tests/README.md index f08ac807ff..bf3a2a87b9 100644 --- a/integration_tests/android_tests/README.md +++ b/integration_tests/android_tests/README.md @@ -1,25 +1,32 @@ # Android Integration Tests -This project runs Android API 26+ instrumented tests for Java `fory-core` and -`fory-json`. API 26 runs both debug reflection coverage and the release-minified -suite. API 36 runs the release-minified suite. Release coverage verifies static -serializers, processor-generated Fory JSON execution for mutable classes, -`JsonCreator` classes, object-mapped and `JsonValue` desugared Records, generated -retention rules, generated validator invocation and failure propagation, -generated operations for the exact target-Mixin pair, and equivalent -application-authored exact rules for unannotated ordinary classes. Mixin -coverage registers the source at runtime after R8 minification so broad -application keep rules cannot hide missing processor output. +This project runs Android API 26+ instrumented tests for Java `fory-core`, Java +`fory-json`, and Kotlin `fory-json-kotlin`. API 26 runs both debug coverage and +the release-minified suite. API 36 runs the release-minified suite. Release +coverage verifies static serializers, processor-generated Fory JSON execution +for mutable Java classes, `JsonCreator` classes, object-mapped and `JsonValue` +desugared Records, Kotlin immutable/default/value/object/sealed models, generated +retention rules, generated validator invocation and failure propagation, and +exact target-Mixin behavior. Mixin coverage registers the source at runtime +after R8 minification so broad application keep rules cannot hide missing +processor output. The tests consume `org.apache.fory:fory-core:1.7.0-SNAPSHOT`, -`org.apache.fory:fory-json:1.7.0-SNAPSHOT`, and -`org.apache.fory:fory-annotation-processor:1.7.0-SNAPSHOT` from the local Maven -repository, so install the Java artifacts before running Gradle: +`org.apache.fory:fory-json:1.7.0-SNAPSHOT`, +`org.apache.fory:fory-annotation-processor:1.7.0-SNAPSHOT`, +`org.apache.fory:fory-json-kotlin:1.7.0-SNAPSHOT`, +`org.apache.fory:fory-json-kotlin-ksp:1.7.0-SNAPSHOT`, and the shared Kotlin JSON +corpus from the local Maven repository. From the repository root, install the +Java, Kotlin, KSP, and corpus artifacts through the single Kotlin CI owner before +running Gradle. The fixture uses Gradle 8.13, Android Gradle Plugin 8.13.2, +Android Build Tools 35.0.0, Kotlin Android plugin 2.3.20, and KSP 2.3.8. KSP +packages exact consumer rules through its standard resource output; the fixture +adds no application-specific transform or processor option. ```bash -cd ../../java -mvn -T16 --no-transfer-progress -pl fory-json,fory-annotation-processor -am install -DskipTests -Dmaven.javadoc.skip=true -Dmaven.source.skip=true -cd ../integration_tests/android_tests +python ./ci/run_ci.py kotlin --task install +cd integration_tests/android_tests +gradle --no-daemon verifyKotlinJsonRules gradle --no-daemon -PforyTestBuildType=debug connectedCheck gradle --no-daemon -PforyTestBuildType=release connectedCheck ``` diff --git a/integration_tests/android_tests/build.gradle b/integration_tests/android_tests/build.gradle index be7e568918..12e5f93c83 100644 --- a/integration_tests/android_tests/build.gradle +++ b/integration_tests/android_tests/build.gradle @@ -40,10 +40,14 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:8.13.2' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.20' + classpath 'com.google.devtools.ksp:symbol-processing-gradle-plugin:2.3.8' } } apply plugin: 'com.android.application' +apply plugin: 'org.jetbrains.kotlin.android' +apply plugin: 'com.google.devtools.ksp' def foryTestBuildType = providers.gradleProperty('foryTestBuildType').getOrElse('release') @@ -88,12 +92,21 @@ android { } } +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + dependencies { implementation('org.apache.fory:fory-core:1.7.0-SNAPSHOT') { exclude group: 'com.google.guava', module: 'guava' exclude group: 'org.codehaus.janino', module: 'janino' } implementation 'org.apache.fory:fory-json:1.7.0-SNAPSHOT' + implementation 'org.apache.fory:fory-json-kotlin:1.7.0-SNAPSHOT' + implementation 'org.apache.fory:kotlin-json-corpus:1.7.0-SNAPSHOT' + ksp 'org.apache.fory:fory-json-kotlin-ksp:1.7.0-SNAPSHOT' annotationProcessor 'org.apache.fory:fory-annotation-processor:1.7.0-SNAPSHOT' implementation 'com.google.guava:guava:32.1.2-android' implementation 'org.slf4j:slf4j-api:2.0.12' @@ -101,3 +114,37 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test:runner:1.6.2' } + +def kotlinJsonRuleModels = [ + 'org.apache.fory.android.AndroidKotlinAccount', + 'org.apache.fory.android.AndroidKotlinDefaults', + 'org.apache.fory.android.AndroidKotlinMarker', +] + +tasks.register('verifyKotlinJsonRules') { + dependsOn 'kspDebugKotlin', 'kspReleaseKotlin' + doLast { + ['debug', 'release'].each { variant -> + def ruleDir = layout.buildDirectory + .dir("generated/ksp/${variant}/resources/META-INF/proguard") + .get().asFile + def expected = kotlinJsonRuleModels.collectEntries { model -> + [("fory-json-${model}.pro"): model] + } + def actual = fileTree(ruleDir).matching { include 'fory-json-*.pro' }.files + .collectEntries { rule -> [(rule.name): rule] } + if (actual.keySet() != expected.keySet()) { + throw new GradleException( + "Unexpected Kotlin JSON rules for ${variant}: " + + (actual.keySet() - expected.keySet()) + + "; missing: " + (expected.keySet() - actual.keySet())) + } + expected.each { name, model -> + def exactKeep = "-keep,allowoptimization class ${model}".toString() + if (!actual[name].readLines('UTF-8').contains(exactKeep)) { + throw new GradleException("${actual[name]} does not retain exact model ${model}") + } + } + } + } +} diff --git a/integration_tests/android_tests/proguard-rules.pro b/integration_tests/android_tests/proguard-rules.pro index 94391724f9..1ff1f799e8 100644 --- a/integration_tests/android_tests/proguard-rules.pro +++ b/integration_tests/android_tests/proguard-rules.pro @@ -21,6 +21,17 @@ public static void generatedMixin(); } +# Instrumentation invokes these application entry points across the target/test APK boundary. +# Kotlin model retention is intentionally owned only by KSP's exact consumer rules. +-keep,allowoptimization class org.apache.fory.android.AndroidKotlinJsonScenarios { + public static void generatedModels(); +} + +# The release androidTest APK omits the Kotlin runtime shared with the separately minified target +# APK. Target R8 cannot see AndroidX Test's reachability, so this cross-APK runtime ABI must retain +# both its classes and method descriptors. This harness rule is independent of model retention. +-keep class kotlin.** { *; } + # Equivalent user-authored rules for models that deliberately omit @JsonType. -keepattributes Signature,RuntimeVisibleAnnotations -keepattributes RuntimeVisibleParameterAnnotations,AnnotationDefault,MethodParameters diff --git a/integration_tests/android_tests/src/androidTest/java/org/apache/fory/android/ForyAndroidInstrumentedTest.java b/integration_tests/android_tests/src/androidTest/java/org/apache/fory/android/ForyAndroidInstrumentedTest.java index 7757f5c818..a9fe0c91b6 100644 --- a/integration_tests/android_tests/src/androidTest/java/org/apache/fory/android/ForyAndroidInstrumentedTest.java +++ b/integration_tests/android_tests/src/androidTest/java/org/apache/fory/android/ForyAndroidInstrumentedTest.java @@ -85,6 +85,11 @@ public void generatedJsonMixin() { AndroidJsonScenarios.generatedMixin(); } + @Test + public void generatedKotlinJsonModels() { + AndroidKotlinJsonScenarios.generatedModels(); + } + @Test public void androidRuntimeDisablesCodegenAndUnsafeCopies() { AndroidForyRuntimeScenarios.androidRuntimeDisablesCodegenAndUnsafeCopies(); diff --git a/integration_tests/android_tests/src/main/kotlin/org/apache/fory/android/AndroidKotlinJsonScenarios.kt b/integration_tests/android_tests/src/main/kotlin/org/apache/fory/android/AndroidKotlinJsonScenarios.kt new file mode 100644 index 0000000000..d1ed3034d8 --- /dev/null +++ b/integration_tests/android_tests/src/main/kotlin/org/apache/fory/android/AndroidKotlinJsonScenarios.kt @@ -0,0 +1,116 @@ +/* + * 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.fory.android + +import org.apache.fory.integration.kotlin.json.corpus.KotlinJsonCorpus +import org.apache.fory.integration.kotlin.json.corpus.PlatformAccount +import org.apache.fory.integration.kotlin.json.corpus.PlatformCodecSlotsMixin +import org.apache.fory.integration.kotlin.json.corpus.PlatformCorpusChecks +import org.apache.fory.integration.kotlin.json.corpus.PlatformJavaProfileMixin +import org.apache.fory.integration.kotlin.json.corpus.PlatformJsonModule +import org.apache.fory.integration.kotlin.json.corpus.PlatformKotlinProfileMixin +import org.apache.fory.json.annotation.JsonType +import org.apache.fory.json.kotlin.ForyJsonKotlin +import org.apache.fory.json.kotlin.jsonTypeRef + +@JsonType +internal data class AndroidKotlinAccount( + val id: Int, + val name: String, + val label: String? = "android-default", +) + +@JsonType internal object AndroidKotlinMarker + +@JsonType +internal data class AndroidKotlinDefaults( + val id: Int, + val v0: Int = 0, + val v1: Int = 1, + val v2: Int = 2, + val v3: Int = 3, + val v4: Int = 4, + val v5: Int = 5, + val v6: Int = 6, + val v7: Int = 7, + val v8: Int = 8, + val v9: Int = 9, + val v10: Int = 10, + val v11: Int = 11, + val v12: Int = 12, + val v13: Int = 13, + val v14: Int = 14, + val v15: Int = 15, + val v16: Int = 16, + val v17: Int = 17, + val v18: Int = 18, + val v19: Int = 19, + val v20: Int = 20, + val v21: Int = 21, + val v22: Int = 22, + val v23: Int = 23, + val v24: Int = 24, + val v25: Int = 25, + val v26: Int = 26, + val v27: Int = 27, + val v28: Int = 28, + val v29: Int = 29, + val v30: Int = 30, + val v31: Int = 31, + val v32: Int = 32, +) + +internal object AndroidKotlinJsonScenarios { + @JvmStatic + fun generatedModels() { + val json = + ForyJsonKotlin.builder() + .withModule(PlatformJsonModule) + .registerMixin(PlatformJavaProfileMixin::class.java) + .registerMixin(PlatformKotlinProfileMixin::class.java) + .registerMixin(PlatformCodecSlotsMixin::class.java) + .withAsyncCompilation(false) + .build() + val accountType = jsonTypeRef() + val value = AndroidKotlinAccount(26, "android", null) + check(json.fromJson(json.toJson(value, accountType), accountType) == value) + check( + json.fromJson("{\"id\":27,\"name\":\"default\"}", accountType) == + AndroidKotlinAccount(27, "default") + ) + val defaults = + json.fromJson( + "{\"id\":28,\"v32\":320}", + jsonTypeRef(), + ) + check(defaults.v0 == 0) + check(defaults.v31 == 31) + check(defaults.v32 == 320) + check(json.fromJson("{}", jsonTypeRef()) === AndroidKotlinMarker) + + val corpusAccount = PlatformAccount(30, "library", null) + val corpusType = KotlinJsonCorpus.accountType() + check(json.fromJson(json.toJson(corpusAccount, corpusType), corpusType) == corpusAccount) + PlatformCorpusChecks.verifyPlatformCases(json) + PlatformCorpusChecks.verifyFailureCases(json) + PlatformCorpusChecks.verifyPropertyFailure(json) + } + +} diff --git a/integration_tests/graalvm_kotlin_tests/pom.xml b/integration_tests/graalvm_kotlin_tests/pom.xml new file mode 100644 index 0000000000..a39483032b --- /dev/null +++ b/integration_tests/graalvm_kotlin_tests/pom.xml @@ -0,0 +1,104 @@ + + + + + org.apache.fory + fory-kotlin-parent + 1.7.0-SNAPSHOT + ../../kotlin + + 4.0.0 + graalvm-kotlin-tests + Fory Kotlin JSON GraalVM Integration Tests + + + true + org.apache.fory.graalvm.kotlin.Main + true + 0.9.28 + + + + + org.apache.fory + fory-json-kotlin + ${project.version} + + + org.apache.fory + kotlin-json-corpus + ${project.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.graalvm.buildtools + native-maven-plugin + ${native.maven.plugin.version} + true + + + build-native + package + + compile-no-fork + + + + + false + main + ${mainClass} + + + + + + + + native-jdk25 + + [25,) + + + + + org.graalvm.buildtools + native-maven-plugin + + + -J--sun-misc-unsafe-memory-access=deny + -J--add-opens=java.base/java.lang.invoke=ALL-UNNAMED + + + + + + + + diff --git a/integration_tests/graalvm_kotlin_tests/src/main/java/org/apache/fory/graalvm/kotlin/CodegenDisabledMain.java b/integration_tests/graalvm_kotlin_tests/src/main/java/org/apache/fory/graalvm/kotlin/CodegenDisabledMain.java new file mode 100644 index 0000000000..efbf306aaa --- /dev/null +++ b/integration_tests/graalvm_kotlin_tests/src/main/java/org/apache/fory/graalvm/kotlin/CodegenDisabledMain.java @@ -0,0 +1,34 @@ +/* + * 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.fory.graalvm.kotlin; + +import org.apache.fory.json.ForyJson; + +/** Makes the invalid provider reachable so Native Image analysis must reject it. */ +public final class CodegenDisabledMain { + private CodegenDisabledMain() {} + + public static void main(String[] args) { + ForyJson json = new CodegenDisabledProvider().invalidConfiguration(); + if (json == null) { + throw new AssertionError("Codegen-disabled provider returned null"); + } + } +} diff --git a/integration_tests/graalvm_kotlin_tests/src/main/java/org/apache/fory/graalvm/kotlin/CodegenDisabledProvider.java b/integration_tests/graalvm_kotlin_tests/src/main/java/org/apache/fory/graalvm/kotlin/CodegenDisabledProvider.java new file mode 100644 index 0000000000..69abb967a3 --- /dev/null +++ b/integration_tests/graalvm_kotlin_tests/src/main/java/org/apache/fory/graalvm/kotlin/CodegenDisabledProvider.java @@ -0,0 +1,34 @@ +/* + * 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.fory.graalvm.kotlin; + +import org.apache.fory.json.ForyJson; +import org.apache.fory.json.annotation.ForyJsonProvider; +import org.apache.fory.json.kotlin.ForyJsonKotlin; + +/** Invalid Native Image provider used only by the expected analysis-failure build. */ +@ForyJsonProvider +public final class CodegenDisabledProvider { + public CodegenDisabledProvider() {} + + public ForyJson invalidConfiguration() { + return ForyJsonKotlin.builder().withCodegen(false).build(); + } +} diff --git a/integration_tests/graalvm_kotlin_tests/src/main/java/org/apache/fory/graalvm/kotlin/InvalidPropertyMain.java b/integration_tests/graalvm_kotlin_tests/src/main/java/org/apache/fory/graalvm/kotlin/InvalidPropertyMain.java new file mode 100644 index 0000000000..a84257d010 --- /dev/null +++ b/integration_tests/graalvm_kotlin_tests/src/main/java/org/apache/fory/graalvm/kotlin/InvalidPropertyMain.java @@ -0,0 +1,43 @@ +/* + * 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.fory.graalvm.kotlin; + +import org.apache.fory.integration.kotlin.json.corpus.PlatformCorpusChecks; +import org.apache.fory.integration.kotlin.json.corpus.PlatformJsonModule; +import org.apache.fory.json.ForyJson; +import org.apache.fory.json.annotation.ForyJsonProvider; +import org.apache.fory.json.kotlin.ForyJsonKotlin; + +/** Makes a PROPERTY scalar branch reachable so Native Image analysis must reject it. */ +public final class InvalidPropertyMain { + private InvalidPropertyMain() {} + + public static void main(String[] args) { + if (!KotlinJsonProvider.class.isAnnotationPresent(ForyJsonProvider.class)) { + throw new AssertionError("Kotlin JSON provider is not reachable"); + } + ForyJson json = + ForyJsonKotlin.builder() + .withModule(PlatformJsonModule.INSTANCE) + .withAsyncCompilation(false) + .build(); + PlatformCorpusChecks.verifyPropertyFailure(json); + } +} diff --git a/integration_tests/graalvm_kotlin_tests/src/main/java/org/apache/fory/graalvm/kotlin/KotlinJsonProvider.java b/integration_tests/graalvm_kotlin_tests/src/main/java/org/apache/fory/graalvm/kotlin/KotlinJsonProvider.java new file mode 100644 index 0000000000..30c47c6026 --- /dev/null +++ b/integration_tests/graalvm_kotlin_tests/src/main/java/org/apache/fory/graalvm/kotlin/KotlinJsonProvider.java @@ -0,0 +1,44 @@ +/* + * 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.fory.graalvm.kotlin; + +import org.apache.fory.integration.kotlin.json.corpus.PlatformCodecSlotsMixin; +import org.apache.fory.integration.kotlin.json.corpus.PlatformJavaProfileMixin; +import org.apache.fory.integration.kotlin.json.corpus.PlatformJsonModule; +import org.apache.fory.integration.kotlin.json.corpus.PlatformKotlinProfileMixin; +import org.apache.fory.json.ForyJson; +import org.apache.fory.json.annotation.ForyJsonProvider; +import org.apache.fory.json.kotlin.ForyJsonKotlin; + +/** Selects the Kotlin-enabled generated configuration during Native Image analysis. */ +@ForyJsonProvider +public final class KotlinJsonProvider { + public KotlinJsonProvider() {} + + public ForyJson generatedConfiguration() { + return ForyJsonKotlin.builder() + .withModule(PlatformJsonModule.INSTANCE) + .registerMixin(PlatformCodecSlotsMixin.class) + .registerMixin(PlatformJavaProfileMixin.class) + .registerMixin(PlatformKotlinProfileMixin.class) + .withAsyncCompilation(false) + .build(); + } +} diff --git a/integration_tests/graalvm_kotlin_tests/src/main/java/org/apache/fory/graalvm/kotlin/Main.java b/integration_tests/graalvm_kotlin_tests/src/main/java/org/apache/fory/graalvm/kotlin/Main.java new file mode 100644 index 0000000000..b09afaf63c --- /dev/null +++ b/integration_tests/graalvm_kotlin_tests/src/main/java/org/apache/fory/graalvm/kotlin/Main.java @@ -0,0 +1,142 @@ +/* + * 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.fory.graalvm.kotlin; + +import java.util.List; +import org.apache.fory.integration.kotlin.json.corpus.KotlinJsonCorpus; +import org.apache.fory.integration.kotlin.json.corpus.PlatformAccount; +import org.apache.fory.integration.kotlin.json.corpus.PlatformCodecSlotsMixin; +import org.apache.fory.integration.kotlin.json.corpus.PlatformCorpusChecks; +import org.apache.fory.integration.kotlin.json.corpus.PlatformJavaProfileMixin; +import org.apache.fory.integration.kotlin.json.corpus.PlatformJsonModule; +import org.apache.fory.integration.kotlin.json.corpus.PlatformKotlinProfileMixin; +import org.apache.fory.integration.kotlin.json.corpus.PlatformShapeMarker; +import org.apache.fory.json.ForyJson; +import org.apache.fory.json.ForyJsonException; +import org.apache.fory.json.annotation.ForyJsonProvider; +import org.apache.fory.json.kotlin.ForyJsonKotlin; +import org.apache.fory.reflect.TypeRef; + +/** Native Image acceptance application for provider-selected Kotlin JSON capabilities. */ +public final class Main { + private static final TypeRef> UNREACHED_LIST_TYPE = new TypeRef>() {}; + + private Main() {} + + public static void main(String[] args) { + check( + KotlinJsonProvider.class.isAnnotationPresent(ForyJsonProvider.class), + "Native configuration provider is not reachable"); + + ForyJson json = + ForyJsonKotlin.builder() + .withModule(PlatformJsonModule.INSTANCE) + .registerMixin(PlatformCodecSlotsMixin.class) + .registerMixin(PlatformJavaProfileMixin.class) + .registerMixin(PlatformKotlinProfileMixin.class) + .withAsyncCompilation(false) + .build(); + PlatformCorpusChecks.verifyPlatformCases(json); + PlatformCorpusChecks.verifyFailureCases(json); + testUnavailableCapabilities(json); + System.out.println("Fory Kotlin JSON Native Image succeed"); + } + + private static void testUnavailableCapabilities(ForyJson selected) { + expectMissingCapability( + () -> + selected.fromJson( + KotlinJsonCorpus.caseJson("rejected-box"), KotlinJsonCorpus.unreachedBoxType()), + "An unreached exact generic binding was accepted"); + expectMissingCapability( + () -> selected.fromJson("not-json", UNREACHED_LIST_TYPE), + "An unreached exact collection binding parsed input"); + + ForyJson withoutKotlin = ForyJson.builder().withAsyncCompilation(false).build(); + expectFailure( + () -> + withoutKotlin.fromJson( + KotlinJsonCorpus.caseJson("account-default"), KotlinJsonCorpus.accountType()), + "A configuration without the Kotlin module accepted an immutable Kotlin model"); + + ForyJson unselected = + ForyJsonKotlin.builder() + .withModule(PlatformJsonModule.INSTANCE) + .withAsyncCompilation(false) + .withFieldMode(true) + .build(); + expectMissingLanguageModel( + () -> unselected.fromJson("not-json", KotlinJsonCorpus.accountType()), + "A configuration not selected by the provider parsed an ordinary Kotlin model"); + expectMissingLanguageModel( + () -> unselected.fromJson("not-json", PlatformShapeMarker.class), + "A configuration not selected by the provider parsed a fixed Kotlin model"); + + PlatformAccount account = + selected.fromJson( + KotlinJsonCorpus.caseJson("account-default"), KotlinJsonCorpus.accountType()); + check( + account.equals(new PlatformAccount(1, "default", "corpus-default")), + "A failed Native capability lookup polluted the selected configuration"); + } + + private static void expectFailure(Runnable operation, String message) { + try { + operation.run(); + } catch (ForyJsonException expected) { + return; + } + throw new AssertionError(message); + } + + private static void expectMissingCapability(Runnable operation, String message) { + try { + operation.run(); + } catch (ForyJsonException expected) { + check( + expected.getMessage().startsWith("Missing generated Fory JSON class for exact type "), + "Unexpected Native capability failure: " + expected.getMessage()); + return; + } + throw new AssertionError(message); + } + + private static void expectMissingLanguageModel(Runnable operation, String message) { + try { + operation.run(); + } catch (ForyJsonException expected) { + check( + expected + .getMessage() + .startsWith( + "Missing provider-selected Fory JSON Native configuration for language object" + + " model "), + "Unexpected Native language-model failure: " + expected.getMessage()); + return; + } + throw new AssertionError(message); + } + + private static void check(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } +} diff --git a/integration_tests/graalvm_kotlin_tests/src/main/resources/META-INF/native-image/org.apache.fory/graalvm-kotlin-tests/native-image.properties b/integration_tests/graalvm_kotlin_tests/src/main/resources/META-INF/native-image/org.apache.fory/graalvm-kotlin-tests/native-image.properties new file mode 100644 index 0000000000..8f730d7de1 --- /dev/null +++ b/integration_tests/graalvm_kotlin_tests/src/main/resources/META-INF/native-image/org.apache.fory/graalvm-kotlin-tests/native-image.properties @@ -0,0 +1,18 @@ +# 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. + +Args=-H:+ReportExceptionStackTraces -H:IncludeResources=org/apache/fory/integration/kotlin/json/corpus/.*[.]json diff --git a/integration_tests/grpc_tests/kotlin/pom.xml b/integration_tests/grpc_tests/kotlin/pom.xml index 28f50a5d76..4b6d943e85 100644 --- a/integration_tests/grpc_tests/kotlin/pom.xml +++ b/integration_tests/grpc_tests/kotlin/pom.xml @@ -33,6 +33,7 @@ fory-kotlin-grpc-tests + true 1.75.0 1.4.3 1.8.0 diff --git a/integration_tests/idl_tests/kotlin/pom.xml b/integration_tests/idl_tests/kotlin/pom.xml index ebed79de42..f407390e2f 100644 --- a/integration_tests/idl_tests/kotlin/pom.xml +++ b/integration_tests/idl_tests/kotlin/pom.xml @@ -32,6 +32,10 @@ 4.0.0 fory-kotlin-idl-tests + + true + + diff --git a/integration_tests/jpms_tests/pom.xml b/integration_tests/jpms_tests/pom.xml index a3608c023a..a40aedb024 100644 --- a/integration_tests/jpms_tests/pom.xml +++ b/integration_tests/jpms_tests/pom.xml @@ -50,6 +50,21 @@ fory-format ${project.version} + + org.apache.fory + fory-json + ${project.version} + + + org.apache.fory + fory-json-kotlin + ${project.version} + + + org.apache.fory + kotlin-json-corpus + ${project.version} + org.apache.fory fory-test-core diff --git a/integration_tests/jpms_tests/src/main/java/module-info.java b/integration_tests/jpms_tests/src/main/java/module-info.java index be2ec6f58c..984457430e 100644 --- a/integration_tests/jpms_tests/src/main/java/module-info.java +++ b/integration_tests/jpms_tests/src/main/java/module-info.java @@ -20,6 +20,9 @@ module org.apache.fory.integration_tests { requires org.apache.fory.core; requires org.apache.fory.format; + requires org.apache.fory.json; + requires org.apache.fory.json.kotlin; + requires org.apache.fory.integration.kotlin.json.corpus; requires org.apache.fory.test.core; // we can't really test any classes from this module because it only contains test-classes diff --git a/integration_tests/jpms_tests/src/main/java/org/apache/fory/integration_tests/Test.java b/integration_tests/jpms_tests/src/main/java/org/apache/fory/integration_tests/Test.java index cd57946ea8..643bab4c6e 100644 --- a/integration_tests/jpms_tests/src/main/java/org/apache/fory/integration_tests/Test.java +++ b/integration_tests/jpms_tests/src/main/java/org/apache/fory/integration_tests/Test.java @@ -21,6 +21,11 @@ import org.apache.fory.Fory; import org.apache.fory.format.encoder.Encoders; +import org.apache.fory.integration.kotlin.json.corpus.KotlinJsonCorpus; +import org.apache.fory.integration.kotlin.json.corpus.PlatformAccount; +import org.apache.fory.json.ForyJson; +import org.apache.fory.json.kotlin.ForyJsonKotlin; +import org.apache.fory.reflect.TypeRef; import org.apache.fory.test.bean.Foo; /** @@ -34,5 +39,24 @@ public static void main(String[] args) { fory.serialize(Foo.create()); Encoders.bean(Foo.class, fory); + + verifyKotlinJson(); + } + + static void verifyKotlinJson() { + ForyJson json = ForyJsonKotlin.builder().withAsyncCompilation(false).build(); + TypeRef accountType = KotlinJsonCorpus.accountType(); + PlatformAccount value = new PlatformAccount(37, "jpms", "default-label"); + PlatformAccount decoded = json.fromJson(json.toJson(value, accountType), accountType); + if (!value.equals(decoded)) { + throw new AssertionError("Kotlin JSON JPMS round trip failed: " + decoded); + } + PlatformAccount defaulted = + json.fromJson(KotlinJsonCorpus.caseJson("account-default"), accountType); + if (defaulted.getId() != 1 + || !"default".equals(defaulted.getName()) + || !"corpus-default".equals(defaulted.getLabel())) { + throw new AssertionError("Kotlin JSON JPMS default constructor bridge was not used"); + } } } diff --git a/integration_tests/jpms_tests/src/test/java/org/apache/fory/integration_tests/JpmsFieldAccessorTest.java b/integration_tests/jpms_tests/src/test/java/org/apache/fory/integration_tests/JpmsFieldAccessorTest.java index 3f81ea9c70..141f062c31 100644 --- a/integration_tests/jpms_tests/src/test/java/org/apache/fory/integration_tests/JpmsFieldAccessorTest.java +++ b/integration_tests/jpms_tests/src/test/java/org/apache/fory/integration_tests/JpmsFieldAccessorTest.java @@ -40,6 +40,11 @@ public class JpmsFieldAccessorTest { private static final int JDK_MAJOR_VERSION = Runtime.version().feature(); private static final String VAR_HANDLE = "java.lang.invoke.VarHandle"; + @Test + public void testModuleConsumer() { + org.apache.fory.integration_tests.Test.verifyKotlinJson(); + } + @Test public void testPrivateFieldAccess() throws Exception { PrivateFieldBean bean = new PrivateFieldBean(7); diff --git a/integration_tests/kotlin_json_corpus/pom.xml b/integration_tests/kotlin_json_corpus/pom.xml new file mode 100644 index 0000000000..246984289b --- /dev/null +++ b/integration_tests/kotlin_json_corpus/pom.xml @@ -0,0 +1,179 @@ + + + + + org.apache.fory + fory-kotlin-parent + 1.7.0-SNAPSHOT + ../../kotlin + + 4.0.0 + kotlin-json-corpus + Fory Kotlin JSON Integration Corpus + + + true + true + + + + + + src/main/resources + + + ${project.build.directory}/generated-resources/ksp + + META-INF/proguard/** + + + + + + me.kpavlov.ksp.maven + ksp-maven-plugin + ${ksp.maven.plugin.version} + + + ksp + generate-sources + + process + + + + + ${project.basedir}/src/main/kotlin + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + + org.apache.fory + fory-json-kotlin-ksp + ${project.version} + + + + + + org.apache.fory + fory-json-kotlin-ksp + ${project.version} + + + com.google.devtools.ksp + symbol-processing-aa-embeddable + ${ksp.version} + + + com.google.devtools.ksp + symbol-processing-api + ${ksp.version} + + + com.google.devtools.ksp + symbol-processing-common-deps + ${ksp.version} + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + + + compile + process-sources + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + -Xexplicit-api=strict + + + + + test-compile + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + org.apache.fory.integration.kotlin.json.corpus + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + + + + + org.apache.fory + fory-json-kotlin + ${project.version} + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-test-testng + ${kotlin.version} + test + + + diff --git a/integration_tests/kotlin_json_corpus/src/main/java/org/apache/fory/integration/kotlin/json/corpus/PlatformJavaProfile.java b/integration_tests/kotlin_json_corpus/src/main/java/org/apache/fory/integration/kotlin/json/corpus/PlatformJavaProfile.java new file mode 100644 index 0000000000..9efae38b09 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/java/org/apache/fory/integration/kotlin/json/corpus/PlatformJavaProfile.java @@ -0,0 +1,39 @@ +/* + * 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.fory.integration.kotlin.json.corpus; + +/** Java model targeted by a Kotlin-authored JSON Mixin. */ +public final class PlatformJavaProfile { + private String label; + + public PlatformJavaProfile() {} + + public PlatformJavaProfile(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } +} diff --git a/integration_tests/kotlin_json_corpus/src/main/java/org/apache/fory/integration/kotlin/json/corpus/PlatformKotlinProfileMixin.java b/integration_tests/kotlin_json_corpus/src/main/java/org/apache/fory/integration/kotlin/json/corpus/PlatformKotlinProfileMixin.java new file mode 100644 index 0000000000..ecbbccbca2 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/java/org/apache/fory/integration/kotlin/json/corpus/PlatformKotlinProfileMixin.java @@ -0,0 +1,30 @@ +/* + * 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.fory.integration.kotlin.json.corpus; + +import org.apache.fory.json.annotation.JsonMixin; +import org.apache.fory.json.annotation.JsonProperty; + +/** Java-authored JSON Mixin targeting a Kotlin data class. */ +@JsonMixin(target = PlatformKotlinProfile.class) +public abstract class PlatformKotlinProfileMixin { + @JsonProperty("display_label") + public abstract String getLabel(); +} diff --git a/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/KotlinJsonCorpus.kt b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/KotlinJsonCorpus.kt new file mode 100644 index 0000000000..8d0cbcab0d --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/KotlinJsonCorpus.kt @@ -0,0 +1,78 @@ +/* + * 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.fory.integration.kotlin.json.corpus + +import java.nio.charset.StandardCharsets +import org.apache.fory.json.ForyJson +import org.apache.fory.json.kotlin.jsonTypeRef +import org.apache.fory.reflect.TypeRef + +/** Java-friendly exact structural type tokens shared by every platform fixture. */ +public object KotlinJsonCorpus { + private const val RESOURCE_ROOT: String = "/org/apache/fory/integration/kotlin/json/corpus/" + + @JvmStatic public fun accountType(): TypeRef = jsonTypeRef() + + @JvmStatic public fun envelopeType(): TypeRef = jsonTypeRef() + + @JvmStatic public fun envelopeValue(): PlatformEnvelope = platformRootValue().envelope + + @JvmStatic public fun boxType(): TypeRef> = jsonTypeRef() + + @JvmStatic public fun unreachedBoxType(): TypeRef> = jsonTypeRef() + + @JvmStatic public fun nodeType(): TypeRef> = jsonTypeRef() + + @JvmStatic public fun rootType(): TypeRef = jsonTypeRef() + + @JvmStatic public fun rootValue(): PlatformRoot = platformRootValue() + + @JvmStatic public fun tokenType(): TypeRef = jsonTypeRef() + + @JvmStatic public fun builtinsType(): TypeRef = jsonTypeRef() + + @JvmStatic public fun valueHolderType(): TypeRef = jsonTypeRef() + + @JvmStatic public fun propertyShapeType(): TypeRef = jsonTypeRef() + + @JvmStatic public fun wrappedShapeType(): TypeRef = jsonTypeRef() + + @JvmStatic + public fun invalidPropertyShapeType(): TypeRef = jsonTypeRef() + + @JvmStatic public fun manifestType(): TypeRef = jsonTypeRef() + + @JvmStatic public fun nullableUnitType(): TypeRef = jsonTypeRef() + + @JvmStatic public fun nullableNothingType(): TypeRef = jsonTypeRef() + + @JvmStatic public fun caseJson(id: String): String = resourceText("cases/$id.json") + + @JvmStatic + public fun manifest(json: ForyJson): PlatformCaseManifest = + json.fromJson(resourceText("cases.json"), manifestType()) + + private fun resourceText(path: String): String { + val stream = + KotlinJsonCorpus::class.java.getResourceAsStream(RESOURCE_ROOT + path) + ?: error("Missing Kotlin JSON corpus resource: $path") + return stream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } + } +} diff --git a/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformBuiltins.kt b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformBuiltins.kt new file mode 100644 index 0000000000..8de76b02bb --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformBuiltins.kt @@ -0,0 +1,104 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalUnsignedTypes::class, kotlin.uuid.ExperimentalUuidApi::class) + +package org.apache.fory.integration.kotlin.json.corpus + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import kotlin.time.TimedValue +import kotlin.uuid.Uuid +import org.apache.fory.json.annotation.JsonType + +@JsonType +public data class PlatformBuiltins( + public val pair: Pair, + public val triple: Triple, + public val ubyte: UByte, + public val ushort: UShort, + public val uint: UInt, + public val ulong: ULong, + public val ubytes: UByteArray, + public val ushorts: UShortArray, + public val uints: UIntArray, + public val ulongs: ULongArray, + public val ubyteKeys: Map, + public val ushortKeys: Map, + public val uintKeys: Map, + public val ulongKeys: Map, + public val zeroDuration: Duration, + public val negativeDuration: Duration, + public val duration: Duration, + public val infiniteDuration: Duration, + public val negativeInfiniteDuration: Duration, + public val instant: Instant, + public val nanosInstant: Instant, + public val minInstant: Instant, + public val maxInstant: Instant, + public val uuid: Uuid, + public val unit: Unit, + public val nullableUnit: Unit?, + public val nothing: Nothing?, + public val intRange: IntRange, + public val uintRange: UIntRange, + public val intProgression: IntProgression, + public val ulongProgression: ULongProgression, + public val timed: TimedValue, +) + +internal fun platformBuiltinsValue(): PlatformBuiltins = + PlatformBuiltins( + pair = Pair(null, "r"), + triple = Triple(1, "two", true), + ubyte = UByte.MAX_VALUE, + ushort = UShort.MAX_VALUE, + uint = UInt.MAX_VALUE, + ulong = ULong.MAX_VALUE, + ubytes = ubyteArrayOf(0u, UByte.MAX_VALUE), + ushorts = ushortArrayOf(0u, UShort.MAX_VALUE), + uints = uintArrayOf(0u, UInt.MAX_VALUE), + ulongs = ulongArrayOf(0u, ULong.MAX_VALUE), + ubyteKeys = linkedMapOf(0.toUByte() to "zero", UByte.MAX_VALUE to "max"), + ushortKeys = linkedMapOf(0.toUShort() to "zero", UShort.MAX_VALUE to "max"), + uintKeys = linkedMapOf(0u to "zero", UInt.MAX_VALUE to "max"), + ulongKeys = linkedMapOf(0uL to "zero", ULong.MAX_VALUE to "max"), + zeroDuration = Duration.ZERO, + negativeDuration = -1.nanoseconds, + duration = 49.hours + 2.minutes + 3.seconds + 456_789.nanoseconds, + infiniteDuration = Duration.INFINITE, + negativeInfiniteDuration = -Duration.INFINITE, + instant = Instant.fromEpochSeconds(0), + nanosInstant = Instant.fromEpochSeconds(-1, 1), + minInstant = Instant.fromEpochSeconds(-31_557_014_167_219_200L), + maxInstant = Instant.fromEpochSeconds(31_556_889_864_403_199L, 999_999_999), + uuid = Uuid.fromLongs(0x0011223344556677L, 0x8899aabbccddeeffuL.toLong()), + unit = Unit, + nullableUnit = null, + nothing = null, + intRange = -4..9, + uintRange = 0u..UInt.MAX_VALUE, + intProgression = IntProgression.fromClosedRange(20, -10, -3), + ulongProgression = ULongProgression.fromClosedRange(20uL, 1uL, -3), + timed = TimedValue("v", 1.nanoseconds), + ) diff --git a/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformCodecAnnotations.kt b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformCodecAnnotations.kt new file mode 100644 index 0000000000..f678013e76 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformCodecAnnotations.kt @@ -0,0 +1,112 @@ +/* + * 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.fory.integration.kotlin.json.corpus + +import java.util.Optional +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.annotation.JsonCodec +import org.apache.fory.json.annotation.JsonMixin +import org.apache.fory.json.annotation.JsonType +import org.apache.fory.json.codec.AbstractJsonValueCodec +import org.apache.fory.json.codec.MapKeyCodec +import org.apache.fory.json.reader.JsonReader +import org.apache.fory.json.writer.JsonWriter + +public class PlatformWholeStringCodec : AbstractJsonValueCodec() { + override fun write(writer: JsonWriter, value: String?): Unit = + writeTagged(writer, "whole:", value) + + override fun read(reader: JsonReader): String? = readTagged(reader, "whole:") +} + +public class PlatformElementStringCodec : AbstractJsonValueCodec() { + override fun write(writer: JsonWriter, value: String?): Unit = + writeTagged(writer, "element:", value) + + override fun read(reader: JsonReader): String? = readTagged(reader, "element:") +} + +public class PlatformContentStringCodec : AbstractJsonValueCodec() { + override fun write(writer: JsonWriter, value: String?): Unit = + writeTagged(writer, "content:", value) + + override fun read(reader: JsonReader): String? = readTagged(reader, "content:") +} + +public class PlatformMapValueStringCodec : AbstractJsonValueCodec() { + override fun write(writer: JsonWriter, value: String?): Unit = + writeTagged(writer, "value:", value) + + override fun read(reader: JsonReader): String? = readTagged(reader, "value:") +} + +public class PlatformIntKeyCodec : MapKeyCodec { + override fun toName(key: Any): String = "key:${key as Int}" + + override fun fromName(name: String): Any { + if (!name.startsWith("key:")) { + throw ForyJsonException("Expected a tagged platform integer key") + } + return name.substring(4).toInt() + } +} + +@JsonType +public data class PlatformCodecSlots( + @field:JsonCodec(value = PlatformWholeStringCodec::class) public val scalar: String, + @field:JsonCodec(elementCodec = PlatformElementStringCodec::class) + public val elements: List, + @field:JsonCodec(contentCodec = PlatformContentStringCodec::class) + public val content: Optional, + @field:JsonCodec( + keyCodec = PlatformIntKeyCodec::class, + valueCodec = PlatformMapValueStringCodec::class, + ) + public val entries: Map, +) + +@JsonMixin(target = PlatformCodecSlots::class) +public abstract class PlatformCodecSlotsMixin { + @get:JsonCodec(value = PlatformWholeStringCodec::class) public abstract val scalar: String + + @get:JsonCodec(elementCodec = PlatformElementStringCodec::class) + public abstract val elements: List + + @get:JsonCodec(contentCodec = PlatformContentStringCodec::class) + public abstract val content: Optional + + @get:JsonCodec( + keyCodec = PlatformIntKeyCodec::class, + valueCodec = PlatformMapValueStringCodec::class, + ) + public abstract val entries: Map +} + +private fun writeTagged(writer: JsonWriter, prefix: String, value: String?) { + if (value == null) writer.writeNull() else writer.writeString(prefix + value) +} + +private fun readTagged(reader: JsonReader, prefix: String): String? { + val value = reader.readString() ?: return null + if (!value.startsWith(prefix)) { + throw ForyJsonException("Expected a $prefix platform string") + } + return value.substring(prefix.length) +} diff --git a/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformCorpusChecks.kt b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformCorpusChecks.kt new file mode 100644 index 0000000000..d5d7bd9029 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformCorpusChecks.kt @@ -0,0 +1,302 @@ +/* + * 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. + */ + +@file:OptIn( + ExperimentalUnsignedTypes::class, + kotlin.time.ExperimentalTime::class, + kotlin.uuid.ExperimentalUuidApi::class, +) + +package org.apache.fory.integration.kotlin.json.corpus + +import kotlin.coroutines.Continuation +import kotlin.coroutines.CoroutineContext +import kotlin.enums.EnumEntries +import kotlin.random.Random +import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.time.Clock +import kotlin.time.ComparableTimeMark +import kotlin.time.TimeMark +import kotlin.time.TimeSource +import kotlin.time.TimedValue +import org.apache.fory.json.ForyJson +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.kotlin.jsonTypeRef + +private enum class PlatformEntry { + VALUE, +} + +/** Executes the shared success and rejection vectors against one platform configuration. */ +public object PlatformCorpusChecks { + @JvmStatic + public fun verifyPlatformCases(json: ForyJson) { + val root = + json.fromJson( + KotlinJsonCorpus.caseJson("root"), + KotlinJsonCorpus.rootType(), + ) + verifyRoot(root) + verifyRoot( + json.fromJson( + json.toJson(root, KotlinJsonCorpus.rootType()), + KotlinJsonCorpus.rootType(), + ) + ) + + check( + json.fromJson( + KotlinJsonCorpus.caseJson("account-default"), + KotlinJsonCorpus.accountType(), + ) == PlatformAccount(1, "default") + ) + check( + json.fromJson( + KotlinJsonCorpus.caseJson("generic-envelope"), + KotlinJsonCorpus.envelopeType(), + ) == + PlatformEnvelope( + PlatformAccount(2, "nested"), + listOf("a"), + listOf(PlatformBox("b")), + UInt.MAX_VALUE, + ) + ) + check( + json.fromJson( + KotlinJsonCorpus.caseJson("recursive-generic"), + KotlinJsonCorpus.nodeType(), + ) == PlatformNode("root", listOf(PlatformNode("leaf"))) + ) + check( + json.fromJson( + json.toJson(PlatformBox("direct-root"), KotlinJsonCorpus.boxType()), + KotlinJsonCorpus.boxType(), + ) == PlatformBox("direct-root") + ) + check( + json.fromJson( + KotlinJsonCorpus.caseJson("custom-module"), + KotlinJsonCorpus.tokenType(), + ) == PlatformToken("module-token") + ) + check( + json.fromJson( + KotlinJsonCorpus.caseJson("singleton"), + jsonTypeRef(), + ) === PlatformMarker + ) + check( + json.fromJson( + KotlinJsonCorpus.caseJson("property-marker"), + KotlinJsonCorpus.propertyShapeType(), + ) === PlatformShapeMarker + ) + check( + json.fromJson( + KotlinJsonCorpus.caseJson("wrapped-data"), + KotlinJsonCorpus.wrappedShapeType(), + ) == PlatformWrappedData("wrapped") + ) + check( + json.fromJson( + KotlinJsonCorpus.caseJson("wrapped-marker"), + KotlinJsonCorpus.wrappedShapeType(), + ) === PlatformWrappedMarker + ) + check( + json.fromJson( + KotlinJsonCorpus.caseJson("unit"), + KotlinJsonCorpus.nullableUnitType(), + ) === Unit + ) + check( + json.fromJson( + KotlinJsonCorpus.caseJson("null"), + KotlinJsonCorpus.nullableUnitType(), + ) == null + ) + json.fromJson( + KotlinJsonCorpus.caseJson("null"), + KotlinJsonCorpus.nullableNothingType(), + ) + verifyMixinCases(json) + + val manifest = KotlinJsonCorpus.manifest(json) + check(manifest.schemaVersion == 1) + check(manifest.cases.map { it.id }.toSet() == expectedCases) + } + + @JvmStatic + public fun verifyFailureCases(json: ForyJson) { + expectFailure { json.fromJson("{}", Pair::class.java) } + expectFailure { json.fromJson("{}", Triple::class.java) } + expectFailure { json.fromJson("{}", TimedValue::class.java) } + expectFailure { json.fromJson("0", jsonTypeRef()) } + expectFailure { json.fromJson("-1", jsonTypeRef()) } + expectFailure { json.fromJson("null", jsonTypeRef()) } + expectFailure { json.fromJson("{\"kind\":\"unknown\"}", KotlinJsonCorpus.propertyShapeType()) } + expectFailure { json.toJson(PlatformUnlistedShape(1), KotlinJsonCorpus.propertyShapeType()) } + expectFailure { json.fromJson("{}", jsonTypeRef()) } + expectFailure { json.fromJson("{}", jsonTypeRef()) } + expectFailure { json.fromJson("{}", jsonTypeRef()) } + expectFailure { json.fromJson("{}", jsonTypeRef()) } + expectFailure { json.fromJson("{}", jsonTypeRef<() -> String>()) } + expectFailure { json.fromJson("{}", jsonTypeRef>()) } + expectFailure { json.fromJson("{}", jsonTypeRef()) } + expectFailure { json.fromJson("{}", jsonTypeRef>()) } + expectFailure { json.fromJson("{}", jsonTypeRef()) } + expectFailure { json.fromJson("{}", jsonTypeRef>()) } + expectFailure { json.fromJson("{}", jsonTypeRef>()) } + expectFailure { json.fromJson("{}", jsonTypeRef>()) } + expectFailure { json.fromJson("{}", jsonTypeRef>()) } + expectFailure { json.fromJson("{}", jsonTypeRef>()) } + expectFailure { json.fromJson("{}", jsonTypeRef>()) } + expectFailure { json.fromJson("{}", jsonTypeRef()) } + expectFailure { json.fromJson("{}", jsonTypeRef()) } + expectFailure { json.fromJson("{}", jsonTypeRef>()) } + expectFailure { json.fromJson("{}", jsonTypeRef>()) } + expectFailure { json.fromJson("{}", jsonTypeRef()) } + expectFailure { json.fromJson("{}", jsonTypeRef()) } + expectFailure { json.fromJson("{}", jsonTypeRef()) } + expectFailure { json.fromJson("{}", jsonTypeRef()) } + + check( + json.fromJson( + KotlinJsonCorpus.caseJson("account-default"), + KotlinJsonCorpus.accountType(), + ) == PlatformAccount(1, "default") + ) + } + + @JvmStatic + public fun verifyPropertyFailure(json: ForyJson) { + expectFailure { + json.fromJson("{\"kind\":\"number\"}", KotlinJsonCorpus.invalidPropertyShapeType()) + } + check( + json.fromJson( + KotlinJsonCorpus.caseJson("account-default"), + KotlinJsonCorpus.accountType(), + ) == PlatformAccount(1, "default") + ) + } + + private fun verifyRoot(actual: PlatformRoot) { + val expected = KotlinJsonCorpus.rootValue() + check(actual.account == expected.account) + check(actual.ordinary.id == expected.ordinary.id) + check(actual.ordinary.name == expected.ordinary.name) + check(actual.envelope == expected.envelope) + check(actual.node == expected.node) + verifyBuiltins(actual.builtins, expected.builtins) + check(actual.value == expected.value) + check(actual.unitHolder == expected.unitHolder) + check(actual.propertyShape == expected.propertyShape) + check(actual.wrappedShape == expected.wrappedShape) + check(actual.annotated == expected.annotated) + check(actual.codecSlots == expected.codecSlots) + check(actual.nulls == expected.nulls) + check(actual.token == expected.token) + } + + private fun verifyBuiltins(actual: PlatformBuiltins, expected: PlatformBuiltins) { + check(actual.pair == expected.pair) + check(actual.triple == expected.triple) + check(actual.ubyte == expected.ubyte) + check(actual.ushort == expected.ushort) + check(actual.uint == expected.uint) + check(actual.ulong == expected.ulong) + check(actual.ubytes.contentEquals(expected.ubytes)) + check(actual.ushorts.contentEquals(expected.ushorts)) + check(actual.uints.contentEquals(expected.uints)) + check(actual.ulongs.contentEquals(expected.ulongs)) + check(actual.ubyteKeys == expected.ubyteKeys) + check(actual.ushortKeys == expected.ushortKeys) + check(actual.uintKeys == expected.uintKeys) + check(actual.ulongKeys == expected.ulongKeys) + check(actual.zeroDuration == expected.zeroDuration) + check(actual.negativeDuration == expected.negativeDuration) + check(actual.duration == expected.duration) + check(actual.infiniteDuration == expected.infiniteDuration) + check(actual.negativeInfiniteDuration == expected.negativeInfiniteDuration) + check(actual.instant == expected.instant) + check(actual.nanosInstant == expected.nanosInstant) + check(actual.minInstant == expected.minInstant) + check(actual.maxInstant == expected.maxInstant) + check(actual.uuid == expected.uuid) + check(actual.unit === Unit) + check(actual.nullableUnit == null) + check(actual.intRange == expected.intRange) + check(actual.uintRange == expected.uintRange) + check(actual.intProgression == expected.intProgression) + check(actual.ulongProgression == expected.ulongProgression) + check(actual.timed == expected.timed) + } + + @JvmStatic + public fun verifyMixinCases(json: ForyJson) { + val javaType = jsonTypeRef() + val javaProfile = PlatformJavaProfile("java-mixin") + val javaText = json.toJson(javaProfile, javaType) + check(javaText == "{\"display_label\":\"java-mixin\"}") + check(json.fromJson(javaText, javaType).label == "java-mixin") + + val kotlinType = jsonTypeRef() + val kotlinProfile = PlatformKotlinProfile("kotlin-mixin") + val kotlinText = json.toJson(kotlinProfile, kotlinType) + check(kotlinText == "{\"display_label\":\"kotlin-mixin\"}") + check(json.fromJson(kotlinText, kotlinType) == kotlinProfile) + } + + private fun expectFailure(operation: () -> Unit) { + try { + operation() + error("Rejected Kotlin JSON corpus case unexpectedly succeeded") + } catch (_: ForyJsonException) { + // Every rejected vector must fail as a controlled root operation. + } + } + + private val expectedCases: Set = + setOf( + "root", + "account-default", + "generic-envelope", + "recursive-generic", + "custom-module", + "singleton", + "property-marker", + "wrapped-data", + "wrapped-marker", + "unit-root", + "nothing-root", + "unreached-generic", + "nothing-non-null", + "value-nullability", + "sealed-authorization", + "property-scalar", + "raw-products", + "unreconstructible-models", + "executable-reflection", + "lazy-cursors", + "abstract-time-state", + ) +} diff --git a/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformCustomCodec.kt b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformCustomCodec.kt new file mode 100644 index 0000000000..624eb9b07c --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformCustomCodec.kt @@ -0,0 +1,47 @@ +/* + * 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.fory.integration.kotlin.json.corpus + +import org.apache.fory.json.ForyJsonModule +import org.apache.fory.json.ModuleContext +import org.apache.fory.json.codec.AbstractJsonValueCodec +import org.apache.fory.json.reader.JsonReader +import org.apache.fory.json.writer.JsonWriter + +/** Application value whose JSON authority belongs exclusively to [PlatformJsonModule]. */ +public data class PlatformToken(public val value: String) + +/** Application module shared by the JVM, Native Image, and Android corpus consumers. */ +public object PlatformJsonModule : ForyJsonModule { + override fun install(context: ModuleContext) { + context.registerCodec(PlatformToken::class.java, PlatformTokenCodec) + } +} + +private object PlatformTokenCodec : AbstractJsonValueCodec() { + override fun write(writer: JsonWriter, value: PlatformToken?) { + if (value == null) writer.writeNull() else writer.writeString(value.value) + } + + override fun read(reader: JsonReader): PlatformToken? { + val value = reader.readString() ?: return null + return PlatformToken(value) + } +} diff --git a/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformJavaProfileMixin.kt b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformJavaProfileMixin.kt new file mode 100644 index 0000000000..6739c8e025 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformJavaProfileMixin.kt @@ -0,0 +1,28 @@ +/* + * 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.fory.integration.kotlin.json.corpus + +import org.apache.fory.json.annotation.JsonMixin +import org.apache.fory.json.annotation.JsonProperty + +@JsonMixin(target = PlatformJavaProfile::class) +public abstract class PlatformJavaProfileMixin { + @get:JsonProperty("display_label") public abstract val label: String +} diff --git a/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformModels.kt b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformModels.kt new file mode 100644 index 0000000000..fdaf41ee53 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformModels.kt @@ -0,0 +1,85 @@ +/* + * 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.fory.integration.kotlin.json.corpus + +import org.apache.fory.json.annotation.JsonType + +@JsonType +public data class PlatformAccount( + public val id: Int, + public val name: String, + public val label: String? = "corpus-default", +) + +@JsonType public data class PlatformBox(public val value: T) + +@JsonType public class PlatformOrdinary(public val id: Int, public val name: String) + +@JsonType +public data class PlatformEnvelope( + public val account: PlatformAccount, + public val names: List, + public val boxedNames: List>, + public val unsigned: UInt, +) + +@JsonType +public data class PlatformNode( + public val value: T, + public val children: List> = emptyList(), +) + +@JsonType +public data class PlatformNulls( + public val required: String, + public val nullable: String?, + public val count: Int, + public val nullableCount: Int?, + public val defaultNullable: String? = "nullable-default", + public val defaultNonNull: String = "non-null-default", +) + +@JsonType +public data class PlatformUnitHolder( + public val required: Unit, + public val nullable: Unit?, + public val nothing: Nothing?, +) + +@JsonType public object PlatformMarker + +@JsonType public data class PlatformKotlinProfile(public val label: String) + +public object PlatformStatefulMarker { + public var state: Int = 1 +} + +public class PlatformComputed(public val id: Int) { + public val computed: Int + get() = id * 2 +} + +public class PlatformDelegated(public val id: Int) { + public val delegated: String by lazy { id.toString() } +} + +public class PlatformInnerOwner { + public inner class InnerModel(public val id: Int) +} diff --git a/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformRoot.kt b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformRoot.kt new file mode 100644 index 0000000000..1ee6075708 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformRoot.kt @@ -0,0 +1,102 @@ +/* + * 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.fory.integration.kotlin.json.corpus + +import java.util.Optional +import org.apache.fory.json.annotation.JsonProperty +import org.apache.fory.json.annotation.JsonType + +@JsonType +public data class PlatformAnnotated( + @field:JsonProperty("field_name") public val fieldName: String, + @get:JsonProperty("getter_name") public val getterName: String, + @param:JsonProperty("parameter_name") public val parameterName: String, + @set:JsonProperty("setter_name") public var setterName: String, + @JsonProperty("bare_name") public val bareName: String, +) + +@JsonType +public data class PlatformRoot( + public val account: PlatformAccount, + public val ordinary: PlatformOrdinary, + public val envelope: PlatformEnvelope, + public val node: PlatformNode, + public val builtins: PlatformBuiltins, + public val value: PlatformValueHolder, + public val unitHolder: PlatformUnitHolder, + public val propertyShape: PlatformPropertyShape, + public val wrappedShape: PlatformWrappedShape, + public val annotated: PlatformAnnotated, + public val codecSlots: PlatformCodecSlots, + public val nulls: PlatformNulls, + public val token: PlatformToken, +) + +@JsonType +public data class PlatformCase( + public val id: String, + public val type: String, + public val resource: String, + public val outcome: String, + public val platforms: List, +) + +@JsonType +public data class PlatformCaseManifest( + public val schemaVersion: Int, + public val cases: List, +) + +internal fun platformRootValue(): PlatformRoot { + val account = PlatformAccount(25, "platform", null) + return PlatformRoot( + account = account, + ordinary = PlatformOrdinary(11, "ordinary"), + envelope = + PlatformEnvelope( + account, + listOf("native", "android"), + listOf(PlatformBox("child")), + UInt.MAX_VALUE, + ), + node = PlatformNode("root", listOf(PlatformNode("leaf"))), + builtins = platformBuiltinsValue(), + value = + PlatformValueHolder( + id = PlatformPositiveId(19), + nullableId = null, + nullableText = PlatformNullableText(null), + keyed = linkedMapOf(PlatformGenericKey(UInt.MAX_VALUE) to "maximum"), + ), + unitHolder = PlatformUnitHolder(Unit, null, null), + propertyShape = PlatformCircle(3), + wrappedShape = PlatformWrappedNumber(9), + annotated = PlatformAnnotated("field", "getter", "parameter", "setter", "bare"), + codecSlots = + PlatformCodecSlots( + scalar = "scalar", + elements = listOf("first", "second"), + content = Optional.of("optional"), + entries = linkedMapOf(7 to "seven"), + ), + nulls = PlatformNulls("required", null, 1, null), + token = PlatformToken("module-token"), + ) +} diff --git a/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformValueAndSealed.kt b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformValueAndSealed.kt new file mode 100644 index 0000000000..82762169fe --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/kotlin/org/apache/fory/integration/kotlin/json/corpus/PlatformValueAndSealed.kt @@ -0,0 +1,93 @@ +/* + * 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.fory.integration.kotlin.json.corpus + +import kotlin.jvm.JvmInline +import org.apache.fory.json.annotation.JsonSubTypes +import org.apache.fory.json.annotation.JsonType + +@JsonType +@JvmInline +public value class PlatformPositiveId(public val value: Long) { + init { + require(value >= 0) { "id must be non-negative" } + } +} + +@JsonType @JvmInline public value class PlatformNullableText(public val value: String?) + +@JsonType @JvmInline public value class PlatformGenericKey(public val value: T) + +@JsonType +public data class PlatformValueHolder( + public val id: PlatformPositiveId, + public val nullableId: PlatformPositiveId?, + public val nullableText: PlatformNullableText, + public val keyed: Map, String>, + public val defaultId: PlatformPositiveId = PlatformPositiveId(7), +) + +@JsonType +@JsonSubTypes( + value = + [ + JsonSubTypes.Type(value = PlatformCircle::class, name = "circle"), + JsonSubTypes.Type(value = PlatformShapeMarker::class, name = "marker"), + ], + property = "kind", +) +public sealed interface PlatformPropertyShape + +@JsonType public data class PlatformCircle(public val radius: Int) : PlatformPropertyShape + +@JsonType public data object PlatformShapeMarker : PlatformPropertyShape + +@JsonType public data class PlatformUnlistedShape(public val value: Int) : PlatformPropertyShape + +@JsonType +@JsonSubTypes( + value = + [ + JsonSubTypes.Type(value = PlatformWrappedData::class, name = "data"), + JsonSubTypes.Type(value = PlatformWrappedNumber::class, name = "number"), + JsonSubTypes.Type(value = PlatformWrappedMarker::class, name = "marker"), + ], + inclusion = JsonSubTypes.Inclusion.WRAPPER_OBJECT, +) +public sealed interface PlatformWrappedShape + +@JsonType public data class PlatformWrappedData(public val value: String) : PlatformWrappedShape + +@JsonType +@JvmInline +public value class PlatformWrappedNumber(public val value: Int) : PlatformWrappedShape + +@JsonType public data object PlatformWrappedMarker : PlatformWrappedShape + +@JsonType +@JsonSubTypes( + value = [JsonSubTypes.Type(value = PlatformPropertyNumber::class, name = "number")], + property = "kind", +) +public sealed interface PlatformInvalidPropertyShape + +@JsonType +@JvmInline +public value class PlatformPropertyNumber(public val value: Int) : PlatformInvalidPropertyShape diff --git a/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases.json b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases.json new file mode 100644 index 0000000000..14af8c1d3e --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases.json @@ -0,0 +1,152 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "root", + "type": "PlatformRoot", + "resource": "cases/root.json", + "outcome": "success", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "account-default", + "type": "PlatformAccount", + "resource": "cases/account-default.json", + "outcome": "success", + "platforms": ["jvm", "android", "native", "jpms"] + }, + { + "id": "generic-envelope", + "type": "PlatformEnvelope", + "resource": "cases/generic-envelope.json", + "outcome": "success", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "recursive-generic", + "type": "PlatformNode", + "resource": "cases/recursive-generic.json", + "outcome": "success", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "custom-module", + "type": "PlatformToken", + "resource": "cases/custom-module.json", + "outcome": "success", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "singleton", + "type": "PlatformMarker", + "resource": "cases/singleton.json", + "outcome": "success", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "property-marker", + "type": "PlatformPropertyShape", + "resource": "cases/property-marker.json", + "outcome": "success", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "wrapped-data", + "type": "PlatformWrappedShape", + "resource": "cases/wrapped-data.json", + "outcome": "success", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "wrapped-marker", + "type": "PlatformWrappedShape", + "resource": "cases/wrapped-marker.json", + "outcome": "success", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "unit-root", + "type": "Unit?", + "resource": "cases/unit.json", + "outcome": "success", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "nothing-root", + "type": "Nothing?", + "resource": "cases/null.json", + "outcome": "success", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "unreached-generic", + "type": "PlatformBox", + "resource": "cases/rejected-box.json", + "outcome": "cold-failure", + "platforms": ["native"] + }, + { + "id": "nothing-non-null", + "type": "Nothing?", + "resource": "cases/rejected-number.json", + "outcome": "failure", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "value-nullability", + "type": "PlatformNullableText?", + "resource": "cases/null.json", + "outcome": "cold-failure", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "sealed-authorization", + "type": "PlatformPropertyShape", + "resource": "cases/rejected-object.json", + "outcome": "failure", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "property-scalar", + "type": "PlatformInvalidPropertyShape", + "resource": "cases/rejected-object.json", + "outcome": "cold-or-analysis-failure", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "raw-products", + "type": "Pair/Triple/TimedValue", + "resource": "cases/rejected-object.json", + "outcome": "cold-failure", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "unreconstructible-models", + "type": "computed/delegated/inner/stateful", + "resource": "cases/rejected-object.json", + "outcome": "cold-failure", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "executable-reflection", + "type": "function/KClass/KType/coroutine", + "resource": "cases/rejected-object.json", + "outcome": "cold-failure", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "lazy-cursors", + "type": "Result/Lazy/Iterable/Iterator/Map.Entry/EnumEntries", + "resource": "cases/rejected-object.json", + "outcome": "cold-failure", + "platforms": ["jvm", "android", "native"] + }, + { + "id": "abstract-time-state", + "type": "ClosedRange/Clock/TimeSource/TimeMark", + "resource": "cases/rejected-object.json", + "outcome": "cold-failure", + "platforms": ["jvm", "android", "native"] + } + ] +} diff --git a/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/account-default.json b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/account-default.json new file mode 100644 index 0000000000..1ca1173485 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/account-default.json @@ -0,0 +1 @@ +{ "id": 1, "name": "default" } diff --git a/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/custom-module.json b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/custom-module.json new file mode 100644 index 0000000000..770a2870ba --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/custom-module.json @@ -0,0 +1 @@ +"module-token" diff --git a/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/generic-envelope.json b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/generic-envelope.json new file mode 100644 index 0000000000..69a8fcdf90 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/generic-envelope.json @@ -0,0 +1,6 @@ +{ + "account": { "id": 2, "name": "nested" }, + "names": ["a"], + "boxedNames": [{ "value": "b" }], + "unsigned": 4294967295 +} diff --git a/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/null.json b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/null.json new file mode 100644 index 0000000000..19765bd501 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/null.json @@ -0,0 +1 @@ +null diff --git a/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/property-marker.json b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/property-marker.json new file mode 100644 index 0000000000..223c0d1206 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/property-marker.json @@ -0,0 +1 @@ +{ "kind": "marker" } diff --git a/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/recursive-generic.json b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/recursive-generic.json new file mode 100644 index 0000000000..8b80543cd1 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/recursive-generic.json @@ -0,0 +1 @@ +{ "value": "root", "children": [{ "value": "leaf" }] } diff --git a/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/rejected-box.json b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/rejected-box.json new file mode 100644 index 0000000000..e1cbe3c154 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/rejected-box.json @@ -0,0 +1 @@ +{ "value": 1 } diff --git a/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/rejected-number.json b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/rejected-number.json new file mode 100644 index 0000000000..573541ac97 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/rejected-number.json @@ -0,0 +1 @@ +0 diff --git a/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/rejected-object.json b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/rejected-object.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/rejected-object.json @@ -0,0 +1 @@ +{} diff --git a/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/root.json b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/root.json new file mode 100644 index 0000000000..4b941a5a65 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/root.json @@ -0,0 +1,74 @@ +{ + "account": { "id": 25, "name": "platform", "label": null }, + "ordinary": { "id": 11, "name": "ordinary" }, + "envelope": { + "account": { "id": 25, "name": "platform", "label": null }, + "names": ["native", "android"], + "boxedNames": [{ "value": "child" }], + "unsigned": 4294967295 + }, + "node": { "value": "root", "children": [{ "value": "leaf" }] }, + "builtins": { + "pair": { "first": null, "second": "r" }, + "triple": { "first": 1, "second": "two", "third": true }, + "ubyte": 255, + "ushort": 65535, + "uint": 4294967295, + "ulong": 18446744073709551615, + "ubytes": [0, 255], + "ushorts": [0, 65535], + "uints": [0, 4294967295], + "ulongs": [0, 18446744073709551615], + "ubyteKeys": { "0": "zero", "255": "max" }, + "ushortKeys": { "0": "zero", "65535": "max" }, + "uintKeys": { "0": "zero", "4294967295": "max" }, + "ulongKeys": { "0": "zero", "18446744073709551615": "max" }, + "zeroDuration": "PT0S", + "negativeDuration": "-PT0.000000001S", + "duration": "PT49H2M3.000456789S", + "infiniteDuration": "PT9999999999999H", + "negativeInfiniteDuration": "-PT9999999999999H", + "instant": "1970-01-01T00:00:00Z", + "nanosInstant": "1969-12-31T23:59:59.000000001Z", + "minInstant": "-1000000000-01-01T00:00:00Z", + "maxInstant": "+1000000000-12-31T23:59:59.999999999Z", + "uuid": "00112233-4455-6677-8899-aabbccddeeff", + "unit": {}, + "nullableUnit": null, + "nothing": null, + "intRange": { "start": -4, "endInclusive": 9 }, + "uintRange": { "start": 0, "endInclusive": 4294967295 }, + "intProgression": { "first": 20, "last": -10, "step": -3 }, + "ulongProgression": { "first": 20, "last": 2, "step": -3 }, + "timed": { "value": "v", "duration": "PT0.000000001S" } + }, + "value": { + "id": 19, + "nullableId": null, + "nullableText": null, + "keyed": { "4294967295": "maximum" } + }, + "unitHolder": { "required": {}, "nullable": null, "nothing": null }, + "propertyShape": { "kind": "circle", "radius": 3 }, + "wrappedShape": { "number": 9 }, + "annotated": { + "field_name": "field", + "getter_name": "getter", + "parameter_name": "parameter", + "setter_name": "setter", + "bare_name": "bare" + }, + "codecSlots": { + "scalar": "whole:scalar", + "elements": ["element:first", "element:second"], + "content": "content:optional", + "entries": { "key:7": "value:seven" } + }, + "nulls": { + "required": "required", + "nullable": null, + "count": 1, + "nullableCount": null + }, + "token": "module-token" +} diff --git a/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/singleton.json b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/singleton.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/singleton.json @@ -0,0 +1 @@ +{} diff --git a/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/unit.json b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/unit.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/unit.json @@ -0,0 +1 @@ +{} diff --git a/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/wrapped-data.json b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/wrapped-data.json new file mode 100644 index 0000000000..6c23388459 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/wrapped-data.json @@ -0,0 +1 @@ +{ "data": { "value": "wrapped" } } diff --git a/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/wrapped-marker.json b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/wrapped-marker.json new file mode 100644 index 0000000000..1bf9de7d11 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/main/resources/org/apache/fory/integration/kotlin/json/corpus/cases/wrapped-marker.json @@ -0,0 +1 @@ +{ "marker": {} } diff --git a/integration_tests/kotlin_json_corpus/src/test/kotlin/org/apache/fory/integration/kotlin/json/corpus/KotlinJsonCorpusTest.kt b/integration_tests/kotlin_json_corpus/src/test/kotlin/org/apache/fory/integration/kotlin/json/corpus/KotlinJsonCorpusTest.kt new file mode 100644 index 0000000000..1f677bf886 --- /dev/null +++ b/integration_tests/kotlin_json_corpus/src/test/kotlin/org/apache/fory/integration/kotlin/json/corpus/KotlinJsonCorpusTest.kt @@ -0,0 +1,57 @@ +/* + * 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.fory.integration.kotlin.json.corpus + +import org.apache.fory.json.kotlin.ForyJsonKotlin +import org.testng.annotations.Test + +public class KotlinJsonCorpusTest { + @Test + public fun mixinCases(): Unit { + val json = + ForyJsonKotlin.builder() + .registerMixin(PlatformJavaProfileMixin::class.java) + .registerMixin(PlatformKotlinProfileMixin::class.java) + .registerMixin(PlatformCodecSlotsMixin::class.java) + .withAsyncCompilation(false) + .build() + PlatformCorpusChecks.verifyMixinCases(json) + } + + @Test + public fun sharedCases(): Unit { + val json = + ForyJsonKotlin.builder() + .withModule(PlatformJsonModule) + .registerMixin(PlatformJavaProfileMixin::class.java) + .registerMixin(PlatformKotlinProfileMixin::class.java) + .registerMixin(PlatformCodecSlotsMixin::class.java) + .withAsyncCompilation(false) + .build() + PlatformCorpusChecks.verifyPlatformCases(json) + } + + @Test + public fun rejectedCases(): Unit { + val json = ForyJsonKotlin.builder().withModule(PlatformJsonModule).withCodegen(false).build() + PlatformCorpusChecks.verifyFailureCases(json) + PlatformCorpusChecks.verifyPropertyFailure(json) + } +} diff --git a/java/fory-core/src/main/java/org/apache/fory/codegen/CodeGenerator.java b/java/fory-core/src/main/java/org/apache/fory/codegen/CodeGenerator.java index 826029a4c2..a5c7b5e99b 100644 --- a/java/fory-core/src/main/java/org/apache/fory/codegen/CodeGenerator.java +++ b/java/fory-core/src/main/java/org/apache/fory/codegen/CodeGenerator.java @@ -32,6 +32,7 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import org.apache.fory.annotation.Internal; import org.apache.fory.builder.AccessorHelper; import org.apache.fory.builder.Generated; import org.apache.fory.collection.Collections; @@ -115,10 +116,25 @@ public CodeGenerator(ClassLoader classLoader) { * @param units compile units */ public ClassLoader compile(CompileUnit... units) { - return compile(Arrays.asList(units), compileState -> compileState.lock.lock()); + return compile(Arrays.asList(units), compileState -> compileState.lock.lock(), null, null); } public ClassLoader compile(List units, CompileCallback callback) { + return compile(units, callback, null, null); + } + + /** Compiles one unit and installs its verified direct invocation bridges before publication. */ + @Internal + public ClassLoader compileDirect(CompileUnit unit, JaninoUtils.DirectInvocation... invocations) { + return compile( + Arrays.asList(unit), compileState -> compileState.lock.lock(), unit, invocations); + } + + private ClassLoader compile( + List units, + CompileCallback callback, + CompileUnit directUnit, + JaninoUtils.DirectInvocation[] invocations) { checkRuntimeCodegenSupported(); List compileUnits = new ArrayList<>(); ClassLoader parentClassLoader; @@ -145,6 +161,14 @@ public ClassLoader compile(List units, CompileCallback callback) { try { classes = JaninoUtils.toBytecode(parentClassLoader, compileUnits.toArray(new CompileUnit[0])); + if (directUnit != null && invocations.length != 0) { + String classFile = directUnit.getQualifiedClassName().replace('.', '/') + ".class"; + byte[] bytecode = classes.get(classFile); + if (bytecode == null) { + throw new CodegenException("Missing generated direct invocation class " + classFile); + } + classes.put(classFile, JaninoUtils.installDirectInvocations(bytecode, invocations)); + } compileState.result = classes; compileState.finished = true; } finally { diff --git a/java/fory-core/src/main/java/org/apache/fory/codegen/JaninoUtils.java b/java/fory-core/src/main/java/org/apache/fory/codegen/JaninoUtils.java index 98dfe57a2c..3bbbca7730 100644 --- a/java/fory-core/src/main/java/org/apache/fory/codegen/JaninoUtils.java +++ b/java/fory-core/src/main/java/org/apache/fory/codegen/JaninoUtils.java @@ -21,12 +21,18 @@ import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Executable; import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.HashMap; @@ -34,6 +40,7 @@ import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; +import org.apache.fory.annotation.Internal; import org.apache.fory.collection.Tuple2; import org.apache.fory.logging.Logger; import org.apache.fory.logging.LoggerFactory; @@ -46,12 +53,87 @@ import org.codehaus.commons.compiler.util.resource.Resource; import org.codehaus.janino.ClassLoaderIClassLoader; import org.codehaus.janino.Compiler; +import org.codehaus.janino.MethodDescriptor; import org.codehaus.janino.util.ClassFile; /** A util to compile code to bytecode and create classloader to load generated class. */ public class JaninoUtils { private static final Logger LOG = LoggerFactory.getLogger(JaninoUtils.class); + /** + * One verified direct JVM invocation used to replace a source placeholder after Janino has + * compiled the containing class. + * + *

The generated-codec bridge is an instance method with a source-nameable signature; the + * target member need not be source-nameable. Parameter index {@code -1} supplies a JVM null + * constant. All other indexes select one bridge parameter. + */ + @Internal + public static final class DirectInvocation { + private final String bridgeName; + private final Class returnType; + private final Class[] parameterTypes; + private final Executable target; + private final int receiverIndex; + private final int[] argumentIndexes; + + private DirectInvocation( + String bridgeName, + Class returnType, + Class[] parameterTypes, + Executable target, + int receiverIndex, + int[] argumentIndexes) { + this.bridgeName = bridgeName; + this.returnType = returnType; + this.parameterTypes = parameterTypes.clone(); + this.target = target; + this.receiverIndex = receiverIndex; + this.argumentIndexes = argumentIndexes.clone(); + validateDirectInvocation(this); + } + + /** Creates a bridge which invokes one exact constructor. */ + public static DirectInvocation constructor( + String bridgeName, + Class[] parameterTypes, + Constructor target, + int... argumentIndexes) { + return new DirectInvocation( + bridgeName, target.getDeclaringClass(), parameterTypes, target, -1, argumentIndexes); + } + + /** Creates a bridge which invokes one exact method. */ + public static DirectInvocation method( + String bridgeName, + Class returnType, + Class[] parameterTypes, + Method target, + int receiverIndex, + int... argumentIndexes) { + return new DirectInvocation( + bridgeName, returnType, parameterTypes, target, receiverIndex, argumentIndexes); + } + + public String bridgeName() { + return bridgeName; + } + + public String descriptor() { + return methodDescriptor(returnType, parameterTypes); + } + + /** Returns whether two bridge declarations invoke the same exact verified target shape. */ + public boolean sameTarget(DirectInvocation other) { + return other != null + && returnType == other.returnType + && target.equals(other.target) + && receiverIndex == other.receiverIndex + && Arrays.equals(parameterTypes, other.parameterTypes) + && Arrays.equals(argumentIndexes, other.argumentIndexes); + } + } + public static Class compileClass( ClassLoader loader, String pkg, String className, String code) { ByteArrayClassLoader classLoader = compile(loader, new CompileUnit(pkg, className, code)); @@ -144,6 +226,298 @@ public static Map toBytecode( return classes; } + /** Replaces verified source placeholders with straight-line direct JVM invocations. */ + @Internal + public static byte[] installDirectInvocations( + byte[] classBytes, DirectInvocation... invocations) { + if (invocations.length == 0) { + return classBytes; + } + try { + ClassFile classFile = new ClassFile(new ByteArrayInputStream(classBytes)); + ReflectionUtils.setObjectFieldValue( + classFile, "methodInfos", new ArrayList<>(classFile.methodInfos)); + for (DirectInvocation invocation : invocations) { + installDirectInvocation(classFile, invocation); + } + return classFile.toByteArray(); + } catch (IOException e) { + throw new CodegenException("Cannot rewrite generated direct invocation", e); + } + } + + private static void installDirectInvocation(ClassFile classFile, DirectInvocation invocation) { + String descriptor = invocation.descriptor(); + ClassFile.MethodInfo sourceMethod = null; + for (ClassFile.MethodInfo method : classFile.methodInfos) { + if (method.getName().equals(invocation.bridgeName()) + && method.getDescriptor().equals(descriptor)) { + if (sourceMethod != null) { + throw new CodegenException( + "Duplicate generated direct invocation " + invocation.bridgeName()); + } + sourceMethod = method; + } + } + if (sourceMethod == null) { + throw new CodegenException( + "Missing generated direct invocation " + + invocation.bridgeName() + + descriptor + + " in " + + classFile.getThisClassName()); + } + if (Modifier.isStatic(sourceMethod.getAccessFlags())) { + throw new CodegenException( + "Generated direct invocation bridge must be an instance method " + + invocation.bridgeName() + + descriptor); + } + classFile.methodInfos.remove(sourceMethod); + ClassFile.MethodInfo method = + classFile.addMethodInfo( + sourceMethod.getAccessFlags(), + invocation.bridgeName(), + new MethodDescriptor(descriptor)); + byte[] code = directInvocationCode(classFile, invocation); + int maxLocals = parameterSlots(invocation.parameterTypes) + 1; + int targetSlots = parameterSlots(invocation.target.getParameterTypes()); + int invocationStack = + invocation.target instanceof Constructor + ? targetSlots + 2 + : targetSlots + (Modifier.isStatic(invocation.target.getModifiers()) ? 0 : 1); + int maxStack = Math.max(invocationStack, slots(invocation.returnType)); + short codeName = classFile.addConstantUtf8Info("Code"); + method.addAttribute( + new ClassFile.CodeAttribute( + codeName, + (short) maxStack, + (short) maxLocals, + code, + new ClassFile.CodeAttribute.ExceptionTableEntry[0], + new ClassFile.AttributeInfo[0])); + } + + private static byte[] directInvocationCode(ClassFile classFile, DirectInvocation invocation) { + ByteArrayOutputStream code = new ByteArrayOutputStream(); + Executable target = invocation.target; + if (target instanceof Constructor) { + short owner = classFile.addConstantClassInfo(typeDescriptor(target.getDeclaringClass())); + writeOpcodeIndex(code, 0xbb, owner); // new + code.write(0x59); // dup + } else if (!Modifier.isStatic(target.getModifiers())) { + writeLoad( + code, + invocation.parameterTypes[invocation.receiverIndex], + localIndex(invocation, invocation.receiverIndex)); + } + Class[] targetParameters = target.getParameterTypes(); + for (int i = 0; i < targetParameters.length; i++) { + int source = invocation.argumentIndexes[i]; + if (source < 0) { + code.write(0x01); // aconst_null + } else { + writeLoad(code, invocation.parameterTypes[source], localIndex(invocation, source)); + } + } + Class ownerType = target.getDeclaringClass(); + String ownerDescriptor = typeDescriptor(ownerType); + String targetName = target instanceof Constructor ? "" : target.getName(); + String targetDescriptor = + methodDescriptor( + target instanceof Method ? ((Method) target).getReturnType() : void.class, + targetParameters); + short reference; + int opcode; + if (target instanceof Constructor) { + reference = classFile.addConstantMethodrefInfo(ownerDescriptor, targetName, targetDescriptor); + opcode = 0xb7; // invokespecial + } else if (Modifier.isStatic(target.getModifiers())) { + reference = + ownerType.isInterface() + ? classFile.addConstantInterfaceMethodrefInfo( + ownerDescriptor, targetName, targetDescriptor) + : classFile.addConstantMethodrefInfo(ownerDescriptor, targetName, targetDescriptor); + opcode = 0xb8; // invokestatic + } else if (ownerType.isInterface()) { + reference = + classFile.addConstantInterfaceMethodrefInfo( + ownerDescriptor, targetName, targetDescriptor); + opcode = 0xb9; // invokeinterface + } else { + reference = classFile.addConstantMethodrefInfo(ownerDescriptor, targetName, targetDescriptor); + opcode = 0xb6; // invokevirtual + } + writeOpcodeIndex(code, opcode, reference); + if (opcode == 0xb9) { + code.write(parameterSlots(targetParameters) + 1); + code.write(0); + } + writeReturn(code, invocation.returnType); + return code.toByteArray(); + } + + private static void validateDirectInvocation(DirectInvocation invocation) { + if (invocation.bridgeName.isEmpty()) { + throw new IllegalArgumentException("Direct invocation bridge name is empty"); + } + Class[] targetParameters = invocation.target.getParameterTypes(); + if (targetParameters.length != invocation.argumentIndexes.length) { + throw new IllegalArgumentException("Direct invocation argument shape does not match target"); + } + if (invocation.target instanceof Constructor) { + if (invocation.returnType != invocation.target.getDeclaringClass() + || invocation.receiverIndex != -1) { + throw new IllegalArgumentException("Invalid direct constructor bridge shape"); + } + } else { + Method method = (Method) invocation.target; + if (invocation.returnType != method.getReturnType()) { + throw new IllegalArgumentException( + "Direct method bridge return type does not match target"); + } + if (Modifier.isStatic(method.getModifiers())) { + if (invocation.receiverIndex != -1) { + throw new IllegalArgumentException("Static direct method cannot have a receiver"); + } + } else if (invocation.receiverIndex < 0 + || invocation.receiverIndex >= invocation.parameterTypes.length + || !method + .getDeclaringClass() + .isAssignableFrom(invocation.parameterTypes[invocation.receiverIndex])) { + throw new IllegalArgumentException("Invalid direct method receiver"); + } + } + for (int i = 0; i < targetParameters.length; i++) { + int source = invocation.argumentIndexes[i]; + if (source < 0) { + if (targetParameters[i].isPrimitive()) { + throw new IllegalArgumentException("Null cannot supply a primitive direct argument"); + } + } else if (source >= invocation.parameterTypes.length + || invocation.parameterTypes[source] != targetParameters[i]) { + throw new IllegalArgumentException("Direct invocation argument type does not match target"); + } + } + } + + private static int localIndex(DirectInvocation invocation, int parameterIndex) { + // Direct placeholders are generated-codec instance methods. Target staticness affects only the + // invocation opcode; local slot zero always remains the generated-codec receiver. + int index = 1; + for (int i = 0; i < parameterIndex; i++) { + index += slots(invocation.parameterTypes[i]); + } + return index; + } + + private static int parameterSlots(Class[] types) { + int slots = 0; + for (Class type : types) { + slots += slots(type); + } + return slots; + } + + private static int slots(Class type) { + return type == long.class || type == double.class ? 2 : 1; + } + + private static void writeLoad(ByteArrayOutputStream code, Class type, int index) { + int opcode; + if (!type.isPrimitive()) { + opcode = 0x19; // aload + } else if (type == long.class) { + opcode = 0x16; // lload + } else if (type == float.class) { + opcode = 0x17; // fload + } else if (type == double.class) { + opcode = 0x18; // dload + } else { + opcode = 0x15; // iload + } + int compactBase; + switch (opcode) { + case 0x15: + compactBase = 0x1a; + break; + case 0x16: + compactBase = 0x1e; + break; + case 0x17: + compactBase = 0x22; + break; + case 0x18: + compactBase = 0x26; + break; + default: + compactBase = 0x2a; + } + if (index <= 3) { + code.write(compactBase + index); + } else if (index <= 255) { + code.write(opcode); + code.write(index); + } else { + code.write(0xc4); // wide + code.write(opcode); + writeShort(code, index); + } + } + + private static void writeOpcodeIndex(ByteArrayOutputStream code, int opcode, short index) { + code.write(opcode); + writeShort(code, index & 0xffff); + } + + private static void writeShort(ByteArrayOutputStream code, int value) { + code.write(value >>> 8); + code.write(value); + } + + private static void writeReturn(ByteArrayOutputStream code, Class type) { + if (type == void.class) { + code.write(0xb1); + } else if (!type.isPrimitive()) { + code.write(0xb0); + } else if (type == long.class) { + code.write(0xad); + } else if (type == float.class) { + code.write(0xae); + } else if (type == double.class) { + code.write(0xaf); + } else { + code.write(0xac); + } + } + + private static String methodDescriptor(Class returnType, Class[] parameterTypes) { + StringBuilder descriptor = new StringBuilder("("); + for (Class parameterType : parameterTypes) { + descriptor.append(typeDescriptor(parameterType)); + } + return descriptor.append(')').append(typeDescriptor(returnType)).toString(); + } + + private static String typeDescriptor(Class type) { + if (type.isPrimitive()) { + if (type == void.class) return "V"; + if (type == boolean.class) return "Z"; + if (type == byte.class) return "B"; + if (type == char.class) return "C"; + if (type == short.class) return "S"; + if (type == int.class) return "I"; + if (type == long.class) return "J"; + if (type == float.class) return "F"; + if (type == double.class) return "D"; + throw new AssertionError(type); + } + if (type.isArray()) { + return type.getName().replace('.', '/'); + } + return 'L' + type.getName().replace('.', '/') + ';'; + } + public static class CodeStats { public final Map methodsSize; public final int constPoolSize; diff --git a/java/fory-core/src/main/java/org/apache/fory/meta/FieldTypes.java b/java/fory-core/src/main/java/org/apache/fory/meta/FieldTypes.java index d3fba549d2..e19cf1ab56 100644 --- a/java/fory-core/src/main/java/org/apache/fory/meta/FieldTypes.java +++ b/java/fory-core/src/main/java/org/apache/fory/meta/FieldTypes.java @@ -1390,7 +1390,11 @@ private static TypeExtMeta typeExtMeta( int typeId, boolean nullable, boolean trackingRef, TypeRef declared) { TypeExtMeta declaredMeta = declared == null ? null : declared.getTypeExtMeta(); return TypeExtMeta.of( - typeId, nullable, trackingRef, declaredMeta != null && declaredMeta.nullableWrapper()); + typeId, + nullable, + trackingRef, + declaredMeta != null && declaredMeta.nullableWrapper(), + declaredMeta != null && declaredMeta.covariant()); } /** Class for Union field type. Union types use declared type. */ diff --git a/java/fory-core/src/main/java/org/apache/fory/meta/TypeExtMeta.java b/java/fory-core/src/main/java/org/apache/fory/meta/TypeExtMeta.java index d0be968651..689bff3d00 100644 --- a/java/fory-core/src/main/java/org/apache/fory/meta/TypeExtMeta.java +++ b/java/fory-core/src/main/java/org/apache/fory/meta/TypeExtMeta.java @@ -26,6 +26,7 @@ public class TypeExtMeta { private final boolean nullable; private final boolean trackingRef; private final boolean nullableWrapper; + private final boolean covariant; public static TypeExtMeta of(int typeId, boolean nullable, boolean trackingRef) { return new TypeExtMeta(typeId, nullable, trackingRef); @@ -36,15 +37,34 @@ public static TypeExtMeta of( return new TypeExtMeta(typeId, nullable, trackingRef, nullableWrapper); } + public static TypeExtMeta of( + int typeId, + boolean nullable, + boolean trackingRef, + boolean nullableWrapper, + boolean covariant) { + return new TypeExtMeta(typeId, nullable, trackingRef, nullableWrapper, covariant); + } + TypeExtMeta(int typeId, boolean nullable, boolean trackingRef) { this(typeId, nullable, trackingRef, false); } TypeExtMeta(int typeId, boolean nullable, boolean trackingRef, boolean nullableWrapper) { + this(typeId, nullable, trackingRef, nullableWrapper, false); + } + + TypeExtMeta( + int typeId, + boolean nullable, + boolean trackingRef, + boolean nullableWrapper, + boolean covariant) { this.typeId = typeId; this.nullable = nullable; this.trackingRef = trackingRef; this.nullableWrapper = nullableWrapper; + this.covariant = covariant; } public int typeId() { @@ -64,6 +84,11 @@ public boolean nullableWrapper() { return nullableWrapper; } + /** Whether this declared occurrence is the producer of a covariant type projection. */ + public boolean covariant() { + return covariant; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -76,12 +101,13 @@ public boolean equals(Object o) { return typeId == that.typeId && nullable == that.nullable && trackingRef == that.trackingRef - && nullableWrapper == that.nullableWrapper; + && nullableWrapper == that.nullableWrapper + && covariant == that.covariant; } @Override public int hashCode() { - return Objects.hash(typeId, nullable, trackingRef, nullableWrapper); + return Objects.hash(typeId, nullable, trackingRef, nullableWrapper, covariant); } @Override @@ -95,6 +121,8 @@ public String toString() { + trackingRef + ", nullableWrapper=" + nullableWrapper + + ", covariant=" + + covariant + '}'; } } diff --git a/java/fory-core/src/main/java/org/apache/fory/reflect/TypeRef.java b/java/fory-core/src/main/java/org/apache/fory/reflect/TypeRef.java index a12f49675d..6a178828a0 100644 --- a/java/fory-core/src/main/java/org/apache/fory/reflect/TypeRef.java +++ b/java/fory-core/src/main/java/org/apache/fory/reflect/TypeRef.java @@ -542,6 +542,8 @@ private void appendTypeKey(StringBuilder builder) { builder.append('#').append(typeExtMeta.typeId()); builder.append(':').append(typeExtMeta.nullable() ? '1' : '0'); builder.append(':').append(typeExtMeta.trackingRef() ? '1' : '0'); + builder.append(':').append(typeExtMeta.nullableWrapper() ? '1' : '0'); + builder.append(':').append(typeExtMeta.covariant() ? '1' : '0'); } if (typeArguments != null && !typeArguments.isEmpty()) { builder.append('<'); diff --git a/java/fory-core/src/test/java/org/apache/fory/reflect/TypeRefTest.java b/java/fory-core/src/test/java/org/apache/fory/reflect/TypeRefTest.java index 46d70f46fc..7bb6692380 100644 --- a/java/fory-core/src/test/java/org/apache/fory/reflect/TypeRefTest.java +++ b/java/fory-core/src/test/java/org/apache/fory/reflect/TypeRefTest.java @@ -393,6 +393,22 @@ public void testCustomContainerTypeUseMetadata() throws Exception { Assert.assertFalse(fixedElementType.hasTypeExtMeta()); } + @Test + public void testTypeKeyMetadata() { + TypeRef plain = + TypeRef.of(String.class, TypeExtMeta.of(Types.UNKNOWN, false, false, false, false)); + TypeRef wrapped = + TypeRef.of(String.class, TypeExtMeta.of(Types.UNKNOWN, false, false, true, false)); + TypeRef covariant = + TypeRef.of(String.class, TypeExtMeta.of(Types.UNKNOWN, false, false, false, true)); + TypeRef both = + TypeRef.of(String.class, TypeExtMeta.of(Types.UNKNOWN, false, false, true, true)); + assertNotEquals(plain.getTypeKey(), wrapped.getTypeKey()); + assertNotEquals(plain.getTypeKey(), covariant.getTypeKey()); + assertNotEquals(wrapped.getTypeKey(), both.getTypeKey()); + assertNotEquals(covariant.getTypeKey(), both.getTypeKey()); + } + @Test public void testScalaContainerTypeRefNormalization() throws Exception { if (!ScalaTypes.SCALA_AVAILABLE) { diff --git a/java/fory-json/src/main/java/org/apache/fory/json/ForyJson.java b/java/fory-json/src/main/java/org/apache/fory/json/ForyJson.java index baecb0cb80..2fad081bc4 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/ForyJson.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/ForyJson.java @@ -28,6 +28,7 @@ import java.util.Objects; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.LockSupport; +import org.apache.fory.json.reader.JsonReader; import org.apache.fory.json.reader.Latin1JsonReader; import org.apache.fory.json.reader.Utf16JsonReader; import org.apache.fory.json.reader.Utf8JsonReader; @@ -36,6 +37,7 @@ import org.apache.fory.json.resolver.JsonTypeResolver; import org.apache.fory.json.writer.StringJsonWriter; import org.apache.fory.json.writer.Utf8JsonWriter; +import org.apache.fory.meta.TypeExtMeta; import org.apache.fory.reflect.TypeRef; import org.apache.fory.serializer.StringSerializer; @@ -132,7 +134,7 @@ public String toJson(Object value) { if (value == null) { writer.writeNull(); } else { - JsonTypeInfo typeInfo = state.rootTypeInfo(value.getClass()); + JsonTypeInfo typeInfo = state.runtimeRootTypeInfo(value.getClass()); typeInfo.stringWriter().writeString(writer, value); } } finally { @@ -162,7 +164,7 @@ public String toJson(Object value) { public String toJson(T value, Class declaredType) { requireDeclaredType(declaredType); validateWriteValue(value, declaredType); - return toJsonDeclared(value, declaredType, declaredType); + return toJsonDeclared(value, declaredType); } /** @@ -178,9 +180,7 @@ public String toJson(T value, Class declaredType) { public String toJson(T value, TypeRef declaredType) { requireDeclaredType(declaredType); validateDeclaredType(declaredType.getType()); - Class rawType = declaredType.getRawType(); - validateWriteValue(value, rawType); - return toJsonDeclared(value, declaredType.getType(), rawType); + return toJsonDeclared(value, declaredType); } /** @@ -200,7 +200,7 @@ public byte[] toJsonBytes(Object value) { if (value == null) { writer.writeNull(); } else { - JsonTypeInfo typeInfo = state.rootTypeInfo(value.getClass()); + JsonTypeInfo typeInfo = state.runtimeRootTypeInfo(value.getClass()); typeInfo.utf8Writer().writeUtf8(writer, value); } } finally { @@ -226,7 +226,7 @@ public byte[] toJsonBytes(Object value) { public byte[] toJsonBytes(T value, Class declaredType) { requireDeclaredType(declaredType); validateWriteValue(value, declaredType); - return toJsonBytesDeclared(value, declaredType, declaredType); + return toJsonBytesDeclared(value, declaredType); } /** @@ -239,9 +239,7 @@ public byte[] toJsonBytes(T value, Class declaredType) { public byte[] toJsonBytes(T value, TypeRef declaredType) { requireDeclaredType(declaredType); validateDeclaredType(declaredType.getType()); - Class rawType = declaredType.getRawType(); - validateWriteValue(value, rawType); - return toJsonBytesDeclared(value, declaredType.getType(), rawType); + return toJsonBytesDeclared(value, declaredType); } /** @@ -265,7 +263,7 @@ public void writeJsonTo(Object value, OutputStream output) { if (value == null) { writer.writeNull(); } else { - JsonTypeInfo typeInfo = state.rootTypeInfo(value.getClass()); + JsonTypeInfo typeInfo = state.runtimeRootTypeInfo(value.getClass()); // Keep root dispatch direct so generated codecs own their own compilation boundaries. typeInfo.utf8Writer().writeUtf8(writer, value); } @@ -293,7 +291,7 @@ public void writeJsonTo(Object value, OutputStream output) { public void writeJsonTo(T value, Class declaredType, OutputStream output) { requireDeclaredType(declaredType); validateWriteValue(value, declaredType); - writeJsonDeclared(value, declaredType, declaredType, output); + writeJsonDeclared(value, declaredType, output); } /** @@ -307,19 +305,38 @@ public void writeJsonTo(T value, Class declaredType, OutputStream output) public void writeJsonTo(T value, TypeRef declaredType, OutputStream output) { requireDeclaredType(declaredType); validateDeclaredType(declaredType.getType()); - Class rawType = declaredType.getRawType(); - validateWriteValue(value, rawType); - writeJsonDeclared(value, declaredType.getType(), rawType, output); + writeJsonDeclared(value, declaredType, output); + } + + private String toJsonDeclared(Object value, Class type) { + PooledState entry = acquire(); + JsonState state = entry.state; + StringJsonWriter writer = state.stringWriter; + try { + state.typeResolver.lockJIT(); + try { + state.declaredRootTypeInfo(type).stringWriter().writeString(writer, value); + } finally { + state.typeResolver.unlockJIT(); + } + return writer.toJson(); + } finally { + try { + writer.reset(); + } finally { + release(entry); + } + } } - private String toJsonDeclared(Object value, Type type, Class fallback) { + private String toJsonDeclared(Object value, TypeRef type) { PooledState entry = acquire(); JsonState state = entry.state; StringJsonWriter writer = state.stringWriter; try { state.typeResolver.lockJIT(); try { - state.rootTypeInfo(type, fallback).stringWriter().writeString(writer, value); + state.declaredWriteTypeInfo(value, type).stringWriter().writeString(writer, value); } finally { state.typeResolver.unlockJIT(); } @@ -333,7 +350,7 @@ private String toJsonDeclared(Object value, Type type, Class fallback) { } } - private byte[] toJsonBytesDeclared(Object value, Type type, Class fallback) { + private byte[] toJsonBytesDeclared(Object value, Class type) { PooledState entry = acquire(); JsonState state = entry.state; Utf8JsonWriter writer = state.utf8Writer; @@ -341,7 +358,28 @@ private byte[] toJsonBytesDeclared(Object value, Type type, Class fallback) { state.typeResolver.lockJIT(); try { // Declared root dispatch stays direct; generated codecs own their own boundaries. - state.rootTypeInfo(type, fallback).utf8Writer().writeUtf8(writer, value); + state.declaredRootTypeInfo(type).utf8Writer().writeUtf8(writer, value); + } finally { + state.typeResolver.unlockJIT(); + } + return writer.toJsonBytes(); + } finally { + try { + writer.reset(); + } finally { + release(entry); + } + } + } + + private byte[] toJsonBytesDeclared(Object value, TypeRef type) { + PooledState entry = acquire(); + JsonState state = entry.state; + Utf8JsonWriter writer = state.utf8Writer; + try { + state.typeResolver.lockJIT(); + try { + state.declaredWriteTypeInfo(value, type).utf8Writer().writeUtf8(writer, value); } finally { state.typeResolver.unlockJIT(); } @@ -355,7 +393,29 @@ private byte[] toJsonBytesDeclared(Object value, Type type, Class fallback) { } } - private void writeJsonDeclared(Object value, Type type, Class fallback, OutputStream output) { + private void writeJsonDeclared(Object value, Class type, OutputStream output) { + Objects.requireNonNull(output, "output"); + PooledState entry = acquire(); + JsonState state = entry.state; + Utf8JsonWriter writer = state.utf8Writer; + try { + state.typeResolver.lockJIT(); + try { + state.declaredRootTypeInfo(type).utf8Writer().writeUtf8(writer, value); + } finally { + state.typeResolver.unlockJIT(); + } + writer.writeTo(output); + } finally { + try { + writer.reset(); + } finally { + release(entry); + } + } + } + + private void writeJsonDeclared(Object value, TypeRef type, OutputStream output) { Objects.requireNonNull(output, "output"); PooledState entry = acquire(); JsonState state = entry.state; @@ -363,7 +423,7 @@ private void writeJsonDeclared(Object value, Type type, Class fallback, Outpu try { state.typeResolver.lockJIT(); try { - state.rootTypeInfo(type, fallback).utf8Writer().writeUtf8(writer, value); + state.declaredWriteTypeInfo(value, type).utf8Writer().writeUtf8(writer, value); } finally { state.typeResolver.unlockJIT(); } @@ -396,6 +456,24 @@ private static void validateWriteValue(Object value, Class declaredType) { } } + private static void validateWriteValue(Object value, TypeRef declaredType) { + validateWriteValue(value, declaredType, declaredType.getRawType()); + } + + private static void validateWriteValue(Object value, TypeRef declaredType, Class rawType) { + TypeExtMeta typeExtMeta = declaredType.getTypeExtMeta(); + if (rawType == Void.class && typeExtMeta != null && typeExtMeta.nullable()) { + if (value != null) { + throw new IllegalArgumentException("Nothing? accepts only null"); + } + return; + } + validateWriteValue(value, rawType); + if (value == null && typeExtMeta != null && !typeExtMeta.nullable()) { + throw new IllegalArgumentException("Cannot write null as non-null " + declaredType); + } + } + private static void requireDeclaredType(Object declaredType) { if (declaredType == null) { throw new IllegalArgumentException("declaredType must not be null"); @@ -462,7 +540,7 @@ public T fromJson(String json, Class type) { try { state.typeResolver.lockJIT(); try { - return castValue(readJavaStringValue(json, type, type, state), type); + return castValue(readJavaStringValue(json, type, state), type); } finally { state.typeResolver.unlockJIT(); } @@ -490,7 +568,7 @@ public T fromJson(String json, TypeRef typeRef) { try { state.typeResolver.lockJIT(); try { - Object value = readJavaStringValue(json, typeRef.getType(), typeRef.getRawType(), state); + Object value = readJavaStringValue(json, typeRef, state); return castValue(value, typeRef); } finally { state.typeResolver.unlockJIT(); @@ -518,7 +596,7 @@ public T fromJson(byte[] bytes, Class type) { try { state.typeResolver.lockJIT(); try { - return castValue(readUtf8Value(state.utf8Reader(bytes), type, type, state), type); + return castValue(readUtf8Value(state.utf8Reader(bytes), type, state), type); } finally { state.typeResolver.unlockJIT(); } @@ -545,8 +623,7 @@ public T fromJson(byte[] bytes, TypeRef typeRef) { try { state.typeResolver.lockJIT(); try { - Object value = - readUtf8Value(state.utf8Reader(bytes), typeRef.getType(), typeRef.getRawType(), state); + Object value = readUtf8Value(state.utf8Reader(bytes), typeRef, state); return castValue(value, typeRef); } finally { state.typeResolver.unlockJIT(); @@ -625,45 +702,101 @@ private static int spread(int hash) { return hash ^ (hash >>> 16); } - private Object readJavaStringValue(String json, Type type, Class fallback, JsonState state) { + private Object readJavaStringValue(String json, Class type, JsonState state) { if (StringSerializer.isBytesBackedString()) { byte coder = StringSerializer.getStringCoder(json); if (StringSerializer.isLatin1Coder(coder)) { // Keep String input on its reader owner even when ASCII Latin1 bytes match UTF-8; // custom JsonValueCodec implementations can observe readLatin1/readUtf16 dispatch. - return readLatin1Value(state.latin1Reader(json), type, fallback, state); + return readLatin1Value(state.latin1Reader(json), type, state); } if (StringSerializer.isUtf16Coder(coder)) { - return readUtf16Value(state.utf16Reader(json), type, fallback, state); + return readUtf16Value(state.utf16Reader(json), type, state); } } - return readUtf16Value(state.charBackedUtf16Reader(json), type, fallback, state); + return readUtf16Value(state.charBackedUtf16Reader(json), type, state); + } + + private Object readJavaStringValue(String json, TypeRef type, JsonState state) { + if (StringSerializer.isBytesBackedString()) { + byte coder = StringSerializer.getStringCoder(json); + if (StringSerializer.isLatin1Coder(coder)) { + return readLatin1Value(state.latin1Reader(json), type, state); + } + if (StringSerializer.isUtf16Coder(coder)) { + return readUtf16Value(state.utf16Reader(json), type, state); + } + } + return readUtf16Value(state.charBackedUtf16Reader(json), type, state); + } + + private Object readLatin1Value(Latin1JsonReader reader, Class type, JsonState state) { + JsonTypeInfo typeInfo = state.declaredRootTypeInfo(type); + Object value = typeInfo.latin1Reader().readLatin1(reader); + reader.finish(); + return value; } - private Object readLatin1Value( - Latin1JsonReader reader, Type type, Class fallback, JsonState state) { - JsonTypeInfo typeInfo = state.rootTypeInfo(type, fallback); + private Object readLatin1Value(Latin1JsonReader reader, TypeRef type, JsonState state) { + JsonTypeInfo typeInfo = state.declaredRootTypeInfo(type); + if (readOuterNull(reader, type, typeInfo)) { + reader.finish(); + return null; + } Object value = typeInfo.latin1Reader().readLatin1(reader); reader.finish(); return value; } - private Object readUtf16Value( - Utf16JsonReader reader, Type type, Class fallback, JsonState state) { - JsonTypeInfo typeInfo = state.rootTypeInfo(type, fallback); + private Object readUtf16Value(Utf16JsonReader reader, Class type, JsonState state) { + JsonTypeInfo typeInfo = state.declaredRootTypeInfo(type); + Object value = typeInfo.utf16Reader().readUtf16(reader); + reader.finish(); + return value; + } + + private Object readUtf16Value(Utf16JsonReader reader, TypeRef type, JsonState state) { + JsonTypeInfo typeInfo = state.declaredRootTypeInfo(type); + if (readOuterNull(reader, type, typeInfo)) { + reader.finish(); + return null; + } Object value = typeInfo.utf16Reader().readUtf16(reader); reader.finish(); return value; } - private Object readUtf8Value( - Utf8JsonReader reader, Type type, Class fallback, JsonState state) { - JsonTypeInfo typeInfo = state.rootTypeInfo(type, fallback); + private Object readUtf8Value(Utf8JsonReader reader, Class type, JsonState state) { + JsonTypeInfo typeInfo = state.declaredRootTypeInfo(type); Object value = typeInfo.utf8Reader().readUtf8(reader); reader.finish(); return value; } + private Object readUtf8Value(Utf8JsonReader reader, TypeRef type, JsonState state) { + JsonTypeInfo typeInfo = state.declaredRootTypeInfo(type); + if (readOuterNull(reader, type, typeInfo)) { + reader.finish(); + return null; + } + Object value = typeInfo.utf8Reader().readUtf8(reader); + reader.finish(); + return value; + } + + private static boolean readOuterNull(JsonReader reader, TypeRef type, JsonTypeInfo typeInfo) { + if (!typeInfo.nullable() && !typeInfo.rejectsNull()) { + return false; + } + if (!reader.tryReadNull()) { + return false; + } + if (typeInfo.rejectsNull()) { + throw new ForyJsonException("Cannot read null as non-null " + type); + } + return true; + } + @SuppressWarnings("unchecked") private static T castValue(Object value, Class type) { if (!type.isPrimitive()) { @@ -717,8 +850,9 @@ private void release() { * *

The resolver is constructed first and retained by all five readers and writers. Codecs * obtain dynamic child bindings from the active reader or writer instead of receiving a resolver - * through every capability call. The last-root cache is state-local and only avoids repeated - * resolver lookup for an identical declared type and fallback pair. + * through every capability call. Three state-local last-root caches independently cover runtime + * classes, declared classes, and exact {@link TypeRef} tokens; each avoids resolver lookup only + * on an identity hit. */ private static final class JsonState { private final JsonTypeResolver typeResolver; @@ -730,8 +864,9 @@ private static final class JsonState { private byte[] charBackedUtf16Bytes; private Class lastRuntimeRootType; private JsonTypeInfo lastRuntimeRootInfo; - private Type lastRootType; - private Class lastRootFallback; + private Class lastDeclaredRootType; + private JsonTypeInfo lastDeclaredRootInfo; + private TypeRef lastRootType; private JsonTypeInfo lastRootInfo; private JsonState(JsonConfig config, JsonSharedRegistry sharedRegistry) { @@ -792,7 +927,7 @@ private void clearUtf8Reader() { utf8Reader.clear(); } - private JsonTypeInfo rootTypeInfo(Class type) { + private JsonTypeInfo runtimeRootTypeInfo(Class type) { JsonTypeInfo typeInfo = lastRuntimeRootInfo; if (lastRuntimeRootType == type && typeInfo != null) { return typeInfo; @@ -803,16 +938,40 @@ private JsonTypeInfo rootTypeInfo(Class type) { return typeInfo; } - private JsonTypeInfo rootTypeInfo(Type type, Class fallback) { + private JsonTypeInfo declaredRootTypeInfo(Class type) { + JsonTypeInfo typeInfo = lastDeclaredRootInfo; + if (lastDeclaredRootType == type && typeInfo != null) { + return typeInfo; + } + // Keep Class roots on the resolver's identity-key path. Converting here to TypeRef would + // allocate on every alternating-root state-cache miss even when the schema is already bound. + typeInfo = typeResolver.getTypeInfo(type, type); + lastDeclaredRootType = type; + lastDeclaredRootInfo = typeInfo; + return typeInfo; + } + + private JsonTypeInfo declaredRootTypeInfo(TypeRef type) { JsonTypeInfo typeInfo = lastRootInfo; - if (lastRootType == type && lastRootFallback == fallback && typeInfo != null) { + if (lastRootType == type && typeInfo != null) { return typeInfo; } - typeInfo = typeResolver.getTypeInfo(type, fallback); + typeInfo = typeResolver.getTypeInfo(type); lastRootType = type; - lastRootFallback = fallback; lastRootInfo = typeInfo; return typeInfo; } + + private JsonTypeInfo declaredWriteTypeInfo(Object value, TypeRef type) { + JsonTypeInfo typeInfo = lastRootInfo; + if (lastRootType == type && typeInfo != null) { + // The exact-root cache already owns the canonical raw type. Recomputing it from TypeRef on + // every typed write repeats structural type traversal in the root hot path. + validateWriteValue(value, type, typeInfo.rawType()); + return typeInfo; + } + validateWriteValue(value, type); + return declaredRootTypeInfo(type); + } } } diff --git a/java/fory-json/src/main/java/org/apache/fory/json/JsonConfig.java b/java/fory-json/src/main/java/org/apache/fory/json/JsonConfig.java index 1bd980d08f..ca50102598 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/JsonConfig.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/JsonConfig.java @@ -26,9 +26,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.atomic.AtomicInteger; import org.apache.fory.annotation.Internal; import org.apache.fory.json.codegen.JsonCodegenKey; import org.apache.fory.json.resolver.CodecRegistry; @@ -37,10 +34,10 @@ * Build configuration used to create all pooled states of one {@link ForyJson} instance. * *

Scalar settings and the codec registry are snapshotted at construction; the JSON runtime never - * observes later builder mutation. {@link #getCodegenHash()} identifies only settings that can - * change generated source; runtime-only settings such as depth, graph memory, and asynchronous - * scheduling do not fragment generated class names. Concurrency, per-reader field-name cache, and - * retained writer-buffer limits are also runtime-only and do not fragment generated class names. + * observes later builder mutation. {@link JsonCodegenKey} identifies only settings that can change + * generated source; runtime-only settings such as depth, graph memory, and asynchronous scheduling + * do not fragment generated class names. Concurrency, per-reader field-name cache, and retained + * writer-buffer limits are also runtime-only and do not fragment generated class names. */ public final class JsonConfig { private static final int MAX_CACHED_FIELD_NAMES = 1 << 29; @@ -63,7 +60,6 @@ public final class JsonConfig { private final JsonTypeChecker typeChecker; private final JsonTypeCheckContext typeCheckContext; private final JsonCodegenKey codegenKey; - private transient int codegenHash; JsonConfig( boolean writeNullFields, @@ -247,21 +243,6 @@ private static void appendIdentity(StringBuilder builder, String value) { builder.append(value.length()).append(':').append(value); } - private static final AtomicInteger COUNTER = new AtomicInteger(0); - - // Equal generated source inputs share one map entry, following core generated-code naming model. - // This process-wide map retains only immutable configuration text and integers, never user - // classes, codec instances, class loaders, or generated classes. - private static final ConcurrentMap CODEGEN_ID_MAP = - new ConcurrentHashMap<>(); - - public int getCodegenHash() { - if (codegenHash == 0) { - codegenHash = CODEGEN_ID_MAP.computeIfAbsent(codegenKey, key -> COUNTER.incrementAndGet()); - } - return codegenHash; - } - /** Returns the immutable generated-source identity for this configuration. */ @Internal public JsonCodegenKey codegenKey() { diff --git a/java/fory-json/src/main/java/org/apache/fory/json/annotation/ForyJsonProvider.java b/java/fory-json/src/main/java/org/apache/fory/json/annotation/ForyJsonProvider.java index 5384d872c5..9d6536da48 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/annotation/ForyJsonProvider.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/annotation/ForyJsonProvider.java @@ -33,8 +33,10 @@ * effective public, non-static, zero-argument instance method whose exact return type is {@link * ForyJson} is invoked once while the native image is built. This includes inherited superclass * methods and public interface default methods. The returned configurations select the generated - * object codecs included in the image; configurations not returned by a provider continue to use - * interpreted codecs. The provider package does not need to be exported or opened to Fory. + * object codecs included in the image. Configurations not returned by a provider continue to use + * interpreted codecs for ordinary Java models and complete value codecs; language-module object + * models require a returned generated configuration. The provider package does not need to be + * exported or opened to Fory. */ @Documented @Retention(RetentionPolicy.RUNTIME) diff --git a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonType.java b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonType.java index 7ac3f672bb..9bf0381653 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonType.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/annotation/JsonType.java @@ -26,17 +26,22 @@ import java.lang.annotation.Target; /** - * Marks a JSON model for build-time generated execution and retention metadata. + * Marks a JSON model for build-time language processing and retention metadata. * - *

The Fory annotation processor generates a type-owned JSON companion for an eligible concrete - * object model or a Record with an effective {@link JsonValue}, together with exact R8 rules for - * the model, companion, and codec classes selected by its {@link JsonCodec} declarations. The - * companion provides direct member access and creator invocation on the JVM and Android. GraalVM - * Native Image discovers this annotation directly and does not use annotation-processor output. A - * concrete subtype listed only by a class-literal {@link JsonSubTypes} entry receives retention - * metadata but needs its own direct {@code JsonType} annotation to receive a companion on the JVM - * or Android. Outside Native Image, a directly annotated model that reaches the default object - * codec fails during codec creation when its generated companion is missing. + *

The Java Fory annotation processor generates a type-owned JSON companion for an eligible + * concrete object model or a Record with an effective {@link JsonValue}, together with exact R8 + * rules for the model, companion, and codec classes selected by its {@link JsonCodec} declarations. + * The companion provides direct member access and creator invocation on the JVM and Android. The + * Kotlin Symbol Processing (KSP) processor emits exact retention rules only; the installed Kotlin + * module supplies the metadata-backed object model used by interpreted and generated execution. + * GraalVM Native Image discovers this annotation directly and does not use annotation-processor + * output. + * + *

A concrete Java subtype listed only by a class-literal {@link JsonSubTypes} entry receives + * retention metadata but needs its own direct {@code JsonType} annotation to receive a Java + * companion on the JVM or Android. Outside Native Image, a directly annotated Java model that + * reaches the default object codec fails during codec creation when its generated companion is + * missing. * *

This annotation does not change the JSON schema and is intentionally not inherited. An * ordinary mutable class may omit it and use reflection, with application-authored exact R8 rules @@ -45,8 +50,9 @@ * exact {@link JsonMixin} pair because Android does not expose the Java Record reflection APIs. * *

{@link JsonMixin} cannot contribute or remove this build-time marker. A non-empty Mixin is its - * own processor entry point and produces the platform configuration and generated operations - * required by its exact target/source mapping. + * own processor entry point. The Java annotation processor generates the operations and platform + * configuration for its exact target/source mapping; Kotlin KSP emits only the corresponding + * retention rules. */ @Documented @Retention(RetentionPolicy.RUNTIME) diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/ArrayCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/ArrayCodec.java index a84fe034fb..148efe60c2 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/ArrayCodec.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/ArrayCodec.java @@ -29,11 +29,13 @@ import org.apache.fory.json.reader.Utf8JsonReader; import org.apache.fory.json.resolver.JsonTypeInfo; import org.apache.fory.json.resolver.JsonTypeResolver; +import org.apache.fory.json.writer.JsonWriter; import org.apache.fory.json.writer.StringJsonWriter; import org.apache.fory.json.writer.Utf8JsonWriter; import org.apache.fory.reflect.TypeRef; import org.apache.fory.serializer.GraphMemoryEstimates; import org.apache.fory.type.TypeUtils; +import org.apache.fory.type.Types; /** * Codec family for Java primitive, boxed, String, and object arrays. @@ -63,8 +65,7 @@ public static ArrayCodec create( } Class componentType = arrayType.getComponentType(); TypeRef componentTypeRef = arrayTypeRef.getComponentType(); - JsonTypeInfo componentTypeInfo = - resolver.getTypeInfo(componentTypeRef.getType(), componentType); + JsonTypeInfo componentTypeInfo = resolver.getTypeInfo(componentTypeRef); return create(arrayType, componentTypeInfo); } @@ -96,30 +97,76 @@ public static ArrayCodec create(Class arrayType, JsonTypeInfo componen && componentCodec == ScalarCodecs.DoubleCodec.PRIMITIVE) { return bind(DoubleArrayCodec.INSTANCE); } else if (componentType == Integer.class && componentCodec == ScalarCodecs.IntCodec.BOXED) { - return bind(BoxedIntArrayCodec.INSTANCE); + return bind( + componentTypeInfo.rejectsNull() + ? BoxedIntArrayCodec.NON_NULL + : BoxedIntArrayCodec.INSTANCE); } else if (componentType == Long.class && componentCodec == ScalarCodecs.LongCodec.BOXED) { - return bind(BoxedLongArrayCodec.INSTANCE); + return bind( + componentTypeInfo.rejectsNull() + ? BoxedLongArrayCodec.NON_NULL + : BoxedLongArrayCodec.INSTANCE); } else if (componentType == Boolean.class && componentCodec == ScalarCodecs.BooleanCodec.BOXED) { - return bind(BoxedBooleanArrayCodec.INSTANCE); + return bind( + componentTypeInfo.rejectsNull() + ? BoxedBooleanArrayCodec.NON_NULL + : BoxedBooleanArrayCodec.INSTANCE); } else if (componentType == Short.class && componentCodec == ScalarCodecs.ShortCodec.BOXED) { - return bind(BoxedShortArrayCodec.INSTANCE); + return bind( + componentTypeInfo.rejectsNull() + ? BoxedShortArrayCodec.NON_NULL + : BoxedShortArrayCodec.INSTANCE); } else if (componentType == Byte.class && componentCodec == ScalarCodecs.ByteCodec.BOXED) { - return bind(BoxedByteArrayCodec.INSTANCE); + return bind( + componentTypeInfo.rejectsNull() + ? BoxedByteArrayCodec.NON_NULL + : BoxedByteArrayCodec.INSTANCE); } else if (componentType == Character.class && componentCodec == ScalarCodecs.CharCodec.BOXED) { - return bind(BoxedCharArrayCodec.INSTANCE); + return bind( + componentTypeInfo.rejectsNull() + ? BoxedCharArrayCodec.NON_NULL + : BoxedCharArrayCodec.INSTANCE); } else if (componentType == Float.class && componentCodec == ScalarCodecs.FloatCodec.BOXED) { - return bind(BoxedFloatArrayCodec.INSTANCE); + return bind( + componentTypeInfo.rejectsNull() + ? BoxedFloatArrayCodec.NON_NULL + : BoxedFloatArrayCodec.INSTANCE); } else if (componentType == Double.class && componentCodec == ScalarCodecs.DoubleCodec.BOXED) { - return bind(BoxedDoubleArrayCodec.INSTANCE); + return bind( + componentTypeInfo.rejectsNull() + ? BoxedDoubleArrayCodec.NON_NULL + : BoxedDoubleArrayCodec.INSTANCE); } else if (componentType == String.class && componentCodec == ScalarCodecs.StringCodec.INSTANCE) { - return bind(StringArrayCodec.INSTANCE); + return bind( + componentTypeInfo.rejectsNull() ? StringArrayCodec.NON_NULL : StringArrayCodec.INSTANCE); } if (componentType.isPrimitive()) { return new CustomPrimitiveArrayCodec<>(componentType, componentTypeInfo); } - return new ObjectArrayCodec<>(componentType, componentTypeInfo); + return componentTypeInfo.rejectsNull() + ? new NonNullObjectArrayCodec<>(componentType, componentTypeInfo) + : new ObjectArrayCodec<>(componentType, componentTypeInfo); + } + + /** Returns the exact unsigned primitive-array specialization for one semantic array id. */ + @Internal + public static ArrayCodec createUnsignedPrimitive(Class arrayType, int typeId) { + if (arrayType == byte[].class && typeId == Types.UINT8_ARRAY) { + return bind(ByteArrayCodec.UNSIGNED); + } + if (arrayType == short[].class && typeId == Types.UINT16_ARRAY) { + return bind(ShortArrayCodec.UNSIGNED); + } + if (arrayType == int[].class && typeId == Types.UINT32_ARRAY) { + return bind(IntArrayCodec.UNSIGNED); + } + if (arrayType == long[].class && typeId == Types.UINT64_ARRAY) { + return bind(LongArrayCodec.UNSIGNED); + } + throw new ForyJsonException( + "Unsigned JSON array semantic id " + typeId + " does not match " + arrayType.getName()); } @SuppressWarnings("unchecked") @@ -149,13 +196,18 @@ static void finishPrimitiveArray(JsonReader reader, int size, int elementBytes) reader.exitDepth(); } - public static final class IntArrayCodec extends ArrayCodec { - private static final IntArrayCodec INSTANCE = new IntArrayCodec(); + public abstract static class IntArrayCodec extends ArrayCodec { + private static final IntArrayCodec INSTANCE = new SignedIntArrayCodec(); + private static final IntArrayCodec UNSIGNED = new UnsignedIntArrayCodec(); private IntArrayCodec() { super(int.class); } + abstract void writeElement(JsonWriter writer, int value); + + abstract int readElement(JsonReader reader); + @Override public void writeString(StringJsonWriter writer, int[] value) { if (value == null) { @@ -166,7 +218,7 @@ public void writeString(StringJsonWriter writer, int[] value) { writer.writeArrayStart(); for (int i = 0; i < array.length; i++) { writer.writeComma(i); - writer.writeInt(array[i]); + writeElement(writer, array[i]); } writer.writeArrayEnd(); } @@ -181,7 +233,7 @@ public void writeUtf8(Utf8JsonWriter writer, int[] value) { writer.writeArrayStart(); for (int i = 0; i < array.length; i++) { writer.writeComma(i); - writer.writeInt(array[i]); + writeElement(writer, array[i]); } writer.writeArrayEnd(); } @@ -207,7 +259,7 @@ public int[] readLatin1(Latin1JsonReader reader) { if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = reader.readIntValue(); + values[size++] = readElement(reader); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishPrimitiveArray(reader, size, Integer.BYTES); @@ -235,7 +287,7 @@ public int[] readUtf16(Utf16JsonReader reader) { if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = reader.readIntValue(); + values[size++] = readElement(reader); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishPrimitiveArray(reader, size, Integer.BYTES); @@ -263,7 +315,7 @@ public int[] readUtf8(Utf8JsonReader reader) { if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = reader.readIntValue(); + values[size++] = readElement(reader); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishPrimitiveArray(reader, size, Integer.BYTES); @@ -271,13 +323,42 @@ public int[] readUtf8(Utf8JsonReader reader) { } } - public static final class LongArrayCodec extends ArrayCodec { - private static final LongArrayCodec INSTANCE = new LongArrayCodec(); + private static final class SignedIntArrayCodec extends IntArrayCodec { + @Override + void writeElement(JsonWriter writer, int value) { + writer.writeInt(value); + } + + @Override + int readElement(JsonReader reader) { + return reader.readInt(); + } + } + + private static final class UnsignedIntArrayCodec extends IntArrayCodec { + @Override + void writeElement(JsonWriter writer, int value) { + writer.writeUnsignedInt(value); + } + + @Override + int readElement(JsonReader reader) { + return reader.readUnsignedInt(); + } + } + + public abstract static class LongArrayCodec extends ArrayCodec { + private static final LongArrayCodec INSTANCE = new SignedLongArrayCodec(); + private static final LongArrayCodec UNSIGNED = new UnsignedLongArrayCodec(); private LongArrayCodec() { super(long.class); } + abstract void writeElement(JsonWriter writer, long value); + + abstract long readElement(JsonReader reader); + private static void finishArray(JsonReader reader, int size) { finishPrimitiveArray(reader, size, Long.BYTES); } @@ -292,7 +373,7 @@ public void writeString(StringJsonWriter writer, long[] value) { writer.writeArrayStart(); for (int i = 0; i < array.length; i++) { writer.writeComma(i); - writer.writeLong(array[i]); + writeElement(writer, array[i]); } writer.writeArrayEnd(); } @@ -307,7 +388,7 @@ public void writeUtf8(Utf8JsonWriter writer, long[] value) { writer.writeArrayStart(); for (int i = 0; i < array.length; i++) { writer.writeComma(i); - writer.writeLong(array[i]); + writeElement(writer, array[i]); } writer.writeArrayEnd(); } @@ -324,25 +405,25 @@ public long[] readLatin1(Latin1JsonReader reader) { return new long[0]; } rejectNull(reader); - long v0 = reader.readLongValue(); + long v0 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 1); return new long[] {v0}; } rejectNull(reader); - long v1 = reader.readLongValue(); + long v1 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 2); return new long[] {v0, v1}; } rejectNull(reader); - long v2 = reader.readLongValue(); + long v2 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 3); return new long[] {v0, v1, v2}; } rejectNull(reader); - long v3 = reader.readLongValue(); + long v3 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 4); return new long[] {v0, v1, v2, v3}; @@ -352,25 +433,25 @@ public long[] readLatin1(Latin1JsonReader reader) { private long[] readLatin1Tail(Latin1JsonReader reader, long v0, long v1, long v2, long v3) { rejectNull(reader); - long v4 = reader.readLongValue(); + long v4 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 5); return new long[] {v0, v1, v2, v3, v4}; } rejectNull(reader); - long v5 = reader.readLongValue(); + long v5 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 6); return new long[] {v0, v1, v2, v3, v4, v5}; } rejectNull(reader); - long v6 = reader.readLongValue(); + long v6 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 7); return new long[] {v0, v1, v2, v3, v4, v5, v6}; } rejectNull(reader); - long v7 = reader.readLongValue(); + long v7 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 8); return new long[] {v0, v1, v2, v3, v4, v5, v6, v7}; @@ -406,7 +487,7 @@ private long[] readLatin1LongTail( if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = reader.readLongValue(); + values[size++] = readElement(reader); } while (reader.consumeNextCommaOrEndArray()); finishArray(reader, size); return Arrays.copyOf(values, size); @@ -424,25 +505,25 @@ public long[] readUtf16(Utf16JsonReader reader) { return new long[0]; } rejectNull(reader); - long v0 = reader.readLongValue(); + long v0 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 1); return new long[] {v0}; } rejectNull(reader); - long v1 = reader.readLongValue(); + long v1 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 2); return new long[] {v0, v1}; } rejectNull(reader); - long v2 = reader.readLongValue(); + long v2 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 3); return new long[] {v0, v1, v2}; } rejectNull(reader); - long v3 = reader.readLongValue(); + long v3 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 4); return new long[] {v0, v1, v2, v3}; @@ -452,25 +533,25 @@ public long[] readUtf16(Utf16JsonReader reader) { private long[] readUtf16Tail(Utf16JsonReader reader, long v0, long v1, long v2, long v3) { rejectNull(reader); - long v4 = reader.readLongValue(); + long v4 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 5); return new long[] {v0, v1, v2, v3, v4}; } rejectNull(reader); - long v5 = reader.readLongValue(); + long v5 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 6); return new long[] {v0, v1, v2, v3, v4, v5}; } rejectNull(reader); - long v6 = reader.readLongValue(); + long v6 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 7); return new long[] {v0, v1, v2, v3, v4, v5, v6}; } rejectNull(reader); - long v7 = reader.readLongValue(); + long v7 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 8); return new long[] {v0, v1, v2, v3, v4, v5, v6, v7}; @@ -506,7 +587,7 @@ private long[] readUtf16LongTail( if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = reader.readLongValue(); + values[size++] = readElement(reader); } while (reader.consumeNextCommaOrEndArray()); finishArray(reader, size); return Arrays.copyOf(values, size); @@ -524,25 +605,25 @@ public long[] readUtf8(Utf8JsonReader reader) { return new long[0]; } rejectNull(reader); - long v0 = reader.readLongValue(); + long v0 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 1); return new long[] {v0}; } rejectNull(reader); - long v1 = reader.readLongValue(); + long v1 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 2); return new long[] {v0, v1}; } rejectNull(reader); - long v2 = reader.readLongValue(); + long v2 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 3); return new long[] {v0, v1, v2}; } rejectNull(reader); - long v3 = reader.readLongValue(); + long v3 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 4); return new long[] {v0, v1, v2, v3}; @@ -552,25 +633,25 @@ public long[] readUtf8(Utf8JsonReader reader) { private long[] readUtf8Tail(Utf8JsonReader reader, long v0, long v1, long v2, long v3) { rejectNull(reader); - long v4 = reader.readLongValue(); + long v4 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 5); return new long[] {v0, v1, v2, v3, v4}; } rejectNull(reader); - long v5 = reader.readLongValue(); + long v5 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 6); return new long[] {v0, v1, v2, v3, v4, v5}; } rejectNull(reader); - long v6 = reader.readLongValue(); + long v6 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 7); return new long[] {v0, v1, v2, v3, v4, v5, v6}; } rejectNull(reader); - long v7 = reader.readLongValue(); + long v7 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishArray(reader, 8); return new long[] {v0, v1, v2, v3, v4, v5, v6, v7}; @@ -608,13 +689,37 @@ private long[] readUtf8LongTail( if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = reader.readLongValue(); + values[size++] = readElement(reader); } while (reader.consumeNextCommaOrEndArray()); finishArray(reader, size); return Arrays.copyOf(values, size); } } + private static final class SignedLongArrayCodec extends LongArrayCodec { + @Override + void writeElement(JsonWriter writer, long value) { + writer.writeLong(value); + } + + @Override + long readElement(JsonReader reader) { + return reader.readLong(); + } + } + + private static final class UnsignedLongArrayCodec extends LongArrayCodec { + @Override + void writeElement(JsonWriter writer, long value) { + writer.writeUnsignedLong(value); + } + + @Override + long readElement(JsonReader reader) { + return reader.readUnsignedLong(); + } + } + public static final class BooleanArrayCodec extends ArrayCodec { private static final BooleanArrayCodec INSTANCE = new BooleanArrayCodec(); private static final int ELEMENT_BYTES = 1; @@ -738,13 +843,18 @@ public boolean[] readUtf8(Utf8JsonReader reader) { } } - public static final class ShortArrayCodec extends ArrayCodec { - private static final ShortArrayCodec INSTANCE = new ShortArrayCodec(); + public abstract static class ShortArrayCodec extends ArrayCodec { + private static final ShortArrayCodec INSTANCE = new SignedShortArrayCodec(); + private static final ShortArrayCodec UNSIGNED = new UnsignedShortArrayCodec(); private ShortArrayCodec() { super(short.class); } + abstract void writeElement(JsonWriter writer, short value); + + abstract short readElement(JsonReader reader); + @Override public void writeString(StringJsonWriter writer, short[] value) { if (value == null) { @@ -755,7 +865,7 @@ public void writeString(StringJsonWriter writer, short[] value) { writer.writeArrayStart(); for (int i = 0; i < array.length; i++) { writer.writeComma(i); - writer.writeInt(array[i]); + writeElement(writer, array[i]); } writer.writeArrayEnd(); } @@ -770,7 +880,7 @@ public void writeUtf8(Utf8JsonWriter writer, short[] value) { writer.writeArrayStart(); for (int i = 0; i < array.length; i++) { writer.writeComma(i); - writer.writeInt(array[i]); + writeElement(writer, array[i]); } writer.writeArrayEnd(); } @@ -796,7 +906,7 @@ public short[] readLatin1(Latin1JsonReader reader) { if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = readShort(reader.readIntValue()); + values[size++] = readElement(reader); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishPrimitiveArray(reader, size, Short.BYTES); @@ -824,7 +934,7 @@ public short[] readUtf16(Utf16JsonReader reader) { if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = readShort(reader.readIntValue()); + values[size++] = readElement(reader); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishPrimitiveArray(reader, size, Short.BYTES); @@ -852,7 +962,7 @@ public short[] readUtf8(Utf8JsonReader reader) { if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = readShort(reader.readIntValue()); + values[size++] = readElement(reader); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishPrimitiveArray(reader, size, Short.BYTES); @@ -860,13 +970,42 @@ public short[] readUtf8(Utf8JsonReader reader) { } } - public static final class ByteArrayCodec extends ArrayCodec { - private static final ByteArrayCodec INSTANCE = new ByteArrayCodec(); + private static final class SignedShortArrayCodec extends ShortArrayCodec { + @Override + void writeElement(JsonWriter writer, short value) { + writer.writeInt(value); + } + + @Override + short readElement(JsonReader reader) { + return readShort(reader.readInt()); + } + } + + private static final class UnsignedShortArrayCodec extends ShortArrayCodec { + @Override + void writeElement(JsonWriter writer, short value) { + writer.writeInt(Short.toUnsignedInt(value)); + } + + @Override + short readElement(JsonReader reader) { + return readUnsignedShort(reader.readUnsignedInt()); + } + } + + public abstract static class ByteArrayCodec extends ArrayCodec { + private static final ByteArrayCodec INSTANCE = new SignedByteArrayCodec(); + private static final ByteArrayCodec UNSIGNED = new UnsignedByteArrayCodec(); private ByteArrayCodec() { super(byte.class); } + abstract void writeElement(JsonWriter writer, byte value); + + abstract byte readElement(JsonReader reader); + @Override public void writeString(StringJsonWriter writer, byte[] value) { if (value == null) { @@ -877,7 +1016,7 @@ public void writeString(StringJsonWriter writer, byte[] value) { writer.writeArrayStart(); for (int i = 0; i < array.length; i++) { writer.writeComma(i); - writer.writeInt(array[i]); + writeElement(writer, array[i]); } writer.writeArrayEnd(); } @@ -892,7 +1031,7 @@ public void writeUtf8(Utf8JsonWriter writer, byte[] value) { writer.writeArrayStart(); for (int i = 0; i < array.length; i++) { writer.writeComma(i); - writer.writeInt(array[i]); + writeElement(writer, array[i]); } writer.writeArrayEnd(); } @@ -918,7 +1057,7 @@ public byte[] readLatin1(Latin1JsonReader reader) { if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = readByte(reader.readIntValue()); + values[size++] = readElement(reader); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishPrimitiveArray(reader, size, Byte.BYTES); @@ -946,7 +1085,7 @@ public byte[] readUtf16(Utf16JsonReader reader) { if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = readByte(reader.readIntValue()); + values[size++] = readElement(reader); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishPrimitiveArray(reader, size, Byte.BYTES); @@ -974,7 +1113,7 @@ public byte[] readUtf8(Utf8JsonReader reader) { if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = readByte(reader.readIntValue()); + values[size++] = readElement(reader); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishPrimitiveArray(reader, size, Byte.BYTES); @@ -982,6 +1121,30 @@ public byte[] readUtf8(Utf8JsonReader reader) { } } + private static final class SignedByteArrayCodec extends ByteArrayCodec { + @Override + void writeElement(JsonWriter writer, byte value) { + writer.writeInt(value); + } + + @Override + byte readElement(JsonReader reader) { + return readByte(reader.readInt()); + } + } + + private static final class UnsignedByteArrayCodec extends ByteArrayCodec { + @Override + void writeElement(JsonWriter writer, byte value) { + writer.writeInt(Byte.toUnsignedInt(value)); + } + + @Override + byte readElement(JsonReader reader) { + return readUnsignedByte(reader.readUnsignedInt()); + } + } + public static final class CharArrayCodec extends ArrayCodec { private static final CharArrayCodec INSTANCE = new CharArrayCodec(); @@ -1343,10 +1506,13 @@ public double[] readUtf8(Utf8JsonReader reader) { } public static final class StringArrayCodec extends ArrayCodec { - private static final StringArrayCodec INSTANCE = new StringArrayCodec(); + private static final StringArrayCodec INSTANCE = new StringArrayCodec(false); + private static final StringArrayCodec NON_NULL = new StringArrayCodec(true); + private final boolean rejectsNull; - private StringArrayCodec() { + private StringArrayCodec(boolean rejectsNull) { super(String.class); + this.rejectsNull = rejectsNull; } @Override @@ -1358,7 +1524,11 @@ public void writeString(StringJsonWriter writer, String[] value) { String[] array = value; writer.writeArrayStart(); for (int i = 0; i < array.length; i++) { - writer.writeStringElement(i, array[i]); + String element = array[i]; + if (element == null && rejectsNull) { + rejectNullElement(); + } + writer.writeStringElement(i, element); } writer.writeArrayEnd(); } @@ -1372,11 +1542,38 @@ public void writeUtf8(Utf8JsonWriter writer, String[] value) { String[] array = value; writer.writeArrayStart(); for (int i = 0; i < array.length; i++) { - writer.writeStringElement(i, array[i]); + String element = array[i]; + if (element == null && rejectsNull) { + rejectNullElement(); + } + writer.writeStringElement(i, element); } writer.writeArrayEnd(); } + private String readElement(Latin1JsonReader reader) { + return requireElement(reader.readNextNullableString()); + } + + private String readElement(Utf16JsonReader reader) { + return requireElement(reader.readNextNullableString()); + } + + private String readElement(Utf8JsonReader reader) { + return requireElement(reader.readNextNullableString()); + } + + private String requireElement(String element) { + if (element == null && rejectsNull) { + return rejectNullElement(); + } + return element; + } + + private static String rejectNullElement() { + throw new ForyJsonException("JSON array element is not nullable"); + } + @Override public String[] readLatin1(Latin1JsonReader reader) { if (reader.tryReadNullToken()) { @@ -1388,22 +1585,22 @@ public String[] readLatin1(Latin1JsonReader reader) { finishReferenceArray(reader, 0); return new String[0]; } - String v0 = reader.readNextNullableString(); + String v0 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 1); return new String[] {v0}; } - String v1 = reader.readNextNullableString(); + String v1 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 2); return new String[] {v0, v1}; } - String v2 = reader.readNextNullableString(); + String v2 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 3); return new String[] {v0, v1, v2}; } - String v3 = reader.readNextNullableString(); + String v3 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 4); return new String[] {v0, v1, v2, v3}; @@ -1413,27 +1610,27 @@ public String[] readLatin1(Latin1JsonReader reader) { private String[] readLatin1Tail( Latin1JsonReader reader, String v0, String v1, String v2, String v3) { - String v4 = reader.readNextNullableString(); + String v4 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 5); return new String[] {v0, v1, v2, v3, v4}; } - String v5 = reader.readNextNullableString(); + String v5 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 6); return new String[] {v0, v1, v2, v3, v4, v5}; } - String v6 = reader.readNextNullableString(); + String v6 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 7); return new String[] {v0, v1, v2, v3, v4, v5, v6}; } - String v7 = reader.readNextNullableString(); + String v7 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 8); return new String[] {v0, v1, v2, v3, v4, v5, v6, v7}; } - String v8 = reader.readNextNullableString(); + String v8 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 9); return new String[] {v0, v1, v2, v3, v4, v5, v6, v7, v8}; @@ -1468,7 +1665,7 @@ private String[] readLatin1LongTail( if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = reader.readNextNullableString(); + values[size++] = readElement(reader); } while (reader.consumeNextCommaOrEndArray()); finishReferenceArray(reader, size); return Arrays.copyOf(values, size); @@ -1485,22 +1682,22 @@ public String[] readUtf16(Utf16JsonReader reader) { finishReferenceArray(reader, 0); return new String[0]; } - String v0 = reader.readNextNullableString(); + String v0 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 1); return new String[] {v0}; } - String v1 = reader.readNextNullableString(); + String v1 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 2); return new String[] {v0, v1}; } - String v2 = reader.readNextNullableString(); + String v2 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 3); return new String[] {v0, v1, v2}; } - String v3 = reader.readNextNullableString(); + String v3 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 4); return new String[] {v0, v1, v2, v3}; @@ -1510,27 +1707,27 @@ public String[] readUtf16(Utf16JsonReader reader) { private String[] readUtf16Tail( Utf16JsonReader reader, String v0, String v1, String v2, String v3) { - String v4 = reader.readNextNullableString(); + String v4 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 5); return new String[] {v0, v1, v2, v3, v4}; } - String v5 = reader.readNextNullableString(); + String v5 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 6); return new String[] {v0, v1, v2, v3, v4, v5}; } - String v6 = reader.readNextNullableString(); + String v6 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 7); return new String[] {v0, v1, v2, v3, v4, v5, v6}; } - String v7 = reader.readNextNullableString(); + String v7 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 8); return new String[] {v0, v1, v2, v3, v4, v5, v6, v7}; } - String v8 = reader.readNextNullableString(); + String v8 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 9); return new String[] {v0, v1, v2, v3, v4, v5, v6, v7, v8}; @@ -1565,7 +1762,7 @@ private String[] readUtf16LongTail( if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = reader.readNextNullableString(); + values[size++] = readElement(reader); } while (reader.consumeNextCommaOrEndArray()); finishReferenceArray(reader, size); return Arrays.copyOf(values, size); @@ -1582,22 +1779,22 @@ public String[] readUtf8(Utf8JsonReader reader) { finishReferenceArray(reader, 0); return new String[0]; } - String v0 = reader.readNextNullableString(); + String v0 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 1); return new String[] {v0}; } - String v1 = reader.readNextNullableString(); + String v1 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 2); return new String[] {v0, v1}; } - String v2 = reader.readNextNullableString(); + String v2 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 3); return new String[] {v0, v1, v2}; } - String v3 = reader.readNextNullableString(); + String v3 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 4); return new String[] {v0, v1, v2, v3}; @@ -1607,27 +1804,27 @@ public String[] readUtf8(Utf8JsonReader reader) { private String[] readUtf8Tail( Utf8JsonReader reader, String v0, String v1, String v2, String v3) { - String v4 = reader.readNextNullableString(); + String v4 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 5); return new String[] {v0, v1, v2, v3, v4}; } - String v5 = reader.readNextNullableString(); + String v5 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 6); return new String[] {v0, v1, v2, v3, v4, v5}; } - String v6 = reader.readNextNullableString(); + String v6 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 7); return new String[] {v0, v1, v2, v3, v4, v5, v6}; } - String v7 = reader.readNextNullableString(); + String v7 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 8); return new String[] {v0, v1, v2, v3, v4, v5, v6, v7}; } - String v8 = reader.readNextNullableString(); + String v8 = readElement(reader); if (!reader.consumeNextCommaOrEndArray()) { finishReferenceArray(reader, 9); return new String[] {v0, v1, v2, v3, v4, v5, v6, v7, v8}; @@ -1664,14 +1861,14 @@ private String[] readUtf8LongTail( if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = reader.readNextNullableString(); + values[size++] = readElement(reader); } while (reader.consumeNextCommaOrEndArray()); finishReferenceArray(reader, size); return Arrays.copyOf(values, size); } } - private static final class ObjectArrayCodec extends ArrayCodec { + private static class ObjectArrayCodec extends ArrayCodec { private static final int VALUES_CACHE_DEPTH = 8; private static final int INITIAL_VALUES_SIZE = 8; private static final int MAX_CACHED_VALUES_SIZE = 1024; @@ -1697,7 +1894,7 @@ public void writeString(StringJsonWriter writer, T value) { writer.writeArrayStart(); for (int i = 0; i < array.length; i++) { writer.writeComma(i); - codec.writeString(writer, array[i]); + writeElement(writer, codec, array[i]); } writer.writeArrayEnd(); } @@ -1713,7 +1910,7 @@ public void writeUtf8(Utf8JsonWriter writer, T value) { writer.writeArrayStart(); for (int i = 0; i < array.length; i++) { writer.writeComma(i); - codec.writeUtf8(writer, array[i]); + writeElement(writer, codec, array[i]); } writer.writeArrayEnd(); } @@ -1746,7 +1943,7 @@ public T readLatin1(Latin1JsonReader reader) { if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = codec.readLatin1(reader); + values[size++] = readElement(reader, codec); } while (reader.consumeNextCommaOrEndArray()); } reader.reserveGraphMemory(ARRAY_HEADER_BYTES + (size & ARRAY_BATCH_MASK) * REFERENCE_BYTES); @@ -1788,7 +1985,7 @@ public T readUtf16(Utf16JsonReader reader) { if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = codec.readUtf16(reader); + values[size++] = readElement(reader, codec); } while (reader.consumeNextCommaOrEndArray()); } reader.reserveGraphMemory(ARRAY_HEADER_BYTES + (size & ARRAY_BATCH_MASK) * REFERENCE_BYTES); @@ -1830,7 +2027,7 @@ public T readUtf8(Utf8JsonReader reader) { if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = codec.readUtf8(reader); + values[size++] = readElement(reader, codec); } while (reader.consumeNextCommaOrEndArray()); } reader.reserveGraphMemory(ARRAY_HEADER_BYTES + (size & ARRAY_BATCH_MASK) * REFERENCE_BYTES); @@ -1860,6 +2057,26 @@ private void releaseValues( valuesDepth = depth; } + void writeElement(StringJsonWriter writer, StringWriterCodec codec, Object element) { + codec.writeString(writer, element); + } + + void writeElement(Utf8JsonWriter writer, Utf8WriterCodec codec, Object element) { + codec.writeUtf8(writer, element); + } + + Object readElement(Latin1JsonReader reader, Latin1ReaderCodec codec) { + return codec.readLatin1(reader); + } + + Object readElement(Utf16JsonReader reader, Utf16ReaderCodec codec) { + return codec.readUtf16(reader); + } + + Object readElement(Utf8JsonReader reader, Utf8ReaderCodec codec) { + return codec.readUtf8(reader); + } + @SuppressWarnings("unchecked") private T newArray(int size) { // The factory constructs this codec only for reference-array components. @@ -1867,6 +2084,56 @@ private T newArray(int size) { } } + private static final class NonNullObjectArrayCodec extends ObjectArrayCodec { + private NonNullObjectArrayCodec(Class componentType, JsonTypeInfo elementTypeInfo) { + super(componentType, elementTypeInfo); + } + + @Override + void writeElement(StringJsonWriter writer, StringWriterCodec codec, Object element) { + if (element == null) { + rejectNullElement(); + } + codec.writeString(writer, element); + } + + @Override + void writeElement(Utf8JsonWriter writer, Utf8WriterCodec codec, Object element) { + if (element == null) { + rejectNullElement(); + } + codec.writeUtf8(writer, element); + } + + @Override + Object readElement(Latin1JsonReader reader, Latin1ReaderCodec codec) { + if (reader.tryReadNullToken()) { + return rejectNullElement(); + } + return codec.readLatin1(reader); + } + + @Override + Object readElement(Utf16JsonReader reader, Utf16ReaderCodec codec) { + if (reader.tryReadNullToken()) { + return rejectNullElement(); + } + return codec.readUtf16(reader); + } + + @Override + Object readElement(Utf8JsonReader reader, Utf8ReaderCodec codec) { + if (reader.tryReadNullToken()) { + return rejectNullElement(); + } + return codec.readUtf8(reader); + } + } + + private static Object rejectNullElement() { + throw new ForyJsonException("JSON array element is not nullable"); + } + private static final class CustomPrimitiveArrayCodec extends ArrayCodec { private static final int INITIAL_SIZE = 8; @@ -2042,10 +2309,13 @@ private T castArray(Object value) { } public static final class BoxedIntArrayCodec extends ArrayCodec { - private static final BoxedIntArrayCodec INSTANCE = new BoxedIntArrayCodec(); + private static final BoxedIntArrayCodec INSTANCE = new BoxedIntArrayCodec(false); + private static final BoxedIntArrayCodec NON_NULL = new BoxedIntArrayCodec(true); + private final boolean rejectsNull; - private BoxedIntArrayCodec() { + private BoxedIntArrayCodec(boolean rejectsNull) { super(Integer.class); + this.rejectsNull = rejectsNull; } @Override @@ -2060,7 +2330,7 @@ public void writeString(StringJsonWriter writer, Integer[] value) { writer.writeComma(i); Integer element = array[i]; if (element == null) { - writer.writeNull(); + writeReferenceNull(writer, rejectsNull); } else { writer.writeInt(element); } @@ -2080,7 +2350,7 @@ public void writeUtf8(Utf8JsonWriter writer, Integer[] value) { writer.writeComma(i); Integer element = array[i]; if (element == null) { - writer.writeNull(); + writeReferenceNull(writer, rejectsNull); } else { writer.writeInt(element); } @@ -2107,7 +2377,9 @@ public Integer[] readLatin1(Latin1JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Integer.valueOf(reader.readIntValue()); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Integer.valueOf(reader.readIntValue()); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2133,7 +2405,9 @@ public Integer[] readUtf16(Utf16JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Integer.valueOf(reader.readIntValue()); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Integer.valueOf(reader.readIntValue()); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2159,7 +2433,9 @@ public Integer[] readUtf8(Utf8JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Integer.valueOf(reader.readIntValue()); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Integer.valueOf(reader.readIntValue()); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2168,10 +2444,13 @@ public Integer[] readUtf8(Utf8JsonReader reader) { } public static final class BoxedLongArrayCodec extends ArrayCodec { - private static final BoxedLongArrayCodec INSTANCE = new BoxedLongArrayCodec(); + private static final BoxedLongArrayCodec INSTANCE = new BoxedLongArrayCodec(false); + private static final BoxedLongArrayCodec NON_NULL = new BoxedLongArrayCodec(true); + private final boolean rejectsNull; - private BoxedLongArrayCodec() { + private BoxedLongArrayCodec(boolean rejectsNull) { super(Long.class); + this.rejectsNull = rejectsNull; } @Override @@ -2186,7 +2465,7 @@ public void writeString(StringJsonWriter writer, Long[] value) { writer.writeComma(i); Long element = array[i]; if (element == null) { - writer.writeNull(); + writeReferenceNull(writer, rejectsNull); } else { writer.writeLong(element); } @@ -2206,7 +2485,7 @@ public void writeUtf8(Utf8JsonWriter writer, Long[] value) { writer.writeComma(i); Long element = array[i]; if (element == null) { - writer.writeNull(); + writeReferenceNull(writer, rejectsNull); } else { writer.writeLong(element); } @@ -2233,7 +2512,9 @@ public Long[] readLatin1(Latin1JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Long.valueOf(reader.readLongValue()); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Long.valueOf(reader.readLongValue()); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2259,7 +2540,9 @@ public Long[] readUtf16(Utf16JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Long.valueOf(reader.readLongValue()); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Long.valueOf(reader.readLongValue()); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2285,7 +2568,9 @@ public Long[] readUtf8(Utf8JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Long.valueOf(reader.readLongValue()); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Long.valueOf(reader.readLongValue()); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2294,10 +2579,13 @@ public Long[] readUtf8(Utf8JsonReader reader) { } public static final class BoxedBooleanArrayCodec extends ArrayCodec { - private static final BoxedBooleanArrayCodec INSTANCE = new BoxedBooleanArrayCodec(); + private static final BoxedBooleanArrayCodec INSTANCE = new BoxedBooleanArrayCodec(false); + private static final BoxedBooleanArrayCodec NON_NULL = new BoxedBooleanArrayCodec(true); + private final boolean rejectsNull; - private BoxedBooleanArrayCodec() { + private BoxedBooleanArrayCodec(boolean rejectsNull) { super(Boolean.class); + this.rejectsNull = rejectsNull; } @Override @@ -2312,7 +2600,7 @@ public void writeString(StringJsonWriter writer, Boolean[] value) { writer.writeComma(i); Boolean element = array[i]; if (element == null) { - writer.writeNull(); + writeReferenceNull(writer, rejectsNull); } else { writer.writeBoolean(element); } @@ -2332,7 +2620,7 @@ public void writeUtf8(Utf8JsonWriter writer, Boolean[] value) { writer.writeComma(i); Boolean element = array[i]; if (element == null) { - writer.writeNull(); + writeReferenceNull(writer, rejectsNull); } else { writer.writeBoolean(element); } @@ -2359,7 +2647,9 @@ public Boolean[] readLatin1(Latin1JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Boolean.valueOf(reader.readBooleanValue()); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Boolean.valueOf(reader.readBooleanValue()); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2385,7 +2675,9 @@ public Boolean[] readUtf16(Utf16JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Boolean.valueOf(reader.readBooleanValue()); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Boolean.valueOf(reader.readBooleanValue()); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2411,7 +2703,9 @@ public Boolean[] readUtf8(Utf8JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Boolean.valueOf(reader.readBooleanValue()); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Boolean.valueOf(reader.readBooleanValue()); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2420,10 +2714,13 @@ public Boolean[] readUtf8(Utf8JsonReader reader) { } public static final class BoxedShortArrayCodec extends ArrayCodec { - private static final BoxedShortArrayCodec INSTANCE = new BoxedShortArrayCodec(); + private static final BoxedShortArrayCodec INSTANCE = new BoxedShortArrayCodec(false); + private static final BoxedShortArrayCodec NON_NULL = new BoxedShortArrayCodec(true); + private final boolean rejectsNull; - private BoxedShortArrayCodec() { + private BoxedShortArrayCodec(boolean rejectsNull) { super(Short.class); + this.rejectsNull = rejectsNull; } @Override @@ -2438,7 +2735,7 @@ public void writeString(StringJsonWriter writer, Short[] value) { writer.writeComma(i); Short element = array[i]; if (element == null) { - writer.writeNull(); + writeReferenceNull(writer, rejectsNull); } else { writer.writeInt(element); } @@ -2458,7 +2755,7 @@ public void writeUtf8(Utf8JsonWriter writer, Short[] value) { writer.writeComma(i); Short element = array[i]; if (element == null) { - writer.writeNull(); + writeReferenceNull(writer, rejectsNull); } else { writer.writeInt(element); } @@ -2485,7 +2782,9 @@ public Short[] readLatin1(Latin1JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Short.valueOf(readShort(reader.readIntValue())); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Short.valueOf(readShort(reader.readIntValue())); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2511,7 +2810,9 @@ public Short[] readUtf16(Utf16JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Short.valueOf(readShort(reader.readIntValue())); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Short.valueOf(readShort(reader.readIntValue())); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2537,7 +2838,9 @@ public Short[] readUtf8(Utf8JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Short.valueOf(readShort(reader.readIntValue())); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Short.valueOf(readShort(reader.readIntValue())); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2546,10 +2849,13 @@ public Short[] readUtf8(Utf8JsonReader reader) { } public static final class BoxedByteArrayCodec extends ArrayCodec { - private static final BoxedByteArrayCodec INSTANCE = new BoxedByteArrayCodec(); + private static final BoxedByteArrayCodec INSTANCE = new BoxedByteArrayCodec(false); + private static final BoxedByteArrayCodec NON_NULL = new BoxedByteArrayCodec(true); + private final boolean rejectsNull; - private BoxedByteArrayCodec() { + private BoxedByteArrayCodec(boolean rejectsNull) { super(Byte.class); + this.rejectsNull = rejectsNull; } @Override @@ -2564,7 +2870,7 @@ public void writeString(StringJsonWriter writer, Byte[] value) { writer.writeComma(i); Byte element = array[i]; if (element == null) { - writer.writeNull(); + writeReferenceNull(writer, rejectsNull); } else { writer.writeInt(element); } @@ -2584,7 +2890,7 @@ public void writeUtf8(Utf8JsonWriter writer, Byte[] value) { writer.writeComma(i); Byte element = array[i]; if (element == null) { - writer.writeNull(); + writeReferenceNull(writer, rejectsNull); } else { writer.writeInt(element); } @@ -2611,7 +2917,9 @@ public Byte[] readLatin1(Latin1JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Byte.valueOf(readByte(reader.readIntValue())); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Byte.valueOf(readByte(reader.readIntValue())); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2637,7 +2945,9 @@ public Byte[] readUtf16(Utf16JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Byte.valueOf(readByte(reader.readIntValue())); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Byte.valueOf(readByte(reader.readIntValue())); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2663,7 +2973,9 @@ public Byte[] readUtf8(Utf8JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Byte.valueOf(readByte(reader.readIntValue())); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Byte.valueOf(readByte(reader.readIntValue())); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2672,10 +2984,13 @@ public Byte[] readUtf8(Utf8JsonReader reader) { } public static final class BoxedCharArrayCodec extends ArrayCodec { - private static final BoxedCharArrayCodec INSTANCE = new BoxedCharArrayCodec(); + private static final BoxedCharArrayCodec INSTANCE = new BoxedCharArrayCodec(false); + private static final BoxedCharArrayCodec NON_NULL = new BoxedCharArrayCodec(true); + private final boolean rejectsNull; - private BoxedCharArrayCodec() { + private BoxedCharArrayCodec(boolean rejectsNull) { super(Character.class); + this.rejectsNull = rejectsNull; } @Override @@ -2690,7 +3005,7 @@ public void writeString(StringJsonWriter writer, Character[] value) { writer.writeComma(i); Character element = array[i]; if (element == null) { - writer.writeNull(); + writeReferenceNull(writer, rejectsNull); } else { writer.writeChar(element); } @@ -2710,7 +3025,7 @@ public void writeUtf8(Utf8JsonWriter writer, Character[] value) { writer.writeComma(i); Character element = array[i]; if (element == null) { - writer.writeNull(); + writeReferenceNull(writer, rejectsNull); } else { writer.writeChar(element); } @@ -2737,7 +3052,8 @@ public Character[] readLatin1(Latin1JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } String element = reader.readNextNullableString(); - values[size++] = element == null ? null : Character.valueOf(readChar(element)); + values[size++] = + element == null ? referenceNull(rejectsNull) : Character.valueOf(readChar(element)); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2763,7 +3079,8 @@ public Character[] readUtf16(Utf16JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } String element = reader.readNextNullableString(); - values[size++] = element == null ? null : Character.valueOf(readChar(element)); + values[size++] = + element == null ? referenceNull(rejectsNull) : Character.valueOf(readChar(element)); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2789,7 +3106,8 @@ public Character[] readUtf8(Utf8JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } String element = reader.readNextNullableString(); - values[size++] = element == null ? null : Character.valueOf(readChar(element)); + values[size++] = + element == null ? referenceNull(rejectsNull) : Character.valueOf(readChar(element)); } while (reader.consumeNextToken(',')); reader.expectNextToken(']'); finishReferenceArray(reader, size); @@ -2798,10 +3116,13 @@ public Character[] readUtf8(Utf8JsonReader reader) { } public static final class BoxedFloatArrayCodec extends ArrayCodec { - private static final BoxedFloatArrayCodec INSTANCE = new BoxedFloatArrayCodec(); + private static final BoxedFloatArrayCodec INSTANCE = new BoxedFloatArrayCodec(false); + private static final BoxedFloatArrayCodec NON_NULL = new BoxedFloatArrayCodec(true); + private final boolean rejectsNull; - private BoxedFloatArrayCodec() { + private BoxedFloatArrayCodec(boolean rejectsNull) { super(Float.class); + this.rejectsNull = rejectsNull; } @Override @@ -2816,7 +3137,7 @@ public void writeString(StringJsonWriter writer, Float[] value) { writer.writeComma(i); Float element = array[i]; if (element == null) { - writer.writeNull(); + writeReferenceNull(writer, rejectsNull); } else { writer.writeFloat(element); } @@ -2836,7 +3157,7 @@ public void writeUtf8(Utf8JsonWriter writer, Float[] value) { writer.writeComma(i); Float element = array[i]; if (element == null) { - writer.writeNull(); + writeReferenceNull(writer, rejectsNull); } else { writer.writeFloat(element); } @@ -2863,7 +3184,9 @@ public Float[] readLatin1(Latin1JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Float.valueOf(reader.readNextFloatValue()); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Float.valueOf(reader.readNextFloatValue()); } while (reader.consumeNextCommaOrEndArray()); finishReferenceArray(reader, size); return Arrays.copyOf(values, size); @@ -2888,7 +3211,9 @@ public Float[] readUtf16(Utf16JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Float.valueOf(reader.readNextFloatValue()); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Float.valueOf(reader.readNextFloatValue()); } while (reader.consumeNextCommaOrEndArray()); finishReferenceArray(reader, size); return Arrays.copyOf(values, size); @@ -2913,7 +3238,9 @@ public Float[] readUtf8(Utf8JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Float.valueOf(reader.readNextFloatValue()); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Float.valueOf(reader.readNextFloatValue()); } while (reader.consumeNextCommaOrEndArray()); finishReferenceArray(reader, size); return Arrays.copyOf(values, size); @@ -2921,10 +3248,13 @@ public Float[] readUtf8(Utf8JsonReader reader) { } public static final class BoxedDoubleArrayCodec extends ArrayCodec { - private static final BoxedDoubleArrayCodec INSTANCE = new BoxedDoubleArrayCodec(); + private static final BoxedDoubleArrayCodec INSTANCE = new BoxedDoubleArrayCodec(false); + private static final BoxedDoubleArrayCodec NON_NULL = new BoxedDoubleArrayCodec(true); + private final boolean rejectsNull; - private BoxedDoubleArrayCodec() { + private BoxedDoubleArrayCodec(boolean rejectsNull) { super(Double.class); + this.rejectsNull = rejectsNull; } @Override @@ -2939,7 +3269,7 @@ public void writeString(StringJsonWriter writer, Double[] value) { writer.writeComma(i); Double element = array[i]; if (element == null) { - writer.writeNull(); + writeReferenceNull(writer, rejectsNull); } else { writer.writeDouble(element); } @@ -2959,7 +3289,7 @@ public void writeUtf8(Utf8JsonWriter writer, Double[] value) { writer.writeComma(i); Double element = array[i]; if (element == null) { - writer.writeNull(); + writeReferenceNull(writer, rejectsNull); } else { writer.writeDouble(element); } @@ -2986,7 +3316,9 @@ public Double[] readLatin1(Latin1JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Double.valueOf(reader.readNextDoubleValue()); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Double.valueOf(reader.readNextDoubleValue()); } while (reader.consumeNextCommaOrEndArray()); finishReferenceArray(reader, size); return Arrays.copyOf(values, size); @@ -3011,7 +3343,9 @@ public Double[] readUtf16(Utf16JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Double.valueOf(reader.readNextDoubleValue()); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Double.valueOf(reader.readNextDoubleValue()); } while (reader.consumeNextCommaOrEndArray()); finishReferenceArray(reader, size); return Arrays.copyOf(values, size); @@ -3036,13 +3370,29 @@ public Double[] readUtf8(Utf8JsonReader reader) { values = Arrays.copyOf(values, values.length << 1); } values[size++] = - reader.tryReadNextNullToken() ? null : Double.valueOf(reader.readNextDoubleValue()); + reader.tryReadNextNullToken() + ? referenceNull(rejectsNull) + : Double.valueOf(reader.readNextDoubleValue()); } while (reader.consumeNextCommaOrEndArray()); finishReferenceArray(reader, size); return Arrays.copyOf(values, size); } } + private static T referenceNull(boolean rejectsNull) { + if (rejectsNull) { + throw new ForyJsonException("JSON array element is not nullable"); + } + return null; + } + + private static void writeReferenceNull(JsonWriter writer, boolean rejectsNull) { + if (rejectsNull) { + throw new ForyJsonException("JSON array element is not nullable"); + } + writer.writeNull(); + } + private static void rejectNull(JsonReader reader) { if (reader.tryReadNull()) { throw new ForyJsonException("Cannot read null into primitive array element"); @@ -3081,6 +3431,21 @@ private static byte readByte(int value) { return (byte) value; } + // JsonReader.readUnsignedInt returns raw int bits, so narrowing gates must compare them unsigned. + private static short readUnsignedShort(int value) { + if (Integer.compareUnsigned(value, 0xffff) > 0) { + throw new ForyJsonException("Unsigned short overflow"); + } + return (short) value; + } + + private static byte readUnsignedByte(int value) { + if (Integer.compareUnsigned(value, 0xff) > 0) { + throw new ForyJsonException("Unsigned byte overflow"); + } + return (byte) value; + } + private static char readChar(JsonReader reader) { return readChar(reader.readString()); } diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/ClosedSubtypeCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/ClosedSubtypeCodec.java index 4d16d974dd..9fa51e097e 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/ClosedSubtypeCodec.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/ClosedSubtypeCodec.java @@ -54,18 +54,19 @@ public final class ClosedSubtypeCodec implements CompositeJsonCodec { private final Class baseType; private final JsonSubTypesInfo definition; private final TypeRef declaredType; - private final Object[] singletonValues; + private final ObjectCodec[] exactChildCodecs; private final JsonTypeInfo[] children; private final ObjectCodec[] objectCodecs; private JsonFieldTable[] inlineReadTables; - private volatile Latin1ReaderCodec[] inlineLatin1Readers; - private volatile Utf16ReaderCodec[] inlineUtf16Readers; - private volatile Utf8ReaderCodec[] inlineUtf8Readers; + private InlineReader[] fixedInlineReaders; + private Latin1ReaderCodec[] inlineLatin1Readers; + private Utf16ReaderCodec[] inlineUtf16Readers; + private Utf8ReaderCodec[] inlineUtf8Readers; /** Creates an unresolved resolver-local dispatcher shell for a validated subtype definition. */ @Internal public ClosedSubtypeCodec(Class baseType, JsonSubTypesInfo definition) { - this(baseType, definition, null); + this(baseType, definition, null, null); } /** Creates an unresolved dispatcher with an exact factory-owned declared root type. */ @@ -75,29 +76,32 @@ public ClosedSubtypeCodec( this(baseType, definition, declaredType, null); } - /** Creates an unresolved dispatcher with parent-owned singleton subtype values. */ + /** Creates an unresolved dispatcher with exact resolver-local object codecs for its children. */ @Internal public ClosedSubtypeCodec( Class baseType, JsonSubTypesInfo definition, TypeRef declaredType, - Object[] singletonValues) { + ObjectCodec[] exactChildCodecs) { this.baseType = baseType; this.definition = definition; this.declaredType = declaredType; - if (singletonValues != null && singletonValues.length != definition.classes.length) { - throw new IllegalArgumentException("Singleton values do not match subtype branches"); - } - this.singletonValues = singletonValues == null ? null : singletonValues.clone(); - if (singletonValues != null) { - for (int i = 0; i < singletonValues.length; i++) { - Object value = singletonValues[i]; - if (value != null && value.getClass() != definition.classes[i]) { - throw new IllegalArgumentException("Singleton value does not match subtype branch " + i); + children = new JsonTypeInfo[definition.classes.length]; + if (exactChildCodecs != null) { + if (exactChildCodecs.length != children.length) { + throw new IllegalArgumentException("Exact subtype codec count does not match definition"); + } + this.exactChildCodecs = exactChildCodecs.clone(); + for (int i = 0; i < children.length; i++) { + ObjectCodec codec = this.exactChildCodecs[i]; + if (codec != null && codec.type() != definition.classes[i]) { + throw new IllegalArgumentException( + "Exact subtype codec does not own " + definition.classes[i].getName()); } } + } else { + this.exactChildCodecs = null; } - children = new JsonTypeInfo[definition.classes.length]; objectCodecs = definition.inclusion == Inclusion.PROPERTY ? (ObjectCodec[]) new ObjectCodec[children.length] @@ -124,12 +128,12 @@ public void resolveTypes(TypeRef type, JsonTypeResolver resolver) { for (int i = 0; i < children.length; i++) { Class subtype = definition.classes[i]; TypeRef childType = rootType.getSubtype(subtype); - Object singleton = singletonValues == null ? null : singletonValues[i]; JsonTypeInfo child = - singleton == null - ? resolver.getSubtypeTypeInfo( - baseType, childType.getType(), subtype, declaredType != null) - : resolver.createLeafTypeInfo(childType, new SingletonSubtypeCodec(singleton)); + resolver.getSubtypeTypeInfo( + baseType, + childType, + declaredType != null, + exactChildCodecs == null ? null : exactChildCodecs[i]); if (definition.inclusion == Inclusion.PROPERTY) { ObjectCodec objectCodec = resolver.canonicalObjectCodec(child); if (objectCodec == null) { @@ -139,18 +143,24 @@ public void resolveTypes(TypeRef type, JsonTypeResolver resolver) { rejectDiscriminatorCollision(objectCodec, definition.scanInfo.property()); objectCodecs[i] = (ObjectCodec) objectCodec; ObjectCodec.AnyInfo any = objectCodec.anyInfo(); - if (any != null && (any.readField() != null || any.readSetter() != null)) { + boolean fixed = objectCodec.fixedInstance(); + if (fixed || any != null && (any.readField() != null || any.readSetter() != null)) { if (inlineReadTables == null) { inlineReadTables = new JsonFieldTable[children.length]; } JsonFieldTable table = objectCodec.readTable().withSkippedName(definition.scanInfo.property()); inlineReadTables[i] = table; + if (fixed) { + if (fixedInlineReaders == null) { + fixedInlineReaders = new InlineReader[children.length]; + } + fixedInlineReaders[i] = new FixedInlineReader((ObjectCodec) objectCodec, table); + } // The subtype scan restores the cursor, so the outer child rereads the discriminator and // needs this parent-local skip table. The resolver constructs a complete immutable array - // of generated readers for each representation and publishes that array in one volatile - // write; never publish its elements independently. Nested child values keep the canonical - // table and canonical capability. + // under its JIT lock and installs the array as one unit; never publish its elements + // independently. Nested child values keep the canonical table and capability. } } children[i] = child; @@ -168,7 +178,7 @@ public void writeString(StringJsonWriter writer, Object value) { writer.writeObjectStart(); writer.writeRawValue( definition.stringSubtypePrefixes[index], definition.stringUtf16SubtypePrefixes[index]); - objectCodecs[index].writeMembers(writer, value, 1); + objectCodecs[index].writeSubtypeMembers(writer, value, 1); writer.writeObjectEnd(); return; } @@ -197,7 +207,7 @@ public void writeUtf8(Utf8JsonWriter writer, Object value) { if (definition.inclusion == Inclusion.PROPERTY) { writer.writeObjectStart(); writer.writeRawValue(definition.utf8SubtypePrefixes[index]); - objectCodecs[index].writeMembers(writer, value, 1); + objectCodecs[index].writeSubtypeMembers(writer, value, 1); writer.writeObjectEnd(); return; } @@ -324,24 +334,7 @@ public Object readUtf8(Utf8JsonReader reader) { private int requireSubtype(Object value) { Class runtimeType = value.getClass(); - Object[] singletons = singletonValues; - if (singletons != null) { - int classMatch = -1; - for (int i = 0; i < singletons.length; i++) { - Object singleton = singletons[i]; - if (singleton != null) { - if (value == singleton) { - return i; - } - } else if (definition.classes[i] == runtimeType) { - classMatch = i; - } - } - if (classMatch >= 0) { - return classMatch; - } - } - int index = singletons == null ? definition.classIndex(runtimeType) : -1; + int index = definition.classIndex(runtimeType); if (index < 0) { throw new ForyJsonException( "Runtime type " + runtimeType.getName() + " is not a declared subtype of " + baseType); @@ -349,61 +342,6 @@ private int requireSubtype(Object value) { return index; } - private static final class SingletonSubtypeCodec implements JsonValueCodec { - private final Object singleton; - - private SingletonSubtypeCodec(Object singleton) { - this.singleton = singleton; - } - - @Override - public void writeString(StringJsonWriter writer, Object value) { - requireSingleton(value); - writer.writeObjectStart(); - writer.writeObjectEnd(); - } - - @Override - public void writeUtf8(Utf8JsonWriter writer, Object value) { - requireSingleton(value); - writer.writeObjectStart(); - writer.writeObjectEnd(); - } - - @Override - public Object readLatin1(Latin1JsonReader reader) { - readEmptyObject(reader); - return singleton; - } - - @Override - public Object readUtf16(Utf16JsonReader reader) { - readEmptyObject(reader); - return singleton; - } - - @Override - public Object readUtf8(Utf8JsonReader reader) { - readEmptyObject(reader); - return singleton; - } - - private void requireSingleton(Object value) { - if (value != singleton) { - throw new ForyJsonException("Expected closed subtype singleton " + singleton); - } - } - - private static void readEmptyObject(org.apache.fory.json.reader.JsonReader reader) { - reader.enterDepth(); - reader.expectNextToken('{'); - if (!reader.consumeNextToken('}')) { - throw new ForyJsonException("Closed subtype singleton requires an empty JSON object"); - } - reader.exitDepth(); - } - } - @Internal public int childCount() { return children.length; @@ -419,6 +357,17 @@ public JsonFieldTable inlineReadTable(int index) { return inlineReadTables == null ? null : inlineReadTables[index]; } + /** Returns the table-bound complete reader for a fixed inline branch, if this branch is fixed. */ + @Internal + public InlineReader fixedInlineReader(int index) { + return fixedInlineReaders == null ? null : fixedInlineReaders[index]; + } + + /** Complete parent-table-bound reader capability for one fixed inline branch. */ + @Internal + public interface InlineReader + extends Latin1ReaderCodec, Utf16ReaderCodec, Utf8ReaderCodec {} + @Internal public Latin1ReaderCodec[] inlineLatin1Readers() { return inlineLatin1Readers; @@ -472,6 +421,32 @@ private void validateInlineReaders(Object[] readers) { } } + /** One immutable fixed-body capability shared by all three parent-local reader arrays. */ + private static final class FixedInlineReader implements InlineReader { + private final ObjectCodec codec; + private final JsonFieldTable table; + + private FixedInlineReader(ObjectCodec codec, JsonFieldTable table) { + this.codec = codec; + this.table = table; + } + + @Override + public Object readLatin1(Latin1JsonReader reader) { + return codec.readLatin1Object(reader, table); + } + + @Override + public Object readUtf16(Utf16JsonReader reader) { + return codec.readUtf16Object(reader, table); + } + + @Override + public Object readUtf8(Utf8JsonReader reader) { + return codec.readUtf8Object(reader, table); + } + } + private static void rejectDiscriminatorCollision(ObjectCodec codec, String property) { // Only the statically known child schema is validated here. Do not probe Any output: dynamic // discriminator conflicts are application-owned, and invoking its getter here would duplicate diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/CollectionCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/CollectionCodec.java index 90b23da642..6c077b28c0 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/CollectionCodec.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/CollectionCodec.java @@ -103,13 +103,26 @@ public abstract class CollectionCodec> implements JsonVa this.createsArrayList = factory.createsArrayList(); } + private static Object requireElement(JsonTypeInfo type, Object element) { + if (element == null && type.rejectsNull()) { + type.rejectNullValue(); + } + return element; + } + + private static void requireWriteElement(JsonTypeInfo type, Object element) { + if (element == null && type.rejectsNull()) { + type.rejectNullValue(); + } + } + public static CollectionCodec create( Class rawType, TypeRef typeRef, JsonTypeResolver resolver) { TypeRef elementTypeRef = CodecUtils.elementTypeRef(typeRef); Type elementType = elementTypeRef.getType(); Class elementRawType = CodecUtils.rawType(elementType, Object.class); CollectionFactory factory = collectionFactory(rawType, elementRawType); - JsonTypeInfo elementTypeInfo = resolver.getTypeInfo(elementType, elementRawType); + JsonTypeInfo elementTypeInfo = resolver.getTypeInfo(elementTypeRef); return create(factory, elementTypeInfo, resolver.canonicalObjectCodec(elementTypeInfo) != null); } @@ -129,34 +142,34 @@ private static CollectionCodec create( CollectionFactory factory, JsonTypeInfo elementTypeInfo, boolean objectElement) { Object elementCodec = elementTypeInfo.stringWriter(); if (elementCodec == ScalarCodecs.StringCodec.INSTANCE) { - return new StringCollectionCodec(factory); + return new StringCollectionCodec(factory, elementTypeInfo); } if (elementCodec == ScalarCodecs.BooleanCodec.BOXED) { - return new BooleanCollectionCodec(factory); + return new BooleanCollectionCodec(factory, elementTypeInfo); } if (elementCodec == ScalarCodecs.IntCodec.BOXED) { - return new IntCollectionCodec(factory); + return new IntCollectionCodec(factory, elementTypeInfo); } if (elementCodec == ScalarCodecs.LongCodec.BOXED) { - return new LongCollectionCodec(factory); + return new LongCollectionCodec(factory, elementTypeInfo); } if (elementCodec == ScalarCodecs.ShortCodec.BOXED) { - return new ShortCollectionCodec(factory); + return new ShortCollectionCodec(factory, elementTypeInfo); } if (elementCodec == ScalarCodecs.ByteCodec.BOXED) { - return new ByteCollectionCodec(factory); + return new ByteCollectionCodec(factory, elementTypeInfo); } if (elementCodec == ScalarCodecs.FloatCodec.BOXED) { - return new FloatCollectionCodec(factory); + return new FloatCollectionCodec(factory, elementTypeInfo); } if (elementCodec == ScalarCodecs.DoubleCodec.BOXED) { - return new DoubleCollectionCodec(factory); + return new DoubleCollectionCodec(factory, elementTypeInfo); } if (elementCodec == ScalarCodecs.BigIntegerCodec.INSTANCE) { - return new BigIntegerCollectionCodec(factory); + return new BigIntegerCollectionCodec(factory, elementTypeInfo); } if (elementCodec == ScalarCodecs.BigDecimalCodec.INSTANCE) { - return new BigDecimalCollectionCodec(factory); + return new BigDecimalCollectionCodec(factory, elementTypeInfo); } if (objectElement) { return new ObjectCollectionCodec(factory, elementTypeInfo); @@ -493,8 +506,19 @@ boolean createsArrayList() { } public abstract static class DirectCollectionCodec extends CollectionCodec> { - DirectCollectionCodec(CollectionFactory factory) { + private final JsonTypeInfo elementTypeInfo; + + DirectCollectionCodec(CollectionFactory factory, JsonTypeInfo elementTypeInfo) { super(factory); + this.elementTypeInfo = elementTypeInfo; + } + + final Object requireElement(Object element) { + return CollectionCodec.requireElement(elementTypeInfo, element); + } + + final void requireWriteElement(Object element) { + CollectionCodec.requireWriteElement(elementTypeInfo, element); } @Override @@ -514,7 +538,7 @@ public final Collection readLatin1(Latin1JsonReader reader) { if ((size & REFERENCE_BATCH_MASK) == REFERENCE_BATCH_MASK) { reader.reserveGraphMemory(REFERENCE_BATCH_BYTES); } - collection.add(readLatin1Element(reader)); + collection.add(requireElement(readLatin1Element(reader))); size++; } while (reader.consumeNextCommaOrEndArray()); } @@ -543,7 +567,7 @@ public final Collection readUtf16(Utf16JsonReader reader) { if ((size & REFERENCE_BATCH_MASK) == REFERENCE_BATCH_MASK) { reader.reserveGraphMemory(REFERENCE_BATCH_BYTES); } - collection.add(readUtf16Element(reader)); + collection.add(requireElement(readUtf16Element(reader))); size++; } while (reader.consumeNextCommaOrEndArray()); } @@ -572,7 +596,7 @@ public final Collection readUtf8(Utf8JsonReader reader) { if ((size & REFERENCE_BATCH_MASK) == REFERENCE_BATCH_MASK) { reader.reserveGraphMemory(REFERENCE_BATCH_BYTES); } - collection.add(readUtf8Element(reader)); + collection.add(requireElement(readUtf8Element(reader))); size++; } while (reader.consumeNextCommaOrEndArray()); } @@ -592,7 +616,7 @@ private ArrayList readLatin1ArrayList(Latin1JsonReader reader) { reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES); return new ArrayList<>(0); } - Object e0 = readLatin1Element(reader); + Object e0 = requireElement(readLatin1Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + REFERENCE_BYTES); @@ -600,7 +624,7 @@ private ArrayList readLatin1ArrayList(Latin1JsonReader reader) { list.add(e0); return list; } - Object e1 = readLatin1Element(reader); + Object e1 = requireElement(readLatin1Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 2 * REFERENCE_BYTES); @@ -609,7 +633,7 @@ private ArrayList readLatin1ArrayList(Latin1JsonReader reader) { list.add(e1); return list; } - Object e2 = readLatin1Element(reader); + Object e2 = requireElement(readLatin1Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 3 * REFERENCE_BYTES); @@ -619,7 +643,7 @@ private ArrayList readLatin1ArrayList(Latin1JsonReader reader) { list.add(e2); return list; } - Object e3 = readLatin1Element(reader); + Object e3 = requireElement(readLatin1Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 4 * REFERENCE_BYTES); @@ -634,7 +658,7 @@ private ArrayList readLatin1ArrayList(Latin1JsonReader reader) { // method smaller than C2's hot-inline limit, so a generated caller can absorb the collection // and element closure solely according to compilation order. The uncommon longer tail stays // separate below. - Object e4 = readLatin1Element(reader); + Object e4 = requireElement(readLatin1Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 5 * REFERENCE_BYTES); @@ -646,7 +670,7 @@ private ArrayList readLatin1ArrayList(Latin1JsonReader reader) { list.add(e4); return list; } - Object e5 = readLatin1Element(reader); + Object e5 = requireElement(readLatin1Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 6 * REFERENCE_BYTES); @@ -664,7 +688,7 @@ private ArrayList readLatin1ArrayList(Latin1JsonReader reader) { private ArrayList readLatin1ArrayListLongTail( Latin1JsonReader reader, Object e0, Object e1, Object e2, Object e3, Object e4, Object e5) { - Object e6 = readLatin1Element(reader); + Object e6 = requireElement(readLatin1Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 7 * REFERENCE_BYTES); @@ -678,7 +702,7 @@ private ArrayList readLatin1ArrayListLongTail( list.add(e6); return list; } - Object e7 = readLatin1Element(reader); + Object e7 = requireElement(readLatin1Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 8 * REFERENCE_BYTES); @@ -703,13 +727,13 @@ private ArrayList readLatin1ArrayListLongTail( list.add(e5); list.add(e6); list.add(e7); - list.add(readLatin1Element(reader)); + list.add(requireElement(readLatin1Element(reader))); int pendingSize = 0; while (reader.consumeNextCommaOrEndArray()) { if ((pendingSize & REFERENCE_BATCH_MASK) == REFERENCE_BATCH_MASK) { reader.reserveGraphMemory(REFERENCE_BATCH_BYTES); } - list.add(readLatin1Element(reader)); + list.add(requireElement(readLatin1Element(reader))); pendingSize++; } int tailSize = pendingSize & REFERENCE_BATCH_MASK; @@ -728,7 +752,7 @@ private ArrayList readUtf16ArrayList(Utf16JsonReader reader) { reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES); return new ArrayList<>(0); } - Object e0 = readUtf16Element(reader); + Object e0 = requireElement(readUtf16Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + REFERENCE_BYTES); @@ -736,7 +760,7 @@ private ArrayList readUtf16ArrayList(Utf16JsonReader reader) { list.add(e0); return list; } - Object e1 = readUtf16Element(reader); + Object e1 = requireElement(readUtf16Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 2 * REFERENCE_BYTES); @@ -745,7 +769,7 @@ private ArrayList readUtf16ArrayList(Utf16JsonReader reader) { list.add(e1); return list; } - Object e2 = readUtf16Element(reader); + Object e2 = requireElement(readUtf16Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 3 * REFERENCE_BYTES); @@ -755,7 +779,7 @@ private ArrayList readUtf16ArrayList(Utf16JsonReader reader) { list.add(e2); return list; } - Object e3 = readUtf16Element(reader); + Object e3 = requireElement(readUtf16Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 4 * REFERENCE_BYTES); @@ -771,7 +795,7 @@ private ArrayList readUtf16ArrayList(Utf16JsonReader reader) { private ArrayList readUtf16ArrayListTail( Utf16JsonReader reader, Object e0, Object e1, Object e2, Object e3) { - Object e4 = readUtf16Element(reader); + Object e4 = requireElement(readUtf16Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 5 * REFERENCE_BYTES); @@ -783,7 +807,7 @@ private ArrayList readUtf16ArrayListTail( list.add(e4); return list; } - Object e5 = readUtf16Element(reader); + Object e5 = requireElement(readUtf16Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 6 * REFERENCE_BYTES); @@ -801,7 +825,7 @@ private ArrayList readUtf16ArrayListTail( private ArrayList readUtf16ArrayListLongTail( Utf16JsonReader reader, Object e0, Object e1, Object e2, Object e3, Object e4, Object e5) { - Object e6 = readUtf16Element(reader); + Object e6 = requireElement(readUtf16Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 7 * REFERENCE_BYTES); @@ -815,7 +839,7 @@ private ArrayList readUtf16ArrayListLongTail( list.add(e6); return list; } - Object e7 = readUtf16Element(reader); + Object e7 = requireElement(readUtf16Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 8 * REFERENCE_BYTES); @@ -840,13 +864,13 @@ private ArrayList readUtf16ArrayListLongTail( list.add(e5); list.add(e6); list.add(e7); - list.add(readUtf16Element(reader)); + list.add(requireElement(readUtf16Element(reader))); int pendingSize = 0; while (reader.consumeNextCommaOrEndArray()) { if ((pendingSize & REFERENCE_BATCH_MASK) == REFERENCE_BATCH_MASK) { reader.reserveGraphMemory(REFERENCE_BATCH_BYTES); } - list.add(readUtf16Element(reader)); + list.add(requireElement(readUtf16Element(reader))); pendingSize++; } int tailSize = pendingSize & REFERENCE_BATCH_MASK; @@ -865,7 +889,7 @@ private ArrayList readUtf8ArrayList(Utf8JsonReader reader) { reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES); return new ArrayList<>(0); } - Object e0 = readUtf8Element(reader); + Object e0 = requireElement(readUtf8Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + REFERENCE_BYTES); @@ -873,7 +897,7 @@ private ArrayList readUtf8ArrayList(Utf8JsonReader reader) { list.add(e0); return list; } - Object e1 = readUtf8Element(reader); + Object e1 = requireElement(readUtf8Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 2 * REFERENCE_BYTES); @@ -882,7 +906,7 @@ private ArrayList readUtf8ArrayList(Utf8JsonReader reader) { list.add(e1); return list; } - Object e2 = readUtf8Element(reader); + Object e2 = requireElement(readUtf8Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 3 * REFERENCE_BYTES); @@ -892,7 +916,7 @@ private ArrayList readUtf8ArrayList(Utf8JsonReader reader) { list.add(e2); return list; } - Object e3 = readUtf8Element(reader); + Object e3 = requireElement(readUtf8Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 4 * REFERENCE_BYTES); @@ -908,7 +932,7 @@ private ArrayList readUtf8ArrayList(Utf8JsonReader reader) { private ArrayList readUtf8ArrayListTail( Utf8JsonReader reader, Object e0, Object e1, Object e2, Object e3) { - Object e4 = readUtf8Element(reader); + Object e4 = requireElement(readUtf8Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 5 * REFERENCE_BYTES); @@ -920,7 +944,7 @@ private ArrayList readUtf8ArrayListTail( list.add(e4); return list; } - Object e5 = readUtf8Element(reader); + Object e5 = requireElement(readUtf8Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 6 * REFERENCE_BYTES); @@ -938,7 +962,7 @@ private ArrayList readUtf8ArrayListTail( private ArrayList readUtf8ArrayListLongTail( Utf8JsonReader reader, Object e0, Object e1, Object e2, Object e3, Object e4, Object e5) { - Object e6 = readUtf8Element(reader); + Object e6 = requireElement(readUtf8Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 7 * REFERENCE_BYTES); @@ -952,7 +976,7 @@ private ArrayList readUtf8ArrayListLongTail( list.add(e6); return list; } - Object e7 = readUtf8Element(reader); + Object e7 = requireElement(readUtf8Element(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 8 * REFERENCE_BYTES); @@ -977,13 +1001,13 @@ private ArrayList readUtf8ArrayListLongTail( list.add(e5); list.add(e6); list.add(e7); - list.add(readUtf8Element(reader)); + list.add(requireElement(readUtf8Element(reader))); int pendingSize = 0; while (reader.consumeNextCommaOrEndArray()) { if ((pendingSize & REFERENCE_BATCH_MASK) == REFERENCE_BATCH_MASK) { reader.reserveGraphMemory(REFERENCE_BATCH_BYTES); } - list.add(readUtf8Element(reader)); + list.add(requireElement(readUtf8Element(reader))); pendingSize++; } int tailSize = pendingSize & REFERENCE_BATCH_MASK; @@ -1020,6 +1044,7 @@ public void writeString(StringJsonWriter writer, Collection value) { int index = 0; for (Object element : value) { writer.writeComma(index++); + requireWriteElement(elementTypeInfo, element); codec.writeString(writer, element); } writer.writeArrayEnd(); @@ -1036,6 +1061,7 @@ public void writeUtf8(Utf8JsonWriter writer, Collection value) { int index = 0; for (Object element : value) { writer.writeComma(index++); + requireWriteElement(elementTypeInfo, element); codec.writeUtf8(writer, element); } writer.writeArrayEnd(); @@ -1056,7 +1082,7 @@ public Collection readLatin1(Latin1JsonReader reader) { if ((size & REFERENCE_BATCH_MASK) == REFERENCE_BATCH_MASK) { reader.reserveGraphMemory(REFERENCE_BATCH_BYTES); } - collection.add(codec.readLatin1(reader)); + collection.add(requireElement(elementTypeInfo, codec.readLatin1(reader))); size++; } while (reader.consumeNextCommaOrEndArray()); } @@ -1083,7 +1109,7 @@ public Collection readUtf16(Utf16JsonReader reader) { if ((size & REFERENCE_BATCH_MASK) == REFERENCE_BATCH_MASK) { reader.reserveGraphMemory(REFERENCE_BATCH_BYTES); } - collection.add(codec.readUtf16(reader)); + collection.add(requireElement(elementTypeInfo, codec.readUtf16(reader))); size++; } while (reader.consumeNextCommaOrEndArray()); } @@ -1110,7 +1136,7 @@ public Collection readUtf8(Utf8JsonReader reader) { if ((size & REFERENCE_BATCH_MASK) == REFERENCE_BATCH_MASK) { reader.reserveGraphMemory(REFERENCE_BATCH_BYTES); } - collection.add(codec.readUtf8(reader)); + collection.add(requireElement(elementTypeInfo, codec.readUtf8(reader))); size++; } while (reader.consumeNextCommaOrEndArray()); } @@ -1144,12 +1170,14 @@ public void writeString(StringJsonWriter writer, Collection value) { for (int index = 0, size = list.size(); index < size; index++) { Object element = list.get(index); writer.writeComma(index); + requireWriteElement(elementTypeInfo, element); codec.writeString(writer, element); } } else { int index = 0; for (Object element : value) { writer.writeComma(index++); + requireWriteElement(elementTypeInfo, element); codec.writeString(writer, element); } } @@ -1169,12 +1197,14 @@ public void writeUtf8(Utf8JsonWriter writer, Collection value) { for (int index = 0, size = list.size(); index < size; index++) { Object element = list.get(index); writer.writeComma(index); + requireWriteElement(elementTypeInfo, element); codec.writeUtf8(writer, element); } } else { int index = 0; for (Object element : value) { writer.writeComma(index++); + requireWriteElement(elementTypeInfo, element); codec.writeUtf8(writer, element); } } @@ -1199,7 +1229,7 @@ public Collection readLatin1(Latin1JsonReader reader) { if ((size & REFERENCE_BATCH_MASK) == REFERENCE_BATCH_MASK) { reader.reserveGraphMemory(REFERENCE_BATCH_BYTES); } - collection.add(codec.readLatin1(reader)); + collection.add(requireElement(elementTypeInfo, codec.readLatin1(reader))); size++; } while (reader.consumeNextCommaOrEndArray()); } @@ -1229,7 +1259,7 @@ public Collection readUtf16(Utf16JsonReader reader) { if ((size & REFERENCE_BATCH_MASK) == REFERENCE_BATCH_MASK) { reader.reserveGraphMemory(REFERENCE_BATCH_BYTES); } - collection.add(codec.readUtf16(reader)); + collection.add(requireElement(elementTypeInfo, codec.readUtf16(reader))); size++; } while (reader.consumeNextCommaOrEndArray()); } @@ -1259,7 +1289,7 @@ public Collection readUtf8(Utf8JsonReader reader) { if ((size & REFERENCE_BATCH_MASK) == REFERENCE_BATCH_MASK) { reader.reserveGraphMemory(REFERENCE_BATCH_BYTES); } - collection.add(codec.readUtf8(reader)); + collection.add(requireElement(elementTypeInfo, codec.readUtf8(reader))); size++; } while (reader.consumeNextCommaOrEndArray()); } @@ -1280,7 +1310,7 @@ private ArrayList readLatin1ArrayList( reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES); return new ArrayList<>(0); } - Object e0 = codec.readLatin1(reader); + Object e0 = requireElement(elementTypeInfo, codec.readLatin1(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + REFERENCE_BYTES); @@ -1288,7 +1318,7 @@ private ArrayList readLatin1ArrayList( list.add(e0); return list; } - Object e1 = codec.readLatin1(reader); + Object e1 = requireElement(elementTypeInfo, codec.readLatin1(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 2 * REFERENCE_BYTES); @@ -1297,7 +1327,7 @@ private ArrayList readLatin1ArrayList( list.add(e1); return list; } - Object e2 = codec.readLatin1(reader); + Object e2 = requireElement(elementTypeInfo, codec.readLatin1(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 3 * REFERENCE_BYTES); @@ -1307,7 +1337,7 @@ private ArrayList readLatin1ArrayList( list.add(e2); return list; } - Object e3 = codec.readLatin1(reader); + Object e3 = requireElement(elementTypeInfo, codec.readLatin1(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 4 * REFERENCE_BYTES); @@ -1322,7 +1352,7 @@ private ArrayList readLatin1ArrayList( // method smaller than C2's hot-inline limit, so a generated caller can absorb the collection // and element closure solely according to compilation order. The uncommon longer tail stays // separate below. - Object e4 = codec.readLatin1(reader); + Object e4 = requireElement(elementTypeInfo, codec.readLatin1(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 5 * REFERENCE_BYTES); @@ -1334,7 +1364,7 @@ private ArrayList readLatin1ArrayList( list.add(e4); return list; } - Object e5 = codec.readLatin1(reader); + Object e5 = requireElement(elementTypeInfo, codec.readLatin1(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 6 * REFERENCE_BYTES); @@ -1359,7 +1389,7 @@ private ArrayList readLatin1ArrayListLongTail( Object e3, Object e4, Object e5) { - Object e6 = codec.readLatin1(reader); + Object e6 = requireElement(elementTypeInfo, codec.readLatin1(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 7 * REFERENCE_BYTES); @@ -1373,7 +1403,7 @@ private ArrayList readLatin1ArrayListLongTail( list.add(e6); return list; } - Object e7 = codec.readLatin1(reader); + Object e7 = requireElement(elementTypeInfo, codec.readLatin1(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 8 * REFERENCE_BYTES); @@ -1400,13 +1430,13 @@ private ArrayList readLatin1ArrayListLongTail( list.add(e5); list.add(e6); list.add(e7); - list.add(codec.readLatin1(reader)); + list.add(requireElement(elementTypeInfo, codec.readLatin1(reader))); int pendingSize = 0; while (reader.consumeNextCommaOrEndArray()) { if ((pendingSize & REFERENCE_BATCH_MASK) == REFERENCE_BATCH_MASK) { reader.reserveGraphMemory(REFERENCE_BATCH_BYTES); } - list.add(codec.readLatin1(reader)); + list.add(requireElement(elementTypeInfo, codec.readLatin1(reader))); pendingSize++; } int tailSize = pendingSize & REFERENCE_BATCH_MASK; @@ -1426,7 +1456,7 @@ private ArrayList readUtf16ArrayList( reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES); return new ArrayList<>(0); } - Object e0 = codec.readUtf16(reader); + Object e0 = requireElement(elementTypeInfo, codec.readUtf16(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + REFERENCE_BYTES); @@ -1434,7 +1464,7 @@ private ArrayList readUtf16ArrayList( list.add(e0); return list; } - Object e1 = codec.readUtf16(reader); + Object e1 = requireElement(elementTypeInfo, codec.readUtf16(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 2 * REFERENCE_BYTES); @@ -1443,7 +1473,7 @@ private ArrayList readUtf16ArrayList( list.add(e1); return list; } - Object e2 = codec.readUtf16(reader); + Object e2 = requireElement(elementTypeInfo, codec.readUtf16(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 3 * REFERENCE_BYTES); @@ -1453,7 +1483,7 @@ private ArrayList readUtf16ArrayList( list.add(e2); return list; } - Object e3 = codec.readUtf16(reader); + Object e3 = requireElement(elementTypeInfo, codec.readUtf16(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 4 * REFERENCE_BYTES); @@ -1474,7 +1504,7 @@ private ArrayList readUtf16ArrayListTail( Object e1, Object e2, Object e3) { - Object e4 = codec.readUtf16(reader); + Object e4 = requireElement(elementTypeInfo, codec.readUtf16(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 5 * REFERENCE_BYTES); @@ -1486,7 +1516,7 @@ private ArrayList readUtf16ArrayListTail( list.add(e4); return list; } - Object e5 = codec.readUtf16(reader); + Object e5 = requireElement(elementTypeInfo, codec.readUtf16(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 6 * REFERENCE_BYTES); @@ -1511,7 +1541,7 @@ private ArrayList readUtf16ArrayListLongTail( Object e3, Object e4, Object e5) { - Object e6 = codec.readUtf16(reader); + Object e6 = requireElement(elementTypeInfo, codec.readUtf16(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 7 * REFERENCE_BYTES); @@ -1525,7 +1555,7 @@ private ArrayList readUtf16ArrayListLongTail( list.add(e6); return list; } - Object e7 = codec.readUtf16(reader); + Object e7 = requireElement(elementTypeInfo, codec.readUtf16(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 8 * REFERENCE_BYTES); @@ -1552,13 +1582,13 @@ private ArrayList readUtf16ArrayListLongTail( list.add(e5); list.add(e6); list.add(e7); - list.add(codec.readUtf16(reader)); + list.add(requireElement(elementTypeInfo, codec.readUtf16(reader))); int pendingSize = 0; while (reader.consumeNextCommaOrEndArray()) { if ((pendingSize & REFERENCE_BATCH_MASK) == REFERENCE_BATCH_MASK) { reader.reserveGraphMemory(REFERENCE_BATCH_BYTES); } - list.add(codec.readUtf16(reader)); + list.add(requireElement(elementTypeInfo, codec.readUtf16(reader))); pendingSize++; } int tailSize = pendingSize & REFERENCE_BATCH_MASK; @@ -1578,7 +1608,7 @@ private ArrayList readUtf8ArrayList( reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES); return new ArrayList<>(0); } - Object e0 = codec.readUtf8(reader); + Object e0 = requireElement(elementTypeInfo, codec.readUtf8(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + REFERENCE_BYTES); @@ -1586,7 +1616,7 @@ private ArrayList readUtf8ArrayList( list.add(e0); return list; } - Object e1 = codec.readUtf8(reader); + Object e1 = requireElement(elementTypeInfo, codec.readUtf8(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 2 * REFERENCE_BYTES); @@ -1595,7 +1625,7 @@ private ArrayList readUtf8ArrayList( list.add(e1); return list; } - Object e2 = codec.readUtf8(reader); + Object e2 = requireElement(elementTypeInfo, codec.readUtf8(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 3 * REFERENCE_BYTES); @@ -1605,7 +1635,7 @@ private ArrayList readUtf8ArrayList( list.add(e2); return list; } - Object e3 = codec.readUtf8(reader); + Object e3 = requireElement(elementTypeInfo, codec.readUtf8(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 4 * REFERENCE_BYTES); @@ -1619,7 +1649,7 @@ private ArrayList readUtf8ArrayList( // Keep the fifth exact-allocation lane in the collection owner. If this lane is split after // four elements, both resulting methods fall below C2's hot-inline limit and let an outer // fallback caller absorb the object-element closure according to compilation order. - Object e4 = codec.readUtf8(reader); + Object e4 = requireElement(elementTypeInfo, codec.readUtf8(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 5 * REFERENCE_BYTES); @@ -1642,7 +1672,7 @@ private ArrayList readUtf8ArrayListTail( Object e2, Object e3, Object e4) { - Object e5 = codec.readUtf8(reader); + Object e5 = requireElement(elementTypeInfo, codec.readUtf8(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 6 * REFERENCE_BYTES); @@ -1667,7 +1697,7 @@ private ArrayList readUtf8ArrayListLongTail( Object e3, Object e4, Object e5) { - Object e6 = codec.readUtf8(reader); + Object e6 = requireElement(elementTypeInfo, codec.readUtf8(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 7 * REFERENCE_BYTES); @@ -1681,7 +1711,7 @@ private ArrayList readUtf8ArrayListLongTail( list.add(e6); return list; } - Object e7 = codec.readUtf8(reader); + Object e7 = requireElement(elementTypeInfo, codec.readUtf8(reader)); if (!reader.consumeNextCommaOrEndArray()) { reader.exitDepth(); reader.reserveGraphMemory(ARRAY_LIST_OWNER_BYTES + 8 * REFERENCE_BYTES); @@ -1708,13 +1738,13 @@ private ArrayList readUtf8ArrayListLongTail( list.add(e5); list.add(e6); list.add(e7); - list.add(codec.readUtf8(reader)); + list.add(requireElement(elementTypeInfo, codec.readUtf8(reader))); int pendingSize = 0; while (reader.consumeNextCommaOrEndArray()) { if ((pendingSize & REFERENCE_BATCH_MASK) == REFERENCE_BATCH_MASK) { reader.reserveGraphMemory(REFERENCE_BATCH_BYTES); } - list.add(codec.readUtf8(reader)); + list.add(requireElement(elementTypeInfo, codec.readUtf8(reader))); pendingSize++; } int tailSize = pendingSize & REFERENCE_BATCH_MASK; @@ -1727,8 +1757,8 @@ private ArrayList readUtf8ArrayListLongTail( } public static final class StringCollectionCodec extends DirectCollectionCodec { - private StringCollectionCodec(CollectionFactory factory) { - super(factory); + private StringCollectionCodec(CollectionFactory factory, JsonTypeInfo elementTypeInfo) { + super(factory, elementTypeInfo); } @Override @@ -1740,6 +1770,7 @@ public void writeString(StringJsonWriter writer, Collection value) { writer.writeArrayStart(); int index = 0; for (Object element : value) { + requireWriteElement(element); writer.writeStringElement(index++, (String) element); } writer.writeArrayEnd(); @@ -1754,6 +1785,7 @@ public void writeUtf8(Utf8JsonWriter writer, Collection value) { writer.writeArrayStart(); int index = 0; for (Object element : value) { + requireWriteElement(element); writer.writeStringElement(index++, (String) element); } writer.writeArrayEnd(); @@ -1776,8 +1808,8 @@ Object readUtf8Element(Utf8JsonReader reader) { } public static final class BooleanCollectionCodec extends DirectCollectionCodec { - private BooleanCollectionCodec(CollectionFactory factory) { - super(factory); + private BooleanCollectionCodec(CollectionFactory factory, JsonTypeInfo elementTypeInfo) { + super(factory, elementTypeInfo); } @Override @@ -1791,6 +1823,7 @@ public void writeString(StringJsonWriter writer, Collection value) { for (Object element : value) { writer.writeComma(index++); if (element == null) { + requireWriteElement(null); writer.writeNull(); } else { writer.writeBoolean((boolean) element); @@ -1810,6 +1843,7 @@ public void writeUtf8(Utf8JsonWriter writer, Collection value) { for (Object element : value) { writer.writeComma(index++); if (element == null) { + requireWriteElement(null); writer.writeNull(); } else { writer.writeBoolean((boolean) element); @@ -1835,8 +1869,8 @@ Object readUtf8Element(Utf8JsonReader reader) { } public abstract static class NumberCollectionCodec extends DirectCollectionCodec { - NumberCollectionCodec(CollectionFactory factory) { - super(factory); + NumberCollectionCodec(CollectionFactory factory, JsonTypeInfo elementTypeInfo) { + super(factory, elementTypeInfo); } @Override @@ -1850,6 +1884,7 @@ public final void writeString(StringJsonWriter writer, Collection value) { for (Object element : value) { writer.writeComma(index++); if (element == null) { + requireWriteElement(null); writer.writeNull(); } else { writeNumber(writer, element); @@ -1869,6 +1904,7 @@ public final void writeUtf8(Utf8JsonWriter writer, Collection value) { for (Object element : value) { writer.writeComma(index++); if (element == null) { + requireWriteElement(null); writer.writeNull(); } else { writeNumber(writer, element); @@ -1881,8 +1917,8 @@ public final void writeUtf8(Utf8JsonWriter writer, Collection value) { } public static final class IntCollectionCodec extends NumberCollectionCodec { - private IntCollectionCodec(CollectionFactory factory) { - super(factory); + private IntCollectionCodec(CollectionFactory factory, JsonTypeInfo elementTypeInfo) { + super(factory, elementTypeInfo); } @Override @@ -1907,8 +1943,8 @@ Object readUtf8Element(Utf8JsonReader reader) { } public static final class LongCollectionCodec extends NumberCollectionCodec { - private LongCollectionCodec(CollectionFactory factory) { - super(factory); + private LongCollectionCodec(CollectionFactory factory, JsonTypeInfo elementTypeInfo) { + super(factory, elementTypeInfo); } @Override @@ -1933,8 +1969,8 @@ Object readUtf8Element(Utf8JsonReader reader) { } public static final class ShortCollectionCodec extends NumberCollectionCodec { - private ShortCollectionCodec(CollectionFactory factory) { - super(factory); + private ShortCollectionCodec(CollectionFactory factory, JsonTypeInfo elementTypeInfo) { + super(factory, elementTypeInfo); } @Override @@ -1959,8 +1995,8 @@ Object readUtf8Element(Utf8JsonReader reader) { } public static final class ByteCollectionCodec extends NumberCollectionCodec { - private ByteCollectionCodec(CollectionFactory factory) { - super(factory); + private ByteCollectionCodec(CollectionFactory factory, JsonTypeInfo elementTypeInfo) { + super(factory, elementTypeInfo); } @Override @@ -1985,8 +2021,8 @@ Object readUtf8Element(Utf8JsonReader reader) { } public static final class FloatCollectionCodec extends NumberCollectionCodec { - private FloatCollectionCodec(CollectionFactory factory) { - super(factory); + private FloatCollectionCodec(CollectionFactory factory, JsonTypeInfo elementTypeInfo) { + super(factory, elementTypeInfo); } @Override @@ -2011,8 +2047,8 @@ Object readUtf8Element(Utf8JsonReader reader) { } public static final class DoubleCollectionCodec extends NumberCollectionCodec { - private DoubleCollectionCodec(CollectionFactory factory) { - super(factory); + private DoubleCollectionCodec(CollectionFactory factory, JsonTypeInfo elementTypeInfo) { + super(factory, elementTypeInfo); } @Override @@ -2037,8 +2073,8 @@ Object readUtf8Element(Utf8JsonReader reader) { } public static final class BigIntegerCollectionCodec extends NumberCollectionCodec { - private BigIntegerCollectionCodec(CollectionFactory factory) { - super(factory); + private BigIntegerCollectionCodec(CollectionFactory factory, JsonTypeInfo elementTypeInfo) { + super(factory, elementTypeInfo); } @Override @@ -2063,8 +2099,8 @@ Object readUtf8Element(Utf8JsonReader reader) { } public static final class BigDecimalCollectionCodec extends NumberCollectionCodec { - private BigDecimalCollectionCodec(CollectionFactory factory) { - super(factory); + private BigDecimalCollectionCodec(CollectionFactory factory, JsonTypeInfo elementTypeInfo) { + super(factory, elementTypeInfo); } @Override diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/DateTimeFormatCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/DateTimeFormatCodec.java index 0e118451bf..7c41d1cde1 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/DateTimeFormatCodec.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/DateTimeFormatCodec.java @@ -140,19 +140,19 @@ public void writeUtf8(Utf8JsonWriter writer, Object value) { @Override public Object readLatin1(Latin1JsonReader reader) { - CharSequence value = reader.readDateTimeText(); + CharSequence value = reader.readQuotedText(); return value == null ? null : parse(value); } @Override public Object readUtf16(Utf16JsonReader reader) { - CharSequence value = reader.readDateTimeText(); + CharSequence value = reader.readQuotedText(); return value == null ? null : parse(value); } @Override public Object readUtf8(Utf8JsonReader reader) { - CharSequence value = reader.readDateTimeText(); + CharSequence value = reader.readQuotedText(); return value == null ? null : parse(value); } diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/DirectUnboxedValueCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/DirectUnboxedValueCodec.java new file mode 100644 index 0000000000..a2a4056b7e --- /dev/null +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/DirectUnboxedValueCodec.java @@ -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 org.apache.fory.json.codec; + +import java.lang.reflect.Method; +import org.apache.fory.annotation.Internal; + +/** Exact parent-carrier operations for a semantic leaf which is not transparent to its carrier. */ +@Internal +public interface DirectUnboxedValueCodec extends UnboxedValueCodec { + /** Returns the exact static {@code (JsonReader) -> carrier} generated invocation. */ + Method readCarrierMethod(); + + /** Returns the exact static {@code (JsonWriter, carrier) -> void} generated invocation. */ + Method writeCarrierMethod(); +} diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/JsonObjectModel.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/JsonObjectModel.java index 0f7fc9a500..64c2422b86 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/JsonObjectModel.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/JsonObjectModel.java @@ -20,109 +20,362 @@ package org.apache.fory.json.codec; import java.lang.reflect.Constructor; +import java.lang.reflect.Executable; +import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.lang.reflect.WildcardType; import java.util.HashSet; +import java.util.List; import java.util.Objects; import org.apache.fory.annotation.Internal; +import org.apache.fory.reflect.TypeRef; -/** Immutable constructor and accessor metadata supplied by a language JSON module. */ +/** Immutable construction and accessor metadata supplied by a language JSON module. */ @Internal public final class JsonObjectModel { - private final Constructor constructor; + private final Executable creator; + private final Executable invocationCreator; + private final Constructor defaultConstructor; private final String[] parameterNames; private final Method[] accessors; private final Method[] defaultMethods; + private final int[] defaultMaskBits; + private final boolean[] parameterNullable; + private final TypeRef[] parameterTypes; private final String[] propertyNames; private final Method[] propertyGetters; private final Method[] propertySetters; - private final Type[] propertyTypes; + private final TypeRef[] propertyTypes; + private final boolean[] propertyReconstructible; + private final boolean[] propertyRequired; + private final Object fixedInstance; + private final Field[] nonPropertyFields; + /** Creates one ordinary language object model. */ public JsonObjectModel( Constructor constructor, + Constructor defaultConstructor, String[] parameterNames, Method[] accessors, - Method[] defaultMethods) { + Method[] defaultMethods, + int[] defaultMaskBits, + boolean[] parameterNullable, + TypeRef[] parameterTypes, + String[] propertyNames, + Method[] propertyGetters, + Method[] propertySetters, + TypeRef[] propertyTypes) { this( + (Executable) constructor, constructor, + defaultConstructor, parameterNames, accessors, defaultMethods, - parameterNames, - accessors, - null, - constructor.getGenericParameterTypes()); + defaultMaskBits, + parameterNullable, + parameterTypes, + propertyNames, + propertyGetters, + propertySetters, + propertyTypes, + allProperties(propertyNames.length), + new boolean[propertyNames.length]); } + /** Creates a model for an explicitly selected constructor or static factory. */ public JsonObjectModel( - Constructor constructor, + Executable creator, + Executable invocationCreator, + Constructor defaultConstructor, String[] parameterNames, Method[] accessors, Method[] defaultMethods, + int[] defaultMaskBits, + boolean[] parameterNullable, + TypeRef[] parameterTypes, String[] propertyNames, Method[] propertyGetters, - Method[] propertySetters) { + Method[] propertySetters, + TypeRef[] propertyTypes) { this( - constructor, + creator, + invocationCreator, + defaultConstructor, parameterNames, accessors, defaultMethods, + defaultMaskBits, + parameterNullable, + parameterTypes, propertyNames, propertyGetters, propertySetters, - propertyTypes( - constructor, parameterNames, propertyNames, propertyGetters, propertySetters)); + propertyTypes, + allProperties(propertyNames.length), + new boolean[propertyNames.length]); } + /** Creates a model with exact reconstructibility and deferred-required facts. */ public JsonObjectModel( - Constructor constructor, + Executable creator, + Executable invocationCreator, + Constructor defaultConstructor, String[] parameterNames, Method[] accessors, Method[] defaultMethods, + int[] defaultMaskBits, + boolean[] parameterNullable, + TypeRef[] parameterTypes, String[] propertyNames, Method[] propertyGetters, Method[] propertySetters, - Type[] propertyTypes) { - this.constructor = Objects.requireNonNull(constructor, "constructor"); + TypeRef[] propertyTypes, + boolean[] propertyReconstructible, + boolean[] propertyRequired) { + this.creator = Objects.requireNonNull(creator, "creator"); + this.invocationCreator = Objects.requireNonNull(invocationCreator, "invocationCreator"); + this.defaultConstructor = defaultConstructor; this.parameterNames = parameterNames.clone(); this.accessors = accessors.clone(); this.defaultMethods = defaultMethods.clone(); + this.defaultMaskBits = defaultMaskBits.clone(); + this.parameterNullable = parameterNullable.clone(); + this.parameterTypes = parameterTypes.clone(); this.propertyNames = propertyNames.clone(); this.propertyGetters = propertyGetters.clone(); - this.propertySetters = - propertySetters == null ? new Method[propertyNames.length] : propertySetters.clone(); + this.propertySetters = propertySetters.clone(); this.propertyTypes = propertyTypes.clone(); - int count = constructor.getParameterCount(); - if (this.parameterNames.length != count - || this.accessors.length != count - || this.defaultMethods.length != count) { - throw new IllegalArgumentException("JSON object model arrays must match constructor arity"); + this.propertyReconstructible = propertyReconstructible.clone(); + this.propertyRequired = propertyRequired.clone(); + this.fixedInstance = null; + nonPropertyFields = new Field[0]; + validate(); + } + + private JsonObjectModel( + Object fixedInstance, + String[] propertyNames, + Method[] propertyGetters, + Method[] propertySetters, + TypeRef[] propertyTypes, + Field[] nonPropertyFields) { + creator = null; + invocationCreator = null; + defaultConstructor = null; + parameterNames = new String[0]; + accessors = new Method[0]; + defaultMethods = new Method[0]; + defaultMaskBits = new int[0]; + parameterNullable = new boolean[0]; + parameterTypes = new TypeRef[0]; + if (propertyGetters.length != propertyNames.length + || propertySetters.length != propertyNames.length + || propertyTypes.length != propertyNames.length) { + throw new IllegalArgumentException("Fixed JSON object-model property arrays must match"); } HashSet names = new HashSet<>(); - for (String name : this.parameterNames) { + for (int i = 0; i < propertyNames.length; i++) { + String name = propertyNames[i]; if (name == null || name.isEmpty() || !names.add(name)) { - throw new IllegalArgumentException("Invalid JSON object model parameter name " + name); + throw new IllegalArgumentException("Invalid JSON object model property name " + name); } + Objects.requireNonNull(propertyTypes[i], "propertyType"); } - if (this.propertyGetters.length != this.propertyNames.length - || this.propertySetters.length != this.propertyNames.length - || this.propertyTypes.length != this.propertyNames.length) { + this.propertyNames = propertyNames.clone(); + this.propertyGetters = propertyGetters.clone(); + this.propertySetters = propertySetters.clone(); + this.propertyTypes = propertyTypes.clone(); + propertyReconstructible = new boolean[propertyNames.length]; + propertyRequired = new boolean[propertyNames.length]; + this.fixedInstance = Objects.requireNonNull(fixedInstance, "fixedInstance"); + HashSet fields = new HashSet<>(); + Class instanceType = fixedInstance.getClass(); + for (Field field : nonPropertyFields) { + Objects.requireNonNull(field, "nonPropertyField"); + int modifiers = field.getModifiers(); + if (!field.getDeclaringClass().isAssignableFrom(instanceType) + || Modifier.isStatic(modifiers) + || !Modifier.isFinal(modifiers) + || !fields.add(field)) { + throw new IllegalArgumentException("Invalid fixed-model non-property field " + field); + } + } + this.nonPropertyFields = nonPropertyFields.clone(); + } + + /** + * Creates a stateless language singleton model whose JSON representation is exactly {@code {}}. + */ + public static JsonObjectModel fixedInstance(Object instance) { + return new JsonObjectModel( + instance, new String[0], new Method[0], new Method[0], new TypeRef[0], new Field[0]); + } + + /** Creates a singleton candidate with effective-property validation. */ + public static JsonObjectModel fixedInstance( + Object instance, + String[] propertyNames, + Method[] propertyGetters, + Method[] propertySetters, + TypeRef[] propertyTypes) { + return new JsonObjectModel( + instance, propertyNames, propertyGetters, propertySetters, propertyTypes, new Field[0]); + } + + /** Creates a fixed model with exact compiler storage excluded from logical JSON state. */ + public static JsonObjectModel fixedInstance( + Object instance, + String[] propertyNames, + Method[] propertyGetters, + Method[] propertySetters, + TypeRef[] propertyTypes, + Field[] nonPropertyFields) { + return new JsonObjectModel( + instance, + propertyNames, + propertyGetters, + propertySetters, + propertyTypes, + nonPropertyFields); + } + + public Object fixedInstance() { + return fixedInstance; + } + + private void validate() { + int count = creator.getParameterCount(); + if (parameterNames.length != count + || accessors.length != count + || defaultMethods.length != count + || defaultMaskBits.length != count + || parameterNullable.length != count + || parameterTypes.length != count) { + throw new IllegalArgumentException("JSON object model arrays must match constructor arity"); + } + if (propertyGetters.length != propertyNames.length + || propertySetters.length != propertyNames.length + || propertyTypes.length != propertyNames.length + || propertyReconstructible.length != propertyNames.length + || propertyRequired.length != propertyNames.length) { throw new IllegalArgumentException( "JSON object model property arrays must have equal length"); } + Class[] logicalCarriers = creator.getParameterTypes(); + Class[] invocationCarriers = invocationCreator.getParameterTypes(); + if (invocationCreator.getDeclaringClass() != creator.getDeclaringClass() + || invocationCarriers.length < count + || invocationCarriers.length > count + 1) { + throw new IllegalArgumentException("Invalid JSON object-model invocation constructor"); + } + for (int i = 0; i < count; i++) { + if (invocationCarriers[i] != logicalCarriers[i]) { + throw new IllegalArgumentException("JSON invocation constructor carrier mismatch"); + } + } + if (invocationCarriers.length != count && invocationCarriers[count].isPrimitive()) { + throw new IllegalArgumentException("JSON invocation marker must be a reference type"); + } + if (creator instanceof Method || invocationCreator instanceof Method) { + if (!(creator instanceof Method) + || !(invocationCreator instanceof Method) + || invocationCarriers.length != count + || defaultConstructor != null) { + throw new IllegalArgumentException("Invalid JSON object-model factory targets"); + } + Class owner = creator.getDeclaringClass(); + if (((Method) creator).getReturnType() != owner + || ((Method) invocationCreator).getReturnType() != owner) { + throw new IllegalArgumentException("JSON object-model factory must return its exact owner"); + } + } + HashSet names = new HashSet<>(); + for (int i = 0; i < parameterNames.length; i++) { + String name = parameterNames[i]; + if (name == null || name.isEmpty() || !names.add(name)) { + throw new IllegalArgumentException("Invalid JSON object model parameter name " + name); + } + Objects.requireNonNull(parameterTypes[i], "parameterType"); + if (defaultMethods[i] != null && defaultMaskBits[i] >= 0) { + throw new IllegalArgumentException("A constructor parameter has two default mechanisms"); + } + } names.clear(); - for (String name : this.propertyNames) { + for (int i = 0; i < propertyNames.length; i++) { + String name = propertyNames[i]; if (name == null || name.isEmpty() || !names.add(name)) { throw new IllegalArgumentException("Invalid JSON object model property name " + name); } + Objects.requireNonNull(propertyTypes[i], "propertyType"); + if (propertyRequired[i] && !propertyReconstructible[i]) { + throw new IllegalArgumentException( + "Required deferred JSON property must be reconstructible " + name); + } + if (propertyRequired[i] + && (propertyTypes[i].getRawType().isPrimitive() + || propertyTypes[i].getTypeExtMeta() == null + || propertyTypes[i].getTypeExtMeta().nullable() + || propertyTypes[i].getTypeExtMeta().nullableWrapper())) { + throw new IllegalArgumentException( + "Required deferred JSON property must have a non-null reference setter " + name); + } + } + boolean hasMaskedDefault = false; + for (int bit : defaultMaskBits) { + if (bit >= 0) { + hasMaskedDefault = true; + break; + } } - for (Type propertyType : this.propertyTypes) { - Objects.requireNonNull(propertyType, "propertyType"); + if (hasMaskedDefault != (defaultConstructor != null)) { + throw new IllegalArgumentException( + "Compiler-default mask metadata requires exactly one default constructor"); + } + if (defaultConstructor != null) { + Class[] invocationTypes = defaultConstructor.getParameterTypes(); + int maskCount = (count + 31) >>> 5; + if (defaultConstructor.getDeclaringClass() != creator.getDeclaringClass() + || invocationTypes.length != count + maskCount + 1 + || !(creator instanceof Constructor)) { + throw new IllegalArgumentException("Invalid compiler-default constructor shape"); + } + for (int i = 0; i < count; i++) { + if (invocationTypes[i] != creator.getParameterTypes()[i]) { + throw new IllegalArgumentException("Compiler-default constructor carrier mismatch"); + } + } + for (int i = 0; i < maskCount; i++) { + if (invocationTypes[count + i] != int.class) { + throw new IllegalArgumentException("Compiler-default mask must use int words"); + } + } + if (invocationTypes[invocationTypes.length - 1].isPrimitive()) { + throw new IllegalArgumentException("Compiler-default marker must be a reference type"); + } + for (int i = 0; i < count; i++) { + int bit = defaultMaskBits[i]; + if (bit >= 0 && bit != i) { + throw new IllegalArgumentException( + "Compiler-default mask bit must match parameter index"); + } + } } } - public Constructor constructor() { - return constructor; + public Executable creator() { + return creator; + } + + public Executable invocationCreator() { + return invocationCreator; + } + + public Constructor defaultConstructor() { + return defaultConstructor; } public String[] parameterNames() { @@ -137,6 +390,18 @@ public Method[] defaultMethods() { return defaultMethods.clone(); } + public int[] defaultMaskBits() { + return defaultMaskBits.clone(); + } + + public boolean[] parameterNullable() { + return parameterNullable.clone(); + } + + public TypeRef[] parameterTypes() { + return parameterTypes.clone(); + } + public String[] propertyNames() { return propertyNames.clone(); } @@ -149,44 +414,81 @@ public Method[] propertySetters() { return propertySetters.clone(); } - public Type[] propertyTypes() { + public TypeRef[] propertyTypes() { return propertyTypes.clone(); } - private static Type[] propertyTypes( - Constructor constructor, - String[] parameterNames, - String[] propertyNames, - Method[] propertyGetters, - Method[] propertySetters) { - Method[] setters = propertySetters == null ? new Method[propertyNames.length] : propertySetters; - if (propertyGetters.length != propertyNames.length || setters.length != propertyNames.length) { - throw new IllegalArgumentException( - "JSON object model property arrays must have equal length"); + /** Returns whether each property has stable constructor or deferred storage. */ + public boolean[] propertyReconstructible() { + return propertyReconstructible.clone(); + } + + /** Returns required-deferred flags aligned with {@link #propertyNames()}. */ + public boolean[] propertyRequired() { + return propertyRequired.clone(); + } + + /** Returns exact compiler storage which is not part of fixed-instance JSON state. */ + public Field[] nonPropertyFields() { + return nonPropertyFields.clone(); + } + + /** Compares one exact JVM member type with its normalized language-model type. */ + @Internal + public static boolean compatibleType(TypeRef memberType, TypeRef logicalType) { + Type member = memberType.getType(); + Type logical = logicalType.getType(); + if (member.equals(logical)) { + return true; } - Type[] parameterTypes = constructor.getGenericParameterTypes(); - Type[] types = new Type[propertyNames.length]; - for (int i = 0; i < types.length; i++) { - for (int parameterIndex = 0; parameterIndex < parameterNames.length; parameterIndex++) { - if (propertyNames[i].equals(parameterNames[parameterIndex])) { - types[i] = parameterTypes[parameterIndex]; - break; - } - } - if (types[i] != null) { - continue; - } - Method getter = propertyGetters[i]; - Method setter = setters[i]; - if (getter != null) { - types[i] = getter.getGenericReturnType(); - } else if (setter != null && setter.getParameterCount() == 1) { - types[i] = setter.getGenericParameterTypes()[0]; - } else { - throw new IllegalArgumentException( - "JSON object model property has no type source " + propertyNames[i]); + if (member instanceof WildcardType + && logicalType.getTypeExtMeta() != null + && logicalType.getTypeExtMeta().covariant()) { + WildcardType wildcard = (WildcardType) member; + Type[] upperBounds = wildcard.getUpperBounds(); + return wildcard.getLowerBounds().length == 0 + && upperBounds.length == 1 + && upperBounds[0] != Object.class + && compatibleType(TypeRef.of(upperBounds[0]), logicalType); + } + Class memberRaw = memberType.getRawType(); + if (memberRaw != logicalType.getRawType()) { + return false; + } + if (memberRaw.isArray()) { + TypeRef memberComponent = memberType.getComponentType(); + TypeRef logicalComponent = logicalType.getComponentType(); + return memberComponent != null + && logicalComponent != null + && compatibleType(memberComponent, logicalComponent); + } + List> memberArguments = memberType.getTypeArguments(); + List> logicalArguments = logicalType.getTypeArguments(); + if (memberArguments.size() != logicalArguments.size()) { + return false; + } + for (int i = 0; i < memberArguments.size(); i++) { + if (!compatibleType(memberArguments.get(i), logicalArguments.get(i))) { + return false; } } - return types; + if (member instanceof ParameterizedType && logical instanceof ParameterizedType) { + return Objects.equals( + ((ParameterizedType) member).getOwnerType(), + ((ParameterizedType) logical).getOwnerType()); + } + // Scala 2 qualifies erased Enumeration.Value occurrences with an exact semantic owner. + return member instanceof Class + && logical instanceof ParameterizedType + && memberRaw.getTypeParameters().length == 0 + && ((ParameterizedType) logical).getActualTypeArguments().length == 0; + } + + private static boolean[] allProperties(int count) { + boolean[] reconstructible = new boolean[count]; + for (int i = 0; i < count; i++) { + reconstructible[i] = true; + } + return reconstructible; } } diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/MapCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/MapCodec.java index 2adc5012cf..1072437475 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/MapCodec.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/MapCodec.java @@ -47,6 +47,7 @@ import org.apache.fory.json.writer.JsonWriter; import org.apache.fory.json.writer.StringJsonWriter; import org.apache.fory.json.writer.Utf8JsonWriter; +import org.apache.fory.meta.TypeExtMeta; import org.apache.fory.platform.GraalvmSupport; import org.apache.fory.reflect.ReflectionUtils; import org.apache.fory.reflect.TypeRef; @@ -130,26 +131,43 @@ public Object readName(JsonReader reader) { public static MapCodec create( Class rawType, TypeRef typeRef, JsonTypeResolver resolver) { Tuple2, TypeRef> keyValueTypeRefs = CodecUtils.mapKeyValueTypeRefs(typeRef); + requireNonNullableKey(keyValueTypeRefs.f0); Type keyType = keyValueTypeRefs.f0.getType(); Class keyRawType = CodecUtils.rawType(keyType, Object.class); - Type valueType = keyValueTypeRefs.f1.getType(); - Class valueRawType = CodecUtils.rawType(valueType, Object.class); resolver.checkMapKeySecure(keyRawType); MapFactory factory = mapFactory(rawType, keyRawType); - JsonTypeInfo valueTypeInfo = resolver.getTypeInfo(valueType, valueRawType); + JsonTypeInfo valueTypeInfo = resolver.getTypeInfo(keyValueTypeRefs.f1); return create(factory, keyRawType, valueTypeInfo); } + @Internal + public static MapCodec create( + Class rawType, TypeRef keyType, JsonTypeInfo valueTypeInfo) { + requireNonNullableKey(keyType); + return create(mapFactory(rawType, keyType.getRawType()), keyType.getRawType(), valueTypeInfo); + } + @Internal public static MapCodec create( Class rawType, Class keyRawType, JsonTypeInfo valueTypeInfo) { return create(mapFactory(rawType, keyRawType), keyRawType, valueTypeInfo); } + @Internal + public static MapCodec create( + Class rawType, TypeRef keyType, JsonTypeInfo valueTypeInfo, MapKeyCodec keyCodec) { + requireNonNullableKey(keyType); + Class keyRawType = keyType.getRawType(); + return genericMapCodec( + mapFactory(rawType, keyRawType), + new CheckedMapKeyCodec(keyRawType, keyCodec), + valueTypeInfo); + } + @Internal public static MapCodec create( Class rawType, Class keyRawType, JsonTypeInfo valueTypeInfo, MapKeyCodec keyCodec) { - return new GenericMapCodec( + return genericMapCodec( mapFactory(rawType, keyRawType), new CheckedMapKeyCodec(keyRawType, keyCodec), valueTypeInfo); @@ -160,43 +178,57 @@ private static MapCodec create( Object valueCodec = valueTypeInfo.stringWriter(); if (keyRawType == String.class) { if (valueCodec == ScalarCodecs.StringCodec.INSTANCE) { - return new StringStringMapCodec(factory); + return new StringStringMapCodec(factory, valueTypeInfo); } if (valueCodec == ScalarCodecs.BooleanCodec.BOXED) { - return new StringBooleanMapCodec(factory); + return new StringBooleanMapCodec(factory, valueTypeInfo); } if (valueCodec == ScalarCodecs.IntCodec.BOXED) { - return new StringIntMapCodec(factory); + return new StringIntMapCodec(factory, valueTypeInfo); } if (valueCodec == ScalarCodecs.LongCodec.BOXED) { - return new StringLongMapCodec(factory); + return new StringLongMapCodec(factory, valueTypeInfo); } if (valueCodec == ScalarCodecs.ShortCodec.BOXED) { - return new StringShortMapCodec(factory); + return new StringShortMapCodec(factory, valueTypeInfo); } if (valueCodec == ScalarCodecs.ByteCodec.BOXED) { - return new StringByteMapCodec(factory); + return new StringByteMapCodec(factory, valueTypeInfo); } if (valueCodec == ScalarCodecs.FloatCodec.BOXED) { - return new StringFloatMapCodec(factory); + return new StringFloatMapCodec(factory, valueTypeInfo); } if (valueCodec == ScalarCodecs.DoubleCodec.BOXED) { - return new StringDoubleMapCodec(factory); + return new StringDoubleMapCodec(factory, valueTypeInfo); } if (valueCodec == ScalarCodecs.BigIntegerCodec.INSTANCE) { - return new StringBigIntegerMapCodec(factory); + return new StringBigIntegerMapCodec(factory, valueTypeInfo); } if (valueCodec == ScalarCodecs.BigDecimalCodec.INSTANCE) { - return new StringBigDecimalMapCodec(factory); + return new StringBigDecimalMapCodec(factory, valueTypeInfo); } } if (keyRawType == Object.class) { - return new GenericMapCodec(factory, OBJECT_KEY_CODEC, valueTypeInfo); + return genericMapCodec(factory, OBJECT_KEY_CODEC, valueTypeInfo); } if (valueCodec == ScalarCodecs.StringCodec.INSTANCE && isNumericKey(keyRawType)) { - return new NumberStringMapCodec(factory, defaultKeyCodec(keyRawType)); + return new NumberStringMapCodec(factory, defaultKeyCodec(keyRawType), valueTypeInfo); + } + return genericMapCodec(factory, defaultKeyCodec(keyRawType), valueTypeInfo); + } + + private static GenericMapCodec genericMapCodec( + MapFactory factory, MapKeyCodec keyCodec, JsonTypeInfo valueTypeInfo) { + return valueTypeInfo.rejectsNull() + ? new NonNullGenericMapCodec(factory, keyCodec, valueTypeInfo) + : new GenericMapCodec(factory, keyCodec, valueTypeInfo); + } + + private static void requireNonNullableKey(TypeRef keyType) { + TypeExtMeta metadata = keyType.getTypeExtMeta(); + if (metadata != null && (metadata.nullable() || metadata.nullableWrapper())) { + throw new ForyJsonException("JSON map key must be non-null: " + keyType); } - return new GenericMapCodec(factory, defaultKeyCodec(keyRawType), valueTypeInfo); } static Map readUntyped(Latin1JsonReader reader) { @@ -495,11 +527,11 @@ final int ownerBytes() { } } - public static final class GenericMapCodec extends MapCodec> { + public static class GenericMapCodec extends MapCodec> { private final MapKeyCodec keyCodec; private final JsonTypeInfo valueTypeInfo; - private GenericMapCodec(MapFactory factory, MapKeyCodec keyCodec, JsonTypeInfo valueTypeInfo) { + GenericMapCodec(MapFactory factory, MapKeyCodec keyCodec, JsonTypeInfo valueTypeInfo) { super(factory); this.keyCodec = keyCodec; this.valueTypeInfo = valueTypeInfo; @@ -517,7 +549,7 @@ public void writeString(StringJsonWriter writer, Map value) { for (Map.Entry entry : value.entrySet()) { writer.writeComma(index++); writeKey(writer, entry.getKey(), keyCodec); - codec.writeString(writer, entry.getValue()); + writeValue(writer, codec, entry.getValue()); } writer.writeObjectEnd(); } @@ -534,7 +566,7 @@ public void writeUtf8(Utf8JsonWriter writer, Map value) { for (Map.Entry entry : value.entrySet()) { writer.writeComma(index++); writeKey(writer, entry.getKey(), keyCodec); - codec.writeUtf8(writer, entry.getValue()); + writeValue(writer, codec, entry.getValue()); } writer.writeObjectEnd(); } @@ -556,7 +588,7 @@ public void writeUtf8(Utf8JsonWriter writer, Map value) { } Object key = keyCodec.readName(reader); reader.expectNextToken(':'); - map.put(key, codec.readLatin1(reader)); + map.put(key, readValue(reader, codec)); size++; } while (reader.consumeNextToken(',')); reader.expectNextToken('}'); @@ -586,7 +618,7 @@ public void writeUtf8(Utf8JsonWriter writer, Map value) { } Object key = keyCodec.readName(reader); reader.expectNextToken(':'); - map.put(key, codec.readUtf16(reader)); + map.put(key, readValue(reader, codec)); size++; } while (reader.consumeNextToken(',')); reader.expectNextToken('}'); @@ -616,7 +648,7 @@ public void writeUtf8(Utf8JsonWriter writer, Map value) { } Object key = keyCodec.readName(reader); reader.expectNextToken(':'); - map.put(key, codec.readUtf8(reader)); + map.put(key, readValue(reader, codec)); size++; } while (reader.consumeNextToken(',')); reader.expectNextToken('}'); @@ -628,11 +660,98 @@ public void writeUtf8(Utf8JsonWriter writer, Map value) { reader.exitDepth(); return finishMap(reader, map); } + + void writeValue(StringJsonWriter writer, StringWriterCodec codec, Object value) { + codec.writeString(writer, value); + } + + void writeValue(Utf8JsonWriter writer, Utf8WriterCodec codec, Object value) { + codec.writeUtf8(writer, value); + } + + Object readValue(Latin1JsonReader reader, Latin1ReaderCodec codec) { + return codec.readLatin1(reader); + } + + Object readValue(Utf16JsonReader reader, Utf16ReaderCodec codec) { + return codec.readUtf16(reader); + } + + Object readValue(Utf8JsonReader reader, Utf8ReaderCodec codec) { + return codec.readUtf8(reader); + } + } + + private static final class NonNullGenericMapCodec extends GenericMapCodec { + private NonNullGenericMapCodec( + MapFactory factory, MapKeyCodec keyCodec, JsonTypeInfo valueTypeInfo) { + super(factory, keyCodec, valueTypeInfo); + } + + @Override + void writeValue(StringJsonWriter writer, StringWriterCodec codec, Object value) { + if (value == null) { + rejectNullMapValue(); + } + codec.writeString(writer, value); + } + + @Override + void writeValue(Utf8JsonWriter writer, Utf8WriterCodec codec, Object value) { + if (value == null) { + rejectNullMapValue(); + } + codec.writeUtf8(writer, value); + } + + @Override + Object readValue(Latin1JsonReader reader, Latin1ReaderCodec codec) { + if (reader.tryReadNullToken()) { + return rejectNullMapValue(); + } + return codec.readLatin1(reader); + } + + @Override + Object readValue(Utf16JsonReader reader, Utf16ReaderCodec codec) { + if (reader.tryReadNullToken()) { + return rejectNullMapValue(); + } + return codec.readUtf16(reader); + } + + @Override + Object readValue(Utf8JsonReader reader, Utf8ReaderCodec codec) { + if (reader.tryReadNullToken()) { + return rejectNullMapValue(); + } + return codec.readUtf8(reader); + } + } + + private static Object rejectNullMapValue() { + throw new ForyJsonException("JSON map value occurrence cannot be null"); } public abstract static class StringKeyMapCodec extends MapCodec> { - StringKeyMapCodec(MapFactory factory) { + private final JsonTypeInfo valueTypeInfo; + + StringKeyMapCodec(MapFactory factory, JsonTypeInfo valueTypeInfo) { super(factory); + this.valueTypeInfo = valueTypeInfo; + } + + final Object nullValue() { + if (valueTypeInfo.rejectsNull()) { + valueTypeInfo.rejectNullValue(); + } + return null; + } + + final void requireNullValue() { + if (valueTypeInfo.rejectsNull()) { + valueTypeInfo.rejectNullValue(); + } } @Override @@ -731,8 +850,8 @@ public abstract static class StringKeyMapCodec extends MapCodec> { } public static final class StringStringMapCodec extends StringKeyMapCodec { - private StringStringMapCodec(MapFactory factory) { - super(factory); + private StringStringMapCodec(MapFactory factory, JsonTypeInfo valueTypeInfo) { + super(factory, valueTypeInfo); } private void writeMap(JsonWriter writer, Object value) { @@ -743,6 +862,7 @@ private void writeMap(JsonWriter writer, Object value) { writer.writeFieldName((String) entry.getKey()); Object element = entry.getValue(); if (element == null) { + requireNullValue(); writer.writeNull(); } else { writer.writeString((String) element); @@ -771,23 +891,23 @@ public void writeUtf8(Utf8JsonWriter writer, Map value) { @Override Object readLatin1Value(Latin1JsonReader reader) { - return reader.readNullableString(); + return reader.tryReadNullToken() ? nullValue() : reader.readString(); } @Override Object readUtf16Value(Utf16JsonReader reader) { - return reader.readNullableString(); + return reader.tryReadNullToken() ? nullValue() : reader.readString(); } @Override Object readUtf8Value(Utf8JsonReader reader) { - return reader.readNullableString(); + return reader.tryReadNullToken() ? nullValue() : reader.readString(); } } public static final class StringBooleanMapCodec extends StringKeyMapCodec { - private StringBooleanMapCodec(MapFactory factory) { - super(factory); + private StringBooleanMapCodec(MapFactory factory, JsonTypeInfo valueTypeInfo) { + super(factory, valueTypeInfo); } private void writeMap(JsonWriter writer, Object value) { @@ -798,6 +918,7 @@ private void writeMap(JsonWriter writer, Object value) { writer.writeFieldName((String) entry.getKey()); Object element = entry.getValue(); if (element == null) { + requireNullValue(); writer.writeNull(); } else { writer.writeBoolean((boolean) element); @@ -826,23 +947,23 @@ public void writeUtf8(Utf8JsonWriter writer, Map value) { @Override Object readLatin1Value(Latin1JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readBooleanValue(); + return reader.tryReadNullToken() ? nullValue() : reader.readBooleanValue(); } @Override Object readUtf16Value(Utf16JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readBooleanValue(); + return reader.tryReadNullToken() ? nullValue() : reader.readBooleanValue(); } @Override Object readUtf8Value(Utf8JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readBooleanValue(); + return reader.tryReadNullToken() ? nullValue() : reader.readBooleanValue(); } } public abstract static class StringNumberMapCodec extends StringKeyMapCodec { - StringNumberMapCodec(MapFactory factory) { - super(factory); + StringNumberMapCodec(MapFactory factory, JsonTypeInfo valueTypeInfo) { + super(factory, valueTypeInfo); } private void writeMap(JsonWriter writer, Object value) { @@ -853,6 +974,7 @@ private void writeMap(JsonWriter writer, Object value) { writer.writeFieldName((String) entry.getKey()); Object element = entry.getValue(); if (element == null) { + requireNullValue(); writer.writeNull(); } else { writeNumber(writer, element); @@ -883,8 +1005,8 @@ public final void writeUtf8(Utf8JsonWriter writer, Map value) { } public static final class StringIntMapCodec extends StringNumberMapCodec { - private StringIntMapCodec(MapFactory factory) { - super(factory); + private StringIntMapCodec(MapFactory factory, JsonTypeInfo valueTypeInfo) { + super(factory, valueTypeInfo); } @Override @@ -894,23 +1016,23 @@ void writeNumber(JsonWriter writer, Object value) { @Override Object readLatin1Value(Latin1JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readIntValue(); + return reader.tryReadNullToken() ? nullValue() : reader.readIntValue(); } @Override Object readUtf16Value(Utf16JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readIntValue(); + return reader.tryReadNullToken() ? nullValue() : reader.readIntValue(); } @Override Object readUtf8Value(Utf8JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readIntValue(); + return reader.tryReadNullToken() ? nullValue() : reader.readIntValue(); } } public static final class StringLongMapCodec extends StringNumberMapCodec { - private StringLongMapCodec(MapFactory factory) { - super(factory); + private StringLongMapCodec(MapFactory factory, JsonTypeInfo valueTypeInfo) { + super(factory, valueTypeInfo); } @Override @@ -920,23 +1042,23 @@ void writeNumber(JsonWriter writer, Object value) { @Override Object readLatin1Value(Latin1JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readLongValue(); + return reader.tryReadNullToken() ? nullValue() : reader.readLongValue(); } @Override Object readUtf16Value(Utf16JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readLongValue(); + return reader.tryReadNullToken() ? nullValue() : reader.readLongValue(); } @Override Object readUtf8Value(Utf8JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readLongValue(); + return reader.tryReadNullToken() ? nullValue() : reader.readLongValue(); } } public static final class StringShortMapCodec extends StringNumberMapCodec { - private StringShortMapCodec(MapFactory factory) { - super(factory); + private StringShortMapCodec(MapFactory factory, JsonTypeInfo valueTypeInfo) { + super(factory, valueTypeInfo); } @Override @@ -946,23 +1068,23 @@ void writeNumber(JsonWriter writer, Object value) { @Override Object readLatin1Value(Latin1JsonReader reader) { - return reader.tryReadNullToken() ? null : readShort(reader.readIntValue()); + return reader.tryReadNullToken() ? nullValue() : readShort(reader.readIntValue()); } @Override Object readUtf16Value(Utf16JsonReader reader) { - return reader.tryReadNullToken() ? null : readShort(reader.readIntValue()); + return reader.tryReadNullToken() ? nullValue() : readShort(reader.readIntValue()); } @Override Object readUtf8Value(Utf8JsonReader reader) { - return reader.tryReadNullToken() ? null : readShort(reader.readIntValue()); + return reader.tryReadNullToken() ? nullValue() : readShort(reader.readIntValue()); } } public static final class StringByteMapCodec extends StringNumberMapCodec { - private StringByteMapCodec(MapFactory factory) { - super(factory); + private StringByteMapCodec(MapFactory factory, JsonTypeInfo valueTypeInfo) { + super(factory, valueTypeInfo); } @Override @@ -972,23 +1094,23 @@ void writeNumber(JsonWriter writer, Object value) { @Override Object readLatin1Value(Latin1JsonReader reader) { - return reader.tryReadNullToken() ? null : readByte(reader.readIntValue()); + return reader.tryReadNullToken() ? nullValue() : readByte(reader.readIntValue()); } @Override Object readUtf16Value(Utf16JsonReader reader) { - return reader.tryReadNullToken() ? null : readByte(reader.readIntValue()); + return reader.tryReadNullToken() ? nullValue() : readByte(reader.readIntValue()); } @Override Object readUtf8Value(Utf8JsonReader reader) { - return reader.tryReadNullToken() ? null : readByte(reader.readIntValue()); + return reader.tryReadNullToken() ? nullValue() : readByte(reader.readIntValue()); } } public static final class StringFloatMapCodec extends StringNumberMapCodec { - private StringFloatMapCodec(MapFactory factory) { - super(factory); + private StringFloatMapCodec(MapFactory factory, JsonTypeInfo valueTypeInfo) { + super(factory, valueTypeInfo); } @Override @@ -998,23 +1120,23 @@ void writeNumber(JsonWriter writer, Object value) { @Override Object readLatin1Value(Latin1JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readFloatTokenValue(); + return reader.tryReadNullToken() ? nullValue() : reader.readFloatTokenValue(); } @Override Object readUtf16Value(Utf16JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readFloatTokenValue(); + return reader.tryReadNullToken() ? nullValue() : reader.readFloatTokenValue(); } @Override Object readUtf8Value(Utf8JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readFloatTokenValue(); + return reader.tryReadNullToken() ? nullValue() : reader.readFloatTokenValue(); } } public static final class StringDoubleMapCodec extends StringNumberMapCodec { - private StringDoubleMapCodec(MapFactory factory) { - super(factory); + private StringDoubleMapCodec(MapFactory factory, JsonTypeInfo valueTypeInfo) { + super(factory, valueTypeInfo); } @Override @@ -1024,23 +1146,23 @@ void writeNumber(JsonWriter writer, Object value) { @Override Object readLatin1Value(Latin1JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readDoubleTokenValue(); + return reader.tryReadNullToken() ? nullValue() : reader.readDoubleTokenValue(); } @Override Object readUtf16Value(Utf16JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readDoubleTokenValue(); + return reader.tryReadNullToken() ? nullValue() : reader.readDoubleTokenValue(); } @Override Object readUtf8Value(Utf8JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readDoubleTokenValue(); + return reader.tryReadNullToken() ? nullValue() : reader.readDoubleTokenValue(); } } public static final class StringBigIntegerMapCodec extends StringNumberMapCodec { - private StringBigIntegerMapCodec(MapFactory factory) { - super(factory); + private StringBigIntegerMapCodec(MapFactory factory, JsonTypeInfo valueTypeInfo) { + super(factory, valueTypeInfo); } @Override @@ -1050,23 +1172,23 @@ void writeNumber(JsonWriter writer, Object value) { @Override Object readLatin1Value(Latin1JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readBigInteger(); + return reader.tryReadNullToken() ? nullValue() : reader.readBigInteger(); } @Override Object readUtf16Value(Utf16JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readBigInteger(); + return reader.tryReadNullToken() ? nullValue() : reader.readBigInteger(); } @Override Object readUtf8Value(Utf8JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readBigInteger(); + return reader.tryReadNullToken() ? nullValue() : reader.readBigInteger(); } } public static final class StringBigDecimalMapCodec extends StringNumberMapCodec { - private StringBigDecimalMapCodec(MapFactory factory) { - super(factory); + private StringBigDecimalMapCodec(MapFactory factory, JsonTypeInfo valueTypeInfo) { + super(factory, valueTypeInfo); } @Override @@ -1076,26 +1198,29 @@ void writeNumber(JsonWriter writer, Object value) { @Override Object readLatin1Value(Latin1JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readBigDecimal(); + return reader.tryReadNullToken() ? nullValue() : reader.readBigDecimal(); } @Override Object readUtf16Value(Utf16JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readBigDecimal(); + return reader.tryReadNullToken() ? nullValue() : reader.readBigDecimal(); } @Override Object readUtf8Value(Utf8JsonReader reader) { - return reader.tryReadNullToken() ? null : reader.readBigDecimal(); + return reader.tryReadNullToken() ? nullValue() : reader.readBigDecimal(); } } public static final class NumberStringMapCodec extends MapCodec> { private final MapKeyCodec keyCodec; + private final JsonTypeInfo valueTypeInfo; - private NumberStringMapCodec(MapFactory factory, MapKeyCodec keyCodec) { + private NumberStringMapCodec( + MapFactory factory, MapKeyCodec keyCodec, JsonTypeInfo valueTypeInfo) { super(factory); this.keyCodec = keyCodec; + this.valueTypeInfo = valueTypeInfo; } private void writeMap(JsonWriter writer, Object value) { @@ -1106,6 +1231,7 @@ private void writeMap(JsonWriter writer, Object value) { writeKey(writer, entry.getKey(), keyCodec); Object element = entry.getValue(); if (element == null) { + requireNullValue(); writer.writeNull(); } else { writer.writeString((String) element); @@ -1148,7 +1274,7 @@ public void writeUtf8(Utf8JsonWriter writer, Map value) { } Object key = keyCodec.readName(reader); reader.expectNextToken(':'); - map.put(key, reader.readNullableString()); + map.put(key, readStringValue(reader)); size++; } while (reader.consumeNextToken(',')); reader.expectNextToken('}'); @@ -1177,7 +1303,7 @@ public void writeUtf8(Utf8JsonWriter writer, Map value) { } Object key = keyCodec.readName(reader); reader.expectNextToken(':'); - map.put(key, reader.readNullableString()); + map.put(key, readStringValue(reader)); size++; } while (reader.consumeNextToken(',')); reader.expectNextToken('}'); @@ -1206,7 +1332,7 @@ public void writeUtf8(Utf8JsonWriter writer, Map value) { } Object key = keyCodec.readName(reader); reader.expectNextToken(':'); - map.put(key, reader.readNullableString()); + map.put(key, readStringValue(reader)); size++; } while (reader.consumeNextToken(',')); reader.expectNextToken('}'); @@ -1218,6 +1344,20 @@ public void writeUtf8(Utf8JsonWriter writer, Map value) { reader.exitDepth(); return finishMap(reader, map); } + + private Object readStringValue(JsonReader reader) { + if (reader.tryReadNullToken()) { + requireNullValue(); + return null; + } + return reader.readString(); + } + + private void requireNullValue() { + if (valueTypeInfo.rejectsNull()) { + valueTypeInfo.rejectNullValue(); + } + } } private static final class CheckedMapKeyCodec implements MapKeyCodec { diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodec.java index 3c1d81bba1..2185ebe40a 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodec.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodec.java @@ -109,7 +109,10 @@ private ObjectCodec( : new JsonFieldTable(readFields, skippedNames); this.instantiator = instantiator; this.creatorInfo = creatorInfo; - graphMemoryBytes = GraphMemoryEstimates.shallowObjectBytes(type); + graphMemoryBytes = + creatorInfo != null && creatorInfo.fixedInstance() + ? 0 + : GraphMemoryEstimates.shallowObjectBytes(type); } @Internal @@ -168,6 +171,29 @@ static ObjectCodec createCodec( ObjectInstantiator instantiator, JsonValidatorInfo validatorInfo) { Class type = ownerType.getRawType(); + if (creatorInfo != null && creatorInfo.fixedInstance()) { + if (validatorInfo != null) { + return new ValidatingFixedObjectCodec<>( + type, + writeFields, + readFields, + creatorInfo, + anyInfo, + skippedNames, + unwrappedInfo, + instantiator, + validatorInfo); + } + return new FixedObjectCodec<>( + type, + writeFields, + readFields, + creatorInfo, + anyInfo, + skippedNames, + unwrappedInfo, + instantiator); + } if (ownerType.getType() instanceof Class) { if (validatorInfo != null) { return new ValidatingObjectCodec<>( @@ -1279,6 +1305,12 @@ final void writeUtf8Object(Utf8JsonWriter writer, T value) { writer.writeObjectEnd(); } + /** Returns whether this object codec represents one pre-existing singleton. */ + @Internal + public final boolean fixedInstance() { + return creatorInfo != null && creatorInfo.fixedInstance(); + } + // ClosedSubtypeCodec owns the open object and discriminator for PROPERTY inclusion. Keep this // interpreted traversal package-local instead of publishing partial-object writing as a child // codec capability; a complete generated writer cannot safely enter an object already in @@ -1309,6 +1341,17 @@ final void writeMembers(Utf8JsonWriter writer, T value, int written) { writeFixedMembers(writer, value, written); } + // PROPERTY dispatch already performs one child call. Let a fixed-object child specialize that + // call for its identity invariant without adding a singleton branch or hook to ordinary object + // writes. + void writeSubtypeMembers(StringJsonWriter writer, T value, int written) { + writeMembers(writer, value, written); + } + + void writeSubtypeMembers(Utf8JsonWriter writer, T value, int written) { + writeMembers(writer, value, written); + } + private int writeUnwrappedMembers(StringJsonWriter writer, Object value, int written) { WriteEntry[] steps = unwrappedInfo.writeSteps(); int[] depths = unwrappedInfo.writeDepths(); @@ -1729,6 +1772,233 @@ private static MethodHandle newAnySetterHandle(Method method) { } } + /** Standard object owner for one pre-existing language singleton. */ + private static class FixedObjectCodec extends ObjectCodec { + private final T instance; + + @SuppressWarnings("unchecked") + private FixedObjectCodec( + Class type, + JsonFieldInfo[] writeFields, + JsonFieldInfo[] readFields, + JsonCreatorInfo creatorInfo, + AnyInfo anyInfo, + String[] skippedNames, + JsonUnwrappedInfo unwrappedInfo, + ObjectInstantiator instantiator) { + super( + type, + writeFields, + readFields, + creatorInfo, + anyInfo, + skippedNames, + unwrappedInfo, + instantiator); + instance = (T) type.cast(creatorInfo.create(null)); + } + + @Override + public void writeString(StringJsonWriter writer, T value) { + if (value == null) { + writer.writeNull(); + return; + } + requireInstance(value); + writer.writeObjectStart(); + writer.writeObjectEnd(); + } + + @Override + public void writeUtf8(Utf8JsonWriter writer, T value) { + if (value == null) { + writer.writeNull(); + return; + } + requireInstance(value); + writer.writeObjectStart(); + writer.writeObjectEnd(); + } + + @Override + public T readLatin1(Latin1JsonReader reader) { + return reader.tryReadNullToken() ? null : readLatin1Object(reader); + } + + @Override + public T readUtf16(Utf16JsonReader reader) { + return reader.tryReadNullToken() ? null : readUtf16Object(reader); + } + + @Override + public T readUtf8(Utf8JsonReader reader) { + return reader.tryReadNullToken() ? null : readUtf8Object(reader); + } + + @Override + T readLatin1Object(Latin1JsonReader reader) { + return readFixedObject(reader); + } + + @Override + T readLatin1Object(Latin1JsonReader reader, JsonFieldTable table) { + return readInlineObject(reader, table); + } + + @Override + T readUtf16Object(Utf16JsonReader reader) { + return readFixedObject(reader); + } + + @Override + T readUtf16Object(Utf16JsonReader reader, JsonFieldTable table) { + return readInlineObject(reader, table); + } + + @Override + T readUtf8Object(Utf8JsonReader reader) { + return readFixedObject(reader); + } + + @Override + T readUtf8Object(Utf8JsonReader reader, JsonFieldTable table) { + return readInlineObject(reader, table); + } + + @Override + void writeSubtypeMembers(StringJsonWriter writer, T value, int written) { + requireInstance(value); + } + + @Override + void writeSubtypeMembers(Utf8JsonWriter writer, T value, int written) { + requireInstance(value); + } + + private void requireInstance(T value) { + if (value != instance) { + throw wrongInstance(); + } + } + + private T readFixedObject(JsonReader reader) { + reader.enterDepth(); + reader.expect('{'); + if (!reader.consume('}')) { + throw nonEmptyObject(); + } + reader.exitDepth(); + return instance; + } + + private T readInlineObject(JsonReader reader, JsonFieldTable table) { + reader.enterDepth(); + reader.expect('{'); + if (reader.consume('}')) { + throw nonEmptyObject(); + } + do { + int match = table.match(reader.readFieldNameHash()); + reader.expect(':'); + if (match != JsonFieldTable.SKIP) { + throw nonEmptyObject(); + } + reader.skipValue(); + } while (reader.consume(',')); + reader.expect('}'); + reader.exitDepth(); + return instance; + } + + private ForyJsonException wrongInstance() { + return new ForyJsonException("Expected singleton instance " + type.getName()); + } + + private ForyJsonException nonEmptyObject() { + return new ForyJsonException( + "JSON singleton " + type.getName() + " requires an empty object"); + } + } + + /** Fixed object owner whose read capability invokes effective validators. */ + private static final class ValidatingFixedObjectCodec extends FixedObjectCodec { + private final JsonValidatorInfo validatorInfo; + + private ValidatingFixedObjectCodec( + Class type, + JsonFieldInfo[] writeFields, + JsonFieldInfo[] readFields, + JsonCreatorInfo creatorInfo, + AnyInfo anyInfo, + String[] skippedNames, + JsonUnwrappedInfo unwrappedInfo, + ObjectInstantiator instantiator, + JsonValidatorInfo validatorInfo) { + super( + type, + writeFields, + readFields, + creatorInfo, + anyInfo, + skippedNames, + unwrappedInfo, + instantiator); + this.validatorInfo = validatorInfo; + } + + @Override + public boolean hasValidators() { + return true; + } + + @Override + public void validateObject(Object value) { + validatorInfo.validate(value); + } + + @Override + T readLatin1Object(Latin1JsonReader reader) { + T object = super.readLatin1Object(reader); + validatorInfo.validate(object); + return object; + } + + @Override + T readLatin1Object(Latin1JsonReader reader, JsonFieldTable table) { + T object = super.readLatin1Object(reader, table); + validatorInfo.validate(object); + return object; + } + + @Override + T readUtf16Object(Utf16JsonReader reader) { + T object = super.readUtf16Object(reader); + validatorInfo.validate(object); + return object; + } + + @Override + T readUtf16Object(Utf16JsonReader reader, JsonFieldTable table) { + T object = super.readUtf16Object(reader, table); + validatorInfo.validate(object); + return object; + } + + @Override + T readUtf8Object(Utf8JsonReader reader) { + T object = super.readUtf8Object(reader); + validatorInfo.validate(object); + return object; + } + + @Override + T readUtf8Object(Utf8JsonReader reader, JsonFieldTable table) { + T object = super.readUtf8Object(reader, table); + validatorInfo.validate(object); + return object; + } + } + /** Owns one parameterized POJO binding whose child types differ from the raw-class binding. */ private static class ParameterizedObjectCodec extends ObjectCodec { private ParameterizedObjectCodec( diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java index 4bf24b6d20..eba59e9e3f 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/ObjectCodecBuilder.java @@ -24,12 +24,10 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Executable; import java.lang.reflect.Field; -import java.lang.reflect.GenericArrayType; import java.lang.reflect.Member; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.lang.reflect.Parameter; -import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Arrays; @@ -112,8 +110,21 @@ boolean record = type, propertyDiscoveryEnabled, record, generatedCodec, annotations, objectModel); JsonValidatorInfo validatorInfo = JsonValidatorInfo.create(type, findValidators(type, annotations), generatedCodec); + if (objectModel != null && objectModel.fixedInstance() != null) { + validateFixedObjectModel(type, hasAnyField, generatedCodec, annotations, objectModel); + return ObjectCodec.createCodec( + ownerType, + new JsonFieldInfo[0], + new JsonFieldInfo[0], + JsonCreatorInfo.fixedInstance(type, objectModel.fixedInstance()), + null, + null, + null, + null, + validatorInfo); + } LinkedHashMap builders = new LinkedHashMap<>(); - addFields(type, record, propertyDiscoveryEnabled, hasAnyField, builders, annotations); + addFields(type, record, propertyDiscoveryEnabled, hasAnyField, builders, annotations, null); if (record) { addRecordAccessors(type, builders, generatedCodec); } else if (objectModel != null) { @@ -121,7 +132,13 @@ boolean record = } Method anySetter = addJsonMethods( - type, propertyDiscoveryEnabled, record, builders, generatedCodec, annotations); + type, + propertyDiscoveryEnabled, + record, + builders, + generatedCodec, + annotations, + objectModel); if (generatedCodec != null && generatedCodec.hasAnySetter() && anySetter == null) { throw new ForyJsonException( "Generated JSON Any setter does not match runtime annotations on " + type.getName()); @@ -166,6 +183,9 @@ boolean record = generatedCodec, annotations, objectModel); + if (objectModel != null) { + validateObjectModelProperties(type, objectModel, builders, creatorInfo); + } if (anySetter != null && (record || creatorInfo != null)) { throw new ForyJsonException( "@JsonAnySetter is not supported on constructor-backed type " + type.getName()); @@ -184,6 +204,7 @@ boolean record = List reads = new ArrayList<>(); List deferredFields = objectModel == null ? null : new ArrayList<>(); List directDeferredFields = objectModel == null ? null : new ArrayList<>(); + List deferredRequired = objectModel == null ? null : new ArrayList<>(); List skippedNames = hasAny ? new ArrayList<>() : null; Map canonicalNames = new LinkedHashMap<>(); Map canonicalHashes = new LinkedHashMap<>(); @@ -203,6 +224,7 @@ boolean record = record, ownerType, propertyNamingStrategy, writeNullFields, generatedCodec); anyConstructionIndex = creatorInfo.argumentCount() + deferredFields.size(); deferredFields.add(field); + deferredRequired.add(builder.requiredDeferred); } continue; } @@ -272,6 +294,7 @@ boolean record = } else if (objectModel != null && builder.hasReadSink()) { unwrappedConstructionIndex = creatorInfo.argumentCount() + deferredFields.size(); deferredFields.add(property); + deferredRequired.add(builder.requiredDeferred); } Declaration declaration = builder.buildUnwrappedDeclaration( @@ -335,6 +358,7 @@ boolean record = if (objectModel != null) { deferredFields.add(field); directDeferredFields.add(field); + deferredRequired.add(builder.requiredDeferred); } } } @@ -373,7 +397,8 @@ boolean record = creatorInfo = creatorInfo.withDeferredFields( deferredFields.toArray(new JsonFieldInfo[0]), - directDeferredFields.toArray(new JsonFieldInfo[0])); + directDeferredFields.toArray(new JsonFieldInfo[0]), + requiredFlags(deferredRequired)); } for (int i = 0; i < readArray.length; i++) { readArray[i].setReadIndex(i); @@ -427,6 +452,28 @@ private static void markRequiredWrite( FieldBuilder builder, JsonCreatorInfo creatorInfo, JsonObjectModel objectModel) { + if (objectModel != null && field.requiresUnboxedBinding()) { + // The logical codec is bound only after the recursive parent shell is published. Its exact + // transparent-null action and physical carrier are normalized in JsonFieldInfo.resolveTypes. + return; + } + if (objectModel != null && field.hasOccurrenceNullability()) { + if (field.occurrenceNullable()) { + if (builder.explicitInclude == JsonProperty.Include.NON_NULL) { + throw new ForyJsonException( + "Nullable reconstructible JSON property " + + field.name() + + " cannot omit an explicit null value"); + } + field.includeNullWrite(); + } else if (builder.hasWriteSource() + && !field.occurrenceWrapsNull() + && field.writeRawType() != null + && !field.writeRawType().isPrimitive()) { + field.requireNonNullWrite(); + } + return; + } int argumentIndex = builder.creatorArgumentIndex; if (objectModel != null && creatorInfo != null @@ -439,6 +486,14 @@ private static void markRequiredWrite( } } + private static boolean[] requiredFlags(List required) { + boolean[] flags = new boolean[required.size()]; + for (int i = 0; i < flags.length; i++) { + flags[i] = required.get(i); + } + return flags; + } + private static Method[] findValidators(Class type, Annotations annotations) { List validators = null; for (Method method : type.getMethods()) { @@ -868,7 +923,8 @@ private static void addFields( boolean propertyDiscoveryEnabled, boolean hasAnyField, LinkedHashMap builders, - Annotations annotations) { + Annotations annotations, + Field[] nonPropertyFields) { List> hierarchy = new ArrayList<>(); for (Class current = type; current != null && current != Object.class; @@ -881,6 +937,9 @@ private static void addFields( for (int i = hierarchy.size() - 1; i >= 0; i--) { Class current = hierarchy.get(i); for (Field field : current.getDeclaredFields()) { + if (containsField(nonPropertyFields, field)) { + continue; + } if (annotations.has(field, JsonUnwrapped.class) && !isEligibleField(field)) { throw new ForyJsonException("@JsonUnwrapped is not supported on JSON field: " + field); } @@ -906,13 +965,26 @@ private static void addFields( } } + private static boolean containsField(Field[] fields, Field target) { + if (fields == null) { + return false; + } + for (Field field : fields) { + if (field.equals(target)) { + return true; + } + } + return false; + } + private static Method addJsonMethods( Class type, boolean propertyDiscoveryEnabled, boolean record, LinkedHashMap builders, GeneratedJsonCodec generatedCodec, - Annotations annotations) { + Annotations annotations, + JsonObjectModel objectModel) { Method anyGetter = null; Method anySetter = null; for (Method method : type.getMethods()) { @@ -951,6 +1023,11 @@ private static Method addJsonMethods( if (!propertyDiscoveryEnabled || record || !isEligibleAccessor(method)) { continue; } + // Language metadata has already installed this exact accessor under its logical source name. + // Bean-name discovery must not create a second property from a mangled JVM method name. + if (containsObjectModelMethod(objectModel, method)) { + continue; + } String propertyName = getterPropertyName(method); if (propertyName != null) { FieldBuilder builder = builders.get(propertyName); @@ -1086,18 +1163,120 @@ private static void addObjectModelAccessors( String[] names = objectModel.propertyNames(); Method[] accessors = objectModel.propertyGetters(); Method[] setters = objectModel.propertySetters(); - Type[] propertyTypes = objectModel.propertyTypes(); + TypeRef[] propertyTypes = objectModel.propertyTypes(); + boolean[] reconstructible = objectModel.propertyReconstructible(); + boolean[] required = objectModel.propertyRequired(); + Set creatorProperties = new HashSet<>(); + for (String name : objectModel.parameterNames()) { + creatorProperties.add(name); + } for (int i = 0; i < names.length; i++) { Method accessor = accessors[i]; FieldBuilder builder = builders.computeIfAbsent(names[i], name -> new FieldBuilder(name, annotations)); builder.setObjectModelType(propertyTypes[i]); + builder.objectModelReconstructible = reconstructible[i]; + builder.requiredDeferred = required[i]; if (accessor != null) { builder.setWriteGetter(type, accessor); } if (setters[i] != null) { builder.setReadSetter(type, setters[i]); } + builder.restrictObjectModelField(creatorProperties.contains(names[i])); + } + } + + private static void validateFixedObjectModel( + Class type, + boolean hasAnyField, + GeneratedJsonCodec generatedCodec, + Annotations annotations, + JsonObjectModel objectModel) { + LinkedHashMap builders = new LinkedHashMap<>(); + // Effective field, getter, setter, and setter-parameter annotations are merged by the same + // property owner used for ordinary objects. A singleton candidate is accepted only when that + // merge removes every instance property in both directions. + addFields( + type, false, true, hasAnyField, builders, annotations, objectModel.nonPropertyFields()); + addObjectModelAccessors(type, builders, annotations, objectModel); + Method anySetter = + addJsonMethods(type, true, false, builders, generatedCodec, annotations, objectModel); + if (anySetter != null) { + throw new ForyJsonException( + "Singleton JSON model has an effective @JsonAnySetter on " + type.getName()); + } + validateObjectModelProperties(type, objectModel, builders, null); + Set candidates = new HashSet<>(Arrays.asList(objectModel.propertyNames())); + for (FieldBuilder builder : builders.values()) { + if (!candidates.contains(builder.name) + && builder.hasLogicalMember() + && (!builder.ignoreRead || !builder.ignoreWrite)) { + throw new ForyJsonException( + "Singleton JSON model has an effective instance property " + + builder.name + + " on " + + type.getName()); + } + } + } + + private static void validateObjectModelProperties( + Class type, + JsonObjectModel model, + LinkedHashMap builders, + JsonCreatorInfo creator) { + String[] names = model.propertyNames(); + boolean[] reconstructible = model.propertyReconstructible(); + for (int i = 0; i < names.length; i++) { + String name = names[i]; + FieldBuilder builder = builders.get(name); + if (builder == null) { + throw new ForyJsonException("Missing JSON object-model property " + name); + } + int argumentIndex = builder.creatorArgumentIndex; + if (argumentIndex >= 0) { + if (builder.ignoreRead != builder.ignoreWrite) { + throw new ForyJsonException( + "Constructor property " + name + " cannot be ignored in one direction on " + type); + } + if (builder.ignoreRead && !creator.hasDefault(argumentIndex)) { + throw new ForyJsonException( + "Ignored constructor property " + name + " requires a language default on " + type); + } + continue; + } + if (builder.ignoreRead && builder.ignoreWrite) { + if (builder.requiredDeferred) { + throw new ForyJsonException( + "Required deferred property " + name + " cannot be ignored on " + type); + } + continue; + } + if (model.fixedInstance() != null) { + throw new ForyJsonException( + "Singleton JSON model has effective instance property " + name + " on " + type); + } + if (!reconstructible[i]) { + throw new ForyJsonException( + "JSON object-model property " + name + " is not reconstructible on " + type); + } + if (!builder.hasWriteSource() || !builder.hasReadSink()) { + throw new ForyJsonException( + "Deferred JSON property " + name + " must be readable and writable on " + type); + } + if (builder.ignoreRead || builder.ignoreWrite) { + throw new ForyJsonException( + "Deferred JSON property " + name + " cannot be ignored in one direction on " + type); + } + if (builder.requiredDeferred + && (builder.objectModelType == null + || builder.objectModelType.getTypeExtMeta() == null + || builder.objectModelType.getTypeExtMeta().nullable() + || builder.objectModelType.getTypeExtMeta().nullableWrapper())) { + throw new ForyJsonException( + "Required deferred JSON property " + name + " must be non-null on " + type); + } } } @@ -1168,10 +1347,10 @@ private static JsonCreatorInfo buildRecordCreatorInfo( if (builder.isAny() || builder.unwrappedAnnotation != null) { continue; } - Type resolved = + TypeRef resolved = parameterTypes == null - ? builder.logicalType(ownerType) - : ownerType.resolveType(parameterTypes[i]).getType(); + ? builder.logicalTypeRef(ownerType) + : ownerType.resolveType(parameterTypes[i]); fields.add( new JsonCreatorFieldInfo( builder.jsonName(namingStrategy), @@ -1180,7 +1359,8 @@ private static JsonCreatorInfo buildRecordCreatorInfo( rawTypes[i], builder.codecAnnotation(), builder.valueCodecClass(), - builder.formatAnnotation())); + builder.formatAnnotation(), + builder.creatorUnboxedRequired)); } JsonCreatorFieldInfo[] fieldArray = fields.toArray(new JsonCreatorFieldInfo[0]); rejectCreatorHashCollisions(fieldArray); @@ -1197,19 +1377,49 @@ private static JsonCreatorInfo buildCreatorInfo( GeneratedJsonCodec generatedCodec, Annotations annotations, JsonObjectModel objectModel) { - JsonCreatorDeclaration declaration = JsonCreatorDeclaration.find(type, annotations.registry); + JsonCreatorDeclaration declaration = + JsonCreatorDeclaration.find(type, annotations.registry, objectModel); if (declaration == null) { - if (generatedCodec != null && generatedCodec.validatedCreatorParameterNames() != null) { + if (objectModel == null + && generatedCodec != null + && generatedCodec.validatedCreatorParameterNames() != null) { throw new ForyJsonException( "Generated JSON creator does not match runtime annotations on " + type.getName()); } - return objectModel == null - ? null - : buildObjectModelCreatorInfo( - type, ownerType, builders, namingStrategy, generatedCodec, annotations, objectModel); + if (objectModel == null) { + return null; + } + validateGeneratedObjectModel(type, objectModel, generatedCodec); + return buildObjectModelCreatorInfo( + type, + ownerType, + builders, + namingStrategy, + generatedCodec, + annotations, + objectModel, + null); } Executable creator = declaration.executable(); JsonCreator annotation = declaration.annotation(); + if (objectModel != null) { + if (!creator.equals(objectModel.creator())) { + throw new ForyJsonException( + "Language JSON object model does not describe selected @JsonCreator " + creator); + } + validateObjectModelCreatorAnnotation( + declaration.annotationSource(), annotation, objectModel, annotations); + validateGeneratedObjectModel(type, objectModel, generatedCodec); + return buildObjectModelCreatorInfo( + type, + ownerType, + builders, + namingStrategy, + generatedCodec, + annotations, + objectModel, + declaration); + } validateGeneratedCreator(type, creator, annotation, generatedCodec, annotations); Map jsonProperties = new LinkedHashMap<>(); @@ -1267,7 +1477,7 @@ private static JsonCreatorInfo buildCreatorInfo( throw new ForyJsonException("@JsonCreator property is ignored for reading: " + javaName); } if (!builder.isAny() && builder.unwrappedAnnotation == null) { - Type resolved = ownerType.resolveType(parameterTypes[i]).getType(); + TypeRef resolved = ownerType.resolveType(parameterTypes[i]); JsonCodec codecAnnotation = builder.codecAnnotation(); Class> valueCodecClass = builder.valueCodecClass(); fields.add( @@ -1278,7 +1488,8 @@ private static JsonCreatorInfo buildCreatorInfo( rawTypes[i], codecAnnotation, valueCodecClass, - builder.formatAnnotation())); + builder.formatAnnotation(), + builder.creatorUnboxedRequired)); } } } else { @@ -1328,7 +1539,7 @@ private static JsonCreatorInfo buildCreatorInfo( "Creator-only property cannot declare an inclusion policy: " + jsonName); } } - Type resolved = ownerType.resolveType(parameterTypes[i]).getType(); + TypeRef resolved = ownerType.resolveType(parameterTypes[i]); JsonCodec codecAnnotation = builder == null ? annotations.get(parameters[i], JsonCodec.class) @@ -1351,7 +1562,7 @@ private static JsonCreatorInfo buildCreatorInfo( jsonName, unwrapped.prefix(), unwrapped.suffix(), - resolved, + resolved.getType(), rawTypes[i], null, null, @@ -1368,7 +1579,8 @@ private static JsonCreatorInfo buildCreatorInfo( rawTypes[i], codecAnnotation, valueCodecClass, - formatAnnotation)); + formatAnnotation, + false)); } } } @@ -1378,6 +1590,40 @@ private static JsonCreatorInfo buildCreatorInfo( type, creator, fieldArray, creatorDefaults(rawTypes), generatedCodec); } + private static void validateObjectModelCreatorAnnotation( + Executable annotationSource, + JsonCreator annotation, + JsonObjectModel objectModel, + Annotations annotations) { + int logicalCount = objectModel.parameterNames().length; + String[] declaredNames = annotation.value(); + if (declaredNames.length != 0) { + if (declaredNames.length != logicalCount) { + throw new ForyJsonException( + "@JsonCreator property count does not match language object model on " + + annotationSource); + } + Parameter[] parameters = annotationSource.getParameters(); + for (int i = 0; i < logicalCount; i++) { + if (annotations.has(parameters[i], JsonProperty.class)) { + throw new ForyJsonException( + "Property-list @JsonCreator parameters cannot declare @JsonProperty: " + + annotationSource); + } + } + return; + } + Parameter[] parameters = annotationSource.getParameters(); + for (int i = 0; i < logicalCount; i++) { + JsonProperty property = annotations.get(parameters[i], JsonProperty.class); + if (property == null || property.value().isEmpty()) { + throw new ForyJsonException( + "Parameter-local @JsonCreator requires a non-empty @JsonProperty on every parameter: " + + annotationSource); + } + } + } + private static JsonCreatorInfo buildObjectModelCreatorInfo( Class type, TypeRef ownerType, @@ -1385,30 +1631,73 @@ private static JsonCreatorInfo buildObjectModelCreatorInfo( PropertyNamingStrategy namingStrategy, GeneratedJsonCodec generatedCodec, Annotations annotations, - JsonObjectModel objectModel) { - Constructor constructor = objectModel.constructor(); + JsonObjectModel objectModel, + JsonCreatorDeclaration declaration) { + if (objectModel.fixedInstance() != null) { + return JsonCreatorInfo.fixedInstance(type, objectModel.fixedInstance()); + } + Executable creator = objectModel.creator(); String[] names = objectModel.parameterNames(); Method[] defaultMethods = objectModel.defaultMethods(); - Type[] parameterTypes = constructor.getGenericParameterTypes(); - Class[] rawTypes = constructor.getParameterTypes(); - Parameter[] parameters = constructor.getParameters(); + int[] defaultMaskBits = objectModel.defaultMaskBits(); + TypeRef[] logicalParameterTypes = objectModel.parameterTypes(); + Type[] parameterTypes = creator.getGenericParameterTypes(); + Class[] rawTypes = creator.getParameterTypes(); + Executable annotationSource = declaration == null ? creator : declaration.annotationSource(); + Parameter[] parameters = annotationSource.getParameters(); + JsonCreator creatorAnnotation = declaration == null ? null : declaration.annotation(); + String[] declaredProperties = creatorAnnotation == null ? null : creatorAnnotation.value(); + Map jsonProperties = null; + if (creatorAnnotation != null && declaredProperties.length == 0) { + jsonProperties = new LinkedHashMap<>(); + for (FieldBuilder builder : builders.values()) { + if (!builder.hasLogicalMember()) { + continue; + } + String jsonName = builder.jsonName(namingStrategy); + FieldBuilder prior = jsonProperties.put(jsonName, builder); + if (prior != null) { + throw new ForyJsonException( + "Duplicate canonical JSON property name " + jsonName + " on " + type.getName()); + } + } + } List fields = new ArrayList<>(parameterTypes.length); for (int i = 0; i < parameterTypes.length; i++) { - FieldBuilder builder = builders.get(names[i]); + FieldBuilder builder; + if (creatorAnnotation == null) { + builder = builders.get(names[i]); + } else if (declaredProperties.length != 0) { + builder = builders.get(declaredProperties[i]); + } else { + JsonProperty property = annotations.get(parameters[i], JsonProperty.class); + builder = property == null ? null : jsonProperties.get(property.value()); + } if (builder == null || !builder.hasLogicalMember()) { - throw new ForyJsonException("Unknown JSON object-model property " + names[i]); + throw new ForyJsonException( + "Unknown JSON object-model property for creator parameter " + + names[i] + + " on " + + creator); + } + if (builder.creatorArgumentIndex >= 0) { + throw new ForyJsonException( + "Multiple creator parameters map to JSON object-model property " + + builder.name + + " on " + + creator); } - bindCreatorType(ownerType, constructor, i, parameterTypes[i], builder); + bindCreatorType(ownerType, creator, i, parameterTypes[i], builder); builder.mergeCreatorParameter(type, parameters[i]); if (!builder.creatorReadAllowed()) { - if (defaultMethods[i] == null) { + if (defaultMethods[i] == null && defaultMaskBits[i] < 0) { throw new ForyJsonException( - "Ignored constructor property " + names[i] + " requires a Scala default"); + "Ignored constructor property " + names[i] + " requires a language default"); } continue; } if (!builder.isAny() && builder.unwrappedAnnotation == null) { - Type resolved = builder.logicalType(ownerType); + TypeRef resolved = logicalParameterTypes[i]; fields.add( new JsonCreatorFieldInfo( builder.jsonName(namingStrategy), @@ -1417,19 +1706,24 @@ private static JsonCreatorInfo buildObjectModelCreatorInfo( rawTypes[i], builder.codecAnnotation(), builder.valueCodecClass(), - builder.formatAnnotation())); + builder.formatAnnotation(), + builder.creatorUnboxedRequired)); } } JsonCreatorFieldInfo[] fieldArray = fields.toArray(new JsonCreatorFieldInfo[0]); rejectCreatorHashCollisions(fieldArray); return new JsonCreatorInfo( type, - constructor, + creator, + objectModel.invocationCreator(), fieldArray, creatorDefaults(rawTypes), generatedCodec, defaultMethods, - names); + names, + objectModel.defaultConstructor(), + defaultMaskBits, + objectModel.parameterNullable()); } private static void validateGeneratedCreator( @@ -1445,7 +1739,6 @@ private static void validateGeneratedCreator( Class[] parameterTypes = generatedCodec.validatedCreatorParameterTypes(); String factoryName = generatedCodec.validatedCreatorFactoryName(); if (names == null - || !creator.equals(generatedCodec.validatedCreator()) || !Arrays.equals(parameterTypes, creator.getParameterTypes()) || creator instanceof Method != (factoryName != null) || creator instanceof Method && !creator.getName().equals(factoryName)) { @@ -1467,6 +1760,26 @@ private static void validateGeneratedCreator( } } + private static void validateGeneratedObjectModel( + Class type, JsonObjectModel objectModel, GeneratedJsonCodec generatedCodec) { + if (generatedCodec == null) { + return; + } + String[] names = generatedCodec.validatedCreatorParameterNames(); + Class[] parameterTypes = generatedCodec.validatedCreatorParameterTypes(); + String factoryName = generatedCodec.validatedCreatorFactoryName(); + Executable creator = objectModel.creator(); + String expectedFactory = creator instanceof Method ? creator.getName() : null; + if (names == null + || !Arrays.equals(names, objectModel.parameterNames()) + || !Arrays.equals(parameterTypes, creator.getParameterTypes()) + || (factoryName == null ? expectedFactory != null : !factoryName.equals(expectedFactory))) { + throw new ForyJsonException( + "Generated JSON creator metadata does not match language object model on " + + type.getName()); + } + } + private static void validatePropertyIndex( int index, String propertyName, Class type, AnnotatedElement source) { if (index < JsonProperty.INDEX_UNKNOWN) { @@ -1488,11 +1801,22 @@ private static void bindCreatorType( int parameterIndex, Type parameterType, FieldBuilder builder) { - Type resolvedParameter = ownerType.resolveType(parameterType).getType(); + TypeRef resolvedParameterRef = ownerType.resolveType(parameterType); + Type resolvedParameter = resolvedParameterRef.getType(); Type propertyType = builder.logicalType(ownerType); - if (!resolvedParameter.equals(propertyType) - && (builder.objectModelType == null - || !sameObjectModelShape(resolvedParameter, propertyType))) { + Class parameterCarrier = creator.getParameterTypes()[parameterIndex]; + boolean compatible = + resolvedParameter.equals(propertyType) + || builder.objectModelType != null + && JsonObjectModel.compatibleType(resolvedParameterRef, builder.objectModelType); + boolean requiresCarrier = + builder.objectModelType != null + && (parameterCarrier == builder.objectModelType.getRawType() || !compatible) + && UnboxedValueCodec.requiresCarrier(parameterCarrier, builder.objectModelType); + if (requiresCarrier) { + builder.creatorUnboxedRequired = true; + } + if (!compatible && !requiresCarrier) { throw new ForyJsonException( "@JsonCreator parameter type " + resolvedParameter @@ -1508,71 +1832,6 @@ private static void bindCreatorType( builder.creatorArgumentIndex = parameterIndex; } - private static boolean sameObjectModelShape(Type left, Type right) { - if (left.equals(right)) { - return true; - } - if (left instanceof Class && ((Class) left).isArray()) { - Type rightComponent = arrayComponent(right); - return rightComponent != null - && sameObjectModelShape(((Class) left).getComponentType(), rightComponent); - } - if (right instanceof Class && ((Class) right).isArray()) { - Type leftComponent = arrayComponent(left); - return leftComponent != null - && sameObjectModelShape(leftComponent, ((Class) right).getComponentType()); - } - if (left instanceof GenericArrayType || right instanceof GenericArrayType) { - Type leftComponent = arrayComponent(left); - Type rightComponent = arrayComponent(right); - return leftComponent != null - && rightComponent != null - && sameObjectModelShape(leftComponent, rightComponent); - } - Class leftRaw = objectModelRawType(left); - Class rightRaw = objectModelRawType(right); - if (leftRaw == null || leftRaw != rightRaw) { - return false; - } - Type[] leftArguments = typeArguments(left); - Type[] rightArguments = typeArguments(right); - if (leftArguments.length != rightArguments.length) { - return false; - } - for (int i = 0; i < leftArguments.length; i++) { - if (!sameObjectModelShape(leftArguments[i], rightArguments[i])) { - return false; - } - } - return true; - } - - private static Type arrayComponent(Type type) { - if (type instanceof Class && ((Class) type).isArray()) { - return ((Class) type).getComponentType(); - } - return type instanceof GenericArrayType - ? ((GenericArrayType) type).getGenericComponentType() - : null; - } - - private static Type[] typeArguments(Type type) { - return type instanceof ParameterizedType - ? ((ParameterizedType) type).getActualTypeArguments() - : new Type[0]; - } - - private static Class objectModelRawType(Type type) { - if (type instanceof Class) { - return (Class) type; - } - if (type instanceof ParameterizedType) { - Type rawType = ((ParameterizedType) type).getRawType(); - return rawType instanceof Class ? (Class) rawType : null; - } - return null; - } - private static void rejectCreatorHashCollisions(JsonCreatorFieldInfo[] fields) { Map names = new LinkedHashMap<>(); for (JsonCreatorFieldInfo field : fields) { @@ -1667,9 +1926,15 @@ private static boolean validateMemberAnnotations( } if (annotations.has(method, JsonCodec.class)) { validateCodecMethod( - type, method, propertyDiscoveryEnabled, record, generatedCodec, annotations); - } - validateCodecParameters(method, propertyDiscoveryEnabled, record, annotations); + type, + method, + propertyDiscoveryEnabled, + record, + generatedCodec, + annotations, + objectModel); + } + validateCodecParameters(method, propertyDiscoveryEnabled, record, objectModel, annotations); if (annotations.has(method, JsonRawValue.class)) { validateRawMethod( type, method, propertyDiscoveryEnabled, record, generatedCodec, annotations); @@ -1682,7 +1947,8 @@ private static boolean validateMemberAnnotations( validateUnwrappedMethod( type, method, propertyDiscoveryEnabled, record, generatedCodec, annotations); } - validateUnwrappedParameters(type, method, propertyDiscoveryEnabled, record, annotations); + validateUnwrappedParameters( + type, method, propertyDiscoveryEnabled, record, objectModel, annotations); if (annotations.has(method, JsonProperty.class)) { validatePropertyMethod(type, method, propertyDiscoveryEnabled, record, generatedCodec); } @@ -1728,9 +1994,15 @@ private static boolean validateMemberAnnotations( // getMethods exposes only the effective inherited declaration. A class or child-interface // override therefore suppresses an annotation from the overridden interface method. validateCodecMethod( - type, method, propertyDiscoveryEnabled, record, generatedCodec, annotations); - } - validateCodecParameters(method, propertyDiscoveryEnabled, record, annotations); + type, + method, + propertyDiscoveryEnabled, + record, + generatedCodec, + annotations, + objectModel); + } + validateCodecParameters(method, propertyDiscoveryEnabled, record, objectModel, annotations); if (annotations.has(method, JsonRawValue.class)) { validateRawMethod( type, method, propertyDiscoveryEnabled, record, generatedCodec, annotations); @@ -1743,7 +2015,8 @@ private static boolean validateMemberAnnotations( validateUnwrappedMethod( type, method, propertyDiscoveryEnabled, record, generatedCodec, annotations); } - validateUnwrappedParameters(type, method, propertyDiscoveryEnabled, record, annotations); + validateUnwrappedParameters( + type, method, propertyDiscoveryEnabled, record, objectModel, annotations); if (annotations.has(method, JsonIgnore.class)) { validateIgnoreMethod( type, method, propertyDiscoveryEnabled, record, generatedCodec, objectModel); @@ -1794,6 +2067,9 @@ private static void validateIgnoreParameters( if (annotations.has(method, JsonCreator.class)) { continue; } + if (objectModel != null && !containsObjectModelSetter(objectModel, method)) { + continue; + } if (i == 0 && (containsObjectModelSetter(objectModel, method) || !record @@ -1817,9 +2093,9 @@ private static void validateIgnoreParameters( boolean selected = annotations.has(constructor, JsonCreator.class) || record && isRecordConstructor(type, constructor) - || objectModel != null && constructor.equals(objectModel.constructor()); + || objectModel != null && constructor.equals(objectModel.creator()); for (Parameter parameter : parameters) { - if (annotations.has(parameter, JsonIgnore.class) && !selected) { + if (annotations.has(parameter, JsonIgnore.class) && !selected && objectModel == null) { throw new ForyJsonException( "@JsonIgnore parameter requires a selected JSON constructor: " + constructor); } @@ -1883,6 +2159,7 @@ private static void validateUnwrappedParameters( Method method, boolean propertyDiscoveryEnabled, boolean record, + JsonObjectModel objectModel, Annotations annotations) { Parameter[] parameters = method.getParameters(); for (int i = 0; i < parameters.length; i++) { @@ -1892,6 +2169,9 @@ private static void validateUnwrappedParameters( if (annotations.has(method, JsonCreator.class)) { continue; } + if (objectModel != null && !containsObjectModelSetter(objectModel, method)) { + continue; + } if (!record && propertyDiscoveryEnabled && isEligibleAccessor(method) @@ -1915,7 +2195,7 @@ private static void validateUnwrappedParameters( JsonUnwrapped annotation = annotations.get(parameters[i], JsonUnwrapped.class); if (annotation == null || annotations.has(constructor, JsonCreator.class) - || objectModel != null && constructor.equals(objectModel.constructor())) { + || objectModel != null) { continue; } if (record && isPropagatedRecordUnwrapped(type, constructor, i, annotation, annotations)) { @@ -1932,7 +2212,8 @@ private static void validateCodecMethod( boolean propertyDiscoveryEnabled, boolean record, GeneratedJsonCodec generatedCodec, - Annotations annotations) { + Annotations annotations, + JsonObjectModel objectModel) { if (annotations.has(method, JsonAnyGetter.class)) { if (!propertyDiscoveryEnabled) { throw new ForyJsonException( @@ -1948,6 +2229,9 @@ private static void validateCodecMethod( throw new ForyJsonException( "@JsonCodec requires an effective ordinary JSON getter: " + method); } + if (containsObjectModelMethod(objectModel, method)) { + return; + } if (!propertyDiscoveryEnabled || !isEligibleAccessor(method) || getterPropertyName(method) == null) { @@ -1957,7 +2241,11 @@ private static void validateCodecMethod( } private static void validateCodecParameters( - Method method, boolean propertyDiscoveryEnabled, boolean record, Annotations annotations) { + Method method, + boolean propertyDiscoveryEnabled, + boolean record, + JsonObjectModel objectModel, + Annotations annotations) { Parameter[] parameters = method.getParameters(); for (int i = 0; i < parameters.length; i++) { if (!annotations.has(parameters[i], JsonCodec.class)) { @@ -1966,6 +2254,9 @@ private static void validateCodecParameters( if (annotations.has(method, JsonCreator.class)) { continue; } + if (objectModel != null && !containsObjectModelSetter(objectModel, method)) { + continue; + } if (annotations.has(method, JsonAnySetter.class)) { if (propertyDiscoveryEnabled && i == 1) { continue; @@ -2000,7 +2291,10 @@ private static void validateCodecParameters( if (record && isRecordConstructor(type, constructor)) { continue; } - if (objectModel != null && constructor.equals(objectModel.constructor())) { + if (objectModel != null && constructor.equals(objectModel.creator())) { + continue; + } + if (objectModel != null) { continue; } throw new ForyJsonException("@JsonCodec parameter requires a @JsonCreator: " + constructor); @@ -2587,6 +2881,7 @@ private static final class FieldBuilder { private AnnotatedElement rawValueSource; private boolean hasJsonProperty; private int creatorArgumentIndex = -1; + private boolean creatorUnboxedRequired; private JsonCodec codecAnnotation; private Class> valueCodecClass; private JsonFormat formatAnnotation; @@ -2596,7 +2891,9 @@ private static final class FieldBuilder { private AnnotatedElement unwrappedSource; private boolean ignoreRead; private boolean ignoreWrite; - private Type objectModelType; + private TypeRef objectModelType; + private boolean objectModelReconstructible = true; + private boolean requiredDeferred; private FieldBuilder(String name, Annotations annotations) { this.name = name; @@ -2634,6 +2931,11 @@ private void setField( } private void setWriteGetter(Class type, Method getter) { + // A language object model installs the exact source getter before ordinary bean discovery. + // Seeing that same Method again is one declaration, not a competing accessor. + if (getter.equals(writeGetter)) { + return; + } mergeIgnore(getter); mergeAnnotation(type, getter); if (ignoreWrite || field != null && !fieldWriteAllowed) { @@ -2646,7 +2948,7 @@ private void setWriteGetter(Class type, Method getter) { writeField = null; } - private void setObjectModelType(Type type) { + private void setObjectModelType(TypeRef type) { if (objectModelType != null && !objectModelType.equals(type)) { throw new ForyJsonException("Conflicting JSON object-model types for property " + name); } @@ -2654,6 +2956,9 @@ private void setObjectModelType(Type type) { } private void setReadSetter(Class type, Method setter) { + if (setter.equals(readSetter)) { + return; + } mergeIgnore(setter); mergeAnnotation(type, setter); Parameter parameter = setter.getParameters()[0]; @@ -2670,6 +2975,24 @@ private void setReadSetter(Class type, Method setter) { readField = null; } + private void restrictObjectModelField(boolean constructorProperty) { + if (writeGetter == null + && writeField != null + && !Modifier.isPublic(writeField.getModifiers())) { + writeField = null; + } + if (constructorProperty || !objectModelReconstructible) { + readField = null; + return; + } + if (readSetter == null + && readField != null + && (!Modifier.isPublic(readField.getModifiers()) + || Modifier.isFinal(readField.getModifiers()))) { + readField = null; + } + } + private void setAnyGetter(Class type, Method getter) { mergeAnnotation(type, getter); if (field != null && !fieldWriteAllowed) { @@ -2740,11 +3063,12 @@ private String nameDescription(PropertyNamingStrategy strategy) { : "Java property " + name + " explicitly named by " + explicitNameSource; } - private Type logicalType(TypeRef ownerType) { - Type type; + private TypeRef logicalTypeRef(TypeRef ownerType) { if (objectModelType != null) { - type = objectModelType; - } else if (writeGetter != null) { + return objectModelType; + } + Type type; + if (writeGetter != null) { type = writeGetter.getGenericReturnType(); } else if (writeField != null) { type = writeField.getGenericType(); @@ -2756,7 +3080,11 @@ private Type logicalType(TypeRef ownerType) { } else { throw new ForyJsonException("JSON property has no type source " + name); } - return ownerType.resolveType(type).getType(); + return ownerType.resolveType(type); + } + + private Type logicalType(TypeRef ownerType) { + return logicalTypeRef(ownerType).getType(); } private JsonFieldInfo build( @@ -3156,12 +3484,24 @@ private void validateTypes(TypeRef ownerType) { readType = ownerType.resolveType(readType).getType(); } if (objectModelType != null) { - Type modelType = ownerType.resolveType(objectModelType).getType(); + Type modelType = objectModelType.getType(); if (writeType == void.class) { writeType = modelType; } - if (writeType != null && !sameObjectModelShape(writeType, modelType) - || readType != null && !sameObjectModelShape(readType, modelType)) { + boolean writeMismatch = + writeType != null + && !JsonObjectModel.compatibleType( + ownerType.resolveType(writeType), objectModelType) + && !UnboxedValueCodec.requiresCarrier(writeRawType(), objectModelType); + Class readRawType = + readSetter != null + ? readSetter.getParameterTypes()[0] + : readField == null ? null : readField.getType(); + boolean readMismatch = + readType != null + && !JsonObjectModel.compatibleType(ownerType.resolveType(readType), objectModelType) + && !UnboxedValueCodec.requiresCarrier(readRawType, objectModelType); + if (writeMismatch || readMismatch) { throw new ForyJsonException( "JSON object-model type " + modelType + " does not match property " + name); } diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/ScalarCodecs.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/ScalarCodecs.java index debc8479cf..9640cf5cb3 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codec/ScalarCodecs.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/ScalarCodecs.java @@ -81,8 +81,11 @@ import org.apache.fory.json.reader.Utf8JsonReader; import org.apache.fory.json.resolver.JsonTypeInfo; import org.apache.fory.json.resolver.JsonTypeResolver; +import org.apache.fory.json.writer.JsonWriter; import org.apache.fory.json.writer.StringJsonWriter; import org.apache.fory.json.writer.Utf8JsonWriter; +import org.apache.fory.meta.TypeExtMeta; +import org.apache.fory.reflect.TypeRef; import org.apache.fory.serializer.GraphMemoryEstimates; import org.apache.fory.type.BFloat16; import org.apache.fory.type.Float16; @@ -1488,7 +1491,7 @@ public void writeUtf8(Utf8JsonWriter writer, UUID value) { if (value == null) { writer.writeNull(); } else { - writer.writeUuid(value); + writer.writeUuid(value.getMostSignificantBits(), value.getLeastSignificantBits()); } } @@ -1497,7 +1500,7 @@ public void writeString(StringJsonWriter writer, UUID value) { if (value == null) { writer.writeNull(); } else { - writer.writeUuid(value); + writer.writeUuid(value.getMostSignificantBits(), value.getLeastSignificantBits()); } } @@ -1857,7 +1860,7 @@ public void writeString(StringJsonWriter writer, Instant value) { if (value == null) { writer.writeNull(); } else { - writer.writeTemporal(value, DateTimeFormatter.ISO_INSTANT); + writer.writeIsoInstant(value.getEpochSecond(), value.getNano()); } } @@ -1866,7 +1869,7 @@ public void writeUtf8(Utf8JsonWriter writer, Instant value) { if (value == null) { writer.writeNull(); } else { - writer.writeTemporal(value, DateTimeFormatter.ISO_INSTANT); + writer.writeIsoInstant(value.getEpochSecond(), value.getNano()); } } @@ -2665,45 +2668,83 @@ public AtomicLong readUtf8(Utf8JsonReader reader) { } } - public static final class AtomicReferenceCodec implements JsonValueCodec> { - private static final int SHALLOW_BYTES = - GraphMemoryEstimates.shallowObjectBytes(AtomicReference.class); + public static class AtomicReferenceCodec + implements JsonValueCodec>, TransparentNullCodec { + static final int SHALLOW_BYTES = GraphMemoryEstimates.shallowObjectBytes(AtomicReference.class); private final JsonTypeInfo valueTypeInfo; + private final byte nullAction; + + private static final byte PLATFORM_NULL = 0; + private static final byte OUTER_NULL = 1; + private static final byte VALUE_NULL = 2; + private static final byte REJECT_NULL = 3; public AtomicReferenceCodec(java.lang.reflect.Type valueType, JsonTypeResolver resolver) { Class valueRawType = CodecUtils.rawType(valueType, Object.class); this.valueTypeInfo = resolver.getTypeInfo(valueType, valueRawType); + nullAction = PLATFORM_NULL; + } + + public AtomicReferenceCodec(TypeRef referenceType, JsonTypeResolver resolver) { + this(referenceType, resolver.getTypeInfo(CodecUtils.elementTypeRef(referenceType))); } @Internal - public AtomicReferenceCodec(JsonTypeInfo valueTypeInfo) { + public AtomicReferenceCodec(TypeRef referenceType, JsonTypeInfo valueTypeInfo) { this.valueTypeInfo = valueTypeInfo; + nullAction = atomicReferenceNullAction(referenceType, valueTypeInfo); + } + + /** Selects the exact transparent child-null operation for one declared reference occurrence. */ + @Internal + public static AtomicReferenceCodec create( + TypeRef referenceType, JsonTypeInfo valueTypeInfo) { + TypeExtMeta metadata = referenceType.getTypeExtMeta(); + if (metadata != null && valueTypeInfo.transparentNull()) { + if (metadata.nullable() && !metadata.nullableWrapper()) { + throw new ForyJsonException( + "Nullable AtomicReference with transparent-null content has ambiguous JSON null"); + } + return new TransparentAtomicReferenceCodec(referenceType, valueTypeInfo); + } + return new AtomicReferenceCodec(referenceType, valueTypeInfo); } @Override public void writeString(StringJsonWriter writer, AtomicReference value) { if (value == null) { + requireAtomicOwner(); writer.writeNull(); } else { - valueTypeInfo.stringWriter().writeString(writer, value.get()); + Object element = value.get(); + if (element == null && nullAction != PLATFORM_NULL) { + writeAtomicNull(writer); + } else { + valueTypeInfo.stringWriter().writeString(writer, element); + } } } @Override public void writeUtf8(Utf8JsonWriter writer, AtomicReference value) { if (value == null) { + requireAtomicOwner(); writer.writeNull(); } else { - valueTypeInfo.utf8Writer().writeUtf8(writer, value.get()); + Object element = value.get(); + if (element == null && nullAction != PLATFORM_NULL) { + writeAtomicNull(writer); + } else { + valueTypeInfo.utf8Writer().writeUtf8(writer, element); + } } } @Override public AtomicReference readLatin1(Latin1JsonReader reader) { if (reader.tryReadNullToken()) { - reader.reserveGraphMemory(SHALLOW_BYTES); - return new AtomicReference<>(); + return readAtomicNull(reader); } Object value = valueTypeInfo.latin1Reader().readLatin1(reader); reader.reserveGraphMemory(SHALLOW_BYTES); @@ -2713,8 +2754,7 @@ public AtomicReference readLatin1(Latin1JsonReader reader) { @Override public AtomicReference readUtf16(Utf16JsonReader reader) { if (reader.tryReadNullToken()) { - reader.reserveGraphMemory(SHALLOW_BYTES); - return new AtomicReference<>(); + return readAtomicNull(reader); } Object value = valueTypeInfo.utf16Reader().readUtf16(reader); reader.reserveGraphMemory(SHALLOW_BYTES); @@ -2724,13 +2764,81 @@ public AtomicReference readUtf16(Utf16JsonReader reader) { @Override public AtomicReference readUtf8(Utf8JsonReader reader) { if (reader.tryReadNullToken()) { - reader.reserveGraphMemory(SHALLOW_BYTES); - return new AtomicReference<>(); + return readAtomicNull(reader); } Object value = valueTypeInfo.utf8Reader().readUtf8(reader); reader.reserveGraphMemory(SHALLOW_BYTES); return new AtomicReference<>(value); } + + private void requireAtomicOwner() { + if (nullAction != PLATFORM_NULL && nullAction != OUTER_NULL) { + throw new ForyJsonException("Non-null AtomicReference occurrence cannot be null"); + } + } + + private void writeAtomicNull(JsonWriter writer) { + if (nullAction != VALUE_NULL) { + throw new ForyJsonException("AtomicReference value occurrence cannot be null"); + } + writer.writeNull(); + } + + private AtomicReference readAtomicNull(JsonReader reader) { + if (nullAction == OUTER_NULL) { + return null; + } + if (nullAction == REJECT_NULL) { + throw new ForyJsonException("AtomicReference value occurrence cannot be null"); + } + reader.reserveGraphMemory(SHALLOW_BYTES); + return new AtomicReference<>(); + } + + private static byte atomicReferenceNullAction( + TypeRef referenceType, JsonTypeInfo valueTypeInfo) { + TypeExtMeta metadata = referenceType.getTypeExtMeta(); + if (metadata == null) { + return PLATFORM_NULL; + } + boolean outerNullable = metadata.nullable() && !metadata.nullableWrapper(); + boolean valueNullable = valueTypeInfo.nullable(); + if (outerNullable && valueNullable) { + throw new ForyJsonException( + "Nullable AtomicReference with nullable content has ambiguous JSON null"); + } + return outerNullable ? OUTER_NULL : valueNullable ? VALUE_NULL : REJECT_NULL; + } + } + + private static final class TransparentAtomicReferenceCodec extends AtomicReferenceCodec { + private final JsonTypeInfo valueTypeInfo; + + private TransparentAtomicReferenceCodec(TypeRef referenceType, JsonTypeInfo valueTypeInfo) { + super(referenceType, valueTypeInfo); + this.valueTypeInfo = valueTypeInfo; + } + + @Override + public AtomicReference readLatin1(Latin1JsonReader reader) { + Object value = valueTypeInfo.latin1Reader().readLatin1(reader); + reader.reserveGraphMemory(SHALLOW_BYTES); + return new AtomicReference<>(value); + } + + @Override + public AtomicReference readUtf16(Utf16JsonReader reader) { + Object value = valueTypeInfo.utf16Reader().readUtf16(reader); + reader.reserveGraphMemory(SHALLOW_BYTES); + return new AtomicReference<>(value); + } + + @Override + public AtomicReference readUtf8(Utf8JsonReader reader) { + Object value = valueTypeInfo.utf8Reader().readUtf8(reader); + reader.reserveGraphMemory(SHALLOW_BYTES); + return new AtomicReference<>(value); + } } public static final class AtomicIntegerArrayCodec implements JsonValueCodec { @@ -2969,8 +3077,7 @@ private static AtomicLongArray readArray(Utf8JsonReader reader) { } } - public static final class AtomicReferenceArrayCodec - implements JsonValueCodec> { + public static class AtomicReferenceArrayCodec implements JsonValueCodec> { private static final int SHALLOW_BYTES = GraphMemoryEstimates.shallowObjectBytes(AtomicReferenceArray.class); private static final int ARRAY_BYTES = GraphMemoryEstimates.objectArrayBytes(); @@ -2986,11 +3093,23 @@ public AtomicReferenceArrayCodec(java.lang.reflect.Type valueType, JsonTypeResol this.valueTypeInfo = resolver.getTypeInfo(valueType, valueRawType); } + public AtomicReferenceArrayCodec(TypeRef valueType, JsonTypeResolver resolver) { + this.valueTypeInfo = resolver.getTypeInfo(valueType); + } + @Internal public AtomicReferenceArrayCodec(JsonTypeInfo valueTypeInfo) { this.valueTypeInfo = valueTypeInfo; } + /** Selects the exact element-null operation for one declared atomic reference array. */ + @Internal + public static AtomicReferenceArrayCodec create(JsonTypeInfo valueTypeInfo) { + return valueTypeInfo.rejectsNull() + ? new NonNullAtomicReferenceArrayCodec(valueTypeInfo) + : new AtomicReferenceArrayCodec(valueTypeInfo); + } + @Override public void writeString(StringJsonWriter writer, AtomicReferenceArray value) { if (value == null) { @@ -3002,7 +3121,7 @@ public void writeString(StringJsonWriter writer, AtomicReferenceArray value) writer.writeArrayStart(); for (int i = 0, length = array.length(); i < length; i++) { writer.writeComma(i); - codec.writeString(writer, array.get(i)); + writeElement(writer, codec, array.get(i)); } writer.writeArrayEnd(); } @@ -3018,7 +3137,7 @@ public void writeUtf8(Utf8JsonWriter writer, AtomicReferenceArray value) { writer.writeArrayStart(); for (int i = 0, length = array.length(); i < length; i++) { writer.writeComma(i); - codec.writeUtf8(writer, array.get(i)); + writeElement(writer, codec, array.get(i)); } writer.writeArrayEnd(); } @@ -3049,7 +3168,7 @@ public AtomicReferenceArray readUtf16(Utf16JsonReader reader) { if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = codec.readUtf16(reader); + values[size++] = readElement(reader, codec); } while (reader.consumeNextCommaOrEndArray()); finishArray(reader, size); return new AtomicReferenceArray<>(Arrays.copyOf(values, size)); @@ -3074,7 +3193,7 @@ public AtomicReferenceArray readUtf8(Utf8JsonReader reader) { if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = codec.readUtf8(reader); + values[size++] = readElement(reader, codec); } while (reader.consumeNextCommaOrEndArray()); finishArray(reader, size); return new AtomicReferenceArray<>(Arrays.copyOf(values, size)); @@ -3095,7 +3214,7 @@ private AtomicReferenceArray readAtomicReferenceArray( if (size == values.length) { values = Arrays.copyOf(values, values.length << 1); } - values[size++] = codec.readLatin1(reader); + values[size++] = readElement(reader, codec); } while (reader.consumeNextCommaOrEndArray()); finishArray(reader, size); return new AtomicReferenceArray<>(Arrays.copyOf(values, size)); @@ -3113,27 +3232,114 @@ private static void reserveReferenceBatch(JsonReader reader, int size) { reader.reserveGraphMemory(REFERENCE_BATCH_BYTES); } } + + void writeElement(StringJsonWriter writer, StringWriterCodec codec, Object element) { + codec.writeString(writer, element); + } + + void writeElement(Utf8JsonWriter writer, Utf8WriterCodec codec, Object element) { + codec.writeUtf8(writer, element); + } + + Object readElement(Latin1JsonReader reader, Latin1ReaderCodec codec) { + return codec.readLatin1(reader); + } + + Object readElement(Utf16JsonReader reader, Utf16ReaderCodec codec) { + return codec.readUtf16(reader); + } + + Object readElement(Utf8JsonReader reader, Utf8ReaderCodec codec) { + return codec.readUtf8(reader); + } + } + + private static final class NonNullAtomicReferenceArrayCodec extends AtomicReferenceArrayCodec { + private NonNullAtomicReferenceArrayCodec(JsonTypeInfo valueTypeInfo) { + super(valueTypeInfo); + } + + @Override + void writeElement(StringJsonWriter writer, StringWriterCodec codec, Object element) { + if (element == null) { + rejectNullAtomicArrayElement(); + } + codec.writeString(writer, element); + } + + @Override + void writeElement(Utf8JsonWriter writer, Utf8WriterCodec codec, Object element) { + if (element == null) { + rejectNullAtomicArrayElement(); + } + codec.writeUtf8(writer, element); + } + + @Override + Object readElement(Latin1JsonReader reader, Latin1ReaderCodec codec) { + if (reader.tryReadNullToken()) { + return rejectNullAtomicArrayElement(); + } + return codec.readLatin1(reader); + } + + @Override + Object readElement(Utf16JsonReader reader, Utf16ReaderCodec codec) { + if (reader.tryReadNullToken()) { + return rejectNullAtomicArrayElement(); + } + return codec.readUtf16(reader); + } + + @Override + Object readElement(Utf8JsonReader reader, Utf8ReaderCodec codec) { + if (reader.tryReadNullToken()) { + return rejectNullAtomicArrayElement(); + } + return codec.readUtf8(reader); + } } - public static final class OptionalCodec implements JsonValueCodec> { + private static Object rejectNullAtomicArrayElement() { + throw new ForyJsonException("AtomicReferenceArray element occurrence cannot be null"); + } + + public static final class OptionalCodec + implements JsonValueCodec>, TransparentNullCodec { private static final int SHALLOW_BYTES = GraphMemoryEstimates.shallowObjectBytes(Optional.class); private final JsonTypeInfo valueTypeInfo; + private final boolean requireOwner; public OptionalCodec(java.lang.reflect.Type valueType, JsonTypeResolver resolver) { Class valueRawType = CodecUtils.rawType(valueType, Object.class); this.valueTypeInfo = resolver.getTypeInfo(valueType, valueRawType); + requireOwner = false; + } + + public OptionalCodec(TypeRef optionalType, JsonTypeResolver resolver) { + this(optionalType, resolver.getTypeInfo(CodecUtils.elementTypeRef(optionalType))); } @Internal - public OptionalCodec(JsonTypeInfo valueTypeInfo) { + public OptionalCodec(TypeRef optionalType, JsonTypeInfo valueTypeInfo) { this.valueTypeInfo = valueTypeInfo; + TypeExtMeta metadata = optionalType.getTypeExtMeta(); + requireOwner = metadata != null; + if (metadata != null && (metadata.nullable() || metadata.nullableWrapper())) { + throw new ForyJsonException("Nullable Optional has ambiguous JSON null: " + optionalType); + } + if (valueTypeInfo.nullable() || valueTypeInfo.transparentNull()) { + throw new ForyJsonException( + "Optional content must have one non-null JSON representation: " + optionalType); + } } @Override public void writeString(StringJsonWriter writer, Optional value) { if (value == null) { + requireOptionalOwner(); writer.writeNull(); return; } @@ -3148,6 +3354,7 @@ public void writeString(StringJsonWriter writer, Optional value) { @Override public void writeUtf8(Utf8JsonWriter writer, Optional value) { if (value == null) { + requireOptionalOwner(); writer.writeNull(); return; } @@ -3197,14 +3404,35 @@ public Optional readUtf8(Utf8JsonReader reader) { reader.reserveGraphMemory(SHALLOW_BYTES); return Optional.of(value); } + + private void requireOptionalOwner() { + if (requireOwner) { + throw new ForyJsonException("Non-null Optional occurrence cannot be null"); + } + } } - public static final class OptionalIntCodec implements JsonValueCodec { - public static final OptionalIntCodec INSTANCE = new OptionalIntCodec(); + private static void requireOptionalOwner(boolean required) { + if (required) { + throw new ForyJsonException("Non-null Optional occurrence cannot be null"); + } + } + + public static final class OptionalIntCodec + implements JsonValueCodec, TransparentNullCodec { + public static final OptionalIntCodec INSTANCE = new OptionalIntCodec(false); + @Internal public static final OptionalIntCodec NON_NULL = new OptionalIntCodec(true); + + private final boolean requireOwner; + + private OptionalIntCodec(boolean requireOwner) { + this.requireOwner = requireOwner; + } @Override public void writeString(StringJsonWriter writer, OptionalInt value) { if (value == null) { + requireOptionalOwner(requireOwner); writer.writeNull(); return; } @@ -3219,6 +3447,7 @@ public void writeString(StringJsonWriter writer, OptionalInt value) { @Override public void writeUtf8(Utf8JsonWriter writer, OptionalInt value) { if (value == null) { + requireOptionalOwner(requireOwner); writer.writeNull(); return; } @@ -3246,12 +3475,21 @@ public OptionalInt readUtf8(Utf8JsonReader reader) { } } - public static final class OptionalLongCodec implements JsonValueCodec { - public static final OptionalLongCodec INSTANCE = new OptionalLongCodec(); + public static final class OptionalLongCodec + implements JsonValueCodec, TransparentNullCodec { + public static final OptionalLongCodec INSTANCE = new OptionalLongCodec(false); + @Internal public static final OptionalLongCodec NON_NULL = new OptionalLongCodec(true); + + private final boolean requireOwner; + + private OptionalLongCodec(boolean requireOwner) { + this.requireOwner = requireOwner; + } @Override public void writeString(StringJsonWriter writer, OptionalLong value) { if (value == null) { + requireOptionalOwner(requireOwner); writer.writeNull(); return; } @@ -3266,6 +3504,7 @@ public void writeString(StringJsonWriter writer, OptionalLong value) { @Override public void writeUtf8(Utf8JsonWriter writer, OptionalLong value) { if (value == null) { + requireOptionalOwner(requireOwner); writer.writeNull(); return; } @@ -3293,12 +3532,21 @@ public OptionalLong readUtf8(Utf8JsonReader reader) { } } - public static final class OptionalDoubleCodec implements JsonValueCodec { - public static final OptionalDoubleCodec INSTANCE = new OptionalDoubleCodec(); + public static final class OptionalDoubleCodec + implements JsonValueCodec, TransparentNullCodec { + public static final OptionalDoubleCodec INSTANCE = new OptionalDoubleCodec(false); + @Internal public static final OptionalDoubleCodec NON_NULL = new OptionalDoubleCodec(true); + + private final boolean requireOwner; + + private OptionalDoubleCodec(boolean requireOwner) { + this.requireOwner = requireOwner; + } @Override public void writeString(StringJsonWriter writer, OptionalDouble value) { if (value == null) { + requireOptionalOwner(requireOwner); writer.writeNull(); return; } @@ -3313,6 +3561,7 @@ public void writeString(StringJsonWriter writer, OptionalDouble value) { @Override public void writeUtf8(Utf8JsonWriter writer, OptionalDouble value) { if (value == null) { + requireOptionalOwner(requireOwner); writer.writeNull(); return; } diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/TransparentNullCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/TransparentNullCodec.java new file mode 100644 index 0000000000..88fe04b88a --- /dev/null +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/TransparentNullCodec.java @@ -0,0 +1,26 @@ +/* + * 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.fory.json.codec; + +import org.apache.fory.annotation.Internal; + +/** Marks a codec that materializes JSON null as a non-outer semantic value. */ +@Internal +public interface TransparentNullCodec {} diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/TransparentUnboxedValueCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/TransparentUnboxedValueCodec.java new file mode 100644 index 0000000000..041480442a --- /dev/null +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/TransparentUnboxedValueCodec.java @@ -0,0 +1,47 @@ +/* + * 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.fory.json.codec; + +import java.lang.reflect.Method; +import org.apache.fory.annotation.Internal; +import org.apache.fory.json.reader.JsonReader; +import org.apache.fory.json.resolver.JsonTypeInfo; + +/** Exact terminal conversion for a logical value transparent to one underlying JSON type. */ +@Internal +public interface TransparentUnboxedValueCodec extends UnboxedValueCodec { + /** Returns the already-bound terminal value type. */ + JsonTypeInfo valueTypeInfo(); + + /** Constructs the parent carrier from one decoded terminal value, charging intermediate boxes. */ + Object constructCarrier(JsonReader reader, Object value); + + /** Extracts the terminal value from one parent carrier. */ + Object extractValue(Object carrier); + + /** Returns exact terminal-to-carrier methods in invocation order. */ + Method[] constructMethods(); + + /** Returns graph charges aligned with {@link #constructMethods()}; zero means no allocation. */ + int[] constructBoxBytes(); + + /** Returns exact carrier-to-terminal methods in invocation order. */ + Method[] extractMethods(); +} diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codec/UnboxedValueCodec.java b/java/fory-json/src/main/java/org/apache/fory/json/codec/UnboxedValueCodec.java new file mode 100644 index 0000000000..8ed4bad0d2 --- /dev/null +++ b/java/fory-json/src/main/java/org/apache/fory/json/codec/UnboxedValueCodec.java @@ -0,0 +1,77 @@ +/* + * 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.fory.json.codec; + +import org.apache.fory.annotation.Internal; +import org.apache.fory.json.reader.Latin1JsonReader; +import org.apache.fory.json.reader.Utf16JsonReader; +import org.apache.fory.json.reader.Utf8JsonReader; +import org.apache.fory.json.writer.StringJsonWriter; +import org.apache.fory.json.writer.Utf8JsonWriter; +import org.apache.fory.meta.TypeExtMeta; +import org.apache.fory.reflect.TypeRef; +import org.apache.fory.type.Types; + +/** + * Cold-bound operations for a logical value whose parent JVM member stores an unboxed carrier. + * + *

This capability is deliberately separate from {@link JsonValueCodec}. The logical codec owns + * the semantic type and its recursive child lifecycle; an object field or creator argument may + * select this capability only after resolving that canonical logical codec. Interpreted object + * codecs use the representation-specific carrier methods below, where primitive boxing is already + * inherent in their argument workspace. Generated codecs cold-select a specialized subtype and + * never call these object-valued methods. + */ +@Internal +public interface UnboxedValueCodec { + /** Returns whether this occurrence requires an exact phase-two carrier operation. */ + static boolean requiresCarrier(Class carrier, TypeRef logicalType) { + if (carrier == null) { + return false; + } + Class logicalClass = logicalType.getRawType(); + TypeExtMeta metadata = logicalType.getTypeExtMeta(); + if (carrier == logicalClass) { + // A primitive semantic leaf can share its JVM carrier with an ordinary primitive while + // requiring different JSON parsing and formatting. Bind its canonical operation before + // generated code specializes the field by carrier kind. + return carrier.isPrimitive() && metadata != null && metadata.typeId() != Types.UNKNOWN; + } + return metadata != null; + } + + /** Returns the exact JVM carrier stored by the parent member. */ + Class carrierType(); + + /** Reads one Latin-1 JSON value directly into the parent carrier. */ + Object readLatin1Carrier(Latin1JsonReader reader); + + /** Reads one UTF-16 JSON value directly into the parent carrier. */ + Object readUtf16Carrier(Utf16JsonReader reader); + + /** Reads one UTF-8 JSON value directly into the parent carrier. */ + Object readUtf8Carrier(Utf8JsonReader reader); + + /** Writes one parent carrier to the String JSON representation. */ + void writeStringCarrier(StringJsonWriter writer, Object carrier); + + /** Writes one parent carrier to the UTF-8 JSON representation. */ + void writeUtf8Carrier(Utf8JsonWriter writer, Object carrier); +} diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codegen/DirectMethodCodegen.java b/java/fory-json/src/main/java/org/apache/fory/json/codegen/DirectMethodCodegen.java new file mode 100644 index 0000000000..7b009a88c2 --- /dev/null +++ b/java/fory-json/src/main/java/org/apache/fory/json/codegen/DirectMethodCodegen.java @@ -0,0 +1,261 @@ +/* + * 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.fory.json.codegen; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Executable; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import org.apache.fory.codegen.JaninoUtils.DirectInvocation; +import org.apache.fory.json.meta.JsonCreatorInfo; + +/** Cold source-placeholder and direct-bytecode metadata for non-source-nameable JVM members. */ +final class DirectMethodCodegen { + private static final Set JAVA_KEYWORDS = + new HashSet<>( + Arrays.asList( + "abstract", + "assert", + "boolean", + "break", + "byte", + "case", + "catch", + "char", + "class", + "const", + "continue", + "default", + "do", + "double", + "else", + "enum", + "extends", + "final", + "finally", + "float", + "for", + "goto", + "if", + "implements", + "import", + "instanceof", + "int", + "interface", + "long", + "native", + "new", + "package", + "private", + "protected", + "public", + "return", + "short", + "static", + "strictfp", + "super", + "switch", + "synchronized", + "this", + "throw", + "throws", + "transient", + "try", + "void", + "volatile", + "while", + "true", + "false", + "null", + "_")); + + private DirectMethodCodegen() {} + + static boolean sourceNameable(Method method) { + String name = method.getName(); + if (name.isEmpty() + || JAVA_KEYWORDS.contains(name) + || !Character.isJavaIdentifierStart(name.charAt(0))) { + return false; + } + for (int i = 1; i < name.length(); i++) { + if (!Character.isJavaIdentifierPart(name.charAt(i))) { + return false; + } + } + return true; + } + + static String getterName(Method getter) { + return bridgeName("get", getter); + } + + static String setterName(Method setter) { + return bridgeName("set", setter); + } + + static String fullCreatorName(Executable executable) { + return bridgeName("create", executable); + } + + static String defaultCreatorName(Constructor constructor) { + return bridgeName("defaults", constructor); + } + + static String valueOperationName(Method method) { + return bridgeName("value", method); + } + + static DirectInvocation getterInvocation(Method getter) { + return DirectInvocation.method( + getterName(getter), + getter.getReturnType(), + new Class[] {getter.getDeclaringClass()}, + getter, + 0); + } + + static DirectInvocation setterInvocation(Method setter) { + return DirectInvocation.method( + setterName(setter), + void.class, + new Class[] {setter.getDeclaringClass(), setter.getParameterTypes()[0]}, + setter, + 0, + 1); + } + + static DirectInvocation valueOperationInvocation(Method method) { + Class[] targetParameters = method.getParameterTypes(); + if (Modifier.isStatic(method.getModifiers())) { + int[] arguments = new int[targetParameters.length]; + for (int i = 0; i < arguments.length; i++) { + arguments[i] = i; + } + return DirectInvocation.method( + valueOperationName(method), + method.getReturnType(), + targetParameters, + method, + -1, + arguments); + } + Class[] bridgeParameters = new Class[targetParameters.length + 1]; + bridgeParameters[0] = method.getDeclaringClass(); + System.arraycopy(targetParameters, 0, bridgeParameters, 1, targetParameters.length); + int[] arguments = new int[targetParameters.length]; + for (int i = 0; i < arguments.length; i++) { + arguments[i] = i + 1; + } + return DirectInvocation.method( + valueOperationName(method), method.getReturnType(), bridgeParameters, method, 0, arguments); + } + + static DirectInvocation constructorInvocation( + String name, Class[] bridgeParameters, Constructor constructor, int[] targetArguments) { + return DirectInvocation.constructor(name, bridgeParameters, constructor, targetArguments); + } + + static boolean requiresFullCreatorBridge(JsonCreatorInfo creator) { + Executable executable = creator.executable(); + Executable target = creator.invocationExecutable(); + return creator.defaultConstructor() != null + || executable != target + || !java.lang.reflect.Modifier.isPublic(target.getModifiers()) + || target instanceof Method && !sourceNameable((Method) target); + } + + static DirectInvocation fullCreatorInvocation(JsonCreatorInfo creator) { + Executable executable = creator.executable(); + Executable target = creator.invocationExecutable(); + Class[] parameters = executable.getParameterTypes(); + int[] arguments = invocationArguments(parameters.length, target.getParameterCount()); + if (target instanceof Constructor) { + return constructorInvocation( + fullCreatorName(target), parameters, (Constructor) target, arguments); + } + Method method = (Method) target; + return DirectInvocation.method( + fullCreatorName(target), method.getReturnType(), parameters, method, -1, arguments); + } + + static DirectInvocation defaultCreatorInvocation(JsonCreatorInfo creator) { + Constructor target = creator.defaultConstructor(); + Class[] logical = creator.executable().getParameterTypes(); + Class[] parameters = Arrays.copyOf(logical, logical.length + creator.defaultMaskCount()); + Arrays.fill(parameters, logical.length, parameters.length, int.class); + return constructorInvocation( + defaultCreatorName(target), + parameters, + target, + invocationArguments(parameters.length, target.getParameterCount())); + } + + private static int[] invocationArguments(int supplied, int targetCount) { + if (targetCount < supplied || targetCount > supplied + 1) { + throw new IllegalArgumentException("Invalid generated creator invocation shape"); + } + int[] arguments = new int[targetCount]; + for (int i = 0; i < supplied; i++) { + arguments[i] = i; + } + if (targetCount != supplied) { + arguments[targetCount - 1] = -1; + } + return arguments; + } + + private static String bridgeName(String role, Executable executable) { + StringBuilder identity = + new StringBuilder(role) + .append(':') + .append(executable.getDeclaringClass().getName()) + .append(':') + .append(executable instanceof Constructor ? "" : executable.getName()) + .append('('); + for (Class type : executable.getParameterTypes()) { + identity.append(type.getName()).append(';'); + } + if (executable instanceof Method) { + identity.append(')').append(((Method) executable).getReturnType().getName()); + } + byte[] digest; + try { + digest = + MessageDigest.getInstance("SHA-256") + .digest(identity.toString().getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException e) { + throw new ExceptionInInitializerError(e); + } + StringBuilder name = new StringBuilder("fory_").append(role).append('_'); + for (int i = 0; i < 12; i++) { + int value = digest[i] & 0xff; + name.append(Character.forDigit(value >>> 4, 16)); + name.append(Character.forDigit(value & 15, 16)); + } + return name.toString(); + } +} diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonCodegen.java b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonCodegen.java index 5c1ac5eb7b..43621b80e1 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonCodegen.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonCodegen.java @@ -19,12 +19,19 @@ package org.apache.fory.json.codegen; +import java.lang.reflect.Executable; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -35,25 +42,31 @@ import org.apache.fory.codegen.CodegenContext; import org.apache.fory.codegen.CompileUnit; import org.apache.fory.codegen.JaninoUtils; +import org.apache.fory.codegen.JaninoUtils.DirectInvocation; import org.apache.fory.json.ForyJsonException; import org.apache.fory.json.codec.CodecUtils; import org.apache.fory.json.codec.CollectionCodec; +import org.apache.fory.json.codec.DirectUnboxedValueCodec; import org.apache.fory.json.codec.JsonUnwrappedInfo; import org.apache.fory.json.codec.Latin1ReaderCodec; import org.apache.fory.json.codec.ObjectCodec; import org.apache.fory.json.codec.ObjectCodec.AnyInfo; import org.apache.fory.json.codec.StringWriterCodec; +import org.apache.fory.json.codec.TransparentUnboxedValueCodec; +import org.apache.fory.json.codec.UnboxedValueCodec; import org.apache.fory.json.codec.Utf16ReaderCodec; import org.apache.fory.json.codec.Utf8ReaderCodec; import org.apache.fory.json.codec.Utf8WriterCodec; import org.apache.fory.json.meta.JsonCreatorFieldInfo; import org.apache.fory.json.meta.JsonCreatorInfo; +import org.apache.fory.json.meta.JsonFieldAccessor; import org.apache.fory.json.meta.JsonFieldInfo; import org.apache.fory.json.meta.JsonFieldKind; import org.apache.fory.json.resolver.JsonTypeInfo; import org.apache.fory.json.resolver.JsonTypeResolver; import org.apache.fory.platform.internal.DefineClass; import org.apache.fory.platform.internal._JDKAccess; +import org.apache.fory.reflect.TypeRef; /** * Generates concrete object and exact-collection capability classes. @@ -76,9 +89,9 @@ public final class JsonCodegen { // use padding, annotations, or compiler directives to manufacture the boundary, and never add // the already-independent callee body back to this planner's budget. private static final int HOT_INLINE_LIMIT = 325; - private static final Map> ID_GENERATOR = new ConcurrentHashMap<>(); - - private final int codegenHash; + private static final int GENERATED_NAME_PREFIX_CODE_POINTS = 32; + private final String codegenIdentity; + private final Map generatedClassSignatures = new ConcurrentHashMap<>(); private final CodeGenerator codeGenerator; private final ClassLoader jsonLoader; private final boolean hostedCodegen; @@ -94,8 +107,8 @@ static String generatedCodecArrayType(CodegenContext ctx, Class arrayType) { return ctx.type(arrayType); } - public JsonCodegen(int codegenHash, ClassLoader jsonLoader, boolean hostedCodegen) { - this.codegenHash = codegenHash; + public JsonCodegen(JsonCodegenKey codegenKey, ClassLoader jsonLoader, boolean hostedCodegen) { + codegenIdentity = codegenKey.identity(); this.jsonLoader = jsonLoader; this.hostedCodegen = hostedCodegen; codeGenerator = new CodeGenerator(jsonLoader); @@ -114,52 +127,65 @@ public JsonCodegen(int codegenHash, ClassLoader jsonLoader, boolean hostedCodege * org.apache.fory.json.resolver.JsonTypeResolver} and are ordered by its {@link JsonJITContext}. */ @Internal - public Class compileStringWriter(ObjectCodec codec, JsonTypeResolver resolver) { + public Class compileStringWriter( + TypeRef declaredType, ObjectCodec codec, JsonTypeResolver resolver) { if (!canCompileWriter(codec)) { return null; } - return buildStringWriter(codec, resolver); + return buildStringWriter(declaredType, codec, resolver); } @Internal - public Class compileUtf8Writer(ObjectCodec codec, JsonTypeResolver resolver) { + public Class compileUtf8Writer( + TypeRef declaredType, ObjectCodec codec, JsonTypeResolver resolver) { if (!canCompileWriter(codec)) { return null; } - return buildUtf8Writer(codec, resolver); + return buildUtf8Writer(declaredType, codec, resolver); } @Internal - public Class compileLatin1Reader(ObjectCodec codec, JsonTypeResolver resolver) { + public Class compileLatin1Reader( + TypeRef declaredType, ObjectCodec codec, JsonTypeResolver resolver) { if (!canCompileReader(codec)) { return null; } - return buildLatin1Reader(codec, resolver); + return buildLatin1Reader(declaredType, codec, resolver); } @Internal - public Class compileUtf16Reader(ObjectCodec codec, JsonTypeResolver resolver) { + public Class compileUtf16Reader( + TypeRef declaredType, ObjectCodec codec, JsonTypeResolver resolver) { if (!canCompileReader(codec)) { return null; } - return buildUtf16Reader(codec, resolver); + return buildUtf16Reader(declaredType, codec, resolver); } @Internal public Class compileUtf8Reader( - ObjectCodec codec, JsonTypeResolver resolver, boolean finalDependencies) { + TypeRef declaredType, + ObjectCodec codec, + JsonTypeResolver resolver, + boolean finalDependencies) { if (!canCompileReader(codec)) { return null; } - return buildUtf8Reader(codec, resolver, finalDependencies); + return buildUtf8Reader(declaredType, codec, resolver, finalDependencies); } @Internal - public Class compileUtf8CollectionWriter(Type declaredType, CollectionCodec owner) { - Class rawType = CodecUtils.rawType(declaredType, Collection.class); - Class elementType = CodecUtils.rawType(CodecUtils.elementType(declaredType), Object.class); + public Class compileUtf8CollectionWriter(TypeRef declaredType, CollectionCodec owner) { + Type type = declaredType.getType(); + Class rawType = CodecUtils.rawType(type, Collection.class); + Class elementType = CodecUtils.rawType(CodecUtils.elementType(type), Object.class); String generatedPackage = CodeGenerator.getPackage(elementType); - String className = className(elementType, simpleClassName(rawType) + "Utf8CollectionWriter"); + String className = + className( + declaredType, + simpleClassName(rawType) + "Utf8CollectionWriter", + generatedPackage, + owner); boolean stringElements = owner instanceof CollectionCodec.StringCollectionCodec; String code = new Utf8CollectionWriterCodegen().genCode(generatedPackage, className, stringElements); @@ -167,62 +193,133 @@ public Class compileUtf8CollectionWriter(Type declaredType, CollectionCodec compileUtf8CollectionReader(Type declaredType, CollectionCodec owner) { + public Class compileUtf8CollectionReader(TypeRef declaredType, CollectionCodec owner) { if (!owner.createsArrayList()) { throw new IllegalArgumentException( "Generated UTF-8 collection requires an ArrayList binding"); } - Class rawType = CodecUtils.rawType(declaredType, Collection.class); - Class elementType = CodecUtils.rawType(CodecUtils.elementType(declaredType), Object.class); + Type type = declaredType.getType(); + Class rawType = CodecUtils.rawType(type, Collection.class); + Class elementType = CodecUtils.rawType(CodecUtils.elementType(type), Object.class); String generatedPackage = CodeGenerator.getPackage(elementType); - String className = className(elementType, simpleClassName(rawType) + "Utf8CollectionReader"); + String className = + className( + declaredType, + simpleClassName(rawType) + "Utf8CollectionReader", + generatedPackage, + owner); boolean stringElements = owner instanceof CollectionCodec.StringCollectionCodec; String code = new Utf8CollectionReaderCodegen().genCode(generatedPackage, className, stringElements); return compileCodecClass(generatedPackage, className, code); } - @Internal - public String stringWriterJITId(Class type) { - return jitId(type, "StringWriter"); - } - - @Internal - public String utf8WriterJITId(Class type) { - return jitId(type, "Utf8Writer"); - } - - @Internal - public String latin1ReaderJITId(Class type) { - return jitId(type, "Latin1Reader"); - } - - @Internal - public String utf16ReaderJITId(Class type) { - return jitId(type, "Utf16Reader"); + private DirectInvocation[] writerInvocations(ObjectCodec codec) { + LinkedHashMap invocations = new LinkedHashMap<>(); + for (JsonFieldInfo field : codec.writeFields()) { + Method getter = field.writeGetter(); + if (getter != null && !DirectMethodCodegen.sourceNameable(getter)) { + addInvocation(invocations, DirectMethodCodegen.getterInvocation(getter)); + } + if (field.writeDirectUnboxedValueCodec() != null) { + addInvocation( + invocations, + DirectMethodCodegen.valueOperationInvocation( + field.writeDirectUnboxedValueCodec().writeCarrierMethod())); + } else if (field.writeTransparentUnboxedValueCodec() != null) { + for (Method method : field.writeTransparentUnboxedValueCodec().extractMethods()) { + addInvocation(invocations, DirectMethodCodegen.valueOperationInvocation(method)); + } + UnboxedValueCodec terminal = field.writeTypeInfo().unboxedValueCodec(); + if (terminal instanceof DirectUnboxedValueCodec) { + addInvocation( + invocations, + DirectMethodCodegen.valueOperationInvocation( + ((DirectUnboxedValueCodec) terminal).writeCarrierMethod())); + } + } + } + AnyInfo any = codec.anyInfo(); + if (any != null + && any.writeGetter() != null + && !DirectMethodCodegen.sourceNameable(any.writeGetter())) { + addInvocation(invocations, DirectMethodCodegen.getterInvocation(any.writeGetter())); + } + return invocations.values().toArray(new DirectInvocation[0]); } - @Internal - public String utf8ReaderJITId(Class type) { - return jitId(type, "Utf8Reader"); + private DirectInvocation[] readerInvocations(ObjectCodec codec) { + LinkedHashMap invocations = new LinkedHashMap<>(); + for (JsonFieldInfo field : codec.readFields()) { + Method setter = field.readSetter(); + if (setter != null && !DirectMethodCodegen.sourceNameable(setter)) { + addInvocation(invocations, DirectMethodCodegen.setterInvocation(setter)); + } + addReadValueInvocations( + invocations, + field.readDirectUnboxedValueCodec(), + field.readTransparentUnboxedValueCodec()); + } + JsonCreatorInfo creator = codec.creatorInfo(); + if (creator != null && !creator.fixedInstance()) { + for (JsonCreatorFieldInfo field : creator.fields()) { + addReadValueInvocations( + invocations, field.directUnboxedValueCodec(), field.transparentUnboxedValueCodec()); + } + if (DirectMethodCodegen.requiresFullCreatorBridge(creator)) { + addInvocation(invocations, DirectMethodCodegen.fullCreatorInvocation(creator)); + } + if (creator.defaultConstructor() != null) { + addInvocation(invocations, DirectMethodCodegen.defaultCreatorInvocation(creator)); + } + } + return invocations.values().toArray(new DirectInvocation[0]); + } + + private static void addReadValueInvocations( + Map invocations, + DirectUnboxedValueCodec direct, + TransparentUnboxedValueCodec unboxed) { + if (direct != null) { + addInvocation( + invocations, DirectMethodCodegen.valueOperationInvocation(direct.readCarrierMethod())); + } else if (unboxed != null) { + UnboxedValueCodec terminal = unboxed.valueTypeInfo().unboxedValueCodec(); + if (terminal instanceof DirectUnboxedValueCodec) { + addInvocation( + invocations, + DirectMethodCodegen.valueOperationInvocation( + ((DirectUnboxedValueCodec) terminal).readCarrierMethod())); + } + for (Method method : unboxed.constructMethods()) { + addInvocation(invocations, DirectMethodCodegen.valueOperationInvocation(method)); + } + } } - private String jitId(Class type, String role) { - return qualifiedClassName(CodeGenerator.getPackage(type), className(type, role)); + private static void addInvocation( + Map invocations, DirectInvocation invocation) { + String key = invocation.bridgeName() + invocation.descriptor(); + DirectInvocation previous = invocations.putIfAbsent(key, invocation); + if (previous != null && !previous.sameTarget(invocation)) { + throw new ForyJsonException("Generated direct invocation collision for " + key); + } } - private Class buildStringWriter(ObjectCodec codec, JsonTypeResolver resolver) { + private Class buildStringWriter( + TypeRef declaredType, ObjectCodec codec, JsonTypeResolver resolver) { Class type = codec.type(); String generatedPackage = CodeGenerator.getPackage(type); - String className = className(type, "StringWriter"); + String className = className(declaredType, "StringWriter", generatedPackage, codec); + DirectInvocation[] invocations = writerInvocations(codec); JsonUnwrappedInfo unwrapped = codec.unwrappedInfo(); if (unwrapped != null) { JsonGeneratedCodecBuilder builder = new JsonGeneratedCodecBuilder(generatedPackage, className, type); String code = - new StringWriterCodegen(this, resolver) + new StringWriterCodegen(this, resolver, codec) .genUnwrappedWriterCode(builder, type, codec, unwrapped); - return compileObjectCodecClass(type, generatedPackage, className, code); + return compileObjectCodecClass(type, generatedPackage, className, code, invocations); } AnyInfo any = codec.anyInfo(); JsonFieldInfo[] properties = codec.writeFields(); @@ -230,32 +327,42 @@ private Class buildStringWriter(ObjectCodec codec, JsonTypeResolver resolv JsonGeneratedCodecBuilder builder = new JsonGeneratedCodecBuilder(generatedPackage, className, type); String code = - new StringWriterCodegen(this, resolver).genAnyWriterCode(builder, type, properties, any); - return compileObjectCodecClass(type, generatedPackage, className, code); + new StringWriterCodegen(this, resolver, codec) + .genAnyWriterCode(builder, type, properties, any); + return compileObjectCodecClass(type, generatedPackage, className, code, invocations); } Function source = groupEnds -> { JsonGeneratedCodecBuilder builder = new JsonGeneratedCodecBuilder(generatedPackage, className, type); - return new StringWriterCodegen(this, resolver) + return new StringWriterCodegen(this, resolver, codec) .genWriterCode(builder, type, properties, groupEnds); }; return compileWriterClass( - type, generatedPackage, className, properties, "writeString", "writeStringMembers", source); + type, + generatedPackage, + className, + properties, + "writeString", + "writeStringMembers", + source, + invocations); } - private Class buildUtf8Writer(ObjectCodec codec, JsonTypeResolver resolver) { + private Class buildUtf8Writer( + TypeRef declaredType, ObjectCodec codec, JsonTypeResolver resolver) { Class type = codec.type(); String generatedPackage = CodeGenerator.getPackage(type); - String className = className(type, "Utf8Writer"); + String className = className(declaredType, "Utf8Writer", generatedPackage, codec); + DirectInvocation[] invocations = writerInvocations(codec); JsonUnwrappedInfo unwrapped = codec.unwrappedInfo(); if (unwrapped != null) { JsonGeneratedCodecBuilder builder = new JsonGeneratedCodecBuilder(generatedPackage, className, type); String code = - new Utf8WriterCodegen(this, resolver, false) + new Utf8WriterCodegen(this, resolver, codec, false) .genUnwrappedWriterCode(builder, type, codec, unwrapped); - return compileObjectCodecClass(type, generatedPackage, className, code); + return compileObjectCodecClass(type, generatedPackage, className, code, invocations); } AnyInfo any = codec.anyInfo(); JsonFieldInfo[] properties = codec.writeFields(); @@ -263,22 +370,22 @@ private Class buildUtf8Writer(ObjectCodec codec, JsonTypeResolver resolver JsonGeneratedCodecBuilder builder = new JsonGeneratedCodecBuilder(generatedPackage, className, type); String code = - new Utf8WriterCodegen(this, resolver, false) + new Utf8WriterCodegen(this, resolver, codec, false) .genAnyWriterCode(builder, type, properties, any); - return compileObjectCodecClass(type, generatedPackage, className, code); + return compileObjectCodecClass(type, generatedPackage, className, code, invocations); } Function normalSource = groupEnds -> { JsonGeneratedCodecBuilder builder = new JsonGeneratedCodecBuilder(generatedPackage, className, type); - return new Utf8WriterCodegen(this, resolver, false) + return new Utf8WriterCodegen(this, resolver, codec, false) .genWriterCode(builder, type, properties, groupEnds); }; Function groupedSource = groupEnds -> { JsonGeneratedCodecBuilder builder = new JsonGeneratedCodecBuilder(generatedPackage, className, type); - return new Utf8WriterCodegen(this, resolver, false) + return new Utf8WriterCodegen(this, resolver, codec, false) .genRootGroupedWriterCode(builder, type, properties, groupEnds); }; String directSource = normalSource.apply(null); @@ -292,22 +399,32 @@ private Class buildUtf8Writer(ObjectCodec codec, JsonTypeResolver resolver JsonGeneratedCodecBuilder builder = new JsonGeneratedCodecBuilder(generatedPackage, className, type); String expandedSource = - new Utf8WriterCodegen(this, resolver, true) + new Utf8WriterCodegen(this, resolver, codec, true) .genWriterCode(builder, type, properties, null); int expandedSize = methodSize(codeStats(generatedPackage, className, expandedSource), "writeUtf8"); if (expandedSize > HOT_INLINE_LIMIT) { - return compileObjectCodecClass(type, generatedPackage, className, expandedSource); + return compileObjectCodecClass( + type, generatedPackage, className, expandedSource, invocations); } } return compileUtf8WriterClass( - type, generatedPackage, className, properties, "writeUtf8", directSource, groupedSource); + type, + generatedPackage, + className, + properties, + "writeUtf8", + directSource, + groupedSource, + invocations); } - private Class buildLatin1Reader(ObjectCodec codec, JsonTypeResolver resolver) { + private Class buildLatin1Reader( + TypeRef declaredType, ObjectCodec codec, JsonTypeResolver resolver) { Class type = codec.type(); String generatedPackage = CodeGenerator.getPackage(type); - String className = className(type, "Latin1Reader"); + String className = className(declaredType, "Latin1Reader", generatedPackage, codec); + DirectInvocation[] invocations = readerInvocations(codec); JsonUnwrappedInfo unwrapped = codec.unwrappedInfo(); if (unwrapped != null) { JsonGeneratedCodecBuilder builder = @@ -315,7 +432,7 @@ private Class buildLatin1Reader(ObjectCodec codec, JsonTypeResolver resolv String code = new Latin1ReaderCodegen(this, resolver) .genUnwrappedReaderCode(builder, type, codec, unwrapped); - return compileObjectCodecClass(type, generatedPackage, className, code); + return compileObjectCodecClass(type, generatedPackage, className, code, invocations); } AnyInfo any = codec.anyInfo(); JsonFieldInfo[] properties = codec.readFields(); @@ -335,13 +452,16 @@ private Class buildLatin1Reader(ObjectCodec codec, JsonTypeResolver resolv properties.length, "readLatin1", codec.creatorInfo() == null, - source); + source, + invocations); } - private Class buildUtf16Reader(ObjectCodec codec, JsonTypeResolver resolver) { + private Class buildUtf16Reader( + TypeRef declaredType, ObjectCodec codec, JsonTypeResolver resolver) { Class type = codec.type(); String generatedPackage = CodeGenerator.getPackage(type); - String className = className(type, "Utf16Reader"); + String className = className(declaredType, "Utf16Reader", generatedPackage, codec); + DirectInvocation[] invocations = readerInvocations(codec); JsonUnwrappedInfo unwrapped = codec.unwrappedInfo(); if (unwrapped != null) { JsonGeneratedCodecBuilder builder = @@ -349,7 +469,7 @@ private Class buildUtf16Reader(ObjectCodec codec, JsonTypeResolver resolve String code = new Utf16ReaderCodegen(this, resolver) .genUnwrappedReaderCode(builder, type, codec, unwrapped); - return compileObjectCodecClass(type, generatedPackage, className, code); + return compileObjectCodecClass(type, generatedPackage, className, code, invocations); } AnyInfo any = codec.anyInfo(); JsonFieldInfo[] properties = codec.readFields(); @@ -369,14 +489,19 @@ private Class buildUtf16Reader(ObjectCodec codec, JsonTypeResolver resolve properties.length, "readUtf16", codec.creatorInfo() == null, - source); + source, + invocations); } private Class buildUtf8Reader( - ObjectCodec codec, JsonTypeResolver resolver, boolean finalDependencies) { + TypeRef declaredType, + ObjectCodec codec, + JsonTypeResolver resolver, + boolean finalDependencies) { Class type = codec.type(); String generatedPackage = CodeGenerator.getPackage(type); - String className = className(type, "Utf8Reader"); + String className = className(declaredType, "Utf8Reader", generatedPackage, codec); + DirectInvocation[] invocations = readerInvocations(codec); JsonUnwrappedInfo unwrapped = codec.unwrappedInfo(); if (unwrapped != null) { JsonGeneratedCodecBuilder builder = @@ -384,7 +509,7 @@ private Class buildUtf8Reader( String code = new Utf8ReaderCodegen(this, resolver, finalDependencies) .genUnwrappedReaderCode(builder, type, codec, unwrapped); - return compileObjectCodecClass(type, generatedPackage, className, code); + return compileObjectCodecClass(type, generatedPackage, className, code, invocations); } AnyInfo any = codec.anyInfo(); JsonFieldInfo[] properties = codec.readFields(); @@ -405,7 +530,8 @@ private Class buildUtf8Reader( properties.length, "readUtf8", codec.creatorInfo() == null, - source); + source, + invocations); } private Class compileReaderClass( @@ -415,12 +541,14 @@ private Class compileReaderClass( int propertyCount, String readMethod, boolean groupable, - Function source) { + Function source, + DirectInvocation[] invocations) { int[] groupEnds = groupable ? readerGroupEnds(generatedPackage, className, propertyCount, readMethod, source) : oneGroup(propertyCount); - return compileObjectCodecClass(ownerType, generatedPackage, className, source.apply(groupEnds)); + return compileObjectCodecClass( + ownerType, generatedPackage, className, source.apply(groupEnds), invocations); } private Class compileWriterClass( @@ -430,9 +558,11 @@ private Class compileWriterClass( JsonFieldInfo[] properties, String writeMethod, String memberMethod, - Function source) { + Function source, + DirectInvocation[] invocations) { if (properties.length < 2) { - return compileObjectCodecClass(ownerType, generatedPackage, className, source.apply(null)); + return compileObjectCodecClass( + ownerType, generatedPackage, className, source.apply(null), invocations); } // Group only the bytecode emitted in this generated class. A callee with its own stable // boundary contributes its invocation, not the body that C2 must keep in the callee. @@ -440,7 +570,8 @@ private Class compileWriterClass( JaninoUtils.CodeStats oneGroupStats = codeStats(generatedPackage, className, source.apply(oneGroup)); if (privateMethodSize(oneGroupStats, writeMethod + "Object") <= HOT_INLINE_LIMIT) { - return compileObjectCodecClass(ownerType, generatedPackage, className, source.apply(null)); + return compileObjectCodecClass( + ownerType, generatedPackage, className, source.apply(null), invocations); } int[] groupEnds = writerGroupEnds( @@ -451,7 +582,8 @@ private Class compileWriterClass( writeMethod, memberMethod, source); - return compileObjectCodecClass(ownerType, generatedPackage, className, source.apply(groupEnds)); + return compileObjectCodecClass( + ownerType, generatedPackage, className, source.apply(groupEnds), invocations); } private Class compileUtf8WriterClass( @@ -461,23 +593,28 @@ private Class compileUtf8WriterClass( JsonFieldInfo[] properties, String writeMethod, String directSource, - Function source) { + Function source, + DirectInvocation[] invocations) { if (properties.length < 2 || methodSize(codeStats(generatedPackage, className, directSource), writeMethod) <= HOT_INLINE_LIMIT) { - return compileObjectCodecClass(ownerType, generatedPackage, className, directSource); + return compileObjectCodecClass( + ownerType, generatedPackage, className, directSource, invocations); } int firstGroupMember = JsonWriterCodegen.firstGroupMember(properties); if (properties.length - firstGroupMember < 2) { - return compileObjectCodecClass(ownerType, generatedPackage, className, directSource); + return compileObjectCodecClass( + ownerType, generatedPackage, className, directSource, invocations); } int[] groupEnds = utf8WriterGroupEnds( generatedPackage, className, properties.length, firstGroupMember, writeMethod, source); if (groupEnds.length < 2) { - return compileObjectCodecClass(ownerType, generatedPackage, className, directSource); + return compileObjectCodecClass( + ownerType, generatedPackage, className, directSource, invocations); } - return compileObjectCodecClass(ownerType, generatedPackage, className, source.apply(groupEnds)); + return compileObjectCodecClass( + ownerType, generatedPackage, className, source.apply(groupEnds), invocations); } private int[] utf8WriterGroupEnds( @@ -656,32 +793,42 @@ private int[] toIntArray(List values) { } private Class compileObjectCodecClass( - Class ownerType, String generatedPackage, String className, String code) { + Class ownerType, + String generatedPackage, + String className, + String code, + DirectInvocation[] invocations) { if (!hostedCodegen || _JDKAccess.isExported(ownerType)) { - return compileCodecClass(generatedPackage, className, code); + return compileCodecClass(generatedPackage, className, code, invocations); } try { // A codec for a concealed model package must live beside the model to access its public // members without an application export or open. Exported and bootstrap models stay in the // generated loader, which also avoids changing their module graph. CompileUnit unit = new CompileUnit(generatedPackage, className, code); - return compileHostedClass(ownerType, unit); + return compileHostedClass(ownerType, unit, invocations); } catch (Throwable e) { throw new ForyJsonException("Cannot compile generated JSON codec " + className, e); } } - private Class compileCodecClass(String generatedPackage, String className, String code) { + private Class compileCodecClass( + String generatedPackage, String className, String code, DirectInvocation[] invocations) { try { CompileUnit unit = new CompileUnit(generatedPackage, className, code); - ClassLoader classLoader = codeGenerator.compile(unit); + ClassLoader classLoader = codeGenerator.compileDirect(unit, invocations); return classLoader.loadClass(qualifiedClassName(generatedPackage, className)); } catch (Throwable e) { throw new ForyJsonException("Cannot compile generated JSON codec " + className, e); } } - private Class compileHostedClass(Class ownerType, CompileUnit unit) { + private Class compileCodecClass(String generatedPackage, String className, String code) { + return compileCodecClass(generatedPackage, className, code, new DirectInvocation[0]); + } + + private Class compileHostedClass( + Class ownerType, CompileUnit unit, DirectInvocation[] invocations) { Map classes = JaninoUtils.toBytecode(jsonLoader, "", unit); String mainClassName = unit.getQualifiedClassName(); String mainClassPath = mainClassName.replace('.', '/') + ".class"; @@ -689,6 +836,7 @@ private Class compileHostedClass(Class ownerType, CompileUnit unit) { if (mainBytecode == null) { throw new ForyJsonException("Missing generated JSON codec bytecode " + mainClassName); } + mainBytecode = JaninoUtils.installDirectInvocations(mainBytecode, invocations); ClassLoader ownerLoader = ownerType.getClassLoader(); if (ownerLoader == null) { throw new ForyJsonException( @@ -715,7 +863,7 @@ private Class compileHostedClass(Class ownerType, CompileUnit unit) { @Internal public boolean canCompileWriter(ObjectCodec codec) { - if (!canCompileType(codec.type())) { + if (codec.fixedInstance() || !canCompileType(codec.type())) { return false; } JsonUnwrappedInfo unwrapped = codec.unwrappedInfo(); @@ -757,7 +905,7 @@ private boolean canCompileUnwrappedWrite( @Internal public boolean canCompileReader(ObjectCodec codec) { - if (!canCompileType(codec.type())) { + if (codec.fixedInstance() || !canCompileType(codec.type())) { return false; } JsonCreatorInfo creator = codec.creatorInfo(); @@ -941,7 +1089,8 @@ Class utf8ReaderFieldType( @Internal public static Class readNestedType(JsonFieldInfo property, JsonTypeResolver resolver) { - if (property.readKind() == JsonFieldKind.OBJECT + if (!property.readsUnboxedValue() + && property.readKind() == JsonFieldKind.OBJECT && property.readRawType() != Object.class && resolver.canonicalObjectCodec(property.readTypeInfo()) != null) { return property.readRawType(); @@ -951,6 +1100,9 @@ public static Class readNestedType(JsonFieldInfo property, JsonTypeResolver r @Internal public static boolean usesWriteCodec(JsonFieldInfo property) { + if (property.writesUnboxedValue() && property.writeKind() == JsonFieldKind.ENUM) { + return true; + } switch (property.writeKind()) { case ARRAY: case MAP: @@ -978,6 +1130,27 @@ static boolean writesStringCollectionDirectly(JsonFieldInfo property) { @Internal public static boolean usesReadCodec(JsonFieldInfo property, JsonTypeResolver resolver) { + if (property.readsUnboxedValue()) { + if (property.readDirectUnboxedValueCodec() != null) { + return false; + } + Class rawType = property.readTypeInfo().rawType(); + JsonFieldKind kind = property.readKind(); + if (rawType == String.class && kind == JsonFieldKind.STRING) { + return false; + } + if (rawType.isPrimitive()) { + return !((rawType == boolean.class && kind == JsonFieldKind.BOOLEAN) + || (rawType == byte.class && kind == JsonFieldKind.BYTE) + || (rawType == short.class && kind == JsonFieldKind.SHORT) + || (rawType == int.class && kind == JsonFieldKind.INT) + || (rawType == long.class && kind == JsonFieldKind.LONG) + || (rawType == float.class && kind == JsonFieldKind.FLOAT) + || (rawType == double.class && kind == JsonFieldKind.DOUBLE) + || (rawType == char.class && kind == JsonFieldKind.CHAR)); + } + return true; + } switch (property.readKind()) { case ENUM: case ARRAY: @@ -1133,18 +1306,407 @@ private static boolean isPublicSourceType(Class type) { return true; } - private String className(Class type, String role) { - String name = simpleClassName(type) + role + "ForyJsonCodec"; - Map subGenerator = - ID_GENERATOR.computeIfAbsent(name, key -> new ConcurrentHashMap<>()); - String key = codegenHash + "_" + CodeGenerator.getClassUniqueId(type); - Integer id = subGenerator.get(key); - if (id == null) { - synchronized (subGenerator) { - id = subGenerator.computeIfAbsent(key, ignored -> subGenerator.size()); + private String className( + TypeRef type, String role, String generatedPackage, ObjectCodec codec) { + String signature = generatedSignature(type, role, codec); + String className = + generatedNamePrefix(type.getRawType()) + role + "ForyJsonCodec_" + digest(signature); + registerGeneratedIdentity(qualifiedClassName(generatedPackage, className), signature); + return className; + } + + private String className( + TypeRef type, String role, String generatedPackage, CollectionCodec codec) { + String signature = generatedSignature(type, role); + StringBuilder builder = new StringBuilder(signature.length() + 64); + builder.append(signature); + appendIdentity(builder, codec.getClass().getName()); + appendIdentity(builder, codec.createsArrayList() ? "1" : "0"); + String completeSignature = builder.toString(); + String className = + generatedNamePrefix(type.getRawType()) + + role + + "ForyJsonCodec_" + + digest(completeSignature); + registerGeneratedIdentity(qualifiedClassName(generatedPackage, className), completeSignature); + return className; + } + + private String generatedSignature(TypeRef type, String role, ObjectCodec codec) { + String signature = generatedSignature(type, role); + StringBuilder builder = new StringBuilder(signature.length() + 512); + builder.append(signature); + appendObjectModel(builder, codec, new IdentityHashMap<>()); + return builder.toString(); + } + + private String generatedSignature(TypeRef type, String role) { + StringBuilder builder = new StringBuilder(codegenIdentity.length() + role.length() + 96); + appendIdentity(builder, codegenIdentity); + appendIdentity(builder, role); + appendIdentity(builder, type.getTypeKey()); + return builder.toString(); + } + + private static void appendObjectModel( + StringBuilder builder, + ObjectCodec codec, + IdentityHashMap, Integer> visited) { + appendIdentity(builder, codec.type().getName()); + Integer reference = visited.get(codec); + if (reference != null) { + // Unwrapped metadata can reach the same descendant through every ancestor group. Encode the + // graph reference once; expanding each repeated node makes deep signatures exponential. + appendIdentity(builder, "ref"); + appendIdentity(builder, Integer.toString(reference)); + return; + } + visited.put(codec, visited.size()); + appendIdentity(builder, codec.getClass().getName()); + appendIdentity(builder, Integer.toString(codec.graphMemoryBytes())); + appendIdentity(builder, codec.hasValidators() ? "1" : "0"); + appendFields(builder, codec.writeFields()); + appendFields(builder, codec.readFields()); + appendCreator(builder, codec.creatorInfo()); + appendAny(builder, codec.anyInfo()); + JsonUnwrappedInfo unwrapped = codec.unwrappedInfo(); + if (unwrapped == null) { + appendIdentity(builder, ""); + } else { + appendIdentity(builder, "unwrapped"); + JsonUnwrappedInfo.Declaration[] declarations = unwrapped.declarations(); + appendIdentity(builder, Integer.toString(declarations.length)); + for (JsonUnwrappedInfo.Declaration declaration : declarations) { + appendIdentity(builder, declaration.javaName()); + appendIdentity(builder, declaration.prefix()); + appendIdentity(builder, declaration.suffix()); + appendIdentity(builder, Integer.toString(declaration.constructionIndex())); + appendIdentity(builder, declaration.writeEnabled() ? "1" : "0"); + appendIdentity(builder, declaration.readEnabled() ? "1" : "0"); + appendAccessor(builder, declaration.writeAccessor()); + appendAccessor(builder, declaration.readAccessor()); + } + appendFields(builder, unwrapped.writeFields()); + int[] depths = unwrapped.writeDepths(); + int[] ends = unwrapped.writeEnds(); + appendIdentity(builder, Integer.toString(unwrapped.maxWriteDepth())); + appendIdentity(builder, Integer.toString(depths.length)); + for (int i = 0; i < depths.length; i++) { + appendIdentity(builder, Integer.toString(depths[i])); + appendIdentity(builder, Integer.toString(ends[i])); + } + JsonUnwrappedInfo.Group[] groups = unwrapped.groups(); + IdentityHashMap groupIndexes = new IdentityHashMap<>(); + for (int i = 0; i < groups.length; i++) { + groupIndexes.put(groups[i], i); + } + appendIdentity(builder, Integer.toString(groups.length)); + for (JsonUnwrappedInfo.Group group : groups) { + appendIdentity(builder, group.declaration().javaName()); + appendIdentity(builder, group.declaration().prefix()); + appendIdentity(builder, group.declaration().suffix()); + appendIdentity(builder, Integer.toString(group.readIndex())); + appendIdentity(builder, groupIndex(groupIndexes, group.parent())); + appendIdentity(builder, group.parentCodec().type().getName()); + appendIdentity(builder, group.writeEnabled() ? "1" : "0"); + appendIdentity(builder, group.readEnabled() ? "1" : "0"); + appendObjectModel(builder, group.childCodec(), visited); + } + appendInts(builder, unwrapped.groupParents()); + appendInts(builder, unwrapped.groupEnds()); + JsonUnwrappedInfo.WriteEntry[] steps = unwrapped.writeSteps(); + appendIdentity(builder, Integer.toString(steps.length)); + for (JsonUnwrappedInfo.WriteEntry step : steps) { + appendIdentity(builder, Integer.toString(step.kind())); + if (step.kind() == JsonUnwrappedInfo.DIRECT) { + appendField(builder, step.field()); + } else if (step.kind() == JsonUnwrappedInfo.GROUP) { + appendIdentity(builder, groupIndex(groupIndexes, step.group())); + } + } + JsonUnwrappedInfo.ReadRoute[] routes = unwrapped.readRoutes(); + appendIdentity(builder, Integer.toString(routes.length)); + for (JsonUnwrappedInfo.ReadRoute route : routes) { + appendIdentity(builder, groupIndex(groupIndexes, route.group())); + appendField(builder, route.field()); + appendCreatorField(builder, route.creatorField()); } } - return id == 0 ? name : name + id; + } + + private static void appendFields(StringBuilder builder, JsonFieldInfo[] fields) { + appendIdentity(builder, Integer.toString(fields.length)); + for (JsonFieldInfo field : fields) { + appendField(builder, field); + } + } + + private static void appendField(StringBuilder builder, JsonFieldInfo field) { + if (field == null) { + appendIdentity(builder, ""); + return; + } + appendIdentity(builder, field.name()); + appendIdentity(builder, field.writeNull() ? "1" : "0"); + appendIdentity(builder, field.requiresNonNullWrite() ? "1" : "0"); + appendIdentity(builder, field.writesRawString() ? "1" : "0"); + appendIdentity(builder, field.writeKind() == null ? "" : field.writeKind().name()); + appendIdentity(builder, field.readKind() == null ? "" : field.readKind().name()); + appendIdentity(builder, typeName(field.writeType())); + appendIdentity(builder, typeName(field.readType())); + appendIdentity(builder, typeName(field.writeMapValueType())); + appendIdentity(builder, nullableClassName(field.writeRawType())); + appendIdentity(builder, nullableClassName(field.readRawType())); + appendIdentity(builder, nullableClassName(field.writeArrayComponentType())); + appendIdentity(builder, nullableClassName(field.writeElementRawType())); + appendIdentity(builder, Integer.toString(field.readIndex())); + appendIdentity(builder, field.hasOccurrenceNullability() ? "1" : "0"); + appendIdentity(builder, field.occurrenceNullable() ? "1" : "0"); + appendIdentity(builder, field.occurrenceWrapsNull() ? "1" : "0"); + appendIdentity(builder, field.writesUnboxedValue() ? "1" : "0"); + appendIdentity(builder, field.readsUnboxedValue() ? "1" : "0"); + appendMember(builder, field.writeField()); + appendMember(builder, field.writeGetter()); + appendMember(builder, field.readField()); + appendMember(builder, field.readSetter()); + appendTypeInfo(builder, field.writeTypeInfo()); + appendTypeInfo(builder, field.readTypeInfo()); + appendUnboxed(builder, field.writeUnboxedValueCodec()); + appendUnboxed(builder, field.readUnboxedValueCodec()); + appendEnum(builder, field.writeRawType()); + appendEnum(builder, field.readRawType()); + appendEnum(builder, field.writeElementRawType()); + } + + private static void appendCreator(StringBuilder builder, JsonCreatorInfo creator) { + if (creator == null) { + appendIdentity(builder, ""); + return; + } + appendIdentity(builder, creator.fixedInstance() ? "fixed" : "creator"); + if (creator.fixedInstance()) { + return; + } + appendExecutable(builder, creator.executable()); + appendExecutable(builder, creator.invocationExecutable()); + appendExecutable(builder, creator.defaultConstructor()); + appendIdentity(builder, creator.tracksArgumentPresence() ? "1" : "0"); + appendIdentity(builder, Integer.toString(creator.argumentCount())); + for (int i = 0; i < creator.argumentCount(); i++) { + appendIdentity(builder, creator.hasDefault(i) ? "1" : "0"); + appendIdentity(builder, Integer.toString(creator.defaultMaskBit(i))); + appendExecutable(builder, creator.defaultMethod(i)); + } + JsonCreatorFieldInfo[] fields = creator.fields(); + appendIdentity(builder, Integer.toString(fields.length)); + for (JsonCreatorFieldInfo field : fields) { + appendCreatorField(builder, field); + } + JsonFieldInfo[] deferredFields = creator.deferredFields(); + appendFields(builder, deferredFields); + for (int i = 0; i < deferredFields.length; i++) { + appendIdentity(builder, creator.deferredRequired(i) ? "1" : "0"); + } + } + + private static void appendCreatorField(StringBuilder builder, JsonCreatorFieldInfo field) { + if (field == null) { + appendIdentity(builder, ""); + return; + } + appendIdentity(builder, field.name()); + appendIdentity(builder, Integer.toString(field.argumentIndex())); + appendIdentity(builder, field.typeRef().getTypeKey()); + appendIdentity(builder, field.rawType().getName()); + appendIdentity(builder, field.rejectsNullRead() ? "1" : "0"); + appendIdentity(builder, field.nullableRead() ? "1" : "0"); + appendIdentity(builder, field.unboxedValue() ? "1" : "0"); + appendTypeInfo(builder, field.typeInfo()); + appendUnboxed(builder, field.unboxedValueCodec()); + appendEnum(builder, field.rawType()); + } + + private static void appendAny(StringBuilder builder, AnyInfo any) { + if (any == null) { + appendIdentity(builder, ""); + return; + } + appendIdentity(builder, "any"); + appendMember(builder, any.writeField()); + appendMember(builder, any.writeGetter()); + appendMember(builder, any.readField()); + appendMember(builder, any.readSetter()); + appendIdentity(builder, Integer.toString(any.writeIndex())); + appendIdentity(builder, Integer.toString(any.constructionIndex())); + appendIdentity(builder, any.valueRawType().getName()); + appendTypeInfo(builder, any.valueTypeInfo()); + appendUnboxed(builder, any.valueTypeInfo().unboxedValueCodec()); + } + + private static void appendAccessor(StringBuilder builder, JsonFieldAccessor accessor) { + if (accessor == null) { + appendIdentity(builder, ""); + return; + } + appendMember(builder, accessor.field()); + appendMember(builder, accessor.getter()); + appendMember(builder, accessor.setter()); + } + + private static void appendMember(StringBuilder builder, java.lang.reflect.Member member) { + if (member == null) { + appendIdentity(builder, ""); + return; + } + if (member instanceof Field) { + appendIdentity(builder, member.getDeclaringClass().getName()); + appendIdentity(builder, member.getName()); + appendIdentity(builder, ((Field) member).getType().getName()); + appendIdentity(builder, Integer.toString(member.getModifiers())); + } else { + appendExecutable(builder, (Executable) member); + } + } + + private static void appendExecutable(StringBuilder builder, Executable executable) { + if (executable == null) { + appendIdentity(builder, ""); + return; + } + appendIdentity(builder, executable.getDeclaringClass().getName()); + appendIdentity(builder, executable instanceof Method ? executable.getName() : ""); + appendIdentity(builder, Integer.toString(executable.getModifiers())); + appendIdentity(builder, executable.isVarArgs() ? "1" : "0"); + for (Class parameterType : executable.getParameterTypes()) { + appendIdentity(builder, parameterType.getName()); + } + appendIdentity( + builder, + executable instanceof Method ? ((Method) executable).getReturnType().getName() : "V"); + } + + private static void appendTypeInfo(StringBuilder builder, JsonTypeInfo typeInfo) { + if (typeInfo == null) { + appendIdentity(builder, ""); + return; + } + appendIdentity(builder, typeInfo.typeRef().getTypeKey()); + appendIdentity(builder, typeInfo.kind().name()); + appendIdentity(builder, typeInfo.nullable() ? "1" : "0"); + appendIdentity(builder, typeInfo.rejectsNull() ? "1" : "0"); + appendIdentity(builder, typeInfo.usesAnnotationCodec() ? "1" : "0"); + appendIdentity( + builder, + typeInfo.unboxedValueCodec() == null + ? "" + : typeInfo.unboxedValueCodec().getClass().getName()); + } + + private static void appendUnboxed(StringBuilder builder, UnboxedValueCodec operation) { + if (operation == null) { + appendIdentity(builder, ""); + return; + } + appendIdentity(builder, operation.getClass().getName()); + appendIdentity(builder, operation.carrierType().getName()); + if (operation instanceof DirectUnboxedValueCodec) { + DirectUnboxedValueCodec direct = (DirectUnboxedValueCodec) operation; + appendIdentity(builder, "direct"); + appendExecutable(builder, direct.readCarrierMethod()); + appendExecutable(builder, direct.writeCarrierMethod()); + return; + } + if (!(operation instanceof TransparentUnboxedValueCodec)) { + throw new ForyJsonException( + "Unsupported unboxed value capability " + operation.getClass().getName()); + } + TransparentUnboxedValueCodec transparent = (TransparentUnboxedValueCodec) operation; + appendIdentity(builder, "transparent"); + appendTypeInfo(builder, transparent.valueTypeInfo()); + appendExecutables(builder, transparent.constructMethods()); + appendInts(builder, transparent.constructBoxBytes()); + appendExecutables(builder, transparent.extractMethods()); + } + + private static void appendExecutables(StringBuilder builder, Executable[] executables) { + appendIdentity(builder, Integer.toString(executables.length)); + for (Executable executable : executables) { + appendExecutable(builder, executable); + } + } + + private static void appendInts(StringBuilder builder, int[] values) { + appendIdentity(builder, Integer.toString(values.length)); + for (int value : values) { + appendIdentity(builder, Integer.toString(value)); + } + } + + private static void appendEnum(StringBuilder builder, Class type) { + if (type == null || !type.isEnum()) { + appendIdentity(builder, ""); + return; + } + Object[] constants = type.getEnumConstants(); + appendIdentity(builder, Integer.toString(constants.length)); + for (Object constant : constants) { + appendIdentity(builder, ((Enum) constant).name()); + } + } + + private static String groupIndex( + IdentityHashMap indexes, JsonUnwrappedInfo.Group group) { + if (group == null) { + return ""; + } + Integer index = indexes.get(group); + if (index == null) { + throw new IllegalStateException("Unwrapped group is outside its metadata owner"); + } + return Integer.toString(index); + } + + private static String typeName(Type type) { + return type == null ? "" : type.getTypeName(); + } + + private static String nullableClassName(Class type) { + return type == null ? "" : type.getName(); + } + + private static void appendIdentity(StringBuilder builder, String value) { + builder.append(value.length()).append(':').append(value); + } + + void registerGeneratedIdentity(String generatedClassName, String signature) { + String previous = generatedClassSignatures.putIfAbsent(generatedClassName, signature); + if (previous != null && !previous.equals(signature)) { + throw new ForyJsonException( + "Generated Fory JSON class-name collision for " + generatedClassName); + } + } + + /** Returns complete structural signatures for the generated classes in this registry. */ + @Internal + public Map generatedClassSignatures() { + return Collections.unmodifiableMap(new HashMap<>(generatedClassSignatures)); + } + + private static String digest(String value) { + try { + byte[] bytes = + MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + char[] hex = new char[bytes.length * 2]; + char[] digits = "0123456789abcdef".toCharArray(); + for (int i = 0; i < bytes.length; i++) { + int current = bytes[i] & 0xff; + hex[i * 2] = digits[current >>> 4]; + hex[i * 2 + 1] = digits[current & 0x0f]; + } + return new String(hex); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError("SHA-256 is unavailable", e); + } } private static String simpleClassName(Class type) { @@ -1164,6 +1726,15 @@ private static String simpleClassName(Class type) { return name.replace('.', '_').replace('$', '_'); } + private static String generatedNamePrefix(Class type) { + String name = simpleClassName(type); + int codePoints = name.codePointCount(0, name.length()); + if (codePoints <= GENERATED_NAME_PREFIX_CODE_POINTS) { + return name; + } + return name.substring(0, name.offsetByCodePoints(0, GENERATED_NAME_PREFIX_CODE_POINTS)); + } + private static String qualifiedClassName(String generatedPackage, String className) { return generatedPackage.isEmpty() ? className : generatedPackage + "." + className; } diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonCodegenKey.java b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonCodegenKey.java index a944949445..c5971c044e 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonCodegenKey.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonCodegenKey.java @@ -68,4 +68,19 @@ public int hashCode() { writeNullFields, propertyDiscoveryEnabled, propertyNamingStrategy, codecRegistryKey); return 31 * result + mixinKey.hashCode(); } + + /** Returns the deterministic generated-source identity used in generated class names. */ + public String identity() { + StringBuilder builder = new StringBuilder(); + builder.append(writeNullFields ? '1' : '0'); + builder.append(propertyDiscoveryEnabled ? '1' : '0'); + append(builder, propertyNamingStrategy); + append(builder, codecRegistryKey); + append(builder, mixinKey); + return builder.toString(); + } + + private static void append(StringBuilder builder, String value) { + builder.append(value.length()).append(':').append(value); + } } diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonGeneratedCodecBuilder.java b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonGeneratedCodecBuilder.java index cf6a505a96..8020c5cdfb 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonGeneratedCodecBuilder.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonGeneratedCodecBuilder.java @@ -23,6 +23,9 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.HashSet; +import java.util.Set; import org.apache.fory.builder.CodecBuilder; import org.apache.fory.codegen.CodegenContext; import org.apache.fory.codegen.Expression; @@ -42,6 +45,7 @@ */ final class JsonGeneratedCodecBuilder extends CodecBuilder { private final String generatedClassName; + private final Set directMethods = new HashSet<>(); JsonGeneratedCodecBuilder(String generatedPackage, String generatedClassName, Class type) { super(new CodegenContext(), TypeRef.of(type)); @@ -104,12 +108,17 @@ Expression fieldValue(JsonFieldInfo property, Expression object) { // JSON writers check the returned member value directly. Requesting expression-level null // state here only emits an unused boolean for each nullable getter and bloats generated // object writers enough to hurt C2 inlining. - return new Expression.Invoke( - object, - getter.getName(), - property.name(), - TypeRef.of(getter.getGenericReturnType()), - false); + if (DirectMethodCodegen.sourceNameable(getter)) { + return new Expression.Invoke( + object, + getter.getName(), + property.name(), + TypeRef.of(getter.getGenericReturnType()), + false); + } + String name = DirectMethodCodegen.getterName(getter); + addDirectMethod(name, getter.getReturnType(), getter.getDeclaringClass(), "target"); + return directInvoke(name, property.name(), TypeRef.of(getter.getGenericReturnType()), object); } return getFieldValue(object, writeDescriptor(property)); } @@ -121,6 +130,12 @@ Expression anyValue(Field field, Expression object) { Expression unwrappedValue(Declaration declaration, Expression object) { Method getter = declaration.writeAccessor().getter(); if (getter != null) { + if (!DirectMethodCodegen.sourceNameable(getter)) { + String name = DirectMethodCodegen.getterName(getter); + addDirectMethod(name, getter.getReturnType(), getter.getDeclaringClass(), "target"); + return directInvoke( + name, declaration.javaName(), TypeRef.of(getter.getGenericReturnType()), object); + } return new Expression.Invoke( object, getter.getName(), @@ -146,7 +161,18 @@ Expression setField(JsonFieldInfo property, Expression object, Expression value) if (!rawType.isAssignableFrom(value.type().getRawType())) { value = tryInlineCast(value, typeRef); } - return new Expression.Invoke(object, setter.getName(), value); + if (DirectMethodCodegen.sourceNameable(setter)) { + return new Expression.Invoke(object, setter.getName(), value); + } + String name = DirectMethodCodegen.setterName(setter); + addDirectMethod( + name, + void.class, + setter.getDeclaringClass(), + "target", + setter.getParameterTypes()[0], + "value"); + return directInvoke(name, "", TypeRef.of(void.class), object, value); } return setFieldValue( object, @@ -175,11 +201,62 @@ Expression setUnwrapped(Declaration declaration, Expression object, Expression v if (!rawType.isAssignableFrom(value.type().getRawType())) { value = tryInlineCast(value, typeRef); } - return new Expression.Invoke(object, setter.getName(), value); + if (DirectMethodCodegen.sourceNameable(setter)) { + return new Expression.Invoke(object, setter.getName(), value); + } + String name = DirectMethodCodegen.setterName(setter); + addDirectMethod( + name, + void.class, + setter.getDeclaringClass(), + "target", + setter.getParameterTypes()[0], + "value"); + return directInvoke(name, "", TypeRef.of(void.class), object, value); } return setFieldValue( object, readDescriptor(declaration.readAccessor().field()), tryInlineCast(value, TypeRef.of(declaration.readAccessor().field().getGenericType()))); } + + void addDirectMethod(String name, Class returnType, Object... parameters) { + if (directMethods.add(name)) { + ctx.addMethod("final", name, "throw new AssertionError();", returnType, parameters); + } + } + + Expression directInvoke(String name, String valueName, TypeRef type, Expression... arguments) { + return new Expression.Invoke( + new Expression.Reference("this", TypeRef.of(Object.class)), + name, + valueName, + type, + false, + false, + arguments); + } + + Expression valueOperation(Method method, Expression... values) { + Class[] targetParameters = method.getParameterTypes(); + boolean isStatic = Modifier.isStatic(method.getModifiers()); + int expected = targetParameters.length + (isStatic ? 0 : 1); + if (values.length != expected) { + throw new IllegalArgumentException("Invalid generated value operation " + method); + } + Object[] parameters = new Object[expected << 1]; + Expression[] arguments = new Expression[expected]; + for (int i = 0; i < expected; i++) { + Class type = + isStatic + ? targetParameters[i] + : i == 0 ? method.getDeclaringClass() : targetParameters[i - 1]; + parameters[i << 1] = type; + parameters[(i << 1) + 1] = "value" + i; + arguments[i] = tryInlineCast(inline(values[i]), TypeRef.of(type)); + } + String name = DirectMethodCodegen.valueOperationName(method); + addDirectMethod(name, method.getReturnType(), parameters); + return directInvoke(name, "value", TypeRef.of(method.getReturnType()), arguments); + } } diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonReaderCodegen.java b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonReaderCodegen.java index 8bc29437cc..bb13130201 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonReaderCodegen.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonReaderCodegen.java @@ -32,13 +32,17 @@ import org.apache.fory.codegen.CodegenContext; import org.apache.fory.codegen.Expression; import org.apache.fory.codegen.Expression.Reference; +import org.apache.fory.json.ForyJsonException; import org.apache.fory.json.codec.CollectionCodec; +import org.apache.fory.json.codec.DirectUnboxedValueCodec; import org.apache.fory.json.codec.JsonUnwrappedInfo; import org.apache.fory.json.codec.JsonUnwrappedInfo.Group; import org.apache.fory.json.codec.JsonUnwrappedInfo.ReadRoute; import org.apache.fory.json.codec.ObjectCodec; import org.apache.fory.json.codec.ObjectCodec.AnyInfo; import org.apache.fory.json.codec.ScalarCodecs; +import org.apache.fory.json.codec.TransparentUnboxedValueCodec; +import org.apache.fory.json.codec.UnboxedValueCodec; import org.apache.fory.json.meta.JsonAsciiToken; import org.apache.fory.json.meta.JsonCreatorFieldInfo; import org.apache.fory.json.meta.JsonCreatorInfo; @@ -64,6 +68,9 @@ */ abstract class JsonReaderCodegen { private static final int READ_FIELD_SWITCH_SIZE = 8; + // Bound both the inlined prefix chain and the typed fallback signature. Wider creators retain + // the existing compact loop instead of inflating every generated reader representation. + private static final int MAX_ORDERED_CREATOR_FIELDS = 16; private static final boolean LITTLE_ENDIAN = NativeByteOrder.IS_LITTLE_ENDIAN; private static final long UTF16_PAIR_MASK = 0x0000FFFF0000FFFFL; private static final long UTF16_BYTE_MASK = 0x00FF00FF00FF00FFL; @@ -181,7 +188,7 @@ String genReaderCode( ctx.addField(JsonFieldInfo.class, "rp" + i); } if (JsonCodegen.usesReadCodec(properties[i], resolver)) { - addCapabilityField(ctx, codecFieldType(properties[i]), "r" + i); + addValueReaderField(ctx, properties[i], "r" + i); } if (storesReadObjectCodec(type, properties[i])) { addObjectReaderField(ctx, properties[i], "o" + i); @@ -236,6 +243,14 @@ private void addCreatorReaderField(CodegenContext ctx, JsonCreatorFieldInfo fiel } } + private void addValueReaderField(CodegenContext ctx, JsonFieldInfo field, String name) { + if (usesReaderSlot(field.readTypeInfo())) { + ctx.addField(true, ctx.type(JsonTypeInfo.class), name, null); + } else { + addCapabilityField(ctx, codecFieldType(field), name); + } + } + private void addAnyReaderField(CodegenContext ctx, AnyInfo any) { if (usesReaderSlot(any.valueTypeInfo())) { ctx.addField(true, ctx.type(JsonTypeInfo.class), "anyReader", null); @@ -360,7 +375,7 @@ String genUnwrappedReaderCode( addReaderFields(ctx, type, directFields); } else { for (int i = 0; i < directCreatorFields.length; i++) { - if (!isDirectCreatorPrimitive(directCreatorFields[i])) { + if (usesCreatorReader(directCreatorFields[i])) { addCreatorReaderField(ctx, directCreatorFields[i], "r" + i); } } @@ -373,12 +388,12 @@ String genUnwrappedReaderCode( ctx.addField(JsonFieldInfo.class, "rp" + id); } if (JsonCodegen.usesReadCodec(field, resolver)) { - addCapabilityField(ctx, codecFieldType(field), "r" + id); + addValueReaderField(ctx, field, "r" + id); } if (storesReadObjectCodec(type, field)) { addObjectReaderField(ctx, field, "o" + id); } - } else if (!isDirectCreatorPrimitive(routes[i].creatorField())) { + } else if (usesCreatorReader(routes[i].creatorField())) { addCreatorReaderField(ctx, routes[i].creatorField(), "r" + id); } } @@ -525,7 +540,7 @@ private void addReaderFields(CodegenContext ctx, Class type, JsonFieldInfo[] ctx.addField(JsonFieldInfo.class, "rp" + i); } if (JsonCodegen.usesReadCodec(properties[i], resolver)) { - addCapabilityField(ctx, codecFieldType(properties[i]), "r" + i); + addValueReaderField(ctx, properties[i], "r" + i); } if (storesReadObjectCodec(type, properties[i])) { addObjectReaderField(ctx, properties[i], "o" + i); @@ -595,7 +610,7 @@ private String genCreatorReaderCode( ctx.addField(ObjectCodec.class, "owner"); ctx.addField(JsonCreatorInfo.class, "creator"); for (int i = 0; i < fields.length; i++) { - if (!isDirectCreatorPrimitive(fields[i])) { + if (usesCreatorReader(fields[i])) { addCreatorReaderField(ctx, fields[i], "r" + i); } } @@ -608,7 +623,10 @@ private String genCreatorReaderCode( "properties", JsonCodegen.generatedCodecArrayType(ctx, readerArrayType()), "codecs"); - addCreatorMethod(ctx, type, creatorInfo); + addCreatorMethod(builder, type, creatorInfo); + if (usesOrderedCreator(fields, creatorInfo)) { + addCreatorSlowMethod(ctx, builder, type, creatorInfo); + } ctx.clearExprState(); Code.ExprCode body = creatorReadExpression(builder, type, creatorInfo).genCode(ctx); String bodyCode = body.code(); @@ -646,12 +664,12 @@ private String genAnyCreatorReaderCode( addAnyReaderField(ctx, any); } for (int i = 0; i < fields.length; i++) { - if (!isDirectCreatorPrimitive(fields[i])) { + if (usesCreatorReader(fields[i])) { addCreatorReaderField(ctx, fields[i], "r" + i); } } addAnyReaderConstructor(ctx, creatorConstructorExpression(fields), storesAnyReader); - addCreatorMethod(ctx, type, creatorInfo); + addCreatorMethod(builder, type, creatorInfo); addGeneratedMethod( ctx, "private final", @@ -719,7 +737,9 @@ private Expression anyCreatorReadExpression( i, new Expression.ListExpression( assignCreatorArgument( - arguments, fields[i].argumentIndex(), readCreatorValue(fields[i], i)), + arguments, + fields[i].argumentIndex(), + readCreatorValue(builder, fields[i], i)), new Expression.Break())); } loop.add( @@ -793,10 +813,13 @@ private Expression readUnknownCreator(Expression fieldStart, Expression hash, Ex eq(match, Expression.Literal.ofInt(JsonFieldTable.UNKNOWN)), read, skip))); } - private void addCreatorMethod(CodegenContext ctx, Class type, JsonCreatorInfo creatorInfo) { + private void addCreatorMethod( + JsonGeneratedCodecBuilder builder, Class type, JsonCreatorInfo creatorInfo) { + CodegenContext ctx = builder.context(); Executable executable = creatorInfo.executable(); Class[] parameterTypes = executable.getParameterTypes(); - Object[] parameters = new Object[parameterTypes.length << 1]; + int maskCount = creatorInfo.defaultMaskCount(); + Object[] parameters = new Object[(parameterTypes.length + maskCount) << 1]; StringBuilder invocation = new StringBuilder(); for (int i = 0; i < parameterTypes.length; i++) { parameters[i << 1] = parameterTypes[i]; @@ -806,17 +829,53 @@ private void addCreatorMethod(CodegenContext ctx, Class type, JsonCreatorInfo } invocation.append('a').append(i); } + StringBuilder maskedInvocation = new StringBuilder(invocation); + for (int i = 0; i < maskCount; i++) { + int parameter = parameterTypes.length + i; + parameters[parameter << 1] = int.class; + parameters[(parameter << 1) + 1] = "m" + i; + maskedInvocation.append(", m").append(i); + } String typeName = ctx.type(type); - String expression = - executable instanceof Constructor - ? "new " + typeName + "(" + invocation + ")" - : typeName + "." + ((Method) executable).getName() + "(" + invocation + ")"; + String expression; + boolean fullBridge = DirectMethodCodegen.requiresFullCreatorBridge(creatorInfo); + if (fullBridge) { + String fullName = DirectMethodCodegen.fullCreatorName(creatorInfo.invocationExecutable()); + builder.addDirectMethod(fullName, type, creatorParameters(parameterTypes)); + expression = "this." + fullName + "(" + invocation + ")"; + } else { + expression = + executable instanceof Constructor + ? "new " + typeName + "(" + invocation + ")" + : typeName + "." + ((Method) executable).getName() + "(" + invocation + ")"; + } StringBuilder body = new StringBuilder(); - body.append(typeName) - .append(" value;\ntry {\n value = ") - .append(expression) - .append(";\n") - .append("} catch (Throwable e) {\n throw owner.creatorFailure(e);\n}\n"); + body.append(typeName).append(" value;\ntry {\n"); + if (maskCount == 0) { + body.append(" value = ").append(expression).append(";\n"); + } else { + String defaultName = DirectMethodCodegen.defaultCreatorName(creatorInfo.defaultConstructor()); + Class[] bridgeParameters = + Arrays.copyOf(parameterTypes, parameterTypes.length + maskCount); + Arrays.fill(bridgeParameters, parameterTypes.length, bridgeParameters.length, int.class); + builder.addDirectMethod(defaultName, type, creatorParameters(bridgeParameters)); + body.append(" if (("); + for (int i = 0; i < maskCount; i++) { + if (i != 0) { + body.append(" | "); + } + body.append('m').append(i); + } + body.append(") == 0) {\n") + .append(" value = ") + .append(expression) + .append(";\n } else {\n value = this.") + .append(defaultName) + .append('(') + .append(maskedInvocation) + .append(");\n }\n"); + } + body.append("} catch (Throwable e) {\n throw owner.creatorFailure(e);\n}\n"); if (executable instanceof Method) { body.append("value = (").append(typeName).append(") owner.requireCreatorResult(value);\n"); } @@ -827,6 +886,15 @@ private void addCreatorMethod(CodegenContext ctx, Class type, JsonCreatorInfo ctx.addMethod("private final", "createValue", body.toString(), type, parameters); } + private static Object[] creatorParameters(Class[] parameterTypes) { + Object[] parameters = new Object[parameterTypes.length << 1]; + for (int i = 0; i < parameterTypes.length; i++) { + parameters[i << 1] = parameterTypes[i]; + parameters[(i << 1) + 1] = "v" + i; + } + return parameters; + } + private Expression creatorConstructorExpression(JsonCreatorFieldInfo[] fields) { Expression.ListExpression expressions = new Expression.ListExpression(); Reference owner = new Reference("owner", TypeRef.of(ObjectCodec.class)); @@ -849,7 +917,7 @@ private Expression creatorConstructorExpression(JsonCreatorFieldInfo[] fields) { addSelfReaderAssignment(expressions); Reference codecs = new Reference("codecs", TypeRef.of(readerArrayType())); for (int i = 0; i < fields.length; i++) { - if (!isDirectCreatorPrimitive(fields[i])) { + if (usesCreatorReader(fields[i])) { if (usesReaderSlot(fields[i].typeInfo())) { Expression creator = new Expression.Invoke(owner, "creatorInfo", TypeRef.of(JsonCreatorInfo.class)) @@ -926,6 +994,25 @@ private Expression creatorReadExpression( new Expression.Invoke(readerRef(), "exitDepth"), finishCreator(builder, type, creatorInfo, arguments)))); + if (usesOrderedCreator(fields, creatorInfo)) { + expressions.add(orderedCreatorRead(builder, type, creatorInfo, arguments)); + expressions.add(reserveObject(objectOwner)); + expressions.add(new Expression.Invoke(readerRef(), "exitDepth")); + expressions.add(finishCreator(builder, type, creatorInfo, arguments)); + return expressions; + } + + expressions.add(creatorReadLoop(builder, fields, arguments)); + expressions.add(reserveObject(objectOwner)); + expressions.add(new Expression.Invoke(readerRef(), "exitDepth")); + expressions.add(finishCreator(builder, type, creatorInfo, arguments)); + return expressions; + } + + private Expression creatorReadLoop( + JsonGeneratedCodecBuilder builder, + JsonCreatorFieldInfo[] fields, + CreatorArguments arguments) { Expression.ListExpression loop = new Expression.ListExpression(); Reference fieldIndex = new Reference("creatorFieldIndex", TypeRef.of(int.class)); loop.add( @@ -939,7 +1026,9 @@ private Expression creatorReadExpression( i, new Expression.ListExpression( assignCreatorArgument( - arguments, fields[i].argumentIndex(), readCreatorValue(fields[i], i)), + arguments, + fields[i].argumentIndex(), + readCreatorValue(builder, fields[i], i)), new Expression.Break())); } loop.add( @@ -948,11 +1037,115 @@ private Expression creatorReadExpression( new Expression.If( not(consumeExpr(',')), new Expression.ListExpression(expectExpr('}'), new Expression.Break()))); - expressions.add(new Expression.While(Expression.Literal.True, loop)); + return new Expression.While(Expression.Literal.True, loop); + } + + private boolean usesOrderedCreator(JsonCreatorFieldInfo[] fields, JsonCreatorInfo creatorInfo) { + if (fields.length == 0 + || fields.length > MAX_ORDERED_CREATOR_FIELDS + || creatorInfo.argumentCount() + creatorInfo.deferredFields().length + > MAX_ORDERED_CREATOR_FIELDS) { + return false; + } + for (JsonCreatorFieldInfo field : fields) { + if (!isDirectName(field.name(), true)) { + return false; + } + } + return true; + } + + private Expression orderedCreatorRead( + JsonGeneratedCodecBuilder builder, + Class type, + JsonCreatorInfo creatorInfo, + CreatorArguments arguments) { + // Each direct-name miss leaves the opening quote unread. Pass already decoded typed arguments + // to the ordinary loop so escaped, unknown, duplicate, and arbitrary-order members resume at + // the exact cursor without reparsing successful prefix fields. + JsonCreatorFieldInfo[] fields = creatorInfo.fields(); + Expression next = creatorSlowReturn(type, creatorInfo, arguments); + for (int i = fields.length - 1; i >= 0; i--) { + JsonCreatorFieldInfo field = fields[i]; + Expression read = + assignCreatorArgument( + arguments, field.argumentIndex(), readCreatorValue(builder, field, i)); + next = + new Expression.If( + tryReadNextFieldNameColon(field.name()), + new Expression.ListExpression( + read, new Expression.If(consumeOrderedCommaOrEndObjectExpr(), next)), + creatorSlowReturn(type, creatorInfo, arguments)); + } + return next; + } + + private Expression creatorSlowReturn( + Class type, JsonCreatorInfo creatorInfo, CreatorArguments arguments) { + return new Expression.Return(creatorSlowCall(type, creatorInfo, arguments)); + } + + private void addCreatorSlowMethod( + CodegenContext ctx, + JsonGeneratedCodecBuilder builder, + Class type, + JsonCreatorInfo creatorInfo) { + Expression.ListExpression expressions = new Expression.ListExpression(); + CreatorArguments arguments = creatorParameterArguments(creatorInfo, expressions); + expressions.add(creatorReadLoop(builder, creatorInfo.fields(), arguments)); expressions.add(reserveObject(objectOwner)); expressions.add(new Expression.Invoke(readerRef(), "exitDepth")); expressions.add(finishCreator(builder, type, creatorInfo, arguments)); - return expressions; + addGeneratedMethod( + ctx, + "private final", + creatorSlowMethod(), + expressions, + type, + creatorSlowParameters(creatorInfo)); + } + + private Expression creatorSlowCall( + Class type, JsonCreatorInfo creatorInfo, CreatorArguments arguments) { + int inputCount = + 1 + arguments.values.length + (arguments.present == null ? 0 : arguments.present.length); + Expression[] inputs = new Expression[inputCount]; + inputs[0] = readerRef(); + System.arraycopy(arguments.values, 0, inputs, 1, arguments.values.length); + if (arguments.present != null) { + System.arraycopy( + arguments.present, 0, inputs, 1 + arguments.values.length, arguments.present.length); + } + return new Expression.Invoke( + new Reference("this", TypeRef.of(Object.class)), + creatorSlowMethod(), + "", + TypeRef.of(type), + false, + false, + inputs); + } + + private Object[] creatorSlowParameters(JsonCreatorInfo creatorInfo) { + Class[] valueTypes = creatorValueTypes(creatorInfo); + int presentCount = creatorInfo.tracksArgumentPresence() ? valueTypes.length : 0; + Object[] parameters = new Object[(1 + valueTypes.length + presentCount) * 2]; + int index = 0; + parameters[index++] = readerType(); + parameters[index++] = "reader"; + for (int i = 0; i < valueTypes.length; i++) { + parameters[index++] = valueTypes[i]; + parameters[index++] = "a" + i; + } + for (int i = 0; i < presentCount; i++) { + parameters[index++] = boolean.class; + parameters[index++] = "p" + i; + } + return parameters; + } + + private String creatorSlowMethod() { + return readMethod() + "Slow"; } private Expression readCreatorFieldIndex(Expression fieldIndex, JsonCreatorFieldInfo[] fields) { @@ -989,24 +1182,55 @@ private CreatorArguments creatorArguments( JsonCreatorInfo creatorInfo, Expression.ListExpression expressions) { // Creator fields cover only read-enabled JSON members. The executable owns the full argument // shape, including ignored or getter-only parameters which must still receive typed defaults. - Executable executable = creatorInfo.executable(); - Class[] parameterTypes = executable.getParameterTypes(); - JsonFieldInfo[] deferredFields = creatorInfo.deferredFields(); - int argumentCount = parameterTypes.length; - Expression[] values = new Expression[argumentCount + deferredFields.length]; + Class[] valueTypes = creatorValueTypes(creatorInfo); + Expression[] values = new Expression[valueTypes.length]; Expression[] present = creatorInfo.tracksArgumentPresence() ? new Expression[values.length] : null; for (int i = 0; i < values.length; i++) { - Class valueType = - i < argumentCount ? parameterTypes[i] : deferredFields[i - argumentCount].readRawType(); - values[i] = new Expression.Variable("a" + i, creatorDefault(valueType)); + values[i] = new Expression.Variable("a" + i, creatorDefault(valueTypes[i])); expressions.add(values[i]); if (present != null) { present[i] = new Expression.Variable("p" + i, Expression.Literal.False); expressions.add(present[i]); } } - return new CreatorArguments(values, present); + Expression[] masks = new Expression[creatorInfo.defaultMaskCount()]; + for (int i = 0; i < masks.length; i++) { + masks[i] = new Expression.Variable("m" + i, Expression.Literal.ofInt(0)); + expressions.add(masks[i]); + } + return new CreatorArguments(values, present, masks); + } + + private CreatorArguments creatorParameterArguments( + JsonCreatorInfo creatorInfo, Expression.ListExpression expressions) { + Class[] valueTypes = creatorValueTypes(creatorInfo); + Expression[] values = new Expression[valueTypes.length]; + Expression[] present = + creatorInfo.tracksArgumentPresence() ? new Expression[valueTypes.length] : null; + for (int i = 0; i < valueTypes.length; i++) { + values[i] = new Reference("a" + i, TypeRef.of(valueTypes[i])); + if (present != null) { + present[i] = new Reference("p" + i, TypeRef.of(boolean.class)); + } + } + Expression[] masks = new Expression[creatorInfo.defaultMaskCount()]; + for (int i = 0; i < masks.length; i++) { + masks[i] = new Expression.Variable("m" + i, Expression.Literal.ofInt(0)); + expressions.add(masks[i]); + } + return new CreatorArguments(values, present, masks); + } + + private Class[] creatorValueTypes(JsonCreatorInfo creatorInfo) { + Class[] parameterTypes = creatorInfo.executable().getParameterTypes(); + JsonFieldInfo[] deferredFields = creatorInfo.deferredFields(); + Class[] valueTypes = + Arrays.copyOf(parameterTypes, parameterTypes.length + deferredFields.length); + for (int i = 0; i < deferredFields.length; i++) { + valueTypes[parameterTypes.length + i] = deferredFields[i].readRawType(); + } + return valueTypes; } private Expression assignCreatorArgument( @@ -1027,10 +1251,34 @@ private Expression resolveMissingArguments( Class[] parameterTypes = creatorInfo.executable().getParameterTypes(); Expression.ListExpression expressions = new Expression.ListExpression(); for (int i = 0; i < parameterTypes.length; i++) { - Expression defaultValue = creatorDefaultValue(creatorInfo, arguments, i, parameterTypes[i]); - expressions.add( - new Expression.If( - not(arguments.present[i]), new Expression.Assign(arguments.values[i], defaultValue))); + int maskBit = creatorInfo.defaultMaskBit(i); + if (maskBit >= 0) { + int word = maskBit >>> 5; + Expression setMask = + new Expression.Assign( + arguments.masks[word], + new Expression.BitOr( + arguments.masks[word], Expression.Literal.ofInt(1 << (maskBit & 31)))); + expressions.add(new Expression.If(not(arguments.present[i]), setMask)); + } else { + Expression defaultValue = creatorDefaultValue(creatorInfo, arguments, i, parameterTypes[i]); + expressions.add( + new Expression.If( + not(arguments.present[i]), + new Expression.Assign(arguments.values[i], defaultValue))); + } + } + JsonFieldInfo[] deferredFields = creatorInfo.deferredFields(); + for (int i = 0; i < deferredFields.length; i++) { + if (creatorInfo.deferredRequired(i)) { + expressions.add( + new Expression.If( + not(arguments.present[parameterTypes.length + i]), + new Expression.Invoke( + fieldRef("creator", JsonCreatorInfo.class), + "requireDeferred", + Expression.Literal.ofInt(i)))); + } } return expressions; } @@ -1070,7 +1318,13 @@ private Expression finishCreator( Expression.ListExpression expressions = new Expression.ListExpression(); expressions.add(resolveMissingArguments(creatorInfo, arguments)); Expression[] constructorArguments = - Arrays.copyOf(arguments.values, creatorInfo.argumentCount()); + Arrays.copyOf(arguments.values, creatorInfo.argumentCount() + arguments.masks.length); + System.arraycopy( + arguments.masks, + 0, + constructorArguments, + creatorInfo.argumentCount(), + arguments.masks.length); Expression value = new Expression.Variable("value", createValue(type, constructorArguments)); expressions.add(value); JsonFieldInfo[] deferredFields = creatorInfo.deferredFields(); @@ -1091,10 +1345,12 @@ private Expression finishCreator( private static final class CreatorArguments { private final Expression[] values; private final Expression[] present; + private final Expression[] masks; - private CreatorArguments(Expression[] values, Expression[] present) { + private CreatorArguments(Expression[] values, Expression[] present, Expression[] masks) { this.values = values; this.present = present; + this.masks = masks; } } @@ -1215,6 +1471,21 @@ private void appendWorkspaceDefaults( } body.append("}\n"); } + JsonFieldInfo[] deferredFields = creator.deferredFields(); + for (int i = 0; i < deferredFields.length; i++) { + if (!creator.deferredRequired(i)) { + continue; + } + body.append("if (") + .append(ctx.type(JsonCreatorInfo.class)) + .append(".isMissing(arguments[") + .append(creator.deferredSlot(i)) + .append("])) {\n throw ") + .append(creatorExpression) + .append(".missingDeferred(") + .append(i) + .append(");\n}\n"); + } } private String unwrappedCreatorMethod(int groupIndex) { @@ -1257,8 +1528,19 @@ private Expression creatorDefault(Class type) { return new Expression.Literal(type == float.class ? 0F : 0D, TypeRef.of(type)); } - private Expression readCreatorValue(JsonCreatorFieldInfo field, int id) { + private Expression readCreatorValue( + JsonGeneratedCodecBuilder builder, JsonCreatorFieldInfo field, int id) { Class type = field.rawType(); + DirectUnboxedValueCodec direct = field.directUnboxedValueCodec(); + if (direct != null) { + return new Expression.Cast( + builder.valueOperation(direct.readCarrierMethod(), readerRef()), TypeRef.of(type)); + } + TransparentUnboxedValueCodec transparent = field.transparentUnboxedValueCodec(); + if (transparent != null) { + Expression value = readCreatorTerminal(builder, field, id); + return new Expression.Cast(constructCarrier(builder, transparent, value), TypeRef.of(type)); + } if (!isDirectCreatorPrimitive(field)) { Expression codec = usesReaderSlot(field.typeInfo()) @@ -1306,12 +1588,123 @@ private Expression readCreatorValue(JsonCreatorFieldInfo field, int id) { throw new IllegalStateException("Unsupported primitive creator type " + type); } + private Expression readCreatorTerminal( + JsonGeneratedCodecBuilder builder, JsonCreatorFieldInfo field, int id) { + return readTerminal(builder, field.typeInfo(), id, false, creatorReader(field, id)); + } + + private Expression creatorReader(JsonCreatorFieldInfo field, int id) { + return usesReaderSlot(field.typeInfo()) + ? readerFromSlot(fieldRef("r" + id, JsonTypeInfo.class)) + : fieldRef("r" + id, readerCapabilityType()); + } + + private Expression readTerminal( + JsonGeneratedCodecBuilder builder, + JsonTypeInfo typeInfo, + int id, + boolean tokenValueRead, + Expression codec) { + Class rawType = typeInfo.rawType(); + JsonFieldKind kind = typeInfo.kind(); + UnboxedValueCodec operation = typeInfo.unboxedValueCodec(); + if (operation instanceof DirectUnboxedValueCodec) { + return builder.valueOperation( + ((DirectUnboxedValueCodec) operation).readCarrierMethod(), readerRef()); + } + if (rawType.isPrimitive()) { + if (rawType == boolean.class && kind == JsonFieldKind.BOOLEAN) { + return readBooleanExpr(tokenValueRead); + } + if (rawType == byte.class && kind == JsonFieldKind.BYTE) { + return new Expression.StaticInvoke( + JsonCreatorFieldInfo.class, + "checkedByte", + TypeRef.of(byte.class), + readIntExpr(tokenValueRead)); + } + if (rawType == short.class && kind == JsonFieldKind.SHORT) { + return new Expression.StaticInvoke( + JsonCreatorFieldInfo.class, + "checkedShort", + TypeRef.of(short.class), + readIntExpr(tokenValueRead)); + } + if (rawType == int.class && kind == JsonFieldKind.INT) { + return readIntExpr(tokenValueRead); + } + if (rawType == long.class && kind == JsonFieldKind.LONG) { + return readLongExpr(tokenValueRead); + } + if (rawType == float.class && kind == JsonFieldKind.FLOAT) { + return readFloatExpr(); + } + if (rawType == double.class && kind == JsonFieldKind.DOUBLE) { + return readDoubleExpr(); + } + if (rawType == char.class && kind == JsonFieldKind.CHAR) { + return new Expression.Invoke(readerRef(), "readChar", TypeRef.of(char.class)).inline(); + } + } + if (rawType == String.class && kind == JsonFieldKind.STRING) { + return readStringExpr(tokenValueRead); + } + return new Expression.Cast( + inline( + new Expression.Invoke( + codec, readMethod(), TypeRef.of(Object.class), false, readerRef())), + TypeRef.of(rawType)); + } + + private Expression constructCarrier( + JsonGeneratedCodecBuilder builder, TransparentUnboxedValueCodec operation, Expression value) { + Method[] methods = operation.constructMethods(); + int[] boxBytes = operation.constructBoxBytes(); + if (methods.length == 0 || methods.length != boxBytes.length) { + throw new ForyJsonException("Invalid transparent unboxed constructor operations"); + } + Expression current = value; + for (int i = 0; i < methods.length; i++) { + Expression invoke = inline(builder.valueOperation(methods[i], current)); + if (boxBytes[i] != 0) { + invoke = + new Expression.ListExpression( + new Expression.Invoke( + readerRef(), "reserveGraphMemory", Expression.Literal.ofInt(boxBytes[i])), + invoke); + } + current = invoke; + } + return current; + } + private static boolean isDirectCreatorPrimitive(JsonCreatorFieldInfo field) { Class type = field.rawType(); + return isDirectPrimitive(type, field.typeInfo().kind()); + } + + private static boolean usesCreatorReader(JsonCreatorFieldInfo field) { + if (field.directUnboxedValueCodec() != null) { + return false; + } + TransparentUnboxedValueCodec transparent = field.transparentUnboxedValueCodec(); + if (transparent == null) { + return !isDirectCreatorPrimitive(field); + } + JsonTypeInfo terminal = transparent.valueTypeInfo(); + if (terminal.unboxedValueCodec() instanceof DirectUnboxedValueCodec) { + return false; + } + if (terminal.rawType() == String.class && terminal.kind() == JsonFieldKind.STRING) { + return false; + } + return !isDirectPrimitive(terminal.rawType(), terminal.kind()); + } + + private static boolean isDirectPrimitive(Class type, JsonFieldKind kind) { if (!type.isPrimitive()) { return false; } - JsonFieldKind kind = field.typeInfo().kind(); return type == boolean.class && kind == JsonFieldKind.BOOLEAN || (type == byte.class && kind == JsonFieldKind.BYTE) || (type == short.class && kind == JsonFieldKind.SHORT) @@ -1581,12 +1974,20 @@ private Expression readerConstructorExpression(Class type, JsonFieldInfo[] pr new Expression.Invoke(property, "nameHash", TypeRef.of(long.class)).inline(), id)); if (JsonCodegen.usesReadCodec(properties[i], resolver)) { - Class codecType = codecFieldType(properties[i]); - expressions.add( - new Expression.Assign( - new Reference("this.r" + i, TypeRef.of(codecType)), - new Expression.Cast( - new Expression.ArrayValue(codecsRef, id), TypeRef.of(codecType)))); + if (usesReaderSlot(properties[i].readTypeInfo())) { + expressions.add( + new Expression.Assign( + new Reference("this.r" + i, TypeRef.of(JsonTypeInfo.class)), + new Expression.Invoke(property, "readTypeInfo", TypeRef.of(JsonTypeInfo.class)) + .inline())); + } else { + Class codecType = codecFieldType(properties[i]); + expressions.add( + new Expression.Assign( + new Reference("this.r" + i, TypeRef.of(codecType)), + new Expression.Cast( + new Expression.ArrayValue(codecsRef, id), TypeRef.of(codecType)))); + } } else if (storesReadObjectCodec(type, properties[i])) { if (usesReaderSlot(properties[i].readTypeInfo())) { expressions.add( @@ -1639,7 +2040,7 @@ private Expression unwrappedReaderConstructor( new Expression.Invoke(owner, "creatorInfo", TypeRef.of(JsonCreatorInfo.class)) .inline())); for (int i = 0; i < creatorFields.length; i++) { - if (!isDirectCreatorPrimitive(creatorFields[i])) { + if (usesCreatorReader(creatorFields[i])) { expressions.add( new Expression.Assign( new Reference("this.r" + i, TypeRef.of(readerCapabilityType())), @@ -1654,7 +2055,7 @@ private Expression unwrappedReaderConstructor( JsonFieldInfo field = routes[i].field(); if (field != null) { addUnwrappedReaderAssignment(expressions, type, field, id, properties, codecs); - } else if (!isDirectCreatorPrimitive(routes[i].creatorField())) { + } else if (usesCreatorReader(routes[i].creatorField())) { expressions.add( new Expression.Assign( new Reference("this.r" + id, TypeRef.of(readerCapabilityType())), @@ -1709,13 +2110,21 @@ private void addUnwrappedReaderAssignment( new Reference("this.rp" + id, TypeRef.of(JsonFieldInfo.class)), property)); } if (JsonCodegen.usesReadCodec(field, resolver)) { - Class codecType = codecFieldType(field); - expressions.add( - new Expression.Assign( - new Reference("this.r" + id, TypeRef.of(codecType)), - new Expression.Cast( - new Expression.ArrayValue(codecs, Expression.Literal.ofInt(id)), - TypeRef.of(codecType)))); + if (usesReaderSlot(field.readTypeInfo())) { + expressions.add( + new Expression.Assign( + new Reference("this.r" + id, TypeRef.of(JsonTypeInfo.class)), + new Expression.Invoke(property, "readTypeInfo", TypeRef.of(JsonTypeInfo.class)) + .inline())); + } else { + Class codecType = codecFieldType(field); + expressions.add( + new Expression.Assign( + new Reference("this.r" + id, TypeRef.of(codecType)), + new Expression.Cast( + new Expression.ArrayValue(codecs, Expression.Literal.ofInt(id)), + TypeRef.of(codecType)))); + } } else if (storesReadObjectCodec(type, field)) { expressions.add( new Expression.Assign( @@ -1854,7 +2263,7 @@ private Expression.ListExpression unwrappedMembers( Expression directRead = creatorFields == null ? unwrappedDirectFields(builder, type, directFields, root, direct) - : unwrappedDirectCreator(creatorFields, root, direct); + : unwrappedDirectCreator(builder, creatorFields, root, direct); Expression directMiss = direct; if (creatorFields != null) { directMiss = @@ -1952,7 +2361,10 @@ private Expression unwrappedDirectFields( } private Expression unwrappedDirectCreator( - JsonCreatorFieldInfo[] fields, Expression root, Expression index) { + JsonGeneratedCodecBuilder builder, + JsonCreatorFieldInfo[] fields, + Expression root, + Expression index) { if (fields.length > READ_FIELD_SWITCH_SIZE) { int chunks = (fields.length + READ_FIELD_SWITCH_SIZE - 1) / READ_FIELD_SWITCH_SIZE; Expression.Switch.Case[] cases = new Expression.Switch.Case[chunks]; @@ -1978,11 +2390,16 @@ private Expression unwrappedDirectCreator( true, "/", index, Expression.Literal.ofInt(READ_FIELD_SWITCH_SIZE)); return new Expression.Switch(chunk, cases, new Expression.Invoke(readerRef(), "skipValue")); } - return unwrappedDirectCreator(fields, 0, fields.length, root, index); + return unwrappedDirectCreator(builder, fields, 0, fields.length, root, index); } private Expression unwrappedDirectCreator( - JsonCreatorFieldInfo[] fields, int start, int end, Expression root, Expression index) { + JsonGeneratedCodecBuilder builder, + JsonCreatorFieldInfo[] fields, + int start, + int end, + Expression root, + Expression index) { Expression.Switch.Case[] cases = new Expression.Switch.Case[end - start]; for (int i = start; i < end; i++) { JsonCreatorFieldInfo field = fields[i]; @@ -1992,7 +2409,7 @@ private Expression unwrappedDirectCreator( new Expression.ListExpression( new Expression.AssignArrayElem( root, - readCreatorValue(field, i), + readCreatorValue(builder, field, i), Expression.Literal.ofInt(field.argumentIndex())), new Expression.Break())); } @@ -2069,7 +2486,7 @@ private Expression unwrappedRouteSwitch( read.add( new Expression.AssignArrayElem( new Expression.Cast(workspace, TypeRef.of(Object[].class)), - readCreatorValue(field, id), + readCreatorValue(builder, field, id), Expression.Literal.ofInt(field.argumentIndex()))); } read.add(new Expression.Break()); @@ -2098,7 +2515,7 @@ private void addUnwrappedReadMethods( Expression read = creatorFields == null ? unwrappedDirectFields(builder, type, fields, start, end, root, index) - : unwrappedDirectCreator(creatorFields, start, end, root, index); + : unwrappedDirectCreator(builder, creatorFields, start, end, root, index); addGeneratedMethod( ctx, "private final", @@ -3656,7 +4073,7 @@ private Expression anyReaderRef() { } private boolean usesReaderSlot(JsonTypeInfo child) { - return resolver.usesReaderSlot(ownerType, child); + return resolver.usesReaderSlot(objectOwner, child); } private Expression readerFromSlot(Expression slot) { @@ -3687,6 +4104,10 @@ Expression consumeCommaOrEndObjectExpr() { .inline(); } + Expression consumeOrderedCommaOrEndObjectExpr() { + return consumeCommaOrEndObjectExpr(); + } + final Expression tryReadNullExpr() { return new Expression.Invoke(readerRef(), "tryReadNextNullToken", TypeRef.of(boolean.class)) .inline(); @@ -4012,20 +4433,13 @@ final Expression not(Expression expression) { } final boolean usesReadCodec(JsonFieldInfo property) { - switch (property.readKind()) { - case ENUM: - case ARRAY: - case COLLECTION: - case MAP: - return true; - case OBJECT: - return !usesReadObjectCodec(property); - default: - return false; - } + return JsonCodegen.usesReadCodec(property, resolver); } final boolean usesReadInfo(JsonFieldInfo property) { + if (property.readsUnboxedValue()) { + return false; + } switch (property.readKind()) { case BOOLEAN: case INT: @@ -4049,7 +4463,8 @@ final boolean usesReadInfo(JsonFieldInfo property) { } final boolean usesReadObjectCodec(JsonFieldInfo property) { - return property.readKind() == JsonFieldKind.OBJECT + return !property.readsUnboxedValue() + && property.readKind() == JsonFieldKind.OBJECT && property.readRawType() != Object.class && resolver.canonicalObjectCodec(property.readTypeInfo()) != null; } @@ -4066,6 +4481,9 @@ private Expression readField( int id, Expression object, boolean tokenValueRead) { + if (property.readsUnboxedValue()) { + return readUnboxedField(builder, property, id, object, tokenValueRead); + } Class rawType = property.readRawType(); switch (property.readKind()) { case BOOLEAN: @@ -4095,6 +4513,30 @@ private Expression readField( } } + private Expression readUnboxedField( + JsonGeneratedCodecBuilder builder, + JsonFieldInfo property, + int id, + Expression object, + boolean tokenValueRead) { + DirectUnboxedValueCodec direct = property.readDirectUnboxedValueCodec(); + if (direct != null) { + return builder.setField( + property, object, builder.valueOperation(direct.readCarrierMethod(), readerRef())); + } + TransparentUnboxedValueCodec transparent = property.readTransparentUnboxedValueCodec(); + Expression value = + readTerminal( + builder, property.readTypeInfo(), id, tokenValueRead, propertyReader(property, id)); + return builder.setField(property, object, constructCarrier(builder, transparent, value)); + } + + private Expression propertyReader(JsonFieldInfo property, int id) { + return usesReaderSlot(property.readTypeInfo()) + ? readerFromSlot(fieldRef("r" + id, JsonTypeInfo.class)) + : fieldRef("r" + id, readerCapabilityType()); + } + final Expression readBoolean( JsonGeneratedCodecBuilder builder, JsonFieldInfo property, @@ -4270,7 +4712,7 @@ final Expression readResolvedValue(JsonFieldInfo property, int id) { Expression value = inline( new Expression.Invoke( - fieldRef("r" + id, readerCapabilityType()), + propertyReader(property, id), readObjectMethod(), TypeRef.of(Object.class), false, diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonWriterCodegen.java b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonWriterCodegen.java index 5f20e42b9c..feba9de754 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonWriterCodegen.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codegen/JsonWriterCodegen.java @@ -24,6 +24,7 @@ import static org.apache.fory.codegen.ExpressionUtils.eq; import static org.apache.fory.codegen.ExpressionUtils.inline; +import java.lang.reflect.Method; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.IdentityHashMap; @@ -36,6 +37,7 @@ import org.apache.fory.codegen.Expression.Reference; import org.apache.fory.codegen.ExpressionOptimizer; import org.apache.fory.json.ForyJsonException; +import org.apache.fory.json.codec.DirectUnboxedValueCodec; import org.apache.fory.json.codec.JsonUnwrappedInfo; import org.apache.fory.json.codec.JsonUnwrappedInfo.Group; import org.apache.fory.json.codec.JsonUnwrappedInfo.WriteEntry; @@ -65,11 +67,13 @@ abstract class JsonWriterCodegen { final JsonCodegen codegen; final JsonTypeResolver resolver; + private final ObjectCodec objectOwner; private Class ownerType; - JsonWriterCodegen(JsonCodegen codegen, JsonTypeResolver resolver) { + JsonWriterCodegen(JsonCodegen codegen, JsonTypeResolver resolver, ObjectCodec objectOwner) { this.codegen = codegen; this.resolver = resolver; + this.objectOwner = objectOwner; } abstract Class codecFieldType(JsonFieldInfo property); @@ -132,6 +136,9 @@ abstract Expression writeStringField( abstract Expression writeFieldName( JsonFieldInfo property, int id, boolean commaKnown, Expression index, Expression writer); + abstract Expression writeNullField( + JsonFieldInfo property, int id, boolean commaKnown, Expression index, Expression writer); + Expression writeObjectEnd(Expression writer) { return new Expression.Invoke(writer, "writeObjectEnd"); } @@ -906,6 +913,7 @@ private Expression writeExpression( first != null && !first.writeNull() && !first.requiresNonNullWrite() + && !first.writesUnboxedValue() && first.writeKind() == JsonFieldKind.STRING ? new Expression.Variable( "v0", cast(inline(builder.fieldValue(first, object)), TypeRef.of(String.class))) @@ -1007,6 +1015,7 @@ private Expression writeAnyExpression( && first != null && !first.writeNull() && !first.requiresNonNullWrite() + && !first.writesUnboxedValue() && first.writeKind() == JsonFieldKind.STRING ? new Expression.Variable( "v0", cast(inline(builder.fieldValue(first, object)), TypeRef.of(String.class))) @@ -1236,7 +1245,9 @@ static int firstGroupMember(JsonFieldInfo[] properties) { } private static boolean canFuseObjectStart(JsonFieldInfo[] properties) { - if (properties.length == 0 || !properties[0].writeRawType().isPrimitive()) { + if (properties.length == 0 + || properties[0].writesUnboxedValue() + || !properties[0].writeRawType().isPrimitive()) { return false; } switch (properties[0].writeKind()) { @@ -1258,11 +1269,12 @@ private Expression writeProp( Expression index, Expression object, Expression writer) { + if (property.writesUnboxedValue()) { + return writeUnboxed(builder, property, id, commaKnown, index, object, writer); + } Class rawType = property.writeRawType(); if (rawType == void.class) { - return new Expression.ListExpression( - writeFieldName(property, id, commaKnown, index, writer), - new Expression.Invoke(writer, "writeNull")); + return writeNullField(property, id, commaKnown, index, writer); } if (rawType.isPrimitive()) { // Primitive members cannot be null and this path consumes the access once. Nullable @@ -1287,6 +1299,17 @@ private Expression writeProp( || kind == JsonFieldKind.OBJECT && writeExactScalar(property, value, writer) == null || kind == JsonFieldKind.COLLECTION && !writesStringCollectionDirectly(property); if (onlyCodec) { + if (kind == JsonFieldKind.OBJECT + && resolver.canonicalObjectCodec(property.writeTypeInfo()) != null) { + return new Expression.ListExpression( + value, + new Expression.If( + eq(value, nullValue), + writeNullField(property, id, commaKnown, index, writer), + new Expression.ListExpression( + writeFieldName(property, id, commaKnown, index, writer), + writeCodec(property, id, value, writer)))); + } return new Expression.ListExpression( value, writeFieldName(property, id, commaKnown, index, writer), @@ -1297,18 +1320,17 @@ private Expression writeProp( value, new Expression.If( eq(value, nullValue), - new Expression.ListExpression( - writeFieldName(property, id, commaKnown, index, writer), - new Expression.Invoke(writer, "writeNull")), + writeNullField(property, id, commaKnown, index, writer), writeValue(property, id, value, commaKnown, index, writer))); } return new Expression.ListExpression( value, - writeFieldName(property, id, commaKnown, index, writer), new Expression.If( eq(value, nullValue), - new Expression.Invoke(writer, "writeNull"), - writeValue(property, id, value, true, index, writer))); + writeNullField(property, id, commaKnown, index, writer), + new Expression.ListExpression( + writeFieldName(property, id, commaKnown, index, writer), + writeValue(property, id, value, true, index, writer)))); } Expression write = isPrefixValue(property.writeKind()) @@ -1327,6 +1349,76 @@ private Expression writeProp( return new Expression.ListExpression(value, new Expression.If(ne(value, nullValue), write)); } + private Expression writeUnboxed( + JsonGeneratedCodecBuilder builder, + JsonFieldInfo property, + int id, + boolean commaKnown, + Expression index, + Expression object, + Expression writer) { + Class carrierType = property.writeRawType(); + Expression carrier = + cast(inline(builder.fieldValue(property, object)), TypeRef.of(carrierType)); + if (property.writeDirectUnboxedValueCodec() != null) { + return new Expression.ListExpression( + writeFieldName(property, id, commaKnown, index, writer), + builder.valueOperation( + property.writeDirectUnboxedValueCodec().writeCarrierMethod(), writer, carrier)); + } + Expression value = carrier; + for (Method method : property.writeTransparentUnboxedValueCodec().extractMethods()) { + value = inline(builder.valueOperation(method, value)); + } + Class valueType = property.writeTypeInfo().rawType(); + value = cast(inline(value), TypeRef.of(valueType)); + if (property.writeTypeInfo().unboxedValueCodec() instanceof DirectUnboxedValueCodec) { + DirectUnboxedValueCodec terminal = + (DirectUnboxedValueCodec) property.writeTypeInfo().unboxedValueCodec(); + return new Expression.ListExpression( + writeFieldName(property, id, commaKnown, index, writer), + builder.valueOperation(terminal.writeCarrierMethod(), writer, value)); + } + if (valueType.isPrimitive()) { + return writePrimitive(property, id, value, commaKnown, index, writer); + } + Expression local = new Expression.Variable("v" + id, value); + Expression nullValue = new Expression.Null(TypeRef.of(valueType), false); + Expression write = + isPrefixValue(property.writeKind()) + ? writeValue(property, id, local, commaKnown, index, writer) + : new Expression.ListExpression( + writeFieldName(property, id, commaKnown, index, writer), + writeValue(property, id, local, true, index, writer)); + if (property.writeNull()) { + if (isPrefixValue(property.writeKind())) { + return new Expression.ListExpression( + local, + new Expression.If( + eq(local, nullValue), + writeNullField(property, id, commaKnown, index, writer), + write)); + } + return new Expression.ListExpression( + local, + new Expression.If( + eq(local, nullValue), + writeNullField(property, id, commaKnown, index, writer), + new Expression.ListExpression( + writeFieldName(property, id, commaKnown, index, writer), + writeValue(property, id, local, true, index, writer)))); + } + if (property.requiresNonNullWrite()) { + return new Expression.ListExpression( + local, + new Expression.If( + eq(local, nullValue), + new Expression.Invoke(fieldRef("wp" + id, JsonFieldInfo.class), "rejectNullWrite"), + write)); + } + return new Expression.ListExpression(local, new Expression.If(ne(local, nullValue), write)); + } + private Expression writePrimitive( JsonFieldInfo property, int id, @@ -1364,6 +1456,14 @@ private Expression writeValue( new Expression.Invoke(writer, "writeRawValue", value)); } JsonFieldKind kind = property.writeKind(); + if (property.writesUnboxedValue() + && (kind == JsonFieldKind.ENUM + || kind == JsonFieldKind.ARRAY + || kind == JsonFieldKind.COLLECTION + || kind == JsonFieldKind.MAP + || kind == JsonFieldKind.OBJECT)) { + return writeCodec(property, id, value, writer); + } switch (kind) { case BOOLEAN: return writeRawFieldValue( @@ -1460,8 +1560,9 @@ static long packedPrefixWord(byte[] prefix, int offset) { private Expression writeCodec( JsonFieldInfo property, int id, Expression value, Expression writer) { boolean object = resolver.canonicalObjectCodec(property.writeTypeInfo()) != null; + Class valueType = property.writeTypeInfo().rawType(); Expression codec = - object && property.writeRawType() == ownerType + object && valueType == ownerType ? new Reference("this", TypeRef.of(completeWriterType())) : usesWriterSlot(property) ? writerFromSlot(fieldRef("w" + id, JsonTypeInfo.class)) @@ -1477,16 +1578,16 @@ private Expression writerFromSlot(Expression slot) { private boolean storesWriteCodec(JsonFieldInfo property) { return usesWriteCodec(property) && (resolver.canonicalObjectCodec(property.writeTypeInfo()) == null - || property.writeRawType() != ownerType); + || property.writeTypeInfo().rawType() != ownerType); } private boolean usesWriterSlot(JsonFieldInfo property) { return storesWriteCodec(property) - && resolver.usesWriterSlot(ownerType, property.writeTypeInfo()); + && resolver.usesWriterSlot(objectOwner, property.writeTypeInfo()); } private boolean usesAnyWriterSlot(AnyInfo any) { - return storesAnyWriter(any) && resolver.usesWriterSlot(ownerType, any.valueTypeInfo()); + return storesAnyWriter(any) && resolver.usesWriterSlot(objectOwner, any.valueTypeInfo()); } private static Expression writeStringCollection(Expression value, Expression writer) { diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codegen/StringWriterCodegen.java b/java/fory-json/src/main/java/org/apache/fory/json/codegen/StringWriterCodegen.java index 40b9ee10c4..b898fc8473 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codegen/StringWriterCodegen.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codegen/StringWriterCodegen.java @@ -23,6 +23,7 @@ import org.apache.fory.codegen.Expression; import org.apache.fory.codegen.Expression.Reference; import org.apache.fory.json.ForyJsonException; +import org.apache.fory.json.codec.ObjectCodec; import org.apache.fory.json.codec.StringWriterCodec; import org.apache.fory.json.meta.JsonFieldInfo; import org.apache.fory.json.resolver.JsonTypeResolver; @@ -32,8 +33,8 @@ final class StringWriterCodegen extends JsonWriterCodegen { private static final int MIN_SPLIT_MEMBERS = 10; - StringWriterCodegen(JsonCodegen codegen, JsonTypeResolver resolver) { - super(codegen, resolver); + StringWriterCodegen(JsonCodegen codegen, JsonTypeResolver resolver, ObjectCodec objectOwner) { + super(codegen, resolver, objectOwner); } @Override @@ -273,6 +274,18 @@ Expression writeFieldName( return expressions; } + @Override + Expression writeNullField( + JsonFieldInfo property, int id, boolean commaKnown, Expression index, Expression writer) { + if (commaKnown && canPackUtf16Prefix(property, true)) { + return new Expression.Invoke( + writer, "writeNullField", stringPackedPrefixArgs(property, id, true)); + } + return new Expression.ListExpression( + writeFieldName(property, id, commaKnown, index, writer), + new Expression.Invoke(writer, "writeNull")); + } + @Override Expression booleanFieldValue(int id, Expression value, boolean commaKnown, Expression index) { return fieldValue(id, "stringBooleanFieldValue", value, commaKnown, index); diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codegen/Utf8ReaderCodegen.java b/java/fory-json/src/main/java/org/apache/fory/json/codegen/Utf8ReaderCodegen.java index 8bf2a4c5bc..0a438a6005 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codegen/Utf8ReaderCodegen.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codegen/Utf8ReaderCodegen.java @@ -115,6 +115,18 @@ Expression consumeCommaOrEndObjectExpr() { return new Expression.LogicalOr(comma, endOrSlow); } + @Override + Expression consumeOrderedCommaOrEndObjectExpr() { + Expression comma = + new Expression.Invoke(readerRef(), "tryConsumeNextOrderedComma", TypeRef.of(boolean.class)) + .inline(); + Expression endOrSlow = + new Expression.Invoke( + readerRef(), "consumeNextOrderedObjectEndOrSlow", TypeRef.of(boolean.class)) + .inline(); + return new Expression.LogicalOr(comma, endOrSlow); + } + static Expression readStringElement(String id) { Reference reader = new Reference("reader", TypeRef.of(Utf8JsonReader.class)); Expression.Variable value = diff --git a/java/fory-json/src/main/java/org/apache/fory/json/codegen/Utf8WriterCodegen.java b/java/fory-json/src/main/java/org/apache/fory/json/codegen/Utf8WriterCodegen.java index 1a92aa1640..7b0b8ba1a4 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/codegen/Utf8WriterCodegen.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/codegen/Utf8WriterCodegen.java @@ -30,6 +30,7 @@ import org.apache.fory.codegen.Expression.Reference; import org.apache.fory.json.ForyJsonException; import org.apache.fory.json.codec.ArrayCodec; +import org.apache.fory.json.codec.ObjectCodec; import org.apache.fory.json.codec.ScalarCodecs; import org.apache.fory.json.codec.Utf8WriterCodec; import org.apache.fory.json.meta.JsonFieldInfo; @@ -44,8 +45,12 @@ final class Utf8WriterCodegen extends JsonWriterCodegen { // that entry. Independently compiled String, child-object, and numeric value bodies remain calls. private final boolean inlineSchemaWrites; - Utf8WriterCodegen(JsonCodegen codegen, JsonTypeResolver resolver, boolean inlineSchemaWrites) { - super(codegen, resolver); + Utf8WriterCodegen( + JsonCodegen codegen, + JsonTypeResolver resolver, + ObjectCodec objectOwner, + boolean inlineSchemaWrites) { + super(codegen, resolver, objectOwner); this.inlineSchemaWrites = inlineSchemaWrites; } @@ -290,6 +295,17 @@ Expression writeFieldName( return expressions; } + @Override + Expression writeNullField( + JsonFieldInfo property, int id, boolean commaKnown, Expression index, Expression writer) { + if (commaKnown && canPackPrefix(property, true)) { + return new Expression.Invoke(writer, "writeNullField", packedPrefixArgs(property, true)); + } + return new Expression.ListExpression( + writeFieldName(property, id, commaKnown, index, writer), + new Expression.Invoke(writer, "writeNull")); + } + @Override Expression writeObjectEnd(Expression writer) { if (!inlineSchemaWrites) { diff --git a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorDeclaration.java b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorDeclaration.java index 164bcf0621..46b534e1bb 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorDeclaration.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorDeclaration.java @@ -23,19 +23,31 @@ import java.lang.reflect.Executable; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import org.apache.fory.annotation.Internal; import org.apache.fory.json.ForyJsonException; import org.apache.fory.json.annotation.JsonCreator; +import org.apache.fory.json.codec.JsonObjectModel; import org.apache.fory.json.resolver.JsonSharedRegistry; /** Immutable result of selecting and validating one declared {@link JsonCreator}. */ @Internal public final class JsonCreatorDeclaration { private final Executable executable; + private final Executable annotationSource; private final JsonCreator annotation; private JsonCreatorDeclaration(Executable executable, JsonCreator annotation) { + this(executable, executable, annotation); + } + + private JsonCreatorDeclaration( + Executable executable, Executable annotationSource, JsonCreator annotation) { this.executable = executable; + this.annotationSource = annotationSource; this.annotation = annotation; } @@ -47,6 +59,11 @@ public JsonCreator annotation() { return annotation; } + /** Returns the exact executable which owns the effective creator and parameter annotations. */ + public Executable annotationSource() { + return annotationSource; + } + public static JsonCreatorDeclaration find(Class type, JsonSharedRegistry registry) { Executable creator = null; JsonCreator annotation = null; @@ -75,9 +92,101 @@ public static JsonCreatorDeclaration find(Class type, JsonSharedRegistry regi return creator == null ? null : new JsonCreatorDeclaration(creator, annotation); } + /** Selects the one logical language creator while collapsing only proven default overloads. */ + public static JsonCreatorDeclaration find( + Class type, JsonSharedRegistry registry, JsonObjectModel objectModel) { + if (objectModel == null) { + return find(type, registry); + } + Executable expected = objectModel.creator(); + Executable invocation = objectModel.invocationCreator(); + Constructor defaultConstructor = objectModel.defaultConstructor(); + JsonCreatorDeclaration selected = null; + boolean unexpected = false; + List declarations = findAll(type, registry); + for (JsonCreatorDeclaration declaration : declarations) { + Executable candidate = declaration.executable; + if (candidate.equals(expected) || candidate.equals(invocation)) { + validate(type, expected, invocation); + if (selected != null + && !Arrays.equals(selected.annotation.value(), declaration.annotation.value())) { + unexpected = true; + } else if (selected == null || candidate.equals(expected)) { + // The annotation can live on the exact compiler accessibility constructor while the + // logical constructor remains the sole construction schema owner. + selected = new JsonCreatorDeclaration(expected, candidate, declaration.annotation); + } + } else if (candidate.equals(defaultConstructor)) { + // Compiler-default constructors are an exact model-owned copy of the logical declaration. + } else if (!isDefaultOverload(candidate, expected, objectModel.defaultMaskBits())) { + validate(type, candidate); + unexpected = true; + } + } + if (unexpected || selected == null && !declarations.isEmpty()) { + throw multipleCreatorsException(type); + } + return selected; + } + + /** Returns every effective creator declaration for exact language metadata mapping. */ + public static List findAll(Class type, JsonSharedRegistry registry) { + ArrayList declarations = new ArrayList<>(); + for (Constructor constructor : type.getDeclaredConstructors()) { + JsonCreator annotation = registry.annotation(type, constructor, JsonCreator.class); + if (annotation == null) { + continue; + } + declarations.add(new JsonCreatorDeclaration(constructor, annotation)); + } + for (Method method : type.getDeclaredMethods()) { + JsonCreator annotation = registry.annotation(type, method, JsonCreator.class); + if (annotation == null || method.isSynthetic() || method.isBridge()) { + continue; + } + declarations.add(new JsonCreatorDeclaration(method, annotation)); + } + return Collections.unmodifiableList(declarations); + } + + private static boolean isDefaultOverload( + Executable candidate, Executable expected, int[] defaultMaskBits) { + if (!(candidate instanceof Constructor) + || !(expected instanceof Constructor) + || candidate.getDeclaringClass() != expected.getDeclaringClass()) { + return false; + } + Class[] candidateTypes = candidate.getParameterTypes(); + Class[] expectedTypes = expected.getParameterTypes(); + if (candidateTypes.length >= expectedTypes.length) { + return false; + } + for (int i = 0; i < candidateTypes.length; i++) { + if (candidateTypes[i] != expectedTypes[i]) { + return false; + } + } + for (int i = candidateTypes.length; i < expectedTypes.length; i++) { + if (i >= defaultMaskBits.length || defaultMaskBits[i] < 0) { + return false; + } + } + return true; + } + private static void validate(Class type, Executable creator) { + validate(type, creator, creator); + } + + private static void validate(Class type, Executable creator, Executable invocationCreator) { int modifiers = creator.getModifiers(); - if (!Modifier.isPublic(modifiers) + boolean invocableConstructor = + creator instanceof Constructor + && invocationCreator instanceof Constructor + && invocationCreator != creator + && invocationCreator.getDeclaringClass() == type + && Modifier.isPublic(invocationCreator.getModifiers()); + if ((!Modifier.isPublic(modifiers) && !invocableConstructor) || creator.isSynthetic() || creator.isVarArgs() || creator.getParameterCount() == 0 diff --git a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorFieldInfo.java b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorFieldInfo.java index bb6b2a98c8..3d0f34bfd5 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorFieldInfo.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorFieldInfo.java @@ -24,12 +24,16 @@ import org.apache.fory.json.ForyJsonException; import org.apache.fory.json.annotation.JsonCodec; import org.apache.fory.json.annotation.JsonFormat; +import org.apache.fory.json.codec.DirectUnboxedValueCodec; import org.apache.fory.json.codec.JsonValueCodec; +import org.apache.fory.json.codec.TransparentUnboxedValueCodec; +import org.apache.fory.json.codec.UnboxedValueCodec; import org.apache.fory.json.reader.Latin1JsonReader; import org.apache.fory.json.reader.Utf16JsonReader; import org.apache.fory.json.reader.Utf8JsonReader; import org.apache.fory.json.resolver.JsonTypeInfo; import org.apache.fory.json.resolver.JsonTypeResolver; +import org.apache.fory.reflect.TypeRef; /** Immutable input metadata for one {@code JsonCreator} argument. */ @Internal @@ -37,29 +41,36 @@ public final class JsonCreatorFieldInfo { private final String name; private final long nameHash; private final int argumentIndex; - private final Type type; + private final TypeRef typeRef; private final Class rawType; private final JsonCodec codecAnnotation; private final Class> valueCodecClass; private final JsonFormat formatAnnotation; + private final boolean unboxedRequired; + private final boolean selectedCodec; private JsonTypeInfo typeInfo; + private JsonTypeInfo occurrenceTypeInfo; + private UnboxedValueCodec unboxedValueCodec; public JsonCreatorFieldInfo( String name, int argumentIndex, - Type type, + TypeRef typeRef, Class rawType, JsonCodec codecAnnotation, Class> valueCodecClass, - JsonFormat formatAnnotation) { + JsonFormat formatAnnotation, + boolean unboxedRequired) { this.name = name; nameHash = JsonFieldNameHash.hash(name); this.argumentIndex = argumentIndex; - this.type = type; + this.typeRef = typeRef; this.rawType = rawType; this.codecAnnotation = codecAnnotation; this.valueCodecClass = valueCodecClass; this.formatAnnotation = formatAnnotation; + this.unboxedRequired = unboxedRequired; + selectedCodec = codecAnnotation != null || valueCodecClass != null || formatAnnotation != null; } public String name() { @@ -71,11 +82,12 @@ public JsonCreatorFieldInfo withName(String transformedName) { return new JsonCreatorFieldInfo( transformedName, argumentIndex, - type, + typeRef, rawType, codecAnnotation, valueCodecClass, - formatAnnotation); + formatAnnotation, + unboxedRequired); } public long nameHash() { @@ -87,7 +99,11 @@ public int argumentIndex() { } public Type type() { - return type; + return typeRef.getType(); + } + + public TypeRef typeRef() { + return typeRef; } public Class rawType() { @@ -99,26 +115,138 @@ public JsonTypeInfo typeInfo() { } public void resolveType(JsonTypeResolver resolver) { - typeInfo = - codecAnnotation != null - ? resolver.getTypeInfo(type, rawType, codecAnnotation) - : valueCodecClass != null - ? resolver.getTypeInfo(type, rawType, valueCodecClass) - : formatAnnotation != null - ? resolver.getTypeInfo(type, rawType, formatAnnotation) - : resolver.getTypeInfo(type, rawType); + if (!unboxedRequired) { + typeInfo = selectedCodec ? selectedTypeInfo(resolver) : resolver.getTypeInfo(typeRef); + occurrenceTypeInfo = typeInfo; + return; + } + if (selectedCodec) { + throw new ForyJsonException( + "JSON creator property " + + name + + " cannot select a codec or format for the unboxed logical type " + + typeRef); + } + JsonTypeInfo canonical = resolver.getTypeInfo(typeRef); + UnboxedValueCodec operation = canonical.unboxedValueCodec(); + if (operation == null || operation.carrierType() != rawType) { + throw new ForyJsonException( + "JSON creator property " + + name + + " has no exact unboxed carrier operation for " + + rawType.getName()); + } + unboxedValueCodec = operation; + occurrenceTypeInfo = canonical; + if (operation instanceof DirectUnboxedValueCodec) { + typeInfo = canonical; + } else if (operation instanceof TransparentUnboxedValueCodec) { + typeInfo = ((TransparentUnboxedValueCodec) operation).valueTypeInfo(); + } else { + throw new ForyJsonException( + "JSON creator property " + + name + + " has an unsupported unboxed carrier capability for " + + rawType.getName()); + } + } + + private JsonTypeInfo selectedTypeInfo(JsonTypeResolver resolver) { + return codecAnnotation != null + ? resolver.getTypeInfo(typeRef, codecAnnotation) + : valueCodecClass != null + ? resolver.getTypeInfo(typeRef, valueCodecClass) + : resolver.getTypeInfo(typeRef, formatAnnotation); } public Object readLatin1(Latin1JsonReader reader) { - return requirePrimitive(typeInfo.latin1Reader().readLatin1(reader), rawType); + if (readOccurrenceNull(reader)) { + return null; + } + Object value = + unboxedValueCodec == null + ? typeInfo.latin1Reader().readLatin1(reader) + : unboxedValueCodec.readLatin1Carrier(reader); + return requirePrimitive(value, rawType); } public Object readUtf16(Utf16JsonReader reader) { - return requirePrimitive(typeInfo.utf16Reader().readUtf16(reader), rawType); + if (readOccurrenceNull(reader)) { + return null; + } + Object value = + unboxedValueCodec == null + ? typeInfo.utf16Reader().readUtf16(reader) + : unboxedValueCodec.readUtf16Carrier(reader); + return requirePrimitive(value, rawType); } public Object readUtf8(Utf8JsonReader reader) { - return requirePrimitive(typeInfo.utf8Reader().readUtf8(reader), rawType); + if (readOccurrenceNull(reader)) { + return null; + } + Object value = + unboxedValueCodec == null + ? typeInfo.utf8Reader().readUtf8(reader) + : unboxedValueCodec.readUtf8Carrier(reader); + return requirePrimitive(value, rawType); + } + + private boolean readOccurrenceNull(org.apache.fory.json.reader.JsonReader reader) { + if (!occurrenceTypeInfo.nullable() && !occurrenceTypeInfo.rejectsNull()) { + return false; + } + if (!reader.tryReadNull()) { + return false; + } + if (occurrenceTypeInfo.rejectsNull()) { + rejectNullRead(); + } + return true; + } + + /** Returns the cold-bound read-side null action for generated specialization. */ + public boolean rejectsNullRead() { + return occurrenceTypeInfo.rejectsNull(); + } + + /** Returns whether generated readers must materialize an outer null for this occurrence. */ + public boolean nullableRead() { + return occurrenceTypeInfo.nullable(); + } + + /** Returns whether a present JSON null materializes a non-null logical value in this carrier. */ + public boolean materializesNullCarrier() { + return unboxedValueCodec != null && occurrenceTypeInfo.transparentNull(); + } + + /** Returns whether this creator slot is bound to an exact unboxed carrier operation. */ + public boolean unboxedValue() { + return unboxedValueCodec != null; + } + + /** Returns the cold-bound unboxed carrier operation. */ + public UnboxedValueCodec unboxedValueCodec() { + return unboxedValueCodec; + } + + /** Returns the exact transparent carrier operation, or {@code null}. */ + public TransparentUnboxedValueCodec transparentUnboxedValueCodec() { + return unboxedValueCodec instanceof TransparentUnboxedValueCodec + ? (TransparentUnboxedValueCodec) unboxedValueCodec + : null; + } + + /** Returns the exact semantic leaf carrier operation, or {@code null}. */ + public DirectUnboxedValueCodec directUnboxedValueCodec() { + return unboxedValueCodec instanceof DirectUnboxedValueCodec + ? (DirectUnboxedValueCodec) unboxedValueCodec + : null; + } + + /** Throws the cold failure used by interpreted and generated readers. */ + public Object rejectNullRead() { + throw new ForyJsonException("JSON creator property " + name + " is not nullable"); } /** Enforces the shared interpreted/generated null contract for a primitive creator argument. */ diff --git a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorInfo.java b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorInfo.java index 532ae9c940..78caef2226 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorInfo.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonCreatorInfo.java @@ -56,6 +56,7 @@ public final class JsonCreatorInfo { private final Class ownerType; private final Executable executable; + private final Executable invocationExecutable; private final JsonCreatorFieldInfo[] fields; private final Object[] defaults; private final long[] hashes; @@ -63,8 +64,15 @@ public final class JsonCreatorInfo { private final GeneratedJsonCodec generatedCodec; private final Method[] defaultMethods; private final MethodHandle[] defaultInvokers; + private final Constructor defaultConstructor; + private final MethodHandle defaultConstructorInvoker; + private final int[] defaultMaskBits; + private final boolean[] parameterNullable; + private final Object fixedInstance; private final String[] parameterNames; private final JsonFieldInfo[] deferredFields; + private final boolean[] deferredRequired; + private boolean[] nullCarriers; private static final Object MISSING = new Object(); public JsonCreatorInfo( @@ -73,59 +81,113 @@ public JsonCreatorInfo( JsonCreatorFieldInfo[] fields, Object[] defaults, GeneratedJsonCodec generatedCodec) { - this(ownerType, executable, fields, defaults, generatedCodec, null, null, null); + this( + ownerType, + executable, + executable, + fields, + defaults, + generatedCodec, + null, + null, + null, + null, + null, + null); } + /** Creates creator metadata with compiler-mask defaults supplied by a language object model. */ public JsonCreatorInfo( Class ownerType, Executable executable, + Executable invocationExecutable, JsonCreatorFieldInfo[] fields, Object[] defaults, GeneratedJsonCodec generatedCodec, Method[] defaultMethods, - String[] parameterNames) { + String[] parameterNames, + Constructor defaultConstructor, + int[] defaultMaskBits, + boolean[] parameterNullable) { this( ownerType, executable, + invocationExecutable, fields, defaults, generatedCodec, defaultMethods, parameterNames, + defaultConstructor, + defaultMaskBits, + parameterNullable, null); } + /** Creates a fixed-instance creator for a stateless language singleton. */ + public static JsonCreatorInfo fixedInstance(Class ownerType, Object instance) { + return new JsonCreatorInfo( + ownerType, + null, + null, + new JsonCreatorFieldInfo[0], + new Object[0], + null, + null, + null, + null, + null, + null, + instance); + } + + /** Returns whether this creator returns a pre-existing singleton instead of allocating. */ + @Internal + public boolean fixedInstance() { + return fixedInstance != null; + } + private JsonCreatorInfo( Class ownerType, Executable executable, + Executable invocationExecutable, JsonCreatorFieldInfo[] fields, Object[] defaults, GeneratedJsonCodec generatedCodec, Method[] defaultMethods, String[] parameterNames, - JsonFieldInfo[] deferredFields) { + Constructor defaultConstructor, + int[] defaultMaskBits, + boolean[] parameterNullable, + Object fixedInstance) { this.ownerType = ownerType; this.executable = executable; - this.deferredFields = deferredFields == null ? new JsonFieldInfo[0] : deferredFields; - if (this.deferredFields.length == 0) { - this.fields = fields; - } else { - this.fields = Arrays.copyOf(fields, fields.length + this.deferredFields.length); - for (int i = 0; i < this.deferredFields.length; i++) { - this.fields[fields.length + i] = this.deferredFields[i].asCreatorField(defaults.length + i); - } - } + this.invocationExecutable = invocationExecutable; + this.deferredFields = new JsonFieldInfo[0]; + this.deferredRequired = new boolean[0]; + this.fields = fields; this.defaults = defaults; this.generatedCodec = generatedCodec; + this.defaultConstructor = defaultConstructor; + this.defaultMaskBits = defaultMaskBits == null ? null : defaultMaskBits.clone(); + this.parameterNullable = parameterNullable == null ? null : parameterNullable.clone(); + this.fixedInstance = fixedInstance; this.parameterNames = parameterNames == null ? null : parameterNames.clone(); this.defaultMethods = defaultMethods == null ? null : defaultMethods.clone(); defaultInvokers = this.defaultMethods == null ? null : buildDefaultInvokers(ownerType, executable, this.defaultMethods); + defaultConstructorInvoker = + defaultConstructor == null + ? null + : buildInvoker( + defaultConstructor, + defaultConstructor.getParameterCount(), + defaultConstructor.getParameterCount()); invoker = - generatedCodec == null - ? buildInvoker(executable, defaults.length + this.deferredFields.length) + generatedCodec == null && invocationExecutable != null + ? buildInvoker(invocationExecutable, defaults.length, defaults.length) : null; hashes = new long[this.fields.length]; for (int i = 0; i < this.fields.length; i++) { @@ -136,15 +198,23 @@ private JsonCreatorInfo( private JsonCreatorInfo( JsonCreatorInfo source, JsonFieldInfo[] deferredFields, - JsonFieldInfo[] directDeferredFields) { + JsonFieldInfo[] directDeferredFields, + boolean[] deferredRequired) { ownerType = source.ownerType; executable = source.executable; + invocationExecutable = source.invocationExecutable; defaults = source.defaults; generatedCodec = source.generatedCodec; defaultMethods = source.defaultMethods; defaultInvokers = source.defaultInvokers; + defaultConstructor = source.defaultConstructor; + defaultConstructorInvoker = source.defaultConstructorInvoker; + defaultMaskBits = source.defaultMaskBits; + parameterNullable = source.parameterNullable; + fixedInstance = source.fixedInstance; parameterNames = source.parameterNames; this.deferredFields = deferredFields; + this.deferredRequired = deferredRequired; fields = Arrays.copyOf(source.fields, source.fields.length + directDeferredFields.length); for (int i = 0; i < directDeferredFields.length; i++) { int deferredIndex = identityIndex(deferredFields, directDeferredFields[i]); @@ -157,7 +227,8 @@ private JsonCreatorInfo( } invoker = generatedCodec == null - ? buildInvoker(executable, defaults.length + deferredFields.length) + ? buildInvoker( + invocationExecutable, defaults.length, defaults.length + deferredFields.length) : null; hashes = new long[fields.length]; for (int i = 0; i < fields.length; i++) { @@ -165,20 +236,19 @@ private JsonCreatorInfo( } } - /** Returns immutable construction metadata extended with post-constructor mutable properties. */ - public JsonCreatorInfo withDeferredFields(JsonFieldInfo[] fields) { - return withDeferredFields(fields, fields); - } - - /** Extends construction with all deferred properties and their directly named JSON subset. */ - public JsonCreatorInfo withDeferredFields(JsonFieldInfo[] fields, JsonFieldInfo[] directFields) { + /** Extends construction with deferred properties and required-presence flags. */ + public JsonCreatorInfo withDeferredFields( + JsonFieldInfo[] fields, JsonFieldInfo[] directFields, boolean[] required) { if (fields.length == 0) { return this; } + if (required.length != fields.length) { + throw new IllegalArgumentException("Deferred JSON required flags must match fields"); + } if (deferredFields.length != 0) { throw new IllegalStateException("Deferred JSON properties are already installed"); } - return new JsonCreatorInfo(this, fields.clone(), directFields.clone()); + return new JsonCreatorInfo(this, fields.clone(), directFields.clone(), required.clone()); } private static int identityIndex(JsonFieldInfo[] fields, JsonFieldInfo target) { @@ -194,6 +264,32 @@ public Executable executable() { return executable; } + /** Returns the exact full JVM invocation target selected during cold model validation. */ + @Internal + public Executable invocationExecutable() { + return invocationExecutable; + } + + /** Returns the exact Kotlin compiler-default constructor, or {@code null}. */ + @Internal + public Constructor defaultConstructor() { + return defaultConstructor; + } + + /** Returns the compiler-default mask bit for one logical argument, or {@code -1}. */ + @Internal + public int defaultMaskBit(int index) { + return defaultMaskBits == null ? -1 : defaultMaskBits[index]; + } + + /** Returns the number of compiler-default mask words in the exact target descriptor. */ + @Internal + public int defaultMaskCount() { + return defaultConstructor == null + ? 0 + : defaultConstructor.getParameterCount() - defaults.length - 1; + } + public JsonCreatorFieldInfo[] fields() { return fields; } @@ -208,6 +304,12 @@ public int deferredSlot(int index) { return defaults.length + index; } + /** Returns whether one deferred property must be present before construction. */ + @Internal + public boolean deferredRequired(int index) { + return deferredRequired[index]; + } + /** Returns the number of arguments passed to the constructor or factory. */ public int argumentCount() { return defaults.length; @@ -220,7 +322,7 @@ public boolean hasDeferredFields() { public Object[] newArguments() { Object[] arguments = Arrays.copyOf(defaults, defaults.length + deferredFields.length); - if (defaultInvokers != null) { + if (defaultInvokers != null || defaultMaskBits != null || parameterNullable != null) { Arrays.fill(arguments, 0, defaults.length, MISSING); } if (deferredFields.length != 0) { @@ -243,11 +345,21 @@ public int index(long hash) { public void resolveTypes(JsonTypeResolver resolver) { for (JsonCreatorFieldInfo field : fields) { field.resolveType(resolver); + if (field.materializesNullCarrier()) { + if (nullCarriers == null) { + nullCarriers = new boolean[defaults.length]; + } + nullCarriers[field.argumentIndex()] = true; + } } } public Object create(Object[] arguments) { - prepareArguments(arguments); + if (fixedInstance != null) { + return fixedInstance; + } + validateLanguageArguments(arguments); + validateDeferredArguments(arguments); Object value; if (generatedCodec != null) { try { @@ -258,24 +370,14 @@ public Object create(Object[] arguments) { } throw new ForyJsonException("JSON creator failed for " + ownerType.getName(), cause); } + } else if (defaultConstructor != null) { + value = invokeDefaultConstructor(arguments); } else if (invoker != null) { + prepareArguments(arguments); value = invoke(arguments); } else { - try { - value = - executable instanceof Constructor - ? ((Constructor) executable).newInstance(arguments) - : ((Method) executable).invoke(null, arguments); - value = requireResult(value); - } catch (InstantiationException | IllegalAccessException e) { - throw new ForyJsonException("Failed to invoke JSON creator for " + ownerType.getName(), e); - } catch (InvocationTargetException e) { - Throwable cause = e.getCause(); - if (cause instanceof Error) { - throw (Error) cause; - } - throw new ForyJsonException("JSON creator failed for " + ownerType.getName(), cause); - } + prepareArguments(arguments); + value = invokeReflectiveCreator(arguments); } applyDeferred(value, arguments); return value; @@ -284,13 +386,17 @@ public Object create(Object[] arguments) { /** Returns whether generated readers must track the presence of constructor arguments. */ @Internal public boolean tracksArgumentPresence() { - return defaultInvokers != null || deferredFields.length != 0; + return defaultInvokers != null + || defaultMaskBits != null + || parameterNullable != null + || deferredFields.length != 0; } /** Returns whether one constructor argument has a language-defined default. */ @Internal public boolean hasDefault(int index) { - return defaultInvokers != null && defaultInvokers[index] != null; + return defaultInvokers != null && defaultInvokers[index] != null + || defaultMaskBits != null && defaultMaskBits[index] >= 0; } /** Returns one prevalidated language-defined constructor default method. */ @@ -302,7 +408,7 @@ public Method defaultMethod(int index) { /** Evaluates one prevalidated language-defined constructor default. */ @Internal public Object defaultValue(int index, Object[] arguments) { - MethodHandle invoker = defaultInvokers[index]; + MethodHandle invoker = defaultInvokers == null ? null : defaultInvokers[index]; if (invoker == null) { throw missingArgument(index); } @@ -325,6 +431,22 @@ public ForyJsonException missingArgument(int index) { "Missing required JSON constructor property " + name + " for " + ownerType.getName()); } + /** Creates the missing-required-deferred-property failure outside generated common paths. */ + @Internal + public ForyJsonException missingDeferred(int index) { + return new ForyJsonException( + "Missing required deferred JSON property " + + deferredFields[index].name() + + " for " + + ownerType.getName()); + } + + /** Throws the cold missing-deferred failure from a generated presence branch. */ + @Internal + public void requireDeferred(int index) { + throw missingDeferred(index); + } + /** Returns whether one construction-workspace slot has not been read. */ @Internal public static boolean isMissing(Object value) { @@ -342,6 +464,124 @@ private void prepareArguments(Object[] arguments) { } } + private void validateLanguageArguments(Object[] arguments) { + if (parameterNullable == null) { + return; + } + for (int i = 0; i < defaults.length; i++) { + Object argument = arguments[i]; + if (argument == MISSING) { + if (!hasDefault(i)) { + throw missingArgument(i); + } + } else if (argument == null && !parameterNullable[i] && !materializesNullCarrier(i)) { + throw nullArgument(i); + } + } + } + + private void validateDeferredArguments(Object[] arguments) { + for (int i = 0; i < deferredRequired.length; i++) { + if (deferredRequired[i] && arguments[defaults.length + i] == MISSING) { + throw missingDeferred(i); + } + } + } + + private Object invokeDefaultConstructor(Object[] arguments) { + int parameterCount = defaults.length; + int maskCount = defaultConstructor.getParameterCount() - parameterCount - 1; + boolean useDefault = false; + for (int i = 0; i < parameterCount; i++) { + Object argument = arguments[i]; + if (argument == MISSING) { + int bit = defaultMaskBits[i]; + if (bit < 0) { + throw missingArgument(i); + } + useDefault = true; + } else if (argument == null + && parameterNullable != null + && !parameterNullable[i] + && !materializesNullCarrier(i)) { + throw nullArgument(i); + } + } + if (!useDefault) { + return invoker == null ? invokeReflectiveCreator(arguments) : invoke(arguments); + } + int[] masks = new int[maskCount]; + Object[] invocation = new Object[defaultConstructor.getParameterCount()]; + for (int i = 0; i < parameterCount; i++) { + Object argument = arguments[i]; + invocation[i] = argument == MISSING ? defaults[i] : argument; + if (argument == MISSING) { + int bit = defaultMaskBits[i]; + masks[bit >>> 5] |= 1 << (bit & 31); + } + } + for (int i = 0; i < maskCount; i++) { + invocation[parameterCount + i] = Integer.valueOf(masks[i]); + } + invocation[invocation.length - 1] = null; + return invokeDefaultTarget(invocation); + } + + private Object invokeDefaultTarget(Object[] arguments) { + if (defaultConstructorInvoker != null) { + try { + return requireResult((Object) defaultConstructorInvoker.invokeExact(arguments)); + } catch (Throwable cause) { + if (cause instanceof Error) { + throw (Error) cause; + } + throw new ForyJsonException("JSON creator failed for " + ownerType.getName(), cause); + } + } + return invokeReflectiveTarget(defaultConstructor, arguments); + } + + private Object invokeReflectiveCreator(Object[] workspace) { + int logicalCount = defaults.length; + int invocationCount = invocationExecutable.getParameterCount(); + Object[] arguments = workspace; + if (workspace.length != invocationCount || invocationCount != logicalCount) { + // The workspace may append deferred properties, while an accessibility constructor may + // append its language marker. Only logical creator arguments belong to the invocation. + arguments = new Object[invocationCount]; + System.arraycopy(workspace, 0, arguments, 0, logicalCount); + } + return invokeReflectiveTarget(invocationExecutable, arguments); + } + + private Object invokeReflectiveTarget(Executable target, Object[] arguments) { + try { + Object value = + target instanceof Constructor + ? ((Constructor) target).newInstance(arguments) + : ((Method) target).invoke(null, arguments); + return requireResult(value); + } catch (InstantiationException | IllegalAccessException e) { + throw new ForyJsonException("Failed to invoke JSON creator for " + ownerType.getName(), e); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof Error) { + throw (Error) cause; + } + throw new ForyJsonException("JSON creator failed for " + ownerType.getName(), cause); + } + } + + private ForyJsonException nullArgument(int index) { + String name = parameterNames == null ? Integer.toString(index) : parameterNames[index]; + return new ForyJsonException( + "JSON constructor property " + name + " is not nullable for " + ownerType.getName()); + } + + private boolean materializesNullCarrier(int index) { + return nullCarriers != null && nullCarriers[index]; + } + private void applyDeferred(Object value, Object[] arguments) { for (int i = 0; i < deferredFields.length; i++) { Object deferred = arguments[defaults.length + i]; @@ -437,22 +677,27 @@ private Object requireResult(Object value) { return value; } - private static MethodHandle buildInvoker(Executable executable, int workspaceSize) { + private static MethodHandle buildInvoker( + Executable executable, int logicalCount, int workspaceSize) { if (AndroidSupport.IS_ANDROID) { // Android has no supported trusted MethodHandle lookup. Creator shape validation guarantees // a public executable; accessibility is needed only when its declaring class is non-public. executable.setAccessible(true); return null; } - int parameterCount = executable.getParameterCount(); - if (workspaceSize == parameterCount) { - return creatorHandle(executable); - } MethodHandle target = GraalvmSupport.IN_GRAALVM_NATIVE_IMAGE ? nativeCreatorHandles(executable).target : creatorTarget(executable); - return workspaceInvoker(target, executable.getParameterTypes()); + Class[] parameterTypes = executable.getParameterTypes(); + if (parameterTypes.length == logicalCount + 1 && !parameterTypes[logicalCount].isPrimitive()) { + target = MethodHandles.insertArguments(target, logicalCount, new Object[] {null}); + parameterTypes = Arrays.copyOf(parameterTypes, logicalCount); + } + if (workspaceSize == parameterTypes.length) { + return arrayInvoker(target, parameterTypes.length); + } + return workspaceInvoker(target, parameterTypes); } /** Returns the array-argument invocation handle for one JSON creator. */ diff --git a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonFieldInfo.java b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonFieldInfo.java index 9a20c92539..98c3901feb 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonFieldInfo.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/meta/JsonFieldInfo.java @@ -29,7 +29,10 @@ import org.apache.fory.json.annotation.JsonCodec; import org.apache.fory.json.annotation.JsonFormat; import org.apache.fory.json.codec.CodecUtils; +import org.apache.fory.json.codec.DirectUnboxedValueCodec; import org.apache.fory.json.codec.JsonValueCodec; +import org.apache.fory.json.codec.TransparentUnboxedValueCodec; +import org.apache.fory.json.codec.UnboxedValueCodec; import org.apache.fory.json.reader.JsonReader; import org.apache.fory.json.reader.Latin1JsonReader; import org.apache.fory.json.reader.Utf16JsonReader; @@ -76,6 +79,7 @@ public final class JsonFieldInfo { private static final int KIND_CUSTOM_PRIMITIVE = 15; private static final int KIND_RAW_STRING = 16; private static final int KIND_NULL = 17; + private static final int KIND_UNBOXED = 18; private static final int WRITE_NULL_MASK = Integer.MIN_VALUE; private static final int REQUIRE_NON_NULL_MASK = 1 << 30; private static final int READ_INDEX_MASK = REQUIRE_NON_NULL_MASK - 1; @@ -87,17 +91,23 @@ public final class JsonFieldInfo { private final Method writeGetter; private final Field readField; private final Method readSetter; + private final TypeRef writeTypeRef; private final Type writeType; private final Class writeRawType; + private final TypeRef readTypeRef; private final Type readType; private final Class readRawType; private final JsonCodec codecAnnotation; private final Class> valueCodecClass; private final JsonFormat formatAnnotation; + private final boolean selectedCodec; + private final boolean writeUnboxedRequired; + private final boolean readUnboxedRequired; private JsonFieldKind writeKind; private JsonFieldKind readKind; private int writeKindId; private int readPrimitiveKindId; + private final int readCarrierKindId; private final JsonFieldAccessor writeAccessor; private final JsonFieldAccessor readAccessor; private final Type writeMapValueType; @@ -139,6 +149,10 @@ public final class JsonFieldInfo { private int readIndexAndWriteNull; private JsonTypeInfo writeTypeInfo; private JsonTypeInfo readTypeInfo; + private JsonTypeInfo writeOccurrenceTypeInfo; + private JsonTypeInfo readOccurrenceTypeInfo; + private UnboxedValueCodec writeUnboxedValueCodec; + private UnboxedValueCodec readUnboxedValueCodec; public JsonFieldInfo( String name, @@ -150,7 +164,7 @@ public JsonFieldInfo( JsonFieldAccessor writeAccessor, JsonFieldAccessor readAccessor, TypeRef ownerType, - Type objectModelType, + TypeRef objectModelType, JsonCodec codecAnnotation, Class> valueCodecClass, JsonFormat formatAnnotation, @@ -166,24 +180,37 @@ public JsonFieldInfo( this.readSetter = readSetter; Class writeFallback = writeRawType(writeField, writeGetter); Class readFallback = readRawType(readField, readSetter); - Type resolvedObjectModelType = resolveType(ownerType, objectModelType); - this.writeType = + TypeRef resolvedObjectModelType = objectModelType; + writeUnboxedRequired = + requiresCarrier( + ownerType, writeType(writeField, writeGetter), writeFallback, resolvedObjectModelType); + readUnboxedRequired = + requiresCarrier( + ownerType, readType(readField, readSetter), readFallback, resolvedObjectModelType); + writeTypeRef = writeFallback == null ? null : resolvedObjectModelType == null - ? resolveType(ownerType, writeType(writeField, writeGetter)) + ? resolveTypeRef(ownerType, writeType(writeField, writeGetter)) : resolvedObjectModelType; - this.writeRawType = semanticRawType(writeType, writeFallback); - this.readType = + writeType = writeTypeRef == null ? null : writeTypeRef.getType(); + this.writeRawType = + writeTypeRef == null + ? null + : writeUnboxedRequired ? writeFallback : writeTypeRef.getRawType(); + readTypeRef = readFallback == null ? null : resolvedObjectModelType == null - ? resolveType(ownerType, readType(readField, readSetter)) + ? resolveTypeRef(ownerType, readType(readField, readSetter)) : resolvedObjectModelType; - this.readRawType = semanticRawType(readType, readFallback); + readType = readTypeRef == null ? null : readTypeRef.getType(); + this.readRawType = + readTypeRef == null ? null : readUnboxedRequired ? readFallback : readTypeRef.getRawType(); this.codecAnnotation = codecAnnotation; this.valueCodecClass = valueCodecClass; this.formatAnnotation = formatAnnotation; + selectedCodec = codecAnnotation != null || valueCodecClass != null || formatAnnotation != null; this.writeAccessor = writeAccessor; this.readAccessor = readAccessor; writeKind = writeRawType == null ? null : kind(writeRawType); @@ -195,6 +222,7 @@ public JsonFieldInfo( ? KIND_NULL : (rawValue ? KIND_RAW_STRING : kindId(writeKind)); readPrimitiveKindId = primitiveKindId(readRawType, readKind); + readCarrierKindId = readRawType == null ? 0 : primitiveKindId(readRawType, kind(readRawType)); Type writeElementType = writeKind == JsonFieldKind.COLLECTION ? CodecUtils.elementType(writeType) : null; writeMapValueType = writeKind == JsonFieldKind.MAP ? CodecUtils.mapValueType(writeType) : null; @@ -287,7 +315,7 @@ public JsonFieldInfo withName(String transformedName, TypeRef ownerType) { writeAccessor, readAccessor, ownerType, - writeType != null ? writeType : readType, + writeTypeRef != null ? writeTypeRef : readTypeRef, codecAnnotation, valueCodecClass, formatAnnotation, @@ -308,10 +336,90 @@ public boolean writeNull() { return readIndexAndWriteNull < 0; } + /** Makes a nullable language-model property explicit so output stays reconstructible. */ + public void includeNullWrite() { + readIndexAndWriteNull |= WRITE_NULL_MASK; + } + + /** Returns whether this field carries explicit Kotlin-style occurrence nullability. */ + public boolean hasOccurrenceNullability() { + TypeRef typeRef = writeTypeRef != null ? writeTypeRef : readTypeRef; + return typeRef != null && typeRef.getTypeExtMeta() != null; + } + + /** Returns the cold-declared occurrence nullability. */ + public boolean occurrenceNullable() { + TypeRef typeRef = writeTypeRef != null ? writeTypeRef : readTypeRef; + return typeRef != null + && typeRef.getTypeExtMeta() != null + && typeRef.getTypeExtMeta().nullable(); + } + + /** Returns whether the logical occurrence itself owns a nullable transparent representation. */ + public boolean occurrenceWrapsNull() { + TypeRef typeRef = writeTypeRef != null ? writeTypeRef : readTypeRef; + return typeRef != null + && typeRef.getTypeExtMeta() != null + && typeRef.getTypeExtMeta().nullableWrapper(); + } + + /** Returns whether phase-two binding must prove an exact unboxed logical codec. */ + public boolean requiresUnboxedBinding() { + return writeUnboxedRequired || readUnboxedRequired; + } + + /** Returns whether the write source uses an exact unboxed carrier operation. */ + public boolean writesUnboxedValue() { + return writeUnboxedValueCodec != null; + } + + /** Returns whether the read sink uses an exact unboxed carrier operation. */ + public boolean readsUnboxedValue() { + return readUnboxedValueCodec != null; + } + + /** Returns the cold-bound write-side unboxed operation. */ + public UnboxedValueCodec writeUnboxedValueCodec() { + return writeUnboxedValueCodec; + } + + /** Returns the cold-bound read-side unboxed operation. */ + public UnboxedValueCodec readUnboxedValueCodec() { + return readUnboxedValueCodec; + } + + /** Returns the write-side exact transparent carrier operation, or {@code null}. */ + public TransparentUnboxedValueCodec writeTransparentUnboxedValueCodec() { + return writeUnboxedValueCodec instanceof TransparentUnboxedValueCodec + ? (TransparentUnboxedValueCodec) writeUnboxedValueCodec + : null; + } + + /** Returns the read-side exact transparent carrier operation, or {@code null}. */ + public TransparentUnboxedValueCodec readTransparentUnboxedValueCodec() { + return readUnboxedValueCodec instanceof TransparentUnboxedValueCodec + ? (TransparentUnboxedValueCodec) readUnboxedValueCodec + : null; + } + + /** Returns the write-side exact semantic leaf carrier operation, or {@code null}. */ + public DirectUnboxedValueCodec writeDirectUnboxedValueCodec() { + return writeUnboxedValueCodec instanceof DirectUnboxedValueCodec + ? (DirectUnboxedValueCodec) writeUnboxedValueCodec + : null; + } + + /** Returns the read-side exact semantic leaf carrier operation, or {@code null}. */ + public DirectUnboxedValueCodec readDirectUnboxedValueCodec() { + return readUnboxedValueCodec instanceof DirectUnboxedValueCodec + ? (DirectUnboxedValueCodec) readUnboxedValueCodec + : null; + } + /** Marks an omitted null as invalid because construction requires this property. */ public void requireNonNullWrite() { - if (writeNull() || writeRawType == null || writeRawType.isPrimitive()) { - throw new IllegalStateException("Only omitted reference properties can require a value"); + if (writeRawType == null || writeRawType.isPrimitive()) { + throw new IllegalStateException("Only reference properties can require a value"); } readIndexAndWriteNull |= REQUIRE_NON_NULL_MASK; } @@ -415,47 +523,124 @@ private static Class fieldRawType(Field field) { return field == null ? null : field.getType(); } - private static Type resolveType(TypeRef ownerType, Type type) { - return type == null ? null : ownerType.resolveType(type).getType(); + private static TypeRef resolveTypeRef(TypeRef ownerType, Type type) { + return type == null ? null : ownerType.resolveType(type); } - private static Class semanticRawType(Type type, Class fallback) { - return type == null ? null : CodecUtils.rawType(type, fallback); + private static boolean requiresCarrier( + TypeRef ownerType, Type memberType, Class carrier, TypeRef logicalType) { + if (memberType == null || carrier == null || logicalType == null) { + return false; + } + boolean requiresCarrier = UnboxedValueCodec.requiresCarrier(carrier, logicalType); + if (!requiresCarrier) { + return false; + } + // A same-carrier semantic primitive still needs its canonical representation operation. An + // erased generic Object occurrence remains boxed when its substituted member type already + // matches the logical type; a true lowered value-class mismatch remains phase-two bound. + return carrier == logicalType.getRawType() + || !ownerType.resolveType(memberType).getType().equals(logicalType.getType()); } public void resolveTypes(JsonTypeResolver typeResolver) { - Type codecType = writeType == null ? readType : writeType; - Class codecRawType = writeRawType == null ? readRawType : writeRawType; - JsonTypeInfo resolvedTypeInfo = + TypeRef codecType = writeTypeRef == null ? readTypeRef : writeTypeRef; + if (selectedCodec && (writeUnboxedRequired || readUnboxedRequired)) { + throw new ForyJsonException( + "JSON property " + + name + + " cannot select a codec or format for the unboxed logical type " + + codecType); + } + JsonTypeInfo selectedTypeInfo = codecAnnotation != null - ? typeResolver.getTypeInfo(codecType, codecRawType, codecAnnotation) + ? typeResolver.getTypeInfo(codecType, codecAnnotation) : valueCodecClass != null - ? typeResolver.getTypeInfo(codecType, codecRawType, valueCodecClass) + ? typeResolver.getTypeInfo(codecType, valueCodecClass) : formatAnnotation != null - ? typeResolver.getTypeInfo(codecType, codecRawType, formatAnnotation) + ? typeResolver.getTypeInfo(codecType, formatAnnotation) : null; boolean rawString = writeKindId == KIND_RAW_STRING; if (writeRawType != null) { - writeTypeInfo = - resolvedTypeInfo == null - ? typeResolver.getTypeInfo(writeType, writeRawType) - : resolvedTypeInfo; + if (writeUnboxedRequired) { + JsonTypeInfo canonical = typeResolver.getTypeInfo(writeTypeRef); + writeUnboxedValueCodec = requireUnboxed(canonical, writeRawType, "write"); + writeOccurrenceTypeInfo = canonical; + if (writeUnboxedValueCodec instanceof DirectUnboxedValueCodec) { + writeTypeInfo = canonical; + } else if (writeUnboxedValueCodec instanceof TransparentUnboxedValueCodec) { + writeTypeInfo = ((TransparentUnboxedValueCodec) writeUnboxedValueCodec).valueTypeInfo(); + } else { + throw unsupportedUnboxed(writeRawType, "write"); + } + } else { + writeTypeInfo = + selectedTypeInfo == null ? typeResolver.getTypeInfo(writeTypeRef) : selectedTypeInfo; + writeOccurrenceTypeInfo = writeTypeInfo; + } if (!rawString && writeRawType != void.class) { writeKind = writeTypeInfo.kind(); - writeKindId = kindId(writeKind); + writeKindId = writeUnboxedValueCodec == null ? kindId(writeKind) : KIND_UNBOXED; + } + if (writeUnboxedValueCodec != null + && !writeOccurrenceTypeInfo.nullable() + && !writeOccurrenceTypeInfo.rejectsNull()) { + includeNullWrite(); } } if (readRawType != null) { - readTypeInfo = - resolvedTypeInfo == null - ? typeResolver.getTypeInfo(readType, readRawType) - : resolvedTypeInfo; + if (readUnboxedRequired) { + JsonTypeInfo canonical = typeResolver.getTypeInfo(readTypeRef); + readUnboxedValueCodec = requireUnboxed(canonical, readRawType, "read"); + readOccurrenceTypeInfo = canonical; + if (readUnboxedValueCodec instanceof DirectUnboxedValueCodec) { + readTypeInfo = canonical; + } else if (readUnboxedValueCodec instanceof TransparentUnboxedValueCodec) { + readTypeInfo = ((TransparentUnboxedValueCodec) readUnboxedValueCodec).valueTypeInfo(); + } else { + throw unsupportedUnboxed(readRawType, "read"); + } + } else { + readTypeInfo = + selectedTypeInfo == null ? typeResolver.getTypeInfo(readTypeRef) : selectedTypeInfo; + readOccurrenceTypeInfo = readTypeInfo; + } readKind = readTypeInfo.kind(); - readPrimitiveKindId = primitiveKindId(readRawType, readKind); + readPrimitiveKindId = + readUnboxedValueCodec == null ? primitiveKindId(readRawType, readKind) : KIND_UNBOXED; } } + private UnboxedValueCodec requireUnboxed( + JsonTypeInfo canonical, Class carrier, String direction) { + UnboxedValueCodec operation = canonical.unboxedValueCodec(); + if (operation == null || operation.carrierType() != carrier) { + throw new ForyJsonException( + "JSON property " + + name + + " has no exact " + + direction + + " unboxed carrier operation for " + + carrier.getName()); + } + return operation; + } + + private ForyJsonException unsupportedUnboxed(Class carrier, String direction) { + return new ForyJsonException( + "JSON property " + + name + + " has an unsupported " + + direction + + " unboxed carrier capability for " + + carrier.getName()); + } + public void readLatin1(Latin1JsonReader reader, Object object) { + if (readOccurrenceNull(reader)) { + readAccessor.putObject(object, null); + return; + } switch (readPrimitiveKindId) { case KIND_BOOLEAN: rejectPrimitiveNull(reader); @@ -493,17 +678,30 @@ public void readLatin1(Latin1JsonReader reader, Object object) { readAccessor.putObject( object, requirePrimitive(readTypeInfo.latin1Reader().readLatin1(reader))); return; + case KIND_UNBOXED: + putCarrier(object, readUnboxedLatin1(reader)); + return; default: readAccessor.putObject(object, readTypeInfo.latin1Reader().readLatin1(reader)); } } public Object readLatin1Value(Latin1JsonReader reader) { + if (readOccurrenceNull(reader)) { + return null; + } + if (readUnboxedValueCodec != null) { + return readUnboxedLatin1(reader); + } Object value = readTypeInfo.latin1Reader().readLatin1(reader); return readPrimitiveKindId == KIND_CUSTOM_PRIMITIVE ? requirePrimitive(value) : value; } public void readUtf16(Utf16JsonReader reader, Object object) { + if (readOccurrenceNull(reader)) { + readAccessor.putObject(object, null); + return; + } switch (readPrimitiveKindId) { case KIND_BOOLEAN: rejectPrimitiveNull(reader); @@ -541,17 +739,30 @@ public void readUtf16(Utf16JsonReader reader, Object object) { readAccessor.putObject( object, requirePrimitive(readTypeInfo.utf16Reader().readUtf16(reader))); return; + case KIND_UNBOXED: + putCarrier(object, readUnboxedUtf16(reader)); + return; default: readAccessor.putObject(object, readTypeInfo.utf16Reader().readUtf16(reader)); } } public Object readUtf16Value(Utf16JsonReader reader) { + if (readOccurrenceNull(reader)) { + return null; + } + if (readUnboxedValueCodec != null) { + return readUnboxedUtf16(reader); + } Object value = readTypeInfo.utf16Reader().readUtf16(reader); return readPrimitiveKindId == KIND_CUSTOM_PRIMITIVE ? requirePrimitive(value) : value; } public void readUtf8(Utf8JsonReader reader, Object object) { + if (readOccurrenceNull(reader)) { + readAccessor.putObject(object, null); + return; + } switch (readPrimitiveKindId) { case KIND_BOOLEAN: rejectPrimitiveNull(reader); @@ -589,30 +800,72 @@ public void readUtf8(Utf8JsonReader reader, Object object) { readAccessor.putObject( object, requirePrimitive(readTypeInfo.utf8Reader().readUtf8(reader))); return; + case KIND_UNBOXED: + putCarrier(object, readUnboxedUtf8(reader)); + return; default: readAccessor.putObject(object, readTypeInfo.utf8Reader().readUtf8(reader)); } } public Object readUtf8Value(Utf8JsonReader reader) { + if (readOccurrenceNull(reader)) { + return null; + } + if (readUnboxedValueCodec != null) { + return readUnboxedUtf8(reader); + } Object value = readTypeInfo.utf8Reader().readUtf8(reader); return readPrimitiveKindId == KIND_CUSTOM_PRIMITIVE ? requirePrimitive(value) : value; } + private boolean readOccurrenceNull(JsonReader reader) { + if (!readOccurrenceTypeInfo.nullable() && !readOccurrenceTypeInfo.rejectsNull()) { + return false; + } + if (!reader.tryReadNull()) { + return false; + } + if (readOccurrenceTypeInfo.rejectsNull()) { + rejectNullRead(); + } + return true; + } + + /** Throws the cold failure used by interpreted and generated readers. */ + public Object rejectNullRead() { + throw new ForyJsonException("JSON property " + name + " is not nullable"); + } + + /** Returns the cold-bound read-side null action for generated specialization. */ + public boolean rejectsNullRead() { + return readOccurrenceTypeInfo.rejectsNull(); + } + + /** Returns whether generated readers must materialize an outer null for this occurrence. */ + public boolean nullableRead() { + return readOccurrenceTypeInfo.nullable(); + } + /** Returns constructor-workspace metadata for this deferred mutable property. */ public JsonCreatorFieldInfo asCreatorField(int argumentIndex) { return new JsonCreatorFieldInfo( name, argumentIndex, - readType, + readTypeRef, readRawType, codecAnnotation, valueCodecClass, - formatAnnotation); + formatAnnotation, + readUnboxedRequired); } /** Assigns one already decoded value through this property's validated read sink. */ public void putValue(Object object, Object value) { + if (readUnboxedValueCodec != null) { + putCarrier(object, value); + return; + } switch (readPrimitiveKindId) { case KIND_BOOLEAN: readAccessor.putBoolean(object, ((Boolean) requirePrimitive(value)).booleanValue()); @@ -646,6 +899,49 @@ public void putValue(Object object, Object value) { } } + private void putCarrier(Object object, Object value) { + switch (readCarrierKindId) { + case KIND_BOOLEAN: + readAccessor.putBoolean(object, ((Boolean) requirePrimitive(value)).booleanValue()); + return; + case KIND_BYTE: + readAccessor.putByte(object, ((Byte) requirePrimitive(value)).byteValue()); + return; + case KIND_SHORT: + readAccessor.putShort(object, ((Short) requirePrimitive(value)).shortValue()); + return; + case KIND_INT: + readAccessor.putInt(object, ((Integer) requirePrimitive(value)).intValue()); + return; + case KIND_LONG: + readAccessor.putLong(object, ((Long) requirePrimitive(value)).longValue()); + return; + case KIND_FLOAT: + readAccessor.putFloat(object, ((Float) requirePrimitive(value)).floatValue()); + return; + case KIND_DOUBLE: + readAccessor.putDouble(object, ((Double) requirePrimitive(value)).doubleValue()); + return; + case KIND_CHAR: + readAccessor.putChar(object, ((Character) requirePrimitive(value)).charValue()); + return; + default: + readAccessor.putObject(object, value); + } + } + + private Object readUnboxedLatin1(Latin1JsonReader reader) { + return readUnboxedValueCodec.readLatin1Carrier(reader); + } + + private Object readUnboxedUtf16(Utf16JsonReader reader) { + return readUnboxedValueCodec.readUtf16Carrier(reader); + } + + private Object readUnboxedUtf8(Utf8JsonReader reader) { + return readUnboxedValueCodec.readUtf8Carrier(reader); + } + // A custom codec may return null, but primitive storage has no nullable representation. Keep // this check at the field owner; built-in primitive fast paths never call it. public Object requirePrimitive(Object value) { @@ -848,6 +1144,8 @@ public boolean writeString(StringJsonWriter writer, Object object, int index) { return writeStringMap(writer, object, index); case KIND_OBJECT: return writeStringPojo(writer, object, index); + case KIND_UNBOXED: + return writeStringUnboxed(writer, object, index); default: return writeStringObject(writer, object, index); } @@ -928,6 +1226,8 @@ public boolean writeUtf8(Utf8JsonWriter writer, Object object, int index) { return writeUtf8Map(writer, object, index); case KIND_OBJECT: return writeUtf8Pojo(writer, object, index); + case KIND_UNBOXED: + return writeUtf8Unboxed(writer, object, index); default: return writeUtf8Object(writer, object, index); } @@ -943,6 +1243,16 @@ private boolean writeStringObject(StringJsonWriter writer, Object object, int in return true; } + private boolean writeStringUnboxed(StringJsonWriter writer, Object object, int index) { + Object carrier = writeAccessor.getObject(object); + if (carrier == null && !writeNull()) { + return omitNullValue(); + } + writer.writeFieldName(this, index); + writeUnboxedValueCodec.writeStringCarrier(writer, carrier); + return true; + } + private boolean writeStringScalar(StringJsonWriter writer, Object object, int index) { Object value = writeAccessor.getObject(object); if (value == null && !writeNull()) { @@ -1183,6 +1493,16 @@ private boolean writeUtf8Object(Utf8JsonWriter writer, Object object, int index) return true; } + private boolean writeUtf8Unboxed(Utf8JsonWriter writer, Object object, int index) { + Object carrier = writeAccessor.getObject(object); + if (carrier == null && !writeNull()) { + return omitNullValue(); + } + writer.writeFieldName(this, index); + writeUnboxedValueCodec.writeUtf8Carrier(writer, carrier); + return true; + } + private boolean writeUtf8String(Utf8JsonWriter writer, Object object, int index) { String value = (String) writeAccessor.getObject(object); if (value == null && !writeNull()) { diff --git a/java/fory-json/src/main/java/org/apache/fory/json/reader/JsonReader.java b/java/fory-json/src/main/java/org/apache/fory/json/reader/JsonReader.java index 8384c6361b..8c80c38298 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/reader/JsonReader.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/reader/JsonReader.java @@ -44,13 +44,14 @@ import org.apache.fory.json.meta.JsonFieldTable; import org.apache.fory.json.meta.JsonSubtypeScanInfo; import org.apache.fory.json.resolver.JsonTypeResolver; +import org.apache.fory.memory.NativeByteOrder; /** * Representation-neutral JSON cursor and common scalar parsing owner. * *

The base class retains the resolver used by dynamic codecs, the current code-unit position, - * configured and current container depth, reusable creator and numeric workspaces, and an ASCII - * token view. Concrete readers own input storage, string decoding, field-name probes, and direct + * configured and current container depth, reusable creator and numeric workspaces, and a quoted + * text view. Concrete readers own input storage, string decoding, field-name probes, and direct * primitive numeric fast paths for their representation. * *

Primitive {@code int}, {@code long}, {@code float}, and {@code double} parsing does not @@ -249,6 +250,9 @@ && matchesScannedString(fieldStart, fieldEnd, info.property())) { protected abstract boolean matchesScannedString(int start, int end, String expected); + /** Decodes one validated quoted token body into the concrete reader's reusable string scratch. */ + protected abstract CharSequence decodeQuotedText(int start, int end); + /** * Reads one wrapper subtype name from a fixed validated table without materializing a String. * @@ -257,7 +261,7 @@ && matchesScannedString(fieldStart, fieldEnd, info.property())) { */ public abstract int readSubtypeName(JsonSubtypeScanInfo info); - private final AsciiStringView asciiStringView = new AsciiStringView(this); + private final QuotedTextView quotedTextView = new QuotedTextView(this); private final Object[] creatorArguments = new Object[1]; // Primitive floating fallback reuses this exact-boundary workspace. Reader construction is the // cold owner so the first precision-sensitive scalar cannot allocate on the numeric hot path. @@ -381,6 +385,7 @@ public final String materializeFieldName(int start) { protected final void reset() { depth = 0; remainingGraphMemoryBytes = config.maxGraphMemoryBytes(); + quotedTextView.clear(); } /** @@ -450,18 +455,36 @@ public String readNullableString() { } /** - * Reads nullable date/time text without materializing an ASCII string. + * Reads nullable quoted text without materializing a String. * - *

The returned view is reused by this reader and must be parsed before another reader method - * is called. Escaped or non-ASCII input falls back to an ordinary String. + *

The returned view is reused by this reader and is valid only until the next reader + * operation. Canonical unescaped ASCII points directly at the input. Escaped and non-ASCII text + * is decoded into the concrete reader's bounded reusable string scratch. */ - @Internal - public final CharSequence readDateTimeText() { - if (tryReadNull()) { + public final CharSequence readQuotedText() { + if (tryReadNullToken()) { return null; } - CharSequence value = tryReadAsciiStringView(); - return value == null ? readString() : value; + return readQuotedTextValue(); + } + + /** Reads a quoted-text value after its owner has already rejected or consumed {@code null}. */ + protected final CharSequence readQuotedTextValue() { + CharSequence value = tryReadQuotedTextView(); + if (value != null) { + return value; + } + int tokenStart = position; + int tokenEnd = scanStringEnd(tokenStart); + value = decodeQuotedText(tokenStart + 1, tokenEnd - 1); + position = tokenEnd; + return value; + } + + /** Publishes decoded Latin1 or native-order UTF16 scratch through the reusable quoted view. */ + protected final CharSequence decodedQuotedText(byte[] bytes, int length, boolean utf16) { + quotedTextView.reset(bytes, length, utf16); + return quotedTextView; } /** Reads a nullable Base64 JSON string directly into its decoded bytes. */ @@ -804,6 +827,58 @@ public final long readLong() { return negative ? result : -result; } + /** Reads one canonical unsigned 32-bit JSON integer and returns its raw bits. */ + public final int readUnsignedInt() { + long value = readUnsignedLong(); + if (Long.compareUnsigned(value, 0xffff_ffffL) > 0) { + throw error("Unsigned integer overflow"); + } + return (int) value; + } + + /** Reads one canonical unsigned 64-bit JSON integer and returns its raw bits. */ + public final long readUnsignedLong() { + skipWhitespace(); + long value = readUnsignedDigits(); + rejectFractionOrExponent(); + return value; + } + + private long readUnsignedDigits() { + int start = position; + if (position >= length()) { + throw error("Expected unsigned digit"); + } + char ch = charAt(position); + if (ch == '0') { + position++; + rejectLeadingDigit(); + return 0; + } + if (ch < '1' || ch > '9') { + throw error("Expected unsigned digit"); + } + long result = 0; + long maxDiv10 = 1_844_674_407_370_955_161L; + while (position < length()) { + ch = charAt(position); + if (ch < '0' || ch > '9') { + break; + } + int digit = ch - '0'; + int comparison = Long.compareUnsigned(result, maxDiv10); + if (comparison > 0 || comparison == 0 && digit > 5) { + throw error("Unsigned long overflow"); + } + result = result * 10 + digit; + position++; + } + if (position == start) { + throw error("Expected unsigned digit"); + } + return result; + } + public BigInteger readBigInteger() { skipWhitespace(); int mark = position; @@ -845,102 +920,57 @@ public UUID readUuid() { return readUuidToken(); } catch (RuntimeException e) { position = mark; - return UUID.fromString(readString()); + return parseUuidValue(readQuotedTextValue()); } } public LocalTime readIsoLocalTime() { - CharSequence value = tryReadAsciiStringView(); - if (value != null) { - return parseLocalTimeValue(value); - } - return parseLocalTimeValue(readString()); + return parseLocalTimeValue(readQuotedTextValue()); } public LocalDateTime readIsoLocalDateTime() { - CharSequence value = tryReadAsciiStringView(); - if (value != null) { - return parseLocalDateTimeValue(value); - } - return parseLocalDateTimeValue(readString()); + return parseLocalDateTimeValue(readQuotedTextValue()); } public Instant readIsoInstant() { - CharSequence value = tryReadAsciiStringView(); - if (value != null) { - return parseInstantValue(value); - } - return parseInstantValue(readString()); + return parseInstantValue(readQuotedTextValue()); } public Duration readDuration() { - CharSequence value = tryReadAsciiStringView(); - if (value != null) { - return parseDurationValue(value); - } - return parseDurationValue(readString()); + return parseDurationValue(readQuotedTextValue()); } public ZoneOffset readZoneOffset() { - int mark = position; - CharSequence value = tryReadAsciiStringView(); - if (value != null) { - ZoneOffset offset = tryParseZoneOffset(value); - if (offset != null) { - return offset; - } else { - position = mark; - } + CharSequence value = readQuotedTextValue(); + ZoneOffset offset = tryParseZoneOffset(value); + if (offset == null) { + throw invalidStringValue("java.time.ZoneOffset"); } - return parseZoneOffsetString(readString()); + return offset; } public ZonedDateTime readZonedDateTime() { - CharSequence value = tryReadAsciiStringView(); - if (value != null) { - return parseZonedDateTimeValue(value); - } - return parseZonedDateTimeValue(readString()); + return parseZonedDateTimeValue(readQuotedTextValue()); } public Year readYear() { - CharSequence value = tryReadAsciiStringView(); - if (value != null) { - return parseYearValue(value); - } - return parseYearString(readString()); + return parseYearText(readQuotedTextValue()); } public YearMonth readYearMonth() { - CharSequence value = tryReadAsciiStringView(); - if (value != null) { - return YearMonth.parse(value); - } - return parseYearMonthString(readString()); + return parseYearMonthValue(readQuotedTextValue()); } public MonthDay readMonthDay() { - CharSequence value = tryReadAsciiStringView(); - if (value != null) { - return MonthDay.parse(value); - } - return parseMonthDayString(readString()); + return parseMonthDayValue(readQuotedTextValue()); } public Period readPeriod() { - CharSequence value = tryReadAsciiStringView(); - if (value != null) { - return Period.parse(value); - } - return parsePeriodString(readString()); + return parsePeriodValue(readQuotedTextValue()); } public OffsetTime readOffsetTime() { - CharSequence value = tryReadAsciiStringView(); - if (value != null) { - return parseOffsetTimeValue(value); - } - return parseOffsetTimeValue(readString()); + return parseOffsetTimeValue(readQuotedTextValue()); } protected final BigDecimal readBigDecimalFallback(int start) { @@ -1012,6 +1042,56 @@ private UUID readUuidToken() { return new UUID(msb, lsb); } + /** Parses the {@link UUID#fromString(String)} grammar from the reusable quoted-text view. */ + protected final UUID parseUuidValue(CharSequence value) { + try { + int first = indexOf(value, '-', 0); + int second = indexOf(value, '-', first + 1); + int third = indexOf(value, '-', second + 1); + int fourth = indexOf(value, '-', third + 1); + if (first < 0 + || second < 0 + || third < 0 + || fourth < 0 + || indexOf(value, '-', fourth + 1) >= 0) { + throw new IllegalArgumentException(); + } + long msb = parseHexGroup(value, 0, first, 8); + msb = (msb << 16) | parseHexGroup(value, first + 1, second, 4); + msb = (msb << 16) | parseHexGroup(value, second + 1, third, 4); + long lsb = parseHexGroup(value, third + 1, fourth, 4); + lsb = (lsb << 48) | parseHexGroup(value, fourth + 1, value.length(), 12); + return new UUID(msb, lsb); + } catch (RuntimeException e) { + throw invalidStringValue("java.util.UUID", e); + } + } + + private static int indexOf(CharSequence value, char expected, int start) { + for (int i = start; i < value.length(); i++) { + if (value.charAt(i) == expected) { + return i; + } + } + return -1; + } + + private static long parseHexGroup(CharSequence value, int start, int end, int maxLength) { + int length = end - start; + if (length <= 0 || length > maxLength) { + throw new IllegalArgumentException(); + } + return parseHex(value, start, length); + } + + private static long parseHex(CharSequence value, int offset, int length) { + long result = 0; + for (int i = 0; i < length; i++) { + result = (result << 4) | uuidHexValue(value.charAt(offset + i)); + } + return result; + } + private long parseHex(int offset, int length) { long value = 0; for (int i = 0; i < length; i++) { @@ -1031,7 +1111,7 @@ private static int uuidHexValue(char ch) { throw new IllegalArgumentException(); } - private AsciiStringView tryReadAsciiStringView() { + private QuotedTextView tryReadQuotedTextView() { skipWhitespace(); int mark = position; if (position >= length() || charAt(position++) != '"') { @@ -1041,8 +1121,8 @@ private AsciiStringView tryReadAsciiStringView() { while (position < length()) { char ch = charAt(position++); if (ch == '"') { - asciiStringView.reset(start, position - 1); - return asciiStringView; + quotedTextView.reset(start, position - 1); + return quotedTextView; } if (ch == '\\' || ch < 0x20 || ch >= 0x80) { position = mark; @@ -1057,24 +1137,62 @@ private ZoneOffset tryParseZoneOffset(CharSequence value) { if (length == 1 && value.charAt(0) == 'Z') { return ZoneOffset.UTC; } - if (length != 6 && length != 9) { + if (length < 2) { return null; } char sign = value.charAt(0); if (sign != '+' && sign != '-') { return null; } - if (value.charAt(3) != ':' || (length == 9 && value.charAt(6) != ':')) { + int hour; + int minute = 0; + int second = 0; + try { + switch (length) { + case 2: + hour = value.charAt(1) - '0'; + if (hour < 0 || hour > 9) { + return null; + } + break; + case 3: + hour = parse2(value, 1); + break; + case 5: + hour = parse2(value, 1); + minute = parse2(value, 3); + break; + case 6: + if (value.charAt(3) != ':') { + return null; + } + hour = parse2(value, 1); + minute = parse2(value, 4); + break; + case 7: + hour = parse2(value, 1); + minute = parse2(value, 3); + second = parse2(value, 5); + break; + case 9: + if (value.charAt(3) != ':' || value.charAt(6) != ':') { + return null; + } + hour = parse2(value, 1); + minute = parse2(value, 4); + second = parse2(value, 7); + break; + default: + return null; + } + int total = hour * 3600 + minute * 60 + second; + return ZoneOffset.ofTotalSeconds(sign == '-' ? -total : total); + } catch (RuntimeException e) { return null; } - int hour = parse2(value, 1); - int minute = parse2(value, 4); - int second = length == 9 ? parse2(value, 7) : 0; - int total = hour * 3600 + minute * 60 + second; - return ZoneOffset.ofTotalSeconds(sign == '-' ? -total : total); } - protected final LocalDate readIsoLocalDateFallback(String value) { + protected final LocalDate readIsoLocalDateFallback(CharSequence value) { try { int length = value.length(); if (length >= 10 @@ -1084,9 +1202,6 @@ protected final LocalDate readIsoLocalDateFallback(String value) { try { return LocalDate.of(parse4(value, 0), parse2(value, 5), parse2(value, 8)); } catch (RuntimeException e) { - if (length > 10 && value.charAt(10) == 'T') { - return LocalDate.parse(value.substring(0, 10)); - } return LocalDate.parse(value); } } @@ -1096,7 +1211,7 @@ protected final LocalDate readIsoLocalDateFallback(String value) { } } - protected final OffsetDateTime readIsoOffsetDateTimeFallback(String value) { + protected final OffsetDateTime readIsoOffsetDateTimeFallback(CharSequence value) { try { return OffsetDateTime.parse(value); } catch (RuntimeException e) { @@ -1136,14 +1251,6 @@ private Duration parseDurationValue(CharSequence value) { } } - private ZoneOffset parseZoneOffsetString(String value) { - try { - return ZoneOffset.of(value); - } catch (RuntimeException e) { - throw invalidStringValue("java.time.ZoneOffset", e); - } - } - private ZonedDateTime parseZonedDateTimeValue(CharSequence value) { try { return ZonedDateTime.parse(value); @@ -1152,7 +1259,7 @@ private ZonedDateTime parseZonedDateTimeValue(CharSequence value) { } } - private Year parseYearString(String value) { + private Year parseYearText(CharSequence value) { try { return parseYearValue(value); } catch (RuntimeException e) { @@ -1160,7 +1267,7 @@ private Year parseYearString(String value) { } } - private YearMonth parseYearMonthString(String value) { + private YearMonth parseYearMonthValue(CharSequence value) { try { return YearMonth.parse(value); } catch (RuntimeException e) { @@ -1168,7 +1275,7 @@ private YearMonth parseYearMonthString(String value) { } } - private MonthDay parseMonthDayString(String value) { + private MonthDay parseMonthDayValue(CharSequence value) { try { return MonthDay.parse(value); } catch (RuntimeException e) { @@ -1176,7 +1283,7 @@ private MonthDay parseMonthDayString(String value) { } } - private Period parsePeriodString(String value) { + private Period parsePeriodValue(CharSequence value) { try { return Period.parse(value); } catch (RuntimeException e) { @@ -1197,6 +1304,10 @@ private ForyJsonException invalidStringValue(String type, RuntimeException e) { "Invalid " + type + " JSON string at JSON position " + position, e); } + private ForyJsonException invalidStringValue(String type) { + return new ForyJsonException("Invalid " + type + " JSON string at JSON position " + position); + } + private static int parse4(CharSequence value, int index) { return parse2(value, index) * 100 + parse2(value, index + 2); } @@ -2419,6 +2530,28 @@ public long readFieldNameLong() { } } + /** Reads an unsigned 32-bit decimal JSON member name without materializing a String. */ + public int readFieldNameUnsignedInt() { + long value = readFieldNameUnsignedLong(); + if (Long.compareUnsigned(value, 0xffff_ffffL) > 0) { + throw error("Unsigned integer field name overflow"); + } + return (int) value; + } + + /** Reads an unsigned 64-bit decimal JSON member name without materializing a String. */ + public long readFieldNameUnsignedLong() { + skipWhitespace(); + if (position >= length() || charAt(position++) != '"') { + throw error("Expected unsigned integer field name"); + } + long value = readUnsignedDigits(); + if (position >= length() || charAt(position++) != '"') { + throw error("Invalid unsigned integer field name"); + } + return value; + } + public JsonFieldInfo readField(JsonFieldTable table) { return table.get(readFieldNameHash()); } @@ -2916,31 +3049,62 @@ private int hexValue(char ch) { protected abstract String slice(int start, int end); - private static final class AsciiStringView implements CharSequence { + private static final class QuotedTextView implements CharSequence { private final JsonReader reader; + private byte[] decodedBytes; private int start; private int end; + private boolean utf16; - AsciiStringView(JsonReader reader) { + QuotedTextView(JsonReader reader) { this.reader = reader; } void reset(int start, int end) { + decodedBytes = null; this.start = start; this.end = end; + utf16 = false; + } + + void reset(byte[] bytes, int length, boolean utf16) { + decodedBytes = bytes; + start = 0; + end = length; + this.utf16 = utf16; + } + + void clear() { + decodedBytes = null; + start = 0; + end = 0; + utf16 = false; } @Override public int length() { - return end - start; + int length = end - start; + return decodedBytes != null && utf16 ? length >>> 1 : length; } @Override public char charAt(int index) { - if (index < 0 || start + index >= end) { + int length = length(); + if (index < 0 || index >= length) { throw new IndexOutOfBoundsException(); } - return reader.charAt(start + index); + byte[] bytes = decodedBytes; + if (bytes == null) { + return reader.charAt(start + index); + } + if (!utf16) { + return (char) (bytes[start + index] & 0xff); + } + int offset = start + (index << 1); + if (NativeByteOrder.IS_LITTLE_ENDIAN) { + return (char) ((bytes[offset] & 0xff) | ((bytes[offset + 1] & 0xff) << 8)); + } + return (char) (((bytes[offset] & 0xff) << 8) | (bytes[offset + 1] & 0xff)); } @Override @@ -2950,7 +3114,15 @@ public CharSequence subSequence(int start, int end) { @Override public String toString() { - return reader.slice(start, end); + if (decodedBytes == null) { + return reader.slice(start, end); + } + int length = length(); + StringBuilder builder = new StringBuilder(length); + for (int i = 0; i < length; i++) { + builder.append(charAt(i)); + } + return builder.toString(); } } } diff --git a/java/fory-json/src/main/java/org/apache/fory/json/reader/Latin1JsonReader.java b/java/fory-json/src/main/java/org/apache/fory/json/reader/Latin1JsonReader.java index a39fb2b452..fae818ec2c 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/reader/Latin1JsonReader.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/reader/Latin1JsonReader.java @@ -209,6 +209,40 @@ protected boolean matchesScannedString(int start, int end, String expected) { return matches && index == expected.length(); } + @Override + protected CharSequence decodeQuotedText(int start, int end) { + byte[] outBytes = stringDecodeBuffer; + int out = 0; + int offset = start; + while (offset < end) { + int raw = input[offset++] & 0xff; + char ch; + if (raw == '\\') { + int escaped = input[offset++] & 0xff; + if (escaped == 'u') { + ch = scanUnicodeEscape(offset); + offset += 4; + } else { + ch = scanSimpleEscape(escaped, offset - 1); + } + if (Character.isHighSurrogate(ch)) { + offset += 2; + char low = scanUnicodeEscape(offset); + offset += 4; + outBytes = ensureStringDecodeCapacity(outBytes, out + 4); + out = putUtf16Char(outBytes, out, ch); + out = putUtf16Char(outBytes, out, low); + continue; + } + } else { + ch = (char) raw; + } + outBytes = ensureStringDecodeCapacity(outBytes, out + 2); + out = putUtf16Char(outBytes, out, ch); + } + return decodedQuotedText(outBytes, out, true); + } + private int scanEscape(int slash, int inputLength) { int cursor = slash + 1; if (cursor >= inputLength) { @@ -812,7 +846,7 @@ public UUID readUuid() { return readUuidToken(); } catch (RuntimeException e) { position = mark; - return UUID.fromString(readStringToken()); + return parseUuidValue(readQuotedTextValue()); } } @@ -1818,7 +1852,7 @@ public LocalDate readIsoLocalDate() { return value; } position = mark; - return readIsoLocalDateFallback(readStringToken()); + return readIsoLocalDateFallback(readQuotedTextValue()); } public OffsetDateTime readIsoOffsetDateTime() { @@ -1829,7 +1863,7 @@ public OffsetDateTime readIsoOffsetDateTime() { return value; } position = mark; - return readIsoOffsetDateTimeFallback(readStringToken()); + return readIsoOffsetDateTimeFallback(readQuotedTextValue()); } private String readStringToken() { diff --git a/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf16JsonReader.java b/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf16JsonReader.java index 97bcf8d043..a487558edb 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf16JsonReader.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf16JsonReader.java @@ -228,6 +228,43 @@ protected boolean matchesScannedString(int start, int end, String expected) { return matches && index == expected.length(); } + @Override + protected CharSequence decodeQuotedText(int start, int end) { + byte[] outBytes = stringDecodeBuffer; + int out = 0; + int offset = start; + while (offset < end) { + char ch = charAtFast(offset++); + if (ch == '\\') { + char escaped = charAtFast(offset++); + if (escaped == 'u') { + ch = scanUnicodeEscape(offset); + offset += 4; + } else { + ch = scanSimpleEscape(escaped, offset - 1); + } + if (Character.isHighSurrogate(ch)) { + offset += 2; + char low = scanUnicodeEscape(offset); + offset += 4; + outBytes = ensureStringDecodeCapacity(outBytes, out + 4); + out = putUtf16Char(outBytes, out, ch); + out = putUtf16Char(outBytes, out, low); + continue; + } + } else if (Character.isHighSurrogate(ch)) { + char low = charAtFast(offset++); + outBytes = ensureStringDecodeCapacity(outBytes, out + 4); + out = putUtf16Char(outBytes, out, ch); + out = putUtf16Char(outBytes, out, low); + continue; + } + outBytes = ensureStringDecodeCapacity(outBytes, out + 2); + out = putUtf16Char(outBytes, out, ch); + } + return decodedQuotedText(outBytes, out, true); + } + private int scanEscape(int slash) { int cursor = slash + 1; if (cursor >= length) { @@ -1752,7 +1789,7 @@ public LocalDate readIsoLocalDate() { return value; } position = mark; - return readIsoLocalDateFallback(readStringToken()); + return readIsoLocalDateFallback(readQuotedTextValue()); } public OffsetDateTime readIsoOffsetDateTime() { @@ -1763,7 +1800,7 @@ public OffsetDateTime readIsoOffsetDateTime() { return value; } position = mark; - return readIsoOffsetDateTimeFallback(readStringToken()); + return readIsoOffsetDateTimeFallback(readQuotedTextValue()); } private String readStringToken() { diff --git a/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf8JsonReader.java b/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf8JsonReader.java index c587d44a34..bb4ea56092 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf8JsonReader.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/reader/Utf8JsonReader.java @@ -268,6 +268,55 @@ protected boolean matchesScannedString(int start, int end, String expected) { return matches && index == expected.length(); } + @Override + protected CharSequence decodeQuotedText(int start, int end) { + byte[] outBytes = stringDecodeBuffer; + int out = 0; + int offset = start; + while (offset < end) { + int raw = input[offset++] & 0xff; + if (raw == '\\') { + int escaped = input[offset++] & 0xff; + char ch; + if (escaped == 'u') { + ch = scanUnicodeEscape(offset); + offset += 4; + } else { + ch = scanSimpleEscape(escaped, offset - 1); + } + if (Character.isHighSurrogate(ch)) { + offset += 2; + char low = scanUnicodeEscape(offset); + offset += 4; + outBytes = ensureStringDecodeCapacity(outBytes, out + 4); + out = putUtf16Char(outBytes, out, ch); + out = putUtf16Char(outBytes, out, low); + } else { + outBytes = ensureStringDecodeCapacity(outBytes, out + 2); + out = putUtf16Char(outBytes, out, ch); + } + continue; + } + if (raw < 0x80) { + outBytes = ensureStringDecodeCapacity(outBytes, out + 2); + out = putUtf16Char(outBytes, out, (char) raw); + continue; + } + long decoded = scanUtf8CodePoint(offset - 1); + offset = (int) (decoded >>> 32); + int codePoint = (int) decoded; + if (codePoint <= 0xffff) { + outBytes = ensureStringDecodeCapacity(outBytes, out + 2); + out = putUtf16Char(outBytes, out, (char) codePoint); + } else { + outBytes = ensureStringDecodeCapacity(outBytes, out + 4); + out = putUtf16Char(outBytes, out, Character.highSurrogate(codePoint)); + out = putUtf16Char(outBytes, out, Character.lowSurrogate(codePoint)); + } + } + return decodedQuotedText(outBytes, out, true); + } + private int scanEscape(int slash, int inputLength) { int cursor = slash + 1; if (cursor >= inputLength) { @@ -482,6 +531,23 @@ public boolean tryConsumeNextComma() { return false; } + /** + * Consumes an adjacent comma and positions an ordered raw-token reader at its next field. + * + *

Only generated ordered creator readers need this stronger postcondition. General field loops + * classify whitespace while reading the next name; normalizing it here as well would scan the + * same separator twice. + */ + @Internal + public boolean tryConsumeNextOrderedComma() { + if (position < input.length && input[position] == ',') { + position++; + skipWhitespaceFast(); + return true; + } + return false; + } + /** * Consumes an object end or a separator requiring whitespace/error classification. * @@ -500,6 +566,15 @@ public boolean consumeNextObjectEndOrSlow() { return consumeNextCommaOrEndObjectSlow(); } + @Internal + public boolean consumeNextOrderedObjectEndOrSlow() { + boolean hasNext = consumeNextObjectEndOrSlow(); + if (hasNext) { + skipWhitespaceFast(); + } + return hasNext; + } + private boolean consumeNextCommaOrEndObjectSlow() { skipWhitespaceFast(); if (position < input.length) { @@ -829,7 +904,7 @@ public UUID readUuid() { return readUuidToken(); } catch (RuntimeException e) { position = mark; - return UUID.fromString(readStringToken()); + return parseUuidValue(readQuotedTextValue()); } } @@ -2064,7 +2139,7 @@ public LocalDate readIsoLocalDate() { return value; } position = mark; - return readIsoLocalDateFallback(readStringToken()); + return readIsoLocalDateFallback(readQuotedTextValue()); } public OffsetDateTime readIsoOffsetDateTime() { @@ -2075,7 +2150,7 @@ public OffsetDateTime readIsoOffsetDateTime() { return value; } position = mark; - return readIsoOffsetDateTimeFallback(readStringToken()); + return readIsoOffsetDateTimeFallback(readQuotedTextValue()); } private String readStringToken() { diff --git a/java/fory-json/src/main/java/org/apache/fory/json/resolver/ExactTypeRequiredException.java b/java/fory-json/src/main/java/org/apache/fory/json/resolver/ExactTypeRequiredException.java new file mode 100644 index 0000000000..1ff3fa20a4 --- /dev/null +++ b/java/fory-json/src/main/java/org/apache/fory/json/resolver/ExactTypeRequiredException.java @@ -0,0 +1,31 @@ +/* + * 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.fory.json.resolver; + +import org.apache.fory.annotation.Internal; +import org.apache.fory.json.ForyJsonException; + +/** A recognized semantic type whose raw class is not a complete declared occurrence. */ +@Internal +public final class ExactTypeRequiredException extends ForyJsonException { + public ExactTypeRequiredException(String message) { + super(message); + } +} diff --git a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonGeneratedClassRegistry.java b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonGeneratedClassRegistry.java index 70200ef94b..2925640c76 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonGeneratedClassRegistry.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonGeneratedClassRegistry.java @@ -19,15 +19,16 @@ package org.apache.fory.json.resolver; -import java.lang.reflect.Type; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; import org.apache.fory.annotation.Internal; +import org.apache.fory.json.codec.GeneratedJsonCodec; import org.apache.fory.json.codegen.JsonCodegenKey; import org.apache.fory.json.resolver.JsonSharedRegistry.GeneratedClasses; +import org.apache.fory.reflect.TypeRef; /** Frozen Native Image mapping from JSON configuration semantics to generated classes. */ @Internal @@ -77,70 +78,118 @@ static Configuration configuration(JsonCodegenKey key) { return configurations.get(key); } + static void mergeSignatures(Map target, Map source) { + for (Map.Entry entry : source.entrySet()) { + String previous = target.putIfAbsent(entry.getKey(), entry.getValue()); + if (previous != null && !previous.equals(entry.getValue())) { + throw new IllegalStateException( + "Generated Fory JSON class-name collision for " + entry.getKey()); + } + } + } + + static void mergeSourceCodecs( + Map, GeneratedJsonCodec> source, + Map, GeneratedJsonCodec> target, + Set> added) { + for (Map.Entry, GeneratedJsonCodec> entry : source.entrySet()) { + TypeRef type = entry.getKey(); + GeneratedJsonCodec codec = entry.getValue(); + GeneratedJsonCodec previous = target.putIfAbsent(type, codec); + if (previous == null) { + added.add(codec.getClass()); + } else if (previous.getClass() != codec.getClass()) { + throw new IllegalStateException( + "Conflicting source-generated Fory JSON companions for " + type); + } + } + } + static final class Configuration { - private final Map, Class> stringWriters; - private final Map, Class> utf8Writers; - private final Map, Class> latin1Readers; - private final Map, Class> utf16Readers; - private final Map, Class> utf8Readers; - private final Map> utf8CollectionWriters; - private final Map> utf8CollectionReaders; + private final Map, Class> stringWriters; + private final Map, Class> utf8Writers; + private final Map, Class> latin1Readers; + private final Map, Class> utf16Readers; + private final Map, Class> utf8Readers; + private final Map, Class> utf8CollectionWriters; + private final Map, Class> utf8CollectionReaders; + private final Map, GeneratedJsonCodec> sourceCodecs; + private final Map signatures; private Configuration(MutableConfiguration source) { - stringWriters = immutable(source.stringWriters); - utf8Writers = immutable(source.utf8Writers); - latin1Readers = immutable(source.latin1Readers); - utf16Readers = immutable(source.utf16Readers); - utf8Readers = immutable(source.utf8Readers); - utf8CollectionWriters = immutable(source.utf8CollectionWriters); - utf8CollectionReaders = immutable(source.utf8CollectionReaders); + stringWriters = immutableValues(source.stringWriters); + utf8Writers = immutableValues(source.utf8Writers); + latin1Readers = immutableValues(source.latin1Readers); + utf16Readers = immutableValues(source.utf16Readers); + utf8Readers = immutableValues(source.utf8Readers); + utf8CollectionWriters = immutableValues(source.utf8CollectionWriters); + utf8CollectionReaders = immutableValues(source.utf8CollectionReaders); + sourceCodecs = immutableValues(source.sourceCodecs); + signatures = Collections.unmodifiableMap(new HashMap<>(source.signatures)); + } + + Class stringWriter(TypeRef type) { + return generatedClass(stringWriters, type); + } + + Class utf8Writer(TypeRef type) { + return generatedClass(utf8Writers, type); } - Class stringWriter(Class type) { - return stringWriters.get(type); + Class latin1Reader(TypeRef type) { + return generatedClass(latin1Readers, type); } - Class utf8Writer(Class type) { - return utf8Writers.get(type); + Class utf16Reader(TypeRef type) { + return generatedClass(utf16Readers, type); } - Class latin1Reader(Class type) { - return latin1Readers.get(type); + Class utf8Reader(TypeRef type) { + return generatedClass(utf8Readers, type); } - Class utf16Reader(Class type) { - return utf16Readers.get(type); + Class utf8CollectionWriter(TypeRef type) { + return generatedClass(utf8CollectionWriters, type); } - Class utf8Reader(Class type) { - return utf8Readers.get(type); + Class utf8CollectionReader(TypeRef type) { + return generatedClass(utf8CollectionReaders, type); } - Class utf8CollectionWriter(Type type) { - return utf8CollectionWriters.get(type); + GeneratedJsonCodec sourceCodec(TypeRef type) { + return sourceCodecs.get(type); } - Class utf8CollectionReader(Type type) { - return utf8CollectionReaders.get(type); + private Class generatedClass(Map, Class> classes, TypeRef type) { + Class generatedClass = classes.get(type); + if (generatedClass != null && !signatures.containsKey(generatedClass.getName())) { + throw new IllegalStateException( + "Missing structural signature for generated Fory JSON class " + + generatedClass.getName()); + } + return generatedClass; } - private static Map> immutable(Map> classes) { - return classes.isEmpty() + private static Map immutableValues(Map values) { + return values.isEmpty() ? Collections.emptyMap() - : Collections.unmodifiableMap(new HashMap<>(classes)); + : Collections.unmodifiableMap(new HashMap<>(values)); } } private static final class MutableConfiguration { - private final Map, Class> stringWriters = new HashMap<>(); - private final Map, Class> utf8Writers = new HashMap<>(); - private final Map, Class> latin1Readers = new HashMap<>(); - private final Map, Class> utf16Readers = new HashMap<>(); - private final Map, Class> utf8Readers = new HashMap<>(); - private final Map> utf8CollectionWriters = new HashMap<>(); - private final Map> utf8CollectionReaders = new HashMap<>(); + private final Map, Class> stringWriters = new HashMap<>(); + private final Map, Class> utf8Writers = new HashMap<>(); + private final Map, Class> latin1Readers = new HashMap<>(); + private final Map, Class> utf16Readers = new HashMap<>(); + private final Map, Class> utf8Readers = new HashMap<>(); + private final Map, Class> utf8CollectionWriters = new HashMap<>(); + private final Map, Class> utf8CollectionReaders = new HashMap<>(); + private final Map, GeneratedJsonCodec> sourceCodecs = new HashMap<>(); + private final Map signatures = new HashMap<>(); private void merge(GeneratedClasses source, Set> added) { + mergeSignatures(source.signatures()); merge(source.stringWriters(), stringWriters, added); merge(source.utf8Writers(), utf8Writers, added); merge(source.latin1Readers(), latin1Readers, added); @@ -148,6 +197,7 @@ private void merge(GeneratedClasses source, Set> added) { merge(source.utf8Readers(), utf8Readers, added); merge(source.utf8CollectionWriters(), utf8CollectionWriters, added); merge(source.utf8CollectionReaders(), utf8CollectionReaders, added); + JsonGeneratedClassRegistry.mergeSourceCodecs(source.sourceCodecs(), sourceCodecs, added); } private static void merge( @@ -167,6 +217,10 @@ private static void merge( } } + private void mergeSignatures(Map source) { + JsonGeneratedClassRegistry.mergeSignatures(signatures, source); + } + private Configuration freeze() { return new Configuration(this); } diff --git a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonMixinAnnotations.java b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonMixinAnnotations.java index 99341de4e8..2c1e992073 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonMixinAnnotations.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonMixinAnnotations.java @@ -390,6 +390,9 @@ private static ForyJsonException invalidSelector( private static A declaredAnnotation( AnnotatedElement element, Class annotationType) { try { + if (element instanceof Parameter) { + return parameterAnnotation((Parameter) element, annotationType); + } return element.getDeclaredAnnotation(annotationType); } catch (RuntimeException | LinkageError e) { throw new ForyJsonException( @@ -397,6 +400,27 @@ private static A declaredAnnotation( } } + private static A parameterAnnotation( + Parameter parameter, Class annotationType) { + Executable executable = parameter.getDeclaringExecutable(); + Parameter[] parameters = executable.getParameters(); + Annotation[][] annotations = executable.getParameterAnnotations(); + // Android 8 ART can crash in Parameter.getDeclaredAnnotation even though the executable-owned + // parameter annotation table is valid. Keep every effective parameter lookup on that table. + for (int i = 0; i < parameters.length; i++) { + if (!parameter.equals(parameters[i])) { + continue; + } + for (Annotation annotation : annotations[i]) { + if (annotation.annotationType() == annotationType) { + return annotationType.cast(annotation); + } + } + return null; + } + throw new IllegalArgumentException("Parameter does not belong to " + executable); + } + static A targetAnnotation( AnnotatedElement element, Class annotationType) { validateTargetControl(element); diff --git a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java index 6ade7bacf4..07cac548b3 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonSharedRegistry.java @@ -30,7 +30,6 @@ import java.lang.reflect.Member; import java.lang.reflect.Method; import java.lang.reflect.Modifier; -import java.lang.reflect.Type; import java.math.BigDecimal; import java.math.BigInteger; import java.net.InetAddress; @@ -127,6 +126,7 @@ import org.apache.fory.json.meta.JsonFieldKind; import org.apache.fory.json.resolver.CodecRegistry.FactoryBinding; import org.apache.fory.json.resolver.JsonGeneratedClassRegistry.Configuration; +import org.apache.fory.meta.TypeExtMeta; import org.apache.fory.platform.AndroidSupport; import org.apache.fory.platform.GraalvmSupport; import org.apache.fory.reflect.ReflectionUtils; @@ -135,6 +135,7 @@ import org.apache.fory.type.BFloat16; import org.apache.fory.type.Float16; import org.apache.fory.type.TypeUtils; +import org.apache.fory.type.Types; import org.apache.fory.util.record.RecordUtils; /** @@ -197,13 +198,16 @@ public int compare(DeclarationCandidate left, DeclarationCandidate right) { private final ConcurrentHashMap, MapKeyCodec> mapKeyCodecs; private final ConcurrentHashMap, GeneratedJsonCodec> generatedCodecs; private final Set> typesWithoutGeneratedCodec; - private final ConcurrentHashMap, CompletableFuture>> stringWriterClasses; - private final ConcurrentHashMap, CompletableFuture>> utf8WriterClasses; - private final ConcurrentHashMap, CompletableFuture>> latin1ReaderClasses; - private final ConcurrentHashMap, CompletableFuture>> utf16ReaderClasses; - private final ConcurrentHashMap, CompletableFuture>> utf8ReaderClasses; - private final ConcurrentHashMap>> utf8CollectionWriterClasses; - private final ConcurrentHashMap>> utf8CollectionReaderClasses; + private final ConcurrentHashMap, GeneratedJsonCodec> generatedCodecCapabilities; + private final ConcurrentHashMap, CompletableFuture>> stringWriterClasses; + private final ConcurrentHashMap, CompletableFuture>> utf8WriterClasses; + private final ConcurrentHashMap, CompletableFuture>> latin1ReaderClasses; + private final ConcurrentHashMap, CompletableFuture>> utf16ReaderClasses; + private final ConcurrentHashMap, CompletableFuture>> utf8ReaderClasses; + private final ConcurrentHashMap, CompletableFuture>> + utf8CollectionWriterClasses; + private final ConcurrentHashMap, CompletableFuture>> + utf8CollectionReaderClasses; // Only ForyJson's fixed-pool reader-local caches publish production entries here, and each reader // owns its configured entry limit. This reference-reuse table does not own a second capacity // policy. @@ -259,6 +263,7 @@ private JsonSharedRegistry( mapKeyCodecs = new ConcurrentHashMap<>(); generatedCodecs = new ConcurrentHashMap<>(); typesWithoutGeneratedCodec = ConcurrentHashMap.newKeySet(); + generatedCodecCapabilities = new ConcurrentHashMap<>(); stringWriterClasses = new ConcurrentHashMap<>(); utf8WriterClasses = new ConcurrentHashMap<>(); latin1ReaderClasses = new ConcurrentHashMap<>(); @@ -272,9 +277,7 @@ private JsonSharedRegistry( boolean createCompiler = codegenEnabled && (hostedCodegen || !GraalvmSupport.IN_GRAALVM_NATIVE_IMAGE); codegen = - createCompiler - ? new JsonCodegen(config.getCodegenHash(), classLoader, hostedCodegen) - : null; + createCompiler ? new JsonCodegen(config.codegenKey(), classLoader, hostedCodegen) : null; nativeCodegenKey = codegenEnabled && GraalvmSupport.IN_GRAALVM_NATIVE_IMAGE ? config.codegenKey() : null; asyncCompilationEnabled = createCompiler && !hostedCodegen && config.asyncCompilationEnabled(); @@ -299,14 +302,62 @@ GeneratedClasses generatedClasses() { if (codegen == null || asyncCompilationEnabled) { throw new IllegalStateException("Generated class snapshots require synchronous codegen"); } + Map, Class> stringWriters = completedClasses(stringWriterClasses); + Map, Class> utf8Writers = completedClasses(utf8WriterClasses); + Map, Class> latin1Readers = completedClasses(latin1ReaderClasses); + Map, Class> utf16Readers = completedClasses(utf16ReaderClasses); + Map, Class> utf8Readers = completedClasses(utf8ReaderClasses); + Map, Class> utf8CollectionWriters = completedClasses(utf8CollectionWriterClasses); + Map, Class> utf8CollectionReaders = completedClasses(utf8CollectionReaderClasses); + Map, GeneratedJsonCodec> sourceCodecs = + immutableSnapshot(generatedCodecCapabilities); return new GeneratedClasses( - completedClasses(stringWriterClasses), - completedClasses(utf8WriterClasses), - completedClasses(latin1ReaderClasses), - completedClasses(utf16ReaderClasses), - completedClasses(utf8ReaderClasses), - completedClasses(utf8CollectionWriterClasses), - completedClasses(utf8CollectionReaderClasses)); + stringWriters, + utf8Writers, + latin1Readers, + utf16Readers, + utf8Readers, + utf8CollectionWriters, + utf8CollectionReaders, + sourceCodecs, + generatedSignatures( + codegen, + stringWriters, + utf8Writers, + latin1Readers, + utf16Readers, + utf8Readers, + utf8CollectionWriters, + utf8CollectionReaders)); + } + + private static Map immutableSnapshot(Map values) { + return values.isEmpty() + ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap<>(values)); + } + + @SafeVarargs + private static Map generatedSignatures( + JsonCodegen codegen, Map, Class>... classes) { + Map allSignatures = codegen.generatedClassSignatures(); + Map signatures = new HashMap<>(); + for (Map, Class> capabilities : classes) { + for (Class generatedClass : capabilities.values()) { + String name = generatedClass.getName(); + String signature = allSignatures.get(name); + if (signature == null) { + throw new IllegalStateException( + "Missing structural signature for generated Fory JSON class " + name); + } + String previous = signatures.putIfAbsent(name, signature); + if (previous != null && !previous.equals(signature)) { + throw new IllegalStateException( + "Conflicting structural signatures for generated Fory JSON class " + name); + } + } + } + return Collections.unmodifiableMap(signatures); } private static Map> completedClasses( @@ -328,22 +379,26 @@ private static Map> completedClasses( } static final class GeneratedClasses { - private final Map, Class> stringWriters; - private final Map, Class> utf8Writers; - private final Map, Class> latin1Readers; - private final Map, Class> utf16Readers; - private final Map, Class> utf8Readers; - private final Map> utf8CollectionWriters; - private final Map> utf8CollectionReaders; + private final Map, Class> stringWriters; + private final Map, Class> utf8Writers; + private final Map, Class> latin1Readers; + private final Map, Class> utf16Readers; + private final Map, Class> utf8Readers; + private final Map, Class> utf8CollectionWriters; + private final Map, Class> utf8CollectionReaders; + private final Map, GeneratedJsonCodec> sourceCodecs; + private final Map signatures; private GeneratedClasses( - Map, Class> stringWriters, - Map, Class> utf8Writers, - Map, Class> latin1Readers, - Map, Class> utf16Readers, - Map, Class> utf8Readers, - Map> utf8CollectionWriters, - Map> utf8CollectionReaders) { + Map, Class> stringWriters, + Map, Class> utf8Writers, + Map, Class> latin1Readers, + Map, Class> utf16Readers, + Map, Class> utf8Readers, + Map, Class> utf8CollectionWriters, + Map, Class> utf8CollectionReaders, + Map, GeneratedJsonCodec> sourceCodecs, + Map signatures) { this.stringWriters = stringWriters; this.utf8Writers = utf8Writers; this.latin1Readers = latin1Readers; @@ -351,76 +406,108 @@ private GeneratedClasses( this.utf8Readers = utf8Readers; this.utf8CollectionWriters = utf8CollectionWriters; this.utf8CollectionReaders = utf8CollectionReaders; + this.sourceCodecs = sourceCodecs; + this.signatures = signatures; } - Map, Class> stringWriters() { + Map, Class> stringWriters() { return stringWriters; } - Map, Class> utf8Writers() { + Map, Class> utf8Writers() { return utf8Writers; } - Map, Class> latin1Readers() { + Map, Class> latin1Readers() { return latin1Readers; } - Map, Class> utf16Readers() { + Map, Class> utf16Readers() { return utf16Readers; } - Map, Class> utf8Readers() { + Map, Class> utf8Readers() { return utf8Readers; } - Map> utf8CollectionWriters() { + Map, Class> utf8CollectionWriters() { return utf8CollectionWriters; } - Map> utf8CollectionReaders() { + Map, Class> utf8CollectionReaders() { return utf8CollectionReaders; } + + Map, GeneratedJsonCodec> sourceCodecs() { + return sourceCodecs; + } + + Map signatures() { + return signatures; + } } - CompletableFuture> stringWriterClass(ObjectCodec owner, JsonTypeResolver resolver) { + CompletableFuture> stringWriterClass( + JsonTypeInfo typeInfo, ObjectCodec owner, JsonTypeResolver resolver) { + TypeRef generatedType = generatedCapabilityType(typeInfo.typeRef()); return generatedClassFuture( - stringWriterClasses, owner.type(), () -> codegen.compileStringWriter(owner, resolver)); + stringWriterClasses, + generatedType, + () -> codegen.compileStringWriter(generatedType, owner, resolver)); } - CompletableFuture> utf8WriterClass(ObjectCodec owner, JsonTypeResolver resolver) { + CompletableFuture> utf8WriterClass( + JsonTypeInfo typeInfo, ObjectCodec owner, JsonTypeResolver resolver) { + TypeRef generatedType = generatedCapabilityType(typeInfo.typeRef()); return generatedClassFuture( - utf8WriterClasses, owner.type(), () -> codegen.compileUtf8Writer(owner, resolver)); + utf8WriterClasses, + generatedType, + () -> codegen.compileUtf8Writer(generatedType, owner, resolver)); } - CompletableFuture> latin1ReaderClass(ObjectCodec owner, JsonTypeResolver resolver) { + CompletableFuture> latin1ReaderClass( + JsonTypeInfo typeInfo, ObjectCodec owner, JsonTypeResolver resolver) { + TypeRef generatedType = generatedCapabilityType(typeInfo.typeRef()); return generatedClassFuture( - latin1ReaderClasses, owner.type(), () -> codegen.compileLatin1Reader(owner, resolver)); + latin1ReaderClasses, + generatedType, + () -> codegen.compileLatin1Reader(generatedType, owner, resolver)); } - CompletableFuture> utf16ReaderClass(ObjectCodec owner, JsonTypeResolver resolver) { + CompletableFuture> utf16ReaderClass( + JsonTypeInfo typeInfo, ObjectCodec owner, JsonTypeResolver resolver) { + TypeRef generatedType = generatedCapabilityType(typeInfo.typeRef()); return generatedClassFuture( - utf16ReaderClasses, owner.type(), () -> codegen.compileUtf16Reader(owner, resolver)); + utf16ReaderClasses, + generatedType, + () -> codegen.compileUtf16Reader(generatedType, owner, resolver)); } - CompletableFuture> utf8ReaderClass(ObjectCodec owner, JsonTypeResolver resolver) { + CompletableFuture> utf8ReaderClass( + JsonTypeInfo typeInfo, ObjectCodec owner, JsonTypeResolver resolver) { + TypeRef generatedType = generatedCapabilityType(typeInfo.typeRef()); return generatedClassFuture( - utf8ReaderClasses, owner.type(), () -> codegen.compileUtf8Reader(owner, resolver, true)); + utf8ReaderClasses, + generatedType, + () -> codegen.compileUtf8Reader(generatedType, owner, resolver, true)); } CompletableFuture> utf8CollectionWriterClass( - Type declaredType, CollectionCodec owner) { + TypeRef declaredType, CollectionCodec owner) { + TypeRef generatedType = generatedCapabilityType(declaredType); return generatedClassFuture( utf8CollectionWriterClasses, - declaredType, - () -> codegen.compileUtf8CollectionWriter(declaredType, owner)); + generatedType, + () -> codegen.compileUtf8CollectionWriter(generatedType, owner)); } CompletableFuture> utf8CollectionReaderClass( - Type declaredType, CollectionCodec owner) { + TypeRef declaredType, CollectionCodec owner) { + TypeRef generatedType = generatedCapabilityType(declaredType); return generatedClassFuture( utf8CollectionReaderClasses, - declaredType, - () -> codegen.compileUtf8CollectionReader(declaredType, owner)); + generatedType, + () -> codegen.compileUtf8CollectionReader(generatedType, owner)); } boolean generatedCapabilitiesEnabled() { @@ -439,39 +526,62 @@ boolean nativeGeneratedClasses() { return nativeCodegenKey != null && codegen == null && nativeConfiguration() != null; } - Class nativeStringWriterClass(Class type) { + Class nativeStringWriterClass(TypeRef type) { Configuration configuration = nativeConfiguration(); - return configuration == null ? null : configuration.stringWriter(type); + return configuration == null ? null : configuration.stringWriter(generatedCapabilityType(type)); } - Class nativeUtf8WriterClass(Class type) { + Class nativeUtf8WriterClass(TypeRef type) { Configuration configuration = nativeConfiguration(); - return configuration == null ? null : configuration.utf8Writer(type); + return configuration == null ? null : configuration.utf8Writer(generatedCapabilityType(type)); } - Class nativeLatin1ReaderClass(Class type) { + Class nativeLatin1ReaderClass(TypeRef type) { Configuration configuration = nativeConfiguration(); - return configuration == null ? null : configuration.latin1Reader(type); + return configuration == null ? null : configuration.latin1Reader(generatedCapabilityType(type)); } - Class nativeUtf16ReaderClass(Class type) { + Class nativeUtf16ReaderClass(TypeRef type) { Configuration configuration = nativeConfiguration(); - return configuration == null ? null : configuration.utf16Reader(type); + return configuration == null ? null : configuration.utf16Reader(generatedCapabilityType(type)); } - Class nativeUtf8ReaderClass(Class type) { + Class nativeUtf8ReaderClass(TypeRef type) { Configuration configuration = nativeConfiguration(); - return configuration == null ? null : configuration.utf8Reader(type); + return configuration == null ? null : configuration.utf8Reader(generatedCapabilityType(type)); } - Class nativeUtf8CollectionWriterClass(Type type) { + Class nativeUtf8CollectionWriterClass(TypeRef type) { Configuration configuration = nativeConfiguration(); - return configuration == null ? null : configuration.utf8CollectionWriter(type); + return configuration == null + ? null + : configuration.utf8CollectionWriter(generatedCapabilityType(type)); } - Class nativeUtf8CollectionReaderClass(Type type) { + Class nativeUtf8CollectionReaderClass(TypeRef type) { Configuration configuration = nativeConfiguration(); - return configuration == null ? null : configuration.utf8CollectionReader(type); + return configuration == null + ? null + : configuration.utf8CollectionReader(generatedCapabilityType(type)); + } + + static TypeRef generatedCapabilityType(TypeRef type) { + TypeExtMeta metadata = type.getTypeExtMeta(); + if (metadata == null + || metadata.typeId() != Types.UNKNOWN + || metadata.trackingRef() + || metadata.nullableWrapper() + || metadata.covariant()) { + return type; + } + // Generated codecs own the value body after the outer occurrence null gate. Ordinary outer + // nullability therefore cannot change generated source, while every nested occurrence and any + // non-default outer semantic fact must remain part of the structural capability identity. + return TypeRef.ofSemanticTypeArguments( + type.getType(), + null, + type.hasExplicitTypeArguments() ? type.getTypeArguments() : null, + type.isArray() ? type.getComponentType() : null); } private Configuration nativeConfiguration() { @@ -556,12 +666,33 @@ public boolean matches(int length, long candidateWord0, long candidateWord1) { } } - GeneratedJsonCodec generatedCodec(Class type) { - // Hosted analysis generates the codec selected by the active configuration. Requiring an - // annotation-processor companion here would exclude models owned by language modules. - if (hostedCodegen || GraalvmSupport.IN_GRAALVM_NATIVE_IMAGE) { - return null; + GeneratedJsonCodec generatedCodec(TypeRef type) { + return generatedCodec(type, true); + } + + private GeneratedJsonCodec generatedCodec(TypeRef type, boolean requireCompanion) { + if (GraalvmSupport.IN_GRAALVM_NATIVE_IMAGE && !hostedCodegen) { + Configuration configuration = nativeConfiguration(); + GeneratedJsonCodec codec = configuration == null ? null : configuration.sourceCodec(type); + if (codec == null && requireCompanion) { + Class rawType = type.getRawType(); + if (rawType.getDeclaredAnnotation(JsonType.class) != null) { + throw missingGeneratedCodec(rawType, mixinType(rawType), "JSON object model"); + } + } + return codec; } + GeneratedJsonCodec codec = generatedCodec(type.getRawType(), requireCompanion); + if (codec != null && hostedCodegen) { + GeneratedJsonCodec previous = generatedCodecCapabilities.putIfAbsent(type, codec); + if (previous != null && previous != codec) { + throw new IllegalStateException("Conflicting generated JSON companions for " + type); + } + } + return codec; + } + + private GeneratedJsonCodec generatedCodec(Class type, boolean requireCompanion) { Class mixinType = mixinType(type); boolean directType = type.getDeclaredAnnotation(JsonType.class) != null; if (!directType && mixinType == null) { @@ -569,7 +700,7 @@ GeneratedJsonCodec generatedCodec(Class type) { } try { GeneratedJsonCodec codec = generatedCodecIfPresent(type, mixinType); - if (codec == null && (directType || mixinType != null && AndroidSupport.IS_ANDROID)) { + if (codec == null && requireCompanion && directType) { throw missingGeneratedCodec(type, mixinType, "JSON object model"); } return codec; @@ -578,6 +709,10 @@ GeneratedJsonCodec generatedCodec(Class type) { } } + GeneratedJsonCodec generatedCodecIfPresent(TypeRef type) { + return generatedCodec(type, false); + } + private GeneratedJsonCodec generatedCodecIfPresent(Class type, Class mixinType) { GeneratedJsonCodec codec = generatedCodecs.get(type); if (codec != null) { @@ -858,6 +993,12 @@ private static Executable validateGeneratedCreator( if (creatorFactory != null && creatorFactory.isEmpty()) { throw invalidGeneratedCodec(type, "creator factory name must not be empty"); } + // Non-Record language models can lower one logical creator to an accessibility/default bridge + // that has no public source-level executable with the generated logical carrier list. The + // resolver-local ObjectCodecBuilder owns validation once its exact language model is known. + if (!record) { + return null; + } Executable creator; if (creatorFactory == null) { Constructor constructor; @@ -935,21 +1076,11 @@ private static Throwable unwrap(Throwable throwable) { : throwable; } - /** Returns the deterministic companion binary name for one model class. */ - @Internal - public static String generatedCodecBinaryName(Class type) { - return generatedCodecBinaryName(type.getName()); + private static String generatedCodecBinaryName(Class type) { + return GeneratedClassNames.withSuffix(type.getName(), "_ForyJsonCodec"); } - /** Returns the deterministic companion binary name for one model binary name. */ - @Internal - public static String generatedCodecBinaryName(String binaryName) { - return GeneratedClassNames.withSuffix(binaryName, "_ForyJsonCodec"); - } - - /** Returns the deterministic generated companion name for one target-Mixin pair. */ - @Internal - public static String generatedMixinCodecBinaryName(Class mixinType, Class targetType) { + private static String generatedMixinCodecBinaryName(Class mixinType, Class targetType) { String sourceName = mixinType.getName(); int packageEnd = sourceName.lastIndexOf('.'); String sourcePackage = packageEnd < 0 ? "" : sourceName.substring(0, packageEnd + 1); @@ -961,8 +1092,24 @@ public static String generatedMixinCodecBinaryName(Class mixinType, Class + "_ForyJsonCodec"; } + JsonValueCodec createSubtypeCodec( + Class rawType, + TypeRef typeRef, + JsonTypeResolver localResolver, + ObjectCodec selectedCodec) { + return createCodec(rawType, typeRef, localResolver, selectedCodec); + } + public JsonValueCodec createCodec( Class rawType, TypeRef typeRef, JsonTypeResolver localResolver) { + return createCodec(rawType, typeRef, localResolver, null); + } + + private JsonValueCodec createCodec( + Class rawType, + TypeRef typeRef, + JsonTypeResolver localResolver, + ObjectCodec selectedCodec) { JsonValueCodec customCodec = customCodec(rawType); if (customCodec != null) { return customCodec; @@ -971,6 +1118,35 @@ public JsonValueCodec createCodec( if (exactFactory != null) { return createExactCodec(rawType, typeRef, exactFactory, localResolver); } + if (selectedCodec != null) { + return selectedCodec; + } + if (typeRef.getTypeExtMeta() != null + && (rawType == OptionalInt.class + || rawType == OptionalLong.class + || rawType == OptionalDouble.class)) { + if (typeRef.getTypeExtMeta().nullable() || typeRef.getTypeExtMeta().nullableWrapper()) { + throw new ForyJsonException("Nullable Optional has ambiguous JSON null: " + typeRef); + } + if (rawType == OptionalInt.class) { + return ScalarCodecs.OptionalIntCodec.NON_NULL; + } + if (rawType == OptionalLong.class) { + return ScalarCodecs.OptionalLongCodec.NON_NULL; + } + return ScalarCodecs.OptionalDoubleCodec.NON_NULL; + } + boolean semanticToken = + typeRef.getTypeExtMeta() != null && typeRef.getTypeExtMeta().typeId() != Types.UNKNOWN; + if (semanticToken) { + // The exact JVM carrier codec cannot erase an explicit semantic type. The installed module + // owns that representation even when the semantic value uses a primitive carrier. + JsonValueCodec codec = createModuleCodec(typeRef, localResolver); + if (codec == null) { + throw new ForyJsonException("No installed JSON module owns semantic type " + typeRef); + } + return codec; + } JsonValueCodec codec = exactCodecs.get(rawType); if (codec != null) { return codec; @@ -993,16 +1169,15 @@ public JsonValueCodec createCodec( return ArrayCodec.create(rawType, typeRef, localResolver); } if (rawType == Optional.class) { - return new ScalarCodecs.OptionalCodec( - CodecUtils.elementType(typeRef.getType()), localResolver); + return new ScalarCodecs.OptionalCodec(typeRef, localResolver); } if (rawType == AtomicReference.class) { - return new ScalarCodecs.AtomicReferenceCodec( - CodecUtils.elementType(typeRef.getType()), localResolver); + JsonTypeInfo contentInfo = localResolver.getTypeInfo(CodecUtils.elementTypeRef(typeRef)); + return ScalarCodecs.AtomicReferenceCodec.create(typeRef, contentInfo); } if (rawType == AtomicReferenceArray.class) { - return new ScalarCodecs.AtomicReferenceArrayCodec( - CodecUtils.elementType(typeRef.getType()), localResolver); + JsonTypeInfo elementInfo = localResolver.getTypeInfo(CodecUtils.elementTypeRef(typeRef)); + return ScalarCodecs.AtomicReferenceArrayCodec.create(elementInfo); } if (Calendar.class.isAssignableFrom(rawType)) { return ScalarCodecs.CalendarCodec.INSTANCE; diff --git a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeInfo.java b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeInfo.java index bdd3804e5a..12f9b28981 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeInfo.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeInfo.java @@ -21,14 +21,19 @@ import java.lang.reflect.Type; import org.apache.fory.annotation.Internal; +import org.apache.fory.json.ForyJsonException; import org.apache.fory.json.codec.JsonValueCodec; import org.apache.fory.json.codec.Latin1ReaderCodec; import org.apache.fory.json.codec.ObjectCodec; import org.apache.fory.json.codec.StringWriterCodec; +import org.apache.fory.json.codec.TransparentNullCodec; +import org.apache.fory.json.codec.UnboxedValueCodec; import org.apache.fory.json.codec.Utf16ReaderCodec; import org.apache.fory.json.codec.Utf8ReaderCodec; import org.apache.fory.json.codec.Utf8WriterCodec; import org.apache.fory.json.meta.JsonFieldKind; +import org.apache.fory.meta.TypeExtMeta; +import org.apache.fory.reflect.TypeRef; /** * JSON type binding resolved and owned by {@link JsonTypeResolver}. @@ -46,9 +51,13 @@ * independently lazy slot. */ public final class JsonTypeInfo { - private final Type type; + private final TypeRef typeRef; private final Class rawType; private final JsonFieldKind kind; + private final boolean nullable; + private final boolean rejectsNull; + private final boolean transparentNull; + private final UnboxedValueCodec unboxedValueCodec; private StringWriterCodec stringWriter; private Utf8WriterCodec utf8Writer; private Latin1ReaderCodec latin1Reader; @@ -56,19 +65,24 @@ public final class JsonTypeInfo { private Utf8ReaderCodec utf8Reader; private final boolean annotationCodec; - JsonTypeInfo(Type type, Class rawType, JsonFieldKind kind, JsonValueCodec codec) { - this(type, rawType, kind, codec, false); + JsonTypeInfo(TypeRef typeRef, JsonFieldKind kind, JsonValueCodec codec) { + this(typeRef, kind, codec, false); } JsonTypeInfo( - Type type, - Class rawType, + TypeRef typeRef, JsonFieldKind kind, JsonValueCodec codec, boolean annotationCodec) { - this.type = type; - this.rawType = rawType; + this.typeRef = typeRef; + this.rawType = typeRef.getRawType(); this.kind = kind; + TypeExtMeta metadata = typeRef.getTypeExtMeta(); + nullable = metadata != null && metadata.nullable() && !metadata.nullableWrapper(); + transparentNull = codec instanceof TransparentNullCodec; + rejectsNull = + metadata != null && !metadata.nullable() && !metadata.nullableWrapper() && !transparentNull; + unboxedValueCodec = codec instanceof UnboxedValueCodec ? (UnboxedValueCodec) codec : null; this.annotationCodec = annotationCodec; stringWriter = codec; utf8Writer = codec; @@ -78,7 +92,13 @@ public final class JsonTypeInfo { } public Type type() { - return type; + return typeRef.getType(); + } + + /** Returns the canonical complete declared type owned by this binding. */ + @Internal + public TypeRef typeRef() { + return typeRef; } public Class rawType() { @@ -89,6 +109,36 @@ public JsonFieldKind kind() { return kind; } + /** Returns whether this exact declared occurrence rejects a JSON or Java {@code null}. */ + @Internal + public boolean rejectsNull() { + return rejectsNull; + } + + /** Returns whether JSON {@code null} denotes an outer null for this exact occurrence. */ + @Internal + public boolean nullable() { + return nullable; + } + + /** Returns whether the logical non-null value itself materializes JSON {@code null}. */ + @Internal + public boolean transparentNull() { + return transparentNull; + } + + /** Returns the canonical logical codec's unboxed-member operation, or {@code null}. */ + @Internal + public UnboxedValueCodec unboxedValueCodec() { + return unboxedValueCodec; + } + + /** Throws the cold failure for a null supplied to this exact non-null occurrence. */ + @Internal + public void rejectNullValue() { + throw new ForyJsonException("JSON null is not allowed for " + typeRef); + } + public StringWriterCodec stringWriter() { return stringWriter; } diff --git a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java index a87107ab07..bd0391a990 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/resolver/JsonTypeResolver.java @@ -19,7 +19,7 @@ package org.apache.fory.json.resolver; -import java.lang.reflect.Constructor; +import java.lang.reflect.Executable; import java.lang.reflect.GenericArrayType; import java.lang.reflect.Method; import java.lang.reflect.Modifier; @@ -33,6 +33,7 @@ import java.util.HashSet; import java.util.IdentityHashMap; import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -62,11 +63,13 @@ import org.apache.fory.json.codec.ObjectCodec.AnyInfo; import org.apache.fory.json.codec.ScalarCodecs; import org.apache.fory.json.codec.StringWriterCodec; +import org.apache.fory.json.codec.UnboxedValueCodec; import org.apache.fory.json.codec.Utf16ReaderCodec; import org.apache.fory.json.codec.Utf8ReaderCodec; import org.apache.fory.json.codec.Utf8WriterCodec; import org.apache.fory.json.codegen.JsonCodegen; import org.apache.fory.json.codegen.JsonJITContext; +import org.apache.fory.json.meta.JsonCreatorDeclaration; import org.apache.fory.json.meta.JsonCreatorFieldInfo; import org.apache.fory.json.meta.JsonCreatorInfo; import org.apache.fory.json.meta.JsonFieldInfo; @@ -74,7 +77,9 @@ import org.apache.fory.json.meta.JsonFieldTable; import org.apache.fory.logging.Logger; import org.apache.fory.logging.LoggerFactory; +import org.apache.fory.meta.TypeExtMeta; import org.apache.fory.reflect.TypeRef; +import org.apache.fory.type.Types; /** * Local JSON type dispatcher used exclusively by one borrowed {@code ForyJson} state at a time. @@ -89,13 +94,14 @@ * *

{@code typeInfos} owns declared and parameterized bindings. {@code objectCodecs} breaks * recursive object-metadata construction by publishing the complete object owner before resolving - * its fields. {@code rawObjectTypeInfos} contains only canonical raw-class default-object bindings - * and is the publication index for generated capabilities. {@code canonicalObjectTypeInfos} indexes - * the same bindings by exact codec identity so custom and parameterized codecs never enter - * raw-class JIT dispatch. + * its fields. {@code rawObjectTypeInfos} contains only canonical raw-class default-object bindings. + * {@code canonicalObjectTypeInfos} indexes every exact declared object binding by codec identity so + * parameterized and language-semantic bindings own distinct generated capabilities. */ public final class JsonTypeResolver { private static final Logger LOG = LoggerFactory.getLogger(JsonTypeResolver.class); + private static final TypeExtMeta NON_NULL_SUBTYPE_TYPE = + TypeExtMeta.of(Types.UNKNOWN, false, false, false, false); private static final String NATIVE_INTERPRETER_MESSAGE = "Fory JSON is using interpreted codecs because the current configuration was not included " + "in this native image. Return this configuration from a reachable " @@ -111,6 +117,7 @@ public final class JsonTypeResolver { private final IdentityMap, JsonTypeInfo> canonicalObjectTypeInfos; private final IdentityMap> collectionCodecs; private final IdentityMap> subtypeTypeRoots; + private final IdentityHashMap, TypeRef> activeGenericBindings; private int resolutionDepth; private Class runtimeResolutionType; private Class subtypeResolutionBase; @@ -127,6 +134,7 @@ public JsonTypeResolver(JsonSharedRegistry sharedRegistry) { canonicalObjectTypeInfos = new IdentityMap<>(); collectionCodecs = new IdentityMap<>(); subtypeTypeRoots = new IdentityMap<>(); + activeGenericBindings = new IdentityHashMap<>(); } /** Returns the shared registry that owns this resolver and its reader cache domain. */ @@ -232,10 +240,8 @@ public ObjectCodec canonicalObjectCodec(JsonTypeInfo typeInfo) { } private ObjectCodec canonicalObjectOwner(JsonTypeInfo typeInfo) { - if (rawObjectTypeInfos.get(typeInfo.rawType()) != typeInfo) { - return null; - } - return objectCodecs.get(metadataKey(typeInfo)); + ObjectCodec owner = objectCodecs.get(metadataKey(typeInfo)); + return owner != null && canonicalObjectTypeInfos.get(owner) == typeInfo ? owner : null; } /** Returns an exact declared ArrayList-backed UTF-8 collection owner, or {@code null}. */ @@ -344,15 +350,46 @@ public JsonTypeInfo getTypeInfo(Type declaredType, Class fallback) { } } + /** Resolves one complete declared type without discarding nested type-use metadata. */ + @Internal + public JsonTypeInfo getTypeInfo(TypeRef declaredType) { + Class rawType = declaredType.getRawType(); + Object key = resolutionTypeKey(declaredType); + JsonTypeInfo typeInfo = typeInfos.get(key); + if (typeInfo == null && declaredType.getType() == rawType && runtimeResolutionType == rawType) { + typeInfo = runtimeTypeInfos.get(rawType); + } + if (typeInfo != null) { + return typeInfo; + } + ResolutionSnapshot snapshot = beginResolution(); + try { + JsonTypeInfo result = resolveTypeInfo(declaredType, key); + completeResolution(snapshot); + return result; + } catch (RuntimeException | Error e) { + rollbackResolution(snapshot); + throw e; + } finally { + endResolution(); + } + } + @Internal public JsonTypeInfo getTypeInfo(Type declaredType, Class fallback, JsonCodec annotation) { + return getTypeInfo( + typeRef(declaredType, CodecUtils.rawType(declaredType, fallback)), annotation); + } + + /** Resolves an annotation-selected representation without dropping occurrence metadata. */ + @Internal + public JsonTypeInfo getTypeInfo(TypeRef declaredType, JsonCodec annotation) { if (annotation == null) { - return getTypeInfo(declaredType, fallback); + return getTypeInfo(declaredType); } - Class rawType = CodecUtils.rawType(declaredType, fallback); ResolutionSnapshot snapshot = beginResolution(); try { - JsonTypeInfo result = resolveTypeInfo(declaredType, rawType, annotation); + JsonTypeInfo result = resolveTypeInfo(declaredType, annotation); completeResolution(snapshot); return result; } catch (RuntimeException | Error e) { @@ -366,16 +403,29 @@ public JsonTypeInfo getTypeInfo(Type declaredType, Class fallback, JsonCodec @Internal public JsonTypeInfo getTypeInfo( Type declaredType, Class fallback, Class> codecClass) { - Class rawType = CodecUtils.rawType(declaredType, fallback); - return annotationTypeInfo(declaredType, rawType, codecClass); + return getTypeInfo( + typeRef(declaredType, CodecUtils.rawType(declaredType, fallback)), codecClass); + } + + /** Resolves one exact annotation codec without dropping occurrence metadata. */ + @Internal + public JsonTypeInfo getTypeInfo( + TypeRef declaredType, Class> codecClass) { + return annotationTypeInfo(declaredType, codecClass); } @Internal public JsonTypeInfo getTypeInfo(Type declaredType, Class fallback, JsonFormat annotation) { - Class rawType = CodecUtils.rawType(declaredType, fallback); + return getTypeInfo( + typeRef(declaredType, CodecUtils.rawType(declaredType, fallback)), annotation); + } + + /** Resolves an exact format occurrence without dropping occurrence metadata. */ + @Internal + public JsonTypeInfo getTypeInfo(TypeRef declaredType, JsonFormat annotation) { ResolutionSnapshot snapshot = beginResolution(); try { - JsonTypeInfo result = resolveTypeInfo(declaredType, rawType, annotation); + JsonTypeInfo result = resolveTypeInfo(declaredType, annotation); completeResolution(snapshot); return result; } catch (RuntimeException | Error e) { @@ -386,22 +436,44 @@ public JsonTypeInfo getTypeInfo(Type declaredType, Class fallback, JsonFormat } } - /** Generates every hosted capability rooted at an explicitly selected Native Image model. */ + /** Generates hosted capabilities and returns their language-neutral object metadata owners. */ @Internal - public void generateHostedCodecs(Class type) { + public List> generateHostedCodecs(Class type) { if (!sharedRegistry.hostedCodegen()) { throw new IllegalStateException("Hosted JSON codec generation requires a hosted registry"); } - JsonTypeInfo typeInfo = getTypeInfo(type, type); + JsonTypeInfo typeInfo; + try { + typeInfo = getTypeInfo(type, type); + } catch (ExactTypeRequiredException ignored) { + // Analysis reachability retains a declaration but does not make its raw Class a schema. + // Exact semantic occurrences are generated when a selected parent resolves them. + return java.util.Collections.emptyList(); + } ArrayList roots = new ArrayList<>(1); roots.add(typeInfo); // A preceding selected model may already have resolved this type inside an uncodegenable graph. // Cached metadata is still a generation root; otherwise that earlier graph can suppress every // capability for an independently eligible annotated model. requestCapabilities(roots); + Set> models = java.util.Collections.newSetFromMap(new IdentityHashMap<>()); + models.addAll(objectCodecs.values()); + return new ArrayList<>(models); + } + + /** Returns effective creator declarations for exact language-metadata signature mapping. */ + @Internal + public List creatorDeclarations(Class type) { + return JsonCreatorDeclaration.findAll(type, sharedRegistry); } private JsonTypeInfo resolveTypeInfo(Type declaredType, Class rawType, Object key) { + return resolveTypeInfo(typeRef(declaredType, rawType), key); + } + + private JsonTypeInfo resolveTypeInfo(TypeRef declaredType, Object key) { + validateCovariant(declaredType); + Class rawType = declaredType.getRawType(); JsonTypeInfo typeInfo = customTypeInfo(declaredType, rawType); if (typeInfo != null) { publishTypeInfo(key, typeInfo); @@ -411,18 +483,20 @@ private JsonTypeInfo resolveTypeInfo(Type declaredType, Class rawType, Object if (definition != null) { sharedRegistry.checkSecure(rawType); ClosedSubtypeCodec codec = new ClosedSubtypeCodec(rawType, definition); - typeInfo = newTypeInfo(declaredType, rawType, codec); + typeInfo = newTypeInfo(declaredType, codec); // Closed graphs may recursively refer to their declared base through a subtype field or // container. Publish the complete dispatcher shell before resolving every finite branch. // The outer cold-resolution transaction removes the complete provisional graph on failure. publishTypeInfo(key, typeInfo); - codec.resolveTypes(TypeRef.of(declaredType), this); + codec.resolveTypes(declaredType, this); return typeInfo; } return buildTypeInfo(rawType, declaredType, key); } - private JsonTypeInfo resolveTypeInfo(Type declaredType, Class rawType, JsonCodec annotation) { + private JsonTypeInfo resolveTypeInfo(TypeRef declaredType, JsonCodec annotation) { + validateCovariant(declaredType); + Class rawType = declaredType.getRawType(); Class> valueCodec = annotation.value(); Class> elementCodec = annotation.elementCodec(); Class> contentCodec = annotation.contentCodec(); @@ -441,7 +515,7 @@ private JsonTypeInfo resolveTypeInfo(Type declaredType, Class rawType, JsonCo throw invalidCodecConfig(rawType, "value cannot be combined with a child codec"); } if (hasValue) { - return annotationTypeInfo(declaredType, rawType, valueCodec); + return annotationTypeInfo(declaredType, valueCodec); } if (sharedRegistry.customCodec(rawType) != null || sharedRegistry.codecDeclaration(rawType) != null @@ -450,34 +524,26 @@ private JsonTypeInfo resolveTypeInfo(Type declaredType, Class rawType, JsonCo rawType, "a child codec is hidden by the complete codec for the current value"); } sharedRegistry.checkSecure(rawType); - TypeRef typeRef = typeRef(declaredType, rawType); + TypeRef typeRef = declaredType; if (rawType.isArray()) { requireSlots(rawType, hasElement, !hasContent && !hasKey && !hasMapValue, "elementCodec"); - Type elementType = - declaredType instanceof GenericArrayType - ? ((GenericArrayType) declaredType).getGenericComponentType() - : rawType.getComponentType(); - requireConcreteChild(elementType, rawType, "elementCodec"); - Class elementRawType = CodecUtils.rawType(elementType, rawType.getComponentType()); - JsonTypeInfo elementInfo = annotationTypeInfo(elementType, elementRawType, elementCodec); - return newTypeInfo(declaredType, rawType, ArrayCodec.create(rawType, elementInfo)); + TypeRef elementType = typeRef.getComponentType(); + requireConcreteChild(elementType.getType(), rawType, "elementCodec"); + JsonTypeInfo elementInfo = annotationTypeInfo(elementType, elementCodec); + return newTypeInfo(declaredType, ArrayCodec.create(rawType, elementInfo)); } if (rawType == AtomicReferenceArray.class) { requireSlots(rawType, hasElement, !hasContent && !hasKey && !hasMapValue, "elementCodec"); TypeRef elementType = directElementType(typeRef, rawType, "elementCodec"); - JsonTypeInfo elementInfo = - annotationTypeInfo(elementType.getType(), elementType.getRawType(), elementCodec); - return newTypeInfo( - declaredType, rawType, new ScalarCodecs.AtomicReferenceArrayCodec(elementInfo)); + JsonTypeInfo elementInfo = annotationTypeInfo(elementType, elementCodec); + return newTypeInfo(declaredType, ScalarCodecs.AtomicReferenceArrayCodec.create(elementInfo)); } if (Collection.class.isAssignableFrom(rawType)) { requireSlots(rawType, hasElement, !hasContent && !hasKey && !hasMapValue, "elementCodec"); TypeRef elementType = directElementType(typeRef, rawType, "elementCodec"); - JsonTypeInfo elementInfo = - annotationTypeInfo(elementType.getType(), elementType.getRawType(), elementCodec); + JsonTypeInfo elementInfo = annotationTypeInfo(elementType, elementCodec); return newTypeInfo( declaredType, - rawType, CollectionCodec.create(rawType, elementType.getRawType(), elementInfo, this)); } if (Map.class.isAssignableFrom(rawType)) { @@ -496,40 +562,39 @@ private JsonTypeInfo resolveTypeInfo(Type declaredType, Class rawType, JsonCo } Class keyRawType = keyType.getRawType(); JsonTypeInfo valueInfo = - hasMapValue - ? annotationTypeInfo(mapValueType.getType(), mapValueType.getRawType(), mapValueCodec) - : getTypeInfo(mapValueType.getType(), mapValueType.getRawType()); + hasMapValue ? annotationTypeInfo(mapValueType, mapValueCodec) : getTypeInfo(mapValueType); checkMapKeySecure(keyRawType); MapCodec codec = hasKey ? MapCodec.create( - rawType, keyRawType, valueInfo, sharedRegistry.mapKeyCodec(keyRawType, keyCodec)) - : MapCodec.create(rawType, keyRawType, valueInfo); - return newTypeInfo(declaredType, rawType, codec); + rawType, keyType, valueInfo, sharedRegistry.mapKeyCodec(keyRawType, keyCodec)) + : MapCodec.create(rawType, keyType, valueInfo); + return newTypeInfo(declaredType, codec); } if (rawType == Optional.class || rawType == AtomicReference.class) { requireSlots(rawType, hasContent, !hasElement && !hasKey && !hasMapValue, "contentCodec"); TypeRef contentType = directElementType(typeRef, rawType, "contentCodec"); - JsonTypeInfo contentInfo = - annotationTypeInfo(contentType.getType(), contentType.getRawType(), contentCodec); + JsonTypeInfo contentInfo = annotationTypeInfo(contentType, contentCodec); JsonValueCodec codec = rawType == Optional.class - ? new ScalarCodecs.OptionalCodec(contentInfo) - : new ScalarCodecs.AtomicReferenceCodec(contentInfo); - return newTypeInfo(declaredType, rawType, codec); + ? new ScalarCodecs.OptionalCodec(declaredType, contentInfo) + : ScalarCodecs.AtomicReferenceCodec.create(declaredType, contentInfo); + return newTypeInfo(declaredType, codec); } JsonValueCodec codec = sharedRegistry.createCodec(rawType, typeRef, this); if (codec instanceof CompositeJsonCodec) { - JsonTypeInfo typeInfo = newTypeInfo(declaredType, rawType, JsonFieldKind.OBJECT, codec, true); + JsonTypeInfo typeInfo = newTypeInfo(declaredType, JsonFieldKind.OBJECT, codec, true); ((CompositeJsonCodec) codec).resolveTypes(typeRef, this, annotation); return typeInfo; } throw invalidCodecConfig(rawType, "does not support child codecs"); } - private JsonTypeInfo resolveTypeInfo(Type declaredType, Class rawType, JsonFormat annotation) { + private JsonTypeInfo resolveTypeInfo(TypeRef declaredType, JsonFormat annotation) { + validateCovariant(declaredType); + Class rawType = declaredType.getRawType(); if (ScalarCodecs.supportsDateTimeFormat(rawType)) { - return formatTypeInfo(declaredType, rawType, annotation); + return formatTypeInfo(declaredType, annotation); } if (sharedRegistry.customCodec(rawType) != null || sharedRegistry.codecDeclaration(rawType) != null @@ -538,31 +603,23 @@ private JsonTypeInfo resolveTypeInfo(Type declaredType, Class rawType, JsonFo throw invalidFormatConfig(rawType, "a complete representation hides its direct child"); } sharedRegistry.checkSecure(rawType); - TypeRef typeRef = typeRef(declaredType, rawType); + TypeRef typeRef = declaredType; if (rawType.isArray()) { - Type elementType = - declaredType instanceof GenericArrayType - ? ((GenericArrayType) declaredType).getGenericComponentType() - : rawType.getComponentType(); - requireConcreteChild(elementType, rawType, "element", "@JsonFormat"); - Class elementRawType = CodecUtils.rawType(elementType, rawType.getComponentType()); - JsonTypeInfo elementInfo = formatTypeInfo(elementType, elementRawType, annotation); - return newTypeInfo(declaredType, rawType, ArrayCodec.create(rawType, elementInfo)); + TypeRef elementType = typeRef.getComponentType(); + requireConcreteChild(elementType.getType(), rawType, "element", "@JsonFormat"); + JsonTypeInfo elementInfo = formatTypeInfo(elementType, annotation); + return newTypeInfo(declaredType, ArrayCodec.create(rawType, elementInfo)); } if (rawType == AtomicReferenceArray.class) { TypeRef elementType = directElementType(typeRef, rawType, "element", "@JsonFormat"); - JsonTypeInfo elementInfo = - formatTypeInfo(elementType.getType(), elementType.getRawType(), annotation); - return newTypeInfo( - declaredType, rawType, new ScalarCodecs.AtomicReferenceArrayCodec(elementInfo)); + JsonTypeInfo elementInfo = formatTypeInfo(elementType, annotation); + return newTypeInfo(declaredType, ScalarCodecs.AtomicReferenceArrayCodec.create(elementInfo)); } if (Collection.class.isAssignableFrom(rawType)) { TypeRef elementType = directElementType(typeRef, rawType, "element", "@JsonFormat"); - JsonTypeInfo elementInfo = - formatTypeInfo(elementType.getType(), elementType.getRawType(), annotation); + JsonTypeInfo elementInfo = formatTypeInfo(elementType, annotation); return newTypeInfo( declaredType, - rawType, CollectionCodec.create(rawType, elementType.getRawType(), elementInfo, this)); } if (Map.class.isAssignableFrom(rawType)) { @@ -570,37 +627,41 @@ private JsonTypeInfo resolveTypeInfo(Type declaredType, Class rawType, JsonFo Tuple2, TypeRef> children = CodecUtils.mapKeyValueTypeRefs(typeRef); TypeRef valueType = children.f1; requireConcreteChild(valueType.getType(), rawType, "value", "@JsonFormat"); - JsonTypeInfo valueInfo = - formatTypeInfo(valueType.getType(), valueType.getRawType(), annotation); + JsonTypeInfo valueInfo = formatTypeInfo(valueType, annotation); Class keyRawType = children.f0.getRawType(); checkMapKeySecure(keyRawType); - return newTypeInfo(declaredType, rawType, MapCodec.create(rawType, keyRawType, valueInfo)); + return newTypeInfo(declaredType, MapCodec.create(rawType, children.f0, valueInfo)); } if (rawType == Optional.class || rawType == AtomicReference.class) { TypeRef contentType = directElementType(typeRef, rawType, "content", "@JsonFormat"); - JsonTypeInfo contentInfo = - formatTypeInfo(contentType.getType(), contentType.getRawType(), annotation); + JsonTypeInfo contentInfo = formatTypeInfo(contentType, annotation); JsonValueCodec codec = rawType == Optional.class - ? new ScalarCodecs.OptionalCodec(contentInfo) - : new ScalarCodecs.AtomicReferenceCodec(contentInfo); - return newTypeInfo(declaredType, rawType, codec); + ? new ScalarCodecs.OptionalCodec(declaredType, contentInfo) + : ScalarCodecs.AtomicReferenceCodec.create(declaredType, contentInfo); + return newTypeInfo(declaredType, codec); } throw invalidFormatConfig(rawType, "requires a date/time value or supported direct wrapper"); } - private JsonTypeInfo formatTypeInfo(Type type, Class rawType, JsonFormat annotation) { + private JsonTypeInfo formatTypeInfo(TypeRef type, JsonFormat annotation) { + validateCovariant(type); + Class rawType = type.getRawType(); sharedRegistry.checkSecure(rawType); JsonValueCodec codec = ScalarCodecs.dateTimeFormatCodec(rawType, annotation.pattern(), annotation.timezone()); - return newTypeInfo(type, rawType, JsonFieldKind.OBJECT, codec, true); + return newTypeInfo(type, JsonFieldKind.OBJECT, codec, true); } private JsonTypeInfo customTypeInfo(Type declaredType, Class rawType) { + return customTypeInfo(typeRef(declaredType, rawType), rawType); + } + + private JsonTypeInfo customTypeInfo(TypeRef declaredType, Class rawType) { JsonValueCodec codec = sharedRegistry.customCodec(rawType); if (codec != null) { sharedRegistry.checkCustomSecure(rawType); - return newTypeInfo(declaredType, rawType, JsonFieldKind.OBJECT, codec, false); + return newTypeInfo(declaredType, JsonFieldKind.OBJECT, codec, false); } JsonCodecDeclaration declaration = sharedRegistry.codecDeclaration(rawType); if (declaration != null) { @@ -608,21 +669,35 @@ private JsonTypeInfo customTypeInfo(Type declaredType, Class rawType) { rejectConflictingValue(rawType); } codec = sharedRegistry.annotationCodec(rawType, declaration.codecClass()); - codec = declaration.bind(declaredType, rawType, codec); - return newTypeInfo(declaredType, rawType, JsonFieldKind.OBJECT, codec, true); + codec = declaration.bind(declaredType.getType(), rawType, codec); + return newTypeInfo(declaredType, JsonFieldKind.OBJECT, codec, true); } JsonValueDeclaration value = sharedRegistry.valueDeclaration(rawType); if (value == null) { return null; } sharedRegistry.checkSecure(rawType); - return newTypeInfo(declaredType, rawType, JsonFieldKind.OBJECT, value.codec(), true); + return newTypeInfo(declaredType, JsonFieldKind.OBJECT, value.codec(), true); } private JsonTypeInfo annotationTypeInfo( - Type type, Class rawType, Class> codecClass) { + TypeRef type, Class> codecClass) { + validateCovariant(type); + Class rawType = type.getRawType(); JsonValueCodec codec = sharedRegistry.annotationCodec(rawType, codecClass); - return newTypeInfo(type, rawType, JsonFieldKind.OBJECT, codec, true); + return newTypeInfo(type, JsonFieldKind.OBJECT, codec, true); + } + + private void validateCovariant(TypeRef type) { + TypeExtMeta metadata = type.getTypeExtMeta(); + if (metadata == null || !metadata.covariant()) { + return; + } + Class rawType = type.getRawType(); + if (!Modifier.isFinal(rawType.getModifiers()) && sharedRegistry.subTypesInfo(rawType) == null) { + throw new ForyJsonException( + "Covariant JSON type must be final or declare effective @JsonSubTypes: " + type); + } } private static TypeRef directElementType(TypeRef typeRef, Class rawType, String slot) { @@ -911,28 +986,57 @@ public boolean resolvingRuntimeType() { return runtimeResolutionType != null; } - /** Resolves one branch while preserving the closed root that owns its discriminator schema. */ + /** + * Resolves one branch, honoring an exact registration before its parent-selected object codec. + */ @Internal public JsonTypeInfo getSubtypeTypeInfo( - Class baseType, Type declaredType, Class fallback, boolean isolated) { + Class baseType, TypeRef subtypeType, boolean isolated, ObjectCodec selectedCodec) { Class previousBase = subtypeResolutionBase; boolean previousIsolation = isolateSubtypeResolution; subtypeResolutionBase = baseType; isolateSubtypeResolution = isolated; try { - return getTypeInfo(declaredType, fallback); + // A discriminator selects one concrete branch value, so its outer occurrence is non-null. + // Preserve the resolved generic children while giving language factories that exact type. + TypeRef declaredType = + TypeRef.ofSemanticTypeArguments( + subtypeType.getType(), + NON_NULL_SUBTYPE_TYPE, + subtypeType.getTypeArguments(), + subtypeType.isArray() ? subtypeType.getComponentType() : null); + if (selectedCodec == null) { + return getTypeInfo(declaredType); + } + Class rawType = declaredType.getRawType(); + Object key = resolutionTypeKey(declaredType); + JsonTypeInfo typeInfo = typeInfos.get(key); + if (typeInfo != null) { + return typeInfo; + } + ResolutionSnapshot snapshot = beginResolution(); + try { + validateCovariant(declaredType); + typeInfo = customTypeInfo(declaredType, rawType); + if (typeInfo != null) { + publishTypeInfo(key, typeInfo); + } else { + typeInfo = buildTypeInfo(rawType, declaredType, key, selectedCodec); + } + completeResolution(snapshot); + return typeInfo; + } catch (RuntimeException | Error e) { + rollbackResolution(snapshot); + throw e; + } finally { + endResolution(); + } } finally { subtypeResolutionBase = previousBase; isolateSubtypeResolution = previousIsolation; } } - /** Returns whether the current cold lookup is resolving a branch of {@code baseType}. */ - @Internal - public boolean resolvingSubtypeOf(Class baseType) { - return subtypeResolutionBase == baseType; - } - public void checkSecure(Class type) { sharedRegistry.checkSecure(type); } @@ -942,79 +1046,120 @@ public void checkSecure(Class type) { public ObjectCodec createObjectCodec(TypeRef ownerType, JsonObjectModel objectModel) { Class type = ownerType.getRawType(); sharedRegistry.checkSecure(type); - validateObjectModel(type, objectModel); + validateObjectModel(ownerType, objectModel); + if (!sharedRegistry.hostedCodegen() && sharedRegistry.missingNativeConfiguration()) { + throw new ForyJsonException( + "Missing provider-selected Fory JSON Native configuration for language object model " + + ownerType); + } + // The language module already owns the exact construction/accessor model. A Java generated + // companion may still supply faster operations, but its absence must not override that model. + GeneratedJsonCodec generatedCodec = sharedRegistry.generatedCodecIfPresent(ownerType); return ObjectCodec.build( ownerType, sharedRegistry.propertyDiscoveryEnabled(), sharedRegistry.propertyNamingStrategy(), sharedRegistry.writeNullFields(), sharedRegistry, - sharedRegistry.generatedCodec(type), + generatedCodec, objectModel); } - private static void validateObjectModel(Class type, JsonObjectModel objectModel) { - Constructor constructor = objectModel.constructor(); - int modifiers = constructor.getModifiers(); - if (constructor.getDeclaringClass() != type - || !Modifier.isPublic(modifiers) - || constructor.isSynthetic() - || constructor.isVarArgs() - || constructor.getTypeParameters().length != 0) { - throw new ForyJsonException("Invalid JSON object-model constructor " + constructor); - } - Class[] parameterTypes = constructor.getParameterTypes(); - String[] names = objectModel.parameterNames(); - Method[] accessors = objectModel.accessors(); - for (int i = 0; i < accessors.length; i++) { - Method accessor = accessors[i]; - if (accessor == null) { - continue; - } - int accessorModifiers = accessor.getModifiers(); - boolean unitAccessor = - accessor.getReturnType() == void.class - && parameterTypes[i].getName().equals("scala.runtime.BoxedUnit"); - if (!accessor.getName().equals(names[i]) - || accessor.getParameterCount() != 0 - || accessor.getReturnType() != parameterTypes[i] && !unitAccessor - || !Modifier.isPublic(accessorModifiers) - || Modifier.isStatic(accessorModifiers) - || accessor.isBridge() - || accessor.isSynthetic() - || !accessor.getDeclaringClass().isAssignableFrom(type)) { - throw new ForyJsonException("Invalid JSON object-model accessor " + accessor); + private static void validateObjectModel(TypeRef ownerType, JsonObjectModel objectModel) { + Class type = ownerType.getRawType(); + Object fixedInstance = objectModel.fixedInstance(); + if (fixedInstance != null) { + if (fixedInstance.getClass() != type) { + throw new ForyJsonException("Invalid fixed JSON object model for " + type.getName()); + } + } else { + Executable creator = objectModel.creator(); + Executable invocationCreator = objectModel.invocationCreator(); + int modifiers = creator.getModifiers(); + if (creator.getDeclaringClass() != type + || creator.isSynthetic() + || creator.isVarArgs() + || creator.getTypeParameters().length != 0 + || creator instanceof Method + && (!Modifier.isPublic(modifiers) + || !Modifier.isStatic(modifiers) + || ((Method) creator).isBridge() + || ((Method) creator).getReturnType() != type)) { + throw new ForyJsonException("Invalid JSON object-model creator " + creator); + } + int invocationModifiers = invocationCreator.getModifiers(); + if (invocationCreator.getDeclaringClass() != type + || !Modifier.isPublic(invocationModifiers) + || invocationCreator.isVarArgs() + || invocationCreator.getTypeParameters().length != 0 + || invocationCreator == creator && !Modifier.isPublic(modifiers) + || invocationCreator instanceof Method + && (!Modifier.isStatic(invocationModifiers) + || ((Method) invocationCreator).isBridge() + || ((Method) invocationCreator).getReturnType() != type)) { + throw new ForyJsonException("Invalid JSON object-model invocation " + invocationCreator); + } + Class[] parameterTypes = creator.getParameterTypes(); + Type[] genericParameterTypes = creator.getGenericParameterTypes(); + TypeRef[] logicalParameterTypes = objectModel.parameterTypes(); + Method[] accessors = objectModel.accessors(); + for (int i = 0; i < accessors.length; i++) { + if (!compatibleObjectModelType( + ownerType, + genericParameterTypes[i], + parameterTypes[i], + parameterTypes[i], + logicalParameterTypes[i])) { + throw new ForyJsonException( + "Invalid JSON object-model creator parameter " + creator + " at index " + i); + } + Method accessor = accessors[i]; + if (accessor == null) { + continue; + } + int accessorModifiers = accessor.getModifiers(); + if (accessor.getParameterCount() != 0 + || !compatibleObjectModelType( + ownerType, + accessor.getGenericReturnType(), + accessor.getReturnType(), + parameterTypes[i], + logicalParameterTypes[i]) + || !Modifier.isPublic(accessorModifiers) + || Modifier.isStatic(accessorModifiers) + || accessor.isBridge() + || accessor.isSynthetic() + || !accessor.getDeclaringClass().isAssignableFrom(type)) { + throw new ForyJsonException("Invalid JSON object-model accessor " + accessor); + } } } String[] propertyNames = objectModel.propertyNames(); Method[] getters = objectModel.propertyGetters(); Method[] setters = objectModel.propertySetters(); + TypeRef[] propertyTypes = objectModel.propertyTypes(); for (int i = 0; i < propertyNames.length; i++) { Method getter = getters[i]; Method setter = setters[i]; - if (getter == null && setter == null) { - throw new ForyJsonException( - "JSON object-model property has no accessor " + propertyNames[i]); - } - Class propertyType = - getter == null ? setter.getParameterTypes()[0] : getter.getReturnType(); - boolean unitGetter = - getter != null - && getter.getReturnType() == void.class - && parameterType(propertyNames[i], names, parameterTypes) != null - && parameterType(propertyNames[i], names, parameterTypes) - .getName() - .equals("scala.runtime.BoxedUnit"); if (getter != null - && (!getter.getName().equals(propertyNames[i]) - || getter.getParameterCount() != 0 - || getter.getReturnType() == void.class && !unitGetter + && (getter.getParameterCount() != 0 + || !compatibleObjectModelType( + ownerType, + getter.getGenericReturnType(), + getter.getReturnType(), + getter.getReturnType(), + propertyTypes[i]) || !validObjectModelMethod(type, getter))) { throw new ForyJsonException("Invalid JSON object-model getter " + getter); } if (setter != null && (setter.getParameterCount() != 1 - || setter.getParameterTypes()[0] != propertyType + || !compatibleObjectModelType( + ownerType, + setter.getGenericParameterTypes()[0], + setter.getParameterTypes()[0], + setter.getParameterTypes()[0], + propertyTypes[i]) || setter.getReturnType() != void.class || !validObjectModelMethod(type, setter))) { throw new ForyJsonException("Invalid JSON object-model setter " + setter); @@ -1022,14 +1167,36 @@ && parameterType(propertyNames[i], names, parameterTypes) } } - private static Class parameterType( - String propertyName, String[] names, Class[] parameterTypes) { - for (int i = 0; i < names.length; i++) { - if (propertyName.equals(names[i])) { - return parameterTypes[i]; - } + private static boolean compatibleObjectModelType( + TypeRef ownerType, + Type memberGenericType, + Class memberType, + Class invocationType, + TypeRef logicalType) { + if (JsonObjectModel.compatibleType(ownerType.resolveType(memberGenericType), logicalType)) { + return true; + } + // A language value may be lowered to a different parent carrier. Do not resolve the logical + // child here: the parent ObjectCodec shell has not been published yet and its underlying value + // can recursively refer back to this owner. The published shell's phase-two field binding must + // obtain the canonical logical codec and prove its exact UnboxedValueCodec carrier. + if (memberType == invocationType + && UnboxedValueCodec.requiresCarrier(memberType, logicalType)) { + return true; + } + if (memberType == void.class) { + return invocationType.getName().equals("scala.runtime.BoxedUnit") + || logicalType.getRawType().getName().equals("scala.runtime.BoxedUnit") + || logicalType.getRawType().getName().equals("kotlin.Unit"); } - return null; + // Scala 3 emits a BoxedUnit method descriptor with a void generic signature for a Unit + // case-class accessor. Reflection therefore reports BoxedUnit as the raw return type and void + // as the generic return type even though the constructor and logical property both use + // BoxedUnit. + return memberGenericType == void.class + && memberType.getName().equals("scala.runtime.BoxedUnit") + && invocationType == memberType + && logicalType.getRawType() == memberType; } private static boolean validObjectModelMethod(Class type, Method method) { @@ -1674,10 +1841,10 @@ private void addReadDependency( /** Returns whether a generated writer must traverse this cyclic edge through its type slot. */ @Internal - public boolean usesWriterSlot(Class ownerType, JsonTypeInfo child) { + public boolean usesWriterSlot(ObjectCodec ownerCodec, JsonTypeInfo child) { jitContext.lock(); try { - JsonTypeInfo owner = rawObjectTypeInfos.get(ownerType); + JsonTypeInfo owner = canonicalObjectTypeInfos.get(ownerCodec); return owner != null && child != owner && canonicalObjectOwner(child) != null @@ -1689,16 +1856,15 @@ && canonicalObjectOwner(child) != null /** Returns whether a generated reader must traverse this cyclic edge through its type slot. */ @Internal - public boolean usesReaderSlot(Class ownerType, JsonTypeInfo child) { + public boolean usesReaderSlot(ObjectCodec ownerCodec, JsonTypeInfo child) { jitContext.lock(); try { - JsonTypeInfo ownerInfo = rawObjectTypeInfos.get(ownerType); - ObjectCodec owner = ownerInfo == null ? null : canonicalObjectOwner(ownerInfo); + JsonTypeInfo owner = canonicalObjectTypeInfos.get(ownerCodec); return owner != null - && owner.unwrappedInfo() == null - && child != ownerInfo + && ownerCodec.unwrappedInfo() == null + && child != owner && canonicalObjectOwner(child) != null - && reachesReader(child, ownerInfo, new IdentityMap<>()); + && reachesReader(child, owner, new IdentityMap<>()); } finally { jitContext.unlock(); } @@ -1780,8 +1946,17 @@ private boolean reachesReader( return false; } - private boolean canCompile(ObjectCodec owner, CapabilityKind kind) { - if (nativeObjectClass(owner.type(), kind) != null) { + private boolean canCompile(JsonTypeInfo typeInfo, ObjectCodec owner, CapabilityKind kind) { + if (owner.fixedInstance()) { + return false; + } + if (nativeObjectClass(typeInfo.typeRef(), kind) != null) { + return true; + } + if (sharedRegistry.nativeGeneratedClasses()) { + // A selected Native configuration is a closed generated-capability universe. Keep the graph + // eligible so loadNativeClasses remains the sole owner of exact lookup and cold failure; + // silently retaining this ObjectCodec would enter reflection-backed construction at runtime. return true; } return codegen != null @@ -1791,18 +1966,18 @@ private boolean canCompile(ObjectCodec owner, CapabilityKind kind) { } private boolean canCompileCollection(JsonTypeInfo typeInfo, CapabilityKind kind) { - Type type = typeInfo.type(); + TypeRef type = typeInfo.typeRef(); boolean generated = kind == CapabilityKind.UTF8_WRITER ? sharedRegistry.nativeUtf8CollectionWriterClass(type) != null : sharedRegistry.nativeUtf8CollectionReaderClass(type) != null; - if (generated) { + if (generated || sharedRegistry.nativeGeneratedClasses()) { return true; } return codegen != null; } - private Class nativeObjectClass(Class type, CapabilityKind kind) { + private Class nativeObjectClass(TypeRef type, CapabilityKind kind) { switch (kind) { case STRING_WRITER: return sharedRegistry.nativeStringWriterClass(type); @@ -1842,24 +2017,26 @@ private CompletableFuture> generatedClass(CapabilityNode node, Capabili } if (node.collectionOwner != null) { if (kind == CapabilityKind.UTF8_WRITER) { - return sharedRegistry.utf8CollectionWriterClass(node.typeInfo.type(), node.collectionOwner); + return sharedRegistry.utf8CollectionWriterClass( + node.typeInfo.typeRef(), node.collectionOwner); } if (kind == CapabilityKind.UTF8_READER) { - return sharedRegistry.utf8CollectionReaderClass(node.typeInfo.type(), node.collectionOwner); + return sharedRegistry.utf8CollectionReaderClass( + node.typeInfo.typeRef(), node.collectionOwner); } throw new IllegalStateException("Unsupported generated JSON collection capability " + kind); } switch (kind) { case STRING_WRITER: - return sharedRegistry.stringWriterClass(node.objectOwner, this); + return sharedRegistry.stringWriterClass(node.typeInfo, node.objectOwner, this); case UTF8_WRITER: - return sharedRegistry.utf8WriterClass(node.objectOwner, this); + return sharedRegistry.utf8WriterClass(node.typeInfo, node.objectOwner, this); case LATIN1_READER: - return sharedRegistry.latin1ReaderClass(node.objectOwner, this); + return sharedRegistry.latin1ReaderClass(node.typeInfo, node.objectOwner, this); case UTF16_READER: - return sharedRegistry.utf16ReaderClass(node.objectOwner, this); + return sharedRegistry.utf16ReaderClass(node.typeInfo, node.objectOwner, this); case UTF8_READER: - return sharedRegistry.utf8ReaderClass(node.objectOwner, this); + return sharedRegistry.utf8ReaderClass(node.typeInfo, node.objectOwner, this); default: throw new IllegalStateException("Unknown JSON capability kind " + kind); } @@ -1871,14 +2048,14 @@ private Class nativeGeneratedClass(CapabilityNode node, CapabilityKind kind) } if (node.collectionOwner != null) { if (kind == CapabilityKind.UTF8_WRITER) { - return sharedRegistry.nativeUtf8CollectionWriterClass(node.typeInfo.type()); + return sharedRegistry.nativeUtf8CollectionWriterClass(node.typeInfo.typeRef()); } if (kind == CapabilityKind.UTF8_READER) { - return sharedRegistry.nativeUtf8CollectionReaderClass(node.typeInfo.type()); + return sharedRegistry.nativeUtf8CollectionReaderClass(node.typeInfo.typeRef()); } throw new IllegalStateException("Unsupported generated JSON collection capability " + kind); } - return nativeObjectClass(node.objectOwner.type(), kind); + return nativeObjectClass(node.typeInfo.typeRef(), kind); } private Object newCapability( @@ -1975,11 +2152,16 @@ private Object newSubtypeReaders( for (int i = 0; i < childCount; i++) { JsonFieldTable table = subtype.inlineReadTable(i); if (table != null) { - JsonTypeInfo child = subtype.child(i); - ObjectCodec owner = erase(requireObjectOwner(child)); - Latin1ReaderCodec canonical = resolvedCapability(child, capabilities, kind); - latin1Readers[i] = - newLatin1Reader(owner, canonical.getClass(), table, capabilities, canonical); + ClosedSubtypeCodec.InlineReader fixed = subtype.fixedInlineReader(i); + if (fixed != null) { + latin1Readers[i] = fixed; + } else { + JsonTypeInfo child = subtype.child(i); + ObjectCodec owner = erase(requireObjectOwner(child)); + Latin1ReaderCodec canonical = resolvedCapability(child, capabilities, kind); + latin1Readers[i] = + newLatin1Reader(owner, canonical.getClass(), table, capabilities, canonical); + } } } return latin1Readers; @@ -1989,11 +2171,16 @@ private Object newSubtypeReaders( for (int i = 0; i < childCount; i++) { JsonFieldTable table = subtype.inlineReadTable(i); if (table != null) { - JsonTypeInfo child = subtype.child(i); - ObjectCodec owner = erase(requireObjectOwner(child)); - Utf16ReaderCodec canonical = resolvedCapability(child, capabilities, kind); - utf16Readers[i] = - newUtf16Reader(owner, canonical.getClass(), table, capabilities, canonical); + ClosedSubtypeCodec.InlineReader fixed = subtype.fixedInlineReader(i); + if (fixed != null) { + utf16Readers[i] = fixed; + } else { + JsonTypeInfo child = subtype.child(i); + ObjectCodec owner = erase(requireObjectOwner(child)); + Utf16ReaderCodec canonical = resolvedCapability(child, capabilities, kind); + utf16Readers[i] = + newUtf16Reader(owner, canonical.getClass(), table, capabilities, canonical); + } } } return utf16Readers; @@ -2003,11 +2190,16 @@ private Object newSubtypeReaders( for (int i = 0; i < childCount; i++) { JsonFieldTable table = subtype.inlineReadTable(i); if (table != null) { - JsonTypeInfo child = subtype.child(i); - ObjectCodec owner = erase(requireObjectOwner(child)); - Utf8ReaderCodec canonical = resolvedCapability(child, capabilities, kind); - utf8Readers[i] = - newUtf8Reader(owner, canonical.getClass(), table, capabilities, canonical); + ClosedSubtypeCodec.InlineReader fixed = subtype.fixedInlineReader(i); + if (fixed != null) { + utf8Readers[i] = fixed; + } else { + JsonTypeInfo child = subtype.child(i); + ObjectCodec owner = erase(requireObjectOwner(child)); + Utf8ReaderCodec canonical = resolvedCapability(child, capabilities, kind); + utf8Readers[i] = + newUtf8Reader(owner, canonical.getClass(), table, capabilities, canonical); + } } } return utf8Readers; @@ -2184,11 +2376,17 @@ private boolean addObject(ObjectCodec rawOwner, JsonTypeInfo typeInfo, boolea if (initial != owner) { return true; } + // A fixed object is already the complete canonical body capability. It is a resolved leaf in + // a generated parent or closed-subtype graph and must not reject that graph merely because + // the singleton body itself has no generated class. + if (owner.fixedInstance()) { + return true; + } CapabilityNode existing = nodes.get(typeInfo); if (existing != null) { return existing.complete || slotEdge; } - if (!canCompile(owner, kind)) { + if (!canCompile(typeInfo, owner, kind)) { return false; } CapabilityNode node = new CapabilityNode(typeInfo, owner, initial); @@ -2197,8 +2395,7 @@ private boolean addObject(ObjectCodec rawOwner, JsonTypeInfo typeInfo, boolea for (int i = 0; i < children.size(); i++) { JsonTypeInfo child = children.get(i); boolean writer = kind == CapabilityKind.STRING_WRITER || kind == CapabilityKind.UTF8_WRITER; - boolean childSlot = - writer ? usesWriterSlot(owner.type(), child) : usesReaderSlot(owner.type(), child); + boolean childSlot = writer ? usesWriterSlot(owner, child) : usesReaderSlot(owner, child); if (!addDependency(child, childSlot)) { return false; } @@ -2259,8 +2456,8 @@ private void loadNativeClasses() { if (node.subtypeOwner == null) { node.generatedClass = nativeGeneratedClass(node, kind); if (node.generatedClass == null) { - throw new IllegalStateException( - "Missing generated Fory JSON class for " + node.typeInfo.type()); + throw new ForyJsonException( + "Missing generated Fory JSON class for exact type " + node.typeInfo.type()); } } } @@ -2376,14 +2573,19 @@ private ObjectCodec buildObjectCodec(TypeRef ownerType, Object key) { if (cached != null) { return (ObjectCodec) cached; } - ObjectCodec codec = newObjectCodec(ownerType); - // Publish the complete declared-type owner before resolving fields so recursive parameterized - // bindings resolve back to the same field table rather than the raw-class binding. - objectCodecs.put(key, codec); - // The outer resolution transaction owns failure cleanup. Keep this owner published until that - // rollback removes its canonical identity index and every other provisional graph entry. - codec.resolveTypes(this); - return codec; + boolean bindingOwner = enterObjectBinding(ownerType); + try { + ObjectCodec codec = newObjectCodec(ownerType); + // Publish the complete declared-type owner before resolving fields so recursive parameterized + // bindings resolve back to the same field table rather than the raw-class binding. + objectCodecs.put(key, codec); + // The outer resolution transaction owns failure cleanup. Keep this owner published until that + // rollback removes its canonical identity index and every other provisional graph entry. + codec.resolveTypes(this); + return codec; + } finally { + exitObjectBinding(ownerType, bindingOwner); + } } private ObjectCodec newObjectCodec(TypeRef ownerType) { @@ -2396,7 +2598,7 @@ private ObjectCodec newObjectCodec(TypeRef ownerType) { || rawType.isEnum()) { throw new ForyJsonException("Unsupported JSON object type " + rawType); } - GeneratedJsonCodec generatedCodec = sharedRegistry.generatedCodec(rawType); + GeneratedJsonCodec generatedCodec = sharedRegistry.generatedCodec(ownerType); return ObjectCodec.build( ownerType, sharedRegistry.propertyDiscoveryEnabled(), @@ -2406,10 +2608,17 @@ private ObjectCodec newObjectCodec(TypeRef ownerType) { generatedCodec); } - private JsonTypeInfo buildTypeInfo(Class rawType, Type declaredType, Object key) { + private JsonTypeInfo buildTypeInfo(Class rawType, TypeRef typeRef, Object key) { + return buildTypeInfo(rawType, typeRef, key, null); + } + + private JsonTypeInfo buildTypeInfo( + Class rawType, TypeRef typeRef, Object key, ObjectCodec selectedCodec) { sharedRegistry.checkSecure(rawType); - TypeRef typeRef = typeRef(declaredType, rawType); - JsonValueCodec codec = sharedRegistry.createCodec(rawType, typeRef, this); + JsonValueCodec codec = + selectedCodec == null + ? sharedRegistry.createCodec(rawType, typeRef, this) + : sharedRegistry.createSubtypeCodec(rawType, typeRef, this, selectedCodec); if (codec == null) { return buildObjectTypeInfo(typeRef, key); } @@ -2417,10 +2626,20 @@ private JsonTypeInfo buildTypeInfo(Class rawType, Type declaredType, Object k if (recursiveTypeInfo != null) { return recursiveTypeInfo; } - JsonTypeInfo typeInfo = newTypeInfo(declaredType, rawType, codec); if (codec instanceof ObjectCodec) { - objectCodecs.put(key, (ObjectCodec) codec); + boolean bindingOwner = enterObjectBinding(typeRef); + try { + JsonTypeInfo typeInfo = newTypeInfo(typeRef, codec); + objectCodecs.put(key, (ObjectCodec) codec); + publishTypeInfo(key, typeInfo); + registerTypeInfoOwner(typeInfo, codec); + resolveCodecTypes(codec, typeRef); + return typeInfo; + } finally { + exitObjectBinding(typeRef, bindingOwner); + } } + JsonTypeInfo typeInfo = newTypeInfo(typeRef, codec); publishTypeInfo(key, typeInfo); registerTypeInfoOwner(typeInfo, codec); resolveCodecTypes(codec, typeRef); @@ -2440,27 +2659,70 @@ private JsonTypeInfo buildObjectTypeInfo(TypeRef ownerType, Object key) { } ObjectCodec codec = objectCodecs.get(key); if (codec == null) { - codec = newObjectCodec(ownerType); - typeInfo = newTypeInfo(ownerType.getType(), ownerType.getRawType(), codec); - // The object codec and its heterogeneous type owner are one recursive metadata unit. Both - // must be visible before any field resolves so self-references reuse the same field table and - // capability slots. The outer cold-resolution transaction removes both on failure. - objectCodecs.put(key, codec); - publishTypeInfo(key, typeInfo); - registerTypeInfoOwner(typeInfo, codec); - codec.resolveTypes(this); - return typeInfo; + boolean bindingOwner = enterObjectBinding(ownerType); + try { + codec = newObjectCodec(ownerType); + typeInfo = newTypeInfo(ownerType, codec); + // The object codec and its heterogeneous type owner are one recursive metadata unit. Both + // must be visible before any field resolves so self-references reuse the same field table + // and + // capability slots. The outer cold-resolution transaction removes both on failure. + objectCodecs.put(key, codec); + publishTypeInfo(key, typeInfo); + registerTypeInfoOwner(typeInfo, codec); + codec.resolveTypes(this); + return typeInfo; + } finally { + exitObjectBinding(ownerType, bindingOwner); + } } // A public getObjectCodec call may already own construction of this shell. Bind its type info // now; the outer owner finishes field resolution before returning the codec to its caller. - typeInfo = newTypeInfo(ownerType.getType(), ownerType.getRawType(), codec); + typeInfo = newTypeInfo(ownerType, codec); publishTypeInfo(key, typeInfo); registerTypeInfoOwner(typeInfo, codec); return typeInfo; } + private boolean enterObjectBinding(TypeRef type) { + Class rawType = type.getRawType(); + if (rawType.getTypeParameters().length == 0) { + return false; + } + TypeRef active = activeGenericBindings.get(rawType); + if (active == null) { + activeGenericBindings.put(rawType, type); + return true; + } + if (!active.getTypeArguments().equals(type.getTypeArguments())) { + throw expandingGenericType(rawType, active, type); + } + return false; + } + + private void exitObjectBinding(TypeRef type, boolean owner) { + if (owner) { + activeGenericBindings.remove(type.getRawType()); + } + } + + private static ForyJsonException expandingGenericType( + Class rawType, TypeRef active, TypeRef nested) { + return new ForyJsonException( + "JSON generic recursion expands " + + rawType.getName() + + " from " + + active + + " to " + + nested); + } + private JsonTypeInfo newTypeInfo(Type type, Class rawType, JsonValueCodec codec) { - return new JsonTypeInfo(type, rawType, sharedRegistry.kind(rawType), bindCodec(codec)); + return newTypeInfo(typeRef(type, rawType), codec); + } + + private JsonTypeInfo newTypeInfo(TypeRef typeRef, JsonValueCodec codec) { + return new JsonTypeInfo(typeRef, sharedRegistry.kind(typeRef.getRawType()), bindCodec(codec)); } private JsonTypeInfo newTypeInfo( @@ -2469,19 +2731,24 @@ private JsonTypeInfo newTypeInfo( JsonFieldKind kind, JsonValueCodec codec, boolean annotationCodec) { - return new JsonTypeInfo(type, rawType, kind, bindCodec(codec), annotationCodec); + return new JsonTypeInfo(typeRef(type, rawType), kind, bindCodec(codec), annotationCodec); + } + + private JsonTypeInfo newTypeInfo( + TypeRef typeRef, JsonFieldKind kind, JsonValueCodec codec, boolean annotationCodec) { + return new JsonTypeInfo(typeRef, kind, bindCodec(codec), annotationCodec); } private void registerTypeInfoOwner(JsonTypeInfo typeInfo, JsonValueCodec initialCodec) { if (initialCodec instanceof CollectionCodec) { collectionCodecs.put(typeInfo, (CollectionCodec) initialCodec); } - if (initialCodec instanceof ObjectCodec - && typeInfo.type() instanceof Class - && typeInfo.rawType() != Object.class) { + if (initialCodec instanceof ObjectCodec && typeInfo.rawType() != Object.class) { ObjectCodec owner = (ObjectCodec) initialCodec; - rawObjectTypeInfos.put(typeInfo.rawType(), typeInfo); canonicalObjectTypeInfos.put(owner, typeInfo); + if (typeInfo.type() instanceof Class) { + rawObjectTypeInfos.put(typeInfo.rawType(), typeInfo); + } } } @@ -2494,7 +2761,7 @@ private void publishTypeInfo(Object key, JsonTypeInfo typeInfo) { private Object metadataKey(JsonTypeInfo typeInfo) { Class subtypeRoot = subtypeTypeRoots.get(typeInfo); - Object key = typeInfoKey(typeInfo.type(), typeInfo.rawType()); + Object key = typeInfoKey(typeInfo.typeRef()); return subtypeRoot == null ? key : new SubtypeTypeKey(subtypeRoot, key); } @@ -2507,6 +2774,17 @@ private Object resolutionTypeKey(Type declaredType, Class rawType) { return subtypeTypeKey(subtypeResolutionBase, declaredType, rawType); } + private Object resolutionTypeKey(TypeRef declaredType) { + if (!isolateSubtypeResolution || subtypeResolutionBase == null) { + return typeInfoKey(declaredType); + } + Object key = typeInfoKey(declaredType); + return referencesSubtype( + subtypeResolutionBase, declaredType.getType(), declaredType.getRawType()) + ? new SubtypeTypeKey(subtypeResolutionBase, key) + : key; + } + private static Object subtypeTypeKey(Class baseType, Type declaredType, Class rawType) { Object key = typeInfoKey(declaredType, rawType); return referencesSubtype(baseType, declaredType, rawType) @@ -2550,6 +2828,13 @@ private static Object typeInfoKey(Type declaredType, Class rawType) { return declaredType instanceof Class ? rawType : declaredType; } + private static Object typeInfoKey(TypeRef declaredType) { + if (declaredType.hasTypeExtMeta()) { + return declaredType; + } + return typeInfoKey(declaredType.getType(), declaredType.getRawType()); + } + private static final class SubtypeTypeKey { private final Class baseType; private final Object typeKey; diff --git a/java/fory-json/src/main/java/org/apache/fory/json/writer/JsonWriter.java b/java/fory-json/src/main/java/org/apache/fory/json/writer/JsonWriter.java index ff50636d1e..15c88f9ea8 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/writer/JsonWriter.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/writer/JsonWriter.java @@ -51,6 +51,8 @@ * reparsing. */ public abstract class JsonWriter { + private static final long MIN_ISO_INSTANT_SECOND = -31_557_014_167_219_200L; + private static final long MAX_ISO_INSTANT_SECOND = 31_556_889_864_403_199L; private final JsonTypeResolver typeResolver; private final int maxDepth; private int depth; @@ -107,6 +109,14 @@ private static void throwDepthExceeded(int maxDepth) { public abstract void writeLong(long value); + /** Writes raw unsigned 32-bit bits as a decimal JSON number. */ + public void writeUnsignedInt(int value) { + writeLong(Integer.toUnsignedLong(value)); + } + + /** Writes raw unsigned 64-bit bits as a decimal JSON number. */ + public abstract void writeUnsignedLong(long value); + public abstract void writeFloat(float value); public abstract void writeDouble(double value); @@ -133,8 +143,49 @@ protected static void throwUnsupportedBigNumber(Class type) { "Unsupported JSON big-number subtype " + type + "; register an explicit codec"); } - public void writeUuid(UUID value) { - writeString(value.toString()); + public final void writeUuid(UUID value) { + writeUuid(value.getMostSignificantBits(), value.getLeastSignificantBits()); + } + + /** Writes one canonical quoted UUID from its primitive 128-bit value. */ + public abstract void writeUuid(long high, long low); + + /** Writes one canonical quoted ISO-8601 instant from epoch seconds and nanoseconds. */ + public abstract void writeIsoInstant(long epochSecond, int nano); + + /** Converts a validated ISO instant epoch day into packed year, month, and day components. */ + protected static long isoDate(long epochSecond, int nano) { + if (nano < 0 + || nano >= 1_000_000_000 + || epochSecond < MIN_ISO_INSTANT_SECOND + || epochSecond > MAX_ISO_INSTANT_SECOND) { + throw invalidIsoInstant(epochSecond, nano); + } + long zeroDay = Math.floorDiv(epochSecond, 86_400) + 719_528 - 60; + long adjust = 0; + if (zeroDay < 0) { + long adjustCycles = (zeroDay + 1) / 146_097 - 1; + adjust = adjustCycles * 400; + zeroDay += -adjustCycles * 146_097; + } + long year = (400 * zeroDay + 591) / 146_097; + long dayOfYear = zeroDay - (365 * year + year / 4 - year / 100 + year / 400); + if (dayOfYear < 0) { + year--; + dayOfYear = zeroDay - (365 * year + year / 4 - year / 100 + year / 400); + } + year += adjust; + int marchDay = (int) dayOfYear; + int marchMonth = (marchDay * 5 + 2) / 153; + int month = (marchMonth + 2) % 12 + 1; + int day = marchDay - (marchMonth * 306 + 5) / 10 + 1; + year += marchMonth / 10; + return (year << 32) | ((long) month << 16) | day; + } + + private static ForyJsonException invalidIsoInstant(long epochSecond, int nano) { + return new ForyJsonException( + "Invalid ISO instant components: epochSecond=" + epochSecond + ", nano=" + nano); } public void writeLocalDate(LocalDate value) { @@ -153,6 +204,69 @@ public void writeDuration(Duration value) { writeString(value.toString()); } + /** + * Writes a canonical quoted ISO duration from magnitude components. + * + *

Finite components are non-negative; minutes and seconds are below 60 and nanoseconds are + * below one billion. Fractions use three, six, or nine digits, and a zero minute component is + * retained between nonzero hours and seconds. Infinite values use {@code PT9999999999999H} and + * require zero finite components. Negative zero is invalid. + */ + public abstract void writeIsoDuration( + boolean infinite, boolean negative, long hours, int minutes, int seconds, int nanos); + + /** Validates the primitive ISO-duration tuple before a concrete writer emits any bytes. */ + protected static void checkIsoDuration( + boolean infinite, boolean negative, long hours, int minutes, int seconds, int nanos) { + boolean zero = (hours | minutes | seconds | nanos) == 0; + if (hours < 0 + || minutes < 0 + || minutes >= 60 + || seconds < 0 + || seconds >= 60 + || nanos < 0 + || nanos >= 1_000_000_000 + || infinite && !zero + || !infinite && negative && zero) { + throw invalidIsoDuration(infinite, negative, hours, minutes, seconds, nanos); + } + } + + /** Returns whether {@link Duration#toString()} matches the primitive ISO-duration spelling. */ + protected static boolean matchesIsoDurationShape( + long hours, int minutes, int seconds, int nanos) { + if (hours != 0 && minutes == 0 && (seconds != 0 || nanos != 0)) { + return false; + } + if (nanos == 0) { + return true; + } + if (nanos % 1_000_000 == 0) { + return nanos / 1_000_000 % 10 != 0; + } + if (nanos % 1000 == 0) { + return nanos / 1000 % 10 != 0; + } + return nanos % 10 != 0; + } + + private static ForyJsonException invalidIsoDuration( + boolean infinite, boolean negative, long hours, int minutes, int seconds, int nanos) { + return new ForyJsonException( + "Invalid ISO duration components: infinite=" + + infinite + + ", negative=" + + negative + + ", hours=" + + hours + + ", minutes=" + + minutes + + ", seconds=" + + seconds + + ", nanos=" + + nanos); + } + public void writePeriod(Period value) { writeString(value.toString()); } @@ -169,6 +283,14 @@ public void writeYear(Year value) { public abstract void writeLongFieldName(long value); + /** Writes raw unsigned 32-bit bits as a decimal JSON member name. */ + public void writeUnsignedIntFieldName(int value) { + writeLongFieldName(Integer.toUnsignedLong(value)); + } + + /** Writes raw unsigned 64-bit bits as a decimal JSON member name. */ + public abstract void writeUnsignedLongFieldName(long value); + public abstract void writeObjectStart(); public abstract void writeObjectEnd(); diff --git a/java/fory-json/src/main/java/org/apache/fory/json/writer/StringJsonWriter.java b/java/fory-json/src/main/java/org/apache/fory/json/writer/StringJsonWriter.java index a492e6c676..eaf19ab19a 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/writer/StringJsonWriter.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/writer/StringJsonWriter.java @@ -32,7 +32,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; -import java.util.UUID; import org.apache.fory.json.ForyJsonException; import org.apache.fory.json.JsonConfig; import org.apache.fory.json.meta.JsonFieldInfo; @@ -212,6 +211,18 @@ public void writeLong(long value) { writeLongUtf16(value); } + @Override + public void writeUnsignedLong(long value) { + if (value >= 0) { + writeLong(value); + return; + } + long quotient = Long.divideUnsigned(value, 10); + int remainder = (int) Long.remainderUnsigned(value, 10); + writeLong(quotient); + writeByteRaw((byte) ('0' + remainder)); + } + private void writeLongLatin1(long value) { if (value == Long.MIN_VALUE) { writeRaw(MIN_LONG_BYTES); @@ -481,15 +492,13 @@ public void writeString(CharSequence value) { } @Override - public void writeUuid(UUID value) { + public void writeUuid(long high, long low) { writeByteRaw((byte) '"'); - long high = value.getMostSignificantBits(); writeHex(high, 60, 8); writeByteRaw((byte) '-'); writeHex(high, 28, 4); writeByteRaw((byte) '-'); writeHex(high, 12, 4); - long low = value.getLeastSignificantBits(); writeByteRaw((byte) '-'); writeHex(low, 60, 4); writeByteRaw((byte) '-'); @@ -497,6 +506,33 @@ public void writeUuid(UUID value) { writeByteRaw((byte) '"'); } + @Override + public void writeIsoInstant(long epochSecond, int nano) { + long date = isoDate(epochSecond, nano); + int secondOfDay = (int) Math.floorMod(epochSecond, 86_400); + int hour = secondOfDay / 3600; + int minute = (secondOfDay - hour * 3600) / 60; + int second = secondOfDay - hour * 3600 - minute * 60; + writeByteRaw((byte) '"'); + writeIsoYear((int) (date >> 32)); + writeByteRaw((byte) '-'); + writeTwoDigits((int) ((date >>> 16) & 0xffff)); + writeByteRaw((byte) '-'); + writeTwoDigits((int) date & 0xffff); + writeByteRaw((byte) 'T'); + writeTwoDigits(hour); + writeByteRaw((byte) ':'); + writeTwoDigits(minute); + writeByteRaw((byte) ':'); + writeTwoDigits(second); + if (nano != 0) { + writeByteRaw((byte) '.'); + writeNano(nano); + } + writeByteRaw((byte) 'Z'); + writeByteRaw((byte) '"'); + } + @Override public void writeLocalDate(LocalDate value) { int year = value.getYear(); @@ -553,11 +589,54 @@ public void writeTemporal(TemporalAccessor value, DateTimeFormatter formatter) { @Override public void writeDuration(Duration value) { + long totalSeconds = value.getSeconds(); + int nanos = value.getNano(); + if (totalSeconds >= 0) { + long hours = totalSeconds / 3600; + int minutes = (int) (totalSeconds % 3600 / 60); + int seconds = (int) (totalSeconds % 60); + if (matchesIsoDurationShape(hours, minutes, seconds, nanos)) { + writeIsoDuration(false, false, hours, minutes, seconds, nanos); + return; + } + } writeByteRaw((byte) '"'); writeDurationBody(value); writeByteRaw((byte) '"'); } + @Override + public void writeIsoDuration( + boolean infinite, boolean negative, long hours, int minutes, int seconds, int nanos) { + checkIsoDuration(infinite, negative, hours, minutes, seconds, nanos); + writeByteRaw((byte) '"'); + if (negative) { + writeByteRaw((byte) '-'); + } + writeAscii("PT"); + long outputHours = infinite ? 9_999_999_999_999L : hours; + boolean hasHours = outputHours != 0; + boolean hasSeconds = seconds != 0 || nanos != 0; + boolean hasMinutes = minutes != 0 || hasSeconds && hasHours; + if (hasHours) { + writeLong(outputHours); + writeByteRaw((byte) 'H'); + } + if (hasMinutes) { + writeInt(minutes); + writeByteRaw((byte) 'M'); + } + if (hasSeconds || !hasHours && !hasMinutes) { + writeInt(seconds); + if (nanos != 0) { + writeByteRaw((byte) '.'); + writeNano(nanos); + } + writeByteRaw((byte) 'S'); + } + writeByteRaw((byte) '"'); + } + @Override public void writePeriod(Period value) { writeByteRaw((byte) '"'); @@ -625,6 +704,32 @@ public void writeFieldName(JsonFieldInfo field, int index) { writeRaw(index == 0 ? field.stringNamePrefix() : field.stringCommaNamePrefix()); } + public void writeNullField( + byte[] prefix, + long utf16Prefix0, + long utf16Prefix1, + long utf16Prefix2, + long utf16Prefix3, + int utf16PrefixLength) { + if (coder == LATIN1) { + int additional = prefix.length + 4; + if (position + additional > buffer.length) { + grow(additional); + } + writeRawLatin1NoEnsure(prefix); + LittleEndian.putInt32(buffer, position, 0x6c6c756e); + position += 4; + return; + } + int additional = Math.max(packedUtf16PrefixSize(utf16PrefixLength), utf16PrefixLength + 8); + if (position + additional > buffer.length) { + grow(additional); + } + writePackedUtf16ValueNoEnsure( + utf16Prefix0, utf16Prefix1, utf16Prefix2, utf16Prefix3, utf16PrefixLength); + writeAsciiUtf16NoEnsure("null", 4); + } + @Override public void writeIntFieldName(int value) { writeByteRaw((byte) '"'); @@ -641,6 +746,14 @@ public void writeLongFieldName(long value) { writeByteRaw((byte) ':'); } + @Override + public void writeUnsignedLongFieldName(long value) { + writeByteRaw((byte) '"'); + writeUnsignedLong(value); + writeByteRaw((byte) '"'); + writeByteRaw((byte) ':'); + } + public void writeBooleanField( byte[] namePrefix, byte[] commaNamePrefix, int index, boolean value) { byte[] prefix = index == 0 ? namePrefix : commaNamePrefix; @@ -2598,6 +2711,20 @@ private static int writePadded4(byte[] bytes, int pos, int value) { return pos + 4; } + private void writeIsoYear(int year) { + if (year >= 0 && year <= 9999) { + writePadded4(year); + } else if (year > 9999) { + writeByteRaw((byte) '+'); + writeInt(year); + } else if (year >= -9999) { + writeByteRaw((byte) '-'); + writePadded4(-year); + } else { + writeInt(year); + } + } + private void writeTwoDigits(int value) { int high = value / 10; writeByteRaw((byte) ('0' + high)); diff --git a/java/fory-json/src/main/java/org/apache/fory/json/writer/Utf8JsonWriter.java b/java/fory-json/src/main/java/org/apache/fory/json/writer/Utf8JsonWriter.java index 126361a130..a5f0414308 100644 --- a/java/fory-json/src/main/java/org/apache/fory/json/writer/Utf8JsonWriter.java +++ b/java/fory-json/src/main/java/org/apache/fory/json/writer/Utf8JsonWriter.java @@ -34,7 +34,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; -import java.util.UUID; import org.apache.fory.annotation.Internal; import org.apache.fory.json.ForyJsonException; import org.apache.fory.json.JsonConfig; @@ -197,6 +196,18 @@ public void writeLong(long value) { writeLongNoEnsure(value); } + @Override + public void writeUnsignedLong(long value) { + if (value >= 0) { + writeLong(value); + return; + } + long quotient = Long.divideUnsigned(value, 10); + int remainder = (int) Long.remainderUnsigned(value, 10); + writeLong(quotient); + buffer[position++] = (byte) ('0' + remainder); + } + @Override public void writeFloat(float value) { if (!Float.isFinite(value)) { @@ -438,20 +449,18 @@ public void writeString(CharSequence value) { } @Override - public void writeUuid(UUID value) { + public void writeUuid(long high, long low) { int pos = position; if (pos + 38 > buffer.length) { grow(38); } byte[] bytes = buffer; bytes[pos++] = (byte) '"'; - long high = value.getMostSignificantBits(); pos = writeHex(bytes, pos, high, 60, 8); bytes[pos++] = (byte) '-'; pos = writeHex(bytes, pos, high, 28, 4); bytes[pos++] = (byte) '-'; pos = writeHex(bytes, pos, high, 12, 4); - long low = value.getLeastSignificantBits(); bytes[pos++] = (byte) '-'; pos = writeHex(bytes, pos, low, 60, 4); bytes[pos++] = (byte) '-'; @@ -460,6 +469,33 @@ public void writeUuid(UUID value) { position = pos; } + @Override + public void writeIsoInstant(long epochSecond, int nano) { + long date = isoDate(epochSecond, nano); + int secondOfDay = (int) Math.floorMod(epochSecond, 86_400); + int hour = secondOfDay / 3600; + int minute = (secondOfDay - hour * 3600) / 60; + int second = secondOfDay - hour * 3600 - minute * 60; + writeByteRaw((byte) '"'); + writeIsoYear((int) (date >> 32)); + writeByteRaw((byte) '-'); + writeTwoDigitsValue((int) ((date >>> 16) & 0xffff)); + writeByteRaw((byte) '-'); + writeTwoDigitsValue((int) date & 0xffff); + writeByteRaw((byte) 'T'); + writeTwoDigitsValue(hour); + writeByteRaw((byte) ':'); + writeTwoDigitsValue(minute); + writeByteRaw((byte) ':'); + writeTwoDigitsValue(second); + if (nano != 0) { + writeByteRaw((byte) '.'); + writeNano(nano); + } + writeByteRaw((byte) 'Z'); + writeByteRaw((byte) '"'); + } + @Override public void writeLocalDate(LocalDate value) { int year = value.getYear(); @@ -584,11 +620,54 @@ public void writeTemporal(TemporalAccessor value, DateTimeFormatter formatter) { @Override public void writeDuration(Duration value) { + long totalSeconds = value.getSeconds(); + int nanos = value.getNano(); + if (totalSeconds >= 0) { + long hours = totalSeconds / 3600; + int minutes = (int) (totalSeconds % 3600 / 60); + int seconds = (int) (totalSeconds % 60); + if (matchesIsoDurationShape(hours, minutes, seconds, nanos)) { + writeIsoDuration(false, false, hours, minutes, seconds, nanos); + return; + } + } writeByteRaw((byte) '"'); writeDurationBody(value); writeByteRaw((byte) '"'); } + @Override + public void writeIsoDuration( + boolean infinite, boolean negative, long hours, int minutes, int seconds, int nanos) { + checkIsoDuration(infinite, negative, hours, minutes, seconds, nanos); + writeByteRaw((byte) '"'); + if (negative) { + writeByteRaw((byte) '-'); + } + writeAscii("PT"); + long outputHours = infinite ? 9_999_999_999_999L : hours; + boolean hasHours = outputHours != 0; + boolean hasSeconds = seconds != 0 || nanos != 0; + boolean hasMinutes = minutes != 0 || hasSeconds && hasHours; + if (hasHours) { + writeLong(outputHours); + writeByteRaw((byte) 'H'); + } + if (hasMinutes) { + writeInt(minutes); + writeByteRaw((byte) 'M'); + } + if (hasSeconds || !hasHours && !hasMinutes) { + writeInt(seconds); + if (nanos != 0) { + writeByteRaw((byte) '.'); + writeNano(nanos); + } + writeByteRaw((byte) 'S'); + } + writeByteRaw((byte) '"'); + } + @Override public void writePeriod(Period value) { writeByteRaw((byte) '"'); @@ -738,6 +817,16 @@ public void writeFieldName(JsonFieldInfo field, int index) { writeRaw(index == 0 ? field.utf8NamePrefix() : field.utf8CommaNamePrefix()); } + public void writeNullField(long prefix0, long prefix1, int prefixLength) { + int additional = Math.max(packedPrefixSize(prefixLength), prefixLength + 4); + if (position + additional > buffer.length) { + grow(additional); + } + writePackedRawNoEnsure(prefix0, prefix1, prefixLength); + LittleEndian.putInt32(buffer, position, 0x6c6c756e); + position += 4; + } + @Override public void writeIntFieldName(int value) { writeByteRaw((byte) '"'); @@ -754,6 +843,14 @@ public void writeLongFieldName(long value) { writeByteRaw((byte) ':'); } + @Override + public void writeUnsignedLongFieldName(long value) { + writeByteRaw((byte) '"'); + writeUnsignedLong(value); + writeByteRaw((byte) '"'); + writeByteRaw((byte) ':'); + } + public void writeBooleanField( byte[] namePrefix, byte[] commaNamePrefix, int index, boolean value) { byte[] prefix = index == 0 ? namePrefix : commaNamePrefix; @@ -1604,6 +1701,60 @@ private void writeStringSlow(CharSequence value, int index, int length) { writeByteRaw((byte) '"'); } + private void writeIsoYear(int year) { + if (year >= 0 && year <= 9999) { + writePadded4Value(year); + } else if (year > 9999) { + writeByteRaw((byte) '+'); + writeInt(year); + } else if (year >= -9999) { + writeByteRaw((byte) '-'); + writePadded4Value(-year); + } else { + writeInt(year); + } + } + + private void writePadded4Value(int value) { + if (position + 4 > buffer.length) { + grow(4); + } + position = writePadded4(buffer, position, value); + } + + private void writeTwoDigitsValue(int value) { + int high = value / 10; + writeByteRaw((byte) ('0' + high)); + writeByteRaw((byte) ('0' + value - high * 10)); + } + + private void writeNano(int nano) { + if (nano % 1_000_000 == 0) { + writePadded3Value(nano / 1_000_000); + return; + } + if (nano % 1000 == 0) { + int micros = nano / 1000; + int high = micros / 1000; + writePadded3Value(high); + writePadded3Value(micros - high * 1000); + return; + } + int first = nano / 100_000_000; + int remainder = nano - first * 100_000_000; + int middle = remainder / 10_000; + writeByteRaw((byte) ('0' + first)); + writePadded4Value(middle); + writePadded4Value(remainder - middle * 10_000); + } + + private void writePadded3Value(int value) { + if (position + 3 > buffer.length) { + grow(3); + } + position = writePadded3(buffer, position, value); + } + private void writeDurationBody(Duration value) { long seconds = value.getSeconds(); int nano = value.getNano(); diff --git a/java/fory-json/src/main/java17/org/apache/fory/json/ForyJsonGraalVMFeature.java b/java/fory-json/src/main/java17/org/apache/fory/json/ForyJsonGraalVMFeature.java index cdc825d6cb..55728b6f19 100644 --- a/java/fory-json/src/main/java17/org/apache/fory/json/ForyJsonGraalVMFeature.java +++ b/java/fory-json/src/main/java17/org/apache/fory/json/ForyJsonGraalVMFeature.java @@ -61,10 +61,13 @@ import org.apache.fory.json.annotation.JsonValidator; import org.apache.fory.json.annotation.JsonValue; import org.apache.fory.json.codec.Base64ByteArrayCodec; +import org.apache.fory.json.codec.JsonUnwrappedInfo; import org.apache.fory.json.codec.ObjectCodec; +import org.apache.fory.json.codec.ObjectCodec.AnyInfo; import org.apache.fory.json.codegen.JsonCodegenKey; import org.apache.fory.json.meta.JsonCreatorInfo; import org.apache.fory.json.meta.JsonFieldAccessor; +import org.apache.fory.json.meta.JsonFieldInfo; import org.apache.fory.json.meta.JsonValidatorInfo; import org.apache.fory.json.resolver.CodecRegistry.FactoryBinding; import org.apache.fory.json.resolver.JsonGeneratedClassRegistry; @@ -83,8 +86,7 @@ /** Prepares reachable Fory JSON models and provider-selected codecs for Native Image. */ final class ForyJsonGraalVMFeature implements Feature { private static final String SCALA_DERIVED_CODEC_METHOD = "derived$ScalaJsonCodec"; - private static final String SCALA_JSON_CODEC_CLASS = - "org.apache.fory.json.scala.ScalaJsonCodec"; + private static final String SCALA_JSON_CODEC_CLASS = "org.apache.fory.json.scala.ScalaJsonCodec"; private static final String SCALA_JSON_CODEC_FACTORY = "org.apache.fory.json.scala.internal.ScalaTypeCodecFactory$"; private static final String SCALA_ENUMERATION_ANNOTATION = @@ -110,6 +112,8 @@ final class ForyJsonGraalVMFeature implements Feature { private final Set> processedCodecs = ConcurrentHashMap.newKeySet(); private final Set> processedContainers = ConcurrentHashMap.newKeySet(); private final Set processedCreators = new LinkedHashSet<>(); + private final Set> processedObjectModels = + Collections.newSetFromMap(new IdentityHashMap<>()); // JsonCodegenKey stays loader-free so runtime configurations can reproduce it. Hosted resolvers // remain loader-specific, while JsonGeneratedClassRegistry merges their generated capabilities. private final Map> hostedConfigurations = @@ -356,15 +360,26 @@ private boolean generateConfigurations(DuringAnalysisAccess access) { ArrayList> models = new ArrayList<>(selectedModels); models.sort(Comparator.comparing(Class::getName)); for (Class model : models) { + // A raw generic Class is not a schema. Hosted capabilities are generated only when a + // concrete TypeRef occurrence is reached from a selected non-generic root; eagerly + // resolving the raw class would also make unreached bindings available in the image. + if (model.getTypeParameters().length != 0) { + continue; + } if (!configuration.processedModels.add(model)) { continue; } + List> objectModels; try { - configuration.resolver.generateHostedCodecs(model); + objectModels = configuration.resolver.generateHostedCodecs(model); } catch (RuntimeException | LinkageError e) { throw new IllegalStateException( "Cannot generate Fory JSON codecs for " + model.getName(), e); } + objectModels.sort(Comparator.comparing(codec -> codec.type().getName())); + for (ObjectCodec objectModel : objectModels) { + registerObjectModel(access, objectModel); + } Set> generatedClasses = JsonGeneratedClassRegistry.register(entry.getKey(), configuration.registry); for (Class generatedClass : generatedClasses) { @@ -377,6 +392,73 @@ private boolean generateConfigurations(DuringAnalysisAccess access) { return changed; } + private void registerObjectModel(DuringAnalysisAccess access, ObjectCodec objectModel) { + if (!processedObjectModels.add(objectModel)) { + return; + } + JsonCreatorInfo creator = objectModel.creatorInfo(); + if (creator != null && !creator.fixedInstance()) { + registerCreator(creator.executable()); + registerCreator(creator.invocationExecutable()); + if (creator.defaultConstructor() != null) { + registerCreator(creator.defaultConstructor()); + } + for (int i = 0; i < creator.argumentCount(); i++) { + Method defaultMethod = creator.defaultMethod(i); + if (defaultMethod != null) { + registerCreator(defaultMethod); + } + } + } + for (JsonFieldInfo field : objectModel.writeFields()) { + registerFieldAccessor(access, field.writeField(), field.writeGetter(), null); + } + for (JsonFieldInfo field : objectModel.readFields()) { + registerFieldAccessor(access, field.readField(), null, field.readSetter()); + } + AnyInfo any = objectModel.anyInfo(); + if (any != null) { + registerFieldAccessor(access, any.writeField(), any.writeGetter(), null); + registerFieldAccessor(access, any.readField(), null, any.readSetter()); + if (any.readSetter() != null) { + ObjectCodec.AnyInfo.anySetterHandle(any.readSetter()); + } + } + JsonUnwrappedInfo unwrapped = objectModel.unwrappedInfo(); + if (unwrapped != null) { + for (JsonUnwrappedInfo.Declaration declaration : unwrapped.declarations()) { + registerFieldAccessor(access, declaration.writeAccessor()); + registerFieldAccessor(access, declaration.readAccessor()); + } + } + } + + private static void registerFieldAccessor( + DuringAnalysisAccess access, JsonFieldAccessor accessor) { + if (accessor != null) { + registerFieldAccessor(access, accessor.field(), accessor.getter(), accessor.setter()); + } + } + + private static void registerFieldAccessor( + DuringAnalysisAccess access, Field field, Method getter, Method setter) { + if (field != null) { + RuntimeReflection.register(field); + JsonFieldAccessor.forField(field); + if (Runtime.version().feature() <= 24) { + access.registerAsUnsafeAccessed(field); + } + } + if (getter != null) { + RuntimeReflection.register(getter); + JsonFieldAccessor.forGetter(getter); + } + if (setter != null) { + RuntimeReflection.register(setter); + JsonFieldAccessor.forSetter(setter); + } + } + private void registerGeneratedClass(Class generatedClass) { Constructor[] constructors = generatedClass.getDeclaredConstructors(); if (constructors.length == 0) { diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonAnyPropertyTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonAnyPropertyTest.java index a9c4e614e3..11008d4624 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonAnyPropertyTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonAnyPropertyTest.java @@ -621,7 +621,7 @@ public void parameterizedBinding() { .get("byte") .value, 3); - assertInterpretedCapabilities(json, type); + assertGeneratedCapabilities(json, type); } @Test @@ -923,21 +923,39 @@ private void assertGeneratedCapabilities(ForyJson json, Class type) { JsonTypeResolver resolver = JsonTestSupport.currentTypeResolver(json); resolver.lockJIT(); try { - Object owner = resolver.getObjectCodec(type); + ObjectCodec owner = resolver.getObjectCodec(type); JsonTypeInfo info = resolver.getTypeInfo(type, type); - if (!StringSerializer.isBytesBackedString()) { - resolver.latin1Reader((ObjectCodec) owner); - } - assertGenerated(info.stringWriter(), owner); - assertGenerated(info.utf8Writer(), owner); - assertGenerated(info.latin1Reader(), owner); - assertGenerated(info.utf16Reader(), owner); - assertGenerated(info.utf8Reader(), owner); + assertGeneratedCapabilities(resolver, info, owner); + } finally { + resolver.unlockJIT(); + } + } + + private void assertGeneratedCapabilities(ForyJson json, TypeRef type) { + JsonTypeResolver resolver = JsonTestSupport.currentTypeResolver(json); + resolver.lockJIT(); + try { + JsonTypeInfo info = resolver.getTypeInfo(type); + ObjectCodec owner = resolver.canonicalObjectCodec(info); + assertTrue(owner != null); + assertGeneratedCapabilities(resolver, info, owner); } finally { resolver.unlockJIT(); } } + private void assertGeneratedCapabilities( + JsonTypeResolver resolver, JsonTypeInfo info, ObjectCodec owner) { + if (!StringSerializer.isBytesBackedString()) { + resolver.latin1Reader(owner); + } + assertGenerated(info.stringWriter(), owner); + assertGenerated(info.utf8Writer(), owner); + assertGenerated(info.latin1Reader(), owner); + assertGenerated(info.utf16Reader(), owner); + assertGenerated(info.utf8Reader(), owner); + } + private void assertGeneratedWriters(ForyJson json, Class type) { JsonTypeResolver resolver = JsonTestSupport.currentTypeResolver(json); resolver.lockJIT(); @@ -968,21 +986,6 @@ private void assertGeneratedReaders(ForyJson json, Class type) { } } - private static void assertInterpretedCapabilities(ForyJson json, TypeRef type) { - JsonTypeResolver resolver = JsonTestSupport.currentTypeResolver(json); - resolver.lockJIT(); - try { - JsonTypeInfo info = resolver.getTypeInfo(type.getType(), type.getRawType()); - Object owner = info.stringWriter(); - assertSame(info.utf8Writer(), owner); - assertSame(info.latin1Reader(), owner); - assertSame(info.utf16Reader(), owner); - assertSame(info.utf8Reader(), owner); - } finally { - resolver.unlockJIT(); - } - } - private static void assertInterpretedReaders(ForyJson json, Class type) { JsonTypeResolver resolver = JsonTestSupport.currentTypeResolver(json); resolver.lockJIT(); diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonAsyncCompilationTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonAsyncCompilationTest.java index 6d385ba9f3..ecdac1756f 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonAsyncCompilationTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonAsyncCompilationTest.java @@ -614,34 +614,43 @@ public void semanticBindingsRemainOwners() throws Exception { controlled.json.fromJson( "{\"value\":\"typed\"}".getBytes(StandardCharsets.UTF_8), declaredType); assertEquals(decoded.value, "typed"); - assertEquals(controlled.executor.submittedTasks(), 0); + assertEquals(controlled.executor.submittedTasks(), 5); assertEquals(controlled.json.fromJson("7", Object.class), Long.valueOf(7)); - assertEquals(controlled.executor.submittedTasks(), 0); + assertEquals(controlled.executor.submittedTasks(), 5); JsonTypeResolver resolver = currentTypeResolver(controlled.json); resolver.lockJIT(); JsonTypeInfo parameterized; + ObjectCodec parameterizedOwner; Object parameterizedReader; try { parameterized = resolver.getTypeInfo(declaredType.getType(), GenericAsyncBox.class); + parameterizedOwner = resolver.canonicalObjectCodec(parameterized); parameterizedReader = parameterized.utf8Reader(); - assertNull(resolver.canonicalObjectCodec(parameterized)); + assertNotNull(parameterizedOwner); + assertSame(parameterizedReader, parameterizedOwner); } finally { resolver.unlockJIT(); } + controlled.executor.runAll(); + assertNotSame(parameterized.utf8Reader(), parameterizedReader); controlled.json.fromJson( "{\"value\":\"raw\"}".getBytes(StandardCharsets.UTF_8), GenericAsyncBox.class); - controlled.executor.runNext(); - assertSame(parameterized.utf8Reader(), parameterizedReader); + assertEquals(controlled.executor.submittedTasks(), 10); resolver.lockJIT(); try { JsonTypeInfo raw = resolver.getTypeInfo(GenericAsyncBox.class, GenericAsyncBox.class); - assertSame( - resolver.canonicalObjectCodec(raw), resolver.getObjectCodec(GenericAsyncBox.class)); + ObjectCodec rawOwner = resolver.getObjectCodec(GenericAsyncBox.class); + assertSame(resolver.canonicalObjectCodec(raw), rawOwner); + assertNotSame(rawOwner, parameterizedOwner); } finally { resolver.unlockJIT(); } + controlled.executor.runAll(); + assertNotSame( + resolver.getTypeInfo(GenericAsyncBox.class, GenericAsyncBox.class).utf8Reader(), + parameterized.utf8Reader()); JsonValueCodec codec = nullCodec(); CodecRegistry codecs = new CodecRegistry(); diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonContainerTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonContainerTest.java index ce64801b73..a84a4dac45 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonContainerTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonContainerTest.java @@ -19,7 +19,12 @@ package org.apache.fory.json; +import static org.apache.fory.json.JsonTestSupport.generatedUtf8WriterClass; +import static org.apache.fory.json.JsonTestSupport.newLatin1Reader; +import static org.apache.fory.json.JsonTestSupport.newUtf16Reader; +import static org.apache.fory.json.JsonTestSupport.newUtf8Reader; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotEquals; import static org.testng.Assert.assertThrows; import static org.testng.Assert.assertTrue; @@ -62,12 +67,14 @@ import java.util.concurrent.atomic.AtomicIntegerArray; import java.util.concurrent.atomic.AtomicLongArray; import java.util.concurrent.atomic.AtomicReferenceArray; +import org.apache.fory.json.codec.ArrayCodec; import org.apache.fory.json.data.FastContainers; import org.apache.fory.json.data.Kind; import org.apache.fory.json.data.MapKeyFields; import org.apache.fory.json.data.Nested; import org.apache.fory.json.data.TokenValues; import org.apache.fory.reflect.TypeRef; +import org.apache.fory.type.Types; import org.testng.annotations.Factory; import org.testng.annotations.Test; @@ -85,6 +92,21 @@ public void writeNestedCollections() { "{\"kind\":\"FAST\",\"names\":[\"a\",\"b\"],\"scores\":{\"one\":1,\"two\":2}}"); } + @Test + public void unsignedArrayOverflow() { + byte[] input = "[4294967295]".getBytes(StandardCharsets.UTF_8); + ArrayCodec uint8 = ArrayCodec.createUnsignedPrimitive(byte[].class, Types.UINT8_ARRAY); + ArrayCodec uint16 = + ArrayCodec.createUnsignedPrimitive(short[].class, Types.UINT16_ARRAY); + + assertThrows(ForyJsonException.class, () -> uint8.readUtf8(newUtf8Reader(input))); + assertThrows(ForyJsonException.class, () -> uint8.readLatin1(newLatin1Reader(input))); + assertThrows(ForyJsonException.class, () -> uint8.readUtf16(newUtf16Reader("[4294967295]"))); + assertThrows(ForyJsonException.class, () -> uint16.readUtf8(newUtf8Reader(input))); + assertThrows(ForyJsonException.class, () -> uint16.readLatin1(newLatin1Reader(input))); + assertThrows(ForyJsonException.class, () -> uint16.readUtf16(newUtf16Reader("[4294967295]"))); + } + @Test public void readTypeRefList() { ForyJson json = newJson(); @@ -122,6 +144,30 @@ public void readParameterizedPojo() { assertEquals(objects.values.get(0).name, "utf8"); } + @Test + public void generatedParameterizedPojo() { + ForyJson json = newJson(true); + TypeRef> strings = new TypeRef>() {}; + TypeRef> values = new TypeRef>() {}; + json.fromJson("{\"value\":\"one\",\"values\":[]}", strings); + json.fromJson( + "{\"value\":{\"count\":1,\"name\":\"one\",\"tags\":[],\"total\":2}," + "\"values\":[]}", + values); + assertNotEquals( + generatedUtf8WriterClass(json, strings), generatedUtf8WriterClass(json, values)); + } + + @Test + public void rejectExpandingGeneric() { + ForyJson json = newJson(true); + assertThrows( + ForyJsonException.class, + () -> json.fromJson("{\"next\":null}", new TypeRef>() {})); + GenericBox value = + json.fromJson("{\"value\":\"ready\",\"values\":[]}", new TypeRef>() {}); + assertEquals(value.value, "ready"); + } + @Test public void readCollectionSubclassElementType() { ForyJson json = newJson(); @@ -746,6 +792,10 @@ public static final class GenericBox { public List values; } + public static final class Expanding { + public Expanding> next; + } + public static final class NoteList extends ArrayList {} public static final class Note { diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonCreatorTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonCreatorTest.java index 59eae033bc..7da99466ca 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonCreatorTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonCreatorTest.java @@ -24,10 +24,15 @@ import static org.testng.Assert.assertThrows; import static org.testng.Assert.fail; +import java.lang.reflect.Executable; +import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import org.apache.fory.json.annotation.JsonCreator; import org.apache.fory.json.annotation.JsonIgnore; +import org.apache.fory.json.annotation.JsonMixin; import org.apache.fory.json.annotation.JsonProperty; +import org.apache.fory.json.codec.JsonObjectModel; +import org.apache.fory.reflect.TypeRef; import org.testng.annotations.Factory; import org.testng.annotations.Test; @@ -52,6 +57,110 @@ public void propertyListConstructor() { assertEquals(utf8.name, "你好"); } + @Test + public void languageModelCreatorMapping() throws Exception { + Executable listCreator = LanguageList.class.getConstructor(String.class); + Method listGetter = LanguageList.class.getMethod("getOutput"); + ForyJson listJson = + newJsonBuilder() + .registerCodec( + LanguageList.class, + (type, resolver) -> + resolver.createObjectCodec( + type, languageModel(listCreator, "source", listGetter))) + .build(); + LanguageList list = listJson.fromJson("{\"output\":\"list\"}", LanguageList.class); + assertEquals(list.output, "list"); + assertEquals(listJson.toJson(list, LanguageList.class), "{\"output\":\"list\"}"); + + Executable parameterCreator = LanguageParameter.class.getConstructor(String.class); + Method parameterGetter = LanguageParameter.class.getMethod("getOutput"); + ForyJson parameterJson = + newJsonBuilder() + .registerCodec( + LanguageParameter.class, + (type, resolver) -> + resolver.createObjectCodec( + type, languageModel(parameterCreator, "source", parameterGetter))) + .build(); + LanguageParameter parameter = + parameterJson.fromJson("{\"wire_value\":\"parameter\"}", LanguageParameter.class); + assertEquals(parameter.output, "parameter"); + assertEquals( + parameterJson.toJson(parameter, LanguageParameter.class), "{\"wire_value\":\"parameter\"}"); + + Executable factoryCreator = LanguageFactory.class.getMethod("create", String.class); + Method factoryGetter = LanguageFactory.class.getMethod("getOutput"); + ForyJson factoryJson = + newJsonBuilder() + .registerCodec( + LanguageFactory.class, + (type, resolver) -> + resolver.createObjectCodec( + type, languageModel(factoryCreator, "source", factoryGetter))) + .build(); + LanguageFactory factory = + factoryJson.fromJson("{\"output\":\"factory\"}", LanguageFactory.class); + assertEquals(factory.output, "factory"); + + Executable mixinCreator = LanguageMixinTarget.class.getConstructor(String.class); + Method mixinGetter = LanguageMixinTarget.class.getMethod("getOutput"); + ForyJson mixinJson = + newJsonBuilder() + .registerMixin(LanguageMixin.class) + .registerCodec( + LanguageMixinTarget.class, + (type, resolver) -> + resolver.createObjectCodec( + type, languageModel(mixinCreator, "source", mixinGetter))) + .build(); + LanguageMixinTarget mixin = + mixinJson.fromJson("{\"output\":\"mixin\"}", LanguageMixinTarget.class); + assertEquals(mixin.output, "mixin"); + + Executable logicalCreator = LanguageInvocation.class.getDeclaredConstructor(String.class); + Executable invocationCreator = + LanguageInvocation.class.getConstructor(String.class, InvocationMarker.class); + Method invocationGetter = LanguageInvocation.class.getMethod("getOutput"); + ForyJson invocationJson = + newJsonBuilder() + .registerCodec( + LanguageInvocation.class, + (type, resolver) -> + resolver.createObjectCodec( + type, + languageModel( + logicalCreator, invocationCreator, "source", invocationGetter))) + .build(); + LanguageInvocation invocation = + invocationJson.fromJson("{\"output\":\"bridge\"}", LanguageInvocation.class); + assertEquals(invocation.output, "bridge"); + } + + private static JsonObjectModel languageModel( + Executable creator, String parameterName, Method getter) { + return languageModel(creator, creator, parameterName, getter); + } + + private static JsonObjectModel languageModel( + Executable creator, Executable invocationCreator, String parameterName, Method getter) { + TypeRef type = TypeRef.of(String.class); + return new JsonObjectModel( + creator, + invocationCreator, + null, + new String[] {parameterName}, + new Method[] {getter}, + new Method[1], + new int[] {-1}, + new boolean[] {false}, + new TypeRef[] {type}, + new String[] {"output"}, + new Method[] {getter}, + new Method[1], + new TypeRef[] {type}); + } + @Test public void fieldFallbacks() { ForyJson json = newJson(); @@ -423,4 +532,85 @@ public static ErrorFactory create(@JsonProperty("id") int id) { throw new AssertionError("creator error"); } } + + public static final class LanguageList { + private final String output; + + @JsonCreator({"output"}) + public LanguageList(String source) { + output = source; + } + + public String getOutput() { + return output; + } + } + + public static final class LanguageParameter { + private final String output; + + @JsonCreator + public LanguageParameter(@JsonProperty("wire_value") String source) { + output = source; + } + + @JsonProperty("wire_value") + public String getOutput() { + return output; + } + } + + public static final class LanguageFactory { + private final String output; + + private LanguageFactory(String output) { + this.output = output; + } + + @JsonCreator({"output"}) + public static LanguageFactory create(String source) { + return new LanguageFactory(source); + } + + public String getOutput() { + return output; + } + } + + public static final class LanguageMixinTarget { + private final String output; + + public LanguageMixinTarget(String source) { + output = source; + } + + public String getOutput() { + return output; + } + } + + @JsonMixin(target = LanguageMixinTarget.class) + public abstract static class LanguageMixin { + @JsonCreator({"output"}) + LanguageMixin(String source) {} + } + + public static final class InvocationMarker {} + + public static final class LanguageInvocation { + private final String output; + + private LanguageInvocation(String source) { + output = source; + } + + @JsonCreator({"output"}) + public LanguageInvocation(String source, InvocationMarker ignored) { + this(source); + } + + public String getOutput() { + return output; + } + } } diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonFieldNameCacheTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonFieldNameCacheTest.java index 798c1a3b21..168cc05598 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonFieldNameCacheTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonFieldNameCacheTest.java @@ -19,7 +19,7 @@ package org.apache.fory.json; -import static org.apache.fory.json.JsonTestSupport.generatedCodecId; +import static org.apache.fory.json.JsonTestSupport.generatedCodecIdentity; import static org.apache.fory.json.JsonTestSupport.generatedUtf8WriterClass; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertNotNull; @@ -68,8 +68,8 @@ public void configuration() { oneEntry.toJsonBytes(new TypedFields()); twoEntries.toJsonBytes(new TypedFields()); assertEquals( - generatedCodecId(generatedUtf8WriterClass(oneEntry, TypedFields.class)), - generatedCodecId(generatedUtf8WriterClass(twoEntries, TypedFields.class))); + generatedCodecIdentity(generatedUtf8WriterClass(oneEntry, TypedFields.class)), + generatedCodecIdentity(generatedUtf8WriterClass(twoEntries, TypedFields.class))); } @Test diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonGeneratedCapabilityKeyTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonGeneratedCapabilityKeyTest.java new file mode 100644 index 0000000000..8d9add9cb1 --- /dev/null +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonGeneratedCapabilityKeyTest.java @@ -0,0 +1,195 @@ +/* + * 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.fory.json; + +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotSame; +import static org.testng.Assert.assertSame; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import org.apache.fory.json.annotation.JsonSubTypes; +import org.apache.fory.json.codec.JsonObjectModel; +import org.apache.fory.json.codec.ObjectCodec; +import org.apache.fory.json.resolver.JsonTypeInfo; +import org.apache.fory.json.resolver.JsonTypeResolver; +import org.apache.fory.meta.TypeExtMeta; +import org.apache.fory.reflect.TypeRef; +import org.apache.fory.type.Types; +import org.testng.annotations.Test; + +public class JsonGeneratedCapabilityKeyTest { + @Test + public void outerNullabilityReusesObjectClasses() { + JsonTypeResolver resolver = resolver(); + JsonTypeInfo raw = resolver.getTypeInfo(Model.class, Model.class); + JsonTypeInfo nonNull = resolver.getTypeInfo(TypeRef.of(Model.class, ordinary(false))); + JsonTypeInfo nullable = resolver.getTypeInfo(TypeRef.of(Model.class, ordinary(true))); + + assertObjectClasses(raw, nonNull); + assertObjectClasses(raw, nullable); + + JsonTypeInfo tracked = + resolver.getTypeInfo( + TypeRef.of(Model.class, TypeExtMeta.of(Types.UNKNOWN, false, true, false, false))); + assertNotSame(raw.utf8Reader().getClass(), tracked.utf8Reader().getClass()); + } + + @Test + public void outerNullabilityReusesCollectionClasses() { + JsonTypeResolver resolver = resolver(); + TypeRef element = TypeRef.of(String.class); + JsonTypeInfo raw = resolver.getTypeInfo(listType(null, element)); + JsonTypeInfo nonNull = resolver.getTypeInfo(listType(ordinary(false), element)); + JsonTypeInfo nullable = resolver.getTypeInfo(listType(ordinary(true), element)); + + assertSame(raw.utf8Writer().getClass(), nonNull.utf8Writer().getClass()); + assertSame(raw.utf8Writer().getClass(), nullable.utf8Writer().getClass()); + assertSame(raw.utf8Reader().getClass(), nonNull.utf8Reader().getClass()); + assertSame(raw.utf8Reader().getClass(), nullable.utf8Reader().getClass()); + + JsonTypeInfo nonNullElement = + resolver.getTypeInfo(listType(null, TypeRef.of(String.class, ordinary(false)))); + JsonTypeInfo nullableElement = + resolver.getTypeInfo(listType(null, TypeRef.of(String.class, ordinary(true)))); + assertNotSame(nonNullElement.utf8Writer().getClass(), nullableElement.utf8Writer().getClass()); + assertNotSame(nonNullElement.utf8Reader().getClass(), nullableElement.utf8Reader().getClass()); + } + + @Test + public void componentMetadataRemainsDistinct() { + JsonTypeResolver resolver = resolver(); + TypeRef nonNullArray = + TypeRef.of( + String[].class, ordinary(false), null, TypeRef.of(String.class, ordinary(false))); + TypeRef nullableArray = + TypeRef.of(String[].class, ordinary(false), null, TypeRef.of(String.class, ordinary(true))); + JsonTypeInfo nonNull = resolver.getTypeInfo(boxType(nonNullArray)); + JsonTypeInfo nullable = resolver.getTypeInfo(boxType(nullableArray)); + assertNotSame(nonNull.utf8Writer().getClass(), nullable.utf8Writer().getClass()); + assertNotSame(nonNull.utf8Reader().getClass(), nullable.utf8Reader().getClass()); + } + + @Test + public void fixedSubtypeIsGeneratedLeaf() { + ForyJson json = + ForyJson.builder() + .registerCodec( + FixedValue.class, + (type, resolver) -> + resolver.createObjectCodec( + type, JsonObjectModel.fixedInstance(FixedValue.INSTANCE))) + .withAsyncCompilation(false) + .build(); + JsonTypeInfo typeInfo = + JsonTestSupport.currentTypeResolver(json) + .getTypeInfo(FixedContainer.class, FixedContainer.class); + assertGeneratedObject(typeInfo); + + FixedContainer value = new FixedContainer(); + value.value = FixedValue.INSTANCE; + assertSame(json.fromJson(json.toJson(value), FixedContainer.class).value, FixedValue.INSTANCE); + + String input = "{\"value\":{\"kind\":\"fixed\"}}"; + assertSame( + ((FixedContainer) + typeInfo.latin1Reader().readLatin1(JsonTestSupport.newLatin1Reader(input))) + .value, + FixedValue.INSTANCE); + assertSame( + ((FixedContainer) typeInfo.utf16Reader().readUtf16(JsonTestSupport.newUtf16Reader(input))) + .value, + FixedValue.INSTANCE); + assertSame( + ((FixedContainer) + typeInfo + .utf8Reader() + .readUtf8( + JsonTestSupport.newUtf8Reader(input.getBytes(StandardCharsets.UTF_8)))) + .value, + FixedValue.INSTANCE); + } + + private static JsonTypeResolver resolver() { + ForyJson json = ForyJson.builder().withAsyncCompilation(false).build(); + return JsonTestSupport.currentTypeResolver(json); + } + + private static TypeExtMeta ordinary(boolean nullable) { + return TypeExtMeta.of(Types.UNKNOWN, nullable, false, false, false); + } + + private static TypeRef listType(TypeExtMeta metadata, TypeRef element) { + return TypeRef.ofDeclaredTypeArguments( + List.class, metadata, Collections.singletonList(element), null); + } + + private static TypeRef boxType(TypeRef value) { + return TypeRef.ofDeclaredTypeArguments( + Box.class, ordinary(false), Collections.singletonList(value), null); + } + + private static void assertObjectClasses(JsonTypeInfo expected, JsonTypeInfo actual) { + assertSame(expected.stringWriter().getClass(), actual.stringWriter().getClass()); + assertSame(expected.utf8Writer().getClass(), actual.utf8Writer().getClass()); + assertSame(expected.latin1Reader().getClass(), actual.latin1Reader().getClass()); + assertSame(expected.utf16Reader().getClass(), actual.utf16Reader().getClass()); + assertSame(expected.utf8Reader().getClass(), actual.utf8Reader().getClass()); + } + + private static void assertGeneratedObject(JsonTypeInfo typeInfo) { + assertFalse(ObjectCodec.class.isAssignableFrom(typeInfo.stringWriter().getClass())); + assertFalse(ObjectCodec.class.isAssignableFrom(typeInfo.utf8Writer().getClass())); + assertFalse(ObjectCodec.class.isAssignableFrom(typeInfo.latin1Reader().getClass())); + assertFalse(ObjectCodec.class.isAssignableFrom(typeInfo.utf16Reader().getClass())); + assertFalse(ObjectCodec.class.isAssignableFrom(typeInfo.utf8Reader().getClass())); + } + + public static final class Model { + public String value; + + public Model() {} + } + + public static final class Box { + public T value; + + public Box() {} + } + + @JsonSubTypes( + value = @JsonSubTypes.Type(value = FixedValue.class, name = "fixed"), + inclusion = JsonSubTypes.Inclusion.PROPERTY, + property = "kind") + public interface FixedBase {} + + public static final class FixedValue implements FixedBase { + static final FixedValue INSTANCE = new FixedValue(); + + private FixedValue() {} + } + + public static final class FixedContainer { + public FixedBase value; + + public FixedContainer() {} + } +} diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonGeneratedCodecTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonGeneratedCodecTest.java index 010e3cdd04..de925ab73e 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonGeneratedCodecTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonGeneratedCodecTest.java @@ -19,7 +19,7 @@ package org.apache.fory.json; -import static org.apache.fory.json.JsonTestSupport.generatedCodecId; +import static org.apache.fory.json.JsonTestSupport.generatedCodecIdentity; import static org.apache.fory.json.JsonTestSupport.generatedUtf8WriterClass; import static org.apache.fory.json.JsonTestSupport.newLatin1Reader; import static org.apache.fory.json.JsonTestSupport.newUtf8Reader; @@ -47,6 +47,8 @@ import org.apache.fory.json.meta.JsonFieldNameHash; import org.apache.fory.json.reader.Latin1JsonReader; import org.apache.fory.json.reader.Utf8JsonReader; +import org.apache.fory.json.resolver.JsonTypeInfo; +import org.apache.fory.json.resolver.JsonTypeResolver; import org.testng.annotations.Test; public class JsonGeneratedCodecTest extends ForyJsonTestModels { @@ -213,9 +215,25 @@ public void sameConfigUsesSameId(boolean codegen) throws Exception { assertGeneratedName(secondCodecClass, PublicFields.class, "Utf8Writer"); assertGeneratedName(writeNullCodecClass, PublicFields.class, "Utf8Writer"); assertGeneratedName(snakeCaseCodecClass, PublicFields.class, "Utf8Writer"); - assertEquals(generatedCodecId(secondCodecClass), generatedCodecId(firstCodecClass)); - assertNotEquals(generatedCodecId(writeNullCodecClass), generatedCodecId(firstCodecClass)); - assertNotEquals(generatedCodecId(snakeCaseCodecClass), generatedCodecId(firstCodecClass)); + assertEquals(generatedCodecIdentity(secondCodecClass), generatedCodecIdentity(firstCodecClass)); + assertNotEquals( + generatedCodecIdentity(writeNullCodecClass), generatedCodecIdentity(firstCodecClass)); + assertNotEquals( + generatedCodecIdentity(snakeCaseCodecClass), generatedCodecIdentity(firstCodecClass)); + } + + @Test + public void boundedGeneratedName() { + ForyJson json = newJson(true); + json.toJsonBytes(new ModelWithANameLongEnoughToRequireDeterministicPrefixTruncation()); + Class generated = + generatedUtf8WriterClass( + json, ModelWithANameLongEnoughToRequireDeterministicPrefixTruncation.class); + assertTrue( + generated.getSimpleName().length() + <= 32 + "Utf8Writer".length() + GENERATED_SUFFIX.length() + 1 + 64, + generated.getName()); + assertEquals(generatedCodecIdentity(generated).length(), 64); } @Test @@ -275,6 +293,22 @@ public void readFieldNamePrefix() { JsonAsciiToken.prefix("\"alpha\":"), -1L, "\"alpha\":".length())); assertEquals(utf8.readIntTokenValue(), 1); + String commaField = ", \n\t\"alpha\":1"; + Utf8JsonReader adjacentComma = newUtf8Reader(commaField.getBytes(StandardCharsets.UTF_8)); + assertTrue(adjacentComma.tryConsumeNextOrderedComma()); + assertTrue( + adjacentComma.tryReadNextFieldNameToken0( + JsonAsciiToken.prefix("\"alpha\":"), -1L, "\"alpha\":".length())); + assertEquals(adjacentComma.readIntTokenValue(), 1); + + Utf8JsonReader spacedComma = + newUtf8Reader((" \n" + commaField).getBytes(StandardCharsets.UTF_8)); + assertTrue(spacedComma.consumeNextOrderedObjectEndOrSlow()); + assertTrue( + spacedComma.tryReadNextFieldNameToken0( + JsonAsciiToken.prefix("\"alpha\":"), -1L, "\"alpha\":".length())); + assertEquals(spacedComma.readIntTokenValue(), 1); + Latin1JsonReader truncatedLatin1 = newLatin1Reader(latin1Bytes(" \"a")); assertEquals(truncatedLatin1.readFieldNamePrefix(), 0); assertEquals(truncatedLatin1.position(), 1); @@ -306,6 +340,47 @@ public void readGeneratedLongAsciiFields(boolean codegen) { assertGeneratedWhenSupported(json, LongAsciiFields.class, codegen); } + @Test + public void readOrderedCreator() { + ForyJson json = newJson(true); + JsonCreatorTest.User latin1 = + json.fromJson("{\"id\":7, \n\t\"name\":\"alice\"}", JsonCreatorTest.User.class); + JsonCreatorTest.User latin1Fallback = + json.fromJson("{\"name\":\"bob\",\"id\":8}", JsonCreatorTest.User.class); + JsonCreatorTest.User utf16 = + json.fromJson("{\"id\":9, \n\t\"name\":\"你好\"}", JsonCreatorTest.User.class); + JsonCreatorTest.User utf16Fallback = + json.fromJson("{\"name\":\"你好\",\"id\":10}", JsonCreatorTest.User.class); + JsonCreatorTest.User utf8 = + json.fromJson( + "{\"id\":11, \n\t\"name\":\"carol\"}".getBytes(StandardCharsets.UTF_8), + JsonCreatorTest.User.class); + JsonCreatorTest.User utf8Fallback = + json.fromJson( + "{\"name\":\"dave\",\"id\":12}".getBytes(StandardCharsets.UTF_8), + JsonCreatorTest.User.class); + assertEquals(latin1.id, 7L); + assertEquals(latin1Fallback.id, 8L); + assertEquals(utf16.id, 9L); + assertEquals(utf16Fallback.id, 10L); + assertEquals(utf8.id, 11L); + assertEquals(utf8Fallback.id, 12L); + + JsonTypeResolver resolver = JsonTestSupport.currentTypeResolver(json); + resolver.getObjectCodec(JsonCreatorTest.User.class); + JsonTypeInfo typeInfo = + resolver.getTypeInfo(JsonCreatorTest.User.class, JsonCreatorTest.User.class); + assertTrue( + Arrays.stream(typeInfo.latin1Reader().getClass().getDeclaredMethods()) + .anyMatch(method -> method.getName().startsWith("readLatin1Slow"))); + assertTrue( + Arrays.stream(typeInfo.utf16Reader().getClass().getDeclaredMethods()) + .anyMatch(method -> method.getName().startsWith("readUtf16Slow"))); + assertTrue( + Arrays.stream(typeInfo.utf8Reader().getClass().getDeclaredMethods()) + .anyMatch(method -> method.getName().startsWith("readUtf8Slow"))); + } + @Test(dataProvider = "enableCodegen") public void readSplitGeneratedFields(boolean codegen) { ForyJson json = newJson(codegen); @@ -328,23 +403,36 @@ public void readSplitGeneratedFields(boolean codegen) { @Test public void writeSplitGeneratedFields() throws Exception { - ForyJson json = newJson(true); + ForyJson json = newJsonBuilder(true).writeNullFields(true).build(); WideWriterFields value = new WideWriterFields(); + value.field01 = null; StringBuilder expected = new StringBuilder("{\"field00\":1"); for (int i = 1; i < 24; i++) { expected.append(",\"field"); if (i < 10) { expected.append('0'); } - expected.append(i).append("\":\"v"); + expected.append(i).append("\":"); + if (i == 1) { + expected.append("null"); + continue; + } + expected.append("\"v"); if (i < 10) { expected.append('0'); } expected.append(i).append('"'); } expected.append('}'); + assertEquals(json.toJson(value), expected.toString()); assertEquals(new String(json.toJsonBytes(value), StandardCharsets.UTF_8), expected.toString()); + JsonTypeInfo typeInfo = + JsonTestSupport.currentTypeResolver(json) + .getTypeInfo(WideWriterFields.class, WideWriterFields.class); + assertTrue( + Arrays.stream(typeInfo.stringWriter().getClass().getDeclaredMethods()) + .anyMatch(method -> method.getName().startsWith("writeStringMembers"))); Class generated = generatedUtf8WriterClass(json, WideWriterFields.class); int groups = 0; for (Method method : generated.getDeclaredMethods()) { @@ -405,6 +493,10 @@ public static class PrefixFields { public int altar; } + public static final class ModelWithANameLongEnoughToRequireDeterministicPrefixTruncation { + public int value; + } + public static class WideFields { public int f0; public String f1; @@ -502,7 +594,7 @@ private static void assertGeneratedName( String simpleName = generatedClass.getSimpleName(); assertTrue(simpleName.startsWith(valueType.getSimpleName()), generatedClass.getName()); assertTrue(simpleName.contains(role + GENERATED_SUFFIX), generatedClass.getName()); - assertFalse(simpleName.contains(GENERATED_SUFFIX + "_"), generatedClass.getName()); - assertTrue(generatedCodecId(generatedClass) >= 0, generatedClass.getName()); + assertTrue(simpleName.contains(GENERATED_SUFFIX + "_"), generatedClass.getName()); + assertEquals(generatedCodecIdentity(generatedClass).length(), 64, generatedClass.getName()); } } diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonMixinTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonMixinTest.java index 619180f1b6..0fcd99fc0c 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonMixinTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonMixinTest.java @@ -19,7 +19,7 @@ package org.apache.fory.json; -import static org.apache.fory.json.JsonTestSupport.generatedCodecId; +import static org.apache.fory.json.JsonTestSupport.generatedCodecIdentity; import static org.apache.fory.json.JsonTestSupport.generatedUtf8WriterClass; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertNotEquals; @@ -29,7 +29,9 @@ import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.UUID; import org.apache.fory.json.annotation.JsonAnyGetter; @@ -55,6 +57,7 @@ import org.apache.fory.json.reader.Utf8JsonReader; import org.apache.fory.json.writer.StringJsonWriter; import org.apache.fory.json.writer.Utf8JsonWriter; +import org.apache.fory.meta.TypeExtMeta; import org.apache.fory.platform.JdkVersion; import org.apache.fory.reflect.TypeRef; import org.testng.SkipException; @@ -214,11 +217,11 @@ public void registrationLifecycle() { repeated.toJsonBytes(new NameTarget("repeat")); equivalent.toJsonBytes(new NameTarget("equal")); assertEquals( - generatedCodecId(generatedUtf8WriterClass(repeated, NameTarget.class)), - generatedCodecId(generatedUtf8WriterClass(equivalent, NameTarget.class))); + generatedCodecIdentity(generatedUtf8WriterClass(repeated, NameTarget.class)), + generatedCodecIdentity(generatedUtf8WriterClass(equivalent, NameTarget.class))); assertNotEquals( - generatedCodecId(generatedUtf8WriterClass(first, NameTarget.class)), - generatedCodecId(generatedUtf8WriterClass(second, NameTarget.class))); + generatedCodecIdentity(generatedUtf8WriterClass(first, NameTarget.class)), + generatedCodecIdentity(generatedUtf8WriterClass(second, NameTarget.class))); } assertGeneratedWhenSupported(first, NameTarget.class); assertGeneratedWhenSupported(second, NameTarget.class); @@ -298,6 +301,33 @@ public void replacementAndRemoval() { assertEquals(order.toJson(new OrderBarrierChild()), "{\"a\":1,\"b\":2}"); } + @Test + public void covariantSubtypeAuthority() { + ForyJson direct = newJson(); + assertEquals( + direct.fromJson("[]", covariantList(String.class)), java.util.Collections.emptyList()); + assertEquals( + direct.fromJson("[]", covariantList(RemovedShape.class)), + java.util.Collections.emptyList()); + assertThrows(ForyJsonException.class, () -> direct.fromJson("[]", covariantList(Shape.class))); + + ForyJson replacement = newJsonBuilder().registerMixin(ShapeMixin.class).build(); + assertEquals( + replacement.fromJson("[]", covariantList(Shape.class)), java.util.Collections.emptyList()); + + ForyJson removed = newJsonBuilder().registerMixin(ShapeRemoveMixin.class).build(); + assertThrows( + ForyJsonException.class, () -> removed.fromJson("[]", covariantList(RemovedShape.class))); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static TypeRef> covariantList(Class elementType) { + TypeExtMeta metadata = TypeExtMeta.of(0, false, false, false, true); + TypeRef element = TypeRef.of((Class) elementType, metadata); + return (TypeRef) + TypeRef.ofDeclaredTypeArguments((Class) List.class, null, Arrays.asList(element), null); + } + @Test public void matching() { ForyJson overload = newJsonBuilder().registerMixin(OverloadMixin.class).build(); diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonModuleTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonModuleTest.java index a6c373031c..2869b68c02 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonModuleTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonModuleTest.java @@ -20,24 +20,35 @@ package org.apache.fory.json; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertSame; import static org.testng.Assert.assertThrows; +import java.lang.reflect.Constructor; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import org.apache.fory.json.annotation.JsonType; import org.apache.fory.json.codec.AbstractJsonValueCodec; import org.apache.fory.json.codec.CompositeJsonCodec; +import org.apache.fory.json.codec.JsonObjectModel; import org.apache.fory.json.codec.JsonValueCodec; +import org.apache.fory.json.codec.ScalarCodecs; import org.apache.fory.json.reader.JsonReader; import org.apache.fory.json.reader.Latin1JsonReader; import org.apache.fory.json.reader.Utf16JsonReader; import org.apache.fory.json.reader.Utf8JsonReader; +import org.apache.fory.json.resolver.ExactTypeRequiredException; +import org.apache.fory.json.resolver.JsonSharedRegistry; import org.apache.fory.json.resolver.JsonTypeInfo; import org.apache.fory.json.resolver.JsonTypeResolver; import org.apache.fory.json.resolver.UnsupportedJsonTypeException; import org.apache.fory.json.writer.JsonWriter; import org.apache.fory.json.writer.StringJsonWriter; import org.apache.fory.json.writer.Utf8JsonWriter; +import org.apache.fory.meta.TypeExtMeta; import org.apache.fory.reflect.TypeRef; +import org.apache.fory.type.Types; import org.testng.annotations.Test; public class JsonModuleTest { @@ -86,6 +97,87 @@ public void applicationExactCodecWins() { assertEquals(json.toJson(new Text("value")), "\"application:value\""); } + @Test + public void semanticPrimitiveUsesModuleCodec() { + JsonCodecFactory factory = + (type, resolver) -> + type.getRawType() == int.class + && type.getTypeExtMeta() != null + && type.getTypeExtMeta().typeId() == Types.UINT32 + ? UnsignedIntCodec.INSTANCE + : null; + ForyJson json = + ForyJson.builder().withModule(context -> context.registerCodecFactory(factory)).build(); + TypeRef unsignedInt = + TypeRef.of(int.class, TypeExtMeta.of(Types.UINT32, false, false)); + + assertEquals(json.toJson(-1, unsignedInt), "4294967295"); + assertEquals(json.toJsonBytes(-1, unsignedInt), "4294967295".getBytes(StandardCharsets.UTF_8)); + assertEquals(json.fromJson("4294967295", unsignedInt), Integer.valueOf(-1)); + assertEquals( + json.fromJson("4294967295".getBytes(StandardCharsets.UTF_8), unsignedInt), + Integer.valueOf(-1)); + + ForyJson application = + ForyJson.builder() + .withModule(context -> context.registerCodecFactory(factory)) + .registerCodec(int.class, ScalarCodecs.IntCodec.PRIMITIVE) + .build(); + assertEquals(application.toJson(-1, unsignedInt), "-1"); + } + + @Test + public void languageModelOwnsAnnotatedType() { + JsonCodecFactory factory = + (type, resolver) -> + type.getRawType() == ModuleObject.class + ? resolver.createObjectCodec( + type, JsonObjectModel.fixedInstance(ModuleObject.INSTANCE)) + : null; + ForyJson json = + ForyJson.builder().withModule(context -> context.registerCodecFactory(factory)).build(); + + assertEquals(json.toJson(ModuleObject.INSTANCE, ModuleObject.class), "{}"); + assertSame(json.fromJson("{}", ModuleObject.class), ModuleObject.INSTANCE); + assertThrows( + ForyJsonException.class, + () -> ForyJson.builder().build().toJson(ModuleObject.INSTANCE, ModuleObject.class)); + } + + @Test + public void hostedFactoryDefersRawSemanticType() throws Exception { + AtomicInteger rawAttempts = new AtomicInteger(); + JsonCodecFactory factory = + (type, resolver) -> { + if (type.getRawType() != SemanticLeaf.class) { + return null; + } + if (!type.hasTypeExtMeta()) { + rawAttempts.incrementAndGet(); + throw new ExactTypeRequiredException("SemanticLeaf requires an exact occurrence"); + } + return ScalarCodecs.StringCodec.INSTANCE; + }; + ForyJson configured = + ForyJson.builder().withModule(context -> context.registerCodecFactory(factory)).build(); + Constructor constructor = + JsonSharedRegistry.class.getDeclaredConstructor( + JsonConfig.class, ExecutorService.class, boolean.class); + constructor.setAccessible(true); + JsonTypeResolver resolver = + new JsonTypeResolver(constructor.newInstance(configured.config(), null, true)); + + assertEquals(resolver.generateHostedCodecs(SemanticLeaf.class).size(), 0); + assertEquals(rawAttempts.get(), 1); + assertEquals(resolver.generateHostedCodecs(SemanticLeaf.class).size(), 0); + assertEquals(rawAttempts.get(), 2); + + TypeRef exactType = + TypeRef.of(SemanticLeaf.class, TypeExtMeta.of(Types.UNKNOWN, false, false)); + JsonTypeInfo exactInfo = resolver.getTypeInfo(exactType); + assertSame(exactInfo.stringWriter(), ScalarCodecs.StringCodec.INSTANCE); + } + @Test public void duplicateModuleKeyFails() { ForyJsonModule first = new KeyedModule("same"); @@ -231,6 +323,20 @@ public Text read(JsonReader reader) { } } + private static final class UnsignedIntCodec extends AbstractJsonValueCodec { + private static final UnsignedIntCodec INSTANCE = new UnsignedIntCodec(); + + @Override + public void write(JsonWriter writer, Integer value) { + writer.writeUnsignedInt(value.intValue()); + } + + @Override + public Integer read(JsonReader reader) { + return reader.readUnsignedInt(); + } + } + private static final class RecursiveCodec implements CompositeJsonCodec { private final AtomicBoolean fail; private JsonTypeInfo self; @@ -355,4 +461,13 @@ private RecursiveValue(RecursiveValue next) { this.next = next; } } + + private static final class SemanticLeaf {} + + @JsonType + private static final class ModuleObject { + private static final ModuleObject INSTANCE = new ModuleObject(); + + private ModuleObject() {} + } } diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonScalarTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonScalarTest.java index 3345a82279..3d23441815 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonScalarTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonScalarTest.java @@ -1118,6 +1118,72 @@ public void readCommonScalarReaders() { assertEquals(utf16Reader(duration).readDuration(), expectedDuration); } + @Test + public void readQuotedText() { + assertQuotedText("\"fory-json\"", "fory-json", true); + assertQuotedText("\"A\\u4e2d\\ud83d\\ude00\"", "A\u4e2d\ud83d\ude00", true); + assertQuotedText("\"A\u4e2d\ud83d\ude00\"", "A\u4e2d\ud83d\ude00", false); + assertEquals(newUtf8Reader("null".getBytes(StandardCharsets.UTF_8)).readQuotedText(), null); + assertEquals(newLatin1Reader(latin1Bytes("null")).readQuotedText(), null); + assertEquals(utf16Reader("null").readQuotedText(), null); + + Utf8JsonReader reused = newUtf8Reader("\"A\\u4e2d\" \"next\"".getBytes(StandardCharsets.UTF_8)); + CharSequence first = reused.readQuotedText(); + assertEquals(first.toString(), "A\u4e2d"); + CharSequence second = reused.readQuotedText(); + assertTrue(first == second); + assertEquals(second.toString(), "next"); + + assertInvalidQuotedText("\"\\uD800\""); + assertInvalidQuotedText("\"\\x\""); + + ForyJson json = newJson(); + assertEquals( + json.fromJson("\"123e4567-e89b-12d3-a456-42661417400\\u0030\"", UUID.class), + UUID.fromString("123e4567-e89b-12d3-a456-426614174000")); + assertEquals(json.fromJson("\"1-1-1-1-1\"", UUID.class), UUID.fromString("1-1-1-1-1")); + assertEquals( + json.fromJson("\"2024-02-03T04:05:06.123\\u005a\"", Instant.class), + Instant.parse("2024-02-03T04:05:06.123Z")); + assertEquals( + json.fromJson("\"PT1H1M1.123\\u0053\"", Duration.class), + Duration.ofSeconds(3661, 123_000_000)); + } + + @Test + public void writePrimitiveQuotedScalars() { + UUID uuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + assertUuidWriter(uuid); + + Instant[] instants = { + Instant.EPOCH, + Instant.ofEpochSecond(-1, 1), + Instant.parse("2024-02-03T04:05:06.123456789Z"), + Instant.MIN, + Instant.MAX + }; + for (Instant instant : instants) { + assertInstantWriter(instant); + } + assertInvalidInstantWriter(Instant.MIN.getEpochSecond() - 1, 0); + assertInvalidInstantWriter(Instant.MAX.getEpochSecond() + 1, 0); + assertInvalidInstantWriter(0, -1); + assertInvalidInstantWriter(0, 1_000_000_000); + + assertDurationWriter(false, false, 0, 0, 0, 0, "\"PT0S\""); + assertDurationWriter(false, false, 1, 0, 1, 120_000_000, "\"PT1H0M1.120S\""); + assertDurationWriter(false, true, 0, 1, 2, 1, "\"-PT1M2.000000001S\""); + assertDurationWriter(true, false, 0, 0, 0, 0, "\"PT9999999999999H\""); + assertDurationWriter(true, true, 0, 0, 0, 0, "\"-PT9999999999999H\""); + + assertInvalidDurationWriter(false, true, 0, 0, 0, 0); + assertInvalidDurationWriter(false, false, -1, 0, 0, 0); + assertInvalidDurationWriter(false, false, 0, 60, 0, 0); + assertInvalidDurationWriter(false, false, 0, 0, 60, 0); + assertInvalidDurationWriter(false, false, 0, 0, 0, 1_000_000_000); + assertInvalidDurationWriter(true, false, 1, 0, 0, 0); + } + @Test public void writeReadSqlTimeScalars() { ForyJson json = newJson(); @@ -3063,6 +3129,92 @@ private static void assertInvalidFloat(JsonReader reader) { }); } + private static void assertQuotedText(String input, String expected, boolean latin1) { + assertEquals( + newUtf8Reader(input.getBytes(StandardCharsets.UTF_8)).readQuotedText().toString(), + expected); + if (latin1) { + assertEquals(newLatin1Reader(latin1Bytes(input)).readQuotedText().toString(), expected); + } + assertEquals(utf16Reader(input).readQuotedText().toString(), expected); + } + + private static void assertInvalidQuotedText(String input) { + assertThrows( + ForyJsonException.class, + () -> newUtf8Reader(input.getBytes(StandardCharsets.UTF_8)).readQuotedText()); + assertThrows( + ForyJsonException.class, () -> newLatin1Reader(latin1Bytes(input)).readQuotedText()); + assertThrows(ForyJsonException.class, () -> utf16Reader(input).readQuotedText()); + } + + private static void assertUuidWriter(UUID value) { + String expected = '"' + value.toString() + '"'; + Utf8JsonWriter utf8Writer = newUtf8Writer(new byte[4]); + utf8Writer.writeUuid(value.getMostSignificantBits(), value.getLeastSignificantBits()); + assertEquals(new String(utf8Writer.toJsonBytes(), StandardCharsets.UTF_8), expected); + StringJsonWriter stringWriter = newStringWriter(new byte[4]); + stringWriter.writeUuid(value.getMostSignificantBits(), value.getLeastSignificantBits()); + assertEquals(stringWriter.toJson(), expected); + } + + private static void assertInstantWriter(Instant value) { + String expected = '"' + value.toString() + '"'; + Utf8JsonWriter utf8Writer = newUtf8Writer(new byte[4]); + utf8Writer.writeIsoInstant(value.getEpochSecond(), value.getNano()); + assertEquals(new String(utf8Writer.toJsonBytes(), StandardCharsets.UTF_8), expected); + StringJsonWriter stringWriter = newStringWriter(new byte[4]); + stringWriter.writeIsoInstant(value.getEpochSecond(), value.getNano()); + assertEquals(stringWriter.toJson(), expected); + StringJsonWriter utf16Writer = utf16StringWriter(); + utf16Writer.writeIsoInstant(value.getEpochSecond(), value.getNano()); + assertEquals(utf16Writer.toJson(), expected); + } + + private static void assertInvalidInstantWriter(long epochSecond, int nanos) { + Utf8JsonWriter utf8Writer = newUtf8Writer(new byte[4]); + assertThrows(ForyJsonException.class, () -> utf8Writer.writeIsoInstant(epochSecond, nanos)); + assertEquals(utf8Writer.toJsonBytes().length, 0); + + StringJsonWriter stringWriter = newStringWriter(new byte[4]); + assertThrows(ForyJsonException.class, () -> stringWriter.writeIsoInstant(epochSecond, nanos)); + assertEquals(stringWriter.toJson(), ""); + } + + private static void assertDurationWriter( + boolean infinite, + boolean negative, + long hours, + int minutes, + int seconds, + int nanos, + String expected) { + Utf8JsonWriter utf8Writer = newUtf8Writer(new byte[4]); + utf8Writer.writeIsoDuration(infinite, negative, hours, minutes, seconds, nanos); + assertEquals(new String(utf8Writer.toJsonBytes(), StandardCharsets.UTF_8), expected); + StringJsonWriter stringWriter = newStringWriter(new byte[4]); + stringWriter.writeIsoDuration(infinite, negative, hours, minutes, seconds, nanos); + assertEquals(stringWriter.toJson(), expected); + StringJsonWriter utf16Writer = utf16StringWriter(); + utf16Writer.writeIsoDuration(infinite, negative, hours, minutes, seconds, nanos); + assertEquals(utf16Writer.toJson(), expected); + } + + private static void assertInvalidDurationWriter( + boolean infinite, boolean negative, long hours, int minutes, int seconds, int nanos) { + Utf8JsonWriter utf8Writer = newUtf8Writer(new byte[4]); + assertThrows( + ForyJsonException.class, + () -> utf8Writer.writeIsoDuration(infinite, negative, hours, minutes, seconds, nanos)); + assertEquals(utf8Writer.toJsonBytes().length, 0); + + StringJsonWriter stringWriter = newStringWriter(new byte[4]); + assertThrows( + ForyJsonException.class, + () -> stringWriter.writeIsoDuration(infinite, negative, hours, minutes, seconds, nanos)); + assertEquals(stringWriter.toJson(), ""); + } + private static void assertWriterNumber(BigInteger value, String expected) { Utf8JsonWriter utf8Writer = newUtf8Writer(new byte[4]); utf8Writer.writeBigInteger(value); diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonSubTypesTest.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonSubTypesTest.java index 4074098d00..bccf56a5e4 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonSubTypesTest.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonSubTypesTest.java @@ -26,21 +26,30 @@ import static org.testng.Assert.assertThrows; import java.io.ByteArrayOutputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.fory.json.annotation.JsonIgnore; import org.apache.fory.json.annotation.JsonPropertyOrder; import org.apache.fory.json.annotation.JsonSubTypes; import org.apache.fory.json.annotation.JsonSubTypes.Inclusion; +import org.apache.fory.json.codec.JsonObjectModel; import org.apache.fory.json.codec.JsonValueCodec; import org.apache.fory.json.meta.JsonSubtypeScanInfo; import org.apache.fory.json.reader.JsonReader; import org.apache.fory.json.reader.Latin1JsonReader; import org.apache.fory.json.reader.Utf16JsonReader; import org.apache.fory.json.reader.Utf8JsonReader; +import org.apache.fory.json.resolver.ExactTypeRequiredException; import org.apache.fory.json.writer.StringJsonWriter; import org.apache.fory.json.writer.Utf8JsonWriter; +import org.apache.fory.meta.TypeExtMeta; import org.apache.fory.reflect.TypeRef; +import org.apache.fory.type.Types; import org.testng.annotations.Factory; import org.testng.annotations.Test; @@ -123,6 +132,107 @@ public void wrapperObjectCustomCodec() { "z"); } + @Test + public void exactSubtypeOccurrenceRollsBack() { + AtomicBoolean fail = new AtomicBoolean(true); + AtomicInteger exactAttempts = new AtomicInteger(); + JsonCodecFactory factory = + (type, resolver) -> { + if (type.getRawType() != SemanticValue.class) { + return null; + } + TypeExtMeta metadata = type.getTypeExtMeta(); + if (metadata == null) { + throw new ExactTypeRequiredException("SemanticValue requires an exact occurrence"); + } + if (metadata.nullable()) { + throw new ForyJsonException("Closed subtype branch must be non-null"); + } + exactAttempts.incrementAndGet(); + if (fail.getAndSet(false)) { + throw new ForyJsonException("forced exact subtype failure"); + } + return SemanticValueCodec.INSTANCE; + }; + ForyJson json = newJsonBuilder().withModule(c -> c.registerCodecFactory(factory)).build(); + + assertThrows( + ForyJsonException.class, + () -> json.fromJson("{\"semantic\":\"first\"}", SemanticBase.class)); + SemanticBase decoded = json.fromJson("{\"semantic\":\"second\"}", SemanticBase.class); + assertEquals(((SemanticValue) decoded).text, "second"); + assertEquals(exactAttempts.get(), 2); + assertEquals(json.toJson(decoded, SemanticBase.class), "{\"semantic\":\"second\"}"); + assertEquals(json.toJson(null, SemanticBase.class), "null"); + assertEquals(json.fromJson("null", SemanticBase.class), null); + + assertThrows( + ExactTypeRequiredException.class, () -> json.fromJson("\"raw\"", SemanticValue.class)); + TypeRef exactType = + TypeRef.of(SemanticValue.class, TypeExtMeta.of(Types.UNKNOWN, false, false)); + assertEquals(json.fromJson("\"exact\"", exactType).text, "exact"); + } + + @Test + public void fixedObjectSubtype() { + JsonCodecFactory factory = + (type, resolver) -> + resolver.createObjectCodec(type, JsonObjectModel.fixedInstance(FixedValue.INSTANCE)); + ForyJson json = newJsonBuilder().registerCodec(FixedValue.class, factory).build(); + + assertEquals(json.toJson(FixedValue.INSTANCE, FixedBase.class), "{\"kind\":\"fixed\"}"); + assertEquals(json.fromJson("{\"kind\":\"fixed\"}", FixedBase.class), FixedValue.INSTANCE); + assertEquals(json.toJson(FixedValue.INSTANCE, FixedValue.class), "{}"); + assertEquals(json.fromJson("{}", FixedValue.class), FixedValue.INSTANCE); + assertThrows( + ForyJsonException.class, + () -> json.fromJson("{\"kind\":\"fixed\",\"extra\":1}", FixedBase.class)); + assertThrows(ForyJsonException.class, () -> json.fromJson("{\"extra\":1}", FixedValue.class)); + } + + @Test + public void fixedObjectState() { + ForyJson inherited = + newJsonBuilder() + .registerCodec( + InheritedFixed.class, + (type, resolver) -> + resolver.createObjectCodec( + type, JsonObjectModel.fixedInstance(InheritedFixed.INSTANCE))) + .build(); + assertThrows(ForyJsonException.class, () -> inherited.fromJson("{}", InheritedFixed.class)); + + ForyJson ignored = + newJsonBuilder() + .registerCodec( + IgnoredFixed.class, + (type, resolver) -> + resolver.createObjectCodec( + type, JsonObjectModel.fixedInstance(IgnoredFixed.INSTANCE))) + .build(); + assertEquals(ignored.toJson(IgnoredFixed.INSTANCE, IgnoredFixed.class), "{}"); + assertEquals(ignored.fromJson("{}", IgnoredFixed.class), IgnoredFixed.INSTANCE); + + Field compilerField = declaredField(CompilerFixed.class, "ordinal"); + ForyJson compilerStorage = + newJsonBuilder() + .registerCodec( + CompilerFixed.class, + (type, resolver) -> + resolver.createObjectCodec( + type, + JsonObjectModel.fixedInstance( + CompilerFixed.INSTANCE, + new String[0], + new Method[0], + new Method[0], + new TypeRef[0], + new Field[] {compilerField}))) + .build(); + assertEquals(compilerStorage.toJson(CompilerFixed.INSTANCE, CompilerFixed.class), "{}"); + assertEquals(compilerStorage.fromJson("{}", CompilerFixed.class), CompilerFixed.INSTANCE); + } + @Test public void wrapperArray() { ForyJson json = newJson(); @@ -295,6 +405,14 @@ private static void assertScannerRestored(JsonReader reader, JsonSubtypeScanInfo reader.expect('{'); } + private static Field declaredField(Class owner, String name) { + try { + return owner.getDeclaredField(name); + } catch (NoSuchFieldException e) { + throw new AssertionError(e); + } + } + @JsonSubTypes( property = "kind", value = { @@ -310,6 +428,57 @@ public interface Shape {} value = {@JsonSubTypes.Type(value = WrappedValue.class, name = "value")}) public interface Wrapped {} + @JsonSubTypes( + inclusion = Inclusion.WRAPPER_OBJECT, + value = {@JsonSubTypes.Type(value = SemanticValue.class, name = "semantic")}) + public interface SemanticBase {} + + public static final class SemanticValue implements SemanticBase { + private final String text; + + private SemanticValue(String text) { + this.text = text; + } + } + + @JsonSubTypes( + property = "kind", + value = {@JsonSubTypes.Type(value = FixedValue.class, name = "fixed")}) + public interface FixedBase {} + + public static final class FixedValue implements FixedBase { + static final FixedValue INSTANCE = new FixedValue(); + + private FixedValue() {} + } + + public static class InheritedState { + public int state = 1; + } + + public static final class InheritedFixed extends InheritedState { + static final InheritedFixed INSTANCE = new InheritedFixed(); + + private InheritedFixed() {} + } + + public static class IgnoredInheritedState { + @JsonIgnore public int state = 1; + } + + public static final class IgnoredFixed extends IgnoredInheritedState { + static final IgnoredFixed INSTANCE = new IgnoredFixed(); + + private IgnoredFixed() {} + } + + public static final class CompilerFixed { + static final CompilerFixed INSTANCE = new CompilerFixed(); + private final int ordinal = 1; + + private CompilerFixed() {} + } + @JsonSubTypes( inclusion = Inclusion.WRAPPER_ARRAY, value = { @@ -379,6 +548,35 @@ public WrappedValue readUtf8(Utf8JsonReader reader) { } } + private static final class SemanticValueCodec implements JsonValueCodec { + private static final SemanticValueCodec INSTANCE = new SemanticValueCodec(); + + @Override + public void writeString(StringJsonWriter writer, SemanticValue value) { + writer.writeString(value.text); + } + + @Override + public void writeUtf8(Utf8JsonWriter writer, SemanticValue value) { + writer.writeString(value.text); + } + + @Override + public SemanticValue readLatin1(Latin1JsonReader reader) { + return new SemanticValue(reader.readString()); + } + + @Override + public SemanticValue readUtf16(Utf16JsonReader reader) { + return new SemanticValue(reader.readString()); + } + + @Override + public SemanticValue readUtf8(Utf8JsonReader reader) { + return new SemanticValue(reader.readString()); + } + } + public static final class ArrayWrappedValue implements ArrayWrapped { public String text; diff --git a/java/fory-json/src/test/java/org/apache/fory/json/JsonTestSupport.java b/java/fory-json/src/test/java/org/apache/fory/json/JsonTestSupport.java index e5d7f6f369..5a51917762 100644 --- a/java/fory-json/src/test/java/org/apache/fory/json/JsonTestSupport.java +++ b/java/fory-json/src/test/java/org/apache/fory/json/JsonTestSupport.java @@ -28,9 +28,11 @@ import org.apache.fory.json.reader.Utf8JsonReader; import org.apache.fory.json.resolver.CodecRegistry; import org.apache.fory.json.resolver.JsonSharedRegistry; +import org.apache.fory.json.resolver.JsonTypeInfo; import org.apache.fory.json.resolver.JsonTypeResolver; import org.apache.fory.json.writer.StringJsonWriter; import org.apache.fory.json.writer.Utf8JsonWriter; +import org.apache.fory.reflect.TypeRef; import org.apache.fory.serializer.StringSerializer; final class JsonTestSupport { @@ -185,14 +187,28 @@ static Class generatedUtf8WriterClass(ForyJson json, Class type) { return codec.getClass(); } - static int generatedCodecId(Class generatedClass) { + static Class generatedUtf8WriterClass(ForyJson json, TypeRef type) { + JsonTypeResolver resolver = currentTypeResolver(json); + JsonTypeInfo typeInfo = resolver.getTypeInfo(type); + Object owner = resolver.canonicalObjectCodec(typeInfo); + Object codec = typeInfo.utf8Writer(); + if (owner == null || codec == owner) { + throw new AssertionError("No generated UTF-8 writer for " + type); + } + return codec.getClass(); + } + + static String generatedCodecIdentity(Class generatedClass) { String simpleName = generatedClass.getSimpleName(); - int suffixStart = simpleName.lastIndexOf(GENERATED_CODEC_SUFFIX); + int suffixStart = simpleName.lastIndexOf(GENERATED_CODEC_SUFFIX + "_"); if (suffixStart < 0) { throw new AssertionError("Unexpected generated class " + generatedClass.getName()); } - String id = simpleName.substring(suffixStart + GENERATED_CODEC_SUFFIX.length()); - return id.isEmpty() ? 0 : Integer.parseInt(id); + String identity = simpleName.substring(suffixStart + GENERATED_CODEC_SUFFIX.length() + 1); + if (!identity.matches("[0-9a-f]{64}")) { + throw new AssertionError("Unexpected generated class " + generatedClass.getName()); + } + return identity; } static String stringReaderPath(String input) { diff --git a/java/fory-json/src/test/java/org/apache/fory/json/codegen/JsonCodegenIdentityTest.java b/java/fory-json/src/test/java/org/apache/fory/json/codegen/JsonCodegenIdentityTest.java new file mode 100644 index 0000000000..b3ab4db2dc --- /dev/null +++ b/java/fory-json/src/test/java/org/apache/fory/json/codegen/JsonCodegenIdentityTest.java @@ -0,0 +1,47 @@ +/* + * 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.fory.json.codegen; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.expectThrows; + +import org.apache.fory.json.ForyJsonException; +import org.apache.fory.json.PropertyNamingStrategy; +import org.testng.annotations.Test; + +public class JsonCodegenIdentityTest { + @Test + public void rejectClassNameCollision() { + JsonCodegen codegen = + new JsonCodegen( + new JsonCodegenKey( + false, true, PropertyNamingStrategy.LOWER_CAMEL_CASE, "factory", "mixin"), + getClass().getClassLoader(), + false); + + codegen.registerGeneratedIdentity("example.Generated", "complete-signature-a"); + codegen.registerGeneratedIdentity("example.Generated", "complete-signature-a"); + assertEquals( + codegen.generatedClassSignatures().get("example.Generated"), "complete-signature-a"); + expectThrows( + ForyJsonException.class, + () -> codegen.registerGeneratedIdentity("example.Generated", "complete-signature-b")); + } +} diff --git a/java/fory-json/src/test/java/org/apache/fory/json/resolver/JsonGeneratedClassRegistryTest.java b/java/fory-json/src/test/java/org/apache/fory/json/resolver/JsonGeneratedClassRegistryTest.java new file mode 100644 index 0000000000..a168b54300 --- /dev/null +++ b/java/fory-json/src/test/java/org/apache/fory/json/resolver/JsonGeneratedClassRegistryTest.java @@ -0,0 +1,128 @@ +/* + * 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.fory.json.resolver; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotEquals; +import static org.testng.Assert.assertSame; +import static org.testng.Assert.expectThrows; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.fory.json.codec.GeneratedJsonCodec; +import org.apache.fory.json.meta.JsonFieldAccessor; +import org.apache.fory.meta.TypeExtMeta; +import org.apache.fory.reflect.TypeRef; +import org.apache.fory.type.Types; +import org.testng.annotations.Test; + +public class JsonGeneratedClassRegistryTest { + @Test + public void rejectSignatureCollision() { + Map signatures = new HashMap<>(); + JsonGeneratedClassRegistry.mergeSignatures( + signatures, Collections.singletonMap("example.Generated", "complete-signature-a")); + JsonGeneratedClassRegistry.mergeSignatures( + signatures, Collections.singletonMap("example.Generated", "complete-signature-a")); + assertEquals(signatures.get("example.Generated"), "complete-signature-a"); + + expectThrows( + IllegalStateException.class, + () -> + JsonGeneratedClassRegistry.mergeSignatures( + signatures, Collections.singletonMap("example.Generated", "complete-signature-b"))); + } + + @Test + public void mergeSourceCodecs() { + TypeRef type = TypeRef.of(String.class); + Map, GeneratedJsonCodec> codecs = new HashMap<>(); + Set> added = new LinkedHashSet<>(); + JsonGeneratedClassRegistry.mergeSourceCodecs( + Collections.singletonMap(type, new SourceCodec()), codecs, added); + JsonGeneratedClassRegistry.mergeSourceCodecs( + Collections.singletonMap(type, new SourceCodec()), codecs, added); + assertEquals(codecs.get(type).getClass(), SourceCodec.class); + assertEquals(added, Collections.singleton(SourceCodec.class)); + expectThrows( + IllegalStateException.class, + () -> + JsonGeneratedClassRegistry.mergeSourceCodecs( + Collections.singletonMap(type, new OtherSourceCodec()), codecs, added)); + } + + @Test + public void generatedCapabilityType() { + TypeRef raw = TypeRef.of(String.class); + TypeRef nonNull = TypeRef.of(String.class, ordinary(false)); + TypeRef nullable = TypeRef.of(String.class, ordinary(true)); + assertEquals(JsonSharedRegistry.generatedCapabilityType(nonNull), raw); + assertEquals(JsonSharedRegistry.generatedCapabilityType(nullable), raw); + + assertPreserved(TypeExtMeta.of(Types.UINT8, false, false, false, false)); + assertPreserved(TypeExtMeta.of(Types.UNKNOWN, false, true, false, false)); + assertPreserved(TypeExtMeta.of(Types.UNKNOWN, false, false, true, false)); + assertPreserved(TypeExtMeta.of(Types.UNKNOWN, false, false, false, true)); + + TypeRef nullableElement = TypeRef.of(String.class, ordinary(true)); + TypeRef list = + TypeRef.ofDeclaredTypeArguments( + java.util.List.class, + ordinary(false), + Collections.singletonList(nullableElement), + null); + TypeRef generated = JsonSharedRegistry.generatedCapabilityType(list); + assertEquals(generated.getTypeArguments().get(0), nullableElement); + assertNotEquals(generated, TypeRef.of(list.getType())); + + TypeRef key = TypeRef.of(Integer.class, ordinary(false)); + TypeRef value = TypeRef.of(String.class, ordinary(true)); + List> mapArguments = java.util.Arrays.asList(key, value); + TypeRef map = TypeRef.ofDeclaredTypeArguments(Map.class, ordinary(true), mapArguments, null); + assertEquals(JsonSharedRegistry.generatedCapabilityType(map).getTypeArguments(), mapArguments); + } + + private static void assertPreserved(TypeExtMeta metadata) { + TypeRef type = TypeRef.of(String.class, metadata); + assertSame(JsonSharedRegistry.generatedCapabilityType(type), type); + } + + private static TypeExtMeta ordinary(boolean nullable) { + return TypeExtMeta.of(Types.UNKNOWN, nullable, false, false, false); + } + + private static class SourceCodec extends GeneratedJsonCodec { + @Override + public Class type() { + return String.class; + } + + @Override + public JsonFieldAccessor[] fieldAccessors() { + return new JsonFieldAccessor[0]; + } + } + + private static final class OtherSourceCodec extends SourceCodec {} +} diff --git a/java/fory-json/src/test/java/org/apache/fory/json/resolver/JsonMixinAnnotationsTest.java b/java/fory-json/src/test/java/org/apache/fory/json/resolver/JsonMixinAnnotationsTest.java new file mode 100644 index 0000000000..ce0625df49 --- /dev/null +++ b/java/fory-json/src/test/java/org/apache/fory/json/resolver/JsonMixinAnnotationsTest.java @@ -0,0 +1,60 @@ +/* + * 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.fory.json.resolver; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNull; + +import java.lang.reflect.Parameter; +import org.apache.fory.json.annotation.JsonMixin; +import org.apache.fory.json.annotation.JsonMixinRemove; +import org.apache.fory.json.annotation.JsonProperty; +import org.testng.annotations.Test; + +public class JsonMixinAnnotationsTest { + @Test + public void parameterAnnotations() { + Parameter parameter = Target.class.getDeclaredConstructors()[0].getParameters()[0]; + assertEquals( + JsonMixinAnnotations.targetAnnotation(parameter, JsonProperty.class).value(), "target"); + + JsonMixinAnnotations.TargetOverlay replacement = + JsonMixinAnnotations.resolve(Target.class, Replacement.class); + assertEquals(replacement.annotation(parameter, JsonProperty.class).value(), "replacement"); + + JsonMixinAnnotations.TargetOverlay removal = + JsonMixinAnnotations.resolve(Target.class, Removal.class); + assertNull(removal.annotation(parameter, JsonProperty.class)); + } + + private static final class Target { + public Target(@JsonProperty("target") String value) {} + } + + @JsonMixin(target = Target.class) + private abstract static class Replacement { + private Replacement(@JsonProperty("replacement") String value) {} + } + + @JsonMixin(target = Target.class) + private abstract static class Removal { + private Removal(@JsonMixinRemove(JsonProperty.class) String value) {} + } +} diff --git a/java/pom.xml b/java/pom.xml index 1be5b9b2e6..a5596b35d3 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -88,6 +88,17 @@ + + snapshot-publication + + + + org.apache.maven.plugins + maven-source-plugin + + + + apache-release diff --git a/kotlin/fory-json-kotlin-ksp/README.md b/kotlin/fory-json-kotlin-ksp/README.md new file mode 100644 index 0000000000..727fc71095 --- /dev/null +++ b/kotlin/fory-json-kotlin-ksp/README.md @@ -0,0 +1,26 @@ +# Fory JSON Kotlin KSP + +`fory-json-kotlin-ksp` generates exact R8 and ProGuard retention rules for Kotlin classes annotated +with `@JsonType`. It also owns a source `@JsonMixin` request when either the Mixin or its exact +target is Kotlin. + +Use it in Android applications that enable shrinking or obfuscation: + +```kotlin +plugins { + id("com.google.devtools.ksp") version "2.3.8" +} + +dependencies { + implementation("org.apache.fory:fory-json-kotlin:1.7.0-SNAPSHOT") + ksp("org.apache.fory:fory-json-kotlin-ksp:1.7.0-SNAPSHOT") +} +``` + +The runtime reads Kotlin/JVM metadata directly. This processor does not generate application code, +codecs, or construction operations, and it is not required for an unminified JVM build. Keep the +generated rule resources in the Android application and do not replace them with package-wide keep +rules. + +See the [Kotlin JSON guide](../../docs/json/kotlin.md) and +[Android guide](../../docs/json/android.md) for model and release-build setup. diff --git a/kotlin/fory-json-kotlin-ksp/pom.xml b/kotlin/fory-json-kotlin-ksp/pom.xml new file mode 100644 index 0000000000..5969170a87 --- /dev/null +++ b/kotlin/fory-json-kotlin-ksp/pom.xml @@ -0,0 +1,109 @@ + + + + + org.apache.fory + fory-kotlin-parent + 1.7.0-SNAPSHOT + + + 4.0.0 + fory-json-kotlin-ksp + Fory JSON Kotlin KSP + Build-time JSON metadata retention rules for Kotlin models used by Apache Fory. + + + + + src/main/resources + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + + + compile + process-sources + compile + + + ${project.basedir}/src/main/kotlin + + -Xexplicit-api=strict + + + + test-compile + test-compile + test-compile + + + ${project.basedir}/src/test/kotlin + + + + + + + com.diffplug.spotless + spotless-maven-plugin + + + maven-surefire-plugin + 3.5.4 + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + org.apache.fory.json.kotlin.ksp + + + + + + + + + + com.google.devtools.ksp + symbol-processing-api + ${ksp.version} + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-test-testng + ${kotlin.version} + test + + + diff --git a/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/ForyJsonKotlinSymbolProcessor.kt b/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/ForyJsonKotlinSymbolProcessor.kt new file mode 100644 index 0000000000..d8add23156 --- /dev/null +++ b/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/ForyJsonKotlinSymbolProcessor.kt @@ -0,0 +1,81 @@ +/* + * 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.fory.json.kotlin.ksp + +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.validate +import java.nio.charset.StandardCharsets + +internal class ForyJsonKotlinSymbolProcessor(environment: SymbolProcessorEnvironment) : + SymbolProcessor { + private val codeGenerator: CodeGenerator = environment.codeGenerator + private val logger: KSPLogger = environment.logger + private val generatedRequests = linkedSetOf() + + override fun process(resolver: Resolver): List { + val deferred = ArrayList() + val modelBuilder = KspModelBuilder(resolver, logger) + for (symbol in resolver.getSymbolsWithAnnotation(JSON_TYPE)) { + if (!symbol.validate()) { + deferred += symbol + continue + } + val declaration = symbol as? KSClassDeclaration + if (declaration == null) { + logger.error("@JsonType can only be used on classes, interfaces, and objects", symbol) + continue + } + val model = modelBuilder.direct(declaration) ?: continue + if (!generatedRequests.add(R8RulesWriter.resourcePath(model))) continue + write(model) + } + for (symbol in resolver.getSymbolsWithAnnotation(JSON_MIXIN)) { + if (!symbol.validate()) { + deferred += symbol + continue + } + val declaration = symbol as? KSClassDeclaration + if (declaration == null) { + logger.error("@JsonMixin can only be used on classes and interfaces", symbol) + continue + } + val model = modelBuilder.mixin(declaration) ?: continue + if (!generatedRequests.add(R8RulesWriter.resourcePath(model))) continue + write(model) + } + return deferred + } + + private fun write(model: JsonModel) { + val dependencies = + Dependencies(aggregating = false, sources = model.originatingFiles.toTypedArray()) + codeGenerator.createNewFileByPath(dependencies, R8RulesWriter.resourcePath(model), "").use { + output -> + output.write(R8RulesWriter.write(model).toByteArray(StandardCharsets.UTF_8)) + } + } +} diff --git a/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/ForyJsonKotlinSymbolProcessorProvider.kt b/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/ForyJsonKotlinSymbolProcessorProvider.kt new file mode 100644 index 0000000000..d344537452 --- /dev/null +++ b/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/ForyJsonKotlinSymbolProcessorProvider.kt @@ -0,0 +1,30 @@ +/* + * 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.fory.json.kotlin.ksp + +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider + +/** Creates the Fory JSON processor discovered through KSP's service loader. */ +public class ForyJsonKotlinSymbolProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + ForyJsonKotlinSymbolProcessor(environment) +} diff --git a/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/JvmDescriptors.kt b/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/JvmDescriptors.kt new file mode 100644 index 0000000000..0fe4c872c4 --- /dev/null +++ b/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/JvmDescriptors.kt @@ -0,0 +1,111 @@ +/* + * 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.fory.json.kotlin.ksp + +internal data class JvmType(val descriptor: String) { + init { + require(parseType(descriptor, 0).second == descriptor.length) { + "Invalid JVM type descriptor $descriptor" + } + require(descriptor != "V") { "void is not a value type" } + } + + val sourceName: String + get() = sourceName(descriptor) +} + +internal data class JvmMethodDescriptor(val parameters: List, val result: String) + +internal fun parseMethodDescriptor(descriptor: String): JvmMethodDescriptor { + require(descriptor.startsWith('(')) { "Invalid JVM method descriptor $descriptor" } + var offset = 1 + val parameters = ArrayList() + while (descriptor[offset] != ')') { + val parsed = parseType(descriptor, offset) + parameters += JvmType(descriptor.substring(offset, parsed.second)) + offset = parsed.second + } + offset++ + val result = parseType(descriptor, offset, allowVoid = true) + require(result.second == descriptor.length) { "Invalid JVM method descriptor $descriptor" } + return JvmMethodDescriptor(parameters, descriptor.substring(offset)) +} + +internal fun methodDescriptor(parameters: List, result: String): String = + parameters.joinToString(separator = "", prefix = "(", postfix = ")$result") { it.descriptor } + +internal fun appendParameters(descriptor: String, parameters: List): String { + val end = descriptor.indexOf(')') + require(end > 0) { "Invalid JVM method descriptor $descriptor" } + return descriptor.substring(0, end) + + parameters.joinToString("") { it.descriptor } + + descriptor.substring(end) +} + +private fun parseType( + descriptor: String, + start: Int, + allowVoid: Boolean = false, +): Pair { + require(start < descriptor.length) { "Incomplete JVM descriptor $descriptor" } + var offset = start + while (descriptor[offset] == '[') { + offset++ + require(offset < descriptor.length) { "Incomplete JVM descriptor $descriptor" } + } + val kind = descriptor[offset] + val end = + when (kind) { + 'Z', + 'B', + 'S', + 'I', + 'J', + 'F', + 'D', + 'C' -> offset + 1 + 'V' -> { + require(allowVoid && offset == start) { "void is not valid here in $descriptor" } + offset + 1 + } + 'L' -> { + val separator = descriptor.indexOf(';', offset + 1) + require(separator > offset + 1) { "Invalid JVM object descriptor $descriptor" } + separator + 1 + } + else -> error("Invalid JVM descriptor $descriptor") + } + return kind to end +} + +private fun sourceName(descriptor: String): String = + when (descriptor[0]) { + 'Z' -> "boolean" + 'B' -> "byte" + 'S' -> "short" + 'I' -> "int" + 'J' -> "long" + 'F' -> "float" + 'D' -> "double" + 'C' -> "char" + '[' -> sourceName(descriptor.substring(1)) + "[]" + 'L' -> descriptor.substring(1, descriptor.length - 1).replace('/', '.').replace('$', '.') + else -> error("Invalid JVM type descriptor $descriptor") + } diff --git a/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/KspModelBuilder.kt b/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/KspModelBuilder.kt new file mode 100644 index 0000000000..d1522546cf --- /dev/null +++ b/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/KspModelBuilder.kt @@ -0,0 +1,1047 @@ +/* + * 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.fory.json.kotlin.ksp + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getConstructors +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.ClassKind +import com.google.devtools.ksp.symbol.FunctionKind +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSNode +import com.google.devtools.ksp.symbol.KSPropertyDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.KSTypeAlias +import com.google.devtools.ksp.symbol.Modifier +import com.google.devtools.ksp.symbol.Nullability +import com.google.devtools.ksp.symbol.Origin + +internal const val JSON_TYPE: String = "org.apache.fory.json.annotation.JsonType" +internal const val JSON_MIXIN: String = "org.apache.fory.json.annotation.JsonMixin" +private const val JSON_SUB_TYPES = "org.apache.fory.json.annotation.JsonSubTypes" +private const val JSON_CODEC = "org.apache.fory.json.annotation.JsonCodec" +private const val JSON_ANY_SETTER = "org.apache.fory.json.annotation.JsonAnySetter" +private const val JSON_VALIDATOR = "org.apache.fory.json.annotation.JsonValidator" +private const val JSON_CREATOR = "org.apache.fory.json.annotation.JsonCreator" +private const val JSON_MIXIN_REMOVE = "org.apache.fory.json.annotation.JsonMixinRemove" +private const val JSON_ANNOTATION_PACKAGE = "org.apache.fory.json.annotation." +private const val DEFAULT_MARKER = "Lkotlin/jvm/internal/DefaultConstructorMarker;" + +/** KSP-side producer for exact runtime-metadata retention of one source declaration. */ +@OptIn(KspExperimental::class) +internal class KspModelBuilder( + private val resolver: Resolver, + private val logger: KSPLogger, +) { + private data class CreatorMembers( + val members: List, + val parameters: Map, + ) + + fun direct(target: KSClassDeclaration): JsonModel? { + if (target.origin != Origin.KOTLIN || target.containingFile == null) return null + if (hasAnnotation(target, JSON_MIXIN)) return null + return kotlinModel(target, null) + } + + fun mixin(source: KSClassDeclaration): JsonModel? { + if (source.containingFile == null || source.origin !in SOURCE_ORIGINS) return null + val target = mixinTarget(source) ?: return null + if (source.origin == Origin.JAVA && !isKotlin(target.origin)) return null + return if (isKotlin(target.origin)) { + kotlinModel(target, source) + } else { + javaModel(target, source) + } + } + + private fun javaModel( + target: KSClassDeclaration, + mixin: KSClassDeclaration, + ): JsonModel? { + val targetName = binaryName(target) ?: return fail(target, "@JsonMixin target must be named") + val members = + javaMembers(target, mixin) + + mixinMembers(mixin) + + listOfNotNull(javaAnySetter(target, mixin), javaCreator(target, mixin)) + + validators(target, mixin) + return JsonModel( + targetBinaryName = targetName, + members = members, + mixinBinaryName = binaryName(mixin), + originatingFiles = originatingFiles(target, mixin), + retainedAnnotations = annotations(target) + annotations(mixin), + retainedTypes = (annotationTypes(target) + annotationTypes(mixin)) - targetName, + codecTypes = codecTypes(target) + codecTypes(mixin), + ) + } + + private fun mixinTarget(source: KSClassDeclaration): KSClassDeclaration? { + val annotation = + source.annotations.firstOrNull { annotationName(it) == JSON_MIXIN } + ?: return fail(source, "Missing @JsonMixin declaration") + val value = annotation.arguments.firstOrNull { it.name?.asString() == "target" }?.value + val type = value as? KSType ?: return fail(source, "@JsonMixin must declare a target type") + if (type.isError) return null + return actual(type.declaration) as? KSClassDeclaration + ?: fail(source, "@JsonMixin target must be a declared type") + } + + private fun originatingFiles( + target: KSClassDeclaration, + mixin: KSClassDeclaration?, + ) = listOfNotNull(mixin?.containingFile, target.containingFile).distinct() + + private fun isKotlin(origin: Origin): Boolean = + origin == Origin.KOTLIN || origin == Origin.KOTLIN_LIB + + private fun kotlinModel( + target: KSClassDeclaration, + mixin: KSClassDeclaration?, + ): JsonModel? { + val targetName = binaryName(target) ?: return fail(target, "@JsonType target must be named") + if (Modifier.INNER in target.modifiers) { + return fail(target, "Kotlin JSON does not support inner classes") + } + val singleton = target.classKind == ClassKind.OBJECT && !target.isCompanionObject + if (singleton) { + val state = + target.declarations.filterIsInstance().firstOrNull { + Modifier.CONST !in it.modifiers + } + if (state != null) return fail(state, "Kotlin JSON object $targetName is stateful") + } + val closed = + if (mixin == null) hasAnnotation(target, JSON_SUB_TYPES) + else effectiveTypeAnnotation(target, mixin, JSON_SUB_TYPES) + val concrete = + target.classKind == ClassKind.CLASS && + Modifier.ABSTRACT !in target.modifiers && + Modifier.SEALED !in target.modifiers + val valueClass = isValueClass(target) + val retainModel = concrete && !closed + val creatorMembers = + if (retainModel && !singleton && !valueClass) { + kotlinCreator(target, mixin) ?: return null + } else { + null + } + val targetMembers = + when { + singleton -> + listOf( + JvmMember( + MemberKind.FIELD, + targetName, + "INSTANCE", + "L${targetName.replace('.', '/')};", + ) + ) + valueClass -> valueClassMembers(target) ?: return null + creatorMembers != null -> + (members(target, creatorMembers) ?: return null) + creatorMembers.members + else -> emptyList() + } + val anySetter = effectiveMethods(target, mixin, JSON_ANY_SETTER) + if (creatorMembers != null && anySetter.isNotEmpty()) { + return fail( + anySetter.first(), + "@JsonAnySetter is not supported on a Kotlin constructor model" + ) + } + val members = + targetMembers + (mixin?.let(::mixinMembers) ?: emptyList()) + validators(target, mixin) + return JsonModel( + targetBinaryName = targetName, + members = members, + mixinBinaryName = mixin?.let(::binaryName), + originatingFiles = originatingFiles(target, mixin), + retainedAnnotations = annotations(target) + (mixin?.let(::annotations) ?: emptySet()), + retainedTypes = + (annotationTypes(target) + (mixin?.let(::annotationTypes) ?: emptySet())) - targetName, + codecTypes = codecTypes(target) + (mixin?.let(::codecTypes) ?: emptySet()), + ) + } + + private fun valueClassMembers(target: KSClassDeclaration): List? { + val members = ArrayList() + var declaration = target + while (true) { + val constructor = + declaration.primaryConstructor + ?: return fail(declaration, "Kotlin value class must have a primary constructor") + if (constructor.parameters.size != 1) { + return fail(constructor, "Kotlin value class must have one underlying parameter") + } + val underlying = constructor.parameters.single().type.resolve() + val carrier = valueClassCarrier(constructor, underlying) ?: return null + val owner = binaryName(declaration)!! + val ownerDescriptor = "L${owner.replace('.', '/')};" + val fieldName = + constructor.parameters.single().name?.asString() + ?: return fail(constructor, "Kotlin value-class parameter must be named") + members += JvmMember(MemberKind.FIELD, owner, fieldName, carrier.descriptor) + members += + JvmMember(MemberKind.METHOD, owner, "", methodDescriptor(listOf(carrier), "V")) + members += + JvmMember( + MemberKind.METHOD, + owner, + "constructor-impl", + methodDescriptor(listOf(carrier), carrier.descriptor), + ) + members += + JvmMember( + MemberKind.METHOD, + owner, + "box-impl", + methodDescriptor(listOf(carrier), ownerDescriptor), + ) + members += + JvmMember( + MemberKind.METHOD, + owner, + "unbox-impl", + methodDescriptor(emptyList(), carrier.descriptor) + ) + val underlyingDeclaration = actual(underlying.declaration) as? KSClassDeclaration + if (underlyingDeclaration == null || !isValueClass(underlyingDeclaration)) { + break + } + declaration = underlyingDeclaration + } + return members + } + + private fun kotlinCreator( + target: KSClassDeclaration, + mixin: KSClassDeclaration?, + ): CreatorMembers? { + val selected = ArrayList() + target + .getConstructors() + .filter { constructor -> + if (mixin == null) hasAnnotation(constructor, JSON_CREATOR) + else hasEffectiveAnnotation(constructor, mixin, JSON_CREATOR) + } + .forEach(selected::add) + kotlinFactories(target) + .filter { factory -> + if (mixin == null) hasAnnotation(factory, JSON_CREATOR) + else hasEffectiveAnnotation(factory, mixin, JSON_CREATOR) + } + .forEach(selected::add) + if (selected.size > 1) { + return fail(selected[1], "Exactly one effective @JsonCreator is allowed") + } + val explicit = selected.singleOrNull() + if (explicit != null && explicit.simpleName.asString() != "") { + return kotlinFactory(target, explicit) + } + val constructor = + explicit + ?: target.primaryConstructor + ?: return fail(target, "Kotlin JSON model has no selected constructor") + if (Modifier.PRIVATE in constructor.modifiers || Modifier.PROTECTED in constructor.modifiers) { + return fail(constructor, "Kotlin JSON constructor must be public or internal") + } + if (constructor.parameters.any { it.isVararg }) { + return fail(constructor, "Kotlin JSON constructor must not be vararg") + } + val rawDescriptor = + descriptor(constructor) + ?: return fail(constructor, "Cannot map Kotlin constructor to a JVM descriptor") + val parameterTypes = constructor.parameters.map { carrier(it.type.resolve()) } + val descriptor = normalizeValueCarriers(rawDescriptor, parameterTypes) + val parsed = parseMethodDescriptor(descriptor) + val count = constructor.parameters.size + val types = + when { + parsed.parameters.size == count -> parsed.parameters + parsed.parameters.size == count + 1 && + parsed.parameters.last().descriptor == DEFAULT_MARKER -> parsed.parameters.dropLast(1) + else -> return fail(constructor, "Kotlin constructor JVM shape is inconsistent") + } + var invocation = descriptor + if ( + parsed.parameters.size == count && + constructor.parameters.indices.any { index -> + isUnboxedValue(constructor.parameters[index].type.resolve(), types[index]) + } + ) { + invocation = appendParameters(descriptor, listOf(JvmType(DEFAULT_MARKER))) + } + val optional = BooleanArray(count) { constructor.parameters[it].hasDefault } + val defaultDescriptor = + if (optional.any { it }) { + methodDescriptor( + types + List((count + 31) ushr 5) { JvmType("I") } + JvmType(DEFAULT_MARKER), + "V", + ) + } else { + null + } + val names = + constructor.parameters.mapIndexed { index, parameter -> + parameter.name?.asString() ?: return fail(parameter, "Unnamed constructor parameter $index") + } + val owner = binaryName(target)!! + val executable = methodDescriptor(types, "V") + val members = linkedSetOf(JvmMember(MemberKind.METHOD, owner, "", executable)) + members += JvmMember(MemberKind.METHOD, owner, "", invocation) + defaultDescriptor?.let { members += JvmMember(MemberKind.METHOD, owner, "", it) } + return CreatorMembers( + members = members.toList(), + parameters = names.indices.associate { names[it] to types[it] }, + ) + } + + private fun kotlinFactory( + target: KSClassDeclaration, + factory: KSFunctionDeclaration, + ): CreatorMembers? { + val owner = + factory.parentDeclaration as? KSClassDeclaration + ?: return fail(factory, "Kotlin @JsonCreator factory must be a target member") + val companionFactory = owner.isCompanionObject + val modifiers = resolver.effectiveJavaModifiers(factory) + if ( + Modifier.PRIVATE in factory.modifiers || + Modifier.PROTECTED in factory.modifiers || + factory.parameters.any { it.isVararg } || + factory.typeParameters.isNotEmpty() || + factory.parameters.any { it.hasDefault } || + companionFactory && !hasAnnotation(factory, "kotlin.jvm.JvmStatic") || + !companionFactory && + Modifier.JAVA_STATIC !in modifiers && + factory.functionKind != FunctionKind.STATIC + ) { + return fail(factory, "Invalid Kotlin @JsonCreator static factory") + } + val targetName = binaryName(target)!! + val sourceTypes = factory.parameters.map { propertyCarrier(it.type.resolve()) } + val rawDescriptor = + descriptor(factory) + ?: return fail(factory, "Cannot map Kotlin @JsonCreator factory to a JVM descriptor") + val normalized = normalizeValueCarriers(rawDescriptor, sourceTypes) + val parsed = parseMethodDescriptor(normalized) + if ( + parsed.parameters.size != factory.parameters.size || + parsed.result != "L${targetName.replace('.', '/')};" + ) { + return fail(factory, "Kotlin @JsonCreator factory must return its exact owner") + } + val names = + factory.parameters.mapIndexed { index, parameter -> + parameter.name?.asString() ?: return fail(parameter, "Unnamed factory parameter $index") + } + val name = resolver.getJvmName(factory) ?: factory.simpleName.asString() + val members = ArrayList(if (companionFactory) 2 else 1) + members += JvmMember(MemberKind.METHOD, targetName, name, normalized) + if (companionFactory) { + members += JvmMember(MemberKind.METHOD, binaryName(owner)!!, name, normalized) + } + return CreatorMembers( + members = members, + parameters = names.indices.associate { names[it] to parsed.parameters[it] }, + ) + } + + private fun kotlinFactories(target: KSClassDeclaration): Sequence { + val direct = + target.declarations.filterIsInstance().filter { function -> + function.simpleName.asString() != "" && + (function.functionKind == FunctionKind.STATIC || + Modifier.JAVA_STATIC in resolver.effectiveJavaModifiers(function)) + } + val companions = + target.declarations + .filterIsInstance() + .filter { it.isCompanionObject } + .flatMap { companion -> + companion.declarations.filterIsInstance().filter { + hasAnnotation(it, "kotlin.jvm.JvmStatic") + } + } + return direct + companions + } + + private fun normalizeValueCarriers( + descriptor: String, + sourceTypes: List, + ): String { + require(descriptor.startsWith('(')) { "Invalid Kotlin constructor descriptor $descriptor" } + val result = StringBuilder(descriptor.length + 32).append('(') + var offset = 1 + var parameter = 0 + while (descriptor[offset] != ')') { + val start = offset + while (descriptor[offset] == '[') offset++ + offset = + if (descriptor[offset] == 'L') descriptor.indexOf(';', offset + 1) + 1 else offset + 1 + require(offset > start) { "Invalid Kotlin constructor descriptor $descriptor" } + if (descriptor[start] == 'V') { + require(parameter < sourceTypes.size) { "Unexpected void carrier in $descriptor" } + result.append(sourceTypes[parameter].descriptor) + } else { + result.append(descriptor, start, offset) + } + parameter++ + } + return result.append(descriptor.substring(offset)).toString() + } + + private fun javaMembers( + target: KSClassDeclaration, + mixin: KSClassDeclaration, + ): List { + val result = linkedMapOf() + for (property in target.getAllProperties()) { + if (property.origin !in JAVA_ORIGINS || !property.hasBackingField) continue + val owner = property.parentDeclaration as? KSClassDeclaration ?: continue + val modifiers = resolver.effectiveJavaModifiers(property) + if (Modifier.JAVA_STATIC in modifiers || Modifier.JAVA_TRANSIENT in modifiers) { + continue + } + val type = propertyDescriptor(property) + if (type.descriptor == "Ljava/lang/Class;") continue + val member = + JvmMember( + MemberKind.FIELD, + binaryName(owner)!!, + property.simpleName.asString(), + type.descriptor, + ) + result["F#${member.ownerBinaryName}#${member.name}#${member.descriptor}"] = member + } + for (function in effectiveFunctions(target)) { + val owner = function.parentDeclaration as? KSClassDeclaration ?: continue + val modifiers = resolver.effectiveJavaModifiers(function) + if ( + Modifier.PUBLIC !in modifiers || + Modifier.JAVA_STATIC in modifiers || + function.functionKind == FunctionKind.STATIC || + function.isAbstract || + function.parameters.any { it.isVararg } || + function.typeParameters.isNotEmpty() || + hasEffectiveAnnotation(function, mixin, JSON_ANY_SETTER) + ) { + continue + } + val name = resolver.getJvmName(function) ?: function.simpleName.asString() + val descriptor = descriptor(function) ?: continue + val method = parseMethodDescriptor(descriptor) + val annotated = hasEffectiveJsonAnnotation(function, mixin) + val selected = + when { + method.parameters.isEmpty() && + method.result != "V" && + method.result != "Ljava/lang/Class;" && + (annotated || isGetterName(name, method.result)) -> true + method.parameters.size == 1 && + method.result == "V" && + (annotated || name.startsWith("set") && name.length > 3) -> true + else -> false + } + if (!selected) continue + val member = + JvmMember( + MemberKind.METHOD, + binaryName(owner)!!, + name, + descriptor, + ) + val parameterKey = method.parameters.joinToString("") { it.descriptor } + result["M#$name#$parameterKey"] = member + } + return result.values.sortedWith( + compareBy(JvmMember::name, JvmMember::descriptor, JvmMember::kind) + ) + } + + private fun isGetterName(name: String, result: String): Boolean = + name.startsWith("get") && name.length > 3 || + name.startsWith("is") && name.length > 2 && (result == "Z" || result == "Ljava/lang/Boolean;") + + private fun javaAnySetter( + target: KSClassDeclaration, + mixin: KSClassDeclaration, + ): JvmMember? { + val methods = effectiveMethods(target, mixin, JSON_ANY_SETTER) + if (methods.size > 1) { + return fail(methods[1], "At most one effective @JsonAnySetter method is allowed") + } + val method = methods.singleOrNull() ?: return null + val descriptor = + descriptor(method) ?: return fail(method, "Cannot map @JsonAnySetter to a JVM descriptor") + val shape = parseMethodDescriptor(descriptor) + val modifiers = resolver.effectiveJavaModifiers(method) + if ( + Modifier.PUBLIC !in modifiers || + Modifier.JAVA_STATIC in modifiers || + method.functionKind == FunctionKind.STATIC || + method.isAbstract || + method.parameters.any { it.isVararg } || + method.typeParameters.isNotEmpty() || + shape.parameters.size != 2 || + shape.parameters[0].descriptor != "Ljava/lang/String;" || + shape.result != "V" + ) { + return fail(method, "Invalid effective @JsonAnySetter method") + } + val owner = method.parentDeclaration as KSClassDeclaration + return JvmMember( + MemberKind.METHOD, + binaryName(owner)!!, + resolver.getJvmName(method) ?: method.simpleName.asString(), + descriptor, + ) + } + + private fun validators( + target: KSClassDeclaration, + mixin: KSClassDeclaration?, + ): List { + val result = ArrayList() + for (method in effectiveMethods(target, mixin, JSON_VALIDATOR)) { + val descriptor = descriptor(method) + val modifiers = resolver.effectiveJavaModifiers(method) + if ( + descriptor != "()V" || + Modifier.PUBLIC !in modifiers || + Modifier.JAVA_STATIC in modifiers || + method.functionKind == FunctionKind.STATIC || + method.isAbstract || + method.parameters.any { it.isVararg } || + method.typeParameters.isNotEmpty() + ) { + logger.error("Invalid effective @JsonValidator method", method) + continue + } + val owner = method.parentDeclaration as? KSClassDeclaration ?: continue + result += + JvmMember( + MemberKind.METHOD, + binaryName(owner)!!, + resolver.getJvmName(method) ?: method.simpleName.asString(), + descriptor, + ) + } + return result.sortedWith(compareBy(JvmMember::ownerBinaryName, JvmMember::name)) + } + + private fun javaCreator( + target: KSClassDeclaration, + mixin: KSClassDeclaration, + ): JvmMember? { + val candidates = ArrayList() + target + .getConstructors() + .filter { hasEffectiveAnnotation(it, mixin, JSON_CREATOR) } + .forEach(candidates::add) + target.declarations + .filterIsInstance() + .filter { + it.simpleName.asString() != "" && hasEffectiveAnnotation(it, mixin, JSON_CREATOR) + } + .forEach(candidates::add) + if (candidates.size > 1) { + return fail(candidates[1], "Exactly one effective @JsonCreator is allowed") + } + val creator = candidates.singleOrNull() ?: return null + val descriptor = + descriptor(creator) ?: return fail(creator, "Cannot map @JsonCreator to a JVM descriptor") + val method = parseMethodDescriptor(descriptor) + val factory = creator.simpleName.asString() != "" + val modifiers = resolver.effectiveJavaModifiers(creator) + if ( + Modifier.PUBLIC !in modifiers || + creator.parameters.any { it.isVararg } || + creator.typeParameters.isNotEmpty() || + creator.parameters.isEmpty() || + factory && + (Modifier.JAVA_STATIC !in modifiers && creator.functionKind != FunctionKind.STATIC || + method.result != "L${binaryName(target)!!.replace('.', '/')};") + ) { + return fail(creator, "Invalid effective @JsonCreator executable") + } + return JvmMember( + MemberKind.METHOD, + binaryName(target)!!, + if (factory) resolver.getJvmName(creator) ?: creator.simpleName.asString() else "", + descriptor, + ) + } + + private fun effectiveFunctions(target: KSClassDeclaration): List { + val result = linkedMapOf() + for (method in target.getAllFunctions()) { + if (method.simpleName.asString() == "" || method.origin == Origin.SYNTHETIC) continue + val descriptor = descriptor(method) ?: continue + val parsed = parseMethodDescriptor(descriptor) + val name = resolver.getJvmName(method) ?: method.simpleName.asString() + val key = name + parsed.parameters.joinToString("") { it.descriptor } + result.putIfAbsent(key, method) + } + return result.values.toList() + } + + private fun effectiveMethods( + target: KSClassDeclaration, + mixin: KSClassDeclaration?, + annotation: String, + ): List = + effectiveFunctions(target).filter { method -> + if (mixin == null) hasAnnotation(method, annotation) + else hasEffectiveAnnotation(method, mixin, annotation) + } + + private fun hasEffectiveAnnotation( + target: KSFunctionDeclaration, + mixin: KSClassDeclaration, + annotation: String, + ): Boolean { + val source = matchingMixinFunction(target, mixin) + if (source != null) { + if (hasAnnotation(source, annotation)) return true + if (removesAnnotation(source, annotation)) return false + } + return hasAnnotation(target, annotation) + } + + private fun hasEffectiveJsonAnnotation( + target: KSFunctionDeclaration, + mixin: KSClassDeclaration, + ): Boolean { + val source = matchingMixinFunction(target, mixin) + return hasJsonAnnotation(target) || source != null && hasJsonAnnotation(source) + } + + private fun matchingMixinFunction( + target: KSFunctionDeclaration, + mixin: KSClassDeclaration, + ): KSFunctionDeclaration? { + val key = callableKey(target) ?: return null + return mixinCallables(mixin).firstOrNull { callableKey(it) == key } + } + + private fun mixinCallables(mixin: KSClassDeclaration): Sequence = + mixin.declarations.filterIsInstance() + + mixin.getConstructors() + + mixin.declarations + .filterIsInstance() + .filter { it.isCompanionObject } + .flatMap { companion -> + companion.declarations.filterIsInstance().filter { + hasAnnotation(it, "kotlin.jvm.JvmStatic") + } + } + + private fun callableKey(function: KSFunctionDeclaration): String? { + val descriptor = descriptor(function) ?: return null + return (resolver.getJvmName(function) ?: function.simpleName.asString()) + descriptor + } + + private fun effectiveTypeAnnotation( + target: KSClassDeclaration, + mixin: KSClassDeclaration, + annotation: String, + ): Boolean = + when { + hasAnnotation(mixin, annotation) -> true + removesAnnotation(mixin, annotation) -> false + else -> hasAnnotation(target, annotation) + } + + private fun removesAnnotation(source: KSAnnotated, annotation: String): Boolean { + val removal = + source.annotations.firstOrNull { annotationName(it) == JSON_MIXIN_REMOVE } ?: return false + return removal.arguments.any { argument -> containsType(argument.value, annotation) } + } + + private fun containsType(value: Any?, typeName: String): Boolean = + when (value) { + is KSType -> + (actual(value.declaration) as? KSClassDeclaration)?.qualifiedName?.asString() == typeName + is Iterable<*> -> value.any { containsType(it, typeName) } + else -> false + } + + private fun hasJsonAnnotation(source: KSAnnotated): Boolean = + source.annotations.any { annotationName(it).startsWith(JSON_ANNOTATION_PACKAGE) } + + private fun mixinMembers(mixin: KSClassDeclaration): List { + val result = linkedMapOf() + val owner = binaryName(mixin) ?: return emptyList() + fun add(member: JvmMember) { + result["${member.kind}#${member.name}#${member.descriptor}"] = member + } + for (property in mixin.declarations.filterIsInstance()) { + val selected = + hasJsonAnnotation(property) || + property.getter?.let(::hasJsonAnnotation) == true || + property.setter?.let(::hasJsonAnnotation) == true + if (!selected) continue + val type = propertyDescriptor(property) + if (property.hasBackingField) { + add(JvmMember(MemberKind.FIELD, owner, property.simpleName.asString(), type.descriptor)) + } + property.getter?.let { getter -> + val name = resolver.getJvmName(getter) ?: return@let + add( + JvmMember(MemberKind.METHOD, owner, name, methodDescriptor(emptyList(), type.descriptor)) + ) + } + property.setter?.let { setter -> + val name = resolver.getJvmName(setter) ?: return@let + add(JvmMember(MemberKind.METHOD, owner, name, methodDescriptor(listOf(type), "V"))) + } + } + for (function in mixinCallables(mixin)) { + val selected = hasJsonAnnotation(function) || function.parameters.any(::hasJsonAnnotation) + if (!selected) continue + val descriptor = descriptor(function) ?: continue + val name = resolver.getJvmName(function) ?: function.simpleName.asString() + add(JvmMember(MemberKind.METHOD, owner, name, descriptor)) + } + return result.values.sortedWith( + compareBy(JvmMember::name, JvmMember::descriptor, JvmMember::kind) + ) + } + + private fun members(target: KSClassDeclaration, creator: CreatorMembers): List? { + val result = ArrayList() + val creatorTypes = creator.parameters + for (property in target.getAllProperties()) { + if (Modifier.CONST in property.modifiers || property.extensionReceiver != null) continue + val owner = property.parentDeclaration as? KSClassDeclaration ?: continue + val ownerName = binaryName(owner) ?: continue + val carrier = creatorTypes[property.simpleName.asString()] ?: propertyDescriptor(property) + if (!property.isDelegated() && property.hasBackingField) { + result += + JvmMember( + MemberKind.FIELD, + ownerName, + property.simpleName.asString(), + carrier.descriptor, + ) + } + property.getter?.let { getter -> + val name = + resolver.getJvmName(getter) + ?: return fail(getter, "Cannot determine the JVM getter for ${property.simpleName}") + result += + JvmMember( + MemberKind.METHOD, + ownerName, + name, + methodDescriptor(emptyList(), carrier.descriptor), + ) + } + property.setter?.let { setter -> + if (property.isMutable && !property.isDelegated()) { + val name = + resolver.getJvmName(setter) + ?: return fail(setter, "Cannot determine the JVM setter for ${property.simpleName}") + result += + JvmMember( + MemberKind.METHOD, + ownerName, + name, + methodDescriptor(listOf(carrier), "V"), + ) + } + } + } + return result.sortedWith(compareBy(JvmMember::name, JvmMember::descriptor, JvmMember::kind)) + } + + private fun propertyDescriptor(property: KSPropertyDeclaration): JvmType { + val descriptor = jvmSignature(property) + return if (descriptor == null || descriptor == "V" || descriptor == "") { + propertyCarrier(property.type.resolve()) + } else { + JvmType(descriptor) + } + } + + private fun carrier(type: KSType): JvmType { + val declaration = actual(type.declaration) + val name = declaration.qualifiedName?.asString() + val nullable = type.nullability == Nullability.NULLABLE + PRIMITIVES[name]?.let { primitive -> + if (nullable && name in UNSIGNED) return JvmType("L${name!!.replace('.', '/')};") + return if (nullable) JvmType(BOXES.getValue(primitive)) else JvmType(primitive) + } + ARRAYS[name]?.let { + return JvmType(it) + } + if (declaration is com.google.devtools.ksp.symbol.KSTypeParameter) { + return JvmType("Ljava/lang/Object;") + } + val binary = + JAVA_TYPES[name] + ?: (declaration as? KSClassDeclaration)?.let(::binaryName) + ?: "java.lang.Object" + return JvmType("L${binary.replace('.', '/')};") + } + + private fun propertyCarrier(type: KSType): JvmType { + if (type.nullability == Nullability.NULLABLE) return carrier(type) + val declaration = actual(type.declaration) as? KSClassDeclaration ?: return carrier(type) + if (!isValueClass(declaration)) return carrier(type) + val constructor = declaration.primaryConstructor ?: return carrier(type) + if (constructor.parameters.size != 1) return carrier(type) + val underlying = constructor.parameters.single().type.resolve() + return valueClassCarrier(constructor, underlying) ?: carrier(type) + } + + private fun valueClassCarrier( + constructor: KSFunctionDeclaration, + underlying: KSType, + ): JvmType? { + val rawDescriptor = + descriptor(constructor) + ?: return fail(constructor, "Cannot map Kotlin value-class constructor to a JVM descriptor") + val normalized = normalizeValueCarriers(rawDescriptor, listOf(carrier(underlying))) + val method = parseMethodDescriptor(normalized) + if ( + method.parameters.size != 1 || + (method.result != "V" && method.result != method.parameters.single().descriptor) + ) { + return fail(constructor, "Kotlin value-class constructor-impl has an invalid JVM shape") + } + return method.parameters.single() + } + + private fun isUnboxedValue(type: KSType, carrier: JvmType): Boolean { + if (type.nullability == Nullability.NULLABLE) return false + val declaration = actual(type.declaration) as? KSClassDeclaration ?: return false + return isValueClass(declaration) && + carrier.descriptor != "L${binaryName(declaration)!!.replace('.', '/')};" + } + + private fun isValueClass(declaration: KSClassDeclaration): Boolean = + // KSP classpath symbols can omit VALUE, but @JvmInline is binary-retained compiler identity. + Modifier.VALUE in declaration.modifiers || hasAnnotation(declaration, JVM_INLINE) + + private fun annotations(target: KSClassDeclaration): Set { + val result = linkedSetOf() + if (isKotlin(target.origin)) result += "kotlin.Metadata" + annotationSources(target).forEach { collectAnnotations(it, result) } + return result + } + + private fun collectAnnotations(source: KSAnnotated, result: MutableSet) { + source.annotations + .map(::annotationName) + .filter { it.startsWith(JSON_ANNOTATION_PACKAGE) } + .forEach(result::add) + } + + private fun annotationTypes(target: KSClassDeclaration): Set { + val result = linkedSetOf() + annotationSources(target).forEach { source -> + source.annotations + .filter { + val annotation = annotationName(it) + annotation.startsWith(JSON_ANNOTATION_PACKAGE) && annotation != JSON_CODEC + } + .flatMap { it.arguments.asSequence() } + .forEach { argument -> collectTypeValue(argument.value, result) } + } + return result + } + + private fun codecTypes(target: KSClassDeclaration): Set { + val result = linkedSetOf() + annotationSources(target).forEach { source -> + source.annotations + .filter { annotationName(it) == JSON_CODEC } + .forEach { annotation -> + annotation.arguments.forEachIndexed { index, argument -> + val slot = argument.name?.asString() + if (slot in CODEC_SLOTS || slot == null && index == 0) { + collectCodecType(argument.value, result) + } + } + } + } + return result + } + + private fun collectCodecType(value: Any?, result: MutableSet) { + when (value) { + is KSType -> { + val binary = (actual(value.declaration) as? KSClassDeclaration)?.let(::binaryName) + if (binary != null && binary !in CODEC_SENTINELS) result += binary + } + is Iterable<*> -> value.forEach { collectCodecType(it, result) } + } + } + + private fun annotationSources(target: KSClassDeclaration): Sequence = sequence { + yield(target) + target.getConstructors().forEach { constructor -> + yield(constructor) + constructor.parameters.forEach { yield(it) } + } + target.getAllProperties().forEach { property -> + yield(property) + property.getter?.let { yield(it) } + property.setter?.let { setter -> + yield(setter) + yield(setter.parameter) + } + } + effectiveFunctions(target).forEach { function -> + yield(function) + function.parameters.forEach { yield(it) } + } + target.declarations + .filterIsInstance() + .filter { it.isCompanionObject } + .forEach { companion -> + yield(companion) + companion.declarations.filterIsInstance().forEach { function -> + yield(function) + function.parameters.forEach { yield(it) } + } + } + } + + private fun collectTypeValue(value: Any?, result: MutableSet) { + when (value) { + is KSType -> + (actual(value.declaration) as? KSClassDeclaration)?.let(::binaryName)?.let(result::add) + is KSAnnotation -> value.arguments.forEach { collectTypeValue(it.value, result) } + is Iterable<*> -> value.forEach { collectTypeValue(it, result) } + } + } + + private fun descriptor(declaration: KSDeclaration): String? { + val signature = jvmSignature(declaration) ?: return null + val start = signature.indexOf('(') + return if (start < 0) null else signature.substring(start) + } + + private fun jvmSignature(declaration: KSDeclaration): String? = + try { + resolver.mapToJvmSignature(declaration) + } catch (_: RuntimeException) { + null + } + + private fun hasAnnotation(source: KSAnnotated, name: String): Boolean = + source.annotations.any { annotationName(it) == name } + + private fun annotationName(annotation: KSAnnotation): String = + annotation.annotationType.resolve().declaration.qualifiedName?.asString().orEmpty() + + private fun actual(declaration: KSDeclaration): KSDeclaration = + if (declaration is KSTypeAlias) actual(declaration.type.resolve().declaration) else declaration + + private fun binaryName(declaration: KSClassDeclaration): String? { + val parent = declaration.parentDeclaration as? KSClassDeclaration + return if (parent == null) declaration.qualifiedName?.asString() + else binaryName(parent)?.let { "$it\$${declaration.simpleName.asString()}" } + } + + private fun fail(node: KSNode, message: String): T? { + logger.error(message, node) + return null + } + + private companion object { + val SOURCE_ORIGINS = setOf(Origin.KOTLIN, Origin.JAVA) + val JAVA_ORIGINS = setOf(Origin.JAVA, Origin.JAVA_LIB) + const val JVM_INLINE = "kotlin.jvm.JvmInline" + val CODEC_SLOTS = setOf("value", "elementCodec", "contentCodec", "keyCodec", "valueCodec") + val CODEC_SENTINELS = + setOf( + "org.apache.fory.json.annotation.JsonCodec\$NoJsonValueCodec", + "org.apache.fory.json.annotation.JsonCodec\$NoMapKeyCodec", + ) + val PRIMITIVES = + mapOf( + "kotlin.Boolean" to "Z", + "kotlin.Byte" to "B", + "kotlin.Short" to "S", + "kotlin.Int" to "I", + "kotlin.Long" to "J", + "kotlin.Float" to "F", + "kotlin.Double" to "D", + "kotlin.Char" to "C", + "kotlin.UByte" to "B", + "kotlin.UShort" to "S", + "kotlin.UInt" to "I", + "kotlin.ULong" to "J", + ) + val UNSIGNED = setOf("kotlin.UByte", "kotlin.UShort", "kotlin.UInt", "kotlin.ULong") + val BOXES = + mapOf( + "Z" to "Ljava/lang/Boolean;", + "B" to "Ljava/lang/Byte;", + "S" to "Ljava/lang/Short;", + "I" to "Ljava/lang/Integer;", + "J" to "Ljava/lang/Long;", + "F" to "Ljava/lang/Float;", + "D" to "Ljava/lang/Double;", + "C" to "Ljava/lang/Character;", + ) + val ARRAYS = + mapOf( + "kotlin.BooleanArray" to "[Z", + "kotlin.ByteArray" to "[B", + "kotlin.ShortArray" to "[S", + "kotlin.IntArray" to "[I", + "kotlin.LongArray" to "[J", + "kotlin.FloatArray" to "[F", + "kotlin.DoubleArray" to "[D", + "kotlin.CharArray" to "[C", + "kotlin.UByteArray" to "[B", + "kotlin.UShortArray" to "[S", + "kotlin.UIntArray" to "[I", + "kotlin.ULongArray" to "[J", + ) + val JAVA_TYPES = + mapOf( + "kotlin.Any" to "java.lang.Object", + "kotlin.String" to "java.lang.String", + "kotlin.CharSequence" to "java.lang.CharSequence", + "kotlin.Throwable" to "java.lang.Throwable", + "kotlin.Nothing" to "java.lang.Void", + "kotlin.collections.Iterable" to "java.lang.Iterable", + "kotlin.collections.Collection" to "java.util.Collection", + "kotlin.collections.MutableCollection" to "java.util.Collection", + "kotlin.collections.List" to "java.util.List", + "kotlin.collections.MutableList" to "java.util.List", + "kotlin.collections.Set" to "java.util.Set", + "kotlin.collections.MutableSet" to "java.util.Set", + "kotlin.collections.Map" to "java.util.Map", + "kotlin.collections.MutableMap" to "java.util.Map", + ) + } +} diff --git a/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/Model.kt b/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/Model.kt new file mode 100644 index 0000000000..e157bb0fed --- /dev/null +++ b/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/Model.kt @@ -0,0 +1,44 @@ +/* + * 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.fory.json.kotlin.ksp + +import com.google.devtools.ksp.symbol.KSFile + +internal enum class MemberKind { + FIELD, + METHOD, +} + +internal data class JvmMember( + val kind: MemberKind, + val ownerBinaryName: String, + val name: String, + val descriptor: String, +) + +internal data class JsonModel( + val targetBinaryName: String, + val members: List, + val mixinBinaryName: String?, + val originatingFiles: List, + val retainedAnnotations: Set, + val retainedTypes: Set, + val codecTypes: Set, +) diff --git a/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/R8RulesWriter.kt b/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/R8RulesWriter.kt new file mode 100644 index 0000000000..865d4d9de5 --- /dev/null +++ b/kotlin/fory-json-kotlin-ksp/src/main/kotlin/org/apache/fory/json/kotlin/ksp/R8RulesWriter.kt @@ -0,0 +1,104 @@ +/* + * 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.fory.json.kotlin.ksp + +/** Emits one exact consumer-rule resource for one source-owned JSON request. */ +internal object R8RulesWriter { + fun resourcePath(model: JsonModel): String = + model.mixinBinaryName?.let { "META-INF/proguard/fory-json-mixin-$it.pro" } + ?: "META-INF/proguard/fory-json-${model.targetBinaryName}.pro" + + fun write(model: JsonModel): String = + buildString(4096) { + append("-keepattributes Signature,RuntimeVisibleAnnotations\n") + append("-keepattributes RuntimeVisibleParameterAnnotations\n") + append("-keepattributes AnnotationDefault,MethodParameters\n") + val nestedType = + model.targetBinaryName.indexOf('$') >= 0 || + model.mixinBinaryName?.indexOf('$')?.let { it >= 0 } == true || + model.members.any { it.ownerBinaryName.indexOf('$') >= 0 } || + model.codecTypes.any { it.indexOf('$') >= 0 } || + model.retainedTypes.any { it.indexOf('$') >= 0 } + if (nestedType) { + append("-keepattributes InnerClasses,EnclosingMethod\n") + } + append('\n') + val memberRules = memberRules(model) + memberRules.forEach { (owner, members) -> + val preserveName = + owner == model.targetBinaryName || + owner == model.mixinBinaryName || + owner in model.retainedTypes + append("-keep,allowoptimization") + if (!preserveName) append(",allowobfuscation") + append(" class $owner\n") + if (members.isNotEmpty()) { + // Runtime metadata and reflection locate these members by exact JVM descriptor. Allowing + // optimization would let R8 rewrite method prototypes and break descriptor matching. + append("-keepclassmembers class $owner {\n") + members.forEach { append(" $it\n") } + append("}\n") + } + append('\n') + } + model.retainedAnnotations.sorted().forEach { annotation -> + append("-keep,allowoptimization,allowobfuscation @interface $annotation\n") + } + model.retainedTypes.sorted().filterNot(memberRules::containsKey).forEach { type -> + append("-keep,allowoptimization class $type\n") + } + } + + private fun memberRules(model: JsonModel): Map> { + val result = linkedMapOf>() + fun add(owner: String, declaration: String) { + result.getOrPut(owner, ::linkedSetOf).add(declaration) + } + result[model.targetBinaryName] = linkedSetOf() + model.mixinBinaryName?.let { result[it] = linkedSetOf() } + model.members.forEach { member -> + when (member.kind) { + MemberKind.FIELD -> + add(member.ownerBinaryName, "${JvmType(member.descriptor).sourceName} ${member.name};") + MemberKind.METHOD -> + add( + member.ownerBinaryName, + if (member.name == "") constructorRule(member.descriptor) + else methodRule(member.name, member.descriptor), + ) + } + } + // JsonSharedRegistry instantiates annotation-selected codecs through Class.getConstructor(). + // Retaining only the class literal does not preserve that reflective constructor under R8. + model.codecTypes.sorted().forEach { codecType -> add(codecType, "public ();") } + return result.mapValues { (_, members) -> members.sorted() } + } + + private fun methodRule(name: String, descriptor: String): String { + val method = parseMethodDescriptor(descriptor) + val result = if (method.result == "V") "void" else JvmType(method.result).sourceName + return "$result $name(${method.parameters.joinToString(",") { it.sourceName }});" + } + + private fun constructorRule(descriptor: String): String { + val parameters = parseMethodDescriptor(descriptor).parameters + return "(${parameters.joinToString(",") { it.sourceName }});" + } +} diff --git a/kotlin/fory-json-kotlin-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/kotlin/fory-json-kotlin-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 0000000000..c079098499 --- /dev/null +++ b/kotlin/fory-json-kotlin-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +org.apache.fory.json.kotlin.ksp.ForyJsonKotlinSymbolProcessorProvider diff --git a/kotlin/fory-json-kotlin-ksp/src/test/kotlin/org/apache/fory/json/kotlin/ksp/KspTestSupport.kt b/kotlin/fory-json-kotlin-ksp/src/test/kotlin/org/apache/fory/json/kotlin/ksp/KspTestSupport.kt new file mode 100644 index 0000000000..4d264a9f4e --- /dev/null +++ b/kotlin/fory-json-kotlin-ksp/src/test/kotlin/org/apache/fory/json/kotlin/ksp/KspTestSupport.kt @@ -0,0 +1,159 @@ +/* + * 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.fory.json.kotlin.ksp + +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFile +import com.google.devtools.ksp.symbol.KSNode +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.OutputStream +import java.lang.reflect.Proxy + +internal class RecordingCodeGenerator : CodeGenerator { + val outputs = linkedMapOf() + val dependenciesByPath = linkedMapOf() + val outputKinds = linkedMapOf() + + override fun createNewFile( + dependencies: Dependencies, + packageName: String, + fileName: String, + extensionName: String, + ): OutputStream { + val directory = packageName.replace('.', '/') + val path = + if (directory.isEmpty()) "$fileName.$extensionName" else "$directory/$fileName.$extensionName" + return output(path, dependencies, outputKind(extensionName)) + } + + override fun createNewFileByPath( + dependencies: Dependencies, + path: String, + extensionName: String, + ): OutputStream = + output( + path + if (extensionName.isEmpty()) "" else ".$extensionName", + dependencies, + outputKind(extensionName), + ) + + override fun associate( + sources: List, + packageName: String, + fileName: String, + extensionName: String, + ) {} + + override fun associateByPath( + sources: List, + path: String, + extensionName: String, + ) {} + + override fun associateWithClasses( + classes: List, + packageName: String, + fileName: String, + extensionName: String, + ) {} + + override val generatedFile: Collection + get() = emptyList() + + fun text(path: String): String = outputs.getValue(path).toByteArray().decodeToString() + + private fun output( + path: String, + dependencies: Dependencies, + kind: OutputKind, + ): ByteArrayOutputStream { + check(path !in outputs) { "Duplicate generated output $path" } + dependenciesByPath[path] = dependencies + outputKinds[path] = kind + return ByteArrayOutputStream().also { outputs[path] = it } + } + + private fun outputKind(extensionName: String): OutputKind = + when (extensionName) { + "java" -> OutputKind.JAVA + "kt" -> OutputKind.KOTLIN + "class" -> OutputKind.CLASS + else -> OutputKind.RESOURCE + } +} + +internal enum class OutputKind { + JAVA, + KOTLIN, + CLASS, + RESOURCE, +} + +internal fun jsonModel( + targetBinaryName: String = "example.Profile", + members: List = emptyList(), + mixinBinaryName: String? = null, + originatingFiles: List = emptyList(), + retainedAnnotations: Set = emptySet(), + retainedTypes: Set = emptySet(), + codecTypes: Set = emptySet(), +): JsonModel = + JsonModel( + targetBinaryName = targetBinaryName, + members = members, + mixinBinaryName = mixinBinaryName, + originatingFiles = originatingFiles, + retainedAnnotations = retainedAnnotations, + retainedTypes = retainedTypes, + codecTypes = codecTypes, + ) + +internal object SilentLogger : KSPLogger { + override fun logging(message: String, symbol: KSNode?) {} + + override fun info(message: String, symbol: KSNode?) {} + + override fun warn(message: String, symbol: KSNode?) {} + + override fun error(message: String, symbol: KSNode?) {} + + override fun exception(e: Throwable) { + throw e + } +} + +internal fun sourceFile(name: String): KSFile = + Proxy.newProxyInstance(KSFile::class.java.classLoader, arrayOf(KSFile::class.java)) { + proxy, + method, + args -> + when (method.name) { + "equals" -> proxy === args?.singleOrNull() + "hashCode" -> System.identityHashCode(proxy) + "toString" -> "KSFile($name)" + "getFileName", + "getFilePath" -> name + else -> null + } + } as KSFile diff --git a/kotlin/fory-json-kotlin-ksp/src/test/kotlin/org/apache/fory/json/kotlin/ksp/ProcessorResourceTest.kt b/kotlin/fory-json-kotlin-ksp/src/test/kotlin/org/apache/fory/json/kotlin/ksp/ProcessorResourceTest.kt new file mode 100644 index 0000000000..350720743f --- /dev/null +++ b/kotlin/fory-json-kotlin-ksp/src/test/kotlin/org/apache/fory/json/kotlin/ksp/ProcessorResourceTest.kt @@ -0,0 +1,156 @@ +/* + * 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.fory.json.kotlin.ksp + +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import java.util.ServiceLoader +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class ProcessorResourceTest { + @Test + fun discoversProvider() { + val providers = + ServiceLoader.load(SymbolProcessorProvider::class.java) + .filterIsInstance() + + assertEquals(1, providers.size) + } + + @Test + fun writesOwnedResources() { + val directSource = sourceFile("Profile.kt") + val sealedSource = sourceFile("Shape.kt") + val mixinSource = sourceFile("ProfileMixin.kt") + val mixinTargetSource = sourceFile("ExternalProfile.java") + val directCodecTypes = codecTypes("example.direct") + val mixinCodecTypes = codecTypes("example.mixin") + val models = + listOf( + jsonModel( + targetBinaryName = "example.Profile", + originatingFiles = listOf(directSource), + retainedAnnotations = setOf("org.apache.fory.json.annotation.JsonType"), + codecTypes = directCodecTypes, + ), + jsonModel( + targetBinaryName = "example.Shape", + originatingFiles = listOf(sealedSource), + retainedAnnotations = setOf("org.apache.fory.json.annotation.JsonSubTypes"), + retainedTypes = setOf("example.Circle"), + ), + jsonModel( + targetBinaryName = "example.ExternalProfile", + mixinBinaryName = "mixins.ProfileMixin", + originatingFiles = listOf(mixinSource, mixinTargetSource), + codecTypes = mixinCodecTypes, + members = + listOf( + JvmMember( + MemberKind.FIELD, + "mixins.ProfileMixin", + "renamed", + "Ljava/lang/String;", + ) + ), + ), + ) + val generator = RecordingCodeGenerator() + val processor = + ForyJsonKotlinSymbolProcessorProvider() + .create( + SymbolProcessorEnvironment( + emptyMap(), + KotlinVersion.CURRENT, + generator, + SilentLogger, + ) + ) + val write = processor.javaClass.getDeclaredMethod("write", JsonModel::class.java) + write.isAccessible = true + + models.forEach { write.invoke(processor, it) } + + assertEquals( + setOf( + "META-INF/proguard/fory-json-example.Profile.pro", + "META-INF/proguard/fory-json-example.Shape.pro", + "META-INF/proguard/fory-json-mixin-mixins.ProfileMixin.pro", + ), + generator.outputs.keys, + ) + assertEquals(setOf(OutputKind.RESOURCE), generator.outputKinds.values.toSet()) + assertFalse( + generator.outputKinds.values.any { + it == OutputKind.JAVA || it == OutputKind.KOTLIN || it == OutputKind.CLASS + } + ) + models.forEach { model -> + val path = R8RulesWriter.resourcePath(model) + assertEquals(R8RulesWriter.write(model), generator.text(path)) + val dependencies = generator.dependenciesByPath.getValue(path) + assertFalse(dependencies.aggregating, path) + assertFalse(dependencies.isAllSources, path) + assertEquals(model.originatingFiles.size, dependencies.originatingFiles.size, path) + model.originatingFiles.forEachIndexed { index, source -> + assertSame(source, dependencies.originatingFiles[index], path) + } + } + assertTrue( + generator.text("META-INF/proguard/fory-json-example.Shape.pro").contains("example.Circle") + ) + assertCodecRules( + generator.text("META-INF/proguard/fory-json-example.Profile.pro"), + directCodecTypes, + ) + assertCodecRules( + generator.text("META-INF/proguard/fory-json-mixin-mixins.ProfileMixin.pro"), + mixinCodecTypes, + ) + } + + private fun codecTypes(packageName: String): Set = + linkedSetOf( + "$packageName.MapValueCodec", + "$packageName.WholeValueCodec", + "$packageName.KeyCodec", + "$packageName.ContentCodec", + "$packageName.ElementCodec", + ) + + private fun assertCodecRules(rules: String, codecTypes: Set) { + codecTypes.forEach { codecType -> + val exactRule = + """-keep,allowoptimization,allowobfuscation class $codecType +-keepclassmembers class $codecType { + public (); +}""" + assertEquals(1, rules.split(exactRule).size - 1, codecType) + } + assertEquals(codecTypes.size, rules.lineSequence().count { it == " public ();" }) + assertFalse(rules.contains('*'), rules) + assertFalse(rules.contains("JsonCodec\$NoJsonValueCodec"), rules) + assertFalse(rules.contains("JsonCodec\$NoMapKeyCodec"), rules) + } +} diff --git a/kotlin/fory-json-kotlin-ksp/src/test/kotlin/org/apache/fory/json/kotlin/ksp/R8RulesWriterTest.kt b/kotlin/fory-json-kotlin-ksp/src/test/kotlin/org/apache/fory/json/kotlin/ksp/R8RulesWriterTest.kt new file mode 100644 index 0000000000..8bfcd271da --- /dev/null +++ b/kotlin/fory-json-kotlin-ksp/src/test/kotlin/org/apache/fory/json/kotlin/ksp/R8RulesWriterTest.kt @@ -0,0 +1,422 @@ +/* + * 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.fory.json.kotlin.ksp + +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals + +class R8RulesWriterTest { + @Test + fun retainsObjectContract() { + val model = + jsonModel( + members = + listOf( + JvmMember(MemberKind.FIELD, "example.Profile", "id", "J"), + JvmMember( + MemberKind.METHOD, + "example.Profile", + "getName", + "()Ljava/lang/String;", + ), + JvmMember( + MemberKind.METHOD, + "example.Profile", + "putExtra", + "(Ljava/lang/String;Ljava/lang/Object;)V", + ), + JvmMember(MemberKind.METHOD, "example.Profile", "validate", "()V"), + JvmMember(MemberKind.METHOD, "example.Profile", "", "(JLjava/lang/String;)V"), + JvmMember( + MemberKind.METHOD, + "example.Profile", + "", + "(JLjava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V", + ), + JvmMember( + MemberKind.METHOD, + "example.Profile", + "", + "(JLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V", + ), + ), + retainedAnnotations = setOf("org.apache.fory.json.annotation.JsonType", "kotlin.Metadata"), + retainedTypes = setOf("example.ProfileSubtype"), + ) + + assertEquals( + "META-INF/proguard/fory-json-example.Profile.pro", + R8RulesWriter.resourcePath(model), + ) + assertEquals( + """-keepattributes Signature,RuntimeVisibleAnnotations +-keepattributes RuntimeVisibleParameterAnnotations +-keepattributes AnnotationDefault,MethodParameters + +-keep,allowoptimization class example.Profile +-keepclassmembers class example.Profile { + (long,java.lang.String); + (long,java.lang.String,int,kotlin.jvm.internal.DefaultConstructorMarker); + (long,java.lang.String,kotlin.jvm.internal.DefaultConstructorMarker); + java.lang.String getName(); + long id; + void putExtra(java.lang.String,java.lang.Object); + void validate(); +} + +-keep,allowoptimization,allowobfuscation @interface kotlin.Metadata +-keep,allowoptimization,allowobfuscation @interface org.apache.fory.json.annotation.JsonType +-keep,allowoptimization class example.ProfileSubtype +""", + R8RulesWriter.write(model), + ) + + val factoryRules = + R8RulesWriter.write( + jsonModel( + members = + listOf( + JvmMember( + MemberKind.METHOD, + "example.Profile\$Companion", + "create", + "(J)Lexample/Profile;", + ), + JvmMember( + MemberKind.METHOD, + "example.Profile", + "create", + "(J)Lexample/Profile;", + ), + ), + ) + ) + assertContains(factoryRules, "class example.Profile\$Companion") + assertContains(factoryRules, "example.Profile create(long);") + } + + @Test + fun retainsSingletonContract() { + val model = + jsonModel( + targetBinaryName = "example.Marker", + members = + listOf( + JvmMember( + MemberKind.FIELD, + "example.Marker", + "INSTANCE", + "Lexample/Marker;", + ) + ), + retainedAnnotations = setOf("org.apache.fory.json.annotation.JsonType"), + ) + + assertEquals( + """-keepattributes Signature,RuntimeVisibleAnnotations +-keepattributes RuntimeVisibleParameterAnnotations +-keepattributes AnnotationDefault,MethodParameters + +-keep,allowoptimization class example.Marker +-keepclassmembers class example.Marker { + example.Marker INSTANCE; +} + +-keep,allowoptimization,allowobfuscation @interface org.apache.fory.json.annotation.JsonType +""", + R8RulesWriter.write(model), + ) + } + + @Test + fun retainsValueContract() { + val model = + jsonModel( + targetBinaryName = "example.UserId", + members = + listOf( + JvmMember(MemberKind.FIELD, "example.UserId", "value", "Lexample/RawId;"), + JvmMember(MemberKind.METHOD, "example.UserId", "", "(Lexample/RawId;)V"), + JvmMember( + MemberKind.METHOD, + "example.UserId", + "constructor-impl", + "(Lexample/RawId;)Lexample/RawId;", + ), + JvmMember( + MemberKind.METHOD, + "example.UserId", + "box-impl", + "(Lexample/RawId;)Lexample/UserId;", + ), + JvmMember( + MemberKind.METHOD, + "example.UserId", + "unbox-impl", + "()Lexample/RawId;", + ), + JvmMember(MemberKind.FIELD, "example.RawId", "raw", "J"), + JvmMember(MemberKind.METHOD, "example.RawId", "", "(J)V"), + JvmMember(MemberKind.METHOD, "example.RawId", "constructor-impl", "(J)J"), + JvmMember(MemberKind.METHOD, "example.RawId", "box-impl", "(J)Lexample/RawId;"), + JvmMember(MemberKind.METHOD, "example.RawId", "unbox-impl", "()J"), + ), + retainedAnnotations = setOf("kotlin.Metadata"), + ) + + assertEquals( + """-keepattributes Signature,RuntimeVisibleAnnotations +-keepattributes RuntimeVisibleParameterAnnotations +-keepattributes AnnotationDefault,MethodParameters + +-keep,allowoptimization class example.UserId +-keepclassmembers class example.UserId { + (example.RawId); + example.RawId constructor-impl(example.RawId); + example.RawId unbox-impl(); + example.RawId value; + example.UserId box-impl(example.RawId); +} + +-keep,allowoptimization,allowobfuscation class example.RawId +-keepclassmembers class example.RawId { + (long); + example.RawId box-impl(long); + long constructor-impl(long); + long raw; + long unbox-impl(); +} + +-keep,allowoptimization,allowobfuscation @interface kotlin.Metadata +""", + R8RulesWriter.write(model), + ) + } + + @Test + fun retainsSealedContract() { + val model = + jsonModel( + targetBinaryName = "example.Shape", + retainedAnnotations = + setOf("org.apache.fory.json.annotation.JsonSubTypes", "kotlin.Metadata"), + retainedTypes = setOf("example.Square", "example.Circle"), + ) + + val rules = R8RulesWriter.write(model) + assertEquals( + """-keepattributes Signature,RuntimeVisibleAnnotations +-keepattributes RuntimeVisibleParameterAnnotations +-keepattributes AnnotationDefault,MethodParameters + +-keep,allowoptimization class example.Shape + +-keep,allowoptimization,allowobfuscation @interface kotlin.Metadata +-keep,allowoptimization,allowobfuscation @interface org.apache.fory.json.annotation.JsonSubTypes +-keep,allowoptimization class example.Circle +-keep,allowoptimization class example.Square +""", + rules, + ) + assertFalse(rules.contains('*'), rules) + } + + @Test + fun retainsCodecConstructors() { + val codecTypes = + linkedSetOf( + "example.codec.MapValueCodec", + "example.codec.WholeValueCodec", + "example.codec.KeyCodec", + "example.codec.ContentCodec", + "example.codec.ElementCodec", + ) + val direct = jsonModel(codecTypes = codecTypes) + val mixin = + jsonModel( + targetBinaryName = "example.ExternalProfile", + mixinBinaryName = "mixins.ProfileMixin", + codecTypes = codecTypes, + ) + + assertEquals( + """-keepattributes Signature,RuntimeVisibleAnnotations +-keepattributes RuntimeVisibleParameterAnnotations +-keepattributes AnnotationDefault,MethodParameters + +-keep,allowoptimization class example.Profile + +-keep,allowoptimization,allowobfuscation class example.codec.ContentCodec +-keepclassmembers class example.codec.ContentCodec { + public (); +} + +-keep,allowoptimization,allowobfuscation class example.codec.ElementCodec +-keepclassmembers class example.codec.ElementCodec { + public (); +} + +-keep,allowoptimization,allowobfuscation class example.codec.KeyCodec +-keepclassmembers class example.codec.KeyCodec { + public (); +} + +-keep,allowoptimization,allowobfuscation class example.codec.MapValueCodec +-keepclassmembers class example.codec.MapValueCodec { + public (); +} + +-keep,allowoptimization,allowobfuscation class example.codec.WholeValueCodec +-keepclassmembers class example.codec.WholeValueCodec { + public (); +} + +""", + R8RulesWriter.write(direct), + ) + assertEquals( + """-keepattributes Signature,RuntimeVisibleAnnotations +-keepattributes RuntimeVisibleParameterAnnotations +-keepattributes AnnotationDefault,MethodParameters + +-keep,allowoptimization class example.ExternalProfile + +-keep,allowoptimization class mixins.ProfileMixin + +-keep,allowoptimization,allowobfuscation class example.codec.ContentCodec +-keepclassmembers class example.codec.ContentCodec { + public (); +} + +-keep,allowoptimization,allowobfuscation class example.codec.ElementCodec +-keepclassmembers class example.codec.ElementCodec { + public (); +} + +-keep,allowoptimization,allowobfuscation class example.codec.KeyCodec +-keepclassmembers class example.codec.KeyCodec { + public (); +} + +-keep,allowoptimization,allowobfuscation class example.codec.MapValueCodec +-keepclassmembers class example.codec.MapValueCodec { + public (); +} + +-keep,allowoptimization,allowobfuscation class example.codec.WholeValueCodec +-keepclassmembers class example.codec.WholeValueCodec { + public (); +} + +""", + R8RulesWriter.write(mixin), + ) + listOf(direct, mixin).forEach { model -> + val rules = R8RulesWriter.write(model) + codecTypes.forEach { codecType -> + assertEquals( + 1, + rules.lineSequence().count { + it == "-keep,allowoptimization,allowobfuscation class $codecType" + }, + ) + assertEquals( + 1, + rules.lineSequence().count { it == "-keepclassmembers class $codecType {" }, + ) + } + assertEquals(codecTypes.size, rules.lineSequence().count { it == " public ();" }) + assertFalse(rules.contains('*'), rules) + CODEC_SENTINELS.forEach { assertFalse(rules.contains(it), rules) } + } + } + + @Test + fun retainsNestedCodecMetadata() { + val rules = R8RulesWriter.write(jsonModel(codecTypes = setOf("example.Codecs\$NestedCodec"))) + + assertContains(rules, "-keepattributes InnerClasses,EnclosingMethod") + assertContains( + rules, + "-keepclassmembers class example.Codecs\$NestedCodec {\n" + " public ();\n" + "}", + ) + } + + @Test + fun identifiesMixinRequest() { + val direct = jsonModel(targetBinaryName = "example.Profile") + val mixin = + jsonModel( + targetBinaryName = "example.Profile", + mixinBinaryName = "mixins.ProfileMixin", + members = + listOf( + JvmMember(MemberKind.METHOD, "example.Profile", "getName", "()Ljava/lang/String;"), + JvmMember( + MemberKind.FIELD, + "mixins.ProfileMixin", + "renamed", + "Ljava/lang/String;", + ), + ), + ) + val second = mixin.copy(mixinBinaryName = "mixins.SecondProfileMixin") + + assertEquals( + "META-INF/proguard/fory-json-mixin-mixins.ProfileMixin.pro", + R8RulesWriter.resourcePath(mixin), + ) + assertEquals( + "META-INF/proguard/fory-json-mixin-mixins.SecondProfileMixin.pro", + R8RulesWriter.resourcePath(second), + ) + assertNotEquals(R8RulesWriter.resourcePath(direct), R8RulesWriter.resourcePath(mixin)) + assertNotEquals(R8RulesWriter.resourcePath(mixin), R8RulesWriter.resourcePath(second)) + assertEquals( + """-keepattributes Signature,RuntimeVisibleAnnotations +-keepattributes RuntimeVisibleParameterAnnotations +-keepattributes AnnotationDefault,MethodParameters + +-keep,allowoptimization class example.Profile +-keepclassmembers class example.Profile { + java.lang.String getName(); +} + +-keep,allowoptimization class mixins.ProfileMixin +-keepclassmembers class mixins.ProfileMixin { + java.lang.String renamed; +} + +""", + R8RulesWriter.write(mixin), + ) + } + + private companion object { + val CODEC_SENTINELS = + setOf( + "org.apache.fory.json.annotation.JsonCodec\$NoJsonValueCodec", + "org.apache.fory.json.annotation.JsonCodec\$NoMapKeyCodec", + ) + } +} diff --git a/kotlin/fory-json-kotlin/README.md b/kotlin/fory-json-kotlin/README.md new file mode 100644 index 0000000000..602240908f --- /dev/null +++ b/kotlin/fory-json-kotlin/README.md @@ -0,0 +1,91 @@ +# Apache Fory JSON Kotlin + +`fory-json-kotlin` adds Kotlin/JVM model support to Apache Fory JSON. It preserves Kotlin +nullability, constructor defaults, generic arguments, unsigned types, value classes, and other +supported Kotlin semantic types while using the standard Fory JSON APIs. + +## Installation + +Add the Kotlin JSON runtime: + +```kotlin +plugins { + kotlin("jvm") version "2.3.20" +} + +dependencies { + implementation("org.apache.fory:fory-json-kotlin:1.7.0-SNAPSHOT") +} +``` + +The runtime reads Kotlin/JVM metadata directly and does not require `kotlin-reflect` or KSP. + +For an Android build that enables R8 or ProGuard, also apply KSP 2.3.8 and use the matching +processor version: + +```kotlin +plugins { + id("com.google.devtools.ksp") version "2.3.8" +} + +dependencies { + ksp("org.apache.fory:fory-json-kotlin-ksp:1.7.0-SNAPSHOT") +} +``` + +Annotate application models with `@JsonType`, or define source-owned `@JsonMixin` declarations, so +KSP can package their exact retention rules. KSP owns a Mixin request when either its source or its +exact target is Kotlin. The processor does not replace runtime metadata mapping. + +## Usage + +Create a `ForyJson` instance with the Kotlin module installed and retain complete type tokens for +declared Kotlin roots: + +```kotlin +import org.apache.fory.json.kotlin.ForyJsonKotlin +import org.apache.fory.json.kotlin.jsonTypeRef + +data class Account( + val id: ULong, + val name: String, + val nickname: String? = null, +) + +val json = ForyJsonKotlin.builder().build() +val accountType = jsonTypeRef() + +val text = json.toJson(Account(7u, "Alice"), accountType) +val account = json.fromJson(text, accountType) +``` + +Construct each `jsonTypeRef()` once and reuse it. It preserves distinctions that a Java `Class` +cannot express, including occurrence nullability, unsigned semantics, value-class identity, and +nested generic arguments such as `List`. + +`ForyJsonKotlin.builder()` is equivalent to installing the module explicitly: + +```kotlin +import org.apache.fory.json.ForyJson +import org.apache.fory.json.kotlin.ForyJsonKotlin + +val json = ForyJson.builder().withModule(ForyJsonKotlin).build() +``` + +Compiler defaults apply only when a JSON member is absent. Explicit JSON `null` remains distinct +and is accepted only for a nullable declaration. Raw generic types, star projections, and +contravariant projections are not complete schemas and are rejected. + +## Platforms + +The module supports Kotlin/JVM on HotSpot, GraalVM Native Image, and Android API 26 or later. +Standard JVM and Android builds read the model's Kotlin metadata. Android builds that enable R8 or +ProGuard must package the exact KSP-generated retention rules instead of broad keep rules. GraalVM +builds use the Fory JSON provider workflow and must not add reflection configuration for application +models. + +Kotlin/Native, Kotlin/JS, and Kotlin/Wasm are not supported by this JVM module. + +For constructor rules, annotations, supported types, collections, sealed hierarchies, security, +and complete platform setup, see the +[Fory JSON Kotlin guide](../../docs/json/kotlin.md). diff --git a/kotlin/fory-json-kotlin/pom.xml b/kotlin/fory-json-kotlin/pom.xml new file mode 100644 index 0000000000..42f9cedbfd --- /dev/null +++ b/kotlin/fory-json-kotlin/pom.xml @@ -0,0 +1,114 @@ + + + + + org.apache.fory + fory-kotlin-parent + 1.7.0-SNAPSHOT + + + 4.0.0 + fory-json-kotlin + Fory JSON Kotlin + Kotlin language support for Apache Fory JSON. + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + + + compile + process-sources + compile + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + -Xexplicit-api=strict + + + + test-compile + test-compile + test-compile + + + ${project.basedir}/src/test/kotlin + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + com.diffplug.spotless + spotless-maven-plugin + + + maven-surefire-plugin + 3.5.4 + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + org.apache.fory.json.kotlin + + + + + + + + + + org.apache.fory + fory-json + ${project.version} + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-metadata-jvm + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-test-testng + ${kotlin.version} + test + + + diff --git a/kotlin/fory-json-kotlin/src/main/java/org/apache/fory/json/kotlin/KotlinExactUnboxedValueOperations.java b/kotlin/fory-json-kotlin/src/main/java/org/apache/fory/json/kotlin/KotlinExactUnboxedValueOperations.java new file mode 100644 index 0000000000..a3178f2397 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/java/org/apache/fory/json/kotlin/KotlinExactUnboxedValueOperations.java @@ -0,0 +1,119 @@ +/* + * 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.fory.json.kotlin; + +import java.lang.invoke.MethodHandle; +import java.lang.reflect.Method; +import org.apache.fory.json.ForyJsonException; +import org.apache.fory.json.reader.JsonReader; + +/** Exact prebound interpreted invocation for one unboxed parent occurrence. */ +final class KotlinExactUnboxedValueOperations implements KotlinUnboxedValueClassOperations { + private final Class owner; + private final Class carrier; + private final MethodHandle construct; + private final MethodHandle extract; + private final Method[] constructMethods; + private final int[] constructBoxBytes; + private final Method[] extractMethods; + + private KotlinExactUnboxedValueOperations( + Class owner, + Class carrier, + MethodHandle construct, + MethodHandle extract, + Method[] constructMethods, + int[] constructBoxBytes, + Method[] extractMethods) { + if (constructMethods.length != constructBoxBytes.length) { + throw new IllegalArgumentException("Unboxed construct operations and charges must align"); + } + this.owner = owner; + this.carrier = carrier; + this.construct = construct; + this.extract = extract; + this.constructMethods = constructMethods.clone(); + this.constructBoxBytes = constructBoxBytes.clone(); + this.extractMethods = extractMethods.clone(); + } + + static KotlinUnboxedValueClassOperations create( + Class owner, + Class carrier, + MethodHandle construct, + MethodHandle extract, + Method[] constructMethods, + int[] constructBoxBytes, + Method[] extractMethods) { + return new KotlinExactUnboxedValueOperations( + owner, carrier, construct, extract, constructMethods, constructBoxBytes, extractMethods); + } + + @Override + public Object constructCarrier(JsonReader reader, Object value) { + try { + return (Object) construct.invokeExact(reader, value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public Object extractValue(Object carrierValue) { + requireCarrier(carrierValue); + try { + return (Object) extract.invokeExact(carrierValue); + } catch (Throwable cause) { + throw failure("extract", cause); + } + } + + @Override + public Method[] constructMethods() { + return constructMethods.clone(); + } + + @Override + public int[] constructBoxBytes() { + return constructBoxBytes.clone(); + } + + @Override + public Method[] extractMethods() { + return extractMethods.clone(); + } + + private void requireCarrier(Object value) { + if (value == null && carrier.isPrimitive()) { + throw new ForyJsonException("Null unboxed carrier for " + owner.getName()); + } + } + + private ForyJsonException failure(String operation, Throwable cause) { + if (cause instanceof Error) { + throw (Error) cause; + } + if (cause instanceof ForyJsonException) { + return (ForyJsonException) cause; + } + return new ForyJsonException( + "Kotlin value-class " + operation + " failed for " + owner.getName(), cause); + } +} diff --git a/kotlin/fory-json-kotlin/src/main/java/org/apache/fory/json/kotlin/KotlinExactValueClassOperations.java b/kotlin/fory-json-kotlin/src/main/java/org/apache/fory/json/kotlin/KotlinExactValueClassOperations.java new file mode 100644 index 0000000000..98ec47e654 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/java/org/apache/fory/json/kotlin/KotlinExactValueClassOperations.java @@ -0,0 +1,444 @@ +/* + * 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.fory.json.kotlin; + +import java.lang.invoke.MethodHandle; +import org.apache.fory.json.ForyJsonException; +import org.apache.fory.json.reader.JsonReader; + +/** Exact signature-polymorphic invocation for prebound value-class operations. */ +final class KotlinExactValueClassOperations { + private KotlinExactValueClassOperations() {} + + static KotlinValueClassOperations create( + Class owner, + Class carrier, + MethodHandle construct, + MethodHandle constructUncharged, + MethodHandle unbox, + KotlinUnboxedValueClassOperations unboxedOperations) { + if (carrier == boolean.class) { + return new BooleanOperations(owner, construct, constructUncharged, unbox, unboxedOperations); + } else if (carrier == byte.class) { + return new ByteOperations(owner, construct, constructUncharged, unbox, unboxedOperations); + } else if (carrier == short.class) { + return new ShortOperations(owner, construct, constructUncharged, unbox, unboxedOperations); + } else if (carrier == int.class) { + return new IntOperations(owner, construct, constructUncharged, unbox, unboxedOperations); + } else if (carrier == long.class) { + return new LongOperations(owner, construct, constructUncharged, unbox, unboxedOperations); + } else if (carrier == float.class) { + return new FloatOperations(owner, construct, constructUncharged, unbox, unboxedOperations); + } else if (carrier == double.class) { + return new DoubleOperations(owner, construct, constructUncharged, unbox, unboxedOperations); + } else if (carrier == char.class) { + return new CharOperations(owner, construct, constructUncharged, unbox, unboxedOperations); + } + return new ReferenceOperations(owner, construct, constructUncharged, unbox, unboxedOperations); + } + + private abstract static class Operations implements KotlinValueClassOperations { + final Class owner; + final MethodHandle construct; + final MethodHandle constructUncharged; + final MethodHandle unbox; + final KotlinUnboxedValueClassOperations unboxedOperations; + + Operations( + Class owner, + MethodHandle construct, + MethodHandle constructUncharged, + MethodHandle unbox, + KotlinUnboxedValueClassOperations unboxedOperations) { + this.owner = owner; + this.construct = construct; + this.constructUncharged = constructUncharged; + this.unbox = unbox; + this.unboxedOperations = unboxedOperations; + } + + @Override + public final KotlinUnboxedValueClassOperations unboxedOperations() { + return unboxedOperations; + } + + final ForyJsonException failure(String operation, Throwable cause) { + if (cause instanceof Error) { + throw (Error) cause; + } + if (cause instanceof ForyJsonException) { + return (ForyJsonException) cause; + } + return new ForyJsonException( + "Kotlin value-class " + operation + " failed for " + owner.getName(), cause); + } + } + + private static final class BooleanOperations extends Operations + implements KotlinBooleanValueClassOperations { + BooleanOperations( + Class owner, + MethodHandle construct, + MethodHandle uncharged, + MethodHandle unbox, + KotlinUnboxedValueClassOperations unboxedOperations) { + super(owner, construct, uncharged, unbox, unboxedOperations); + } + + @Override + public Object constructBoolean(JsonReader reader, boolean value) { + try { + return (Object) construct.invokeExact(reader, value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public Object constructBooleanUncharged(boolean value) { + try { + return (Object) constructUncharged.invokeExact(value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public boolean unboxBoolean(Object value) { + try { + return (boolean) unbox.invokeExact(value); + } catch (Throwable cause) { + throw failure("unbox", cause); + } + } + } + + private static final class ByteOperations extends Operations + implements KotlinByteValueClassOperations { + ByteOperations( + Class owner, + MethodHandle construct, + MethodHandle uncharged, + MethodHandle unbox, + KotlinUnboxedValueClassOperations unboxedOperations) { + super(owner, construct, uncharged, unbox, unboxedOperations); + } + + @Override + public Object constructByte(JsonReader reader, byte value) { + try { + return (Object) construct.invokeExact(reader, value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public Object constructByteUncharged(byte value) { + try { + return (Object) constructUncharged.invokeExact(value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public byte unboxByte(Object value) { + try { + return (byte) unbox.invokeExact(value); + } catch (Throwable cause) { + throw failure("unbox", cause); + } + } + } + + private static final class ShortOperations extends Operations + implements KotlinShortValueClassOperations { + ShortOperations( + Class owner, + MethodHandle construct, + MethodHandle uncharged, + MethodHandle unbox, + KotlinUnboxedValueClassOperations unboxedOperations) { + super(owner, construct, uncharged, unbox, unboxedOperations); + } + + @Override + public Object constructShort(JsonReader reader, short value) { + try { + return (Object) construct.invokeExact(reader, value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public Object constructShortUncharged(short value) { + try { + return (Object) constructUncharged.invokeExact(value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public short unboxShort(Object value) { + try { + return (short) unbox.invokeExact(value); + } catch (Throwable cause) { + throw failure("unbox", cause); + } + } + } + + private static final class IntOperations extends Operations + implements KotlinIntValueClassOperations { + IntOperations( + Class owner, + MethodHandle construct, + MethodHandle uncharged, + MethodHandle unbox, + KotlinUnboxedValueClassOperations unboxedOperations) { + super(owner, construct, uncharged, unbox, unboxedOperations); + } + + @Override + public Object constructInt(JsonReader reader, int value) { + try { + return (Object) construct.invokeExact(reader, value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public Object constructIntUncharged(int value) { + try { + return (Object) constructUncharged.invokeExact(value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public int unboxInt(Object value) { + try { + return (int) unbox.invokeExact(value); + } catch (Throwable cause) { + throw failure("unbox", cause); + } + } + } + + private static final class LongOperations extends Operations + implements KotlinLongValueClassOperations { + LongOperations( + Class owner, + MethodHandle construct, + MethodHandle uncharged, + MethodHandle unbox, + KotlinUnboxedValueClassOperations unboxedOperations) { + super(owner, construct, uncharged, unbox, unboxedOperations); + } + + @Override + public Object constructLong(JsonReader reader, long value) { + try { + return (Object) construct.invokeExact(reader, value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public Object constructLongUncharged(long value) { + try { + return (Object) constructUncharged.invokeExact(value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public long unboxLong(Object value) { + try { + return (long) unbox.invokeExact(value); + } catch (Throwable cause) { + throw failure("unbox", cause); + } + } + } + + private static final class FloatOperations extends Operations + implements KotlinFloatValueClassOperations { + FloatOperations( + Class owner, + MethodHandle construct, + MethodHandle uncharged, + MethodHandle unbox, + KotlinUnboxedValueClassOperations unboxedOperations) { + super(owner, construct, uncharged, unbox, unboxedOperations); + } + + @Override + public Object constructFloat(JsonReader reader, float value) { + try { + return (Object) construct.invokeExact(reader, value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public Object constructFloatUncharged(float value) { + try { + return (Object) constructUncharged.invokeExact(value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public float unboxFloat(Object value) { + try { + return (float) unbox.invokeExact(value); + } catch (Throwable cause) { + throw failure("unbox", cause); + } + } + } + + private static final class DoubleOperations extends Operations + implements KotlinDoubleValueClassOperations { + DoubleOperations( + Class owner, + MethodHandle construct, + MethodHandle uncharged, + MethodHandle unbox, + KotlinUnboxedValueClassOperations unboxedOperations) { + super(owner, construct, uncharged, unbox, unboxedOperations); + } + + @Override + public Object constructDouble(JsonReader reader, double value) { + try { + return (Object) construct.invokeExact(reader, value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public Object constructDoubleUncharged(double value) { + try { + return (Object) constructUncharged.invokeExact(value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public double unboxDouble(Object value) { + try { + return (double) unbox.invokeExact(value); + } catch (Throwable cause) { + throw failure("unbox", cause); + } + } + } + + private static final class CharOperations extends Operations + implements KotlinCharValueClassOperations { + CharOperations( + Class owner, + MethodHandle construct, + MethodHandle uncharged, + MethodHandle unbox, + KotlinUnboxedValueClassOperations unboxedOperations) { + super(owner, construct, uncharged, unbox, unboxedOperations); + } + + @Override + public Object constructChar(JsonReader reader, char value) { + try { + return (Object) construct.invokeExact(reader, value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public Object constructCharUncharged(char value) { + try { + return (Object) constructUncharged.invokeExact(value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public char unboxChar(Object value) { + try { + return (char) unbox.invokeExact(value); + } catch (Throwable cause) { + throw failure("unbox", cause); + } + } + } + + private static final class ReferenceOperations extends Operations + implements KotlinReferenceValueClassOperations { + ReferenceOperations( + Class owner, + MethodHandle construct, + MethodHandle uncharged, + MethodHandle unbox, + KotlinUnboxedValueClassOperations unboxedOperations) { + super(owner, construct, uncharged, unbox, unboxedOperations); + } + + @Override + public Object constructValue(JsonReader reader, Object value) { + try { + return (Object) construct.invokeExact(reader, value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public Object constructValueUncharged(Object value) { + try { + return (Object) constructUncharged.invokeExact(value); + } catch (Throwable cause) { + throw failure("construct", cause); + } + } + + @Override + public Object unboxValue(Object value) { + try { + return (Object) unbox.invokeExact(value); + } catch (Throwable cause) { + throw failure("unbox", cause); + } + } + } +} diff --git a/kotlin/fory-json-kotlin/src/main/java/org/apache/fory/json/kotlin/KotlinTemporalAccess.java b/kotlin/fory-json-kotlin/src/main/java/org/apache/fory/json/kotlin/KotlinTemporalAccess.java new file mode 100644 index 0000000000..fda52b5b73 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/java/org/apache/fory/json/kotlin/KotlinTemporalAccess.java @@ -0,0 +1,49 @@ +/* + * 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.fory.json.kotlin; + +import kotlin.uuid.Uuid; +import org.apache.fory.annotation.Internal; +import org.apache.fory.json.reader.JsonReader; +import org.apache.fory.json.writer.JsonWriter; + +/** + * Java source access to Kotlin-internal getters and inline carriers not expressible without boxing. + */ +@Internal +final class KotlinTemporalAccess { + private KotlinTemporalAccess() {} + + static long uuidHigh(Uuid value) { + return value.getMostSignificantBits(); + } + + static long uuidLow(Uuid value) { + return value.getLeastSignificantBits(); + } + + static Object readDurationCarrier(JsonReader reader) { + return Long.valueOf(KotlinTemporalCodecs.readDurationRaw(reader)); + } + + static void writeDurationCarrier(JsonWriter writer, Object carrier) { + KotlinTemporalCodecs.writeDurationRaw(writer, ((Long) carrier).longValue()); + } +} diff --git a/kotlin/fory-json-kotlin/src/main/java/org/apache/fory/json/kotlin/KotlinTimedValueCodec.java b/kotlin/fory-json-kotlin/src/main/java/org/apache/fory/json/kotlin/KotlinTimedValueCodec.java new file mode 100644 index 0000000000..a536a988d5 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/java/org/apache/fory/json/kotlin/KotlinTimedValueCodec.java @@ -0,0 +1,283 @@ +/* + * 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.fory.json.kotlin; + +import java.util.List; +import kotlin.time.TimedValue; +import org.apache.fory.annotation.Internal; +import org.apache.fory.json.ForyJsonException; +import org.apache.fory.json.codec.CompositeJsonCodec; +import org.apache.fory.json.codec.Latin1ReaderCodec; +import org.apache.fory.json.codec.StringWriterCodec; +import org.apache.fory.json.codec.Utf16ReaderCodec; +import org.apache.fory.json.codec.Utf8ReaderCodec; +import org.apache.fory.json.codec.Utf8WriterCodec; +import org.apache.fory.json.meta.JsonFieldNameHash; +import org.apache.fory.json.reader.JsonReader; +import org.apache.fory.json.reader.Latin1JsonReader; +import org.apache.fory.json.reader.Utf16JsonReader; +import org.apache.fory.json.reader.Utf8JsonReader; +import org.apache.fory.json.resolver.JsonTypeInfo; +import org.apache.fory.json.resolver.JsonTypeResolver; +import org.apache.fory.json.writer.JsonWriter; +import org.apache.fory.json.writer.StringJsonWriter; +import org.apache.fory.json.writer.Utf8JsonWriter; +import org.apache.fory.reflect.TypeRef; +import org.apache.fory.serializer.GraphMemoryEstimates; + +/** Exact two-field composite owner for {@link TimedValue}. */ +@Internal +final class KotlinTimedValueCodec implements CompositeJsonCodec> { + private static final int VALUE_FIELD = 0; + private static final int DURATION_FIELD = 1; + private static final int ALL_FIELDS = 3; + private static final byte DELEGATE_NULL = 0; + private static final byte ACCEPT_NULL = 1; + private static final byte REJECT_NULL = 2; + private static final long VALUE_HASH = JsonFieldNameHash.hash("value"); + private static final long DURATION_HASH = JsonFieldNameHash.hash("duration"); + private static final int OWNER_BYTES = GraphMemoryEstimates.shallowObjectBytes(TimedValue.class); + + // JsonTypeResolver publishes this codec shell before binding the recursive child. The same + // resolver transaction writes these ordinary slots exactly once or rolls the whole graph back. + // Hot dispatch must not re-query mutable JsonTypeInfo capability slots. + private JsonTypeInfo valueTypeInfo; + private byte valueNullAction; + private StringWriterCodec valueStringWriter; + private Utf8WriterCodec valueUtf8Writer; + private Latin1ReaderCodec valueLatin1Reader; + private Utf16ReaderCodec valueUtf16Reader; + private Utf8ReaderCodec valueUtf8Reader; + + @Override + public void resolveTypes(TypeRef type, JsonTypeResolver resolver) { + if (valueStringWriter != null) { + throw new IllegalStateException("Kotlin TimedValue child is already resolved"); + } + List> arguments = type.getTypeArguments(); + if (arguments.size() != 1) { + throw new ForyJsonException("Kotlin TimedValue requires one exact value type argument"); + } + valueTypeInfo = resolver.getTypeInfo(arguments.get(0)); + valueNullAction = + valueTypeInfo.nullable() + ? ACCEPT_NULL + : valueTypeInfo.rejectsNull() ? REJECT_NULL : DELEGATE_NULL; + valueStringWriter = valueTypeInfo.stringWriter(); + valueUtf8Writer = valueTypeInfo.utf8Writer(); + valueLatin1Reader = valueTypeInfo.latin1Reader(); + valueUtf16Reader = valueTypeInfo.utf16Reader(); + valueUtf8Reader = valueTypeInfo.utf8Reader(); + } + + @Override + public void writeString(StringJsonWriter writer, TimedValue value) { + if (value == null) { + writer.writeNull(); + return; + } + StringWriterCodec valueWriter = valueStringWriter; + writer.writeObjectStart(); + writer.writeFieldName("value"); + Object child = value.getValue(); + if (!writeValueNull(writer, child)) { + valueWriter.writeString(writer, child); + } + writeDurationName(writer); + KotlinTemporalCodecs.writeDurationRaw( + writer, KotlinTemporalCodecs.INSTANCE.timedDurationRaw(value)); + writer.writeObjectEnd(); + } + + @Override + public void writeUtf8(Utf8JsonWriter writer, TimedValue value) { + if (value == null) { + writer.writeNull(); + return; + } + Utf8WriterCodec valueWriter = valueUtf8Writer; + writer.writeObjectStart(); + writer.writeFieldName("value"); + Object child = value.getValue(); + if (!writeValueNull(writer, child)) { + valueWriter.writeUtf8(writer, child); + } + writeDurationName(writer); + KotlinTemporalCodecs.writeDurationRaw( + writer, KotlinTemporalCodecs.INSTANCE.timedDurationRaw(value)); + writer.writeObjectEnd(); + } + + @Override + public TimedValue readLatin1(Latin1JsonReader reader) { + if (reader.tryReadNullToken()) { + return null; + } + reader.enterDepth(); + reader.expectNextToken('{'); + Object value = null; + long duration = 0; + int seen = 0; + Latin1ReaderCodec valueReader = valueLatin1Reader; + if (!reader.consumeNextToken('}')) { + do { + int field = readField(reader, seen); + seen |= 1 << field; + if (field == VALUE_FIELD) { + if (!readValueNull(reader)) { + value = valueReader.readLatin1(reader); + } + } else { + duration = KotlinTemporalCodecs.readDurationRaw(reader); + } + } while (reader.consumeNextToken(',')); + reader.expectNextToken('}'); + } + requireFields(seen); + return create(reader, value, duration); + } + + @Override + public TimedValue readUtf16(Utf16JsonReader reader) { + if (reader.tryReadNullToken()) { + return null; + } + reader.enterDepth(); + reader.expectNextToken('{'); + Object value = null; + long duration = 0; + int seen = 0; + Utf16ReaderCodec valueReader = valueUtf16Reader; + if (!reader.consumeNextToken('}')) { + do { + int field = readField(reader, seen); + seen |= 1 << field; + if (field == VALUE_FIELD) { + if (!readValueNull(reader)) { + value = valueReader.readUtf16(reader); + } + } else { + duration = KotlinTemporalCodecs.readDurationRaw(reader); + } + } while (reader.consumeNextToken(',')); + reader.expectNextToken('}'); + } + requireFields(seen); + return create(reader, value, duration); + } + + @Override + public TimedValue readUtf8(Utf8JsonReader reader) { + if (reader.tryReadNullToken()) { + return null; + } + reader.enterDepth(); + reader.expectNextToken('{'); + Object value = null; + long duration = 0; + int seen = 0; + Utf8ReaderCodec valueReader = valueUtf8Reader; + if (!reader.consumeNextToken('}')) { + do { + int field = readField(reader, seen); + seen |= 1 << field; + if (field == VALUE_FIELD) { + if (!readValueNull(reader)) { + value = valueReader.readUtf8(reader); + } + } else { + duration = KotlinTemporalCodecs.readDurationRaw(reader); + } + } while (reader.consumeNextToken(',')); + reader.expectNextToken('}'); + } + requireFields(seen); + return create(reader, value, duration); + } + + private static void writeDurationName(JsonWriter writer) { + writer.writeComma(1); + writer.writeFieldName("duration"); + } + + private boolean writeValueNull(JsonWriter writer, Object value) { + if (value != null) { + return false; + } + byte action = valueNullAction; + if (action == ACCEPT_NULL) { + writer.writeNull(); + return true; + } + if (action == REJECT_NULL) { + valueTypeInfo.rejectNullValue(); + } + return false; + } + + private boolean readValueNull(JsonReader reader) { + byte action = valueNullAction; + if (action == DELEGATE_NULL || !reader.tryReadNull()) { + return false; + } + if (action == REJECT_NULL) { + valueTypeInfo.rejectNullValue(); + } + return true; + } + + private static int readField(JsonReader reader, int seen) { + long hash = reader.readFieldNameHash(); + int field; + if (hash == VALUE_HASH) { + field = VALUE_FIELD; + } else if (hash == DURATION_HASH) { + field = DURATION_FIELD; + } else { + throw unknownField(); + } + if ((seen & (1 << field)) != 0) { + throw duplicateField(); + } + reader.expectNextToken(':'); + return field; + } + + private static TimedValue create(JsonReader reader, Object value, long duration) { + // Complete the JSON composite before accounting for and constructing its result owner. + reader.exitDepth(); + reader.reserveGraphMemory(OWNER_BYTES); + return KotlinTemporalCodecs.INSTANCE.newTimedValue(value, duration); + } + + private static void requireFields(int seen) { + if (seen != ALL_FIELDS) { + throw new ForyJsonException("Kotlin TimedValue JSON requires value and duration"); + } + } + + private static ForyJsonException unknownField() { + return new ForyJsonException("Unknown Kotlin TimedValue JSON field"); + } + + private static ForyJsonException duplicateField() { + return new ForyJsonException("Duplicate Kotlin TimedValue JSON field"); + } +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/ForyJsonKotlin.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/ForyJsonKotlin.kt new file mode 100644 index 0000000000..89e62030d8 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/ForyJsonKotlin.kt @@ -0,0 +1,35 @@ +/* + * 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.fory.json.kotlin + +import org.apache.fory.json.ForyJson +import org.apache.fory.json.ForyJsonBuilder +import org.apache.fory.json.ForyJsonModule +import org.apache.fory.json.ModuleContext + +/** Installs Kotlin/JVM semantic types and immutable model construction in Fory JSON. */ +public object ForyJsonKotlin : ForyJsonModule { + /** Creates a JSON builder with Kotlin support installed. */ + @JvmStatic public fun builder(): ForyJsonBuilder = ForyJson.builder().withModule(this) + + override fun install(context: ModuleContext) { + context.registerCodecFactory(KotlinJsonCodecFactory) + } +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinJsonCodecFactory.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinJsonCodecFactory.kt new file mode 100644 index 0000000000..d2952da6ac --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinJsonCodecFactory.kt @@ -0,0 +1,108 @@ +/* + * 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.fory.json.kotlin + +import kotlin.time.TimedValue +import org.apache.fory.json.JsonCodecFactory +import org.apache.fory.json.codec.ArrayCodec +import org.apache.fory.json.codec.JsonValueCodec +import org.apache.fory.json.codec.ScalarCodecs +import org.apache.fory.json.resolver.ExactTypeRequiredException +import org.apache.fory.json.resolver.JsonTypeResolver +import org.apache.fory.json.resolver.UnsupportedJsonTypeException +import org.apache.fory.reflect.TypeRef +import org.apache.fory.type.Types + +internal object KotlinJsonCodecFactory : JsonCodecFactory { + override fun create(type: TypeRef<*>, resolver: JsonTypeResolver): JsonValueCodec<*>? { + val rawType = type.rawType + val semanticId = type.typeExtMeta?.typeId() ?: 0 + if ( + semanticId in Types.UINT8..Types.UINT64 && + semanticId != Types.VAR_UINT32 && + semanticId != Types.VAR_UINT64 && + semanticId != Types.TAGGED_UINT64 + ) { + return KotlinUnsignedCodecs.scalar( + semanticId, + !rawType.isPrimitive, + type.typeExtMeta.nullable(), + ) + } + if (semanticId in Types.UINT8_ARRAY..Types.UINT64_ARRAY) { + KotlinUnsignedArrayCodecs.create(type)?.let { + return it + } + return ArrayCodec.createUnsignedPrimitive(rawType, semanticId) + } + if (rawType == Unit::class.java) { + return if (type.typeExtMeta?.nullable() == true) { + KotlinSingletonCodecs.NULLABLE_UNIT + } else { + KotlinSingletonCodecs.UNIT + } + } + if (rawType == Void::class.java) { + if (type.typeExtMeta?.nullable() == true) return ScalarCodecs.VoidCodec.INSTANCE + throw UnsupportedJsonTypeException("Kotlin Nothing has no JSON value") + } + KotlinMapKeyCodecs.create(type, resolver)?.let { + return it + } + if (Map::class.java.isAssignableFrom(rawType)) { + val arguments = type.typeArguments + if (arguments.size == 2 && KotlinValueClassMetadata.isValueClass(arguments[0].rawType)) { + return KotlinValueClassCodecs.createMap(type, resolver) + } + } + KotlinProductCodecs.create(type, resolver)?.let { + return it + } + KotlinRangeCodecs.create(type)?.let { + return it + } + KotlinProgressionCodecs.create(type)?.let { + return it + } + KotlinTemporalCodecs.create(type)?.let { + return it + } + if (rawType == TimedValue::class.java) return KotlinTimedValueCodec() + KotlinUnsupportedTypes.reject(rawType) + if ( + Collection::class.java.isAssignableFrom(rawType) || Map::class.java.isAssignableFrom(rawType) + ) { + return null + } + if (KotlinValueClassMetadata.isValueClass(rawType)) { + if (!type.hasTypeExtMeta()) { + throw ExactTypeRequiredException( + "Kotlin JSON value class ${rawType.name} requires an exact declared occurrence", + ) + } + return KotlinValueClassCodecs.create(type) + } + if (rawType.getAnnotation(Metadata::class.java) == null) return null + return resolver.createObjectCodec( + type, + KotlinMetadataModels.objectModel(type, resolver.creatorDeclarations(rawType)), + ) + } +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinMapKeyCodecs.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinMapKeyCodecs.kt new file mode 100644 index 0000000000..2afd639354 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinMapKeyCodecs.kt @@ -0,0 +1,169 @@ +/* + * 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.fory.json.kotlin + +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.codec.JsonValueCodec +import org.apache.fory.json.codec.MapCodec +import org.apache.fory.json.codec.MapKeyCodec +import org.apache.fory.json.reader.JsonReader +import org.apache.fory.json.resolver.JsonTypeResolver +import org.apache.fory.json.writer.JsonWriter +import org.apache.fory.reflect.TypeRef +import org.apache.fory.type.Types + +/** Supplies semantic unsigned member-name conversion to the existing core map codec. */ +internal object KotlinMapKeyCodecs { + fun create(type: TypeRef<*>, resolver: JsonTypeResolver): JsonValueCodec<*>? { + if (!Map::class.java.isAssignableFrom(type.rawType)) return null + val arguments = type.typeArguments + if (arguments.size != 2) return null + val keyType = arguments[0] + val typeId = keyType.typeExtMeta?.typeId() ?: 0 + val keyCodec = keyCodec(keyType) ?: return null + if (keyType.typeExtMeta?.nullable() == true) { + throw ForyJsonException("Kotlin unsigned JSON map keys cannot be nullable") + } + resolver.checkMapKeySecure(keyType.rawType) + val valueTypeInfo = resolver.getTypeInfo(arguments[1]) + return MapCodec.create(type.rawType, keyClass(typeId), valueTypeInfo, keyCodec) + } + + /** Returns the terminal member-name codec for an exact boxed U* or primitive physical carrier. */ + fun keyCodec(type: TypeRef<*>): MapKeyCodec? { + val typeId = type.typeExtMeta?.typeId() ?: return null + val rawType = type.rawType + return when (typeId) { + Types.UINT8 -> + requireCarrier( + type, + typeId, + rawType == UByte::class.java || rawType == java.lang.Byte.TYPE, + UByteKeyCodec, + ) + Types.UINT16 -> + requireCarrier( + type, + typeId, + rawType == UShort::class.java || rawType == java.lang.Short.TYPE, + UShortKeyCodec, + ) + Types.UINT32 -> + requireCarrier( + type, + typeId, + rawType == UInt::class.java || rawType == java.lang.Integer.TYPE, + UIntKeyCodec, + ) + Types.UINT64 -> + requireCarrier( + type, + typeId, + rawType == ULong::class.java || rawType == java.lang.Long.TYPE, + ULongKeyCodec, + ) + else -> null + } + } + + private fun requireCarrier( + type: TypeRef<*>, + typeId: Int, + matches: Boolean, + codec: MapKeyCodec, + ): MapKeyCodec { + if (!matches) { + throw ForyJsonException( + "Kotlin unsigned map-key carrier ${type.rawType.name} does not match semantic type id " + + typeId, + ) + } + return codec + } + + private fun keyClass(typeId: Int): Class<*> = + when (typeId) { + Types.UINT8 -> UByte::class.java + Types.UINT16 -> UShort::class.java + Types.UINT32 -> UInt::class.java + Types.UINT64 -> ULong::class.java + else -> throw ForyJsonException("Unknown Kotlin unsigned JSON map-key type id $typeId") + } + + private object UByteKeyCodec : MapKeyCodec { + override fun toName(key: Any): String = (key as UByte).toString() + + override fun fromName(name: String): Any = + name.toUByteOrNull() ?: throw ForyJsonException("Invalid UByte JSON map key") + + override fun writeName(writer: JsonWriter, key: Any) = + writer.writeUnsignedIntFieldName((key as UByte).toInt()) + + override fun readName(reader: JsonReader): Any { + val value = reader.readFieldNameUnsignedInt() + if (Integer.compareUnsigned(value, UByte.MAX_VALUE.toInt()) > 0) { + throw ForyJsonException("UByte map-key overflow") + } + return value.toUByte() + } + } + + private object UShortKeyCodec : MapKeyCodec { + override fun toName(key: Any): String = (key as UShort).toString() + + override fun fromName(name: String): Any = + name.toUShortOrNull() ?: throw ForyJsonException("Invalid UShort JSON map key") + + override fun writeName(writer: JsonWriter, key: Any) = + writer.writeUnsignedIntFieldName((key as UShort).toInt()) + + override fun readName(reader: JsonReader): Any { + val value = reader.readFieldNameUnsignedInt() + if (Integer.compareUnsigned(value, UShort.MAX_VALUE.toInt()) > 0) { + throw ForyJsonException("UShort map-key overflow") + } + return value.toUShort() + } + } + + private object UIntKeyCodec : MapKeyCodec { + override fun toName(key: Any): String = (key as UInt).toString() + + override fun fromName(name: String): Any = + name.toUIntOrNull() ?: throw ForyJsonException("Invalid UInt JSON map key") + + override fun writeName(writer: JsonWriter, key: Any) = + writer.writeUnsignedIntFieldName((key as UInt).toInt()) + + override fun readName(reader: JsonReader): Any = reader.readFieldNameUnsignedInt().toUInt() + } + + private object ULongKeyCodec : MapKeyCodec { + override fun toName(key: Any): String = (key as ULong).toString() + + override fun fromName(name: String): Any = + name.toULongOrNull() ?: throw ForyJsonException("Invalid ULong JSON map key") + + override fun writeName(writer: JsonWriter, key: Any) = + writer.writeUnsignedLongFieldName((key as ULong).toLong()) + + override fun readName(reader: JsonReader): Any = reader.readFieldNameUnsignedLong().toULong() + } +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinMetadataModels.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinMetadataModels.kt new file mode 100644 index 0000000000..18546c0d57 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinMetadataModels.kt @@ -0,0 +1,939 @@ +/* + * 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.fory.json.kotlin + +import java.lang.reflect.Array as ReflectArray +import java.lang.reflect.Constructor +import java.lang.reflect.Executable +import java.lang.reflect.Field +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import java.util.LinkedHashMap +import kotlin.ExperimentalContextParameters +import kotlin.metadata.ClassKind +import kotlin.metadata.ExperimentalContextReceivers +import kotlin.metadata.KmClass +import kotlin.metadata.KmClassifier +import kotlin.metadata.KmConstructor +import kotlin.metadata.KmFunction +import kotlin.metadata.KmProperty +import kotlin.metadata.KmType +import kotlin.metadata.KmTypeProjection +import kotlin.metadata.KmValueParameter +import kotlin.metadata.KmVariance +import kotlin.metadata.Visibility +import kotlin.metadata.declaresDefaultValue +import kotlin.metadata.isConst +import kotlin.metadata.isDefinitelyNonNull +import kotlin.metadata.isDelegated +import kotlin.metadata.isInner +import kotlin.metadata.isLateinit +import kotlin.metadata.isNullable +import kotlin.metadata.isSecondary +import kotlin.metadata.isSuspend +import kotlin.metadata.isValue +import kotlin.metadata.isVar +import kotlin.metadata.jvm.KotlinClassMetadata +import kotlin.metadata.jvm.fieldSignature +import kotlin.metadata.jvm.getterSignature +import kotlin.metadata.jvm.setterSignature +import kotlin.metadata.jvm.signature +import kotlin.metadata.kind +import kotlin.metadata.visibility +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.codec.JsonObjectModel +import org.apache.fory.json.meta.JsonCreatorDeclaration +import org.apache.fory.meta.TypeExtMeta +import org.apache.fory.reflect.TypeRef +import org.apache.fory.type.Types + +/** Strict cold-path translation from Kotlin class metadata to the standard JSON object model. */ +@OptIn(ExperimentalContextParameters::class) +internal object KotlinMetadataModels { + fun objectModel( + ownerType: TypeRef<*>, + creatorDeclarations: List = emptyList(), + ): JsonObjectModel { + val rawType = ownerType.rawType + val model = readClass(rawType) + if (rawType.isAnonymousClass || rawType.isLocalClass || rawType.isSynthetic) { + unsupported(rawType, "local, anonymous, and synthetic classes have no stable schema") + } + if (!Modifier.isPublic(rawType.modifiers)) { + unsupported(rawType, "model class is not JVM-public") + } + if (model.isInner) unsupported(rawType, "inner classes need an outer instance") + if (hasImplicitContext(model)) { + unsupported(rawType, "context receivers have no JSON argument source") + } + if (model.isValue) unsupported(rawType, "value classes use the value codec") + return when (model.kind) { + ClassKind.OBJECT -> singletonModel(ownerType, rawType, model, creatorDeclarations) + ClassKind.COMPANION_OBJECT -> unsupported(rawType, "companion objects have no value schema") + ClassKind.INTERFACE -> + unsupported(rawType, "interfaces require @JsonSubTypes or a custom codec") + ClassKind.ANNOTATION_CLASS -> unsupported(rawType, "annotation classes have no value schema") + ClassKind.ENUM_CLASS, + ClassKind.ENUM_ENTRY -> unsupported(rawType, "enum classes use the core enum codec") + ClassKind.CLASS -> classModel(ownerType, rawType, model, creatorDeclarations) + } + } + + private fun singletonModel( + ownerType: TypeRef<*>, + rawType: Class<*>, + model: KmClass, + creators: List, + ): JsonObjectModel { + if (creators.isNotEmpty()) unsupported(rawType, "a singleton cannot declare a JSON creator") + val properties = properties(ownerType, model) + val instanceField = + try { + rawType.getField("INSTANCE") + } catch (cause: ReflectiveOperationException) { + throw ForyJsonException( + "Unsupported Kotlin singleton ${rawType.name}: missing INSTANCE", + cause + ) + } + if ( + !Modifier.isPublic(instanceField.modifiers) || + !Modifier.isStatic(instanceField.modifiers) || + !Modifier.isFinal(instanceField.modifiers) || + instanceField.declaringClass != rawType || + instanceField.isSynthetic || + instanceField.type != rawType + ) { + unsupported(rawType, "missing exact singleton INSTANCE field") + } + val instance = + try { + instanceField.get(null) + } catch (cause: ReflectiveOperationException) { + throw ForyJsonException( + "Unsupported Kotlin singleton ${rawType.name}: inaccessible INSTANCE", + cause + ) + } + return JsonObjectModel.fixedInstance( + instance, + properties.map { it.name }.toTypedArray(), + properties.map { it.getter }.toTypedArray(), + properties.map { it.setter }.toTypedArray(), + properties.map { it.type }.toTypedArray(), + ) + } + + private fun classModel( + ownerType: TypeRef<*>, + rawType: Class<*>, + model: KmClass, + creators: List, + ): JsonObjectModel { + if (Modifier.isAbstract(rawType.modifiers) || rawType.isInterface) { + unsupported(rawType, "abstract models require @JsonSubTypes or a custom codec") + } + val creator = selectCreator(ownerType, rawType, model, creators) + val properties = properties(ownerType, model) + val declaredProperties = + properties.filter { it.declaringType == rawType }.associateBy { it.name } + val substitutions = KotlinMetadataTypes.substitutions(ownerType, model) + val parameters = creator.parameters + val names = Array(parameters.size) { parameters[it].name } + val parameterTypes = + Array>(parameters.size) { + KotlinMetadataTypes.resolve(parameters[it].type, rawType.classLoader, substitutions, false) + } + val parameterNullable = BooleanArray(parameters.size) { nullable(parameterTypes[it]) } + val defaultMaskBits = + IntArray(parameters.size) { if (parameters[it].declaresDefaultValue) it else -1 } + val accessors = arrayOfNulls(parameters.size) + if (creator.primary) { + for (index in parameters.indices) { + val property = declaredProperties[names[index]] + if (property != null && property.type == parameterTypes[index]) { + if (creators.isEmpty() && property.getter == null && !property.fieldReadable) { + unsupported(rawType, "primary property ${names[index]} is not publicly readable") + } + accessors[index] = property.getter + } else if (creators.isEmpty()) { + unsupported(rawType, "primary parameter ${names[index]} is not an exact property") + } + } + } + return JsonObjectModel( + creator.executable, + creator.invocation, + creator.defaultConstructor, + names, + accessors, + arrayOfNulls(parameters.size), + defaultMaskBits, + parameterNullable, + parameterTypes, + properties.map { it.name }.toTypedArray(), + properties.map { it.getter }.toTypedArray(), + properties.map { it.setter }.toTypedArray(), + properties.map { it.type }.toTypedArray(), + BooleanArray(properties.size) { properties[it].reconstructible }, + BooleanArray(properties.size) { properties[it].required }, + ) + } + + private fun selectCreator( + ownerType: TypeRef<*>, + rawType: Class<*>, + model: KmClass, + declarations: List, + ): CreatorMetadata { + if (declarations.isEmpty()) { + val primary = + model.constructors.singleOrNull { !it.isSecondary } + ?: unsupported(rawType, "missing unique primary constructor") + return constructorMetadata(rawType, primary, true, null) + } + val exact = ArrayList(1) + for (declaration in declarations) { + val executable = declaration.executable() + when (executable) { + is Constructor<*> -> { + if (executable.declaringClass != rawType) continue + val descriptor = constructorDescriptor(executable) + val source = model.constructors.singleOrNull { it.signature?.descriptor == descriptor } + if (source != null) { + exact += constructorMetadata(rawType, source, !source.isSecondary, executable) + } + } + is Method -> { + factoryMetadataOrNull(ownerType, rawType, model, executable)?.let { exact += it } + } + } + } + return exact.singleOrNull() + ?: unsupported( + rawType, + "effective @JsonCreator does not select one logical Kotlin declaration" + ) + } + + private fun constructorMetadata( + rawType: Class<*>, + source: KmConstructor, + primary: Boolean, + selected: Constructor<*>?, + ): CreatorMetadata { + val signature = + source.signature ?: unsupported(rawType, "selected constructor has no JVM signature") + val selectedConstructor = findConstructor(rawType, signature.descriptor) + val constructor = logicalConstructor(rawType, selectedConstructor, source.valueParameters.size) + val sourceAccessible = + source.visibility == Visibility.PUBLIC || source.visibility == Visibility.INTERNAL + if (!sourceAccessible && !isAccessibilityConstructor(selectedConstructor, constructor)) { + unsupported(rawType, "selected constructor is not source-public") + } + // A source-public constructor with direct value-class parameters is annotated on its public + // synthetic accessibility constructor. The metadata signature names that exact bridge; copied + // @JvmOverloads prefixes have no KmConstructor signature and never reach this comparison. + if (selected != null && selected != selectedConstructor) { + unsupported(rawType, "selected constructor is a compiler-derived overload") + } + if ( + constructor.isSynthetic || constructor.isVarArgs || constructor.typeParameters.isNotEmpty() + ) { + unsupported(rawType, "selected constructor is not an exact JVM declaration") + } + val invocation = + if (selectedConstructor.parameterCount != constructor.parameterCount) selectedConstructor + else if (Modifier.isPublic(constructor.modifiers)) constructor + else findInvocationConstructor(rawType, constructor) + val defaultConstructor = + if (source.valueParameters.any { it.declaresDefaultValue }) { + findDefaultConstructor(rawType, constructor) + } else { + null + } + return CreatorMetadata( + constructor, + invocation, + defaultConstructor, + source.valueParameters, + primary, + ) + } + + private fun factoryMetadataOrNull( + ownerType: TypeRef<*>, + rawType: Class<*>, + model: KmClass, + factory: Method, + ): CreatorMetadata? { + if (factory.declaringClass != rawType || factory.returnType != rawType) return null + val companionName = model.companionObject ?: return null + val companionType = + try { + Class.forName("${rawType.name}\$$companionName", false, rawType.classLoader) + } catch (_: ClassNotFoundException) { + return null + } + val companion = readClass(companionType) + val descriptor = methodDescriptor(factory) + val source = + companion.functions.singleOrNull { + it.signature?.name == factory.name && it.signature?.descriptor == descriptor + } ?: return null + validateFactory(rawType, factory, source) + if (source.valueParameters.any { it.declaresDefaultValue }) { + unsupported(rawType, "a selected static factory cannot declare compiler defaults") + } + val returnType = + KotlinMetadataTypes.resolve(source.returnType, companionType.classLoader, emptyMap(), false) + if (!exactFactoryOwner(ownerType, returnType)) { + unsupported(rawType, "selected static factory does not return its exact non-null owner") + } + return CreatorMetadata(factory, factory, null, source.valueParameters, false) + } + + private fun exactFactoryOwner(ownerType: TypeRef<*>, returnType: TypeRef<*>): Boolean { + if (nullable(returnType)) return false + if (ownerType.typeExtMeta == null) { + return ownerType.rawType.typeParameters.isEmpty() && ownerType.rawType == returnType.rawType + } + return returnType == KotlinMetadataTypes.withOccurrence(ownerType, false, false) + } + + private fun validateFactory(rawType: Class<*>, factory: Method, source: KmFunction) { + val modifiers = factory.modifiers + if ( + !Modifier.isPublic(modifiers) || + !Modifier.isStatic(modifiers) || + factory.isSynthetic || + factory.isBridge || + factory.isVarArgs || + factory.typeParameters.isNotEmpty() || + source.visibility != Visibility.PUBLIC && source.visibility != Visibility.INTERNAL || + source.receiverParameterType != null || + hasImplicitContext(source) || + source.isSuspend || + source.typeParameters.isNotEmpty() + ) { + unsupported(rawType, "selected factory is not an exact public @JvmStatic declaration") + } + } + + private fun properties(ownerType: TypeRef<*>, model: KmClass): List { + val candidates = LinkedHashMap>() + collectProperties( + PropertyOwner( + ownerType.rawType, + KotlinMetadataTypes.substitutions(ownerType, model), + ), + model, + candidates, + HashSet(), + HashSet(), + ) + return candidates.map { (name, declarations) -> + declarations.singleOrNull { candidate -> + declarations.all { it == candidate || overrides(it, candidate) } + } + ?: unsupported( + ownerType.rawType, + "ambiguous inherited property $name from " + + declarations.joinToString { it.declaringType.name }, + ) + } + } + + private fun collectProperties( + owner: PropertyOwner, + model: KmClass, + properties: LinkedHashMap>, + active: MutableSet, + visited: MutableSet, + ) { + if (!active.add(owner)) { + throw ForyJsonException("Recursive Kotlin class hierarchy at ${owner.rawType.name}") + } + try { + if (!visited.add(owner)) return + for (supertype in model.supertypes) { + val superType = + KotlinMetadataTypes.supertype( + supertype, + owner.rawType.classLoader, + owner.substitutions, + ) + if (superType.rawType == Any::class.java) continue + val superModel = readClassOrNull(superType.rawType) ?: continue + if (superModel.kind == ClassKind.CLASS || superModel.kind == ClassKind.INTERFACE) { + collectProperties( + PropertyOwner( + superType.rawType, + KotlinMetadataTypes.superSubstitutions(superModel, superType), + ), + superModel, + properties, + active, + visited, + ) + } + } + for (property in model.properties) { + if (property.isConst) continue + val candidate = propertyMetadata(owner.rawType, property, owner.substitutions) + properties.getOrPut(candidate.name) { ArrayList(1) }.add(candidate) + } + } finally { + active.remove(owner) + } + } + + private fun propertyMetadata( + declaringType: Class<*>, + property: KmProperty, + substitutions: Map>, + ): PropertyMetadata { + val type = + KotlinMetadataTypes.resolve( + property.returnType, + declaringType.classLoader, + substitutions, + false + ) + val instance = property.receiverParameterType == null && !hasImplicitContext(property) + val getter = if (instance) propertyMethod(declaringType, property.getterSignature) else null + val setter = + if (instance && property.isVar && !property.isDelegated) { + propertyMethod(declaringType, property.setterSignature) + } else { + null + } + val field = + if (instance && !property.isDelegated) { + publicField(declaringType, property) + } else { + null + } + val reconstructible = + property.fieldSignature != null && + (getter != null && setter != null || + property.isVar && field != null && !Modifier.isFinal(field.modifiers)) + val required = property.isLateinit + if (required && (!reconstructible || nullable(type))) { + unsupported(declaringType, "lateinit property ${property.name} is not an exact non-null var") + } + return PropertyMetadata( + property.name, + declaringType, + type, + getter, + setter, + field != null, + reconstructible, + required, + ) + } + + private fun propertyMethod( + declaringType: Class<*>, + signature: kotlin.metadata.jvm.JvmMethodSignature?, + ): Method? { + if (signature == null) return null + val method = + declaringType.declaredMethods.singleOrNull { + it.name == signature.name && methodDescriptor(it) == signature.descriptor + } ?: unsupported(declaringType, "method $signature was not found exactly") + val modifiers = method.modifiers + return if ( + Modifier.isPublic(modifiers) && + !Modifier.isStatic(modifiers) && + !method.isBridge && + !method.isSynthetic + ) + method + else null + } + + private fun publicField( + declaringType: Class<*>, + property: KmProperty, + ): Field? { + val signature = property.fieldSignature ?: return null + if (signature.name != property.name) return null + val field = + declaringType.declaredFields.singleOrNull { + it.name == signature.name && descriptor(it.type) == signature.descriptor + } ?: unsupported(declaringType, "field $signature was not found exactly") + val modifiers = field.modifiers + return if (Modifier.isPublic(modifiers) && !Modifier.isStatic(modifiers) && !field.isSynthetic) + field + else null + } + + private fun overrides(prior: PropertyMetadata, current: PropertyMetadata): Boolean { + if (prior.declaringType == current.declaringType) { + return prior.type == current.type && prior.getter == current.getter + } + if (!prior.declaringType.isAssignableFrom(current.declaringType)) return false + val priorGetter = prior.getter ?: return false + val currentGetter = current.getter ?: return false + return priorGetter.name == currentGetter.name && + priorGetter.parameterTypes.contentEquals(currentGetter.parameterTypes) + } + + private fun readClass(type: Class<*>): KmClass { + val metadata = type.getAnnotation(Metadata::class.java) ?: unsupported(type, "no metadata") + val classMetadata = + try { + KotlinClassMetadata.readStrict(metadata) + } catch (cause: IllegalArgumentException) { + throw ForyJsonException("Unsupported Kotlin metadata on ${type.name}", cause) + } + if (classMetadata !is KotlinClassMetadata.Class) { + unsupported(type, "metadata is not a class declaration") + } + val version = classMetadata.version + if (version.major != 2 || version.minor != 3) { + unsupported(type, "metadata ABI $version; expected 2.3") + } + return classMetadata.kmClass + } + + // Kotlin metadata 2.3 encodes contextParameters on functions and properties, but KmClass still + // exposes only its deprecated contextReceiverTypes slot. Keep that one class-level ABI read in + // this cold helper until Kotlin metadata removes the contextual-class format. + @OptIn(ExperimentalContextReceivers::class) + @Suppress("DEPRECATION") + internal fun hasImplicitContext(declaration: Any): Boolean = + when (declaration) { + is KmClass -> declaration.contextReceiverTypes.isNotEmpty() + is KmFunction -> declaration.contextParameters.isNotEmpty() + is KmProperty -> declaration.contextParameters.isNotEmpty() + else -> error("Unsupported Kotlin metadata declaration ${declaration::class.java.name}") + } + + private fun readClassOrNull(type: Class<*>): KmClass? = + if (type.getAnnotation(Metadata::class.java) == null) null else readClass(type) + + private fun findConstructor(rawType: Class<*>, descriptor: String): Constructor<*> = + rawType.declaredConstructors.singleOrNull { constructorDescriptor(it) == descriptor } + ?: unsupported(rawType, "constructor descriptor $descriptor was not found exactly") + + private fun logicalConstructor( + rawType: Class<*>, + selected: Constructor<*>, + logicalCount: Int, + ): Constructor<*> { + if (selected.parameterCount == logicalCount) return selected + val selectedTypes = selected.parameterTypes + if ( + !Modifier.isPublic(selected.modifiers) || + !selected.isSynthetic || + selectedTypes.size != logicalCount + 1 || + selectedTypes.last().name != "kotlin.jvm.internal.DefaultConstructorMarker" + ) { + unsupported(rawType, "selected JVM constructor has an invalid accessibility shape") + } + return rawType.declaredConstructors.singleOrNull { candidate -> + val candidateTypes = candidate.parameterTypes + candidateTypes.size == logicalCount && + candidateTypes.indices.all { candidateTypes[it] == selectedTypes[it] } + } ?: unsupported(rawType, "logical constructor was not found exactly") + } + + private fun isAccessibilityConstructor( + selected: Constructor<*>, + logical: Constructor<*>, + ): Boolean = + selected !== logical && + Modifier.isPublic(selected.modifiers) && + selected.isSynthetic && + !logical.isSynthetic && + selected.parameterTypes.size == logical.parameterTypes.size + 1 && + logical.parameterTypes.indices.all { + selected.parameterTypes[it] == logical.parameterTypes[it] + } && + selected.parameterTypes.last().name == "kotlin.jvm.internal.DefaultConstructorMarker" + + private fun findDefaultConstructor( + rawType: Class<*>, + constructor: Constructor<*> + ): Constructor<*> { + val parameters = constructor.parameterTypes + val maskCount = (parameters.size + 31) ushr 5 + return rawType.declaredConstructors.singleOrNull { candidate -> + val candidateTypes = candidate.parameterTypes + candidate.isSynthetic && + candidateTypes.size == parameters.size + maskCount + 1 && + parameters.indices.all { candidateTypes[it] == parameters[it] } && + (0 until maskCount).all { + candidateTypes[parameters.size + it] == Int::class.javaPrimitiveType + } && + candidateTypes.last().name == "kotlin.jvm.internal.DefaultConstructorMarker" + } ?: unsupported(rawType, "compiler-default constructor shape was not found exactly") + } + + private fun findInvocationConstructor( + rawType: Class<*>, + constructor: Constructor<*>, + ): Constructor<*> { + val parameters = constructor.parameterTypes + return rawType.declaredConstructors.singleOrNull { candidate -> + val candidateTypes = candidate.parameterTypes + Modifier.isPublic(candidate.modifiers) && + candidate.isSynthetic && + candidateTypes.size == parameters.size + 1 && + parameters.indices.all { candidateTypes[it] == parameters[it] } && + candidateTypes.last().name == "kotlin.jvm.internal.DefaultConstructorMarker" + } ?: unsupported(rawType, "public compiler accessibility constructor was not found exactly") + } + + private fun constructorDescriptor(constructor: Constructor<*>): String = + descriptor(constructor.parameterTypes, Void.TYPE) + + private fun methodDescriptor(method: Method): String = + descriptor(method.parameterTypes, method.returnType) + + private fun descriptor(parameters: Array>, result: Class<*>): String = buildString { + append('(') + parameters.forEach { append(descriptor(it)) } + append(')') + append(descriptor(result)) + } + + private fun descriptor(type: Class<*>): String = + when { + type.isArray -> type.name.replace('.', '/') + !type.isPrimitive -> "L${type.name.replace('.', '/')};" + type == Void.TYPE -> "V" + type == java.lang.Boolean.TYPE -> "Z" + type == java.lang.Byte.TYPE -> "B" + type == java.lang.Short.TYPE -> "S" + type == java.lang.Integer.TYPE -> "I" + type == java.lang.Long.TYPE -> "J" + type == java.lang.Float.TYPE -> "F" + type == java.lang.Double.TYPE -> "D" + type == java.lang.Character.TYPE -> "C" + else -> error("Unsupported primitive carrier $type") + } + + private fun nullable(type: TypeRef<*>): Boolean = + type.typeExtMeta?.nullable() + ?: throw ForyJsonException("Kotlin JSON occurrence has no nullability: ${type.type}") + + private fun unsupported(type: Class<*>, reason: String): Nothing = + throw ForyJsonException("Unsupported Kotlin JSON model ${type.name}: $reason") + + private data class CreatorMetadata( + val executable: Executable, + val invocation: Executable, + val defaultConstructor: Constructor<*>?, + val parameters: List, + val primary: Boolean, + ) + + private data class PropertyMetadata( + val name: String, + val declaringType: Class<*>, + val type: TypeRef<*>, + val getter: Method?, + val setter: Method?, + val fieldReadable: Boolean, + val reconstructible: Boolean, + val required: Boolean, + ) + + private data class PropertyOwner( + val rawType: Class<*>, + val substitutions: Map>, + ) +} + +/** The single owner of strict Kotlin metadata type substitution and structural JSON tokens. */ +internal object KotlinMetadataTypes { + fun substitutions(ownerType: TypeRef<*>, model: KmClass): Map> { + if (model.typeParameters.isEmpty()) return emptyMap() + val arguments = ownerType.typeArguments + if (arguments.isEmpty()) return emptyMap() + if (arguments.size != model.typeParameters.size) { + throw ForyJsonException( + "Kotlin generic model ${ownerType.type} requires exact type arguments" + ) + } + return model.typeParameters.indices.associate { model.typeParameters[it].id to arguments[it] } + } + + fun supertype( + type: KmType, + loader: ClassLoader?, + substitutions: Map>, + ): SupertypeMetadata { + if (type.flexibleTypeUpperBound != null) { + throw ForyJsonException("Kotlin platform supertypes require an exact custom JSON codec") + } + val classifier = type.classifier + if (classifier !is KmClassifier.Class) { + throw ForyJsonException("Unsupported Kotlin JSON supertype classifier $classifier") + } + val rawType = classFor(classifier.name, loader) + val arguments = ArrayList?>(type.arguments.size) + for (projection in type.arguments) { + arguments += + if (dependsOnMissing(projection, substitutions)) null + else resolveProjection(projection, loader, substitutions) + } + return SupertypeMetadata(rawType, arguments) + } + + fun superSubstitutions( + model: KmClass, + supertype: SupertypeMetadata, + ): Map> { + if (model.typeParameters.size != supertype.arguments.size) { + throw ForyJsonException( + "Kotlin generic supertype ${supertype.rawType.name} requires exact type arguments", + ) + } + val result = LinkedHashMap>(model.typeParameters.size) + for (index in model.typeParameters.indices) { + val argument = supertype.arguments[index] ?: continue + result[model.typeParameters[index].id] = argument + } + return result + } + + fun resolve( + type: KmType, + loader: ClassLoader?, + substitutions: Map>, + typeArgument: Boolean, + ): TypeRef<*> { + if (type.flexibleTypeUpperBound != null) { + throw ForyJsonException("Kotlin platform types require an exact custom JSON codec") + } + val classifier = type.classifier + if (classifier is KmClassifier.TypeParameter) { + val substituted = + substitutions[classifier.id] + ?: throw ForyJsonException("Unresolved Kotlin JSON type parameter ${classifier.id}") + val nullable = + if (type.isDefinitelyNonNull) false else type.isNullable || occurrenceNullable(substituted) + return withNullability(substituted, nullable) + } + if (classifier !is KmClassifier.Class) { + throw ForyJsonException("Unsupported Kotlin JSON type classifier $classifier") + } + val nullable = type.isNullable && !type.isDefinitelyNonNull + val logicalClass = classFor(classifier.name, loader) + val arguments = type.arguments.map { resolveProjection(it, loader, substitutions) } + val component = if (classifier.name == "kotlin/Array") arguments.singleOrNull() else null + val semanticId = semanticTypeId(classifier.name) + val unsignedCarrier = unsignedCarrier(semanticId) + val rawType = + if (component != null) { + ReflectArray.newInstance(box(component.rawType), 0).javaClass + } else if (!typeArgument && (!nullable || isUnsignedArray(semanticId))) { + // A direct nullable unsigned-array member still has a nullable primitive-array JVM + // carrier. Only generic/container occurrences use the boxed Kotlin wrapper class. + unsignedCarrier ?: logicalClass + } else { + box(logicalClass) + } + val metadata = TypeExtMeta.of(semanticId, nullable, false, false, false) + return when { + component != null -> TypeRef.of(rawType, metadata, null, component) + arguments.isEmpty() -> plainTypeRef(rawType, metadata) + else -> TypeRef.ofDeclaredTypeArguments(rawType, metadata, arguments, null) + } + } + + private fun resolveProjection( + projection: KmTypeProjection, + loader: ClassLoader?, + substitutions: Map>, + ): TypeRef<*> { + val type = + projection.type ?: throw ForyJsonException("Star-projected Kotlin JSON types are unsupported") + if (projection.variance == KmVariance.IN) { + throw ForyJsonException("Contravariant Kotlin JSON types are unsupported") + } + val resolved = resolve(type, loader, substitutions, true) + return if (projection.variance == KmVariance.OUT) withCovariance(resolved) else resolved + } + + private fun dependsOnMissing( + projection: KmTypeProjection, + substitutions: Map>, + ): Boolean { + if (projection.variance == KmVariance.IN) return false + val type = projection.type ?: return false + val classifier = type.classifier + if (classifier is KmClassifier.TypeParameter && classifier.id !in substitutions) return true + return type.arguments.any { dependsOnMissing(it, substitutions) } + } + + private fun withNullability(type: TypeRef<*>, nullable: Boolean): TypeRef<*> { + val current = + type.typeExtMeta + ?: throw ForyJsonException("Platform-typed Kotlin JSON occurrence $type is unsupported") + return withOccurrence(type, nullable, current.covariant()) + } + + private fun withCovariance(type: TypeRef<*>): TypeRef<*> { + val current = + type.typeExtMeta + ?: throw ForyJsonException("Platform-typed Kotlin JSON occurrence $type is unsupported") + return withOccurrence(type, current.nullable(), true) + } + + fun withOccurrence(type: TypeRef<*>, nullable: Boolean, covariant: Boolean): TypeRef<*> { + val current = + type.typeExtMeta + ?: throw ForyJsonException("Platform-typed Kotlin JSON occurrence $type is unsupported") + if (current.nullable() == nullable && current.covariant() == covariant) return type + val metadata = + TypeExtMeta.of( + current.typeId(), + nullable, + current.trackingRef(), + current.nullableWrapper(), + covariant, + ) + val component = if (type.isArray) type.componentType else null + return TypeRef.ofSemanticTypeArguments(type.type, metadata, type.typeArguments, component) + } + + private fun occurrenceNullable(type: TypeRef<*>): Boolean = + type.typeExtMeta?.nullable() + ?: throw ForyJsonException("Kotlin JSON occurrence has no nullability: ${type.type}") + + @Suppress("UNCHECKED_CAST") + private fun plainTypeRef(type: Class<*>, metadata: TypeExtMeta): TypeRef<*> = + TypeRef.of(type as Class, metadata) + + private fun classFor(name: String, loader: ClassLoader?): Class<*> = + try { + when (name) { + "kotlin/Boolean" -> java.lang.Boolean.TYPE + "kotlin/Byte" -> java.lang.Byte.TYPE + "kotlin/Short" -> java.lang.Short.TYPE + "kotlin/Int" -> java.lang.Integer.TYPE + "kotlin/Long" -> java.lang.Long.TYPE + "kotlin/Float" -> java.lang.Float.TYPE + "kotlin/Double" -> java.lang.Double.TYPE + "kotlin/Char" -> java.lang.Character.TYPE + "kotlin/BooleanArray" -> BooleanArray::class.java + "kotlin/ByteArray" -> ByteArray::class.java + "kotlin/ShortArray" -> ShortArray::class.java + "kotlin/IntArray" -> IntArray::class.java + "kotlin/LongArray" -> LongArray::class.java + "kotlin/FloatArray" -> FloatArray::class.java + "kotlin/DoubleArray" -> DoubleArray::class.java + "kotlin/CharArray" -> CharArray::class.java + "kotlin/String" -> String::class.java + "kotlin/Any" -> Any::class.java + "kotlin/Unit" -> Unit::class.java + "kotlin/Nothing" -> Void::class.java + "kotlin/Number" -> Number::class.java + "kotlin/CharSequence" -> CharSequence::class.java + "kotlin/Comparable" -> Comparable::class.java + "kotlin/Throwable" -> Throwable::class.java + "kotlin/Enum" -> Enum::class.java + "kotlin/collections/Iterable", + "kotlin/collections/MutableIterable" -> Iterable::class.java + "kotlin/collections/Collection", + "kotlin/collections/MutableCollection" -> Collection::class.java + "kotlin/collections/List", + "kotlin/collections/MutableList" -> List::class.java + "kotlin/collections/Set", + "kotlin/collections/MutableSet" -> Set::class.java + "kotlin/collections/Map", + "kotlin/collections/MutableMap" -> Map::class.java + "kotlin/collections/Iterator", + "kotlin/collections/MutableIterator" -> Iterator::class.java + "kotlin/collections/ListIterator", + "kotlin/collections/MutableListIterator" -> ListIterator::class.java + "kotlin/collections/Map.Entry", + "kotlin/collections/MutableMap.MutableEntry" -> Map.Entry::class.java + "kotlin/Array" -> Array::class.java + else -> Class.forName(binaryName(name), false, loader) + } + } catch (cause: ClassNotFoundException) { + throw ForyJsonException("Kotlin JSON metadata type $name is not available", cause) + } + + private fun binaryName(metadataName: String): String { + val packageEnd = metadataName.lastIndexOf('/') + val packageName = + if (packageEnd < 0) "" else metadataName.substring(0, packageEnd).replace('/', '.') + "." + return packageName + metadataName.substring(packageEnd + 1).replace('.', '$') + } + + private fun box(type: Class<*>): Class<*> = + when (type) { + java.lang.Boolean.TYPE -> Boolean::class.javaObjectType + java.lang.Byte.TYPE -> Byte::class.javaObjectType + java.lang.Short.TYPE -> Short::class.javaObjectType + java.lang.Integer.TYPE -> Int::class.javaObjectType + java.lang.Long.TYPE -> Long::class.javaObjectType + java.lang.Float.TYPE -> Float::class.javaObjectType + java.lang.Double.TYPE -> Double::class.javaObjectType + java.lang.Character.TYPE -> Char::class.javaObjectType + java.lang.Void.TYPE -> Void::class.java + else -> type + } + + private fun semanticTypeId(name: String): Int = + when (name) { + "kotlin/UByte" -> Types.UINT8 + "kotlin/UShort" -> Types.UINT16 + "kotlin/UInt" -> Types.UINT32 + "kotlin/ULong" -> Types.UINT64 + "kotlin/UByteArray" -> Types.UINT8_ARRAY + "kotlin/UShortArray" -> Types.UINT16_ARRAY + "kotlin/UIntArray" -> Types.UINT32_ARRAY + "kotlin/ULongArray" -> Types.UINT64_ARRAY + else -> 0 + } + + private fun unsignedCarrier(typeId: Int): Class<*>? = + when (typeId) { + Types.UINT8 -> java.lang.Byte.TYPE + Types.UINT16 -> java.lang.Short.TYPE + Types.UINT32 -> java.lang.Integer.TYPE + Types.UINT64 -> java.lang.Long.TYPE + Types.UINT8_ARRAY -> ByteArray::class.java + Types.UINT16_ARRAY -> ShortArray::class.java + Types.UINT32_ARRAY -> IntArray::class.java + Types.UINT64_ARRAY -> LongArray::class.java + else -> null + } + + private fun isUnsignedArray(typeId: Int): Boolean = + typeId == Types.UINT8_ARRAY || + typeId == Types.UINT16_ARRAY || + typeId == Types.UINT32_ARRAY || + typeId == Types.UINT64_ARRAY + + class SupertypeMetadata( + val rawType: Class<*>, + val arguments: List?>, + ) +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinProductCodecs.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinProductCodecs.kt new file mode 100644 index 0000000000..45202cf3ef --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinProductCodecs.kt @@ -0,0 +1,107 @@ +/* + * 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.fory.json.kotlin + +import java.lang.reflect.Constructor +import java.lang.reflect.Method +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.codec.JsonObjectModel +import org.apache.fory.json.codec.JsonValueCodec +import org.apache.fory.json.resolver.JsonTypeResolver +import org.apache.fory.reflect.TypeRef + +/** Exact Kotlin standard-library product models owned by the standard object codec. */ +internal object KotlinProductCodecs { + private val pairConstructor = Pair::class.java.getConstructor(Any::class.java, Any::class.java) + private val pairFirst = Pair::class.java.getMethod("getFirst") + private val pairSecond = Pair::class.java.getMethod("getSecond") + + private val tripleConstructor = + Triple::class.java.getConstructor(Any::class.java, Any::class.java, Any::class.java) + private val tripleFirst = Triple::class.java.getMethod("getFirst") + private val tripleSecond = Triple::class.java.getMethod("getSecond") + private val tripleThird = Triple::class.java.getMethod("getThird") + + fun create(type: TypeRef<*>, resolver: JsonTypeResolver): JsonValueCodec<*>? = + when (type.rawType) { + Pair::class.java -> pair(type, resolver) + Triple::class.java -> triple(type, resolver) + else -> null + } + + private fun pair(type: TypeRef<*>, resolver: JsonTypeResolver): JsonValueCodec<*> { + val arguments = requireArguments(type, 2) + return resolver.createObjectCodec( + type, + model( + pairConstructor, + arrayOf("first", "second"), + arrayOf(pairFirst, pairSecond), + arguments, + ), + ) + } + + private fun triple(type: TypeRef<*>, resolver: JsonTypeResolver): JsonValueCodec<*> { + val arguments = requireArguments(type, 3) + return resolver.createObjectCodec( + type, + model( + tripleConstructor, + arrayOf("first", "second", "third"), + arrayOf(tripleFirst, tripleSecond, tripleThird), + arguments, + ), + ) + } + + private fun model( + constructor: Constructor<*>, + names: Array, + accessors: Array, + types: Array>, + ): JsonObjectModel = + JsonObjectModel( + constructor, + null, + names, + accessors, + arrayOfNulls(names.size), + IntArray(names.size) { -1 }, + BooleanArray(names.size) { nullable(types[it]) }, + types, + names, + accessors, + arrayOfNulls(names.size), + types, + ) + + private fun requireArguments(type: TypeRef<*>, count: Int): Array> { + val arguments = type.typeArguments + if (arguments.size != count) { + throw ForyJsonException( + "Kotlin JSON product ${type.type} requires $count exact type arguments" + ) + } + return arguments.toTypedArray() + } + + private fun nullable(type: TypeRef<*>): Boolean = type.typeExtMeta?.nullable() == true +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinProgressionCodecs.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinProgressionCodecs.kt new file mode 100644 index 0000000000..f8839bcdbb --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinProgressionCodecs.kt @@ -0,0 +1,438 @@ +/* + * 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.fory.json.kotlin + +import kotlin.ranges.CharProgression +import kotlin.ranges.IntProgression +import kotlin.ranges.LongProgression +import kotlin.ranges.UIntProgression +import kotlin.ranges.ULongProgression +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.codec.JsonValueCodec +import org.apache.fory.json.meta.JsonFieldNameHash +import org.apache.fory.json.reader.JsonReader +import org.apache.fory.json.reader.Latin1JsonReader +import org.apache.fory.json.reader.Utf16JsonReader +import org.apache.fory.json.reader.Utf8JsonReader +import org.apache.fory.json.writer.JsonWriter +import org.apache.fory.json.writer.StringJsonWriter +import org.apache.fory.json.writer.Utf8JsonWriter +import org.apache.fory.reflect.TypeRef +import org.apache.fory.serializer.GraphMemoryEstimates + +/** + * Primitive progression codecs which validate the canonical stored last element before allocation. + */ +@OptIn(ExperimentalUnsignedTypes::class) +internal object KotlinProgressionCodecs { + private const val FIRST = 0 + private const val LAST = 1 + private const val STEP = 2 + private const val ALL_FIELDS = 0b111 + private val firstHash = JsonFieldNameHash.hash("first") + private val lastHash = JsonFieldNameHash.hash("last") + private val stepHash = JsonFieldNameHash.hash("step") + + fun create(type: TypeRef<*>): JsonValueCodec<*>? = + when (type.rawType) { + CharProgression::class.java -> CharProgressionCodec + IntProgression::class.java -> IntProgressionCodec + LongProgression::class.java -> LongProgressionCodec + UIntProgression::class.java -> UIntProgressionCodec + ULongProgression::class.java -> ULongProgressionCodec + else -> null + } + + private object CharProgressionCodec : JsonValueCodec { + private val ownerBytes = GraphMemoryEstimates.shallowObjectBytes(CharProgression::class.java) + + override fun writeString(writer: StringJsonWriter, value: CharProgression?) = + write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: CharProgression?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): CharProgression? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): CharProgression? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): CharProgression? = read(reader) + + private fun write(writer: JsonWriter, value: CharProgression?) { + if (value == null) { + writer.writeNull() + return + } + writer.writeObjectStart() + writer.writeFieldName("first") + writer.writeChar(value.first) + writer.writeComma(1) + writer.writeFieldName("last") + writer.writeChar(value.last) + writer.writeComma(2) + writer.writeFieldName("step") + writer.writeInt(value.step) + writer.writeObjectEnd() + } + + private fun read(reader: JsonReader): CharProgression? { + if (reader.tryReadNull()) return null + reader.enterDepth() + reader.expectNextToken('{') + var first = 0.toChar() + var last = 0.toChar() + var step = 0 + var seen = 0 + if (!reader.consumeNextToken('}')) { + do { + val index = readField(reader, seen) + seen = seen or (1 shl index) + when (index) { + FIRST -> first = reader.readChar() + LAST -> last = reader.readChar() + STEP -> step = reader.readInt() + } + } while (reader.consumeNextToken(',')) + reader.expectNextToken('}') + } + requireFields(seen) + validateStep(step) + if (normalizedLast(first.code, last.code, step) != last.code) invalidLast() + reader.exitDepth() + reader.reserveGraphMemory(ownerBytes) + return CharProgression.fromClosedRange(first, last, step) + } + } + + private object IntProgressionCodec : JsonValueCodec { + private val ownerBytes = GraphMemoryEstimates.shallowObjectBytes(IntProgression::class.java) + + override fun writeString(writer: StringJsonWriter, value: IntProgression?) = + write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: IntProgression?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): IntProgression? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): IntProgression? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): IntProgression? = read(reader) + + private fun write(writer: JsonWriter, value: IntProgression?) { + if (value == null) { + writer.writeNull() + return + } + writeHeader(writer) + writer.writeInt(value.first) + writeLast(writer) + writer.writeInt(value.last) + writeStep(writer) + writer.writeInt(value.step) + writer.writeObjectEnd() + } + + private fun read(reader: JsonReader): IntProgression? { + if (reader.tryReadNull()) return null + reader.enterDepth() + reader.expectNextToken('{') + var first = 0 + var last = 0 + var step = 0 + var seen = 0 + if (!reader.consumeNextToken('}')) { + do { + val index = readField(reader, seen) + seen = seen or (1 shl index) + when (index) { + FIRST -> first = reader.readInt() + LAST -> last = reader.readInt() + STEP -> step = reader.readInt() + } + } while (reader.consumeNextToken(',')) + reader.expectNextToken('}') + } + requireFields(seen) + validateStep(step) + if (normalizedLast(first, last, step) != last) invalidLast() + reader.exitDepth() + reader.reserveGraphMemory(ownerBytes) + return IntProgression.fromClosedRange(first, last, step) + } + } + + private object LongProgressionCodec : JsonValueCodec { + private val ownerBytes = GraphMemoryEstimates.shallowObjectBytes(LongProgression::class.java) + + override fun writeString(writer: StringJsonWriter, value: LongProgression?) = + write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: LongProgression?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): LongProgression? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): LongProgression? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): LongProgression? = read(reader) + + private fun write(writer: JsonWriter, value: LongProgression?) { + if (value == null) { + writer.writeNull() + return + } + writeHeader(writer) + writer.writeLong(value.first) + writeLast(writer) + writer.writeLong(value.last) + writeStep(writer) + writer.writeLong(value.step) + writer.writeObjectEnd() + } + + private fun read(reader: JsonReader): LongProgression? { + if (reader.tryReadNull()) return null + reader.enterDepth() + reader.expectNextToken('{') + var first = 0L + var last = 0L + var step = 0L + var seen = 0 + if (!reader.consumeNextToken('}')) { + do { + val index = readField(reader, seen) + seen = seen or (1 shl index) + when (index) { + FIRST -> first = reader.readLong() + LAST -> last = reader.readLong() + STEP -> step = reader.readLong() + } + } while (reader.consumeNextToken(',')) + reader.expectNextToken('}') + } + requireFields(seen) + validateStep(step) + if (normalizedLast(first, last, step) != last) invalidLast() + reader.exitDepth() + reader.reserveGraphMemory(ownerBytes) + return LongProgression.fromClosedRange(first, last, step) + } + } + + private object UIntProgressionCodec : JsonValueCodec { + private val ownerBytes = GraphMemoryEstimates.shallowObjectBytes(UIntProgression::class.java) + + override fun writeString(writer: StringJsonWriter, value: UIntProgression?) = + write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: UIntProgression?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): UIntProgression? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): UIntProgression? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): UIntProgression? = read(reader) + + private fun write(writer: JsonWriter, value: UIntProgression?) { + if (value == null) { + writer.writeNull() + return + } + writeHeader(writer) + writer.writeUnsignedInt(value.first.toInt()) + writeLast(writer) + writer.writeUnsignedInt(value.last.toInt()) + writeStep(writer) + writer.writeInt(value.step) + writer.writeObjectEnd() + } + + private fun read(reader: JsonReader): UIntProgression? { + if (reader.tryReadNull()) return null + reader.enterDepth() + reader.expectNextToken('{') + var first = 0u + var last = 0u + var step = 0 + var seen = 0 + if (!reader.consumeNextToken('}')) { + do { + val index = readField(reader, seen) + seen = seen or (1 shl index) + when (index) { + FIRST -> first = reader.readUnsignedInt().toUInt() + LAST -> last = reader.readUnsignedInt().toUInt() + STEP -> step = reader.readInt() + } + } while (reader.consumeNextToken(',')) + reader.expectNextToken('}') + } + requireFields(seen) + validateStep(step) + if (normalizedLast(first, last, step) != last) invalidLast() + reader.exitDepth() + reader.reserveGraphMemory(ownerBytes) + return UIntProgression.fromClosedRange(first, last, step) + } + } + + private object ULongProgressionCodec : JsonValueCodec { + private val ownerBytes = GraphMemoryEstimates.shallowObjectBytes(ULongProgression::class.java) + + override fun writeString(writer: StringJsonWriter, value: ULongProgression?) = + write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: ULongProgression?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): ULongProgression? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): ULongProgression? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): ULongProgression? = read(reader) + + private fun write(writer: JsonWriter, value: ULongProgression?) { + if (value == null) { + writer.writeNull() + return + } + writeHeader(writer) + writer.writeUnsignedLong(value.first.toLong()) + writeLast(writer) + writer.writeUnsignedLong(value.last.toLong()) + writeStep(writer) + writer.writeLong(value.step) + writer.writeObjectEnd() + } + + private fun read(reader: JsonReader): ULongProgression? { + if (reader.tryReadNull()) return null + reader.enterDepth() + reader.expectNextToken('{') + var first = 0uL + var last = 0uL + var step = 0L + var seen = 0 + if (!reader.consumeNextToken('}')) { + do { + val index = readField(reader, seen) + seen = seen or (1 shl index) + when (index) { + FIRST -> first = reader.readUnsignedLong().toULong() + LAST -> last = reader.readUnsignedLong().toULong() + STEP -> step = reader.readLong() + } + } while (reader.consumeNextToken(',')) + reader.expectNextToken('}') + } + requireFields(seen) + validateStep(step) + if (normalizedLast(first, last, step) != last) invalidLast() + reader.exitDepth() + reader.reserveGraphMemory(ownerBytes) + return ULongProgression.fromClosedRange(first, last, step) + } + } + + private fun readField(reader: JsonReader, seen: Int): Int { + val hash = reader.readFieldNameHash() + val index = + when (hash) { + firstHash -> FIRST + lastHash -> LAST + stepHash -> STEP + else -> unknownField() + } + if (seen and (1 shl index) != 0) duplicateField() + reader.expectNextToken(':') + return index + } + + private fun writeHeader(writer: JsonWriter) { + writer.writeObjectStart() + writer.writeFieldName("first") + } + + private fun writeLast(writer: JsonWriter) { + writer.writeComma(1) + writer.writeFieldName("last") + } + + private fun writeStep(writer: JsonWriter) { + writer.writeComma(2) + writer.writeFieldName("step") + } + + private fun requireFields(seen: Int) { + if (seen != ALL_FIELDS) missingField() + } + + private fun validateStep(step: Int) { + if (step == 0 || step == Int.MIN_VALUE) invalidStep() + } + + private fun validateStep(step: Long) { + if (step == 0L || step == Long.MIN_VALUE) invalidStep() + } + + private fun normalizedLast(first: Int, last: Int, step: Int): Int = + if (step > 0) last - difference(last, first, step) else last + difference(first, last, -step) + + private fun normalizedLast(first: Long, last: Long, step: Long): Long = + if (step > 0) last - difference(last, first, step) else last + difference(first, last, -step) + + private fun normalizedLast(first: UInt, last: UInt, step: Int): UInt = + if (step > 0) last - difference(last, first, step.toUInt()) + else last + difference(first, last, (-step).toUInt()) + + private fun normalizedLast(first: ULong, last: ULong, step: Long): ULong = + if (step > 0) last - difference(last, first, step.toULong()) + else last + difference(first, last, (-step).toULong()) + + private fun difference(a: Int, b: Int, divisor: Int): Int = + Math.floorMod(Math.floorMod(a, divisor) - Math.floorMod(b, divisor), divisor) + + private fun difference(a: Long, b: Long, divisor: Long): Long = + Math.floorMod(Math.floorMod(a, divisor) - Math.floorMod(b, divisor), divisor) + + private fun difference(a: UInt, b: UInt, divisor: UInt): UInt { + val left = a % divisor + val right = b % divisor + return if (left >= right) left - right else left - right + divisor + } + + private fun difference(a: ULong, b: ULong, divisor: ULong): ULong { + val left = a % divisor + val right = b % divisor + return if (left >= right) left - right else left - right + divisor + } + + private fun unknownField(): Nothing = + throw ForyJsonException("Unknown Kotlin progression JSON field") + + private fun duplicateField(): Nothing = + throw ForyJsonException("Duplicate Kotlin progression JSON field") + + private fun missingField(): Nothing = + throw ForyJsonException("Kotlin progression JSON requires first, last, and step") + + private fun invalidStep(): Nothing = + throw ForyJsonException("Kotlin progression step must be non-zero and not the minimum value") + + private fun invalidLast(): Nothing = + throw ForyJsonException("Kotlin progression last must be normalized for first and step") +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinRangeCodecs.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinRangeCodecs.kt new file mode 100644 index 0000000000..d3b3c0321a --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinRangeCodecs.kt @@ -0,0 +1,349 @@ +/* + * 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.fory.json.kotlin + +import kotlin.ranges.CharRange +import kotlin.ranges.IntRange +import kotlin.ranges.LongRange +import kotlin.ranges.UIntRange +import kotlin.ranges.ULongRange +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.codec.JsonValueCodec +import org.apache.fory.json.meta.JsonFieldNameHash +import org.apache.fory.json.reader.JsonReader +import org.apache.fory.json.reader.Latin1JsonReader +import org.apache.fory.json.reader.Utf16JsonReader +import org.apache.fory.json.reader.Utf8JsonReader +import org.apache.fory.json.writer.JsonWriter +import org.apache.fory.json.writer.StringJsonWriter +import org.apache.fory.json.writer.Utf8JsonWriter +import org.apache.fory.reflect.TypeRef +import org.apache.fory.serializer.GraphMemoryEstimates + +/** + * Fixed primitive endpoint codecs for Kotlin's concrete range types. + * + * ClosedRange bridge getters return boxed Comparable values while these constructors and their + * inherited progression storage use primitives. Direct scalar access keeps one unambiguous schema + * and avoids boxing the endpoints. + */ +@OptIn(ExperimentalUnsignedTypes::class) +internal object KotlinRangeCodecs { + private const val START = 0 + private const val END_INCLUSIVE = 1 + private const val ALL_FIELDS = 0b11 + private val startHash = JsonFieldNameHash.hash("start") + private val endInclusiveHash = JsonFieldNameHash.hash("endInclusive") + + fun create(type: TypeRef<*>): JsonValueCodec<*>? = + when (type.rawType) { + CharRange::class.java -> CharRangeCodec + IntRange::class.java -> IntRangeCodec + LongRange::class.java -> LongRangeCodec + UIntRange::class.java -> UIntRangeCodec + ULongRange::class.java -> ULongRangeCodec + else -> null + } + + private object CharRangeCodec : JsonValueCodec { + private val ownerBytes = GraphMemoryEstimates.shallowObjectBytes(CharRange::class.java) + + override fun writeString(writer: StringJsonWriter, value: CharRange?) = write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: CharRange?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): CharRange? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): CharRange? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): CharRange? = read(reader) + + private fun write(writer: JsonWriter, value: CharRange?) { + if (value == null) { + writer.writeNull() + return + } + writeHeader(writer) + writer.writeChar(value.first) + writeEndInclusive(writer) + writer.writeChar(value.last) + writer.writeObjectEnd() + } + + private fun read(reader: JsonReader): CharRange? { + if (reader.tryReadNull()) return null + reader.enterDepth() + reader.expectNextToken('{') + var start = 0.toChar() + var endInclusive = 0.toChar() + var seen = 0 + if (!reader.consumeNextToken('}')) { + do { + val index = readField(reader, seen) + seen = seen or (1 shl index) + when (index) { + START -> start = reader.readChar() + END_INCLUSIVE -> endInclusive = reader.readChar() + } + } while (reader.consumeNextToken(',')) + reader.expectNextToken('}') + } + requireFields(seen) + reader.exitDepth() + reader.reserveGraphMemory(ownerBytes) + return CharRange(start, endInclusive) + } + } + + private object IntRangeCodec : JsonValueCodec { + private val ownerBytes = GraphMemoryEstimates.shallowObjectBytes(IntRange::class.java) + + override fun writeString(writer: StringJsonWriter, value: IntRange?) = write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: IntRange?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): IntRange? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): IntRange? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): IntRange? = read(reader) + + private fun write(writer: JsonWriter, value: IntRange?) { + if (value == null) { + writer.writeNull() + return + } + writeHeader(writer) + writer.writeInt(value.first) + writeEndInclusive(writer) + writer.writeInt(value.last) + writer.writeObjectEnd() + } + + private fun read(reader: JsonReader): IntRange? { + if (reader.tryReadNull()) return null + reader.enterDepth() + reader.expectNextToken('{') + var start = 0 + var endInclusive = 0 + var seen = 0 + if (!reader.consumeNextToken('}')) { + do { + val index = readField(reader, seen) + seen = seen or (1 shl index) + when (index) { + START -> start = reader.readInt() + END_INCLUSIVE -> endInclusive = reader.readInt() + } + } while (reader.consumeNextToken(',')) + reader.expectNextToken('}') + } + requireFields(seen) + reader.exitDepth() + reader.reserveGraphMemory(ownerBytes) + return IntRange(start, endInclusive) + } + } + + private object LongRangeCodec : JsonValueCodec { + private val ownerBytes = GraphMemoryEstimates.shallowObjectBytes(LongRange::class.java) + + override fun writeString(writer: StringJsonWriter, value: LongRange?) = write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: LongRange?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): LongRange? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): LongRange? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): LongRange? = read(reader) + + private fun write(writer: JsonWriter, value: LongRange?) { + if (value == null) { + writer.writeNull() + return + } + writeHeader(writer) + writer.writeLong(value.first) + writeEndInclusive(writer) + writer.writeLong(value.last) + writer.writeObjectEnd() + } + + private fun read(reader: JsonReader): LongRange? { + if (reader.tryReadNull()) return null + reader.enterDepth() + reader.expectNextToken('{') + var start = 0L + var endInclusive = 0L + var seen = 0 + if (!reader.consumeNextToken('}')) { + do { + val index = readField(reader, seen) + seen = seen or (1 shl index) + when (index) { + START -> start = reader.readLong() + END_INCLUSIVE -> endInclusive = reader.readLong() + } + } while (reader.consumeNextToken(',')) + reader.expectNextToken('}') + } + requireFields(seen) + reader.exitDepth() + reader.reserveGraphMemory(ownerBytes) + return LongRange(start, endInclusive) + } + } + + private object UIntRangeCodec : JsonValueCodec { + private val ownerBytes = GraphMemoryEstimates.shallowObjectBytes(UIntRange::class.java) + + override fun writeString(writer: StringJsonWriter, value: UIntRange?) = write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: UIntRange?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): UIntRange? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): UIntRange? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): UIntRange? = read(reader) + + private fun write(writer: JsonWriter, value: UIntRange?) { + if (value == null) { + writer.writeNull() + return + } + writeHeader(writer) + writer.writeUnsignedInt(value.first.toInt()) + writeEndInclusive(writer) + writer.writeUnsignedInt(value.last.toInt()) + writer.writeObjectEnd() + } + + private fun read(reader: JsonReader): UIntRange? { + if (reader.tryReadNull()) return null + reader.enterDepth() + reader.expectNextToken('{') + var start = 0u + var endInclusive = 0u + var seen = 0 + if (!reader.consumeNextToken('}')) { + do { + val index = readField(reader, seen) + seen = seen or (1 shl index) + when (index) { + START -> start = reader.readUnsignedInt().toUInt() + END_INCLUSIVE -> endInclusive = reader.readUnsignedInt().toUInt() + } + } while (reader.consumeNextToken(',')) + reader.expectNextToken('}') + } + requireFields(seen) + reader.exitDepth() + reader.reserveGraphMemory(ownerBytes) + return UIntRange(start, endInclusive) + } + } + + private object ULongRangeCodec : JsonValueCodec { + private val ownerBytes = GraphMemoryEstimates.shallowObjectBytes(ULongRange::class.java) + + override fun writeString(writer: StringJsonWriter, value: ULongRange?) = write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: ULongRange?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): ULongRange? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): ULongRange? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): ULongRange? = read(reader) + + private fun write(writer: JsonWriter, value: ULongRange?) { + if (value == null) { + writer.writeNull() + return + } + writeHeader(writer) + writer.writeUnsignedLong(value.first.toLong()) + writeEndInclusive(writer) + writer.writeUnsignedLong(value.last.toLong()) + writer.writeObjectEnd() + } + + private fun read(reader: JsonReader): ULongRange? { + if (reader.tryReadNull()) return null + reader.enterDepth() + reader.expectNextToken('{') + var start = 0uL + var endInclusive = 0uL + var seen = 0 + if (!reader.consumeNextToken('}')) { + do { + val index = readField(reader, seen) + seen = seen or (1 shl index) + when (index) { + START -> start = reader.readUnsignedLong().toULong() + END_INCLUSIVE -> endInclusive = reader.readUnsignedLong().toULong() + } + } while (reader.consumeNextToken(',')) + reader.expectNextToken('}') + } + requireFields(seen) + reader.exitDepth() + reader.reserveGraphMemory(ownerBytes) + return ULongRange(start, endInclusive) + } + } + + private fun readField(reader: JsonReader, seen: Int): Int { + val hash = reader.readFieldNameHash() + val index = + when (hash) { + startHash -> START + endInclusiveHash -> END_INCLUSIVE + else -> unknownField() + } + if (seen and (1 shl index) != 0) duplicateField() + reader.expectNextToken(':') + return index + } + + private fun writeHeader(writer: JsonWriter) { + writer.writeObjectStart() + writer.writeFieldName("start") + } + + private fun writeEndInclusive(writer: JsonWriter) { + writer.writeComma(1) + writer.writeFieldName("endInclusive") + } + + private fun requireFields(seen: Int) { + if (seen != ALL_FIELDS) missingField() + } + + private fun unknownField(): Nothing = throw ForyJsonException("Unknown Kotlin range JSON field") + + private fun duplicateField(): Nothing = + throw ForyJsonException("Duplicate Kotlin range JSON field") + + private fun missingField(): Nothing = + throw ForyJsonException("Kotlin range JSON requires start and endInclusive") +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinSingletonCodecs.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinSingletonCodecs.kt new file mode 100644 index 0000000000..6688fd6f2e --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinSingletonCodecs.kt @@ -0,0 +1,103 @@ +/* + * 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.fory.json.kotlin + +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.codec.JsonValueCodec +import org.apache.fory.json.reader.JsonReader +import org.apache.fory.json.reader.Latin1JsonReader +import org.apache.fory.json.reader.Utf16JsonReader +import org.apache.fory.json.reader.Utf8JsonReader +import org.apache.fory.json.writer.StringJsonWriter +import org.apache.fory.json.writer.Utf8JsonWriter + +internal object KotlinSingletonCodecs { + val UNIT: JsonValueCodec = UnitCodec + val NULLABLE_UNIT: JsonValueCodec = NullableUnitCodec + + private object UnitCodec : JsonValueCodec { + override fun writeString(writer: StringJsonWriter, value: Unit?) { + requireUnit(value) + writer.writeObjectStart() + writer.writeObjectEnd() + } + + override fun writeUtf8(writer: Utf8JsonWriter, value: Unit?) { + requireUnit(value) + writer.writeObjectStart() + writer.writeObjectEnd() + } + + override fun readLatin1(reader: Latin1JsonReader): Unit { + readEmptyObject(reader) + return Unit + } + + override fun readUtf16(reader: Utf16JsonReader): Unit { + readEmptyObject(reader) + return Unit + } + + override fun readUtf8(reader: Utf8JsonReader): Unit { + readEmptyObject(reader) + return Unit + } + + private fun requireUnit(value: Unit?) { + if (value == null) throw ForyJsonException("Kotlin Unit is not nullable") + } + } + + private object NullableUnitCodec : JsonValueCodec { + override fun writeString(writer: StringJsonWriter, value: Unit?) { + if (value == null) writer.writeNull() else writeUnit(writer) + } + + override fun writeUtf8(writer: Utf8JsonWriter, value: Unit?) { + if (value == null) writer.writeNull() else writeUnit(writer) + } + + override fun readLatin1(reader: Latin1JsonReader): Unit? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): Unit? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): Unit? = read(reader) + + private fun read(reader: JsonReader): Unit? { + if (reader.tryReadNull()) return null + readEmptyObject(reader) + return Unit + } + } + + private fun writeUnit(writer: org.apache.fory.json.writer.JsonWriter) { + writer.writeObjectStart() + writer.writeObjectEnd() + } + + private fun readEmptyObject(reader: JsonReader) { + reader.enterDepth() + reader.expectNextToken('{') + if (!reader.consumeNextToken('}')) { + throw ForyJsonException("Kotlin Unit must be represented by an empty JSON object") + } + reader.exitDepth() + } +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinTemporalCodecs.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinTemporalCodecs.kt new file mode 100644 index 0000000000..7b9f4adb92 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinTemporalCodecs.kt @@ -0,0 +1,505 @@ +/* + * 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.fory.json.kotlin + +import java.lang.reflect.Method +import kotlin.math.roundToLong +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import kotlin.time.TimedValue +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.codec.DirectUnboxedValueCodec +import org.apache.fory.json.codec.JsonValueCodec +import org.apache.fory.json.reader.JsonReader +import org.apache.fory.json.reader.Latin1JsonReader +import org.apache.fory.json.reader.Utf16JsonReader +import org.apache.fory.json.reader.Utf8JsonReader +import org.apache.fory.json.writer.JsonWriter +import org.apache.fory.json.writer.StringJsonWriter +import org.apache.fory.json.writer.Utf8JsonWriter +import org.apache.fory.reflect.TypeRef + +/** Allocation-free token codecs for Kotlin's standard temporal and UUID values. */ +@OptIn(ExperimentalUuidApi::class) +internal object KotlinTemporalCodecs { + private const val SECONDS_PER_DAY = 86_400L + private const val DAYS_0000_TO_1970 = 719_528L + private const val MIN_INSTANT_SECOND = -31_557_014_167_219_200L + private const val MAX_INSTANT_SECOND = 31_556_889_864_403_199L + // Kotlin Duration 2.3 uses this millisecond boundary as both saturating limit and infinity. + private const val MAX_DURATION_MILLIS = Long.MAX_VALUE / 2 + private val readDurationCarrierMethod: Method = + KotlinTemporalCodecs::class.java.getMethod("readDurationRaw", JsonReader::class.java) + private val writeDurationCarrierMethod: Method = + KotlinTemporalCodecs::class + .java + .getMethod( + "writeDurationRaw", + JsonWriter::class.java, + java.lang.Long.TYPE, + ) + + /** Returns whether this family owns the exact Kotlin class instead of metadata fallback. */ + fun supports(rawType: Class<*>): Boolean = + rawType == Duration::class.java || rawType == Instant::class.java || rawType == Uuid::class.java + + fun create(type: TypeRef<*>): JsonValueCodec<*>? = + when (type.rawType) { + Duration::class.java -> DurationCodec + Instant::class.java -> InstantCodec + Uuid::class.java -> UuidCodec + else -> null + } + + @JvmStatic + @JvmName("writeDurationRaw") + fun writeDurationRaw(writer: JsonWriter, value: Duration) = writeDurationValue(writer, value) + + @JvmStatic + @JvmName("readDurationRaw") + fun readDurationRaw(reader: JsonReader): Duration { + val text = reader.readQuotedText() ?: nullDuration() + return parseDuration(text) + } + + @JvmName("timedDurationRaw") fun timedDurationRaw(value: TimedValue<*>): Duration = value.duration + + @JvmName("newTimedValue") + fun newTimedValue(value: Any?, duration: Duration): TimedValue = TimedValue(value, duration) + + private object DurationCodec : JsonValueCodec, DirectUnboxedValueCodec { + override fun writeString(writer: StringJsonWriter, value: Duration?) = write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Duration?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): Duration? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): Duration? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): Duration? = read(reader) + + override fun carrierType(): Class<*> = java.lang.Long.TYPE + + override fun readLatin1Carrier(reader: Latin1JsonReader): Any = + KotlinTemporalAccess.readDurationCarrier(reader) + + override fun readUtf16Carrier(reader: Utf16JsonReader): Any = + KotlinTemporalAccess.readDurationCarrier(reader) + + override fun readUtf8Carrier(reader: Utf8JsonReader): Any = + KotlinTemporalAccess.readDurationCarrier(reader) + + override fun writeStringCarrier(writer: StringJsonWriter, carrier: Any) = + KotlinTemporalAccess.writeDurationCarrier(writer, carrier) + + override fun writeUtf8Carrier(writer: Utf8JsonWriter, carrier: Any) = + KotlinTemporalAccess.writeDurationCarrier(writer, carrier) + + override fun readCarrierMethod(): Method = readDurationCarrierMethod + + override fun writeCarrierMethod(): Method = writeDurationCarrierMethod + + private fun write(writer: JsonWriter, value: Duration?) { + if (value == null) { + writer.writeNull() + return + } + writeDurationValue(writer, value) + } + + private fun read(reader: JsonReader): Duration? { + val text = reader.readQuotedText() ?: return null + return parseDuration(text) + } + } + + private fun writeDurationValue(writer: JsonWriter, value: Duration) { + val negative = value.isNegative() + val magnitude = value.absoluteValue + if (magnitude.isInfinite()) { + writer.writeIsoDuration(true, negative, 0, 0, 0, 0) + return + } + writer.writeIsoDuration( + false, + negative, + magnitude.inWholeHours, + (magnitude.inWholeMinutes % 60).toInt(), + (magnitude.inWholeSeconds % 60).toInt(), + (magnitude - magnitude.inWholeSeconds.seconds).inWholeNanoseconds.toInt(), + ) + } + + private object InstantCodec : JsonValueCodec { + override fun writeString(writer: StringJsonWriter, value: Instant?) = write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Instant?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): Instant? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): Instant? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): Instant? = read(reader) + + private fun write(writer: JsonWriter, value: Instant?) { + if (value == null) { + writer.writeNull() + return + } + writer.writeIsoInstant(value.epochSeconds, value.nanosecondsOfSecond) + } + + private fun read(reader: JsonReader): Instant? { + val text = reader.readQuotedText() ?: return null + return parseInstant(text) + } + } + + private object UuidCodec : JsonValueCodec { + override fun writeString(writer: StringJsonWriter, value: Uuid?) = write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Uuid?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): Uuid? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): Uuid? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): Uuid? = read(reader) + + private fun write(writer: JsonWriter, value: Uuid?) { + if (value == null) { + writer.writeNull() + return + } + writer.writeUuid(KotlinTemporalAccess.uuidHigh(value), KotlinTemporalAccess.uuidLow(value)) + } + + private fun read(reader: JsonReader): Uuid? { + val text = reader.readQuotedText() ?: return null + if ( + text.length != 36 || text[8] != '-' || text[13] != '-' || text[18] != '-' || text[23] != '-' + ) { + invalidUuid() + } + var high = 0L + var low = 0L + var digits = 0 + for (index in 0 until 36) { + if (index == 8 || index == 13 || index == 18 || index == 23) continue + val digit = hex(text[index]) + if (digits < 16) high = (high shl 4) or digit.toLong() + else low = (low shl 4) or digit.toLong() + digits++ + } + return Uuid.fromLongs(high, low) + } + } + + private fun parseDuration(text: CharSequence): Duration { + val length = text.length + if (length < 3) invalidDuration() + var index = 0 + val negative = text[index] == '-' + if (negative || text[index] == '+') index++ + if (index >= length || text[index++] != 'P') invalidDuration() + var totalMillis = 0L + var totalNanos = 0L + var sawComponent = false + var sawTimeComponent = false + var inTime = false + var seen = 0 + var lastTimeOrder = 0 + while (index < length) { + if (text[index] == 'T') { + if (inTime) invalidDuration() + inTime = true + index++ + if (index == length) invalidDuration() + continue + } + var componentSign = 1 + if (text[index] == '-' || text[index] == '+') { + if (text[index] == '-') componentSign = -1 + index++ + } + val numberStart = index + var number = 0L + var overflow = false + while (index < length) { + val ch = text[index] + if (ch !in '0'..'9') break + val digit = ch.code - '0'.code + if (!overflow) { + if (number > (MAX_DURATION_MILLIS - digit) / 10) { + number = MAX_DURATION_MILLIS + overflow = true + } else { + number = number * 10 + digit + } + } + index++ + } + if (index == numberStart || index >= length) invalidDuration() + var fractionNanos = 0L + if (text[index] == '.') { + if (!inTime) invalidDuration() + index++ + var fractionDigits = 0 + var fraction = 0L + while (index < length && text[index] in '0'..'9') { + if (fractionDigits < 15) { + fraction = fraction * 10 + text[index].code - '0'.code + } + fractionDigits++ + index++ + } + if (fractionDigits == 0 || index >= length || text[index] != 'S') invalidDuration() + // parseIsoString consumes 15 fraction digits, ignores the rest, then rounds to nanos. + repeat(15 - minOf(fractionDigits, 15)) { fraction *= 10 } + fractionNanos = componentSign * (fraction * 0.000001).roundToLong() + } + when (text[index++]) { + 'D' -> { + if (inTime || seen and 1 != 0) invalidDuration() + seen = seen or 1 + totalMillis = signedMillis(number, componentSign, 86_400_000L) + } + 'H' -> { + if (!inTime || seen and 2 != 0 || lastTimeOrder >= 1) invalidDuration() + seen = seen or 2 + lastTimeOrder = 1 + sawTimeComponent = true + totalMillis = + addDurationMillis(totalMillis, signedMillis(number, componentSign, 3_600_000L)) + } + 'M' -> { + if (!inTime || seen and 4 != 0 || lastTimeOrder >= 2) invalidDuration() + seen = seen or 4 + lastTimeOrder = 2 + sawTimeComponent = true + totalMillis = addDurationMillis(totalMillis, signedMillis(number, componentSign, 60_000L)) + } + 'S' -> { + if (!inTime || seen and 8 != 0 || lastTimeOrder >= 3) invalidDuration() + seen = seen or 8 + lastTimeOrder = 3 + sawTimeComponent = true + totalMillis = addDurationMillis(totalMillis, signedMillis(number, componentSign, 1_000L)) + totalNanos = fractionNanos + if (index != length) invalidDuration() + } + else -> invalidDuration() + } + sawComponent = true + } + if (!sawComponent || inTime && !sawTimeComponent) invalidDuration() + var value = totalMillis.milliseconds + totalNanos.nanoseconds + if (negative) value = -value + return value + } + + private fun signedMillis(value: Long, sign: Int, multiplier: Long): Long { + val magnitude = + if (value > MAX_DURATION_MILLIS / multiplier) MAX_DURATION_MILLIS else value * multiplier + return if (sign < 0) -magnitude else magnitude + } + + private fun addDurationMillis(total: Long, component: Long): Long { + if (total == MAX_DURATION_MILLIS || total == -MAX_DURATION_MILLIS) { + if ( + (component == MAX_DURATION_MILLIS || component == -MAX_DURATION_MILLIS) && + total xor component < 0 + ) { + invalidDuration() + } + return total + } + if (component == MAX_DURATION_MILLIS || component == -MAX_DURATION_MILLIS) return component + return (total + component).coerceIn(-MAX_DURATION_MILLIS, MAX_DURATION_MILLIS) + } + + private fun parseInstant(text: CharSequence): Instant { + val length = text.length + if (length < 20) invalidInstant() + var index = 0 + var negativeYear = false + var explicitPositive = false + when (text[index]) { + '-' -> { + negativeYear = true + index++ + } + '+' -> { + explicitPositive = true + index++ + } + } + val yearStart = index + var year = 0L + while (index < length && text[index] in '0'..'9') { + if (year > 1_000_000_000L) invalidInstant() + year = year * 10 + text[index].code - '0'.code + index++ + } + val yearDigits = index - yearStart + if (yearDigits < 4 || index >= length || text[index++] != '-') invalidInstant() + if (!negativeYear && !explicitPositive && yearDigits != 4) invalidInstant() + if (explicitPositive && yearDigits <= 4) invalidInstant() + if (negativeYear) year = -year + if (year !in -1_000_000_000L..1_000_000_000L) invalidInstant() + val month = twoDigits(text, index) + index += 2 + if (index >= length || text[index++] != '-') invalidInstant() + val day = twoDigits(text, index) + index += 2 + if (index >= length || text[index] != 'T' && text[index] != 't') invalidInstant() + index++ + val hour = twoDigits(text, index) + index += 2 + if (index >= length || text[index++] != ':') invalidInstant() + val minute = twoDigits(text, index) + index += 2 + if (index >= length || text[index++] != ':') invalidInstant() + val second = twoDigits(text, index) + index += 2 + var nano = 0 + if (index < length && text[index] == '.') { + index++ + val fractionStart = index + while (index < length && text[index] in '0'..'9') { + if (index - fractionStart >= 9) invalidInstant() + nano = nano * 10 + text[index].code - '0'.code + index++ + } + val digits = index - fractionStart + if (digits == 0) invalidInstant() + repeat(9 - digits) { nano *= 10 } + } + var offsetSeconds = 0 + if (index < length && (text[index] == 'Z' || text[index] == 'z')) { + index++ + } else { + if (index >= length || text[index] != '+' && text[index] != '-') invalidInstant() + val offsetNegative = text[index++] == '-' + val offsetHour = twoDigits(text, index) + index += 2 + var offsetMinute = 0 + var offsetSecond = 0 + if (index < length && text[index] == ':') { + offsetMinute = twoDigits(text, ++index) + index += 2 + if (index < length && text[index] == ':') { + offsetSecond = twoDigits(text, ++index) + index += 2 + } + } + if ( + offsetHour > 18 || + offsetMinute > 59 || + offsetSecond > 59 || + offsetHour == 18 && (offsetMinute != 0 || offsetSecond != 0) + ) { + invalidInstant() + } + offsetSeconds = offsetHour * 3_600 + offsetMinute * 60 + offsetSecond + if (offsetNegative) offsetSeconds = -offsetSeconds + } + if (index != length) invalidInstant() + validateDateTime(year, month, day, hour, minute, second) + val epochDay = epochDay(year, month, day) + var epochSecond = + Math.addExact( + Math.multiplyExact(epochDay, SECONDS_PER_DAY), + hour * 3_600L + minute * 60L + second, + ) + epochSecond = Math.subtractExact(epochSecond, offsetSeconds.toLong()) + // fromEpochSeconds saturates, while Instant.parse rejects values outside this exact range. + if (epochSecond !in MIN_INSTANT_SECOND..MAX_INSTANT_SECOND) invalidInstant() + return Instant.fromEpochSeconds(epochSecond, nano) + } + + private fun epochDay(year: Long, month: Int, day: Int): Long { + var total = 365L * year + total += + if (year >= 0) (year + 3) / 4 - (year + 99) / 100 + (year + 399) / 400 + else -(year / -4 - year / -100 + year / -400) + total += (367 * month - 362) / 12 + total += day - 1 + if (month > 2) total -= if (leapYear(year)) 1 else 2 + return total - DAYS_0000_TO_1970 + } + + private fun validateDateTime( + year: Long, + month: Int, + day: Int, + hour: Int, + minute: Int, + second: Int, + ) { + if (month !in 1..12 || hour !in 0..23 || minute !in 0..59 || second !in 0..59) { + invalidInstant() + } + val maxDay = + when (month) { + 2 -> if (leapYear(year)) 29 else 28 + 4, + 6, + 9, + 11 -> 30 + else -> 31 + } + if (day !in 1..maxDay) invalidInstant() + } + + private fun leapYear(year: Long): Boolean = + year % 4L == 0L && (year % 100L != 0L || year % 400L == 0L) + + private fun twoDigits(text: CharSequence, index: Int): Int { + if (index + 1 >= text.length) invalidInstant() + val high = text[index] + val low = text[index + 1] + if (high !in '0'..'9' || low !in '0'..'9') invalidInstant() + return (high.code - '0'.code) * 10 + low.code - '0'.code + } + + private fun hex(value: Char): Int = + when (value) { + in '0'..'9' -> value.code - '0'.code + in 'a'..'f' -> value.code - 'a'.code + 10 + in 'A'..'F' -> value.code - 'A'.code + 10 + else -> invalidUuid() + } + + private fun invalidDuration(): Nothing = + throw ForyJsonException("Invalid Kotlin Duration ISO JSON value") + + private fun nullDuration(): Nothing = throw ForyJsonException("Kotlin Duration cannot be null") + + private fun invalidInstant(): Nothing = + throw ForyJsonException("Invalid Kotlin Instant ISO JSON value") + + private fun invalidUuid(): Nothing = throw ForyJsonException("Invalid Kotlin Uuid JSON value") +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinTypeRefs.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinTypeRefs.kt new file mode 100644 index 0000000000..58feb592ba --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinTypeRefs.kt @@ -0,0 +1,125 @@ +/* + * 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.fory.json.kotlin + +import java.lang.reflect.Array as ReflectArray +import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.reflect.KTypeParameter +import kotlin.reflect.KTypeProjection +import kotlin.reflect.KVariance +import kotlin.reflect.typeOf +import org.apache.fory.json.ForyJsonException +import org.apache.fory.meta.TypeExtMeta +import org.apache.fory.reflect.TypeRef +import org.apache.fory.type.Types + +/** Returns a structural Fory JSON type token which preserves Kotlin nullability and value types. */ +@OptIn(ExperimentalStdlibApi::class) +@Suppress("UNCHECKED_CAST") +public inline fun jsonTypeRef(): TypeRef = + KotlinTypeRefs.from(typeOf()) as TypeRef + +/** Kotlin/JVM type-token conversion used by public reified roots and metadata model discovery. */ +@OptIn(ExperimentalUnsignedTypes::class) +@PublishedApi +internal object KotlinTypeRefs { + /** Converts one closed Kotlin type into its canonical structural JSON token. */ + @PublishedApi internal fun from(type: KType): TypeRef<*> = from(type, false, false) + + private fun from(type: KType, typeArgument: Boolean, covariant: Boolean): TypeRef<*> { + val classifier = type.classifier + if (classifier is KTypeParameter) { + throw ForyJsonException( + "Unresolved Kotlin type parameter ${classifier.name}; use a complete declared type", + ) + } + if (classifier !is KClass<*>) { + throw ForyJsonException("Unsupported Kotlin type classifier $classifier") + } + val raw = carrier(classifier.java, type.isMarkedNullable || typeArgument) + val arguments = type.arguments.map { projection(it) } + val component = + if (classifier.java.isArray && arguments.isNotEmpty()) { + // KType specializes Array's classifier to the concrete JVM array class, so comparing + // the classifier with Array loses the component token and its nullability. + if (arguments.size != 1) { + throw ForyJsonException("Kotlin Array requires one exact component type") + } + arguments[0] + } else { + null + } + val actualRaw = + if (component != null) ReflectArray.newInstance(box(component.rawType), 0).javaClass else raw + val metadata = typeMetadata(classifier, type.isMarkedNullable, covariant) + return when { + component != null -> TypeRef.of(actualRaw, metadata, null, component) + arguments.isEmpty() -> plainTypeRef(actualRaw, metadata) + else -> TypeRef.ofDeclaredTypeArguments(actualRaw, metadata, arguments, null) + } + } + + private fun projection(projection: KTypeProjection): TypeRef<*> { + val type = + projection.type ?: throw ForyJsonException("Star-projected Kotlin JSON types are unsupported") + if (projection.variance == KVariance.IN) { + throw ForyJsonException("Contravariant Kotlin JSON types are unsupported: $projection") + } + return from(type, true, projection.variance == KVariance.OUT) + } + + @Suppress("UNCHECKED_CAST") + private fun plainTypeRef(type: Class<*>, metadata: TypeExtMeta): TypeRef<*> = + TypeRef.of(type as Class, metadata) + + private fun carrier(type: Class<*>, boxed: Boolean): Class<*> = + if (!boxed || !type.isPrimitive) type else box(type) + + private fun box(type: Class<*>): Class<*> = + when (type) { + Boolean::class.javaPrimitiveType -> Boolean::class.javaObjectType + Byte::class.javaPrimitiveType -> Byte::class.javaObjectType + Short::class.javaPrimitiveType -> Short::class.javaObjectType + Int::class.javaPrimitiveType -> Int::class.javaObjectType + Long::class.javaPrimitiveType -> Long::class.javaObjectType + Float::class.javaPrimitiveType -> Float::class.javaObjectType + Double::class.javaPrimitiveType -> Double::class.javaObjectType + Char::class.javaPrimitiveType -> Char::class.javaObjectType + java.lang.Void.TYPE -> java.lang.Void::class.java + else -> type + } + + private fun typeMetadata(type: KClass<*>, nullable: Boolean, covariant: Boolean): TypeExtMeta = + TypeExtMeta.of(semanticTypeId(type), nullable, false, false, covariant) + + private fun semanticTypeId(type: KClass<*>): Int = + when (type) { + UByte::class -> Types.UINT8 + UShort::class -> Types.UINT16 + UInt::class -> Types.UINT32 + ULong::class -> Types.UINT64 + UByteArray::class -> Types.UINT8_ARRAY + UShortArray::class -> Types.UINT16_ARRAY + UIntArray::class -> Types.UINT32_ARRAY + ULongArray::class -> Types.UINT64_ARRAY + else -> 0 + } +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinUnsignedArrayCodecs.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinUnsignedArrayCodecs.kt new file mode 100644 index 0000000000..55a0ed3c49 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinUnsignedArrayCodecs.kt @@ -0,0 +1,184 @@ +/* + * 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.fory.json.kotlin + +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.codec.ArrayCodec +import org.apache.fory.json.codec.JsonValueCodec +import org.apache.fory.json.reader.Latin1JsonReader +import org.apache.fory.json.reader.Utf16JsonReader +import org.apache.fory.json.reader.Utf8JsonReader +import org.apache.fory.json.writer.StringJsonWriter +import org.apache.fory.json.writer.Utf8JsonWriter +import org.apache.fory.reflect.TypeRef +import org.apache.fory.serializer.GraphMemoryEstimates +import org.apache.fory.type.Types + +/** + * Kotlin unsigned-array boxes over the exact core primitive-array capabilities. + * + * Only boxed root and container occurrences select these codecs. Direct object occurrences expose + * their primitive backing and bind the core array codec without a wrapper round trip. The erased + * JsonValueCodec read bridge performs the actual Kotlin box after each typed read returns, so every + * typed read reserves the wrapper before returning its no-copy unsigned view. + */ +@OptIn(ExperimentalUnsignedTypes::class) +internal object KotlinUnsignedArrayCodecs { + fun create(type: TypeRef<*>): JsonValueCodec<*>? { + val typeId = type.typeExtMeta?.typeId() ?: Types.UNKNOWN + val codec = + when { + type.rawType == UByteArray::class.java && typeId == Types.UINT8_ARRAY -> UByteArrayCodec + type.rawType == UShortArray::class.java && typeId == Types.UINT16_ARRAY -> UShortArrayCodec + type.rawType == UIntArray::class.java && typeId == Types.UINT32_ARRAY -> UIntArrayCodec + type.rawType == ULongArray::class.java && typeId == Types.UINT64_ARRAY -> ULongArrayCodec + else -> null + } + if ( + codec == null && + (type.rawType == UByteArray::class.java || + type.rawType == UShortArray::class.java || + type.rawType == UIntArray::class.java || + type.rawType == ULongArray::class.java) + ) { + throw ForyJsonException( + "Kotlin unsigned-array carrier ${type.rawType.name} does not match semantic type id $typeId", + ) + } + return codec + } + + private object UByteArrayCodec : JsonValueCodec { + private val delegate = + ArrayCodec.createUnsignedPrimitive(ByteArray::class.java, Types.UINT8_ARRAY) + private val wrapperBytes = GraphMemoryEstimates.shallowObjectBytes(UByteArray::class.java) + + override fun writeString(writer: StringJsonWriter, value: UByteArray?) = + delegate.writeString(writer, value?.asByteArray()) + + override fun writeUtf8(writer: Utf8JsonWriter, value: UByteArray?) = + delegate.writeUtf8(writer, value?.asByteArray()) + + override fun readLatin1(reader: Latin1JsonReader): UByteArray? = + wrap(reader, delegate.readLatin1(reader)) + + override fun readUtf16(reader: Utf16JsonReader): UByteArray? = + wrap(reader, delegate.readUtf16(reader)) + + override fun readUtf8(reader: Utf8JsonReader): UByteArray? = + wrap(reader, delegate.readUtf8(reader)) + + private fun wrap( + reader: org.apache.fory.json.reader.JsonReader, + value: ByteArray? + ): UByteArray? { + if (value == null) return null + reader.reserveGraphMemory(wrapperBytes) + return value.asUByteArray() + } + } + + private object UShortArrayCodec : JsonValueCodec { + private val delegate = + ArrayCodec.createUnsignedPrimitive(ShortArray::class.java, Types.UINT16_ARRAY) + private val wrapperBytes = GraphMemoryEstimates.shallowObjectBytes(UShortArray::class.java) + + override fun writeString(writer: StringJsonWriter, value: UShortArray?) = + delegate.writeString(writer, value?.asShortArray()) + + override fun writeUtf8(writer: Utf8JsonWriter, value: UShortArray?) = + delegate.writeUtf8(writer, value?.asShortArray()) + + override fun readLatin1(reader: Latin1JsonReader): UShortArray? = + wrap(reader, delegate.readLatin1(reader)) + + override fun readUtf16(reader: Utf16JsonReader): UShortArray? = + wrap(reader, delegate.readUtf16(reader)) + + override fun readUtf8(reader: Utf8JsonReader): UShortArray? = + wrap(reader, delegate.readUtf8(reader)) + + private fun wrap( + reader: org.apache.fory.json.reader.JsonReader, + value: ShortArray?, + ): UShortArray? { + if (value == null) return null + reader.reserveGraphMemory(wrapperBytes) + return value.asUShortArray() + } + } + + private object UIntArrayCodec : JsonValueCodec { + private val delegate = + ArrayCodec.createUnsignedPrimitive(IntArray::class.java, Types.UINT32_ARRAY) + private val wrapperBytes = GraphMemoryEstimates.shallowObjectBytes(UIntArray::class.java) + + override fun writeString(writer: StringJsonWriter, value: UIntArray?) = + delegate.writeString(writer, value?.asIntArray()) + + override fun writeUtf8(writer: Utf8JsonWriter, value: UIntArray?) = + delegate.writeUtf8(writer, value?.asIntArray()) + + override fun readLatin1(reader: Latin1JsonReader): UIntArray? = + wrap(reader, delegate.readLatin1(reader)) + + override fun readUtf16(reader: Utf16JsonReader): UIntArray? = + wrap(reader, delegate.readUtf16(reader)) + + override fun readUtf8(reader: Utf8JsonReader): UIntArray? = + wrap(reader, delegate.readUtf8(reader)) + + private fun wrap(reader: org.apache.fory.json.reader.JsonReader, value: IntArray?): UIntArray? { + if (value == null) return null + reader.reserveGraphMemory(wrapperBytes) + return value.asUIntArray() + } + } + + private object ULongArrayCodec : JsonValueCodec { + private val delegate = + ArrayCodec.createUnsignedPrimitive(LongArray::class.java, Types.UINT64_ARRAY) + private val wrapperBytes = GraphMemoryEstimates.shallowObjectBytes(ULongArray::class.java) + + override fun writeString(writer: StringJsonWriter, value: ULongArray?) = + delegate.writeString(writer, value?.asLongArray()) + + override fun writeUtf8(writer: Utf8JsonWriter, value: ULongArray?) = + delegate.writeUtf8(writer, value?.asLongArray()) + + override fun readLatin1(reader: Latin1JsonReader): ULongArray? = + wrap(reader, delegate.readLatin1(reader)) + + override fun readUtf16(reader: Utf16JsonReader): ULongArray? = + wrap(reader, delegate.readUtf16(reader)) + + override fun readUtf8(reader: Utf8JsonReader): ULongArray? = + wrap(reader, delegate.readUtf8(reader)) + + private fun wrap( + reader: org.apache.fory.json.reader.JsonReader, + value: LongArray? + ): ULongArray? { + if (value == null) return null + reader.reserveGraphMemory(wrapperBytes) + return value.asULongArray() + } + } +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinUnsignedCodecs.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinUnsignedCodecs.kt new file mode 100644 index 0000000000..7413aee403 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinUnsignedCodecs.kt @@ -0,0 +1,401 @@ +/* + * 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.fory.json.kotlin + +import java.lang.reflect.Method +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.codec.DirectUnboxedValueCodec +import org.apache.fory.json.codec.JsonValueCodec +import org.apache.fory.json.reader.JsonReader +import org.apache.fory.json.reader.Latin1JsonReader +import org.apache.fory.json.reader.Utf16JsonReader +import org.apache.fory.json.reader.Utf8JsonReader +import org.apache.fory.json.writer.JsonWriter +import org.apache.fory.json.writer.StringJsonWriter +import org.apache.fory.json.writer.Utf8JsonWriter +import org.apache.fory.type.Types + +/** Width- and carrier-specialized codecs for Kotlin unsigned scalars. */ +@OptIn(ExperimentalUnsignedTypes::class) +internal object KotlinUnsignedCodecs { + private val readUByteCarrierMethod: Method = + KotlinUnsignedCodecs::class.java.getMethod("readUByteRaw", JsonReader::class.java) + private val writeUByteCarrierMethod: Method = + KotlinUnsignedCodecs::class + .java + .getMethod( + "writeUByteRaw", + JsonWriter::class.java, + java.lang.Byte.TYPE, + ) + private val readUShortCarrierMethod: Method = + KotlinUnsignedCodecs::class.java.getMethod("readUShortRaw", JsonReader::class.java) + private val writeUShortCarrierMethod: Method = + KotlinUnsignedCodecs::class + .java + .getMethod( + "writeUShortRaw", + JsonWriter::class.java, + java.lang.Short.TYPE, + ) + private val readUIntCarrierMethod: Method = + KotlinUnsignedCodecs::class.java.getMethod("readUIntRaw", JsonReader::class.java) + private val writeUIntCarrierMethod: Method = + KotlinUnsignedCodecs::class + .java + .getMethod( + "writeUIntRaw", + JsonWriter::class.java, + java.lang.Integer.TYPE, + ) + private val readULongCarrierMethod: Method = + KotlinUnsignedCodecs::class.java.getMethod("readULongRaw", JsonReader::class.java) + private val writeULongCarrierMethod: Method = + KotlinUnsignedCodecs::class + .java + .getMethod( + "writeULongRaw", + JsonWriter::class.java, + java.lang.Long.TYPE, + ) + + fun scalar(typeId: Int, boxedResult: Boolean, nullable: Boolean): JsonValueCodec = + when (typeId) { + Types.UINT8 -> + if (nullable) UByteCodec.NULLABLE + else if (boxedResult) UByteCodec.BOXED else UByteCarrierCodec + Types.UINT16 -> + if (nullable) UShortCodec.NULLABLE + else if (boxedResult) UShortCodec.BOXED else UShortCarrierCodec + Types.UINT32 -> + if (nullable) UIntCodec.NULLABLE else if (boxedResult) UIntCodec.BOXED else UIntCarrierCodec + Types.UINT64 -> + if (nullable) ULongCodec.NULLABLE + else if (boxedResult) ULongCodec.BOXED else ULongCarrierCodec + else -> throw ForyJsonException("Unknown Kotlin unsigned JSON type id $typeId") + } + + @JvmStatic + @JvmName("readUByteRaw") + fun readUByteRaw(reader: JsonReader): Byte { + val value = reader.readUnsignedInt() + if (Integer.compareUnsigned(value, UByte.MAX_VALUE.toInt()) > 0) ubyteOverflow() + return value.toByte() + } + + @JvmStatic + @JvmName("writeUByteRaw") + fun writeUByteRaw(writer: JsonWriter, value: Byte) = + writer.writeUnsignedInt(value.toInt() and 0xff) + + @JvmStatic + @JvmName("readUShortRaw") + fun readUShortRaw(reader: JsonReader): Short { + val value = reader.readUnsignedInt() + if (Integer.compareUnsigned(value, UShort.MAX_VALUE.toInt()) > 0) ushortOverflow() + return value.toShort() + } + + @JvmStatic + @JvmName("writeUShortRaw") + fun writeUShortRaw(writer: JsonWriter, value: Short) = + writer.writeUnsignedInt(value.toInt() and 0xffff) + + @JvmStatic + @JvmName("readUIntRaw") + fun readUIntRaw(reader: JsonReader): Int = reader.readUnsignedInt() + + @JvmStatic + @JvmName("writeUIntRaw") + fun writeUIntRaw(writer: JsonWriter, value: Int) = writer.writeUnsignedInt(value) + + @JvmStatic + @JvmName("readULongRaw") + fun readULongRaw(reader: JsonReader): Long = reader.readUnsignedLong() + + @JvmStatic + @JvmName("writeULongRaw") + fun writeULongRaw(writer: JsonWriter, value: Long) = writer.writeUnsignedLong(value) + + private class UByteCodec(private val nullable: Boolean) : JsonValueCodec { + companion object { + val BOXED: JsonValueCodec = UByteCodec(false) + val NULLABLE: JsonValueCodec = UByteCodec(true) + } + + override fun writeString(writer: StringJsonWriter, value: Any?) = write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): Any? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): Any? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): Any? = read(reader) + + private fun write(writer: JsonWriter, value: Any?) { + if (value == null) { + writeNull(writer, nullable) + return + } + writeUByteRaw(writer, (value as UByte).toByte()) + } + + private fun read(reader: JsonReader): Any? { + if (reader.tryReadNull()) return readNull(nullable) + return readUByteRaw(reader).toUByte() + } + } + + private object UByteCarrierCodec : JsonValueCodec, DirectUnboxedValueCodec { + override fun writeString(writer: StringJsonWriter, value: Any?) = + writeStringCarrier(writer, value ?: rejectNull()) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any?) = + writeUtf8Carrier(writer, value ?: rejectNull()) + + override fun readLatin1(reader: Latin1JsonReader): Any = readLatin1Carrier(reader) + + override fun readUtf16(reader: Utf16JsonReader): Any = readUtf16Carrier(reader) + + override fun readUtf8(reader: Utf8JsonReader): Any = readUtf8Carrier(reader) + + override fun carrierType(): Class<*> = java.lang.Byte.TYPE + + override fun readLatin1Carrier(reader: Latin1JsonReader): Any = readUByteRaw(reader) + + override fun readUtf16Carrier(reader: Utf16JsonReader): Any = readUByteRaw(reader) + + override fun readUtf8Carrier(reader: Utf8JsonReader): Any = readUByteRaw(reader) + + override fun writeStringCarrier(writer: StringJsonWriter, carrier: Any) = + writeUByteRaw(writer, carrier as Byte) + + override fun writeUtf8Carrier(writer: Utf8JsonWriter, carrier: Any) = + writeUByteRaw(writer, carrier as Byte) + + override fun readCarrierMethod(): Method = readUByteCarrierMethod + + override fun writeCarrierMethod(): Method = writeUByteCarrierMethod + } + + private class UShortCodec(private val nullable: Boolean) : JsonValueCodec { + companion object { + val BOXED: JsonValueCodec = UShortCodec(false) + val NULLABLE: JsonValueCodec = UShortCodec(true) + } + + override fun writeString(writer: StringJsonWriter, value: Any?) = write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): Any? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): Any? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): Any? = read(reader) + + private fun write(writer: JsonWriter, value: Any?) { + if (value == null) { + writeNull(writer, nullable) + return + } + writeUShortRaw(writer, (value as UShort).toShort()) + } + + private fun read(reader: JsonReader): Any? { + if (reader.tryReadNull()) return readNull(nullable) + return readUShortRaw(reader).toUShort() + } + } + + private object UShortCarrierCodec : JsonValueCodec, DirectUnboxedValueCodec { + override fun writeString(writer: StringJsonWriter, value: Any?) = + writeStringCarrier(writer, value ?: rejectNull()) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any?) = + writeUtf8Carrier(writer, value ?: rejectNull()) + + override fun readLatin1(reader: Latin1JsonReader): Any = readLatin1Carrier(reader) + + override fun readUtf16(reader: Utf16JsonReader): Any = readUtf16Carrier(reader) + + override fun readUtf8(reader: Utf8JsonReader): Any = readUtf8Carrier(reader) + + override fun carrierType(): Class<*> = java.lang.Short.TYPE + + override fun readLatin1Carrier(reader: Latin1JsonReader): Any = readUShortRaw(reader) + + override fun readUtf16Carrier(reader: Utf16JsonReader): Any = readUShortRaw(reader) + + override fun readUtf8Carrier(reader: Utf8JsonReader): Any = readUShortRaw(reader) + + override fun writeStringCarrier(writer: StringJsonWriter, carrier: Any) = + writeUShortRaw(writer, carrier as Short) + + override fun writeUtf8Carrier(writer: Utf8JsonWriter, carrier: Any) = + writeUShortRaw(writer, carrier as Short) + + override fun readCarrierMethod(): Method = readUShortCarrierMethod + + override fun writeCarrierMethod(): Method = writeUShortCarrierMethod + } + + private class UIntCodec(private val nullable: Boolean) : JsonValueCodec { + companion object { + val BOXED: JsonValueCodec = UIntCodec(false) + val NULLABLE: JsonValueCodec = UIntCodec(true) + } + + override fun writeString(writer: StringJsonWriter, value: Any?) = write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): Any? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): Any? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): Any? = read(reader) + + private fun write(writer: JsonWriter, value: Any?) { + if (value == null) { + writeNull(writer, nullable) + return + } + writeUIntRaw(writer, (value as UInt).toInt()) + } + + private fun read(reader: JsonReader): Any? { + if (reader.tryReadNull()) return readNull(nullable) + return readUIntRaw(reader).toUInt() + } + } + + private object UIntCarrierCodec : JsonValueCodec, DirectUnboxedValueCodec { + override fun writeString(writer: StringJsonWriter, value: Any?) = + writeStringCarrier(writer, value ?: rejectNull()) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any?) = + writeUtf8Carrier(writer, value ?: rejectNull()) + + override fun readLatin1(reader: Latin1JsonReader): Any = readLatin1Carrier(reader) + + override fun readUtf16(reader: Utf16JsonReader): Any = readUtf16Carrier(reader) + + override fun readUtf8(reader: Utf8JsonReader): Any = readUtf8Carrier(reader) + + override fun carrierType(): Class<*> = java.lang.Integer.TYPE + + override fun readLatin1Carrier(reader: Latin1JsonReader): Any = readUIntRaw(reader) + + override fun readUtf16Carrier(reader: Utf16JsonReader): Any = readUIntRaw(reader) + + override fun readUtf8Carrier(reader: Utf8JsonReader): Any = readUIntRaw(reader) + + override fun writeStringCarrier(writer: StringJsonWriter, carrier: Any) = + writeUIntRaw(writer, carrier as Int) + + override fun writeUtf8Carrier(writer: Utf8JsonWriter, carrier: Any) = + writeUIntRaw(writer, carrier as Int) + + override fun readCarrierMethod(): Method = readUIntCarrierMethod + + override fun writeCarrierMethod(): Method = writeUIntCarrierMethod + } + + private class ULongCodec(private val nullable: Boolean) : JsonValueCodec { + companion object { + val BOXED: JsonValueCodec = ULongCodec(false) + val NULLABLE: JsonValueCodec = ULongCodec(true) + } + + override fun writeString(writer: StringJsonWriter, value: Any?) = write(writer, value) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any?) = write(writer, value) + + override fun readLatin1(reader: Latin1JsonReader): Any? = read(reader) + + override fun readUtf16(reader: Utf16JsonReader): Any? = read(reader) + + override fun readUtf8(reader: Utf8JsonReader): Any? = read(reader) + + private fun write(writer: JsonWriter, value: Any?) { + if (value == null) { + writeNull(writer, nullable) + return + } + writeULongRaw(writer, (value as ULong).toLong()) + } + + private fun read(reader: JsonReader): Any? { + if (reader.tryReadNull()) return readNull(nullable) + return readULongRaw(reader).toULong() + } + } + + private object ULongCarrierCodec : JsonValueCodec, DirectUnboxedValueCodec { + override fun writeString(writer: StringJsonWriter, value: Any?) = + writeStringCarrier(writer, value ?: rejectNull()) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any?) = + writeUtf8Carrier(writer, value ?: rejectNull()) + + override fun readLatin1(reader: Latin1JsonReader): Any = readLatin1Carrier(reader) + + override fun readUtf16(reader: Utf16JsonReader): Any = readUtf16Carrier(reader) + + override fun readUtf8(reader: Utf8JsonReader): Any = readUtf8Carrier(reader) + + override fun carrierType(): Class<*> = java.lang.Long.TYPE + + override fun readLatin1Carrier(reader: Latin1JsonReader): Any = readULongRaw(reader) + + override fun readUtf16Carrier(reader: Utf16JsonReader): Any = readULongRaw(reader) + + override fun readUtf8Carrier(reader: Utf8JsonReader): Any = readULongRaw(reader) + + override fun writeStringCarrier(writer: StringJsonWriter, carrier: Any) = + writeULongRaw(writer, carrier as Long) + + override fun writeUtf8Carrier(writer: Utf8JsonWriter, carrier: Any) = + writeULongRaw(writer, carrier as Long) + + override fun readCarrierMethod(): Method = readULongCarrierMethod + + override fun writeCarrierMethod(): Method = writeULongCarrierMethod + } + + private fun writeNull(writer: JsonWriter, nullable: Boolean) { + if (!nullable) rejectNull() + writer.writeNull() + } + + private fun readNull(nullable: Boolean): Any? { + if (!nullable) rejectNull() + return null + } + + private fun rejectNull(): Nothing = + throw ForyJsonException("Kotlin unsigned value is not nullable") + + private fun ubyteOverflow(): Nothing = throw ForyJsonException("UByte overflow") + + private fun ushortOverflow(): Nothing = throw ForyJsonException("UShort overflow") +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinUnsupportedTypes.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinUnsupportedTypes.kt new file mode 100644 index 0000000000..db66e97c6e --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinUnsupportedTypes.kt @@ -0,0 +1,91 @@ +/* + * 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.fory.json.kotlin + +import kotlin.random.Random +import kotlin.ranges.ClosedRange +import kotlin.ranges.OpenEndRange +import kotlin.time.Clock +import kotlin.time.ComparableTimeMark +import kotlin.time.TimeMark +import kotlin.time.TimeSource +import org.apache.fory.json.resolver.UnsupportedJsonTypeException + +/** Closed rejection table for Kotlin carriers that do not define portable value schemas. */ +internal object KotlinUnsupportedTypes { + /** Returns whether this exact Kotlin class has a fixed automatic-rejection owner. */ + fun rejects(type: Class<*>): Boolean = reason(type) != null + + fun reject(type: Class<*>) { + val reason = reason(type) ?: return + throw UnsupportedJsonTypeException("Unsupported Kotlin JSON type ${type.name}: $reason") + } + + private fun reason(type: Class<*>): String? { + val name = type.name + return when { + isPrivateCollection(type, name) -> + "implementation-private collections must be declared through a public collection type" + type == Result::class.java -> "Result may retain failure execution state" + Lazy::class.java.isAssignableFrom(type) -> "Lazy values have deferred executable state" + Map.Entry::class.java.isAssignableFrom(type) -> "map entries have no declared result identity" + (Iterable::class.java.isAssignableFrom(type) && + !Collection::class.java.isAssignableFrom(type)) || + Sequence::class.java.isAssignableFrom(type) -> + "iterables and sequences may be lazy, infinite, or one-shot" + Iterator::class.java.isAssignableFrom(type) -> "iterators are live destructive cursors" + ClosedRange::class.java.isAssignableFrom(type) || + OpenEndRange::class.java.isAssignableFrom(type) -> + "range interfaces have no single constructible concrete schema" + Clock::class.java.isAssignableFrom(type) || + TimeSource::class.java.isAssignableFrom(type) || + TimeMark::class.java.isAssignableFrom(type) || + ComparableTimeMark::class.java.isAssignableFrom(type) -> + "time sources and marks retain ambient process state" + type == Regex::class.java || + name.startsWith("kotlin.text.MatcherMatchResult") || + name.startsWith("kotlin.text.MatchResult") || + name.startsWith("kotlin.text.MatchGroup") -> + "regular expressions and match state require an application-owned resource policy" + Random::class.java.isAssignableFrom(type) -> "random generators retain mutable entropy state" + kotlin.Function::class.java.isAssignableFrom(type) || + name.startsWith("kotlin.jvm.functions.") -> "function values retain executable state" + name.startsWith("kotlin.reflect.") -> "reflection values are class or callable authority" + name.startsWith("kotlin.coroutines.") || name.startsWith("kotlinx.coroutines.") -> + "coroutine values retain scheduler or continuation state" + name.startsWith("kotlin.properties.") -> "property delegates retain executable state" + name.startsWith("kotlin.sequences.") -> "sequences may be lazy, infinite, or one-shot" + else -> null + } + } + + private fun isPrivateCollection(type: Class<*>, name: String): Boolean { + if (!Collection::class.java.isAssignableFrom(type) && !Map::class.java.isAssignableFrom(type)) { + return false + } + if (type == ArrayDeque::class.java) return false + return name == "kotlin.collections.EmptyList" || + name == "kotlin.collections.EmptySet" || + name == "kotlin.collections.EmptyMap" || + name.startsWith("kotlin.collections.builders.") || + name.startsWith("kotlin.collections.ReversedList") || + name.startsWith("kotlin.enums.EnumEntries") + } +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinValueClassCodecs.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinValueClassCodecs.kt new file mode 100644 index 0000000000..9a85bd0f4c --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinValueClassCodecs.kt @@ -0,0 +1,417 @@ +/* + * 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.fory.json.kotlin + +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.codec.CompositeJsonCodec +import org.apache.fory.json.codec.JsonValueCodec +import org.apache.fory.json.codec.MapCodec +import org.apache.fory.json.codec.MapKeyCodec +import org.apache.fory.json.codec.TransparentNullCodec +import org.apache.fory.json.codec.TransparentUnboxedValueCodec +import org.apache.fory.json.reader.JsonReader +import org.apache.fory.json.reader.Latin1JsonReader +import org.apache.fory.json.reader.Utf16JsonReader +import org.apache.fory.json.reader.Utf8JsonReader +import org.apache.fory.json.resolver.JsonTypeInfo +import org.apache.fory.json.resolver.JsonTypeResolver +import org.apache.fory.json.writer.StringJsonWriter +import org.apache.fory.json.writer.Utf8JsonWriter +import org.apache.fory.reflect.TypeRef + +/** Kotlin-owned entry for runtime value-class capabilities. */ +internal object KotlinValueClassCodecs { + fun create(type: TypeRef<*>): CompositeJsonCodec { + val model = KotlinValueClassMetadata.inspect(type) + return create(model.shape, KotlinValueClassOperations.create(model)) + } + + private fun create( + shape: KotlinValueClassShape, + operations: KotlinValueClassOperations, + ): CompositeJsonCodec = + when { + shape.outerNullable -> NullableOuterCodec(shape, operations) + shape.underlyingNullable -> NullableUnderlyingCodec(shape, operations) + else -> NonNullCodec(shape, operations) + } + + fun createMap(type: TypeRef<*>, resolver: JsonTypeResolver): JsonValueCodec<*> { + val arguments = type.typeArguments + if (arguments.size != 2) { + throw ForyJsonException("Kotlin JSON map ${type.type} requires exact key and value types") + } + val keyType = arguments[0] + val keyCodec = mapKeyCodec(keyType, resolver) + val valueInfo = resolver.getTypeInfo(arguments[1]) + return MapCodec.create(type.rawType, keyType.rawType, valueInfo, keyCodec) + } + + fun mapKeyCodec(type: TypeRef<*>, resolver: JsonTypeResolver): MapKeyCodec { + val model = KotlinValueClassMetadata.inspect(type) + val shape = model.shape + shape.requireMapKey() + for (layer in shape.layers) resolver.checkMapKeySecure(layer.ownerClass) + resolver.checkMapKeySecure(shape.terminalType.rawType) + return KotlinValueClassMapKeys.create( + shape, + KotlinValueClassOperations.create(model), + terminalMapKey(shape.terminalType), + ) + } + + private fun terminalMapKey(type: TypeRef<*>): MapKeyCodec { + KotlinMapKeyCodecs.keyCodec(type)?.let { + return it + } + val rawType = type.rawType + if (rawType != String::class.java && !rawType.isEnum && !signedMapKey(rawType)) { + throw ForyJsonException("Unsupported Kotlin JSON value-class map key terminal ${type.type}") + } + return MapCodec.keyCodec(rawType) + } + + private fun signedMapKey(type: Class<*>): Boolean = + type == Byte::class.javaPrimitiveType || + type == Byte::class.javaObjectType || + type == Short::class.javaPrimitiveType || + type == Short::class.javaObjectType || + type == Int::class.javaPrimitiveType || + type == Int::class.javaObjectType || + type == Long::class.javaPrimitiveType || + type == Long::class.javaObjectType +} + +internal interface KotlinValueClassCapability { + fun writeString(writer: StringJsonWriter, value: Any) + + fun writeUtf8(writer: Utf8JsonWriter, value: Any) + + fun readLatin1(reader: Latin1JsonReader): Any + + fun readUtf16(reader: Utf16JsonReader): Any + + fun readUtf8(reader: Utf8JsonReader): Any +} + +private abstract class KotlinValueClassCodec( + protected val shape: KotlinValueClassShape, + private val operations: KotlinValueClassOperations, +) : CompositeJsonCodec, TransparentUnboxedValueCodec { + private var capability: KotlinValueClassCapability = UnresolvedValueClassCapability + private var terminalTypeInfo: JsonTypeInfo? = null + private val unboxedOperations: KotlinUnboxedValueClassOperations = operations.unboxedOperations() + + final override fun resolveTypes(type: TypeRef<*>, resolver: JsonTypeResolver) { + if (type != shape.ownerType) { + throw ForyJsonException( + "Kotlin value-class codec owner ${shape.ownerType} cannot bind child for $type", + ) + } + for (layer in shape.layers) resolver.checkSecure(layer.ownerClass) + val child = resolver.getTypeInfo(shape.terminalType) + terminalTypeInfo = child + capability = KotlinValueClassCapabilities.bind(shape, operations, child) + } + + final override fun carrierType(): Class<*> = shape.layers.first().carrierClass + + final override fun valueTypeInfo(): JsonTypeInfo = + terminalTypeInfo + ?: throw IllegalStateException("Kotlin value-class terminal capability is not resolved") + + final override fun constructCarrier(reader: JsonReader, value: Any?): Any? = + unboxedOperations.constructCarrier(reader, value) + + final override fun extractValue(carrier: Any?): Any? = unboxedOperations.extractValue(carrier) + + final override fun constructMethods(): Array = + unboxedOperations.constructMethods() + + final override fun constructBoxBytes(): IntArray = unboxedOperations.constructBoxBytes() + + final override fun extractMethods(): Array = + unboxedOperations.extractMethods() + + final override fun readLatin1Carrier(reader: Latin1JsonReader): Any? = + constructCarrier(reader, valueTypeInfo().latin1Reader().readLatin1(reader)) + + final override fun readUtf16Carrier(reader: Utf16JsonReader): Any? = + constructCarrier(reader, valueTypeInfo().utf16Reader().readUtf16(reader)) + + final override fun readUtf8Carrier(reader: Utf8JsonReader): Any? = + constructCarrier(reader, valueTypeInfo().utf8Reader().readUtf8(reader)) + + final override fun writeStringCarrier(writer: StringJsonWriter, carrier: Any?) = + valueTypeInfo().stringWriter().writeString(writer, extractValue(carrier)) + + final override fun writeUtf8Carrier(writer: Utf8JsonWriter, carrier: Any?) = + valueTypeInfo().utf8Writer().writeUtf8(writer, extractValue(carrier)) + + protected fun writeStringValue(writer: StringJsonWriter, value: Any) = + capability.writeString(writer, value) + + protected fun writeUtf8Value(writer: Utf8JsonWriter, value: Any) = + capability.writeUtf8(writer, value) + + protected fun readLatin1Value(reader: Latin1JsonReader): Any = capability.readLatin1(reader) + + protected fun readUtf16Value(reader: Utf16JsonReader): Any = capability.readUtf16(reader) + + protected fun readUtf8Value(reader: Utf8JsonReader): Any = capability.readUtf8(reader) + + protected fun nonNull(value: Any?): Any = + value ?: throw ForyJsonException("Kotlin value class ${shape.ownerClass.name} is not nullable") +} + +private class NullableOuterCodec( + shape: KotlinValueClassShape, + operations: KotlinValueClassOperations, +) : KotlinValueClassCodec(shape, operations) { + override fun writeString(writer: StringJsonWriter, value: Any?) { + if (value == null) writer.writeNull() else writeStringValue(writer, value) + } + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any?) { + if (value == null) writer.writeNull() else writeUtf8Value(writer, value) + } + + override fun readLatin1(reader: Latin1JsonReader): Any? = + if (reader.tryReadNullToken()) null else readLatin1Value(reader) + + override fun readUtf16(reader: Utf16JsonReader): Any? = + if (reader.tryReadNullToken()) null else readUtf16Value(reader) + + override fun readUtf8(reader: Utf8JsonReader): Any? = + if (reader.tryReadNullToken()) null else readUtf8Value(reader) +} + +private class NullableUnderlyingCodec( + shape: KotlinValueClassShape, + operations: KotlinValueClassOperations, +) : KotlinValueClassCodec(shape, operations), TransparentNullCodec { + override fun writeString(writer: StringJsonWriter, value: Any?) = + writeStringValue(writer, nonNull(value)) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any?) = + writeUtf8Value(writer, nonNull(value)) + + override fun readLatin1(reader: Latin1JsonReader): Any = readLatin1Value(reader) + + override fun readUtf16(reader: Utf16JsonReader): Any = readUtf16Value(reader) + + override fun readUtf8(reader: Utf8JsonReader): Any = readUtf8Value(reader) +} + +private class NonNullCodec( + shape: KotlinValueClassShape, + operations: KotlinValueClassOperations, +) : KotlinValueClassCodec(shape, operations) { + override fun writeString(writer: StringJsonWriter, value: Any?) = + writeStringValue(writer, nonNull(value)) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any?) = + writeUtf8Value(writer, nonNull(value)) + + override fun readLatin1(reader: Latin1JsonReader): Any = + if (reader.tryReadNullToken()) nullFailure() else readLatin1Value(reader) + + override fun readUtf16(reader: Utf16JsonReader): Any = + if (reader.tryReadNullToken()) nullFailure() else readUtf16Value(reader) + + override fun readUtf8(reader: Utf8JsonReader): Any = + if (reader.tryReadNullToken()) nullFailure() else readUtf8Value(reader) + + private fun nullFailure(): Nothing = + throw ForyJsonException("Kotlin value class ${shape.ownerClass.name} is not nullable") +} + +private object UnresolvedValueClassCapability : KotlinValueClassCapability { + override fun writeString(writer: StringJsonWriter, value: Any): Unit = unresolved() + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any): Unit = unresolved() + + override fun readLatin1(reader: Latin1JsonReader): Any = unresolved() + + override fun readUtf16(reader: Utf16JsonReader): Any = unresolved() + + override fun readUtf8(reader: Utf8JsonReader): Any = unresolved() + + private fun unresolved(): Nothing = + throw IllegalStateException("Kotlin value-class child capability is not resolved") +} + +internal class GenericValueClassCapability( + private val operations: BoxedValueClassOperations, + child: JsonTypeInfo, +) : KotlinValueClassCapability { + private val stringWriter = child.stringWriter() + private val utf8Writer = child.utf8Writer() + private val latin1Reader = child.latin1Reader() + private val utf16Reader = child.utf16Reader() + private val utf8Reader = child.utf8Reader() + + override fun writeString(writer: StringJsonWriter, value: Any) = + stringWriter.writeString(writer, operations.unbox(value)) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any) = + utf8Writer.writeUtf8(writer, operations.unbox(value)) + + override fun readLatin1(reader: Latin1JsonReader): Any = + operations.construct(reader, latin1Reader.readLatin1(reader)) + + override fun readUtf16(reader: Utf16JsonReader): Any = + operations.construct(reader, utf16Reader.readUtf16(reader)) + + override fun readUtf8(reader: Utf8JsonReader): Any = + operations.construct(reader, utf8Reader.readUtf8(reader)) +} + +internal interface BoxedValueClassOperations { + fun construct(reader: JsonReader, value: Any?): Any + + fun constructUncharged(value: Any?): Any + + fun unbox(value: Any): Any? +} + +internal fun boxedOperations(operations: KotlinValueClassOperations): BoxedValueClassOperations = + when (operations) { + is BoxedValueClassOperations -> operations + is KotlinBooleanValueClassOperations<*> -> BooleanBoxedOperations(operations.cast()) + is KotlinByteValueClassOperations<*> -> ByteBoxedOperations(operations.cast()) + is KotlinShortValueClassOperations<*> -> ShortBoxedOperations(operations.cast()) + is KotlinIntValueClassOperations<*> -> IntBoxedOperations(operations.cast()) + is KotlinLongValueClassOperations<*> -> LongBoxedOperations(operations.cast()) + is KotlinFloatValueClassOperations<*> -> FloatBoxedOperations(operations.cast()) + is KotlinDoubleValueClassOperations<*> -> DoubleBoxedOperations(operations.cast()) + is KotlinCharValueClassOperations<*> -> CharBoxedOperations(operations.cast()) + is KotlinReferenceValueClassOperations<*> -> ReferenceBoxedOperations(operations.cast()) + else -> + throw ForyJsonException("Unknown Kotlin value-class operations ${operations.javaClass.name}") + } + +@Suppress("UNCHECKED_CAST") private fun Any.cast(): T = this as T + +private class BooleanBoxedOperations( + private val delegate: KotlinBooleanValueClassOperations, +) : BoxedValueClassOperations { + override fun construct(reader: JsonReader, value: Any?): Any = + delegate.constructBoolean(reader, value as Boolean) + + override fun constructUncharged(value: Any?): Any = + delegate.constructBooleanUncharged(value as Boolean) + + override fun unbox(value: Any): Any = delegate.unboxBoolean(value) +} + +private class ByteBoxedOperations( + private val delegate: KotlinByteValueClassOperations, +) : BoxedValueClassOperations { + override fun construct(reader: JsonReader, value: Any?): Any = + delegate.constructByte(reader, (value as Number).toByte()) + + override fun constructUncharged(value: Any?): Any = + delegate.constructByteUncharged((value as Number).toByte()) + + override fun unbox(value: Any): Any = delegate.unboxByte(value) +} + +private class ShortBoxedOperations( + private val delegate: KotlinShortValueClassOperations, +) : BoxedValueClassOperations { + override fun construct(reader: JsonReader, value: Any?): Any = + delegate.constructShort(reader, (value as Number).toShort()) + + override fun constructUncharged(value: Any?): Any = + delegate.constructShortUncharged((value as Number).toShort()) + + override fun unbox(value: Any): Any = delegate.unboxShort(value) +} + +private class IntBoxedOperations( + private val delegate: KotlinIntValueClassOperations, +) : BoxedValueClassOperations { + override fun construct(reader: JsonReader, value: Any?): Any = + delegate.constructInt(reader, (value as Number).toInt()) + + override fun constructUncharged(value: Any?): Any = + delegate.constructIntUncharged((value as Number).toInt()) + + override fun unbox(value: Any): Any = delegate.unboxInt(value) +} + +private class LongBoxedOperations( + private val delegate: KotlinLongValueClassOperations, +) : BoxedValueClassOperations { + override fun construct(reader: JsonReader, value: Any?): Any = + delegate.constructLong(reader, (value as Number).toLong()) + + override fun constructUncharged(value: Any?): Any = + delegate.constructLongUncharged((value as Number).toLong()) + + override fun unbox(value: Any): Any = delegate.unboxLong(value) +} + +private class FloatBoxedOperations( + private val delegate: KotlinFloatValueClassOperations, +) : BoxedValueClassOperations { + override fun construct(reader: JsonReader, value: Any?): Any = + delegate.constructFloat(reader, (value as Number).toFloat()) + + override fun constructUncharged(value: Any?): Any = + delegate.constructFloatUncharged((value as Number).toFloat()) + + override fun unbox(value: Any): Any = delegate.unboxFloat(value) +} + +private class DoubleBoxedOperations( + private val delegate: KotlinDoubleValueClassOperations, +) : BoxedValueClassOperations { + override fun construct(reader: JsonReader, value: Any?): Any = + delegate.constructDouble(reader, (value as Number).toDouble()) + + override fun constructUncharged(value: Any?): Any = + delegate.constructDoubleUncharged((value as Number).toDouble()) + + override fun unbox(value: Any): Any = delegate.unboxDouble(value) +} + +private class CharBoxedOperations( + private val delegate: KotlinCharValueClassOperations, +) : BoxedValueClassOperations { + override fun construct(reader: JsonReader, value: Any?): Any = + delegate.constructChar(reader, value as Char) + + override fun constructUncharged(value: Any?): Any = delegate.constructCharUncharged(value as Char) + + override fun unbox(value: Any): Any = delegate.unboxChar(value) +} + +private class ReferenceBoxedOperations( + private val delegate: KotlinReferenceValueClassOperations, +) : BoxedValueClassOperations { + override fun construct(reader: JsonReader, value: Any?): Any = + delegate.constructValue(reader, value) + + override fun constructUncharged(value: Any?): Any = delegate.constructValueUncharged(value) + + override fun unbox(value: Any): Any? = delegate.unboxValue(value) +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinValueClassMapKeys.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinValueClassMapKeys.kt new file mode 100644 index 0000000000..8384f6b21f --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinValueClassMapKeys.kt @@ -0,0 +1,238 @@ +/* + * 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.fory.json.kotlin + +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.codec.MapKeyCodec +import org.apache.fory.json.meta.JsonCreatorFieldInfo +import org.apache.fory.json.reader.JsonReader +import org.apache.fory.json.writer.JsonWriter +import org.apache.fory.type.Types + +/** Cold selection of the fixed typed member-name operation for a value-class key chain. */ +internal object KotlinValueClassMapKeys { + fun create( + shape: KotlinValueClassShape, + operations: KotlinValueClassOperations, + terminal: MapKeyCodec, + ): MapKeyCodec { + val type = shape.terminalType + val typeId = type.typeExtMeta?.typeId() ?: Types.UNKNOWN + val rawType = type.rawType + // A generic value-class layer has an Object carrier even when its substituted terminal is a + // primitive logical type. That JVM ABI must use the reference operation path; selecting a + // primitive operation from the substituted TypeRef would invent a carrier the class does not + // have. + if (shape.layers.last().carrierClass != Any::class.java) { + when (typeId) { + Types.UINT8 -> + operations.typed>()?.let { + return UByteKey(it) + } + Types.UINT16 -> + operations.typed>()?.let { + return UShortKey(it) + } + Types.UINT32 -> + operations.typed>()?.let { + return UIntKey(it) + } + Types.UINT64 -> + operations.typed>()?.let { + return ULongKey(it) + } + } + when (rawType) { + Byte::class.javaPrimitiveType, + Byte::class.javaObjectType -> + operations.typed>()?.let { + return ByteKey(it) + } + Short::class.javaPrimitiveType, + Short::class.javaObjectType -> + operations.typed>()?.let { + return ShortKey(it) + } + Int::class.javaPrimitiveType, + Int::class.javaObjectType -> + operations.typed>()?.let { + return IntKey(it) + } + Long::class.javaPrimitiveType, + Long::class.javaObjectType -> + operations.typed>()?.let { + return LongKey(it) + } + } + } + return ReferenceKey(boxedOperations(operations), terminal) + } + + @Suppress("UNCHECKED_CAST") + private inline fun KotlinValueClassOperations.typed(): + T? = this as? T +} + +private class ReferenceKey( + private val operations: BoxedValueClassOperations, + private val terminal: MapKeyCodec, +) : MapKeyCodec { + override fun toName(key: Any): String = terminal.toName(operations.unbox(key)) + + override fun fromName(name: String): Any = operations.constructUncharged(terminal.fromName(name)) + + override fun writeName(writer: JsonWriter, key: Any) = + terminal.writeName(writer, operations.unbox(key)) + + override fun readName(reader: JsonReader): Any = + operations.construct(reader, terminal.readName(reader)) +} + +private class ByteKey( + private val operations: KotlinByteValueClassOperations, +) : MapKeyCodec { + override fun toName(key: Any): String = operations.unboxByte(key).toString() + + override fun fromName(name: String): Any = + operations.constructByteUncharged(JsonCreatorFieldInfo.checkedByte(name.toInt())) + + override fun writeName(writer: JsonWriter, key: Any) = + writer.writeIntFieldName(operations.unboxByte(key).toInt()) + + override fun readName(reader: JsonReader): Any = + operations.constructByte(reader, JsonCreatorFieldInfo.checkedByte(reader.readFieldNameInt())) +} + +private class ShortKey( + private val operations: KotlinShortValueClassOperations, +) : MapKeyCodec { + override fun toName(key: Any): String = operations.unboxShort(key).toString() + + override fun fromName(name: String): Any = + operations.constructShortUncharged(JsonCreatorFieldInfo.checkedShort(name.toInt())) + + override fun writeName(writer: JsonWriter, key: Any) = + writer.writeIntFieldName(operations.unboxShort(key).toInt()) + + override fun readName(reader: JsonReader): Any = + operations.constructShort(reader, JsonCreatorFieldInfo.checkedShort(reader.readFieldNameInt())) +} + +private class IntKey( + private val operations: KotlinIntValueClassOperations, +) : MapKeyCodec { + override fun toName(key: Any): String = operations.unboxInt(key).toString() + + override fun fromName(name: String): Any = operations.constructIntUncharged(name.toInt()) + + override fun writeName(writer: JsonWriter, key: Any) = + writer.writeIntFieldName(operations.unboxInt(key)) + + override fun readName(reader: JsonReader): Any = + operations.constructInt(reader, reader.readFieldNameInt()) +} + +private class LongKey( + private val operations: KotlinLongValueClassOperations, +) : MapKeyCodec { + override fun toName(key: Any): String = operations.unboxLong(key).toString() + + override fun fromName(name: String): Any = operations.constructLongUncharged(name.toLong()) + + override fun writeName(writer: JsonWriter, key: Any) = + writer.writeLongFieldName(operations.unboxLong(key)) + + override fun readName(reader: JsonReader): Any = + operations.constructLong(reader, reader.readFieldNameLong()) +} + +private class UByteKey( + private val operations: KotlinByteValueClassOperations, +) : MapKeyCodec { + override fun toName(key: Any): String = (operations.unboxByte(key).toInt() and 0xff).toString() + + override fun fromName(name: String): Any = + operations.constructByteUncharged(checked(name.toUInt().toInt())) + + override fun writeName(writer: JsonWriter, key: Any) = + writer.writeUnsignedIntFieldName(operations.unboxByte(key).toInt() and 0xff) + + override fun readName(reader: JsonReader): Any = + operations.constructByte(reader, checked(reader.readFieldNameUnsignedInt())) + + private fun checked(value: Int): Byte { + if (Integer.compareUnsigned(value, UByte.MAX_VALUE.toInt()) > 0) { + throw ForyJsonException("UByte map-key overflow") + } + return value.toByte() + } +} + +private class UShortKey( + private val operations: KotlinShortValueClassOperations, +) : MapKeyCodec { + override fun toName(key: Any): String = (operations.unboxShort(key).toInt() and 0xffff).toString() + + override fun fromName(name: String): Any = + operations.constructShortUncharged(checked(name.toUInt().toInt())) + + override fun writeName(writer: JsonWriter, key: Any) = + writer.writeUnsignedIntFieldName(operations.unboxShort(key).toInt() and 0xffff) + + override fun readName(reader: JsonReader): Any = + operations.constructShort(reader, checked(reader.readFieldNameUnsignedInt())) + + private fun checked(value: Int): Short { + if (Integer.compareUnsigned(value, UShort.MAX_VALUE.toInt()) > 0) { + throw ForyJsonException("UShort map-key overflow") + } + return value.toShort() + } +} + +private class UIntKey( + private val operations: KotlinIntValueClassOperations, +) : MapKeyCodec { + override fun toName(key: Any): String = Integer.toUnsignedString(operations.unboxInt(key)) + + override fun fromName(name: String): Any = + operations.constructIntUncharged(Integer.parseUnsignedInt(name)) + + override fun writeName(writer: JsonWriter, key: Any) = + writer.writeUnsignedIntFieldName(operations.unboxInt(key)) + + override fun readName(reader: JsonReader): Any = + operations.constructInt(reader, reader.readFieldNameUnsignedInt()) +} + +private class ULongKey( + private val operations: KotlinLongValueClassOperations, +) : MapKeyCodec { + override fun toName(key: Any): String = java.lang.Long.toUnsignedString(operations.unboxLong(key)) + + override fun fromName(name: String): Any = + operations.constructLongUncharged(java.lang.Long.parseUnsignedLong(name)) + + override fun writeName(writer: JsonWriter, key: Any) = + writer.writeUnsignedLongFieldName(operations.unboxLong(key)) + + override fun readName(reader: JsonReader): Any = + operations.constructLong(reader, reader.readFieldNameUnsignedLong()) +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinValueClassMetadata.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinValueClassMetadata.kt new file mode 100644 index 0000000000..a3840a9ac5 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinValueClassMetadata.kt @@ -0,0 +1,416 @@ +/* + * 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.fory.json.kotlin + +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import java.util.IdentityHashMap +import kotlin.metadata.ClassKind +import kotlin.metadata.KmClass +import kotlin.metadata.Visibility +import kotlin.metadata.isSecondary +import kotlin.metadata.isValue +import kotlin.metadata.jvm.KotlinClassMetadata +import kotlin.metadata.jvm.signature +import kotlin.metadata.kind +import kotlin.metadata.visibility +import org.apache.fory.json.ForyJsonException +import org.apache.fory.reflect.TypeRef +import org.apache.fory.serializer.GraphMemoryEstimates + +/** Immutable cold metadata for one exact, possibly nested, value-class occurrence. */ +internal class KotlinValueClassShape( + val ownerType: TypeRef<*>, + val outerNullable: Boolean, + val layers: List, + val terminalType: TypeRef<*>, +) { + val ownerClass: Class<*> = + layers.firstOrNull()?.ownerClass + ?: throw ForyJsonException("A Kotlin value-class shape requires at least one layer") + val underlyingNullable: Boolean = nullable(layers.first().underlyingType) + + init { + if (layers.isEmpty() || layers.first().occurrenceType != ownerType) { + throw ForyJsonException("Invalid Kotlin value-class layer sequence for $ownerType") + } + if (outerNullable && underlyingNullable) { + throw ForyJsonException( + "Unsupported Kotlin JSON value class ${ownerClass.name}: nullable outer and " + + "nullable underlying values need an explicit tagged codec", + ) + } + validateCarriers() + } + + fun requireMapKey() { + for (layer in layers) { + if (nullable(layer.occurrenceType) || nullable(layer.underlyingType)) { + throw ForyJsonException( + "Kotlin JSON value-class map key ${ownerClass.name} must be non-null at every layer", + ) + } + } + if (nullable(terminalType)) { + throw ForyJsonException( + "Kotlin JSON value-class map key ${ownerClass.name} has a nullable terminal type", + ) + } + } + + private fun validateCarriers() { + for (index in layers.indices) { + val layer = layers[index] + if (layer.occurrenceType.rawType != layer.ownerClass) { + invalidLayer(layer, "occurrence type does not name the exact owner") + } + if (index < layers.lastIndex) { + val inner = layers[index + 1] + if (layer.underlyingType != inner.occurrenceType) { + invalidLayer(layer, "underlying type does not match the next semantic layer") + } + if (layer.carrierClass != inner.ownerClass && layer.carrierClass != inner.carrierClass) { + invalidLayer(layer, "physical carrier does not match the nested value class") + } + } else { + if (layer.underlyingType != terminalType) { + invalidLayer(layer, "underlying type does not match the terminal type") + } + val terminalClass = terminalType.rawType + if (layer.carrierClass.isPrimitive) { + if (layer.carrierClass != terminalClass) { + invalidLayer(layer, "primitive carrier does not match the terminal type") + } + } else if (!layer.carrierClass.isAssignableFrom(box(terminalClass))) { + invalidLayer(layer, "reference carrier does not accept the terminal type") + } + } + } + } + + private fun invalidLayer(layer: KotlinValueClassLayer, reason: String): Nothing = + throw ForyJsonException( + "Invalid Kotlin value-class metadata for ${layer.ownerClass.name}: $reason", + ) +} + +/** Exact Kotlin/JVM operations and the logical underlying type for one semantic layer. */ +internal class KotlinValueClassLayer( + val occurrenceType: TypeRef<*>, + val ownerClass: Class<*>, + val underlyingType: TypeRef<*>, + val carrierClass: Class<*>, +) { + val shallowBytes: Int = GraphMemoryEstimates.shallowObjectBytes(ownerClass) +} + +/** Reflection-derived exact operations kept separate from the semantic shape. */ +internal class KotlinReflectiveValueClass( + val shape: KotlinValueClassShape, + val constructors: List, + val boxes: List, + val unboxes: List, +) { + val carrierConstructMethods: Array + val carrierConstructBoxBytes: IntArray + val carrierExtractMethods: Array + + init { + // A lowered parent already stores the outer carrier. Generated code must run every + // constructor-impl but materialize only inner wrappers required by the next layer's JVM ABI; + // the reverse list likewise omits the outer unbox-impl. + val constructMethods = ArrayList(shape.layers.size * 2) + val constructBytes = ArrayList(shape.layers.size * 2) + var index = shape.layers.lastIndex + constructMethods += constructors[index] + constructBytes += 0 + while (index > 0) { + val inner = shape.layers[index] + val outer = shape.layers[index - 1] + if (outer.carrierClass == inner.ownerClass) { + constructMethods += boxes[index] + constructBytes += inner.shallowBytes + } + index-- + constructMethods += constructors[index] + constructBytes += 0 + } + carrierConstructMethods = constructMethods.toTypedArray() + carrierConstructBoxBytes = constructBytes.toIntArray() + + val extractMethods = ArrayList(shape.layers.size - 1) + for (innerIndex in 1 until shape.layers.size) { + if (shape.layers[innerIndex - 1].carrierClass == shape.layers[innerIndex].ownerClass) { + extractMethods += unboxes[innerIndex] + } + } + carrierExtractMethods = extractMethods.toTypedArray() + } +} + +private class ReflectedLayer( + val shape: KotlinValueClassLayer, + val constructor: Method, + val box: Method, + val unbox: Method, +) + +/** Strict Kotlin 2.3 metadata and JVM-descriptor validation for value-class codec construction. */ +internal object KotlinValueClassMetadata { + fun inspect(ownerType: TypeRef<*>): KotlinReflectiveValueClass { + val layers = ArrayList(2) + val constructors = ArrayList(2) + val boxes = ArrayList(2) + val unboxes = ArrayList(2) + val active = IdentityHashMap, Boolean>() + var occurrence = ownerType + var reflected = inspectLayer(occurrence, active) + while (true) { + val layer = reflected + layers += layer.shape + constructors += layer.constructor + boxes += layer.box + unboxes += layer.unbox + val underlying = layer.shape.underlyingType + // Generic value-class layers retain their erased Object carrier. Descending into a + // substituted value-class argument would invent a flattened ABI that the outer class does + // not have; the substituted logical type remains the terminal codec owner. + if ( + layer.shape.carrierClass == Any::class.java || + !isValueClass(underlying.rawType) || + KotlinTemporalCodecs.supports(underlying.rawType) || + KotlinUnsupportedTypes.rejects(underlying.rawType) + ) { + return KotlinReflectiveValueClass( + KotlinValueClassShape( + ownerType, + ownerType.typeExtMeta?.nullable() ?: true, + layers, + underlying, + ), + constructors, + boxes, + unboxes, + ) + } + val nested = inspectLayer(underlying, active) + if ( + layer.shape.carrierClass != nested.shape.ownerClass && + layer.shape.carrierClass != nested.shape.carrierClass + ) { + return KotlinReflectiveValueClass( + KotlinValueClassShape( + ownerType, + ownerType.typeExtMeta?.nullable() ?: true, + layers, + underlying, + ), + constructors, + boxes, + unboxes, + ) + } + occurrence = underlying + reflected = nested + } + } + + fun isValueClass(type: Class<*>): Boolean { + val metadata = type.getAnnotation(Metadata::class.java) ?: return false + val classMetadata = + try { + KotlinClassMetadata.readStrict(metadata) + } catch (_: IllegalArgumentException) { + return false + } + return classMetadata is KotlinClassMetadata.Class && classMetadata.kmClass.isValue + } + + private fun inspectLayer( + occurrenceType: TypeRef<*>, + active: IdentityHashMap, Boolean>, + ): ReflectedLayer { + val rawType = occurrenceType.rawType + if (active.put(rawType, true) != null) { + unsupported(rawType, "recursive value-class underlying type") + } + val metadata = + rawType.getAnnotation(Metadata::class.java) ?: unsupported(rawType, "missing Kotlin metadata") + val classMetadata = + try { + KotlinClassMetadata.readStrict(metadata) + } catch (cause: IllegalArgumentException) { + throw ForyJsonException("Unsupported Kotlin metadata on ${rawType.name}", cause) + } + if (classMetadata !is KotlinClassMetadata.Class) { + unsupported(rawType, "metadata is not a class declaration") + } + val version = classMetadata.version + if (version.major != 2 || version.minor != 3) { + unsupported(rawType, "metadata ABI $version; expected 2.3") + } + val model = classMetadata.kmClass + if (!model.isValue || model.kind != ClassKind.CLASS) { + unsupported(rawType, "declaration is not a value class") + } + val primary = + model.constructors.singleOrNull { !it.isSecondary } + ?: unsupported(rawType, "missing unique primary constructor") + if (primary.visibility != Visibility.PUBLIC && primary.visibility != Visibility.INTERNAL) { + unsupported(rawType, "primary constructor is not JVM-accessible") + } + if (primary.valueParameters.size != 1) { + unsupported(rawType, "primary constructor must have one underlying parameter") + } + val underlyingName = + model.inlineClassUnderlyingPropertyName + ?: unsupported(rawType, "missing underlying property name") + val underlyingKmType = + model.inlineClassUnderlyingType ?: unsupported(rawType, "missing underlying property type") + val parameter = primary.valueParameters.single() + if (parameter.name != underlyingName || parameter.type != underlyingKmType) { + unsupported(rawType, "primary parameter and underlying property metadata disagree") + } + val substitutions = substitutions(occurrenceType, model) + val underlyingType = + KotlinMetadataTypes.resolve(underlyingKmType, rawType.classLoader, substitutions, false) + val constructorSignature = + primary.signature ?: unsupported(rawType, "primary constructor has no JVM signature") + val underlyingField = + rawType.declaredFields.singleOrNull { + !Modifier.isStatic(it.modifiers) && it.name == underlyingName + } ?: unsupported(rawType, "underlying field $underlyingName was not found exactly") + if ( + !Modifier.isPrivate(underlyingField.modifiers) || !Modifier.isFinal(underlyingField.modifiers) + ) { + unsupported(rawType, "underlying field is not an exact private final carrier") + } + val carrier = underlyingField.type + val constructorImpl = exactMethod(rawType, "constructor-impl", arrayOf(carrier), carrier, true) + if ( + constructorSignature.name != constructorImpl.name || + constructorSignature.descriptor != methodDescriptor(constructorImpl) + ) { + unsupported(rawType, "primary constructor metadata does not name the exact constructor-impl") + } + val boxedConstructor = + rawType.declaredConstructors.singleOrNull { + it.parameterTypes.contentEquals(arrayOf(carrier)) + } ?: unsupported(rawType, "boxed constructor does not have the exact carrier descriptor") + if (!Modifier.isPrivate(boxedConstructor.modifiers)) { + unsupported(rawType, "boxed constructor is not private") + } + val boxImpl = exactMethod(rawType, "box-impl", arrayOf(carrier), rawType, true) + val unboxImpl = exactMethod(rawType, "unbox-impl", emptyArray(), carrier, false) + return ReflectedLayer( + KotlinValueClassLayer( + occurrenceType, + rawType, + underlyingType, + carrier, + ), + constructorImpl, + boxImpl, + unboxImpl, + ) + } + + private fun substitutions(ownerType: TypeRef<*>, model: KmClass): Map> { + val substitutions = KotlinMetadataTypes.substitutions(ownerType, model) + if (model.typeParameters.isNotEmpty() && substitutions.size != model.typeParameters.size) { + throw ForyJsonException( + "Kotlin JSON value class ${ownerType.type} requires exact type arguments", + ) + } + for (argument in substitutions.values) { + if (argument.typeExtMeta == null) { + throw ForyJsonException( + "Kotlin JSON value class ${ownerType.type} has a platform-typed argument $argument", + ) + } + } + return substitutions + } + + private fun exactMethod( + owner: Class<*>, + name: String, + parameters: Array>, + result: Class<*>, + static: Boolean, + ): Method { + val method = + owner.declaredMethods.singleOrNull { + it.name == name && it.parameterTypes.contentEquals(parameters) && it.returnType == result + } ?: unsupported(owner, "method $name${descriptor(parameters, result)} was not found exactly") + if (!Modifier.isPublic(method.modifiers) || Modifier.isStatic(method.modifiers) != static) { + unsupported(owner, "method $name${descriptor(parameters, result)} is not directly callable") + } + if (!static && !Modifier.isFinal(method.modifiers)) { + unsupported(owner, "method $name${descriptor(parameters, result)} is not final") + } + return method + } + + private fun unsupported(type: Class<*>, reason: String): Nothing = + throw ForyJsonException("Unsupported Kotlin JSON value class ${type.name}: $reason") +} + +private fun nullable(type: TypeRef<*>): Boolean = type.typeExtMeta?.nullable() == true + +private fun methodDescriptor(method: Method): String = + descriptor(method.parameterTypes, method.returnType) + +private fun descriptor(parameters: Array>, result: Class<*>): String = buildString { + append('(') + parameters.forEach { append(descriptor(it)) } + append(')') + append(descriptor(result)) +} + +private fun descriptor(type: Class<*>): String = + when { + type.isArray -> type.name.replace('.', '/') + !type.isPrimitive -> "L${type.name.replace('.', '/')};" + type == Void.TYPE -> "V" + type == java.lang.Boolean.TYPE -> "Z" + type == java.lang.Byte.TYPE -> "B" + type == java.lang.Short.TYPE -> "S" + type == java.lang.Integer.TYPE -> "I" + type == java.lang.Long.TYPE -> "J" + type == java.lang.Float.TYPE -> "F" + type == java.lang.Double.TYPE -> "D" + type == java.lang.Character.TYPE -> "C" + else -> error("Unsupported primitive carrier $type") + } + +private fun box(type: Class<*>): Class<*> = + when (type) { + java.lang.Boolean.TYPE -> Boolean::class.javaObjectType + java.lang.Byte.TYPE -> Byte::class.javaObjectType + java.lang.Short.TYPE -> Short::class.javaObjectType + java.lang.Integer.TYPE -> Int::class.javaObjectType + java.lang.Long.TYPE -> Long::class.javaObjectType + java.lang.Float.TYPE -> Float::class.javaObjectType + java.lang.Double.TYPE -> Double::class.javaObjectType + java.lang.Character.TYPE -> Char::class.javaObjectType + java.lang.Void.TYPE -> java.lang.Void::class.java + else -> type + } diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinValueClassOperations.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinValueClassOperations.kt new file mode 100644 index 0000000000..67c18c2afc --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinValueClassOperations.kt @@ -0,0 +1,399 @@ +/* + * 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.fory.json.kotlin + +import java.lang.invoke.MethodHandle +import java.lang.invoke.MethodHandles +import java.lang.invoke.MethodType +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.reader.JsonReader +import org.apache.fory.platform.AndroidSupport +import org.apache.fory.platform.internal._JDKAccess + +/** Immutable operations that implement one exact value-class layer chain. */ +internal interface KotlinValueClassOperations { + fun unboxedOperations(): KotlinUnboxedValueClassOperations + + companion object { + fun create(model: KotlinReflectiveValueClass): KotlinValueClassOperations = + if (AndroidSupport.IS_ANDROID) { + KotlinReflectionValueClassOperations(model) + } else { + KotlinMethodHandleValueClassOperations.create(model) + } + } +} + +/** Interpreted and direct-codegen operations for one unboxed parent occurrence. */ +internal interface KotlinUnboxedValueClassOperations { + fun constructCarrier(reader: JsonReader, value: Any?): Any? + + fun extractValue(carrier: Any?): Any? + + fun constructMethods(): Array + + fun constructBoxBytes(): IntArray + + fun extractMethods(): Array +} + +internal interface KotlinBooleanValueClassOperations : KotlinValueClassOperations { + fun constructBoolean(reader: JsonReader, value: Boolean): T + + fun constructBooleanUncharged(value: Boolean): T + + fun unboxBoolean(value: T): Boolean +} + +internal interface KotlinByteValueClassOperations : KotlinValueClassOperations { + fun constructByte(reader: JsonReader, value: Byte): T + + fun constructByteUncharged(value: Byte): T + + fun unboxByte(value: T): Byte +} + +internal interface KotlinShortValueClassOperations : KotlinValueClassOperations { + fun constructShort(reader: JsonReader, value: Short): T + + fun constructShortUncharged(value: Short): T + + fun unboxShort(value: T): Short +} + +internal interface KotlinIntValueClassOperations : KotlinValueClassOperations { + fun constructInt(reader: JsonReader, value: Int): T + + fun constructIntUncharged(value: Int): T + + fun unboxInt(value: T): Int +} + +internal interface KotlinLongValueClassOperations : KotlinValueClassOperations { + fun constructLong(reader: JsonReader, value: Long): T + + fun constructLongUncharged(value: Long): T + + fun unboxLong(value: T): Long +} + +internal interface KotlinFloatValueClassOperations : KotlinValueClassOperations { + fun constructFloat(reader: JsonReader, value: Float): T + + fun constructFloatUncharged(value: Float): T + + fun unboxFloat(value: T): Float +} + +internal interface KotlinDoubleValueClassOperations : KotlinValueClassOperations { + fun constructDouble(reader: JsonReader, value: Double): T + + fun constructDoubleUncharged(value: Double): T + + fun unboxDouble(value: T): Double +} + +internal interface KotlinCharValueClassOperations : KotlinValueClassOperations { + fun constructChar(reader: JsonReader, value: Char): T + + fun constructCharUncharged(value: Char): T + + fun unboxChar(value: T): Char +} + +internal interface KotlinReferenceValueClassOperations : KotlinValueClassOperations { + fun constructValue(reader: JsonReader, value: Any?): T + + fun constructValueUncharged(value: Any?): T + + fun unboxValue(value: T): Any? +} + +/** Android interpreted owner for the same exact methods validated by value-class metadata. */ +private class KotlinReflectionValueClassOperations( + private val model: KotlinReflectiveValueClass, +) : KotlinValueClassOperations, KotlinUnboxedValueClassOperations, BoxedValueClassOperations { + init { + try { + model.constructors.forEach { it.isAccessible = true } + model.boxes.forEach { it.isAccessible = true } + model.unboxes.forEach { it.isAccessible = true } + } catch (cause: RuntimeException) { + throw failure("bind", cause) + } + } + + override fun unboxedOperations(): KotlinUnboxedValueClassOperations = this + + override fun construct(reader: JsonReader, value: Any?): Any = + constructValue(reader, value, true)!! + + override fun constructUncharged(value: Any?): Any = constructValue(null, value, true)!! + + override fun unbox(value: Any): Any? { + var current: Any? = invoke(model.unboxes[0], value) + for (index in 1 until model.shape.layers.size) { + if (model.shape.layers[index - 1].carrierClass == model.shape.layers[index].ownerClass) { + current = invoke(model.unboxes[index], current) + } + } + return current + } + + override fun constructCarrier(reader: JsonReader, value: Any?): Any? = + constructValue(reader, value, false) + + override fun extractValue(carrier: Any?): Any? { + var current = carrier + for (index in 1 until model.shape.layers.size) { + if (model.shape.layers[index - 1].carrierClass == model.shape.layers[index].ownerClass) { + current = invoke(model.unboxes[index], current) + } + } + return current + } + + override fun constructMethods(): Array = model.carrierConstructMethods.clone() + + override fun constructBoxBytes(): IntArray = model.carrierConstructBoxBytes.clone() + + override fun extractMethods(): Array = model.carrierExtractMethods.clone() + + private fun constructValue(reader: JsonReader?, terminal: Any?, boxOuter: Boolean): Any? { + val layers = model.shape.layers + var index = layers.lastIndex + var current = invoke(model.constructors[index], null, terminal) + while (index > 0) { + val inner = layers[index] + val outer = layers[index - 1] + if (outer.carrierClass == inner.ownerClass) { + if (reader != null) reader.reserveGraphMemory(inner.shallowBytes) + current = invoke(model.boxes[index], null, current) + } + index-- + current = invoke(model.constructors[index], null, current) + } + if (boxOuter) { + if (reader != null) reader.reserveGraphMemory(layers[0].shallowBytes) + current = invoke(model.boxes[0], null, current) + } + return current + } + + private fun invoke(method: Method, receiver: Any?, vararg arguments: Any?): Any? = + try { + method.invoke(receiver, *arguments) + } catch (cause: Throwable) { + throw failure(method.name, cause) + } + + private fun failure(operation: String, cause: Throwable): ForyJsonException { + val actual = if (cause is InvocationTargetException) cause.cause ?: cause else cause + if (actual is Error) throw actual + if (actual is ForyJsonException) return actual + return ForyJsonException( + "Kotlin value-class $operation failed for ${model.shape.ownerClass.name}", + actual, + ) + } +} + +/** HotSpot owner; method discovery and MethodHandle composition finish during codec creation. */ +private object KotlinMethodHandleValueClassOperations { + private val LOOKUP: MethodHandles.Lookup = MethodHandles.lookup() + private val RESERVE: MethodHandle = + LOOKUP.findStatic( + KotlinMethodHandleValueClassOperations::class.java, + "reserve", + MethodType.methodType(Void.TYPE, JsonReader::class.java, Int::class.javaPrimitiveType), + ) + + fun create(model: KotlinReflectiveValueClass): KotlinValueClassOperations { + val constructors = bind(model.shape, model.constructors, "constructor-impl") + val boxes = bind(model.shape, model.boxes, "box-impl") + val unboxes = bind(model.shape, model.unboxes, "unbox-impl") + val charged = buildConstruct(model.shape, constructors, boxes, true) + val uncharged = buildConstruct(model.shape, constructors, boxes, false) + val unbox = buildUnbox(model.shape, unboxes) + val carrier = model.shape.layers.last().carrierClass + val invocationCarrier = if (carrier.isPrimitive) carrier else Any::class.java + val unboxed = createUnboxed(model, constructors, boxes, unboxes) + return KotlinExactValueClassOperations.create( + model.shape.ownerClass, + carrier, + charged.asType( + MethodType.methodType( + Any::class.java, + JsonReader::class.java, + invocationCarrier, + ), + ), + uncharged.asType(MethodType.methodType(Any::class.java, invocationCarrier)), + unbox.asType(MethodType.methodType(invocationCarrier, Any::class.java)), + unboxed, + ) + } + + private fun createUnboxed( + model: KotlinReflectiveValueClass, + constructors: List, + boxes: List, + unboxes: List, + ): KotlinUnboxedValueClassOperations { + val shape = model.shape + val carrier = shape.layers.first().carrierClass + val construct = + buildCarrierConstruct(shape, constructors, boxes) + .asType( + MethodType.methodType( + Any::class.java, + JsonReader::class.java, + Any::class.java, + ), + ) + val extract = + buildCarrierExtract(shape, unboxes) + .asType(MethodType.methodType(Any::class.java, Any::class.java)) + return KotlinExactUnboxedValueOperations.create( + shape.ownerClass, + carrier, + construct, + extract, + model.carrierConstructMethods, + model.carrierConstructBoxBytes, + model.carrierExtractMethods, + ) + } + + @JvmStatic + private fun reserve(reader: JsonReader, bytes: Int) { + reader.reserveGraphMemory(bytes) + } + + private fun bind( + shape: KotlinValueClassShape, + methods: List, + operation: String, + ): List = + methods.mapIndexed { index, method -> + try { + _JDKAccess._trustedLookup(shape.layers[index].ownerClass).unreflect(method) + } catch (cause: IllegalAccessException) { + throw ForyJsonException( + "Cannot bind exact Kotlin value-class $operation for ${shape.layers[index].ownerClass.name}", + cause, + ) + } + } + + private fun buildConstruct( + shape: KotlinValueClassShape, + constructors: List, + boxes: List, + charge: Boolean, + ): MethodHandle { + val layers = shape.layers + var index = layers.lastIndex + var current = constructors[index] + if (charge) current = MethodHandles.dropArguments(current, 0, JsonReader::class.java) + while (index > 0) { + val inner = layers[index] + val outer = layers[index - 1] + if (outer.carrierClass == inner.ownerClass) { + current = + if (charge) composeCharged(current, boxes[index], inner.shallowBytes) + else MethodHandles.filterReturnValue(current, boxes[index]) + } + index-- + current = MethodHandles.filterReturnValue(current, constructors[index]) + } + return if (charge) composeCharged(current, boxes[0], layers[0].shallowBytes) + else MethodHandles.filterReturnValue(current, boxes[0]) + } + + private fun buildCarrierConstruct( + shape: KotlinValueClassShape, + constructors: List, + boxes: List, + ): MethodHandle { + val layers = shape.layers + var index = layers.lastIndex + var current = MethodHandles.dropArguments(constructors[index], 0, JsonReader::class.java) + while (index > 0) { + val inner = layers[index] + val outer = layers[index - 1] + if (outer.carrierClass == inner.ownerClass) { + current = composeCharged(current, boxes[index], inner.shallowBytes) + } + index-- + current = MethodHandles.filterReturnValue(current, constructors[index]) + } + return current + } + + private fun composeCharged(current: MethodHandle, box: MethodHandle, bytes: Int): MethodHandle { + val chargedBox = chargedBox(box, bytes) + val filtered = MethodHandles.collectArguments(chargedBox, 1, current) + val input = current.type().parameterType(1) + return MethodHandles.permuteArguments( + filtered, + MethodType.methodType(box.type().returnType(), JsonReader::class.java, input), + 0, + 0, + 1, + ) + } + + private fun chargedBox(box: MethodHandle, bytes: Int): MethodHandle { + val target = MethodHandles.dropArguments(box, 0, JsonReader::class.java) + val reserve = MethodHandles.insertArguments(RESERVE, 1, bytes) + return MethodHandles.foldArguments(target, reserve) + } + + private fun buildUnbox( + shape: KotlinValueClassShape, + unboxes: List, + ): MethodHandle { + val layers = shape.layers + var current = unboxes[0] + for (index in 1 until layers.size) { + if (layers[index - 1].carrierClass == layers[index].ownerClass) { + current = MethodHandles.filterReturnValue(current, unboxes[index]) + } + } + return current + } + + private fun buildCarrierExtract( + shape: KotlinValueClassShape, + unboxes: List, + ): MethodHandle { + val layers = shape.layers + var current = MethodHandles.identity(layers.first().carrierClass) + for (index in 1 until layers.size) { + if (layers[index - 1].carrierClass == layers[index].ownerClass) { + current = MethodHandles.filterReturnValue(current, unboxes[index]) + } + } + return current + } +} diff --git a/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinValueClassPrimitiveCodecs.kt b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinValueClassPrimitiveCodecs.kt new file mode 100644 index 0000000000..8d756b0b46 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/main/kotlin/org/apache/fory/json/kotlin/KotlinValueClassPrimitiveCodecs.kt @@ -0,0 +1,368 @@ +/* + * 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.fory.json.kotlin + +import org.apache.fory.json.codec.JsonValueCodec +import org.apache.fory.json.codec.ScalarCodecs +import org.apache.fory.json.meta.JsonCreatorFieldInfo +import org.apache.fory.json.reader.Latin1JsonReader +import org.apache.fory.json.reader.Utf16JsonReader +import org.apache.fory.json.reader.Utf8JsonReader +import org.apache.fory.json.resolver.JsonTypeInfo +import org.apache.fory.json.writer.StringJsonWriter +import org.apache.fory.json.writer.Utf8JsonWriter +import org.apache.fory.type.Types + +/** Cold selection of the fixed primitive or generic value-class execution capability. */ +internal object KotlinValueClassCapabilities { + fun bind( + shape: KotlinValueClassShape, + operations: KotlinValueClassOperations, + child: JsonTypeInfo, + ): KotlinValueClassCapability { + val terminal = shape.terminalType + val typeId = terminal.typeExtMeta?.typeId() ?: Types.UNKNOWN + val carrier = shape.layers.last().carrierClass + when (carrier) { + java.lang.Boolean.TYPE -> { + val expected = ScalarCodecs.BooleanCodec.PRIMITIVE + if (typeId == Types.UNKNOWN && exact(child, expected)) { + operations.typed>()?.let { + return BooleanCapability(it) + } + } + } + java.lang.Byte.TYPE -> { + if (typeId == Types.UINT8) { + val expected = KotlinUnsignedCodecs.scalar(typeId, false, false) + if (exact(child, expected)) { + operations.typed>()?.let { + return UByteCapability(it) + } + } + } else if (typeId == Types.UNKNOWN && exact(child, ScalarCodecs.ByteCodec.PRIMITIVE)) { + operations.typed>()?.let { + return ByteCapability(it) + } + } + } + java.lang.Short.TYPE -> { + if (typeId == Types.UINT16) { + val expected = KotlinUnsignedCodecs.scalar(typeId, false, false) + if (exact(child, expected)) { + operations.typed>()?.let { + return UShortCapability(it) + } + } + } else if (typeId == Types.UNKNOWN && exact(child, ScalarCodecs.ShortCodec.PRIMITIVE)) { + operations.typed>()?.let { + return ShortCapability(it) + } + } + } + java.lang.Integer.TYPE -> { + if (typeId == Types.UINT32) { + val expected = KotlinUnsignedCodecs.scalar(typeId, false, false) + if (exact(child, expected)) { + operations.typed>()?.let { + return UIntCapability(it) + } + } + } else if (typeId == Types.UNKNOWN && exact(child, ScalarCodecs.IntCodec.PRIMITIVE)) { + operations.typed>()?.let { + return IntCapability(it) + } + } + } + java.lang.Long.TYPE -> { + if (typeId == Types.UINT64) { + val expected = KotlinUnsignedCodecs.scalar(typeId, false, false) + if (exact(child, expected)) { + operations.typed>()?.let { + return ULongCapability(it) + } + } + } else if (typeId == Types.UNKNOWN && exact(child, ScalarCodecs.LongCodec.PRIMITIVE)) { + operations.typed>()?.let { + return LongCapability(it) + } + } + } + java.lang.Float.TYPE -> + if (typeId == Types.UNKNOWN && exact(child, ScalarCodecs.FloatCodec.PRIMITIVE)) { + operations.typed>()?.let { + return FloatCapability(it) + } + } + java.lang.Double.TYPE -> + if (typeId == Types.UNKNOWN && exact(child, ScalarCodecs.DoubleCodec.PRIMITIVE)) { + operations.typed>()?.let { + return DoubleCapability(it) + } + } + java.lang.Character.TYPE -> + if (typeId == Types.UNKNOWN && exact(child, ScalarCodecs.CharCodec.PRIMITIVE)) { + operations.typed>()?.let { + return CharCapability(it) + } + } + } + return GenericValueClassCapability(boxedOperations(operations), child) + } + + private fun exact(child: JsonTypeInfo, expected: JsonValueCodec<*>): Boolean = + child.stringWriter() === expected && + child.utf8Writer() === expected && + child.latin1Reader() === expected && + child.utf16Reader() === expected && + child.utf8Reader() === expected + + @Suppress("UNCHECKED_CAST") + private inline fun KotlinValueClassOperations.typed(): + T? = this as? T +} + +private class BooleanCapability( + private val operations: KotlinBooleanValueClassOperations, +) : KotlinValueClassCapability { + override fun writeString(writer: StringJsonWriter, value: Any) = + writer.writeBoolean(operations.unboxBoolean(value)) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any) = + writer.writeBoolean(operations.unboxBoolean(value)) + + override fun readLatin1(reader: Latin1JsonReader): Any = + operations.constructBoolean(reader, reader.readBooleanValue()) + + override fun readUtf16(reader: Utf16JsonReader): Any = + operations.constructBoolean(reader, reader.readBooleanValue()) + + override fun readUtf8(reader: Utf8JsonReader): Any = + operations.constructBoolean(reader, reader.readBooleanValue()) +} + +private class ByteCapability( + private val operations: KotlinByteValueClassOperations, +) : KotlinValueClassCapability { + override fun writeString(writer: StringJsonWriter, value: Any) = + writer.writeInt(operations.unboxByte(value).toInt()) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any) = + writer.writeInt(operations.unboxByte(value).toInt()) + + override fun readLatin1(reader: Latin1JsonReader): Any = + operations.constructByte(reader, JsonCreatorFieldInfo.checkedByte(reader.readIntValue())) + + override fun readUtf16(reader: Utf16JsonReader): Any = + operations.constructByte(reader, JsonCreatorFieldInfo.checkedByte(reader.readIntValue())) + + override fun readUtf8(reader: Utf8JsonReader): Any = + operations.constructByte(reader, JsonCreatorFieldInfo.checkedByte(reader.readIntValue())) +} + +private class ShortCapability( + private val operations: KotlinShortValueClassOperations, +) : KotlinValueClassCapability { + override fun writeString(writer: StringJsonWriter, value: Any) = + writer.writeInt(operations.unboxShort(value).toInt()) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any) = + writer.writeInt(operations.unboxShort(value).toInt()) + + override fun readLatin1(reader: Latin1JsonReader): Any = + operations.constructShort(reader, JsonCreatorFieldInfo.checkedShort(reader.readIntValue())) + + override fun readUtf16(reader: Utf16JsonReader): Any = + operations.constructShort(reader, JsonCreatorFieldInfo.checkedShort(reader.readIntValue())) + + override fun readUtf8(reader: Utf8JsonReader): Any = + operations.constructShort(reader, JsonCreatorFieldInfo.checkedShort(reader.readIntValue())) +} + +private class IntCapability( + private val operations: KotlinIntValueClassOperations, +) : KotlinValueClassCapability { + override fun writeString(writer: StringJsonWriter, value: Any) = + writer.writeInt(operations.unboxInt(value)) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any) = + writer.writeInt(operations.unboxInt(value)) + + override fun readLatin1(reader: Latin1JsonReader): Any = + operations.constructInt(reader, reader.readIntValue()) + + override fun readUtf16(reader: Utf16JsonReader): Any = + operations.constructInt(reader, reader.readIntValue()) + + override fun readUtf8(reader: Utf8JsonReader): Any = + operations.constructInt(reader, reader.readIntValue()) +} + +private class LongCapability( + private val operations: KotlinLongValueClassOperations, +) : KotlinValueClassCapability { + override fun writeString(writer: StringJsonWriter, value: Any) = + writer.writeLong(operations.unboxLong(value)) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any) = + writer.writeLong(operations.unboxLong(value)) + + override fun readLatin1(reader: Latin1JsonReader): Any = + operations.constructLong(reader, reader.readLongValue()) + + override fun readUtf16(reader: Utf16JsonReader): Any = + operations.constructLong(reader, reader.readLongValue()) + + override fun readUtf8(reader: Utf8JsonReader): Any = + operations.constructLong(reader, reader.readLongValue()) +} + +private class FloatCapability( + private val operations: KotlinFloatValueClassOperations, +) : KotlinValueClassCapability { + override fun writeString(writer: StringJsonWriter, value: Any) = + writer.writeFloat(operations.unboxFloat(value)) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any) = + writer.writeFloat(operations.unboxFloat(value)) + + override fun readLatin1(reader: Latin1JsonReader): Any = + operations.constructFloat(reader, reader.readFloat()) + + override fun readUtf16(reader: Utf16JsonReader): Any = + operations.constructFloat(reader, reader.readFloat()) + + override fun readUtf8(reader: Utf8JsonReader): Any = + operations.constructFloat(reader, reader.readFloat()) +} + +private class DoubleCapability( + private val operations: KotlinDoubleValueClassOperations, +) : KotlinValueClassCapability { + override fun writeString(writer: StringJsonWriter, value: Any) = + writer.writeDouble(operations.unboxDouble(value)) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any) = + writer.writeDouble(operations.unboxDouble(value)) + + override fun readLatin1(reader: Latin1JsonReader): Any = + operations.constructDouble(reader, reader.readDouble()) + + override fun readUtf16(reader: Utf16JsonReader): Any = + operations.constructDouble(reader, reader.readDouble()) + + override fun readUtf8(reader: Utf8JsonReader): Any = + operations.constructDouble(reader, reader.readDouble()) +} + +private class CharCapability( + private val operations: KotlinCharValueClassOperations, +) : KotlinValueClassCapability { + override fun writeString(writer: StringJsonWriter, value: Any) = + writer.writeChar(operations.unboxChar(value)) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any) = + writer.writeChar(operations.unboxChar(value)) + + override fun readLatin1(reader: Latin1JsonReader): Any = + operations.constructChar(reader, reader.readChar()) + + override fun readUtf16(reader: Utf16JsonReader): Any = + operations.constructChar(reader, reader.readChar()) + + override fun readUtf8(reader: Utf8JsonReader): Any = + operations.constructChar(reader, reader.readChar()) +} + +private class UByteCapability( + private val operations: KotlinByteValueClassOperations, +) : KotlinValueClassCapability { + override fun writeString(writer: StringJsonWriter, value: Any) = + KotlinUnsignedCodecs.writeUByteRaw(writer, operations.unboxByte(value)) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any) = + KotlinUnsignedCodecs.writeUByteRaw(writer, operations.unboxByte(value)) + + override fun readLatin1(reader: Latin1JsonReader): Any = + operations.constructByte(reader, KotlinUnsignedCodecs.readUByteRaw(reader)) + + override fun readUtf16(reader: Utf16JsonReader): Any = + operations.constructByte(reader, KotlinUnsignedCodecs.readUByteRaw(reader)) + + override fun readUtf8(reader: Utf8JsonReader): Any = + operations.constructByte(reader, KotlinUnsignedCodecs.readUByteRaw(reader)) +} + +private class UShortCapability( + private val operations: KotlinShortValueClassOperations, +) : KotlinValueClassCapability { + override fun writeString(writer: StringJsonWriter, value: Any) = + KotlinUnsignedCodecs.writeUShortRaw(writer, operations.unboxShort(value)) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any) = + KotlinUnsignedCodecs.writeUShortRaw(writer, operations.unboxShort(value)) + + override fun readLatin1(reader: Latin1JsonReader): Any = + operations.constructShort(reader, KotlinUnsignedCodecs.readUShortRaw(reader)) + + override fun readUtf16(reader: Utf16JsonReader): Any = + operations.constructShort(reader, KotlinUnsignedCodecs.readUShortRaw(reader)) + + override fun readUtf8(reader: Utf8JsonReader): Any = + operations.constructShort(reader, KotlinUnsignedCodecs.readUShortRaw(reader)) +} + +private class UIntCapability( + private val operations: KotlinIntValueClassOperations, +) : KotlinValueClassCapability { + override fun writeString(writer: StringJsonWriter, value: Any) = + KotlinUnsignedCodecs.writeUIntRaw(writer, operations.unboxInt(value)) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any) = + KotlinUnsignedCodecs.writeUIntRaw(writer, operations.unboxInt(value)) + + override fun readLatin1(reader: Latin1JsonReader): Any = + operations.constructInt(reader, KotlinUnsignedCodecs.readUIntRaw(reader)) + + override fun readUtf16(reader: Utf16JsonReader): Any = + operations.constructInt(reader, KotlinUnsignedCodecs.readUIntRaw(reader)) + + override fun readUtf8(reader: Utf8JsonReader): Any = + operations.constructInt(reader, KotlinUnsignedCodecs.readUIntRaw(reader)) +} + +private class ULongCapability( + private val operations: KotlinLongValueClassOperations, +) : KotlinValueClassCapability { + override fun writeString(writer: StringJsonWriter, value: Any) = + KotlinUnsignedCodecs.writeULongRaw(writer, operations.unboxLong(value)) + + override fun writeUtf8(writer: Utf8JsonWriter, value: Any) = + KotlinUnsignedCodecs.writeULongRaw(writer, operations.unboxLong(value)) + + override fun readLatin1(reader: Latin1JsonReader): Any = + operations.constructLong(reader, KotlinUnsignedCodecs.readULongRaw(reader)) + + override fun readUtf16(reader: Utf16JsonReader): Any = + operations.constructLong(reader, KotlinUnsignedCodecs.readULongRaw(reader)) + + override fun readUtf8(reader: Utf8JsonReader): Any = + operations.constructLong(reader, KotlinUnsignedCodecs.readULongRaw(reader)) +} diff --git a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/ForyJsonKotlinTest.kt b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/ForyJsonKotlinTest.kt new file mode 100644 index 0000000000..662e20d3ca --- /dev/null +++ b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/ForyJsonKotlinTest.kt @@ -0,0 +1,147 @@ +/* + * 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.fory.json.kotlin + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.apache.fory.json.ForyJsonException +import org.apache.fory.type.Types + +class ForyJsonKotlinTest { + data class Account(val id: Long, val name: String, val label: String? = null) + + data class Box(val value: T) + + data class Required(val id: Long, val name: String) + + data class EvaluatedDefault( + val seed: Int, + val values: MutableList = mutableListOf(nextDefault++), + ) { + companion object { + var nextDefault: Int = 1 + } + } + + data class ReferencedDefault(val base: Int, val derived: Int = base + 1) + + data class UnsignedValues(val count: UInt, val total: ULong, val optional: UInt?) + + object Marker + + @Test + fun dataClass() { + val fory = ForyJsonKotlin.builder().withAsyncCompilation(false).build() + val account = Account(7, "Ada", "owner") + val json = fory.toJson(account, jsonTypeRef()) + assertEquals(account, fory.fromJson(json, jsonTypeRef())) + assertEquals( + account, + fory.fromJson(fory.toJsonBytes(account, jsonTypeRef()), jsonTypeRef()) + ) + } + + @Test + fun defaultArgument() { + val fory = ForyJsonKotlin.builder().withAsyncCompilation(false).build() + assertEquals( + Account(9, "default"), + fory.fromJson("{\"id\":9,\"name\":\"default\"}", jsonTypeRef()) + ) + assertEquals( + Account(9, "explicit", null), + fory.fromJson("{\"id\":9,\"name\":\"explicit\",\"label\":null}", jsonTypeRef()) + ) + assertTrue( + fory.toJson(Account(9, "default"), jsonTypeRef()).contains("\"label\":null") + ) + } + + @Test + fun requiredArguments() { + val fory = ForyJsonKotlin.builder().withAsyncCompilation(false).build() + assertFailsWith { fory.fromJson("{\"id\":9}", jsonTypeRef()) } + assertFailsWith { + fory.fromJson("{\"id\":9,\"name\":null}", jsonTypeRef()) + } + } + + @Test + fun evaluatesDefaultPerObject() { + val fory = ForyJsonKotlin.builder().withAsyncCompilation(false).build() + EvaluatedDefault.nextDefault = 1 + val first = fory.fromJson("{\"seed\":1}", jsonTypeRef()) + val second = fory.fromJson("{\"seed\":2}", jsonTypeRef()) + assertEquals(listOf(1), first.values) + assertEquals(listOf(2), second.values) + first.values += 9 + assertEquals(listOf(2), second.values) + } + + @Test + fun defaultReferencesEarlierArgument() { + val fory = ForyJsonKotlin.builder().withAsyncCompilation(false).build() + assertEquals( + ReferencedDefault(41, 42), + fory.fromJson("{\"base\":41}", jsonTypeRef()), + ) + } + + @Test + fun genericClass() { + val fory = ForyJsonKotlin.builder().withAsyncCompilation(false).build() + val type = jsonTypeRef>>() + val value = Box(listOf("one", "two")) + assertEquals(value, fory.fromJson(fory.toJson(value, type), type)) + } + + @Test + fun singleton() { + val fory = ForyJsonKotlin.builder().withAsyncCompilation(false).build() + assertEquals("{}", fory.toJson(Marker, jsonTypeRef())) + assertSame(Marker, fory.fromJson("{}", jsonTypeRef())) + } + + @OptIn(ExperimentalUnsignedTypes::class) + @Test + fun unsignedValues() { + val fory = ForyJsonKotlin.builder().withAsyncCompilation(false).build() + val value = UnsignedValues(UInt.MAX_VALUE, ULong.MAX_VALUE, 17u) + val json = fory.toJson(value, jsonTypeRef()) + assertEquals(value, fory.fromJson(json, jsonTypeRef())) + assertEquals("4294967295", fory.toJson(UInt.MAX_VALUE, jsonTypeRef())) + assertEquals(UInt.MAX_VALUE, fory.fromJson("4294967295", jsonTypeRef())) + assertEquals(ULong.MAX_VALUE, fory.fromJson("18446744073709551615", jsonTypeRef())) + val listType = jsonTypeRef>() + assertEquals(listOf(0u, UInt.MAX_VALUE), fory.fromJson("[0,4294967295]", listType)) + } + + @Test + fun unsignedMetadata() { + val model = KotlinMetadataModels.objectModel(jsonTypeRef()) + assertEquals( + listOf(Types.UINT32, Types.UINT64, Types.UINT32), + model.propertyTypes().map { it.typeExtMeta.typeId() }, + ) + } +} diff --git a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinAnnotationRuntimeTest.kt b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinAnnotationRuntimeTest.kt new file mode 100644 index 0000000000..15c05f5580 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinAnnotationRuntimeTest.kt @@ -0,0 +1,338 @@ +/* + * 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.fory.json.kotlin + +import java.util.Optional +import kotlin.jvm.JvmInline +import kotlin.jvm.JvmName +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.PropertyNamingStrategy +import org.apache.fory.json.annotation.JsonCodec +import org.apache.fory.json.annotation.JsonCreator +import org.apache.fory.json.annotation.JsonIgnore +import org.apache.fory.json.annotation.JsonMixin +import org.apache.fory.json.annotation.JsonProperty +import org.apache.fory.json.codec.AbstractJsonValueCodec +import org.apache.fory.json.codec.MapKeyCodec +import org.apache.fory.json.reader.JsonReader +import org.apache.fory.json.writer.JsonWriter + +class TaggedKotlinStringCodec : AbstractJsonValueCodec() { + override fun write(writer: JsonWriter, value: String) { + writer.writeString("tag:$value") + } + + override fun read(reader: JsonReader): String { + val value = reader.readString() + if (!value.startsWith("tag:")) { + throw ForyJsonException("Expected tagged Kotlin string") + } + return value.substring(4) + } +} + +class OtherKotlinStringCodec : AbstractJsonValueCodec() { + override fun write(writer: JsonWriter, value: String) { + writer.writeString("other:$value") + } + + override fun read(reader: JsonReader): String = reader.readString().removePrefix("other:") +} + +class TaggedIntKeyCodec : MapKeyCodec { + override fun toName(key: Any): String = "key:$key" + + override fun fromName(name: String): Any { + if (!name.startsWith("key:")) throw ForyJsonException("Expected tagged integer key") + return name.substring(4).toInt() + } +} + +@JvmInline value class ExplicitCreatorId(val value: Int) + +class KotlinAnnotationRuntimeTest { + data class UseSiteModel( + @field:JsonCodec(TaggedKotlinStringCodec::class) val fieldValue: String, + @get:JsonCodec(TaggedKotlinStringCodec::class) val getterValue: String, + @param:JsonCodec(TaggedKotlinStringCodec::class) val parameterValue: String, + @set:JsonCodec(TaggedKotlinStringCodec::class) var setterValue: String, + @setparam:JsonCodec(TaggedKotlinStringCodec::class) var setterParameterValue: String, + @field:JsonCodec(TaggedKotlinStringCodec::class) + @get:JsonCodec(TaggedKotlinStringCodec::class) + val mergedValue: String, + ) + + data class ChildCodecModel( + @field:JsonCodec(elementCodec = TaggedKotlinStringCodec::class) val values: List, + @field:JsonCodec(contentCodec = TaggedKotlinStringCodec::class) val optional: Optional, + @field:JsonCodec(valueCodec = TaggedKotlinStringCodec::class) val entries: Map, + @field:JsonCodec(keyCodec = TaggedIntKeyCodec::class) val keyed: Map, + ) + + data class ConflictingCodec( + @field:JsonCodec(TaggedKotlinStringCodec::class) + @get:JsonCodec(OtherKotlinStringCodec::class) + val value: String, + ) + + data class InvalidSetterProperty( + @setparam:JsonProperty("renamed") var value: String, + ) + + data class PropertyUseSites( + @field:JsonProperty("field_name") val fieldValue: String, + @get:JsonProperty("getter_name") val getterValue: String, + @param:JsonProperty("parameter_name") val parameterValue: String, + @set:JsonProperty("setter_name") var setterValue: String, + @param:JsonProperty("bare_name") val bareValue: String, + ) + + class SecondaryCreator + private constructor( + val value: String, + val count: Int, + @get:JsonIgnore val selected: Boolean, + ) { + @JsonCreator(value = ["value", "count"]) + constructor( + sourceValue: String, + sourceCount: Int = 7, + ) : this(sourceValue, sourceCount, true) + + override fun equals(other: Any?): Boolean = + other is SecondaryCreator && + value == other.value && + count == other.count && + selected == other.selected + + override fun hashCode(): Int = 31 * (31 * value.hashCode() + count) + selected.hashCode() + } + + class FactoryCreator + private constructor( + @get:JsonProperty("wire_value") val value: String, + @get:JsonIgnore val selected: Boolean, + ) { + companion object { + @JvmStatic + @JsonCreator + fun create(@JsonProperty("wire_value") sourceValue: String): FactoryCreator = + FactoryCreator(sourceValue, true) + } + + override fun equals(other: Any?): Boolean = + other is FactoryCreator && value == other.value && selected == other.selected + + override fun hashCode(): Int = 31 * value.hashCode() + selected.hashCode() + } + + class MangledFactoryCreator + private constructor( + @get:JsonProperty("wire_value") val value: String, + ) { + companion object { + @JvmStatic + @JvmName("create-value") + @JsonCreator + fun create(@JsonProperty("wire_value") sourceValue: String): MangledFactoryCreator = + MangledFactoryCreator(sourceValue) + } + + override fun equals(other: Any?): Boolean = + other is MangledFactoryCreator && value == other.value + + override fun hashCode(): Int = value.hashCode() + } + + class MixinSelectedCreator { + val displayName: String + @get:JsonIgnore val route: String + + constructor(sourceText: String) { + displayName = sourceText + route = "text" + } + + constructor(sourceNumber: Int) { + displayName = sourceNumber.toString() + route = "number" + } + + override fun equals(other: Any?): Boolean = + other is MixinSelectedCreator && displayName == other.displayName && route == other.route + + override fun hashCode(): Int = 31 * displayName.hashCode() + route.hashCode() + } + + @JsonMixin(target = MixinSelectedCreator::class) + abstract class MixinSelectedCreatorAnnotations + @JsonCreator(value = ["displayName"]) + constructor(sourceText: String) + + class ValueClassCreator + private constructor( + val id: ExplicitCreatorId, + @get:JsonIgnore val selected: Boolean, + ) { + @JsonCreator(value = ["id"]) constructor(sourceId: ExplicitCreatorId) : this(sourceId, true) + + override fun equals(other: Any?): Boolean = + other is ValueClassCreator && id == other.id && selected == other.selected + + override fun hashCode(): Int = 31 * id.hashCode() + selected.hashCode() + } + + @Test + fun codecUseSites() { + val value = UseSiteModel("field", "getter", "parameter", "setter", "setparam", "merged") + forEachJsonMode { json -> + val type = jsonTypeRef() + val text = json.toJson(value, type) + listOf("field", "getter", "parameter", "setter", "setparam", "merged").forEach { + assertTrue(text.contains("\"tag:$it\""), text) + } + assertEquals(value, json.fromJson(text, type)) + assertEquals(value, json.fromJson(json.toJsonBytes(value, type), type)) + assertEquals( + UseSiteModel("漢", "getter", "parameter", "setter", "setparam", "merged"), + json.fromJson( + """{"fieldValue":"tag:漢","getterValue":"tag:getter","parameterValue":"tag:parameter","setterValue":"tag:setter","setterParameterValue":"tag:setparam","mergedValue":"tag:merged"}""", + type, + ), + ) + } + } + + @Test + fun childCodecSelections() { + val value = + ChildCodecModel( + values = listOf("one", "two"), + optional = Optional.of("optional"), + entries = linkedMapOf("first" to "value"), + keyed = linkedMapOf(7 to "seven"), + ) + forEachJsonMode { json -> + val type = jsonTypeRef() + val text = json.toJson(value, type) + assertTrue(text.contains("[\"tag:one\",\"tag:two\"]"), text) + assertTrue(text.contains("\"optional\":\"tag:optional\""), text) + assertTrue(text.contains("\"first\":\"tag:value\""), text) + assertTrue(text.contains("\"key:7\":\"seven\""), text) + assertEquals(value, json.fromJson(text, type)) + assertEquals(value, json.fromJson(json.toJsonBytes(value, type), type)) + } + } + + @Test + fun propertyUseSites() { + val value = PropertyUseSites("field", "getter", "parameter", "setter", "bare") + forEachJsonMode { json -> + val type = jsonTypeRef() + val text = json.toJson(value, type) + listOf("field_name", "getter_name", "parameter_name", "setter_name", "bare_name").forEach { + assertTrue(text.contains("\"$it\""), text) + } + assertEquals(value, json.fromJson(text, type)) + assertEquals(value, json.fromJson(json.toJsonBytes(value, type), type)) + } + } + + @Test + fun annotationConflictsAreColdFailures() { + val json = newKotlinJson(KotlinJsonTestMode.INTERPRETED) + assertFailsWith { + json.fromJson("{\"value\":\"tag:value\"}", jsonTypeRef()) + } + assertFailsWith { + json.fromJson("{\"renamed\":\"value\"}", jsonTypeRef()) + } + } + + @Test + fun explicitSecondaryCreator() { + forEachJsonMode { json -> + val type = jsonTypeRef() + val defaulted = SecondaryCreator("漢") + assertEquals(defaulted, json.fromJson("{\"value\":\"漢\"}", type)) + assertEquals(defaulted, json.fromJson("{\"value\":\"漢\"}".toByteArray(), type)) + assertEquals(defaulted, json.fromJson(json.toJson(defaulted, type), type)) + assertEquals(defaulted, json.fromJson(json.toJsonBytes(defaulted, type), type)) + assertFailsWith { json.fromJson("{}", type) } + assertFailsWith { json.fromJson("{\"value\":null}", type) } + } + } + + @Test + fun explicitStaticFactory() { + forEachJsonMode { json -> + val type = jsonTypeRef() + val expected = FactoryCreator.create("漢") + assertEquals(expected, json.fromJson("{\"wire_value\":\"漢\"}", type)) + assertEquals(expected, json.fromJson("{\"wire_value\":\"漢\"}".toByteArray(), type)) + assertEquals(expected, json.fromJson(json.toJson(expected, type), type)) + assertEquals(expected, json.fromJson(json.toJsonBytes(expected, type), type)) + assertFailsWith { json.fromJson("{\"wire_value\":null}", type) } + } + } + + @Test + fun mangledStaticFactory() { + forEachJsonMode { json -> + val type = jsonTypeRef() + val expected = MangledFactoryCreator.create("漢") + assertEquals(expected, json.fromJson("{\"wire_value\":\"漢\"}", type)) + assertEquals(expected, json.fromJson(json.toJsonBytes(expected, type), type)) + } + } + + @Test + fun mixinCreatorMapping() { + KotlinJsonTestMode.entries.forEach { mode -> + val json = + newKotlinJson(mode) { + registerMixin(MixinSelectedCreatorAnnotations::class.java) + withPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE) + } + val type = jsonTypeRef() + val expected = MixinSelectedCreator("漢") + assertEquals(expected, json.fromJson("{\"display_name\":\"漢\"}", type)) + assertEquals(expected, json.fromJson("{\"display_name\":\"漢\"}".toByteArray(), type)) + assertEquals("{\"display_name\":\"漢\"}", json.toJson(expected, type)) + assertEquals(expected, json.fromJson(json.toJsonBytes(expected, type), type)) + } + } + + @Test + fun valueClassCreator() { + forEachJsonMode { json -> + val type = jsonTypeRef() + val expected = ValueClassCreator(ExplicitCreatorId(17)) + assertEquals(expected, json.fromJson("{\"id\":17}", type)) + assertEquals(expected, json.fromJson("{\"id\":17}".toByteArray(), type)) + assertEquals(expected, json.fromJson(json.toJson(expected, type), type)) + assertEquals(expected, json.fromJson(json.toJsonBytes(expected, type), type)) + } + } +} diff --git a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinBuiltInCodecsTest.kt b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinBuiltInCodecsTest.kt new file mode 100644 index 0000000000..5339771a14 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinBuiltInCodecsTest.kt @@ -0,0 +1,700 @@ +/* + * 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.fory.json.kotlin + +import java.lang.reflect.Modifier +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import kotlin.time.TimedValue +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.codec.DirectUnboxedValueCodec +import org.apache.fory.json.reader.JsonReader +import org.apache.fory.json.writer.JsonWriter +import org.apache.fory.meta.TypeExtMeta +import org.apache.fory.reflect.TypeRef +import org.apache.fory.serializer.GraphMemoryEstimates +import org.apache.fory.type.Types + +@OptIn(ExperimentalUnsignedTypes::class, ExperimentalUuidApi::class) +class KotlinBuiltInCodecsTest { + private enum class EntryValue { + ONE, + } + + data class NullOnly(val value: Nothing?) + + data class TimedNode(val name: String, val next: TimedValue?) + + data class UnsignedArrayHolder( + val tag: String, + val direct: UByteArray, + val nullable: UShortArray?, + val nested: List, + ) + + data class UnsignedScalarHolder( + val ubyte: UByte, + val ushort: UShort, + val uint: UInt, + val ulong: ULong, + val nullable: UInt?, + val tag: String, + ) + + class MutableUnsignedHolder { + var ubyte: UByte = 0u + var ushort: UShort = 0u + var uint: UInt = 0u + var ulong: ULong = 0u + var tag: String = "" + } + + data class DurationHolder( + val zero: Duration, + val negative: Duration, + val nullable: Duration?, + val tag: String, + ) + + private val fory = ForyJsonKotlin.builder().withAsyncCompilation(false).build() + + @Test + fun products() { + val pairType = jsonTypeRef>() + val pair = Pair(null, "right") + assertEquals("{\"first\":null,\"second\":\"right\"}", fory.toJson(pair, pairType)) + assertEquals(pair, fory.fromJson(fory.toJson(pair, pairType), pairType)) + + val tripleType = jsonTypeRef>() + val triple = Triple(1, "two", true) + assertEquals(triple, fory.fromJson(fory.toJson(triple, tripleType), tripleType)) + assertFailsWith { fory.fromJson("{}", Pair::class.java) } + } + + @Test + fun ranges() { + assertRoundTrip('a'..'z', jsonTypeRef()) + assertRoundTrip('\u4e2d'..'\u9fa0', jsonTypeRef()) + assertRoundTrip(-4..9, jsonTypeRef()) + assertRoundTrip(-4L..9L, jsonTypeRef()) + assertRoundTrip(0u..UInt.MAX_VALUE, jsonTypeRef()) + assertRoundTrip(0uL..ULong.MAX_VALUE, jsonTypeRef()) + val intType = jsonTypeRef() + assertEquals("{\"start\":-4,\"endInclusive\":9}", fory.toJson(-4..9, intType)) + assertEquals(-4..9, fory.fromJson(fory.toJsonBytes(-4..9, intType), intType)) + assertEquals( + 0u..UInt.MAX_VALUE, + fory.fromJson( + "{\"endInclusive\":4294967295,\"start\":0}", + jsonTypeRef(), + ), + ) + assertFailsWith { fory.fromJson("{\"start\":1}", intType) } + assertFailsWith { + fory.fromJson("{\"start\":1,\"start\":2,\"endInclusive\":3}", intType) + } + assertFailsWith { fory.fromJson("{\"start\":1,\"unknown\":3}", intType) } + + val ownerBytes = GraphMemoryEstimates.shallowObjectBytes(IntRange::class.java) + val exact = ForyJsonKotlin.builder().withMaxGraphMemoryBytes(ownerBytes.toLong()).build() + assertEquals(1..2, exact.fromJson("{\"start\":1,\"endInclusive\":2}", intType)) + val short = ForyJsonKotlin.builder().withMaxGraphMemoryBytes((ownerBytes - 1).toLong()).build() + assertFailsWith { + short.fromJson("{\"start\":1,\"endInclusive\":2}", intType) + } + } + + @Test + fun progressions() { + assertRoundTrip(CharProgression.fromClosedRange('a', 'z', 3), jsonTypeRef()) + assertRoundTrip(IntProgression.fromClosedRange(-10, 20, 3), jsonTypeRef()) + assertRoundTrip(LongProgression.fromClosedRange(20, -10, -3), jsonTypeRef()) + assertRoundTrip(UIntProgression.fromClosedRange(1u, 20u, 3), jsonTypeRef()) + assertRoundTrip( + ULongProgression.fromClosedRange(20uL, 1uL, -3), + jsonTypeRef() + ) + + assertFailsWith { + fory.fromJson( + "{\"first\":1,\"last\":8,\"step\":3}", + jsonTypeRef(), + ) + } + assertFailsWith { + fory.fromJson( + "{\"first\":1,\"last\":7,\"step\":0}", + jsonTypeRef(), + ) + } + val type = jsonTypeRef() + val ownerBytes = GraphMemoryEstimates.shallowObjectBytes(IntProgression::class.java) + val exact = ForyJsonKotlin.builder().withMaxGraphMemoryBytes(ownerBytes.toLong()).build() + assertEquals( + IntProgression.fromClosedRange(1, 7, 3), + exact.fromJson("{\"first\":1,\"last\":7,\"step\":3}", type), + ) + val short = ForyJsonKotlin.builder().withMaxGraphMemoryBytes((ownerBytes - 1).toLong()).build() + assertFailsWith { + short.fromJson("{\"first\":1,\"last\":7,\"step\":3}", type) + } + } + + @Test + fun unsignedMapKeys() { + assertMapRoundTrip( + linkedMapOf(0.toUByte() to "zero", UByte.MAX_VALUE to "max"), + jsonTypeRef>(), + "{\"0\":\"zero\",\"255\":\"max\"}", + ) + assertMapRoundTrip( + linkedMapOf(0.toUShort() to "zero", UShort.MAX_VALUE to "max"), + jsonTypeRef>(), + "{\"0\":\"zero\",\"65535\":\"max\"}", + ) + assertMapRoundTrip( + linkedMapOf(0u to "zero", UInt.MAX_VALUE to "max"), + jsonTypeRef>(), + "{\"0\":\"zero\",\"4294967295\":\"max\"}", + ) + assertMapRoundTrip( + linkedMapOf(0uL to "zero", ULong.MAX_VALUE to "max"), + jsonTypeRef>(), + "{\"0\":\"zero\",\"18446744073709551615\":\"max\"}", + ) + assertFailsWith { + fory.fromJson("{\"4294967295\":\"bad\"}", jsonTypeRef>()) + } + assertFailsWith { + fory.fromJson("{\"4294967295\":\"bad\"}", jsonTypeRef>()) + } + for ((carrier, typeId) in + listOf( + java.lang.Byte.TYPE to Types.UINT8, + java.lang.Short.TYPE to Types.UINT16, + java.lang.Integer.TYPE to Types.UINT32, + java.lang.Long.TYPE to Types.UINT64, + )) { + val physical = TypeRef.of(carrier, TypeExtMeta.of(typeId, false, false)) + assertTrue(KotlinMapKeyCodecs.keyCodec(physical) != null) + } + val mismatched = TypeRef.of(String::class.java, TypeExtMeta.of(Types.UINT32, false, false)) + assertFailsWith { KotlinMapKeyCodecs.keyCodec(mismatched) } + } + + @Test + fun unsignedBounds() { + assertEquals(UByte.MAX_VALUE, fory.fromJson("255", jsonTypeRef())) + assertEquals(UShort.MAX_VALUE, fory.fromJson("65535", jsonTypeRef())) + assertEquals(UInt.MAX_VALUE, fory.fromJson("4294967295", jsonTypeRef())) + assertEquals(ULong.MAX_VALUE, fory.fromJson("18446744073709551615", jsonTypeRef())) + assertFailsWith { fory.fromJson("4294967295", jsonTypeRef()) } + assertFailsWith { fory.fromJson("4294967295", jsonTypeRef()) } + + assertUnsignedDirect(Types.UINT8, java.lang.Byte.TYPE, "readUByteRaw", "writeUByteRaw") + assertUnsignedDirect(Types.UINT16, java.lang.Short.TYPE, "readUShortRaw", "writeUShortRaw") + assertUnsignedDirect(Types.UINT32, java.lang.Integer.TYPE, "readUIntRaw", "writeUIntRaw") + assertUnsignedDirect(Types.UINT64, java.lang.Long.TYPE, "readULongRaw", "writeULongRaw") + + val type = jsonTypeRef() + val latin1 = + UnsignedScalarHolder( + UByte.MAX_VALUE, + UShort.MAX_VALUE, + UInt.MAX_VALUE, + ULong.MAX_VALUE, + null, + "ascii", + ) + val utf16 = latin1.copy(tag = "\u4e2d") + val latin1Json = + "{\"ubyte\":255,\"ushort\":65535,\"uint\":4294967295," + + "\"ulong\":18446744073709551615,\"nullable\":null,\"tag\":\"ascii\"}" + val utf16Json = latin1Json.replace("ascii", "\u4e2d") + forEachJsonMode { json -> + assertEquals(latin1Json, json.toJson(latin1, type)) + assertEquals(latin1, json.fromJson(latin1Json, type)) + assertEquals(utf16, json.fromJson(utf16Json, type)) + val bytes = json.toJsonBytes(latin1, type) + assertEquals(latin1Json, bytes.toString(Charsets.UTF_8)) + assertEquals(latin1, json.fromJson(bytes, type)) + + val mutable = MutableUnsignedHolder() + mutable.ubyte = UByte.MAX_VALUE + mutable.ushort = UShort.MAX_VALUE + mutable.uint = UInt.MAX_VALUE + mutable.ulong = ULong.MAX_VALUE + mutable.tag = "\u4e2d" + val mutableType = jsonTypeRef() + val mutableJson = json.toJson(mutable, mutableType) + val decodedMutable = json.fromJson(mutableJson, mutableType) + assertEquals(UByte.MAX_VALUE, decodedMutable.ubyte) + assertEquals(UShort.MAX_VALUE, decodedMutable.ushort) + assertEquals(UInt.MAX_VALUE, decodedMutable.uint) + assertEquals(ULong.MAX_VALUE, decodedMutable.ulong) + + assertFailsWith { + json.fromJson(latin1Json.replace("\"ubyte\":255", "\"ubyte\":256"), type) + } + assertFailsWith { + json.fromJson(latin1Json.replace("\"ushort\":65535", "\"ushort\":65536"), type) + } + assertFailsWith { + json.fromJson(latin1Json.replace("\"uint\":4294967295", "\"uint\":4294967296"), type) + } + assertFailsWith { + json.fromJson( + latin1Json.replace( + "\"ulong\":18446744073709551615", + "\"ulong\":18446744073709551616", + ), + type, + ) + } + assertFailsWith { + json.fromJson(latin1Json.replace("\"ubyte\":255", "\"ubyte\":null"), type) + } + } + assertEquals(null, fory.fromJson("null", jsonTypeRef())) + assertUnsignedGeneratedCode(latin1, utf16, latin1Json, utf16Json) + } + + @Test + fun unsignedArrays() { + val ubytes = ubyteArrayOf(0u, UByte.MAX_VALUE) + val ushorts = ushortArrayOf(0u, UShort.MAX_VALUE) + val uints = uintArrayOf(0u, UInt.MAX_VALUE) + val ulongs = ulongArrayOf(0u, ULong.MAX_VALUE) + assertTrue(ubytes.contentEquals(roundTrip(ubytes, jsonTypeRef()))) + assertTrue(ushorts.contentEquals(roundTrip(ushorts, jsonTypeRef()))) + assertTrue(uints.contentEquals(roundTrip(uints, jsonTypeRef()))) + assertTrue(ulongs.contentEquals(roundTrip(ulongs, jsonTypeRef()))) + } + + @Test + fun unsignedArrayRepresentations() { + val bytes = ubyteArrayOf(0u, UByte.MAX_VALUE) + val rootType = jsonTypeRef() + val text = fory.toJson(bytes, rootType) + assertEquals("[0,255]", text) + assertTrue(bytes.contentEquals(fory.fromJson(text, rootType))) + + val utf8 = fory.toJsonBytes(bytes, rootType) + assertEquals(text, utf8.toString(Charsets.UTF_8)) + assertTrue(bytes.contentEquals(fory.fromJson(utf8, rootType))) + + val holderType = jsonTypeRef() + val holder = + UnsignedArrayHolder( + "\u4e2d", + bytes, + ushortArrayOf(0u, UShort.MAX_VALUE), + listOf(uintArrayOf(0u, UInt.MAX_VALUE)), + ) + val decoded = fory.fromJson(fory.toJson(holder, holderType), holderType) + assertEquals(holder.tag, decoded.tag) + assertTrue(holder.direct.contentEquals(decoded.direct)) + assertTrue(holder.nullable!!.contentEquals(decoded.nullable!!)) + assertTrue(holder.nested.single().contentEquals(decoded.nested.single())) + } + + @Test + fun unsignedArrayOverflow() { + assertFailsWith { fory.fromJson("[256]", jsonTypeRef()) } + assertFailsWith { fory.fromJson("[65536]", jsonTypeRef()) } + assertFailsWith { fory.fromJson("[4294967296]", jsonTypeRef()) } + assertFailsWith { + fory.fromJson("[18446744073709551616]", jsonTypeRef()) + } + val mismatched = + TypeRef.of(UByteArray::class.java, TypeExtMeta.of(Types.UINT16_ARRAY, false, false)) + assertFailsWith { fory.fromJson("[]", mismatched) } + } + + @Test + fun unsignedArrayBudget() { + val arrayBytes = GraphMemoryEstimates.objectArrayBytes() + Byte.SIZE_BYTES + val wrapperBytes = GraphMemoryEstimates.shallowObjectBytes(UByteArray::class.java) + val type = jsonTypeRef() + val exact = + ForyJsonKotlin.builder() + .withAsyncCompilation(false) + .withMaxGraphMemoryBytes((arrayBytes + wrapperBytes).toLong()) + .build() + assertTrue(ubyteArrayOf(1u).contentEquals(exact.fromJson("[1]", type))) + + val short = + ForyJsonKotlin.builder() + .withAsyncCompilation(false) + .withMaxGraphMemoryBytes((arrayBytes + wrapperBytes - 1).toLong()) + .build() + assertFailsWith { short.fromJson("[1]", type) } + } + + @Test + fun duration() { + val values = + listOf( + Duration.ZERO, + 1.nanoseconds, + -1.nanoseconds, + 999_999_999.nanoseconds, + -(1.seconds + 1.nanoseconds), + 49.hours + 2.minutes + 3.seconds + 456_789.nanoseconds, + Long.MAX_VALUE.nanoseconds, + Duration.INFINITE, + -Duration.INFINITE, + ) + for (value in values) { + assertEquals("\"${value.toIsoString()}\"", fory.toJson(value, jsonTypeRef())) + assertRoundTrip(value, jsonTypeRef()) + } + for (text in + listOf( + "+PT1S", + "P1D", + "P1DT2H3M4.1234567899S", + "PT61M", + "-PT0.000000001S", + "PT-1H+30M", + "-PT-1H+30M", + "P+1DT-2H+3M-4.0000000005S", + "PT0.0000000005S", + "PT0.9999999995S", + "PT9999999999999H", + "PT10000000000000H", + )) { + assertEquals( + Duration.parseIsoString(text), + fory.fromJson("\"$text\"", jsonTypeRef()), + ) + } + for (text in + listOf( + "PT", + "P1DT", + "PT1.5H", + "PT1H1H", + "PT1M1H", + "P9999999999999DT-9999999999999H", + )) { + assertFailsWith { Duration.parseIsoString(text) } + assertFailsWith { fory.fromJson("\"$text\"", jsonTypeRef()) } + } + assertEquals(1.seconds, fory.fromJson("\"PT\\u0031S\"", jsonTypeRef())) + assertEquals(2.seconds, fory.fromJson("\"PT2S\"", jsonTypeRef())) + + val direct = KotlinTemporalCodecs.create(jsonTypeRef()) as DirectUnboxedValueCodec + assertEquals(java.lang.Long.TYPE, direct.carrierType()) + val readMethod = direct.readCarrierMethod() + assertTrue(Modifier.isStatic(readMethod.modifiers)) + assertEquals(java.lang.Long.TYPE, readMethod.returnType) + assertEquals(listOf(JsonReader::class.java), readMethod.parameterTypes.toList()) + val writeMethod = direct.writeCarrierMethod() + assertTrue(Modifier.isStatic(writeMethod.modifiers)) + assertEquals(java.lang.Void.TYPE, writeMethod.returnType) + assertEquals( + listOf(JsonWriter::class.java, java.lang.Long.TYPE), + writeMethod.parameterTypes.toList(), + ) + + val latin1Holder = DurationHolder(Duration.ZERO, -1.nanoseconds, null, "ascii") + val utf16Holder = latin1Holder.copy(tag = "\u4e2d") + val holderType = jsonTypeRef() + val latin1Json = + "{\"zero\":\"PT0S\",\"negative\":\"-PT0.000000001S\",\"nullable\":null,\"tag\":\"ascii\"}" + val utf16Json = + "{\"zero\":\"PT0S\",\"negative\":\"-PT0.000000001S\",\"nullable\":null,\"tag\":\"\u4e2d\"}" + forEachJsonMode { json -> + assertEquals(latin1Json, json.toJson(latin1Holder, holderType)) + assertEquals(latin1Holder, json.fromJson(latin1Json, holderType)) + assertEquals(utf16Holder, json.fromJson(utf16Json, holderType)) + val utf8 = json.toJsonBytes(latin1Holder, holderType) + assertEquals(latin1Json, utf8.toString(Charsets.UTF_8)) + assertEquals(latin1Holder, json.fromJson(utf8, holderType)) + } + } + + @Test + fun instant() { + val values = + listOf( + Instant.fromEpochSeconds(0), + Instant.fromEpochSeconds(-1, 1), + Instant.fromEpochSeconds(1, 999_999_999), + Instant.fromEpochSeconds(-31_557_014_167_219_200L), + Instant.fromEpochSeconds(31_556_889_864_403_199L, 999_999_999), + ) + for (value in values) { + assertEquals("\"$value\"", fory.toJson(value, jsonTypeRef())) + assertRoundTrip(value, jsonTypeRef()) + } + for (text in + listOf( + "1970-01-01T00:00:00Z", + "1970-01-01t00:00:00z", + "1970-01-01T01:00:00+01", + "1970-01-01T01:30:00+01:30", + "1970-01-01T01:02:03+01:02:03", + "+10000-01-01T00:00:00.123456789Z", + )) { + assertEquals(Instant.parse(text), fory.fromJson("\"$text\"", jsonTypeRef())) + } + for (text in + listOf( + "1970-01-01T00:00Z", + "1970-01-01T00:00:60Z", + "1970-01-01T24:00:00Z", + "1970-01-01T00:00:00.1234567890Z", + "1970-01-01T00:00:00+18:01", + "-1000000000-01-01T00:00:00+00:00:01", + "+1000000000-12-31T23:59:59-00:00:01", + )) { + assertFailsWith { Instant.parse(text) } + assertFailsWith { fory.fromJson("\"$text\"", jsonTypeRef()) } + } + } + + @Test + fun uuid() { + val value = Uuid.fromLongs(0x0011223344556677L, 0x8899aabbccddeeffuL.toLong()) + val json = "\"00112233-4455-6677-8899-aabbccddeeff\"" + assertEquals(json, fory.toJson(value, jsonTypeRef())) + assertEquals(value, fory.fromJson(json, jsonTypeRef())) + assertEquals( + value, + fory.fromJson("\"00112233-4455-6677-8899-AABBCCDDEEFF\"", jsonTypeRef()), + ) + assertEquals( + value, + fory.fromJson("\"00112233-4455-6677-8899-\\u0061abbccddeeff\"", jsonTypeRef()), + ) + for (text in + listOf( + "00112233445566778899aabbccddeeff", + "{00112233-4455-6677-8899-aabbccddeeff}", + "00112233-4455-6677-8899-aabbccddeefg", + )) { + assertFailsWith { fory.fromJson("\"$text\"", jsonTypeRef()) } + } + } + + @Test + fun timedValue() { + val type = jsonTypeRef>() + val value = TimedValue("done", 2.seconds + 17.nanoseconds) + assertEquals(value, fory.fromJson(fory.toJson(value, type), type)) + val nullableType = jsonTypeRef>() + val nullable = TimedValue(null, -1.nanoseconds) + assertEquals(nullable, fory.fromJson(fory.toJson(nullable, nullableType), nullableType)) + val nullableUnitType = jsonTypeRef>() + assertRoundTrip(TimedValue(null, 1.nanoseconds), nullableUnitType) + assertRoundTrip(TimedValue(Unit, 2.nanoseconds), nullableUnitType) + val recursiveType = jsonTypeRef() + val recursive = TimedNode("outer", TimedValue(TimedNode("leaf", null), 3.seconds)) + assertEquals(recursive, fory.fromJson(fory.toJson(recursive, recursiveType), recursiveType)) + assertFailsWith { + fory.fromJson("{\"value\":\"bad\",\"duration\":null}", type) + } + assertFailsWith { + fory.fromJson("{\"value\":null,\"duration\":\"PT1S\"}", type) + } + @Suppress("UNCHECKED_CAST") val invalid = TimedValue(null, 1.seconds) as TimedValue + assertFailsWith { fory.toJson(invalid, type) } + assertEquals(value, fory.fromJson(fory.toJson(value, type), type)) + } + + @Test + fun nullOnly() { + val value = NullOnly(null) + assertEquals( + value, + fory.fromJson(fory.toJson(value, jsonTypeRef()), jsonTypeRef()) + ) + assertFailsWith { fory.fromJson("{\"value\":0}", jsonTypeRef()) } + } + + @Test + fun unitNullability() { + val nullableType = jsonTypeRef() + assertEquals("null", fory.toJson(null, nullableType)) + assertEquals(null, fory.fromJson("null", nullableType)) + assertEquals("{}", fory.toJson(Unit, nullableType)) + assertEquals(Unit, fory.fromJson("{}", nullableType)) + assertFailsWith { fory.fromJson("null", jsonTypeRef()) } + } + + @Test + fun rejectedTypes() { + assertFailsWith { fory.fromJson("[]", jsonTypeRef>()) } + assertFailsWith { fory.fromJson("\"a+\"", jsonTypeRef()) } + assertFailsWith { fory.fromJson("{}", jsonTypeRef()) } + assertFailsWith { fory.fromJson("[]", emptyList()::class.java) } + assertFailsWith { fory.fromJson("[]", EntryValue.entries::class.java) } + } + + private fun assertMapRoundTrip( + value: Map, + type: org.apache.fory.reflect.TypeRef>, + expectedJson: String, + ) { + val json = fory.toJson(value, type) + assertEquals(expectedJson, json) + assertEquals(value, fory.fromJson(json, type)) + } + + private fun assertRoundTrip(value: T, type: org.apache.fory.reflect.TypeRef) { + assertEquals(value, roundTrip(value, type)) + } + + private fun assertUnsignedDirect( + typeId: Int, + carrier: Class<*>, + readName: String, + writeName: String, + ) { + val direct = KotlinUnsignedCodecs.scalar(typeId, false, false) as DirectUnboxedValueCodec + assertEquals(carrier, direct.carrierType()) + val readMethod = direct.readCarrierMethod() + assertEquals(readName, readMethod.name) + assertTrue(Modifier.isStatic(readMethod.modifiers)) + assertEquals(carrier, readMethod.returnType) + assertEquals(listOf(JsonReader::class.java), readMethod.parameterTypes.toList()) + val writeMethod = direct.writeCarrierMethod() + assertEquals(writeName, writeMethod.name) + assertTrue(Modifier.isStatic(writeMethod.modifiers)) + assertEquals(java.lang.Void.TYPE, writeMethod.returnType) + assertEquals(listOf(JsonWriter::class.java, carrier), writeMethod.parameterTypes.toList()) + } + + private fun assertUnsignedGeneratedCode( + latin1: UnsignedScalarHolder, + utf16: UnsignedScalarHolder, + latin1Json: String, + utf16Json: String, + ) { + val json = newKotlinJson(KotlinJsonTestMode.SYNCHRONOUS) + val type = jsonTypeRef() + json.toJson(latin1, type) + json.toJsonBytes(latin1, type) + json.fromJson(latin1Json, type) + json.fromJson(utf16Json, type) + json.fromJson(latin1Json.toByteArray(), type) + + val mutable = MutableUnsignedHolder() + mutable.ubyte = UByte.MAX_VALUE + mutable.ushort = UShort.MAX_VALUE + mutable.uint = UInt.MAX_VALUE + mutable.ulong = ULong.MAX_VALUE + mutable.tag = "ascii" + val mutableType = jsonTypeRef() + val mutableJson = json.toJson(mutable, mutableType) + json.toJsonBytes(mutable, mutableType) + json.fromJson(mutableJson, mutableType) + json.fromJson(mutableJson.replace("ascii", "\u4e2d"), mutableType) + json.fromJson(mutableJson.toByteArray(), mutableType) + + assertUnsignedGeneratedClasses(generatedClassBytes(json, "Unsigned")) + assertUnsignedGeneratedClasses(generatedClassBytes(json, "MutableU")) + } + + private fun assertUnsignedGeneratedClasses(classes: Map) { + val readers = classes.filterKeys { it.contains("ReaderForyJsonCodec") } + val writers = classes.filterKeys { it.contains("WriterForyJsonCodec") } + assertEquals(3, readers.size, readers.keys.toString()) + assertEquals(2, writers.size, writers.keys.toString()) + readers.forEach { (name, bytes) -> + val refs = generatedMethodRefs(bytes) + assertUnsignedReadRef(name, refs, "readUByteRaw", "B") + assertUnsignedReadRef(name, refs, "readUShortRaw", "S") + assertUnsignedReadRef(name, refs, "readUIntRaw", "I") + assertUnsignedReadRef(name, refs, "readULongRaw", "J") + assertNoUnsignedBoxing(name, refs) + } + writers.forEach { (name, bytes) -> + val refs = generatedMethodRefs(bytes) + assertUnsignedWriteRef(name, refs, "writeUByteRaw", "B") + assertUnsignedWriteRef(name, refs, "writeUShortRaw", "S") + assertUnsignedWriteRef(name, refs, "writeUIntRaw", "I") + assertUnsignedWriteRef(name, refs, "writeULongRaw", "J") + assertNoUnsignedBoxing(name, refs) + } + } + + private fun assertUnsignedReadRef( + className: String, + refs: List, + methodName: String, + carrierDescriptor: String, + ) { + assertTrue( + refs.any { + it.owner == "org/apache/fory/json/kotlin/KotlinUnsignedCodecs" && + it.name == methodName && + it.descriptor == "(Lorg/apache/fory/json/reader/JsonReader;)$carrierDescriptor" + }, + "$className does not invoke $methodName with the exact primitive carrier: $refs", + ) + } + + private fun assertUnsignedWriteRef( + className: String, + refs: List, + methodName: String, + carrierDescriptor: String, + ) { + assertTrue( + refs.any { + it.owner == "org/apache/fory/json/kotlin/KotlinUnsignedCodecs" && + it.name == methodName && + it.descriptor == "(Lorg/apache/fory/json/writer/JsonWriter;$carrierDescriptor)V" + }, + "$className does not invoke $methodName with the exact primitive carrier: $refs", + ) + } + + private fun assertNoUnsignedBoxing(className: String, refs: List) { + val unsignedOwners = setOf("kotlin/UByte", "kotlin/UShort", "kotlin/UInt", "kotlin/ULong") + val boxedOwners = + setOf("java/lang/Byte", "java/lang/Short", "java/lang/Integer", "java/lang/Long") + assertFalse( + refs.any { it.owner in boxedOwners && it.name == "valueOf" }, + "$className boxes a direct unsigned carrier: $refs", + ) + assertFalse( + refs.any { it.owner in unsignedOwners && (it.name == "box-impl" || it.name == "unbox-impl") }, + "$className materializes a direct unsigned wrapper: $refs", + ) + } + + private fun roundTrip(value: T, type: org.apache.fory.reflect.TypeRef): T = + fory.fromJson(fory.toJson(value, type), type) +} diff --git a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinDefaultArgumentsTest.kt b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinDefaultArgumentsTest.kt new file mode 100644 index 0000000000..397ac28e15 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinDefaultArgumentsTest.kt @@ -0,0 +1,115 @@ +/* + * 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.fory.json.kotlin + +import kotlin.test.Test +import kotlin.test.assertEquals + +class KotlinDefaultArgumentsTest { + class MaskDefaults( + val p0: Int = 0, + val p1: Int = 1, + val p2: Int = 2, + val p3: Int = 3, + val p4: Int = 4, + val p5: Int = 5, + val p6: Int = 6, + val p7: Int = 7, + val p8: Int = 8, + val p9: Int = 9, + val p10: Int = 10, + val p11: Int = 11, + val p12: Int = 12, + val p13: Int = 13, + val p14: Int = 14, + val p15: Int = 15, + val p16: Int = 16, + val p17: Int = 17, + val p18: Int = 18, + val p19: Int = 19, + val p20: Int = 20, + val p21: Int = 21, + val p22: Int = 22, + val p23: Int = 23, + val p24: Int = 24, + val p25: Int = 25, + val p26: Int = 26, + val p27: Int = 27, + val p28: Int = 28, + val p29: Int = 29, + val p30: Int = 30, + val p31: Int = 31, + val p32: Int = 32, + val p33: Int = 33, + val p34: Int = 34, + val p35: Int = 35, + val p36: Int = 36, + val p37: Int = 37, + val p38: Int = 38, + val p39: Int = 39, + val p40: Int = 40, + val p41: Int = 41, + val p42: Int = 42, + val p43: Int = 43, + val p44: Int = 44, + val p45: Int = 45, + val p46: Int = 46, + val p47: Int = 47, + val p48: Int = 48, + val p49: Int = 49, + val p50: Int = 50, + val p51: Int = 51, + val p52: Int = 52, + val p53: Int = 53, + val p54: Int = 54, + val p55: Int = 55, + val p56: Int = 56, + val p57: Int = 57, + val p58: Int = 58, + val p59: Int = 59, + val p60: Int = 60, + val p61: Int = 61, + val p62: Int = 62, + val p63: Int = 63, + val p64: Int = 64, + ) + + @Test + fun maskWords() { + val fory = ForyJsonKotlin.builder().withAsyncCompilation(false).build() + val type = jsonTypeRef() + val latin1 = "{\"p0\":100,\"p31\":131,\"p32\":132,\"p63\":163,\"p64\":164}" + assertMaskValues(fory.fromJson(latin1, type)) + assertMaskValues(fory.fromJson(latin1.dropLast(1) + ",\"ignored\":\"漢\"}", type)) + assertMaskValues(fory.fromJson(latin1.toByteArray(), type)) + } + + private fun assertMaskValues(value: MaskDefaults) { + assertEquals(100, value.p0) + assertEquals(1, value.p1) + assertEquals(30, value.p30) + assertEquals(131, value.p31) + assertEquals(132, value.p32) + assertEquals(33, value.p33) + assertEquals(62, value.p62) + assertEquals(163, value.p63) + assertEquals(164, value.p64) + } +} diff --git a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinGenericRuntimeTest.kt b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinGenericRuntimeTest.kt new file mode 100644 index 0000000000..8717b3a0de --- /dev/null +++ b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinGenericRuntimeTest.kt @@ -0,0 +1,129 @@ +/* + * 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.fory.json.kotlin + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.apache.fory.json.ForyJsonException + +class KotlinGenericRuntimeTest { + data class GenericBox(val value: T) + + data class GenericNode(val value: T, val next: GenericNode?) + + data class MutualLeft(val value: T, val right: MutualRight?) + + data class MutualRight(val value: T, val left: MutualLeft?) + + data class ListNode(val value: Int, val children: List) + + data class MapNode(val value: Int, val children: Map) + + class Expanding(val next: Expanding>?) + + class Swapping(val next: Swapping?) + + @Test + fun distinctGenericBindings() { + forEachJsonMode { json -> + val stringType = jsonTypeRef>() + val intType = jsonTypeRef>() + val stringValue = GenericBox("漢") + val intValue = GenericBox(42) + + assertEquals(stringValue, json.fromJson(json.toJson(stringValue, stringType), stringType)) + assertEquals(intValue, json.fromJson(json.toJson(intValue, intType), intType)) + assertEquals( + stringValue, + json.fromJson("{\"value\":\"漢\"}", stringType), + ) + assertEquals( + intValue, + json.fromJson(json.toJsonBytes(intValue, intType), intType), + ) + assertEquals(stringValue, json.fromJson("{\"value\":\"漢\"}", stringType)) + } + } + + @Test + fun exactRecursiveBinding() { + val value = GenericNode("root", GenericNode("leaf", null)) + forEachJsonMode { json -> + val type = jsonTypeRef>() + assertEquals(value, json.fromJson(json.toJson(value, type), type)) + assertEquals(value, json.fromJson(json.toJsonBytes(value, type), type)) + assertEquals( + GenericNode("漢", GenericNode("leaf", null)), + json.fromJson( + "{\"value\":\"漢\",\"next\":{\"value\":\"leaf\",\"next\":null}}", + type, + ), + ) + } + } + + @Test + fun exactMutualCycle() { + val value = MutualLeft("left", MutualRight("right", MutualLeft("tail", null))) + forEachJsonMode { json -> + val type = jsonTypeRef>() + assertEquals(value, json.fromJson(json.toJson(value, type), type)) + assertEquals(value, json.fromJson(json.toJsonBytes(value, type), type)) + } + } + + @Test + fun recursiveCoreContainers() { + val listValue = listOf(ListNode(1, listOf(ListNode(2, emptyList())))) + val mapValue = MapNode(1, linkedMapOf("child" to MapNode(2, emptyMap()))) + forEachJsonMode { json -> + val listType = jsonTypeRef>() + assertEquals(listValue, json.fromJson(json.toJson(listValue, listType), listType)) + assertEquals(listValue, json.fromJson(json.toJsonBytes(listValue, listType), listType)) + + val mapType = jsonTypeRef() + assertEquals(mapValue, json.fromJson(json.toJson(mapValue, mapType), mapType)) + assertEquals(mapValue, json.fromJson(json.toJsonBytes(mapValue, mapType), mapType)) + } + } + + @Test + fun changingBindingRejectsAndRollsBack() { + forEachJsonMode { json -> + assertFailsWith { + json.fromJson("{\"next\":null}", jsonTypeRef>()) + } + val validType = jsonTypeRef>() + assertEquals(GenericBox("valid"), json.fromJson("{\"value\":\"valid\"}", validType)) + + assertFailsWith { + json.fromJson("{\"next\":null}", jsonTypeRef>()) + } + assertEquals( + GenericNode("valid", null), + json.fromJson( + "{\"value\":\"valid\",\"next\":null}", + jsonTypeRef>(), + ), + ) + } + } +} diff --git a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinMetadataModelsTest.kt b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinMetadataModelsTest.kt new file mode 100644 index 0000000000..ff9aa118dc --- /dev/null +++ b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinMetadataModelsTest.kt @@ -0,0 +1,363 @@ +/* + * 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.fory.json.kotlin + +import kotlin.ExperimentalContextParameters +import kotlin.jvm.JvmInline +import kotlin.metadata.ExperimentalContextReceivers +import kotlin.metadata.KmClass +import kotlin.metadata.KmClassifier +import kotlin.metadata.KmFunction +import kotlin.metadata.KmProperty +import kotlin.metadata.KmType +import kotlin.metadata.KmValueParameter +import kotlin.metadata.jvm.JvmMetadataVersion +import kotlin.metadata.jvm.KotlinClassMetadata +import kotlin.properties.Delegates +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.annotation.JsonCreator +import org.apache.fory.reflect.TypeRef +import org.apache.fory.type.Types + +@JvmInline value class MetadataId(val value: Long) + +@JvmInline value class MetadataGenericId(val value: T) + +@JvmInline value class MetadataNullableId(val value: String?) + +class SelfValueNode(val id: MetadataId, val next: SelfValueNode?) + +class MutualValueLeft(val id: MetadataId, val right: MutualValueRight?) + +class MutualValueRight(val id: MetadataId, val left: MutualValueLeft?) + +class ValueOccurrences( + val generic: MetadataGenericId, + val nullableUnderlying: MetadataNullableId, +) + +class PlatformFactory private constructor(val value: String) { + companion object { + @JvmStatic + @JsonCreator(value = ["value"]) + fun create(value: String): PlatformFactory = PlatformFactory(value) + } +} + +class KotlinMetadataModelsTest { + private class PrivateModel(val value: String) + + data class GenericFields(val value: T, val optional: T?) + + open class GenericBase { + var inherited: T? = null + } + + class GenericChild(val id: Int) : GenericBase() + + class PropertyKinds(val id: Int) { + var mutable: String = "initial" + lateinit var required: String + var privateSink: String = "initial" + private set + + @JvmField var direct: String = "initial" + val immutable: String = "constant" + var delegated: String by Delegates.observable("initial") { _, _, _ -> } + var computed: String + get() = mutable + set(value) { + mutable = value + } + } + + class PrivatePrimary(private val value: String) + + class DirectPrimary(@JvmField val value: String) + + object StatefulCandidate { + const val CONSTANT: String = "constant" + var state: String = "state" + } + + object Stateless + + class UnusedGeneric(val id: Int) + + class UsedGeneric(val value: T) + + open class PartialBase { + var concrete: A? = null + } + + class PartialChild(val id: Int) : PartialBase() + + class MissingChild(val id: Int) : PartialBase() + + interface LeftProperty { + val shared: String + } + + interface RightProperty { + val shared: String + } + + data class DiamondProperty(override val shared: String) : LeftProperty, RightProperty + + class NullableNothing(val value: Nothing?) + + class MetadataBox(val value: T) + + class CovariantField(val value: MetadataBox) + + @OptIn(ExperimentalUnsignedTypes::class) + data class UnsignedArrays( + val ubytes: UByteArray, + val nullableUbytes: UByteArray?, + val ushorts: UShortArray, + val nullableUshorts: UShortArray?, + val uints: UIntArray, + val nullableUints: UIntArray?, + val ulongs: ULongArray, + val nullableUlongs: ULongArray?, + ) + + @Test + fun genericNullability() { + val model = KotlinMetadataModels.objectModel(jsonTypeRef>()) + val types = model.parameterTypes() + assertEquals(String::class.java, types[0].rawType) + assertFalse(types[0].typeExtMeta.nullable()) + assertEquals(String::class.java, types[1].rawType) + assertTrue(types[1].typeExtMeta.nullable()) + } + + @Test + fun inheritedGenericProperty() { + val model = KotlinMetadataModels.objectModel(jsonTypeRef()) + val index = model.propertyNames().indexOf("inherited") + assertTrue(index >= 0) + val type = model.propertyTypes()[index] + assertEquals(String::class.java, type.rawType) + assertTrue(type.typeExtMeta.nullable()) + assertEquals(GenericBase::class.java, model.propertyGetters()[index].declaringClass) + assertEquals(GenericBase::class.java, model.propertySetters()[index].declaringClass) + } + + @Test + fun propertyKinds() { + val model = KotlinMetadataModels.objectModel(jsonTypeRef()) + val names = model.propertyNames() + val reconstructible = model.propertyReconstructible() + val required = model.propertyRequired() + assertTrue(reconstructible[names.indexOf("mutable")]) + assertTrue(reconstructible[names.indexOf("required")]) + assertTrue(required[names.indexOf("required")]) + assertFalse(reconstructible[names.indexOf("privateSink")]) + assertTrue(reconstructible[names.indexOf("direct")]) + assertEquals(null, model.propertyGetters()[names.indexOf("direct")]) + assertEquals(null, model.propertySetters()[names.indexOf("direct")]) + assertFalse(reconstructible[names.indexOf("immutable")]) + assertFalse(reconstructible[names.indexOf("delegated")]) + assertFalse(reconstructible[names.indexOf("computed")]) + } + + @Test + fun singletonCandidates() { + val stateful = KotlinMetadataModels.objectModel(jsonTypeRef()) + assertSame(StatefulCandidate, stateful.fixedInstance()) + assertEquals(listOf("state"), stateful.propertyNames().toList()) + + val stateless = KotlinMetadataModels.objectModel(jsonTypeRef()) + assertSame(Stateless, stateless.fixedInstance()) + assertTrue(stateless.propertyNames().isEmpty()) + } + + @Test + fun rawGenericUse() { + val unused = KotlinMetadataModels.objectModel(TypeRef.of(UnusedGeneric::class.java)) + assertEquals(listOf("id"), unused.parameterNames().toList()) + assertFailsWith { + KotlinMetadataModels.objectModel(TypeRef.of(UsedGeneric::class.java)) + } + + val partial = KotlinMetadataModels.objectModel(TypeRef.of(PartialChild::class.java)) + val concrete = partial.propertyNames().indexOf("concrete") + assertEquals(String::class.java, partial.propertyTypes()[concrete].rawType) + assertTrue(partial.propertyTypes()[concrete].typeExtMeta.nullable()) + assertFailsWith { + KotlinMetadataModels.objectModel(TypeRef.of(MissingChild::class.java)) + } + } + + @Test + fun classVisibility() { + assertFailsWith { + KotlinMetadataModels.objectModel(jsonTypeRef()) + } + } + + @Test + fun primaryReadability() { + assertFailsWith { + KotlinMetadataModels.objectModel(jsonTypeRef()) + } + val direct = KotlinMetadataModels.objectModel(jsonTypeRef()) + assertEquals(null, direct.accessors().single()) + assertEquals(listOf("value"), direct.propertyNames().toList()) + } + + @Test + fun mostSpecificProperty() { + val model = KotlinMetadataModels.objectModel(jsonTypeRef()) + assertEquals(listOf("shared"), model.propertyNames().toList()) + assertEquals( + DiamondProperty::class.java, + model.propertyGetters().single().declaringClass, + ) + } + + @Test + fun nothingOccurrence() { + val nullable = KotlinMetadataModels.objectModel(jsonTypeRef()) + assertEquals(Void::class.java, nullable.parameterTypes().single().rawType) + assertTrue(nullable.parameterTypes().single().typeExtMeta.nullable()) + } + + @Test + fun covariantOccurrence() { + val model = KotlinMetadataModels.objectModel(jsonTypeRef()) + val argument = model.parameterTypes().single().typeArguments.single() + assertEquals(String::class.java, argument.rawType) + assertTrue(argument.typeExtMeta.nullable()) + assertTrue(argument.typeExtMeta.covariant()) + } + + @OptIn(ExperimentalContextParameters::class, ExperimentalContextReceivers::class) + @Suppress("DEPRECATION") + @Test + fun legacyContextMetadata() { + fun stringType(): KmType = KmType().apply { classifier = KmClassifier.Class("kotlin/String") } + + val function = + KmFunction("contextFunction").apply { + returnType = stringType() + contextParameters += KmValueParameter("scope").apply { type = stringType() } + } + val property = + KmProperty("contextProperty").apply { + returnType = stringType() + contextParameters += KmValueParameter("scope").apply { type = stringType() } + } + val encoded = + KotlinClassMetadata.Class( + KmClass().apply { + name = "org/apache/fory/json/kotlin/LegacyContextModel" + contextReceiverTypes += stringType() + functions += function + properties += property + }, + JvmMetadataVersion(2, 3, 0), + 0, + ) + .write() + val decoded = KotlinClassMetadata.readStrict(encoded) as KotlinClassMetadata.Class + assertTrue(KotlinMetadataModels.hasImplicitContext(decoded.kmClass)) + assertTrue(KotlinMetadataModels.hasImplicitContext(decoded.kmClass.functions.single())) + assertTrue(KotlinMetadataModels.hasImplicitContext(decoded.kmClass.properties.single())) + } + + @Test + fun recursiveValueOccurrences() { + val self = KotlinMetadataModels.objectModel(jsonTypeRef()) + assertEquals(MetadataId::class.java, self.parameterTypes()[0].rawType) + assertEquals(Long::class.javaPrimitiveType, self.creator().parameterTypes[0]) + assertEquals(SelfValueNode::class.java, self.parameterTypes()[1].rawType) + assertTrue(self.parameterTypes()[1].typeExtMeta.nullable()) + + val left = KotlinMetadataModels.objectModel(jsonTypeRef()) + val right = KotlinMetadataModels.objectModel(jsonTypeRef()) + assertEquals(MetadataId::class.java, left.parameterTypes()[0].rawType) + assertEquals(MutualValueRight::class.java, left.parameterTypes()[1].rawType) + assertEquals(MetadataId::class.java, right.parameterTypes()[0].rawType) + assertEquals(MutualValueLeft::class.java, right.parameterTypes()[1].rawType) + } + + @Test + fun valueOccurrenceTypes() { + val model = KotlinMetadataModels.objectModel(jsonTypeRef()) + val logicalTypes = model.parameterTypes() + assertEquals(MetadataGenericId::class.java, logicalTypes[0].rawType) + assertEquals(String::class.java, logicalTypes[0].typeArguments.single().rawType) + assertFalse(logicalTypes[0].typeExtMeta.nullable()) + assertEquals(Any::class.java, model.creator().parameterTypes[0]) + + assertEquals(MetadataNullableId::class.java, logicalTypes[1].rawType) + assertFalse(logicalTypes[1].typeExtMeta.nullable()) + assertEquals(String::class.java, model.creator().parameterTypes[1]) + } + + @Test + fun platformFactoryOwner() { + val json = newKotlinJson(KotlinJsonTestMode.INTERPRETED) + val decoded = json.fromJson("{\"value\":\"text\"}", PlatformFactory::class.java) + assertEquals("text", decoded.value) + } + + @OptIn(ExperimentalUnsignedTypes::class) + @Test + fun unsignedArrayCarrier() { + val model = KotlinMetadataModels.objectModel(jsonTypeRef()) + val types = model.parameterTypes() + val carriers = + arrayOf( + ByteArray::class.java, + ByteArray::class.java, + ShortArray::class.java, + ShortArray::class.java, + IntArray::class.java, + IntArray::class.java, + LongArray::class.java, + LongArray::class.java, + ) + val typeIds = + intArrayOf( + Types.UINT8_ARRAY, + Types.UINT8_ARRAY, + Types.UINT16_ARRAY, + Types.UINT16_ARRAY, + Types.UINT32_ARRAY, + Types.UINT32_ARRAY, + Types.UINT64_ARRAY, + Types.UINT64_ARRAY, + ) + for (index in types.indices) { + assertEquals(carriers[index], types[index].rawType) + assertEquals(typeIds[index], types[index].typeExtMeta.typeId()) + assertEquals(index % 2 == 1, types[index].typeExtMeta.nullable()) + } + } +} diff --git a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinNullabilityRuntimeTest.kt b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinNullabilityRuntimeTest.kt new file mode 100644 index 0000000000..cbab57cc02 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinNullabilityRuntimeTest.kt @@ -0,0 +1,377 @@ +/* + * 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.fory.json.kotlin + +import java.util.Optional +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.atomic.AtomicReferenceArray +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.apache.fory.json.ForyJson +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.annotation.JsonProperty +import org.apache.fory.meta.TypeExtMeta +import org.apache.fory.reflect.TypeRef + +class KotlinNullabilityRuntimeTest { + data class ArrayValue(val text: String) + + data class ConstructorNulls( + val required: String, + val nullable: String?, + val count: Int, + val nullableCount: Int?, + val defaultNullable: String? = "nullable-default", + val defaultNonNull: String = "non-null-default", + ) + + data class CollectionModel( + val readOnlyList: List, + val mutableList: MutableList, + val readOnlySet: Set, + val mutableSet: MutableSet, + val readOnlyMap: Map, + val mutableMap: MutableMap, + val deque: ArrayDeque, + val array: Array, + ) + + data class OmissionModel( + val id: Int, + val defaultNull: String? = null, + val requiredNull: String?, + ) + + class DeferredNullableModel(val id: Int) { + var value: String? = "initializer" + + override fun equals(other: Any?): Boolean = + other is DeferredNullableModel && id == other.id && value == other.value + + override fun hashCode(): Int = 31 * id + (value?.hashCode() ?: 0) + } + + data class InvalidOmission( + @get:JsonProperty(include = JsonProperty.Include.NON_NULL) val value: String?, + ) + + @Test + fun rootNullability() { + forEachJsonMode { json -> + val nonNull = jsonTypeRef() + val nullable = jsonTypeRef() + assertFailsWith { json.fromJson("null", nonNull) } + assertFailsWith { json.fromJson("null".toByteArray(), nonNull) } + assertNull(json.fromJson("null", nullable)) + assertNull(json.fromJson("null".toByteArray(), nullable)) + assertEquals("null", json.toJson(null, nullable)) + assertEquals("null", json.toJsonBytes(null, nullable).decodeToString()) + assertEquals("漢字", json.fromJson("\"漢字\"", nonNull)) + assertEquals("text", json.fromJson("\"text\"".toByteArray(), nonNull)) + } + } + + @Test + fun constructorPresenceAndNull() { + forEachJsonMode { json -> + val type = jsonTypeRef() + val defaultsJson = """{"required":"漢","nullable":null,"count":1,"nullableCount":null}""" + val defaults = + ConstructorNulls( + required = "漢", + nullable = null, + count = 1, + nullableCount = null, + ) + assertEquals(defaults, json.fromJson(defaultsJson, type)) + assertEquals(defaults, json.fromJson(defaultsJson.toByteArray(), type)) + + val explicitNull = + """{"required":"value","nullable":null,"count":1,"nullableCount":null,"defaultNullable":null,"defaultNonNull":"set"}""" + assertEquals( + ConstructorNulls("value", null, 1, null, null, "set"), + json.fromJson(explicitNull, type), + ) + + assertFailsWith { + json.fromJson("""{"required":"value","count":1,"nullableCount":null}""", type) + } + assertFailsWith { + json.fromJson( + """{"required":null,"nullable":null,"count":1,"nullableCount":null}""", + type, + ) + } + assertFailsWith { + json.fromJson( + """{"required":"value","nullable":null,"count":null,"nullableCount":null}""", + type, + ) + } + assertFailsWith { + json.fromJson( + """{"required":"value","nullable":null,"count":1,"nullableCount":null,"defaultNonNull":null}""", + type, + ) + } + assertEquals(defaults, json.fromJson(json.toJson(defaults, type), type)) + assertEquals(defaults, json.fromJson(json.toJsonBytes(defaults, type), type)) + } + } + + @Test + fun nestedNullability() { + forEachJsonMode { json -> + val nullableList = jsonTypeRef>() + assertEquals(listOf("a", null), json.fromJson("[\"a\",null]", nullableList)) + assertEquals( + listOf("漢", null), + json.fromJson("[\"漢\",null]", nullableList), + ) + assertEquals( + listOf("a", null), + json.fromJson("[\"a\",null]".toByteArray(), nullableList), + ) + assertFailsWith { + json.fromJson("[\"a\",null]", jsonTypeRef>()) + } + + val nullableMap = jsonTypeRef>() + assertEquals(mapOf("value" to null), json.fromJson("{\"value\":null}", nullableMap)) + assertFailsWith { + json.fromJson("{\"value\":null}", jsonTypeRef>()) + } + assertFailsWith { json.fromJson("{}", jsonTypeRef>()) } + + val nullableArray = jsonTypeRef>() + assertContentEquals( + arrayOf("a", null), + json.fromJson("[\"a\",null]", nullableArray), + ) + assertFailsWith { + json.fromJson("[\"a\",null]", jsonTypeRef>()) + } + } + } + + @Test + fun boxedArrayNullability() { + forEachJsonMode { json -> + val ints = jsonTypeRef>() + val nullableInts = jsonTypeRef>() + assertArrayRoundTrip(json, arrayOf(1, 2), ints) + assertArrayRoundTrip(json, arrayOf(1, null), nullableInts) + assertArrayNullRejected(json, ints, arrayOf(1, null), "[1,null]") + } + } + + @Test + fun objectArrayNullability() { + forEachJsonMode { json -> + val values = jsonTypeRef>() + val nullableValues = jsonTypeRef>() + assertArrayRoundTrip(json, arrayOf(ArrayValue("漢")), values) + assertArrayRoundTrip(json, arrayOf(ArrayValue("a"), null), nullableValues) + assertArrayNullRejected( + json, + values, + arrayOf(ArrayValue("a"), null), + """[{"text":"a"},null]""", + ) + } + } + + @Test + fun atomicArrayNullability() { + forEachJsonMode { json -> + val atomic = jsonTypeRef>() + val nullableAtomic = jsonTypeRef>() + assertAtomicRoundTrip(json, AtomicReferenceArray(arrayOf("a", "漢")), atomic) + assertAtomicRoundTrip(json, AtomicReferenceArray(arrayOf("a", null)), nullableAtomic) + assertFailsWith { json.fromJson("[\"a\",null]", atomic) } + assertFailsWith { json.fromJson("[\"a\",null]".toByteArray(), atomic) } + @Suppress("UNCHECKED_CAST") + val invalidAtomic = AtomicReferenceArray(arrayOf("a", null)) as AtomicReferenceArray + assertFailsWith { json.toJson(invalidAtomic, atomic) } + assertFailsWith { json.toJsonBytes(invalidAtomic, atomic) } + } + } + + @Test + fun standardCollectionOwners() { + val value = + CollectionModel( + readOnlyList = listOf("one", "two"), + mutableList = mutableListOf(1, 2), + readOnlySet = linkedSetOf("one", "two"), + mutableSet = linkedSetOf(1, 2), + readOnlyMap = linkedMapOf("one" to 1, "two" to 2), + mutableMap = linkedMapOf("one" to "first"), + deque = ArrayDeque(listOf(1, 2)), + array = arrayOf("one", "two"), + ) + forEachJsonMode { json -> + val type = jsonTypeRef() + val decoded = json.fromJson(json.toJson(value, type), type) + assertCollections(value, decoded) + assertCollections(value, json.fromJson(json.toJsonBytes(value, type), type)) + assertTrue(decoded.readOnlyList is ArrayList<*>) + assertTrue(decoded.readOnlySet is LinkedHashSet<*>) + assertTrue(decoded.readOnlyMap is LinkedHashMap<*, *>) + @Suppress("UNCHECKED_CAST") (decoded.readOnlyList as MutableList).add("mutable") + assertEquals(listOf("one", "two", "mutable"), decoded.readOnlyList) + } + } + + @Test + fun transparentWrapperNullability() { + forEachJsonMode { json -> + assertEquals(Optional.empty(), json.fromJson("null", jsonTypeRef>())) + assertFailsWith { json.fromJson("null", jsonTypeRef>()) } + assertFailsWith { json.fromJson("null", jsonTypeRef?>()) } + + val childNullable = json.fromJson("null", jsonTypeRef>()) + assertNull(childNullable.get()) + assertNull(json.fromJson("null", jsonTypeRef?>())) + assertFailsWith { + json.fromJson("null", jsonTypeRef>()) + } + assertFailsWith { + json.fromJson("null", jsonTypeRef?>()) + } + } + } + + @Test + fun reconstructibleNullOmission() { + KotlinJsonTestMode.entries.forEach { mode -> + val json = newNullOmittingJson(mode) + val type = jsonTypeRef() + val value = OmissionModel(id = 1, requiredNull = null) + val text = json.toJson(value, type) + assertTrue(text.contains("\"defaultNull\":null"), text) + assertTrue(text.contains("\"requiredNull\":null"), text) + assertEquals(value, json.fromJson(text, type)) + assertEquals(value, json.fromJson(json.toJsonBytes(value, type), type)) + + val deferredType = jsonTypeRef() + val deferred = DeferredNullableModel(2).also { it.value = null } + val deferredText = json.toJson(deferred, deferredType) + assertTrue(deferredText.contains("\"value\":null"), deferredText) + assertEquals(deferred, json.fromJson(deferredText, deferredType)) + } + + val json = newKotlinJson(KotlinJsonTestMode.INTERPRETED) + assertFailsWith { + json.toJson(InvalidOmission(null), jsonTypeRef()) + } + } + + @Test + fun unitAndNothingRoots() { + forEachJsonMode { json -> + val unit = jsonTypeRef() + assertEquals("{}", json.toJson(Unit, unit)) + assertEquals("{}", json.toJsonBytes(Unit, unit).decodeToString()) + assertSame(Unit, json.fromJson("{}", unit)) + assertSame(Unit, json.fromJson("{}".toByteArray(), unit)) + assertFailsWith { json.fromJson("{\"value\":1}", unit) } + assertFailsWith { json.fromJson("null", unit) } + + val nullableUnit = jsonTypeRef() + assertNull(json.fromJson("null", nullableUnit)) + assertSame(Unit, json.fromJson("{}", nullableUnit)) + assertEquals("null", json.toJson(null, nullableUnit)) + + val nothing = jsonTypeRef() + assertNull(json.fromJson("null", nothing)) + assertNull(json.fromJson("null".toByteArray(), nothing)) + assertEquals("null", json.toJson(null, nothing)) + assertEquals("null", json.toJsonBytes(null, nothing).decodeToString()) + assertFailsWith { json.fromJson("0", nothing) } + val nonNullNothing = + TypeRef.of(java.lang.Void::class.java, TypeExtMeta.of(0, false, false, false)) + assertFailsWith { json.fromJson("null", nonNullNothing) } + assertSame(Unit, json.fromJson("{}", unit)) + } + } + + private fun assertCollections(expected: CollectionModel, actual: CollectionModel) { + assertEquals(expected.readOnlyList, actual.readOnlyList) + assertEquals(expected.mutableList, actual.mutableList) + assertEquals(expected.readOnlySet, actual.readOnlySet) + assertEquals(expected.mutableSet, actual.mutableSet) + assertEquals(expected.readOnlyMap, actual.readOnlyMap) + assertEquals(expected.mutableMap, actual.mutableMap) + assertEquals(expected.deque, actual.deque) + assertContentEquals(expected.array, actual.array) + } + + private fun assertArrayRoundTrip(json: ForyJson, value: Array, type: TypeRef>) { + assertContentEquals(value, json.fromJson(json.toJson(value, type), type)) + assertContentEquals(value, json.fromJson(json.toJsonBytes(value, type), type)) + } + + private fun assertArrayNullRejected( + json: ForyJson, + type: TypeRef>, + nullableValue: Array, + text: String, + ) { + assertFailsWith { json.fromJson(text, type) } + assertFailsWith { json.fromJson(text.toByteArray(), type) } + @Suppress("UNCHECKED_CAST") val invalid = nullableValue as Array + assertFailsWith { json.toJson(invalid, type) } + assertFailsWith { json.toJsonBytes(invalid, type) } + } + + private fun assertAtomicRoundTrip( + json: ForyJson, + value: AtomicReferenceArray, + type: TypeRef>, + ) { + assertAtomicEquals(value, json.fromJson(json.toJson(value, type), type)) + assertAtomicEquals(value, json.fromJson(json.toJsonBytes(value, type), type)) + } + + private fun assertAtomicEquals( + expected: AtomicReferenceArray, + actual: AtomicReferenceArray, + ) { + assertEquals(expected.length(), actual.length()) + repeat(expected.length()) { assertEquals(expected.get(it), actual.get(it)) } + } + + private fun newNullOmittingJson(mode: KotlinJsonTestMode): ForyJson { + val builder = ForyJsonKotlin.builder().writeNullFields(false) + return when (mode) { + KotlinJsonTestMode.INTERPRETED -> builder.withCodegen(false).build() + KotlinJsonTestMode.SYNCHRONOUS -> + builder.withCodegen(true).withAsyncCompilation(false).build() + KotlinJsonTestMode.ASYNCHRONOUS -> + builder.withCodegen(true).withAsyncCompilation(true).build() + } + } +} diff --git a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinObjectRuntimeMatrixTest.kt b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinObjectRuntimeMatrixTest.kt new file mode 100644 index 0000000000..af43ce7487 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinObjectRuntimeMatrixTest.kt @@ -0,0 +1,252 @@ +/* + * 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.fory.json.kotlin + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame +import org.apache.fory.json.ForyJson +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.annotation.JsonIgnore +import org.apache.fory.reflect.TypeRef + +class KotlinObjectRuntimeMatrixTest { + data class NestedModel(val id: Int, val text: String) + + data class OverloadedModel @JvmOverloads constructor(val value: String, val count: Int = 7) + + class DeferredModel(val id: Int) { + var label: String = "initializer" + + override fun equals(other: Any?): Boolean = + other is DeferredModel && id == other.id && label == other.label + + override fun hashCode(): Int = 31 * id + label.hashCode() + } + + class JvmFieldModel(val id: Int) { + @JvmField var label: String = "initializer" + + override fun equals(other: Any?): Boolean = + other is JvmFieldModel && id == other.id && label == other.label + + override fun hashCode(): Int = 31 * id + label.hashCode() + } + + class PrivateSetterModel(val id: Int) { + var label: String = "initializer" + private set + } + + open class GenericBase { + var inherited: T? = null + } + + class InheritedModel(val id: Int) : GenericBase() { + override fun equals(other: Any?): Boolean = + other is InheritedModel && id == other.id && inherited == other.inherited + + override fun hashCode(): Int = 31 * id + (inherited?.hashCode() ?: 0) + } + + class LateinitModel(val id: Int) { + lateinit var required: String + } + + class IgnoredComputed(val id: Int) { + @get:JsonIgnore + val computed: Int + get() = id * 2 + + override fun equals(other: Any?): Boolean = other is IgnoredComputed && id == other.id + + override fun hashCode(): Int = id + } + + class ComputedModel(val id: Int) { + val computed: Int + get() = id * 2 + } + + class DelegatedModel(val id: Int) { + val delegated: String by lazy { id.toString() } + } + + inner class InnerModel(val id: Int) + + object Marker + + data object DataMarker + + object StatefulMarker { + var state: Int = 1 + } + + class CompanionOwner { + companion object + } + + @Test + fun ordinaryModelsAcrossRepresentations() { + assertRoundTrip(NestedModel(7, "plain"), jsonTypeRef()) + assertRoundTrip( + NestedModel(8, "漢字"), + jsonTypeRef(), + "{\"id\":8,\"text\":\"漢字\"}", + ) + } + + @Test + fun jvmOverloadsKeepsOneCreator() { + forEachJsonMode { json -> + val type = jsonTypeRef() + val value = OverloadedModel("漢") + assertEquals(value, json.fromJson("{\"value\":\"漢\"}", type)) + assertEquals(value, json.fromJson("{\"value\":\"漢\"}".toByteArray(), type)) + assertEquals(value, json.fromJson(json.toJson(value, type), type)) + assertEquals(value, json.fromJson(json.toJsonBytes(value, type), type)) + } + } + + @Test + fun deferredAndInheritedProperties() { + forEachJsonMode { json -> + val type = jsonTypeRef() + assertEquals(DeferredModel(1), json.fromJson("{\"id\":1}", type)) + val explicit = DeferredModel(1).also { it.label = "input" } + assertEquals(explicit, json.fromJson("{\"id\":1,\"label\":\"input\"}", type)) + assertEquals(explicit, json.fromJson(json.toJsonBytes(explicit, type), type)) + + val inheritedType = jsonTypeRef() + val inherited = InheritedModel(2).also { it.inherited = "base" } + assertEquals(inherited, json.fromJson(json.toJson(inherited, inheritedType), inheritedType)) + assertEquals( + inherited, + json.fromJson("{\"id\":2,\"inherited\":\"base\",\"unicode\":\"漢\"}", inheritedType), + ) + } + } + + @Test + fun jvmFieldRoundTrip() { + forEachJsonMode { json -> + val type = jsonTypeRef() + assertEquals(JvmFieldModel(1), json.fromJson("{\"id\":1}", type)) + val explicit = JvmFieldModel(1).also { it.label = "漢" } + assertEquals(explicit, json.fromJson("{\"id\":1,\"label\":\"漢\"}", type)) + assertEquals(explicit, json.fromJson(json.toJson(explicit, type), type)) + assertEquals(explicit, json.fromJson(json.toJsonBytes(explicit, type), type)) + } + } + + @Test + fun privateSetterIsRejected() { + val json = newKotlinJson(KotlinJsonTestMode.INTERPRETED) + assertFailsWith { + json.fromJson( + "{\"id\":1,\"label\":\"input\"}", + jsonTypeRef(), + ) + } + } + + @Test + fun lateinitAndIgnoredBodyProperties() { + forEachJsonMode { json -> + val lateinitType = jsonTypeRef() + assertFailsWith { json.fromJson("{\"id\":1}", lateinitType) } + val value = json.fromJson("{\"id\":1,\"required\":\"ready\"}", lateinitType) + assertEquals(1, value.id) + assertEquals("ready", value.required) + + val ignoredType = jsonTypeRef() + val ignored = IgnoredComputed(3) + assertEquals("{\"id\":3}", json.toJson(ignored, ignoredType)) + assertEquals(ignored, json.fromJson("{\"id\":3}", ignoredType)) + } + } + + @Test + fun unreconstructiblePropertiesAreRejected() { + val json = newKotlinJson(KotlinJsonTestMode.INTERPRETED) + assertFailsWith { + json.fromJson("{\"id\":1,\"computed\":2}", jsonTypeRef()) + } + assertFailsWith { + json.fromJson("{\"id\":1,\"delegated\":\"1\"}", jsonTypeRef()) + } + } + + @Test + fun unstableClassShapesAreRejected() { + val json = newKotlinJson(KotlinJsonTestMode.INTERPRETED) + assertFailsWith { json.fromJson("{\"id\":1}", jsonTypeRef()) } + + class LocalModel(val id: Int) + assertFailsWith { json.fromJson("{\"id\":1}", jsonTypeRef()) } + + val anonymous = + object { + val id: Int = 1 + } + assertFailsWith { json.fromJson("{\"id\":1}", anonymous.javaClass) } + } + + @Test + fun singletonIdentityAndStrictShape() { + forEachJsonMode { json -> + assertSingleton(json, Marker, jsonTypeRef()) + assertSingleton(json, DataMarker, jsonTypeRef()) + } + + val budgeted = ForyJsonKotlin.builder().withCodegen(false).withMaxGraphMemoryBytes(1).build() + assertSame(Marker, budgeted.fromJson("{}", jsonTypeRef())) + assertSame(DataMarker, budgeted.fromJson("{}", jsonTypeRef())) + } + + @Test + fun statefulAndCompanionObjectsAreRejected() { + val json = newKotlinJson(KotlinJsonTestMode.INTERPRETED) + assertFailsWith { json.fromJson("{}", jsonTypeRef()) } + assertFailsWith { + json.fromJson("{}", jsonTypeRef()) + } + } + + private fun assertSingleton(json: ForyJson, value: T, type: TypeRef) { + assertEquals("{}", json.toJson(value, type)) + assertEquals("{}", json.toJsonBytes(value, type).decodeToString()) + assertSame(value, json.fromJson("{}", type)) + assertSame(value, json.fromJson("{}".toByteArray(), type)) + assertFailsWith { json.fromJson("{\"unexpected\":1}", type) } + assertFailsWith { json.fromJson("null", type) } + assertSame(value, json.fromJson("{}", type)) + } + + private fun assertRoundTrip(value: T, type: TypeRef, utf16Json: String? = null) { + forEachJsonMode { json -> + val text = json.toJson(value, type) + assertEquals(value, json.fromJson(text, type)) + assertEquals(value, json.fromJson(json.toJsonBytes(value, type), type)) + if (utf16Json != null) assertEquals(value, json.fromJson(utf16Json, type)) + } + } +} diff --git a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinRuntimeTestSupport.kt b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinRuntimeTestSupport.kt new file mode 100644 index 0000000000..609d767924 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinRuntimeTestSupport.kt @@ -0,0 +1,87 @@ +/* + * 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.fory.json.kotlin + +import java.io.ByteArrayInputStream +import org.apache.fory.codegen.CompileState +import org.apache.fory.json.ForyJson +import org.apache.fory.json.ForyJsonBuilder +import org.apache.fory.reflect.ReflectionUtils +import org.codehaus.janino.util.ClassFile + +internal enum class KotlinJsonTestMode { + INTERPRETED, + SYNCHRONOUS, + ASYNCHRONOUS, +} + +internal fun forEachJsonMode(action: (ForyJson) -> Unit) { + KotlinJsonTestMode.entries.forEach { action(newKotlinJson(it)) } +} + +internal fun newKotlinJson( + mode: KotlinJsonTestMode, + configure: ForyJsonBuilder.() -> Unit = {}, +): ForyJson { + val builder = ForyJsonKotlin.builder().apply(configure) + return when (mode) { + KotlinJsonTestMode.INTERPRETED -> builder.withCodegen(false).build() + KotlinJsonTestMode.SYNCHRONOUS -> builder.withCodegen(true).withAsyncCompilation(false).build() + KotlinJsonTestMode.ASYNCHRONOUS -> builder.withCodegen(true).withAsyncCompilation(true).build() + } +} + +@Suppress("UNCHECKED_CAST") +internal fun generatedClassBytes(json: ForyJson, modelName: String): Map { + val slots = ReflectionUtils.getObjectFieldValue(json, "slots") as Array + val state = ReflectionUtils.getObjectFieldValue(slots[0], "state") + val resolver = ReflectionUtils.getObjectFieldValue(state, "typeResolver") + val registry = ReflectionUtils.getObjectFieldValue(resolver, "sharedRegistry") + val codegen = ReflectionUtils.getObjectFieldValue(registry, "codegen") + val generator = ReflectionUtils.getObjectFieldValue(codegen, "codeGenerator") + val states = + ReflectionUtils.getObjectFieldValue(generator, "parallelCompileState") + as Map + return states.values + .flatMap { it.result.entries } + .filter { it.key.contains(modelName) } + .associate { it.key to it.value } +} + +internal fun generatedMethodRefs(bytes: ByteArray): List { + val classFile = ClassFile(ByteArrayInputStream(bytes)) + return (1 until classFile.constantPoolSize).mapNotNull { index -> + val info = + runCatching { classFile.getConstantPoolInfo(index.toShort()) }.getOrNull() + as? ClassFile.ConstantMethodrefInfo ?: return@mapNotNull null + val nameAndType = info.getNameAndType(classFile) + GeneratedMethodRef( + info.getClassInfo(classFile).getName(classFile), + nameAndType.getName(classFile), + nameAndType.getDescriptor(classFile), + ) + } +} + +internal data class GeneratedMethodRef( + val owner: String, + val name: String, + val descriptor: String, +) diff --git a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinSealedRuntimeTest.kt b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinSealedRuntimeTest.kt new file mode 100644 index 0000000000..75fdb7e117 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinSealedRuntimeTest.kt @@ -0,0 +1,165 @@ +/* + * 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.fory.json.kotlin + +import kotlin.jvm.JvmInline +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertSame +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.annotation.JsonSubTypes + +@JsonSubTypes( + value = + [ + JsonSubTypes.Type(value = PropertyCircle::class, name = "circle"), + JsonSubTypes.Type(value = PropertyMarker::class, name = "marker"), + JsonSubTypes.Type(value = ListedOpenBranch::class, name = "open"), + ], + property = "kind", +) +sealed interface PropertyShape + +data class PropertyCircle(val radius: Int) : PropertyShape + +data object PropertyMarker : PropertyShape + +open class ListedOpenBranch(val value: Int) : PropertyShape + +class UnlistedDescendant(value: Int) : ListedOpenBranch(value) + +data class UnlistedShape(val value: String) : PropertyShape + +@JsonSubTypes( + value = + [ + JsonSubTypes.Type(value = WrappedData::class, name = "data"), + JsonSubTypes.Type(value = WrappedNumber::class, name = "number"), + ], + inclusion = JsonSubTypes.Inclusion.WRAPPER_OBJECT, +) +sealed interface ObjectWrappedShape + +data class WrappedData(val value: String) : ObjectWrappedShape + +@JvmInline value class WrappedNumber(val value: Int) : ObjectWrappedShape + +@JsonSubTypes( + value = [JsonSubTypes.Type(value = ArrayWrappedData::class, name = "data")], + inclusion = JsonSubTypes.Inclusion.WRAPPER_ARRAY, +) +sealed interface ArrayWrappedShape + +data class ArrayWrappedData(val value: String) : ArrayWrappedShape + +@JsonSubTypes( + value = [JsonSubTypes.Type(value = InvalidPropertyNumber::class, name = "number")], + property = "kind", +) +sealed interface InvalidPropertyShape + +@JvmInline value class InvalidPropertyNumber(val value: Int) : InvalidPropertyShape + +class KotlinSealedRuntimeTest { + @Test + fun propertyShape() { + forEachJsonMode { json -> + val type = jsonTypeRef() + val circle: PropertyShape = PropertyCircle(3) + assertEquals("{\"kind\":\"circle\",\"radius\":3}", json.toJson(circle, type)) + assertEquals(circle, json.fromJson(json.toJson(circle, type), type)) + assertEquals(circle, json.fromJson(json.toJsonBytes(circle, type), type)) + + val marker: PropertyShape = PropertyMarker + assertEquals("{\"kind\":\"marker\"}", json.toJson(marker, type)) + assertSame(PropertyMarker, json.fromJson("{\"kind\":\"marker\"}", type)) + assertSame( + PropertyMarker, + json.fromJson("{\"kind\":\"marker\"}".toByteArray(), type), + ) + + val nullable = jsonTypeRef() + assertNull(json.fromJson("null", nullable)) + assertEquals("null", json.toJson(null, nullable)) + } + } + + @Test + fun discriminatorFailures() { + forEachJsonMode { json -> + val type = jsonTypeRef() + assertFailsWith { json.fromJson("{\"radius\":3}", type) } + assertFailsWith { + json.fromJson("{\"kind\":\"unknown\",\"radius\":3}", type) + } + assertFailsWith { + json.fromJson("{\"kind\":\"circle\",\"kind\":\"circle\",\"radius\":3}", type) + } + assertFailsWith { + json.fromJson("{\"kind\":\"marker\",\"unexpected\":1}", type) + } + assertEquals(PropertyCircle(4), json.fromJson("{\"kind\":\"circle\",\"radius\":4}", type)) + } + } + + @Test + fun unlistedRuntimeTypes() { + forEachJsonMode { json -> + val type = jsonTypeRef() + assertFailsWith { json.toJson(UnlistedShape("value"), type) } + assertFailsWith { json.toJson(UnlistedDescendant(1), type) } + assertEquals( + ListedOpenBranch::class.java, + json.fromJson("{\"kind\":\"open\",\"value\":1}", type)::class.java, + ) + } + } + + @Test + fun wrapperShapes() { + forEachJsonMode { json -> + val objectType = jsonTypeRef() + val data: ObjectWrappedShape = WrappedData("漢") + assertEquals("{\"data\":{\"value\":\"漢\"}}", json.toJson(data, objectType)) + assertEquals(data, json.fromJson(json.toJson(data, objectType), objectType)) + assertEquals(data, json.fromJson(json.toJsonBytes(data, objectType), objectType)) + + val number: ObjectWrappedShape = WrappedNumber(9) + assertEquals("{\"number\":9}", json.toJson(number, objectType)) + assertEquals(number, json.fromJson("{\"number\":9}", objectType)) + + val arrayType = jsonTypeRef() + val array: ArrayWrappedShape = ArrayWrappedData("value") + assertEquals("[\"data\",{\"value\":\"value\"}]", json.toJson(array, arrayType)) + assertEquals(array, json.fromJson(json.toJson(array, arrayType), arrayType)) + assertEquals(array, json.fromJson(json.toJsonBytes(array, arrayType), arrayType)) + } + } + + @Test + fun propertyRequiresObjectBranch() { + val json = newKotlinJson(KotlinJsonTestMode.INTERPRETED) + assertFailsWith { + json.fromJson("{\"kind\":\"number\",\"value\":1}", jsonTypeRef()) + } + } +} diff --git a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinTypeRefRuntimeTest.kt b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinTypeRefRuntimeTest.kt new file mode 100644 index 0000000000..876754dc42 --- /dev/null +++ b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinTypeRefRuntimeTest.kt @@ -0,0 +1,219 @@ +/* + * 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.fory.json.kotlin + +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.apache.fory.json.ForyJsonException +import org.apache.fory.json.annotation.JsonMixin +import org.apache.fory.json.annotation.JsonMixinRemove +import org.apache.fory.json.annotation.JsonSubTypes + +private typealias StringTokenBox = TokenBox + +data class TokenBox(val value: T) + +data class CovariantBox(val value: T) + +data class NonNullBoundBox(val value: T) + +class InvariantBox(val value: T) { + override fun equals(other: Any?): Boolean = other is InvariantBox<*> && value == other.value + + override fun hashCode(): Int = value?.hashCode() ?: 0 +} + +@JsonSubTypes( + value = [JsonSubTypes.Type(value = DirectProjectionCircle::class, name = "circle")], + property = "kind", +) +interface DirectProjectionShape + +data class DirectProjectionCircle(val radius: Int) : DirectProjectionShape + +data class DirectProjectionSquare(val size: Int) : DirectProjectionShape + +data class DirectProjectionHolder(val value: InvariantBox) + +@JsonMixin(target = DirectProjectionShape::class) +@JsonMixinRemove(value = [JsonSubTypes::class]) +interface ProjectionRemovalMixin + +@JsonMixin(target = DirectProjectionShape::class) +@JsonSubTypes( + value = [JsonSubTypes.Type(value = DirectProjectionSquare::class, name = "square")], + property = "kind", +) +interface ProjectionReplacementMixin + +interface ContributedProjectionShape + +data class ContributedProjectionValue(val label: String) : ContributedProjectionShape + +data class ContributedProjectionHolder(val value: InvariantBox) + +@JsonMixin(target = ContributedProjectionShape::class) +@JsonSubTypes( + value = [JsonSubTypes.Type(value = ContributedProjectionValue::class, name = "value")], + property = "kind", +) +interface ProjectionContributionMixin + +class KotlinTypeRefRuntimeTest { + @Test + fun typeAliasUsesExpandedBinding() { + val alias = jsonTypeRef() + val expanded = jsonTypeRef>() + assertEquals(expanded, alias) + + forEachJsonMode { json -> + val value = TokenBox("漢") + assertEquals(value, json.fromJson(json.toJson(value, alias), alias)) + assertEquals(value, json.fromJson(json.toJsonBytes(value, alias), alias)) + } + } + + @Test + fun declarationAndUseSiteVariance() { + forEachJsonMode { json -> + val covariant = jsonTypeRef>() + val covariantValue = CovariantBox("value") + assertEquals( + covariantValue, + json.fromJson(json.toJson(covariantValue, covariant), covariant), + ) + + val projected = jsonTypeRef>() + val projectedValue: InvariantBox = InvariantBox("projected") + assertEquals( + projectedValue, + json.fromJson(json.toJson(projectedValue, projected), projected), + ) + assertEquals( + projectedValue, + json.fromJson(json.toJsonBytes(projectedValue, projected), projected), + ) + } + } + + @Test + fun invalidProjections() { + assertFailsWith { jsonTypeRef>() } + assertFailsWith { jsonTypeRef>() } + + val open = jsonTypeRef>() + val json = newKotlinJson(KotlinJsonTestMode.INTERPRETED) + assertFailsWith { json.fromJson("{\"value\":\"text\"}", open) } + } + + @Test + fun directProjectedSubtype() { + val directType = jsonTypeRef() + val direct = DirectProjectionHolder(InvariantBox(DirectProjectionCircle(3))) + forEachJsonMode { json -> + assertEquals(direct, json.fromJson(json.toJson(direct, directType), directType)) + assertEquals(direct, json.fromJson(json.toJsonBytes(direct, directType), directType)) + } + } + + @Test + fun removedProjectedSubtype() { + val directType = jsonTypeRef() + KotlinJsonTestMode.entries.forEach { mode -> + val removed = newKotlinJson(mode) { registerMixin(ProjectionRemovalMixin::class.java) } + val error = + assertFailsWith { + removed.fromJson("{\"value\":{\"kind\":\"circle\",\"radius\":3}}", directType) + } + assertContains( + error.message.orEmpty(), + "Covariant JSON type must be final or declare effective @JsonSubTypes", + ) + } + } + + @Test + fun contributedProjectedSubtype() { + val contributedType = jsonTypeRef() + val contributedValue = + ContributedProjectionHolder(InvariantBox(ContributedProjectionValue("漢"))) + KotlinJsonTestMode.entries.forEach { mode -> + val contributed = + newKotlinJson(mode) { registerMixin(ProjectionContributionMixin::class.java) } + assertEquals( + contributedValue, + contributed.fromJson( + contributed.toJson(contributedValue, contributedType), + contributedType + ), + ) + assertEquals( + contributedValue, + contributed.fromJson( + contributed.toJsonBytes(contributedValue, contributedType), + contributedType + ), + ) + } + } + + @Test + fun replacedProjectedSubtype() { + val directType = jsonTypeRef() + val replacementValue = DirectProjectionHolder(InvariantBox(DirectProjectionSquare(4))) + KotlinJsonTestMode.entries.forEach { mode -> + val replacement = + newKotlinJson(mode) { registerMixin(ProjectionReplacementMixin::class.java) } + assertEquals( + replacementValue, + replacement.fromJson(replacement.toJson(replacementValue, directType), directType), + ) + assertEquals( + replacementValue, + replacement.fromJson(replacement.toJsonBytes(replacementValue, directType), directType), + ) + } + } + + @Test + fun rawGenericRootIsRejected() { + val json = newKotlinJson(KotlinJsonTestMode.INTERPRETED) + assertFailsWith { + json.fromJson("{\"value\":\"text\"}", TokenBox::class.java) + } + val type = jsonTypeRef>() + assertEquals(TokenBox("text"), json.fromJson("{\"value\":\"text\"}", type)) + } + + @Test + fun substitutedNullability() { + forEachJsonMode { json -> + assertFailsWith { + json.fromJson("{\"value\":null}", jsonTypeRef>()) + } + assertEquals( + TokenBox(null), + json.fromJson("{\"value\":null}", jsonTypeRef>()), + ) + } + } +} diff --git a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinUnsupportedRuntimeTest.kt b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinUnsupportedRuntimeTest.kt new file mode 100644 index 0000000000..b649369ddc --- /dev/null +++ b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinUnsupportedRuntimeTest.kt @@ -0,0 +1,93 @@ +/* + * 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.fory.json.kotlin + +import kotlin.coroutines.Continuation +import kotlin.coroutines.CoroutineContext +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.ComparableTimeMark +import kotlin.time.ExperimentalTime +import kotlin.time.TimeMark +import kotlin.time.TimeSource +import org.apache.fory.json.ForyJson +import org.apache.fory.json.ForyJsonException + +@OptIn(ExperimentalTime::class) +class KotlinUnsupportedRuntimeTest { + @Test + fun executableAndReflectionFamilies() { + val json = newKotlinJson(KotlinJsonTestMode.INTERPRETED) + assertRejected<() -> String>(json) + assertRejectedClass( + json, + Class.forName("kotlin.coroutines.jvm.internal.SuspendFunction"), + ) + assertRejected>(json) + assertRejected(json) + assertRejected>(json) + assertRejected(json) + assertRejected>(json) + } + + @Test + fun lazyAndCursorFamilies() { + val json = newKotlinJson(KotlinJsonTestMode.INTERPRETED) + assertRejected>(json) + assertRejected>(json) + assertRejected>(json) + assertRejected>(json) + assertRejected>(json) + assertRejected>(json) + } + + @Test + fun abstractRangeAndTimeStateFamilies() { + val json = newKotlinJson(KotlinJsonTestMode.INTERPRETED) + assertRejected>(json) + assertRejected>(json) + assertRejected(json) + assertRejected(json) + assertRejected(json) + assertRejected(json) + } + + private inline fun assertRejected(json: ForyJson) { + val failure = assertFailsWith { json.fromJson("{}", jsonTypeRef()) } + assertTrue( + failure.message?.contains("Unsupported Kotlin JSON type") == true, + failure.message, + ) + } + + @Suppress("UNCHECKED_CAST") + private fun assertRejectedClass(json: ForyJson, type: Class<*>) { + val failure = assertFailsWith { json.fromJson("{}", type as Class) } + assertTrue( + failure.message?.contains("Unsupported Kotlin JSON type") == true, + failure.message, + ) + } +} diff --git a/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinValueClassCodecTest.kt b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinValueClassCodecTest.kt new file mode 100644 index 0000000000..d01200df5f --- /dev/null +++ b/kotlin/fory-json-kotlin/src/test/kotlin/org/apache/fory/json/kotlin/KotlinValueClassCodecTest.kt @@ -0,0 +1,342 @@ +/* + * 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.fory.json.kotlin + +import kotlin.jvm.JvmInline +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.apache.fory.json.ForyJson +import org.apache.fory.json.ForyJsonException +import org.apache.fory.serializer.GraphMemoryEstimates + +@JvmInline +internal value class PositiveId(internal val value: Long) { + init { + require(value >= 0) { "id must be non-negative" } + } +} + +@JvmInline internal value class NullableText(internal val value: String?) + +@JvmInline internal value class GenericValue(internal val value: T) + +@JvmInline internal value class NestedId(internal val value: PositiveId) + +@JvmInline internal value class GenericKey(internal val value: T) + +internal data class ValueClassHolder( + internal val id: PositiveId, + internal val nullableId: PositiveId?, + internal val generic: GenericValue, + internal val defaultId: PositiveId = PositiveId(41), +) + +internal data class NullableValueClassHolder(internal val text: NullableText) + +internal data class PrimitiveValueHolder(internal val id: PositiveId) + +@JvmInline internal value class UnsignedId(internal val value: UInt) + +internal data class UnsignedValueHolder(internal val id: UnsignedId) + +@JvmInline internal value class RecursiveValueId(internal val value: Long) + +internal data class SelfValueHolder( + internal val id: RecursiveValueId, + internal val next: SelfValueHolder?, +) + +internal data class MutualValueLeftHolder( + internal val id: RecursiveValueId, + internal val right: MutualValueRightHolder?, +) + +internal data class MutualValueRightHolder( + internal val id: RecursiveValueId, + internal val left: MutualValueLeftHolder?, +) + +class KotlinValueClassCodecTest { + private fun fory(maxGraphMemoryBytes: Long = ForyJson.DEFAULT_MAX_GRAPH_MEMORY_BYTES): ForyJson = + ForyJsonKotlin.builder().withCodegen(false).withMaxGraphMemoryBytes(maxGraphMemoryBytes).build() + + @Test + fun exactMetadata() { + val model = KotlinValueClassMetadata.inspect(jsonTypeRef()) + val shape = model.shape + assertEquals(PositiveId::class.java, shape.ownerClass) + assertEquals(Long::class.javaPrimitiveType, shape.terminalType.rawType) + assertEquals(listOf("constructor-impl"), model.constructors.map { it.name }) + assertEquals( + listOf(listOf(Long::class.javaPrimitiveType)), + model.constructors.map { it.parameterTypes.toList() }, + ) + assertEquals(listOf(Long::class.javaPrimitiveType), model.constructors.map { it.returnType }) + assertEquals(listOf("box-impl"), model.boxes.map { it.name }) + assertEquals(listOf(PositiveId::class.java), model.boxes.map { it.returnType }) + assertEquals(listOf("unbox-impl"), model.unboxes.map { it.name }) + assertEquals(listOf(Long::class.javaPrimitiveType), model.unboxes.map { it.returnType }) + } + + @Test + fun rootRoundTrip() { + val json = fory() + val type = jsonTypeRef() + val value = PositiveId(19) + assertEquals("19", json.toJson(value, type)) + assertEquals(value, json.fromJson("19", type)) + assertEquals(value, json.fromJson(json.toJsonBytes(value, type), type)) + } + + @Test + fun constructorInvariant() { + val json = fory() + val failure = + assertFailsWith { json.fromJson("-1", jsonTypeRef()) } + assertTrue( + generateSequence(failure as Throwable?) { it.cause } + .any { it is IllegalArgumentException && it.message == "id must be non-negative" } + ) + assertEquals(PositiveId(1), json.fromJson("1", jsonTypeRef())) + } + + @Test + fun nullableOuter() { + val json = fory() + val type = jsonTypeRef() + assertEquals("null", json.toJson(null, type)) + assertEquals(null, json.fromJson("null", type)) + assertEquals(PositiveId(7), json.fromJson("7", type)) + } + + @Test + fun nullableUnderlying() { + val json = fory() + val type = jsonTypeRef() + assertEquals(NullableText(null), json.fromJson("null", type)) + assertEquals("null", json.toJson(NullableText(null), type)) + assertEquals(NullableText("text"), json.fromJson("\"text\"", type)) + } + + @Test + fun ambiguousNullability() { + assertFailsWith { + KotlinValueClassMetadata.inspect(jsonTypeRef()) + } + } + + @Test + fun genericSubstitution() { + val nonNull = KotlinValueClassMetadata.inspect(jsonTypeRef>()).shape + assertEquals(String::class.java, nonNull.terminalType.rawType) + assertEquals(false, nonNull.terminalType.typeExtMeta.nullable()) + val nullable = KotlinValueClassMetadata.inspect(jsonTypeRef>()).shape + assertEquals(String::class.java, nullable.terminalType.rawType) + assertEquals(true, nullable.terminalType.typeExtMeta.nullable()) + + val json = fory() + val type = jsonTypeRef>>() + val value = listOf(GenericValue("a"), GenericValue("b")) + assertEquals(value, json.fromJson(json.toJson(value, type), type)) + } + + @Test + fun nestedValueClass() { + val shape = KotlinValueClassMetadata.inspect(jsonTypeRef()).shape + assertEquals( + listOf(NestedId::class.java, PositiveId::class.java), + shape.layers.map { it.ownerClass }, + ) + assertEquals(Long::class.javaPrimitiveType, shape.terminalType.rawType) + + val json = fory() + val type = jsonTypeRef() + val value = NestedId(PositiveId(23)) + assertEquals("23", json.toJson(value, type)) + assertEquals(value, json.fromJson("23", type)) + assertFailsWith { json.fromJson("-1", type) } + } + + @Test + fun objectOccurrences() { + forEachJsonMode { json -> + val type = jsonTypeRef() + val value = + ValueClassHolder( + PositiveId(7), + PositiveId(8), + GenericValue("value"), + PositiveId(9), + ) + assertEquals(value, json.fromJson(json.toJson(value, type), type)) + assertEquals( + ValueClassHolder(PositiveId(1), null, GenericValue("default")), + json.fromJson( + """{"id":1,"nullableId":null,"generic":"default"}""", + type, + ), + ) + } + } + + @Test + fun selfRecursiveOccurrence() { + val type = jsonTypeRef() + val value = SelfValueHolder(RecursiveValueId(1), SelfValueHolder(RecursiveValueId(2), null)) + forEachJsonMode { json -> + assertEquals(value, json.fromJson(json.toJson(value, type), type)) + assertEquals(value, json.fromJson(json.toJsonBytes(value, type), type)) + } + } + + @Test + fun mutualRecursiveOccurrence() { + val type = jsonTypeRef() + val value = + MutualValueLeftHolder( + RecursiveValueId(1), + MutualValueRightHolder( + RecursiveValueId(2), + MutualValueLeftHolder(RecursiveValueId(3), null), + ), + ) + forEachJsonMode { json -> + assertEquals(value, json.fromJson(json.toJson(value, type), type)) + assertEquals(value, json.fromJson(json.toJsonBytes(value, type), type)) + } + } + + @Test + fun nullableUnderlyingProperty() { + forEachJsonMode { json -> + val type = jsonTypeRef() + val value = NullableValueClassHolder(NullableText(null)) + assertEquals(value, json.fromJson(json.toJson(value, type), type)) + assertEquals(value, json.fromJson("""{"text":null}""", type)) + } + } + + @Test + fun mapKeyChain() { + val json = fory() + val signedType = jsonTypeRef, String>>() + val signed = linkedMapOf(GenericKey(31) to "value") + assertEquals("{\"31\":\"value\"}", json.toJson(signed, signedType)) + assertEquals(signed, json.fromJson("{\"31\":\"value\"}", signedType)) + + val unsignedType = jsonTypeRef, String>>() + val unsigned = linkedMapOf(GenericKey(UInt.MAX_VALUE) to "maximum") + assertEquals( + "{\"4294967295\":\"maximum\"}", + json.toJson(unsigned, unsignedType), + ) + assertEquals( + unsigned, + json.fromJson("{\"4294967295\":\"maximum\"}", unsignedType), + ) + } + + @Test + fun mapKeyBoxIsCharged() { + val mapBytes = GraphMemoryEstimates.shallowObjectBytes(LinkedHashMap::class.java) + val wrapperBytes = GraphMemoryEstimates.shallowObjectBytes(GenericKey::class.java) + val json = fory((mapBytes + wrapperBytes - 1).toLong()) + assertFailsWith { + json.fromJson("{\"1\":\"value\"}", jsonTypeRef, String>>()) + } + } + + @Test + fun boxedRootIsCharged() { + val wrapperBytes = GraphMemoryEstimates.shallowObjectBytes(PositiveId::class.java) + val json = fory((wrapperBytes - 1).toLong()) + assertFailsWith { json.fromJson("1", jsonTypeRef()) } + } + + @Test + fun generatedPrimitiveCarrier() { + val json = newKotlinJson(KotlinJsonTestMode.SYNCHRONOUS) + val type = jsonTypeRef() + val value = PrimitiveValueHolder(PositiveId(37)) + assertEquals(value, json.fromJson(json.toJson(value, type), type)) + assertEquals(value, json.fromJson(json.toJsonBytes(value, type), type)) + assertEquals(value, json.fromJson("""{"雪":0,"id":37}""", type)) + + val generated = generatedClassBytes(json, "PrimitiveValueHolder") + val readers = generated.filterKeys { it.contains("ReaderForyJsonCodec") } + val writers = generated.filterKeys { it.contains("WriterForyJsonCodec") } + assertEquals(3, readers.size, readers.keys.toString()) + assertEquals(2, writers.size, writers.keys.toString()) + + val valueOwner = PositiveId::class.java.name.replace('.', '/') + readers.forEach { (name, bytes) -> + val refs = generatedMethodRefs(bytes) + assertTrue( + refs.any { + it.owner == valueOwner && it.name == "constructor-impl" && it.descriptor == "(J)J" + }, + "$name does not invoke the exact primitive constructor-impl", + ) + assertNoValueBoxing(name, refs, valueOwner) + } + writers.forEach { (name, bytes) -> + val refs = generatedMethodRefs(bytes) + assertTrue( + refs.any { + it.owner == PrimitiveValueHolder::class.java.name.replace('.', '/') && + it.name.startsWith("getId-") && + it.descriptor == "()J" + }, + "$name does not read the physical long getter", + ) + assertNoValueBoxing(name, refs, valueOwner) + } + } + + @Test + fun semanticTerminal() { + val type = jsonTypeRef() + val value = UnsignedValueHolder(UnsignedId(UInt.MAX_VALUE)) + forEachJsonMode { json -> + val text = json.toJson(value, type) + assertEquals("""{"id":4294967295}""", text) + assertEquals(value, json.fromJson(text, type)) + assertEquals(value, json.fromJson(json.toJsonBytes(value, type), type)) + } + } + + private fun assertNoValueBoxing( + name: String, + refs: List, + valueOwner: String, + ) { + assertFalse( + refs.any { it.owner == "java/lang/Long" && it.name == "valueOf" }, + "$name boxes the primitive long carrier: $refs", + ) + assertFalse( + refs.any { it.owner == valueOwner && (it.name == "box-impl" || it.name == "unbox-impl") }, + "$name materializes the outer value-class wrapper: $refs", + ) + } +} diff --git a/kotlin/fory-kotlin-tests/pom.xml b/kotlin/fory-kotlin-tests/pom.xml index 752f2de8a9..5fa7f38a41 100644 --- a/kotlin/fory-kotlin-tests/pom.xml +++ b/kotlin/fory-kotlin-tests/pom.xml @@ -32,6 +32,7 @@ fory-kotlin-tests + true true diff --git a/kotlin/pom.xml b/kotlin/pom.xml index e25cd4d6ca..90b8342ede 100644 --- a/kotlin/pom.xml +++ b/kotlin/pom.xml @@ -51,6 +51,8 @@ fory-kotlin fory-kotlin-ksp + fory-json-kotlin + fory-json-kotlin-ksp fory-kotlin-tests @@ -60,12 +62,29 @@ UTF-8 1.8 2.3.20 - 2.3.7 + 2.3.8 0.4.1 + 3.6.1 + 2.2.0 2.43.0 + 1.35.0 + false + + true + + snapshot-publication + + + + org.apache.maven.plugins + maven-source-plugin + + + + apache-release @@ -132,6 +151,16 @@ kotlin-maven-plugin ${kotlin.version} + + org.codehaus.mojo + build-helper-maven-plugin + ${build-helper-maven-plugin.version} + + + org.jetbrains.dokka + dokka-maven-plugin + ${dokka-maven-plugin.version} + org.apache.maven.plugins maven-source-plugin @@ -140,7 +169,7 @@ attach-sources - jar + jar-no-fork @@ -177,7 +206,7 @@ - 1.19.1 + ${google-java-format.version} @@ -191,5 +220,53 @@ + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-kotlin-sources + generate-sources + + add-source + + + + ${project.basedir}/src/main/kotlin + + + + + add-kotlin-test-sources + generate-test-sources + + add-test-source + + + + ${project.basedir}/src/test/kotlin + + + + + + + org.jetbrains.dokka + dokka-maven-plugin + + + attach-dokka-javadocs + package + + javadocJar + + + ${dokka.skip} + + + + + diff --git a/scala/fory-json-scala/src/main/scala-3/org/apache/fory/json/scala/internal/DerivedScalaJsonCodec.scala b/scala/fory-json-scala/src/main/scala-3/org/apache/fory/json/scala/internal/DerivedScalaJsonCodec.scala index 3a5ba3a8db..ad4881875c 100644 --- a/scala/fory-json-scala/src/main/scala-3/org/apache/fory/json/scala/internal/DerivedScalaJsonCodec.scala +++ b/scala/fory-json-scala/src/main/scala-3/org/apache/fory/json/scala/internal/DerivedScalaJsonCodec.scala @@ -24,7 +24,7 @@ import java.util.{ArrayList, Collections, HashSet, List => JList} import org.apache.fory.json.ForyJsonException import org.apache.fory.json.annotation.JsonSubTypes.Inclusion -import org.apache.fory.json.codec.{ClosedSubtypeCodec, JsonSubTypesInfo, JsonValueCodec} +import org.apache.fory.json.codec.{ClosedSubtypeCodec, JsonSubTypesInfo, JsonValueCodec, ObjectCodec} import org.apache.fory.json.resolver.JsonTypeResolver import org.apache.fory.json.scala.ScalaJsonCodec import org.apache.fory.reflect.TypeRef @@ -34,7 +34,8 @@ private[scala] final class DerivedScalaJsonCodec[T]( caseClasses: Array[Class[_]], caseNames: Array[String], singletonCases: Array[AnyRef] -) extends ScalaJsonCodec[T] { +) extends ScalaJsonCodec[T] +{ if ( caseClasses.length == 0 || caseClasses.length != caseNames.length || caseClasses.length != singletonCases.length @@ -62,7 +63,7 @@ private[scala] final class DerivedScalaJsonCodec[T]( if (singleton != null && singleton.getClass != caseClass) throw new IllegalArgumentException(s"Invalid derived Scala enum singleton $name") if (classSet.add(caseClass)) result.add(caseClass) - else if (singleton == null) + else throw new IllegalArgumentException(s"Duplicate derived Scala enum case ${caseClass.getName}") index += 1 } @@ -87,11 +88,21 @@ private[scala] final class DerivedScalaJsonCodec[T]( override def create(typeRef: TypeRef[_], resolver: JsonTypeResolver): JsonValueCodec[_] = { if (typeRef.getRawType != rootType) throw new ForyJsonException(s"Derived Scala enum codec expected ${rootType.getName}") + val childCodecs = new Array[ObjectCodec[_]](classes.length) + var index = 0 + while (index < classes.length) { + val childType = typeRef.getSubtype(classes(index)) + val singleton = singletons(index) + childCodecs(index) = + if (singleton == null) ScalaObjectModels.caseClassCodec(childType, resolver) + else ScalaObjectModels.fixedCodec(childType, resolver, singleton) + index += 1 + } new ClosedSubtypeCodec( rootType, new JsonSubTypesInfo(Inclusion.WRAPPER_OBJECT, "", classes.clone(), names.clone()), typeRef, - singletons.clone() + childCodecs ) } diff --git a/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaObjectModels.scala b/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaObjectModels.scala index 56ae2265ad..71cb62dbdb 100644 --- a/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaObjectModels.scala +++ b/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaObjectModels.scala @@ -22,10 +22,8 @@ package org.apache.fory.json.scala.internal import java.lang.reflect.{Constructor, Field, Method, Modifier} import org.apache.fory.json.ForyJsonException -import org.apache.fory.json.codec.{AbstractJsonValueCodec, JsonObjectModel, JsonValueCodec} -import org.apache.fory.json.reader.JsonReader +import org.apache.fory.json.codec.{JsonObjectModel, JsonValueCodec, ObjectCodec} import org.apache.fory.json.resolver.JsonTypeResolver -import org.apache.fory.json.writer.JsonWriter import org.apache.fory.reflect.TypeRef private[scala] object ScalaObjectModels { @@ -36,7 +34,7 @@ private[scala] object ScalaObjectModels { findPrimaryConstructor(typeClass) != null } - def caseClassCodec(typeRef: TypeRef[_], resolver: JsonTypeResolver): JsonValueCodec[_] = { + def caseClassCodec(typeRef: TypeRef[_], resolver: JsonTypeResolver): ObjectCodec[_] = { val typeClass = typeRef.getRawType val constructor = findPrimaryConstructor(typeClass) if (constructor == null) { @@ -76,15 +74,22 @@ private[scala] object ScalaObjectModels { propertyNames, propertyGetters, propertySetters - ) + ).map(typeRef.resolveType) + // Constructor properties and their accessors are one logical occurrence. In particular, + // @JsonEnumeration binds an erased Enumeration.Value parameter to the exact MODULE$ owner. + val logicalParameterTypes = propertyTypes.take(names.length) val defaults = constructorDefaults(typeClass, parameterTypes) resolver.createObjectCodec( typeRef, new JsonObjectModel( constructor, + null, names, accessors, defaults, + Array.fill(names.length)(-1), + Array.fill(names.length)(true), + logicalParameterTypes, propertyNames, propertyGetters, propertySetters, @@ -93,9 +98,71 @@ private[scala] object ScalaObjectModels { ) } - def singletonCodec(typeClass: Class[_]): JsonValueCodec[_] = { + def singletonCodec( + typeRef: TypeRef[_], + resolver: JsonTypeResolver + ): JsonValueCodec[_] = { + val typeClass = typeRef.getRawType val field = singletonField(typeClass) - if (field == null) null else new ScalaSingletonCodec(typeClass, field) + if (field == null) null + else fixedCodec(typeRef, resolver, field.get(null)) + } + + def fixedCodec( + typeRef: TypeRef[_], + resolver: JsonTypeResolver, + instance: AnyRef + ): ObjectCodec[_] = { + val typeClass = typeRef.getRawType + if (instance == null || instance.getClass != typeClass) + throw ScalaTypeSupport.unsupported(typeRef, "singleton instance has a different runtime class") + val nonPropertyFields = singletonNonPropertyFields(typeClass) + val fields = singletonStateFields(typeClass, nonPropertyFields) + val getters = fields.map(field => propertyGetter(typeClass, field.getName, field.getType)) + val propertyTypes = fields.indices.map { index => + val getter = getters(index) + typeRef.resolveType(if (getter == null) fields(index).getGenericType else getter.getGenericReturnType) + }.toArray + resolver.createObjectCodec( + typeRef, + JsonObjectModel.fixedInstance( + instance, + fields.map(_.getName), + getters, + Array.fill[Method](fields.length)(null), + propertyTypes, + nonPropertyFields + ) + ) + } + + private def singletonStateFields( + typeClass: Class[_], + nonPropertyFields: Array[Field] + ): Array[Field] = { + val moduleClass = typeClass.getName.endsWith("$") + typeClass.getDeclaredFields.filter { field => + val name = field.getName + val modifiers = field.getModifiers + if (moduleClass) { + name != "MODULE$" && !field.isSynthetic && + (!Modifier.isStatic(modifiers) || !Modifier.isFinal(modifiers) || + propertyGetter(typeClass, name, field.getType) != null) + } + else { + !Modifier.isStatic(modifiers) && !field.isSynthetic && !nonPropertyFields.contains(field) + } + } + } + + private def singletonNonPropertyFields(typeClass: Class[_]): Array[Field] = { + if (typeClass.getName.endsWith("$")) return Array.empty + typeClass.getDeclaredFields.filter { field => + val name = field.getName + val modifiers = field.getModifiers + !Modifier.isStatic(modifiers) && Modifier.isFinal(modifiers) && !field.isSynthetic && + (name.startsWith("_$ordinal$") || name.startsWith("$name$")) + } } private def productFields(typeClass: Class[_]): Array[Field] = { @@ -235,28 +302,3 @@ private[scala] object ScalaObjectModels { } catch { case _: NoSuchFieldException => null } } } - -private final class ScalaSingletonCodec(typeClass: Class[_], field: Field) - extends AbstractJsonValueCodec[AnyRef] { - private val singleton = field.get(null).asInstanceOf[AnyRef] - - override def write(writer: JsonWriter, value: AnyRef): Unit = { - if (value == null) writer.writeNull() - else if (value ne singleton) - throw new ForyJsonException(s"Expected singleton ${typeClass.getName}") - else { - writer.writeObjectStart() - writer.writeObjectEnd() - } - } - - override def read(reader: JsonReader): AnyRef = { - if (reader.tryReadNullToken()) return null - reader.enterDepth() - reader.expectNextToken('{') - if (!reader.consumeNextToken('}')) - throw new ForyJsonException(s"Scala singleton ${typeClass.getName} requires an empty object") - reader.exitDepth() - singleton - } -} diff --git a/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaTypeCodecFactory.scala b/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaTypeCodecFactory.scala index 487fb34fcc..f62ba5a223 100644 --- a/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaTypeCodecFactory.scala +++ b/scala/fory-json-scala/src/main/scala/org/apache/fory/json/scala/internal/ScalaTypeCodecFactory.scala @@ -99,8 +99,7 @@ private[scala] object ScalaTypeCodecFactory extends JsonCodecFactory { val enumRoot = ScalaEnumCodec.enumRoot(rawType) if ( enumFamily != null && enumRoot == null && - (enumFamily == rawType || - resolver.resolvingRuntimeType() && !resolver.resolvingSubtypeOf(enumFamily)) + (enumFamily == rawType || resolver.resolvingRuntimeType()) ) { val derivedCodec = ScalaDerivedCodec.find(enumFamily) if (derivedCodec != null) { @@ -110,13 +109,13 @@ private[scala] object ScalaTypeCodecFactory extends JsonCodecFactory { if (enumRoot != null && (enumRoot == rawType || resolver.resolvingRuntimeType())) { return ScalaEnumCodec.create(enumRoot, typeRef) } - if (enumFamily != null && !resolver.resolvingSubtypeOf(enumFamily)) { + if (enumFamily != null) { throw ScalaTypeSupport.unsupported( typeRef, "Scala enum with parameters requires an exact derived or custom codec" ) } - val singleton = ScalaObjectModels.singletonCodec(rawType) + val singleton = ScalaObjectModels.singletonCodec(typeRef, resolver) if (singleton != null) return singleton val valueClass = ScalaValueClassCodec.create(rawType) if (valueClass != null) return valueClass diff --git a/scala/fory-json-scala/src/test/scala-3/org/apache/fory/json/scala/ScalaJsonDerivationSuite.scala b/scala/fory-json-scala/src/test/scala-3/org/apache/fory/json/scala/ScalaJsonDerivationSuite.scala index 11855db33c..9f228d2e30 100644 --- a/scala/fory-json-scala/src/test/scala-3/org/apache/fory/json/scala/ScalaJsonDerivationSuite.scala +++ b/scala/fory-json-scala/src/test/scala-3/org/apache/fory/json/scala/ScalaJsonDerivationSuite.scala @@ -19,7 +19,13 @@ package org.apache.fory.json.scala -import org.apache.fory.json.ForyJsonException +import java.util.concurrent.atomic.AtomicBoolean + +import org.apache.fory.json.{ForyJsonException, JsonCodecFactory} +import org.apache.fory.json.codec.{AbstractJsonValueCodec, JsonValueCodec} +import org.apache.fory.json.reader.JsonReader +import org.apache.fory.json.resolver.JsonTypeResolver +import org.apache.fory.json.writer.JsonWriter import org.scalatest.funsuite.AnyFunSuite import org.apache.fory.reflect.TypeRef @@ -44,6 +50,32 @@ enum DisplayColor { override def toString: String = "display" } +final class PendingCodec extends AbstractJsonValueCodec[Result] { + override def write(writer: JsonWriter, value: Result): Unit = { + if (value != Result.Pending) + throw new ForyJsonException("Expected Result.Pending") + writer.writeString("pending") + } + + override def read(reader: JsonReader): Result = { + if (reader.readString() != "pending") + throw new ForyJsonException("Expected pending") + Result.Pending + } +} + +final class PendingFactory extends JsonCodecFactory { + private val first = new AtomicBoolean(true) + + override def factoryKey(): String = getClass.getName + + override def create(typeRef: TypeRef[_], resolver: JsonTypeResolver): JsonValueCodec[_] = { + if (first.getAndSet(false)) + throw new ForyJsonException("First child resolution fails") + new PendingCodec + } +} + class ScalaJsonDerivationSuite extends AnyFunSuite { test("EmptyTuple uses an empty JSON array") { val json = ForyJsonScala.builder().withCodegen(false).build() @@ -64,9 +96,40 @@ class ScalaJsonDerivationSuite extends AnyFunSuite { assertThrows[ForyJsonException]( json.fromJson("{\"value\":\"raw\"}", classOf[Result.Ok]) ) + val pendingClass = Result.Pending.getClass.asInstanceOf[Class[Result]] + assertThrows[ForyJsonException](json.fromJson("{}", pendingClass)) assert(json.fromJson(json.toJson(ok), classOf[Result]) == ok) assert(json.fromJson(json.toJson(error), classOf[Result]) == error) assert(json.fromJson(json.toJson(pending), classOf[Result]) == pending) + assert(json.fromJson("{\"Pending\":{}}", classOf[Result]) eq Result.Pending) + assertThrows[ForyJsonException]( + json.fromJson("{\"Pending\":{\"extra\":1}}", classOf[Result]) + ) + } + + test("derived child honors exact codec") { + val pendingClass = Result.Pending.getClass.asInstanceOf[Class[Result]] + val json = + ForyJsonScala.builder().registerCodec(pendingClass, new PendingCodec).withCodegen(false).build() + + assert(json.toJson(Result.Pending, classOf[Result]) == "{\"Pending\":\"pending\"}") + assert(json.fromJson("{\"Pending\":\"pending\"}", classOf[Result]) eq Result.Pending) + assert(json.toJson(Result.Pending, pendingClass) == "\"pending\"") + } + + test("derived child rollback") { + val pendingClass = Result.Pending.getClass.asInstanceOf[Class[Result]] + val json = ForyJsonScala + .builder() + .registerCodec(pendingClass, new PendingFactory) + .withCodegen(false) + .withConcurrencyLevel(1) + .build() + + assertThrows[ForyJsonException]( + json.fromJson("{\"Pending\":\"pending\"}", classOf[Result]) + ) + assert(json.fromJson("{\"Pending\":\"pending\"}", classOf[Result]) eq Result.Pending) } test("third-party enum uses builder registration") { diff --git a/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonSuite.scala b/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonSuite.scala index f2065107e1..95b7ce0ab9 100644 --- a/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonSuite.scala +++ b/scala/fory-json-scala/src/test/scala/org/apache/fory/json/scala/ScalaJsonSuite.scala @@ -66,6 +66,12 @@ case class ExplicitNullable( @JsonProperty(include = JsonProperty.Include.ALWAYS) value: String ) +object StableToken + +object StatefulToken { + val value: Int = 1 +} + object Weekday extends Enumeration { val Monday, Tuesday = Value } @@ -328,6 +334,22 @@ class ScalaJsonSuite extends AnyFunSuite { assert(json.fromJson("{\"value\":null}", classOf[UnitValue]) == UnitValue(())) } + test("standalone object uses strict fixed object codec") { + for (json <- Seq( + ForyJsonScala.builder().withCodegen(false).build(), + ForyJsonScala.builder().withAsyncCompilation(false).build() + )) { + assert(json.toJson(StableToken) == "{}") + assert(json.fromJson("{}", StableToken.getClass) eq StableToken) + assertThrows[ForyJsonException](json.fromJson("{\"extra\":1}", StableToken.getClass)) + } + } + + test("stateful object requires an exact codec") { + val json = ForyJsonScala.builder().withCodegen(false).build() + assertThrows[ForyJsonException](json.toJson(StatefulToken)) + } + test("Scala composite child codec annotations") { val value = CodecSlots( List("a", "b"),