You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@fory.apache.org by ch...@apache.org on 2026/07/27 10:36:13 UTC
(fory) branch main updated: feat(xlang): harden external type support (#3890)
This is an automated email from the ASF dual-hosted git repository.
chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git
The following commit(s) were added to refs/heads/main by this push:
new ad37f0d72 feat(xlang): harden external type support (#3890)
ad37f0d72 is described below
commit ad37f0d72eed073d11cb8804548c69f84a570832
Author: Shawn Yang <sh...@gmail.com>
AuthorDate: Mon Jul 27 18:36:08 2026 +0800
feat(xlang): harden external type support (#3890)
## Why?
## What does this PR do?
## Related issues
## AI Contribution Checklist
- [ ] Substantial AI assistance was used in this PR: `yes` / `no`
- [ ] If `yes`, I included a completed [AI Contribution
Checklist](https://github.com/apache/fory/blob/main/AI_POLICY.md#9-contributor-checklist-for-ai-assisted-prs)
in this PR description and the required `AI Usage Disclosure`.
- [ ] If `yes`, my PR description includes the required `ai_review`
summary and screenshot evidence or equivalent persisted links of the
final clean AI review results from both fresh reviewers described in
`AI_POLICY.md`, the Fory-guided reviewer and the independent general
reviewer, on the current PR diff or current HEAD after the latest code
changes.
## Does this PR introduce any user-facing change?
- [ ] Does this PR introduce any public API change?
- [ ] Does this PR introduce any binary protocol compatibility change?
## Benchmark
---
.agents/languages/csharp.md | 5 +
.agents/languages/dart.md | 5 +
.agents/languages/swift.md | 5 +
benchmarks/csharp/ExternalEquivalenceBenchmark.cs | 25 +-
benchmarks/csharp/ExternalEquivalenceProgram.cs | 9 +-
benchmarks/csharp/README.md | 51 ++-
benchmarks/csharp/external_equivalence_report.py | 285 +++++++++----
benchmarks/csharp/run.sh | 134 +++++-
csharp/README.md | 1 +
.../Fory.Generator/AnalyzerReleases.Unshipped.md | 1 +
csharp/src/Fory.Generator/ForyModelGenerator.cs | 456 ++++++++++++++++++++-
csharp/src/Fory/Attributes.cs | 8 +
csharp/tests/Fory.ExternalTypes/ExternalModels.cs | 39 +-
csharp/tests/Fory.Tests/ExternalTypeSerializers.cs | 10 +
csharp/tests/Fory.Tests/ForyGeneratorTests.cs | 196 +++++++++
csharp/tests/Fory.Tests/GraphMemoryBudgetTests.cs | 60 +++
dart/packages/fory/CHANGELOG.md | 3 +
dart/packages/fory/README.md | 8 +-
.../fory/lib/src/annotation/fory_field.dart | 22 +-
.../fory/lib/src/codegen/fory_generator.dart | 94 ++++-
.../fory/test/external_type_generator_test.dart | 56 +++
.../fory/test/graph_memory_budget_test.dart | 42 ++
.../fory/test/runtime_validation_test.dart | 62 +--
.../fory/test/type_spec_annotation_test.dart | 48 +--
docs/guide/csharp/external-types.md | 18 +-
docs/guide/csharp/supported-types.md | 3 +
docs/guide/dart/external-types.md | 8 +-
docs/guide/dart/schema-metadata.md | 11 +-
docs/guide/swift/external-types.md | 4 +
docs/security/deserialization.md | 21 +
docs/specification/xlang_implementation_guide.md | 49 ++-
.../org/apache/fory/xlang/CSharpXlangTest.java | 6 +
swift/Sources/Fory/MacroDeclarations.swift | 4 +-
swift/Sources/ForyMacro/ForyObjectMacro.swift | 152 +++++--
.../ForyMacro/ForyObjectMacroReadGeneration.swift | 52 ++-
.../Tests/ForyExternalModels/ExternalModels.swift | 1 +
.../ForyTests/ExternalTypeSerializationTests.swift | 67 +++
swift/Tests/ForyTests/MacroDiagnosticTests.swift | 173 ++++++++
38 files changed, 1887 insertions(+), 307 deletions(-)
diff --git a/.agents/languages/csharp.md b/.agents/languages/csharp.md
index 07969c975..7b090d751 100644
--- a/.agents/languages/csharp.md
+++ b/.agents/languages/csharp.md
@@ -42,6 +42,11 @@ Load this file when changing `csharp/` or C# xlang behavior.
converted, or inaccessible targets use a manual serializer. Derive
allocation, reference publication, default value, and graph-memory behavior
from the target class/struct kind, never the declaration kind.
+- External class graph-memory formulas count external declaration fields plus
+ public instance fields visible on the target and its base classes. An external
+ declaration field marked ignored is budget-only and must not enter wire
+ metadata or generated target access. Resolve this field set in the generator;
+ do not inspect target layout in deserialization hot paths.
- External children compose through the concrete carrier serializers already
owned by `TypeResolver`: nullable structs, one-dimensional arrays, List,
LinkedList, Queue, Stack, HashSet, SortedSet, ImmutableHashSet, Dictionary,
diff --git a/.agents/languages/dart.md b/.agents/languages/dart.md
index fdb8af4b8..814b64aad 100644
--- a/.agents/languages/dart.md
+++ b/.agents/languages/dart.md
@@ -23,6 +23,11 @@ Load this file when changing `dart/`.
statically provable reference-tracked path back to the target, including
paths through supported list, set, and map type arguments. Do not simulate
early publication with placeholders or final-field patching.
+- External object graph-memory formulas count all declaration fields plus
+ concrete public instance fields visible on the target, superclass, and mixin
+ storage paths. Ignored declaration fields are budget-only and must not enter
+ target access, construction, metadata, or wire code. Compute this union during
+ generation and emit one constant reservation.
- Keep root numeric wrapper defaults separate from generated field metadata. Root wrapper resolution belongs in the builtin resolver, while annotations and generated metadata choose fixed, tagged, or declared-field encodings.
- Dart 64-bit carriers are optimized for each platform. Do not replace native extension-type wrappers with allocation-heavy classes or route web/native hot paths through `BigInt` unless the user approves a representation change.
- In `Buffer`, cursor, serializer, and generated-code hot paths, prefer direct byte/local integer operations and conditional import/export files over callbacks, records, holder objects, wrapper round-trips, or runtime platform branches.
diff --git a/.agents/languages/swift.md b/.agents/languages/swift.md
index c4cf4030b..74c92b6de 100644
--- a/.agents/languages/swift.md
+++ b/.agents/languages/swift.md
@@ -31,6 +31,11 @@ Load this file when changing `swift/` or Swift xlang behavior.
- External structural serializers extend `@ForyStruct`, `@ForyEnum`, and `@ForyUnion` through
`target:`. Because those declarations are separate from their targets, static nodes select them
with `with`, and registration uses the existing serializer registration API.
+- Swift macros cannot inspect an external target's stored layout. External class
+ graph-memory formulas therefore count only external declaration fields;
+ ignored declaration fields are budget-only and must not enter target access,
+ construction, metadata, or wire code. Omitted large value storage must be
+ declared explicitly and ignored.
- Direct `Any` and `AnyObject` root overloads remain disfavored forwarding facades over
`DynamicSerializer<Any>` and `DynamicSerializer<AnyObject>`, including their Data-buffer forms.
Arbitrary protocol roots explicitly select `DynamicSerializer<T>`. Do not add an unconstrained
diff --git a/benchmarks/csharp/ExternalEquivalenceBenchmark.cs b/benchmarks/csharp/ExternalEquivalenceBenchmark.cs
index c835b5a1e..c15c866d5 100644
--- a/benchmarks/csharp/ExternalEquivalenceBenchmark.cs
+++ b/benchmarks/csharp/ExternalEquivalenceBenchmark.cs
@@ -44,7 +44,7 @@ internal sealed record EquivalenceSideResult(
int SerializedSize,
string SerializedFrameBase64,
EquivalenceMeasurement Measurement,
- double? AllocatedBytesPerOperation);
+ double AllocatedBytesPerOperation);
internal sealed record EquivalenceSideOutput(
string GeneratedAtUtc,
@@ -138,16 +138,12 @@ internal static class ExternalEquivalenceBenchmark
benchmarkCase.Action,
options.DurationSeconds);
- double? bytesPerOperation = null;
- if (options.AllocationIterations > 0)
- {
- CollectGarbage();
- long allocatedBytes = MeasureAllocation(
- benchmarkCase.Action,
- options.AllocationIterations);
- bytesPerOperation =
- (double)allocatedBytes / options.AllocationIterations;
- }
+ CollectGarbage();
+ long allocatedBytes = MeasureAllocation(
+ benchmarkCase.Action,
+ options.AllocationIterations);
+ double bytesPerOperation =
+ (double)allocatedBytes / options.AllocationIterations;
return new EquivalenceSideResult(
benchmarkCase.DataType,
@@ -582,11 +578,8 @@ internal static class ExternalEquivalenceBenchmark
Console.WriteLine(
$"{result.DataType}/{result.Operation}: "
+ $"{result.Measurement.AverageNanoseconds:N1} ns/op");
- if (result.AllocatedBytesPerOperation.HasValue)
- {
- Console.WriteLine(
- $" allocated={result.AllocatedBytesPerOperation:N2} B/op");
- }
+ Console.WriteLine(
+ $" allocated={result.AllocatedBytesPerOperation:N2} B/op");
}
}
diff --git a/benchmarks/csharp/ExternalEquivalenceProgram.cs b/benchmarks/csharp/ExternalEquivalenceProgram.cs
index 3933395fc..743e21750 100644
--- a/benchmarks/csharp/ExternalEquivalenceProgram.cs
+++ b/benchmarks/csharp/ExternalEquivalenceProgram.cs
@@ -29,6 +29,8 @@ internal enum ExternalImplementation
internal sealed class ExternalBenchmarkOptions
{
+ public const int DefaultAllocationIterations = 100_000;
+
public HashSet<string> DataFilter { get; init; } = [];
public double WarmupSeconds { get; init; } = 1.0;
@@ -39,7 +41,7 @@ internal sealed class ExternalBenchmarkOptions
public ExternalImplementation Implementation { get; init; }
- public int AllocationIterations { get; init; }
+ public int AllocationIterations { get; init; } = DefaultAllocationIterations;
public bool ShowHelp { get; init; }
@@ -50,7 +52,7 @@ internal sealed class ExternalBenchmarkOptions
double durationSeconds = 3.0;
string outputPath = "external_equivalence_results.json";
ExternalImplementation implementation = ExternalImplementation.None;
- int allocationIterations = 0;
+ int allocationIterations = DefaultAllocationIterations;
bool showHelp = false;
for (int i = 0; i < args.Length; i++)
@@ -225,7 +227,8 @@ internal static class ExternalEquivalenceProgram
Console.WriteLine(" --duration <seconds>");
Console.WriteLine(" --output <path>");
Console.WriteLine(" --external-implementation <ordinary|external>");
- Console.WriteLine(" --allocation-iterations <count>");
+ Console.WriteLine(
+ $" --allocation-iterations <count> (default: {ExternalBenchmarkOptions.DefaultAllocationIterations})");
Console.WriteLine(" --help");
}
}
diff --git a/benchmarks/csharp/README.md b/benchmarks/csharp/README.md
index 63f17b5b9..0999a1379 100644
--- a/benchmarks/csharp/README.md
+++ b/benchmarks/csharp/README.md
@@ -38,6 +38,7 @@ Options:
--warmup <seconds>
--external-equivalence
--allocation-iterations <count>
+ --baseline-worktree <dir>
```
Examples:
@@ -55,9 +56,9 @@ Examples:
## External-Type Equivalence
-The opt-in external-type mode compares generated serializers for ordinary
-benchmark models with external structural serializers for equivalent models
-from a Fory-free project:
+The opt-in external-type mode compares the latest `apache/main` ordinary
+serializer, the current ordinary serializer, and the current external
+structural serializer for equivalent models from a Fory-free project:
```bash
./run.sh --external-equivalence --duration 10 --warmup 3
@@ -74,13 +75,22 @@ package that owns the external-type models and measurements. Building or
running the ordinary benchmark therefore does not compile external-type models
or their generated serializer instantiations.
-The runner builds the external-type benchmark once. For each selected lane, it
-launches two balanced adjacent pairs in fixed order: ordinary then external,
-followed by external then ordinary. Each of the four workers runs in a fresh
-process and constructs, registers, validates, warms, times, and optionally
-measures allocations for only its selected implementation. Keeping the
+The runner fetches `apache/main` and reuses the dedicated sibling worktree
+`../fory-benchmark-baseline` by default. Use `--baseline-worktree` to select
+another dedicated path. If the worktree already exists, the runner refuses
+tracked changes before switching it to the fetched commit. It never resets or
+cleans the worktree, so untracked files are preserved and a conflicting file
+causes the switch to fail safely.
+
+The runner builds the baseline and current external-type benchmark projects
+separately. For each selected lane, it launches two balanced adjacent triples
+in fixed order: `apache-main ordinary external`, followed by
+`external ordinary apache-main`. Each of the six workers runs in a fresh
+process and constructs, registers, validates, warms, times, and measures
+allocations for only its selected implementation. Each baseline/current and
+ordinary/external comparison is therefore adjacent in both orders. Keeping the
implementations in separate processes prevents shared reference-generic
-Dynamic PGO profiles from biasing the second implementation while retaining
+Dynamic PGO profiles from biasing a later implementation while retaining
normal .NET tiering.
Use `--data` to select one of these lanes:
@@ -93,7 +103,8 @@ Use `--data` to select one of these lanes:
- `map-field`
- `map-root`
-Add a fixed allocation pass with:
+Every worker runs a fixed allocation pass with 100,000 operations by default.
+To select another fixed count, use:
```bash
./run.sh --external-equivalence --allocation-iterations 100000
@@ -107,14 +118,17 @@ allocation counters start. Allocation results use
Results are written to
`build/external_equivalence_results.json`. Per-process results, including a
Base64 proof frame, are written under `build/external_equivalence_sides/` with
-the lane, one-based sample index, and implementation in each filename. After
-every worker finishes, the merger rejects missing, duplicate, reordered, or
-reused samples, incompatible runtime metadata, serialized size or byte
-differences, and allocation differences. It reports the median ordinary and
-external throughput and latency from the two samples per implementation. The
-combined JSON records the sample count and fixed pair orders. This opt-in mode
-does not invoke the default Python report generator or update the published
-36-case report.
+the lane, one-based sample index, and role in each filename. After every worker
+finishes, the merger rejects missing, duplicate, reordered, or reused samples,
+incompatible runtime metadata, serialized size or byte differences, and
+unstable per-role allocation samples. It fails if current ordinary latency is
+more than 1% above `apache/main`, current external latency is more than 1% above
+current ordinary, current external latency is more than 1% above `apache/main`,
+current ordinary allocates more than `apache/main`, or current external
+allocation differs from current ordinary. The complete JSON, including commit
+provenance and every violation, is written before a regression returns a
+nonzero exit code. This opt-in mode does not invoke the default Python report
+generator or update the published 36-case report.
The benchmark executable requires exactly one implementation for direct worker
use:
@@ -126,6 +140,7 @@ dotnet run -c Release --no-build \
--data class-root \
--duration 10 \
--warmup 3 \
+ --allocation-iterations 100000 \
--output build/external_equivalence_sides/class-root-direct-ordinary.json
```
diff --git a/benchmarks/csharp/external_equivalence_report.py b/benchmarks/csharp/external_equivalence_report.py
index 95a9944d4..07864c5fc 100755
--- a/benchmarks/csharp/external_equivalence_report.py
+++ b/benchmarks/csharp/external_equivalence_report.py
@@ -21,6 +21,7 @@ import base64
import binascii
import json
import math
+import re
import sys
from datetime import datetime, timezone
from pathlib import Path
@@ -36,11 +37,17 @@ LANES = (
"map-root",
)
OPERATIONS = ("serialize", "deserialize")
-IMPLEMENTATIONS = ("ordinary", "external")
+ROLES = ("apache-main", "ordinary", "external")
+WORKER_IMPLEMENTATIONS = {
+ "apache-main": "ordinary",
+ "ordinary": "ordinary",
+ "external": "external",
+}
SAMPLE_ORDERS = (
- ("ordinary", "external"),
- ("external", "ordinary"),
+ ("apache-main", "ordinary", "external"),
+ ("external", "ordinary", "apache-main"),
)
+MAX_SLOWDOWN_PERCENT = 1.0
TEXT_METADATA = (
"RuntimeVersion",
"OsDescription",
@@ -68,14 +75,37 @@ def parse_args(argv=None):
parser.add_argument(
"--sample",
action="append",
- nargs=5,
+ nargs=6,
required=True,
- metavar=("LANE", "INDEX", "ORDER", "FIRST_INPUT", "SECOND_INPUT"),
+ metavar=(
+ "LANE",
+ "INDEX",
+ "ORDER",
+ "FIRST_INPUT",
+ "SECOND_INPUT",
+ "THIRD_INPUT",
+ ),
help=(
- "Balanced sample metadata and worker JSON paths; repeat for each lane "
- "and sample."
+ "Balanced apache-main/current-ordinary/current-external sample metadata "
+ "and worker JSON paths; repeat for each lane and sample."
),
)
+ parser.add_argument(
+ "--apache-main-commit",
+ required=True,
+ help="Fetched apache/main commit used by baseline workers.",
+ )
+ parser.add_argument(
+ "--current-commit",
+ required=True,
+ help="Current worktree HEAD commit used by ordinary/external workers.",
+ )
+ parser.add_argument(
+ "--current-dirty",
+ required=True,
+ choices=("true", "false"),
+ help="Whether the current worktree had changes when workers were launched.",
+ )
parser.add_argument("--output", required=True, help="Combined JSON output path.")
return parser.parse_args(argv)
@@ -110,6 +140,13 @@ def require_number(value, owner: str, positive: bool):
return value
+def require_commit(value, owner: str) -> str:
+ commit = require_string(value, owner)
+ if re.fullmatch(r"[0-9a-f]{40}", commit) is None:
+ raise ValueError(f"{owner} must be a lowercase 40-character commit id")
+ return commit
+
+
def validate_metadata(document: dict, path: Path) -> dict:
owner = str(path)
require_string(document.get("GeneratedAtUtc"), f"{owner}.GeneratedAtUtc")
@@ -121,7 +158,7 @@ def validate_metadata(document: dict, path: Path) -> dict:
require_int(
document.get("AllocationIterations"),
f"{owner}.AllocationIterations",
- 0,
+ 1,
)
return {field: document[field] for field in METADATA_FIELDS}
@@ -164,7 +201,14 @@ def parse_sample_specs(raw_samples, expected_lanes):
input_owners = {}
for raw_sample in raw_samples:
- lane, raw_index, order_name, first_input, second_input = raw_sample
+ (
+ lane,
+ raw_index,
+ order_name,
+ first_input,
+ second_input,
+ third_input,
+ ) = raw_sample
if lane not in expected_lane_set:
raise ValueError(f"sample lane is not expected: {lane!r}")
try:
@@ -202,6 +246,12 @@ def parse_sample_specs(raw_samples, expected_lanes):
f"sample {lane}/{sample_index} second input",
)
),
+ Path(
+ require_string(
+ third_input,
+ f"sample {lane}/{sample_index} third input",
+ )
+ ),
)
for path in paths:
normalized = path.resolve()
@@ -271,23 +321,13 @@ def load_worker(path: Path, expected_lane: str, expected_implementation: str):
result.get("Measurement"), f"{owner}.Measurement"
)
allocated = result.get("AllocatedBytesPerOperation")
- if metadata["AllocationIterations"] == 0:
- if allocated is not None:
- raise ValueError(
- f"{owner}.AllocatedBytesPerOperation must be null when "
- "allocation measurement is disabled"
- )
- elif allocated is None:
- raise ValueError(
- f"{owner}.AllocatedBytesPerOperation is required when "
- "allocation measurement is enabled"
- )
- else:
- require_number(
- allocated,
- f"{owner}.AllocatedBytesPerOperation",
- False,
- )
+ if allocated is None:
+ raise ValueError(f"{owner}.AllocatedBytesPerOperation is required")
+ require_number(
+ allocated,
+ f"{owner}.AllocatedBytesPerOperation",
+ False,
+ )
results[operation] = {
"SerializedSize": serialized_size,
@@ -324,11 +364,11 @@ def load_samples(specs, expected_lanes):
raise ValueError(
f"internal sample order mismatch for {lane}/{sample_index}"
)
- for implementation, path in zip(expected_order, spec["Paths"]):
+ for role, path in zip(expected_order, spec["Paths"]):
current_metadata, worker_results = load_worker(
path,
lane,
- implementation,
+ WORKER_IMPLEMENTATIONS[role],
)
if metadata is None:
metadata = current_metadata
@@ -344,7 +384,7 @@ def load_samples(specs, expected_lanes):
)
for operation, result in worker_results.items():
- results[(implementation, lane, sample_index, operation)] = result
+ results[(role, lane, sample_index, operation)] = result
if metadata is None:
raise ValueError("no benchmark samples were loaded")
@@ -362,10 +402,30 @@ def median_summary(samples) -> dict:
}
-def merge_results(metadata: dict, results: dict, expected_lanes) -> dict:
+def stable_allocation(samples, owner: str):
+ allocations = {sample["AllocatedBytesPerOperation"] for sample in samples}
+ if len(allocations) != 1:
+ raise ValueError(f"allocation mismatch between samples for {owner}")
+ return next(iter(allocations))
+
+
+def slowdown_percent(candidate: dict, baseline: dict) -> float:
+ return (
+ candidate["AverageNanoseconds"] / baseline["AverageNanoseconds"] - 1.0
+ ) * 100.0
+
+
+def merge_results(
+ metadata: dict,
+ results: dict,
+ expected_lanes,
+ apache_main_commit: str,
+ current_commit: str,
+ current_dirty: bool,
+) -> dict:
expected_keys = {
- (implementation, lane, sample_index, operation)
- for implementation in IMPLEMENTATIONS
+ (role, lane, sample_index, operation)
+ for role in ROLES
for lane in expected_lanes
for sample_index in range(1, len(SAMPLE_ORDERS) + 1)
for operation in OPERATIONS
@@ -375,8 +435,8 @@ def merge_results(metadata: dict, results: dict, expected_lanes) -> dict:
raise ValueError(
"missing results: "
+ ", ".join(
- f"{implementation}/{lane}/{sample_index}/{operation}"
- for implementation, lane, sample_index, operation in sorted(missing)
+ f"{role}/{lane}/{sample_index}/{operation}"
+ for role, lane, sample_index, operation in sorted(missing)
)
)
unexpected = set(results) - expected_keys
@@ -384,28 +444,33 @@ def merge_results(metadata: dict, results: dict, expected_lanes) -> dict:
raise ValueError(
"unexpected results: "
+ ", ".join(
- f"{implementation}/{lane}/{sample_index}/{operation}"
- for implementation, lane, sample_index, operation in sorted(unexpected)
+ f"{role}/{lane}/{sample_index}/{operation}"
+ for role, lane, sample_index, operation in sorted(unexpected)
)
)
combined_results = []
+ violations = []
for lane in expected_lanes:
- baseline = results[("ordinary", lane, 1, "serialize")]
+ proof = results[("apache-main", lane, 1, "serialize")]
lane_samples = [
- results[(implementation, lane, sample_index, operation)]
- for implementation in IMPLEMENTATIONS
+ results[(role, lane, sample_index, operation)]
+ for role in ROLES
for sample_index in range(1, len(SAMPLE_ORDERS) + 1)
for operation in OPERATIONS
]
for sample in lane_samples:
if (
- sample["SerializedSize"] != baseline["SerializedSize"]
- or sample["Frame"] != baseline["Frame"]
+ sample["SerializedSize"] != proof["SerializedSize"]
+ or sample["Frame"] != proof["Frame"]
):
raise ValueError(f"serialized frame differs between samples for {lane}")
for operation in OPERATIONS:
+ apache_main_samples = [
+ results[("apache-main", lane, sample_index, operation)]
+ for sample_index in range(1, len(SAMPLE_ORDERS) + 1)
+ ]
ordinary_samples = [
results[("ordinary", lane, sample_index, operation)]
for sample_index in range(1, len(SAMPLE_ORDERS) + 1)
@@ -414,43 +479,103 @@ def merge_results(metadata: dict, results: dict, expected_lanes) -> dict:
results[("external", lane, sample_index, operation)]
for sample_index in range(1, len(SAMPLE_ORDERS) + 1)
]
- operation_samples = ordinary_samples + external_samples
- allocations = {
- sample["AllocatedBytesPerOperation"] for sample in operation_samples
- }
- if len(allocations) != 1:
- raise ValueError(
- f"allocation mismatch between samples for {lane}/{operation}"
- )
- allocated_bytes = operation_samples[0]["AllocatedBytesPerOperation"]
+ apache_main_allocation = stable_allocation(
+ apache_main_samples,
+ f"apache-main/{lane}/{operation}",
+ )
+ ordinary_allocation = stable_allocation(
+ ordinary_samples,
+ f"ordinary/{lane}/{operation}",
+ )
+ external_allocation = stable_allocation(
+ external_samples,
+ f"external/{lane}/{operation}",
+ )
+
+ apache_main_median = median_summary(apache_main_samples)
ordinary_median = median_summary(ordinary_samples)
external_median = median_summary(external_samples)
- slowdown = (
- external_median["AverageNanoseconds"]
- / ordinary_median["AverageNanoseconds"]
- - 1.0
- ) * 100.0
- allocation_enabled = metadata["AllocationIterations"] > 0
+ ordinary_slowdown = slowdown_percent(
+ ordinary_median,
+ apache_main_median,
+ )
+ external_slowdown = slowdown_percent(
+ external_median,
+ ordinary_median,
+ )
+ external_vs_apache_main_slowdown = slowdown_percent(
+ external_median,
+ apache_main_median,
+ )
+
+ if ordinary_slowdown > MAX_SLOWDOWN_PERCENT:
+ violations.append(
+ f"{lane}/{operation}: current ordinary is "
+ f"{ordinary_slowdown:+.2f}% slower than apache/main "
+ f"(limit: +{MAX_SLOWDOWN_PERCENT:.2f}%)"
+ )
+ if external_slowdown > MAX_SLOWDOWN_PERCENT:
+ violations.append(
+ f"{lane}/{operation}: current external is "
+ f"{external_slowdown:+.2f}% slower than current ordinary "
+ f"(limit: +{MAX_SLOWDOWN_PERCENT:.2f}%)"
+ )
+ if external_vs_apache_main_slowdown > MAX_SLOWDOWN_PERCENT:
+ violations.append(
+ f"{lane}/{operation}: current external is "
+ f"{external_vs_apache_main_slowdown:+.2f}% slower than "
+ f"apache/main (limit: +{MAX_SLOWDOWN_PERCENT:.2f}%)"
+ )
+ if ordinary_allocation > apache_main_allocation:
+ violations.append(
+ f"{lane}/{operation}: current ordinary allocates "
+ f"{ordinary_allocation:.2f} B/op, above apache/main "
+ f"{apache_main_allocation:.2f} B/op"
+ )
+ if external_allocation != ordinary_allocation:
+ violations.append(
+ f"{lane}/{operation}: current external allocation "
+ f"{external_allocation:.2f} B/op differs from current ordinary "
+ f"{ordinary_allocation:.2f} B/op"
+ )
+
combined_results.append(
{
"DataType": lane,
"Operation": operation,
- "SerializedSize": baseline["SerializedSize"],
- "OrdinaryMedian": ordinary_median,
- "ExternalMedian": external_median,
- "SlowdownPercent": slowdown,
- "OrdinaryAllocatedBytesPerOperation": allocated_bytes,
- "ExternalAllocatedBytesPerOperation": allocated_bytes,
- "AllocationEqual": True if allocation_enabled else None,
+ "SerializedSize": proof["SerializedSize"],
+ "ApacheMainMedian": apache_main_median,
+ "CurrentOrdinaryMedian": ordinary_median,
+ "CurrentExternalMedian": external_median,
+ "CurrentOrdinarySlowdownPercent": ordinary_slowdown,
+ "CurrentExternalSlowdownPercent": external_slowdown,
+ "ExternalVsApacheMainSlowdownPercent": (
+ external_vs_apache_main_slowdown
+ ),
+ "ApacheMainAllocatedBytesPerOperation": apache_main_allocation,
+ "CurrentOrdinaryAllocatedBytesPerOperation": ordinary_allocation,
+ "CurrentExternalAllocatedBytesPerOperation": external_allocation,
+ "CurrentOrdinaryAllocationWithinBaseline": (
+ ordinary_allocation <= apache_main_allocation
+ ),
+ "CurrentExternalAllocationEqual": (
+ external_allocation == ordinary_allocation
+ ),
}
)
return {
"GeneratedAtUtc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
+ "ApacheMainCommit": apache_main_commit,
+ "CurrentCommit": current_commit,
+ "CurrentDirty": current_dirty,
**metadata,
- "SamplesPerImplementation": len(SAMPLE_ORDERS),
+ "MaximumSlowdownPercent": MAX_SLOWDOWN_PERCENT,
+ "SamplesPerRole": len(SAMPLE_ORDERS),
"SampleOrders": [sample_order_name(order) for order in SAMPLE_ORDERS],
+ "Passed": not violations,
+ "Violations": violations,
"Results": combined_results,
}
@@ -458,16 +583,19 @@ def merge_results(metadata: dict, results: dict, expected_lanes) -> dict:
def print_summary(output: dict) -> None:
print("=== External-Type Equivalence Median Summary ===")
for result in output["Results"]:
- allocation = ""
- if result["AllocationEqual"] is not None:
- allocation = (
- f", allocated={result['OrdinaryAllocatedBytesPerOperation']:.2f} B/op"
- )
print(
f"{result['DataType']}/{result['Operation']}: "
- f"ordinary={result['OrdinaryMedian']['AverageNanoseconds']:.1f} ns/op, "
- f"external={result['ExternalMedian']['AverageNanoseconds']:.1f} ns/op, "
- f"slowdown={result['SlowdownPercent']:+.2f}%{allocation}"
+ f"apache-main={result['ApacheMainMedian']['AverageNanoseconds']:.1f} ns/op, "
+ f"ordinary={result['CurrentOrdinaryMedian']['AverageNanoseconds']:.1f} "
+ f"ns/op ({result['CurrentOrdinarySlowdownPercent']:+.2f}%), "
+ f"external={result['CurrentExternalMedian']['AverageNanoseconds']:.1f} "
+ f"ns/op (vs ordinary "
+ f"{result['CurrentExternalSlowdownPercent']:+.2f}%, vs apache-main "
+ f"{result['ExternalVsApacheMainSlowdownPercent']:+.2f}%), "
+ f"allocated="
+ f"{result['ApacheMainAllocatedBytesPerOperation']:.2f}/"
+ f"{result['CurrentOrdinaryAllocatedBytesPerOperation']:.2f}/"
+ f"{result['CurrentExternalAllocatedBytesPerOperation']:.2f} B/op"
)
@@ -479,13 +607,24 @@ def main(argv=None) -> int:
specs = parse_sample_specs(args.sample, lanes)
metadata, results = load_samples(specs, lanes)
- output = merge_results(metadata, results, lanes)
+ output = merge_results(
+ metadata,
+ results,
+ lanes,
+ require_commit(args.apache_main_commit, "--apache-main-commit"),
+ require_commit(args.current_commit, "--current-commit"),
+ args.current_dirty == "true",
+ )
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("w", encoding="utf-8") as destination:
json.dump(output, destination, indent=2)
destination.write("\n")
print_summary(output)
+ if not output["Passed"]:
+ for violation in output["Violations"]:
+ print(f"regression: {violation}", file=sys.stderr)
+ return 1
return 0
diff --git a/benchmarks/csharp/run.sh b/benchmarks/csharp/run.sh
index 4c3f47fc2..bc953d8fe 100755
--- a/benchmarks/csharp/run.sh
+++ b/benchmarks/csharp/run.sh
@@ -36,6 +36,7 @@ OUTPUT_DIR=""
COPY_DOCS=true
EXTERNAL_EQUIVALENCE=false
ALLOCATION_ITERATIONS=""
+BASELINE_WORKTREE=""
usage() {
cat <<USAGE
@@ -53,7 +54,8 @@ Options:
--output-dir <dir> Base directory for benchmark outputs
--no-copy-docs Skip copying report/plots into docs/benchmarks/csharp
--external-equivalence Run ordinary/external Fory equivalence cases
- --allocation-iterations <n> Measure allocations for each equivalence case
+ --allocation-iterations <n> Allocation operations per equivalence case (default: 100000)
+ --baseline-worktree <dir> Dedicated apache/main worktree for equivalence runs
--help Show this help
USAGE
exit 0
@@ -93,6 +95,10 @@ while [[ $# -gt 0 ]]; do
ALLOCATION_ITERATIONS="$2"
shift 2
;;
+ --baseline-worktree)
+ BASELINE_WORKTREE="$2"
+ shift 2
+ ;;
--help|-h)
usage
;;
@@ -113,6 +119,11 @@ if [[ "$EXTERNAL_EQUIVALENCE" == false && -n "$ALLOCATION_ITERATIONS" ]]; then
exit 1
fi
+if [[ "$EXTERNAL_EQUIVALENCE" == false && -n "$BASELINE_WORKTREE" ]]; then
+ echo -e "${RED}--baseline-worktree requires --external-equivalence.${NC}"
+ exit 1
+fi
+
if [[ "$EXTERNAL_EQUIVALENCE" == true && "${FORY_BENCH_SCHEMA_MISMATCH:-0}" == "1" ]]; then
echo -e "${RED}--external-equivalence does not support schema-mismatch mode.${NC}"
exit 1
@@ -150,6 +161,11 @@ if [[ "$EXTERNAL_EQUIVALENCE" == true ]]; then
RESULT_JSON="$BUILD_DIR/external_equivalence_results.json"
SIDE_DIR="$BUILD_DIR/external_equivalence_sides"
mkdir -p "$SIDE_DIR"
+ ALLOCATION_ITERATIONS="${ALLOCATION_ITERATIONS:-100000}"
+ if [[ ! "$ALLOCATION_ITERATIONS" =~ ^[1-9][0-9]*$ ]]; then
+ echo -e "${RED}--allocation-iterations must be a positive integer.${NC}"
+ exit 1
+ fi
if [[ -n "$DATA" ]]; then
EXTERNAL_LANES=("$DATA")
@@ -165,56 +181,138 @@ if [[ "$EXTERNAL_EQUIVALENCE" == true ]]; then
)
fi
- echo -e "${YELLOW}[1/4] Restoring dependencies...${NC}"
- dotnet restore ./Fory.ExternalTypeBenchmark.csproj >/dev/null
+ REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
+ if [[ -z "$BASELINE_WORKTREE" ]]; then
+ BASELINE_WORKTREE="$(dirname "$REPO_ROOT")/fory-benchmark-baseline"
+ fi
- echo -e "${YELLOW}[2/4] Building benchmark once...${NC}"
- dotnet build -c Release --no-restore ./Fory.ExternalTypeBenchmark.csproj
+ echo -e "${YELLOW}[1/5] Preparing fresh apache/main baseline...${NC}"
+ git -C "$REPO_ROOT" fetch apache main
+ APACHE_MAIN_COMMIT="$(
+ git -C "$REPO_ROOT" rev-parse --verify FETCH_HEAD^{commit}
+ )"
+
+ if [[ -e "$BASELINE_WORKTREE" ]]; then
+ BASELINE_REPO_ROOT="$(
+ git -C "$BASELINE_WORKTREE" rev-parse --show-toplevel 2>/dev/null
+ )" || {
+ echo -e "${RED}Baseline path is not a git worktree: $BASELINE_WORKTREE${NC}"
+ exit 1
+ }
+ CURRENT_COMMON_DIR="$(
+ git -C "$REPO_ROOT" rev-parse --path-format=absolute --git-common-dir
+ )"
+ BASELINE_COMMON_DIR="$(
+ git -C "$BASELINE_WORKTREE" \
+ rev-parse --path-format=absolute --git-common-dir
+ )"
+ if [[ "$BASELINE_COMMON_DIR" != "$CURRENT_COMMON_DIR" ]]; then
+ echo -e "${RED}Baseline path is not a worktree of the current repository.${NC}"
+ exit 1
+ fi
+ if [[ "$BASELINE_REPO_ROOT" == "$REPO_ROOT" ]]; then
+ echo -e "${RED}Baseline worktree must differ from the current worktree.${NC}"
+ exit 1
+ fi
+ if ! git -C "$BASELINE_WORKTREE" diff --quiet -- ||
+ ! git -C "$BASELINE_WORKTREE" diff --cached --quiet --; then
+ echo -e "${RED}Baseline worktree has tracked changes: $BASELINE_WORKTREE${NC}"
+ exit 1
+ fi
+ if ! git -C "$BASELINE_WORKTREE" switch \
+ --detach --no-overwrite-ignore "$APACHE_MAIN_COMMIT"; then
+ echo -e "${RED}Could not switch the baseline worktree safely; its files were not reset or cleaned.${NC}"
+ exit 1
+ fi
+ else
+ git -C "$REPO_ROOT" worktree add \
+ --detach "$BASELINE_WORKTREE" "$APACHE_MAIN_COMMIT"
+ fi
- echo -e "${YELLOW}[3/4] Running isolated adjacent pairs...${NC}"
- MERGE_ARGS=(--output "$RESULT_JSON")
+ BASELINE_COMMIT="$(git -C "$BASELINE_WORKTREE" rev-parse HEAD)"
+ if [[ "$BASELINE_COMMIT" != "$APACHE_MAIN_COMMIT" ]]; then
+ echo -e "${RED}Baseline worktree is not at the fetched apache/main commit.${NC}"
+ exit 1
+ fi
+
+ CURRENT_COMMIT="$(git -C "$REPO_ROOT" rev-parse HEAD)"
+ if [[ -n "$(git -C "$REPO_ROOT" status --porcelain --untracked-files=normal)" ]]; then
+ CURRENT_DIRTY=true
+ else
+ CURRENT_DIRTY=false
+ fi
+
+ CURRENT_PROJECT="$SCRIPT_DIR/Fory.ExternalTypeBenchmark.csproj"
+ BASELINE_PROJECT="$BASELINE_WORKTREE/benchmarks/csharp/Fory.ExternalTypeBenchmark.csproj"
+
+ echo -e "${YELLOW}[2/5] Restoring baseline and current dependencies...${NC}"
+ dotnet restore "$BASELINE_PROJECT" >/dev/null
+ dotnet restore "$CURRENT_PROJECT" >/dev/null
+
+ echo -e "${YELLOW}[3/5] Building baseline and current benchmarks...${NC}"
+ dotnet build -c Release --no-restore "$BASELINE_PROJECT"
+ dotnet build -c Release --no-restore "$CURRENT_PROJECT"
+
+ echo -e "${YELLOW}[4/5] Running isolated adjacent triples...${NC}"
+ MERGE_ARGS=(
+ --output "$RESULT_JSON"
+ --apache-main-commit "$APACHE_MAIN_COMMIT"
+ --current-commit "$CURRENT_COMMIT"
+ --current-dirty "$CURRENT_DIRTY"
+ )
for LANE in "${EXTERNAL_LANES[@]}"; do
MERGE_ARGS+=(--lane "$LANE")
WORKER_ARGS=(
--data "$LANE"
--duration "$DURATION"
--warmup "$WARMUP"
+ --allocation-iterations "$ALLOCATION_ITERATIONS"
)
- if [[ -n "$ALLOCATION_ITERATIONS" ]]; then
- WORKER_ARGS+=(--allocation-iterations "$ALLOCATION_ITERATIONS")
- fi
for SAMPLE_INDEX in 1 2; do
if [[ "$SAMPLE_INDEX" == 1 ]]; then
- PAIR_ORDER=(ordinary external)
+ SAMPLE_ORDER=(apache-main ordinary external)
else
- PAIR_ORDER=(external ordinary)
+ SAMPLE_ORDER=(external ordinary apache-main)
fi
SIDE_INPUTS=()
- for IMPLEMENTATION in "${PAIR_ORDER[@]}"; do
- SIDE_JSON="$SIDE_DIR/${LANE}-sample-${SAMPLE_INDEX}-${IMPLEMENTATION}.json"
+ for ROLE in "${SAMPLE_ORDER[@]}"; do
+ if [[ "$ROLE" == apache-main ]]; then
+ PROJECT="$BASELINE_PROJECT"
+ IMPLEMENTATION=ordinary
+ else
+ PROJECT="$CURRENT_PROJECT"
+ IMPLEMENTATION="$ROLE"
+ fi
+
+ SIDE_JSON="$SIDE_DIR/${LANE}-sample-${SAMPLE_INDEX}-${ROLE}.json"
SIDE_INPUTS+=("$SIDE_JSON")
- echo "Running $LANE sample $SAMPLE_INDEX ${PAIR_ORDER[*]}: $IMPLEMENTATION worker..."
+ echo "Running $LANE sample $SAMPLE_INDEX ${SAMPLE_ORDER[*]}: $ROLE worker..."
dotnet run -c Release --no-build \
- --project ./Fory.ExternalTypeBenchmark.csproj -- \
+ --project "$PROJECT" -- \
"${WORKER_ARGS[@]}" \
--external-implementation "$IMPLEMENTATION" \
--output "$SIDE_JSON"
done
+ SAMPLE_ORDER_NAME="$(
+ IFS=-
+ echo "${SAMPLE_ORDER[*]}"
+ )"
MERGE_ARGS+=(
--sample
"$LANE"
"$SAMPLE_INDEX"
- "${PAIR_ORDER[0]}-${PAIR_ORDER[1]}"
+ "$SAMPLE_ORDER_NAME"
"${SIDE_INPUTS[0]}"
"${SIDE_INPUTS[1]}"
+ "${SIDE_INPUTS[2]}"
)
done
done
- echo -e "${YELLOW}[4/4] Validating and merging results...${NC}"
+ echo -e "${YELLOW}[5/5] Validating and merging results...${NC}"
python3 external_equivalence_report.py "${MERGE_ARGS[@]}"
echo ""
echo -e "${GREEN}=== All done! ===${NC}"
diff --git a/csharp/README.md b/csharp/README.md
index a505ea727..af4d5b99d 100644
--- a/csharp/README.md
+++ b/csharp/README.md
@@ -199,6 +199,7 @@ internal abstract class UserSerializer
Fory fory = Fory.Builder().Build();
fory.Register<ThirdParty.User>(300);
+ThirdParty.User user = new() { Name = "Alice" };
byte[] payload = fory.Serialize(user);
ThirdParty.User decoded = fory.Deserialize<ThirdParty.User>(payload);
```
diff --git a/csharp/src/Fory.Generator/AnalyzerReleases.Unshipped.md b/csharp/src/Fory.Generator/AnalyzerReleases.Unshipped.md
index 9f5047f7b..42a1240c9 100644
--- a/csharp/src/Fory.Generator/AnalyzerReleases.Unshipped.md
+++ b/csharp/src/Fory.Generator/AnalyzerReleases.Unshipped.md
@@ -16,3 +16,4 @@ FORY011 | Fory | Error | Invalid external serializer member
FORY012 | Fory | Error | External serializer member type mismatch
FORY013 | Fory | Error | Unsupported extern alias
FORY014 | Fory | Error | Enum value is outside the supported range
+FORY015 | Fory | Error | Invalid ignored Fory field
diff --git a/csharp/src/Fory.Generator/ForyModelGenerator.cs b/csharp/src/Fory.Generator/ForyModelGenerator.cs
index 76ad404ec..98409897a 100644
--- a/csharp/src/Fory.Generator/ForyModelGenerator.cs
+++ b/csharp/src/Fory.Generator/ForyModelGenerator.cs
@@ -146,6 +146,14 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
+ private static readonly DiagnosticDescriptor InvalidIgnoredField = new(
+ id: "FORY015",
+ title: "Invalid ignored Fory field",
+ messageFormat: "Member '{0}' uses invalid [ForyField(Ignore = true)]: {1}",
+ category: "Fory",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
public void Initialize(IncrementalGeneratorInitializationContext context)
{
IncrementalValuesProvider<TypeModel?> typeModels = context.SyntaxProvider
@@ -206,10 +214,11 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
validModels.Add(model);
}
- Dictionary<string, TypeModel> models = new(StringComparer.Ordinal);
- foreach (IGrouping<string, TypeModel> targetGroup in validModels.GroupBy(
- model => model.TargetTypeName,
- StringComparer.Ordinal))
+ IEqualityComparer<ITypeSymbol> targetTypeComparer = RuntimeTypeComparer.Instance;
+ Dictionary<ITypeSymbol, TypeModel> models = new(targetTypeComparer);
+ foreach (IGrouping<ITypeSymbol, TypeModel> targetGroup in validModels.GroupBy(
+ model => model.TargetType,
+ targetTypeComparer))
{
if (targetGroup.Skip(1).Any())
{
@@ -225,7 +234,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
}
TypeModel targetModel = targetGroup.First();
- models.Add(targetModel.TargetTypeName, targetModel);
+ models.Add(targetModel.TargetType, targetModel);
}
if (models.Count == 0)
@@ -244,9 +253,10 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
sb.AppendLine("}");
sb.AppendLine();
- foreach (KeyValuePair<string, TypeModel> entry in models.OrderBy(kv => kv.Key, StringComparer.Ordinal))
+ foreach (TypeModel model in models.Values
+ .OrderBy(model => model.TargetTypeName, StringComparer.Ordinal)
+ .ThenBy(model => model.DeclarationName, StringComparer.Ordinal))
{
- TypeModel model = entry.Value;
if (model.Kind == DeclKind.Struct || model.Kind == DeclKind.Class)
{
EmitObjectSerializer(sb, model);
@@ -264,9 +274,10 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
sb.AppendLine(" [global::System.Runtime.CompilerServices.ModuleInitializer]");
sb.AppendLine(" internal static void Register()");
sb.AppendLine(" {");
- foreach (KeyValuePair<string, TypeModel> entry in models.OrderBy(kv => kv.Key, StringComparer.Ordinal))
+ foreach (TypeModel model in models.Values
+ .OrderBy(model => model.TargetTypeName, StringComparer.Ordinal)
+ .ThenBy(model => model.DeclarationName, StringComparer.Ordinal))
{
- TypeModel model = entry.Value;
if (model.Kind == DeclKind.Enum)
{
sb.AppendLine(
@@ -2104,9 +2115,9 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
private static string ModelGraphMemoryExpr(TypeModel model)
{
System.Collections.Generic.List<string> parts = new() { GraphObjectOwnerBytesExpr };
- foreach (MemberModel member in model.SortedMembers)
+ foreach (GraphFieldModel field in model.GraphFields)
{
- parts.Add(FieldGraphMemoryExpr(member));
+ parts.Add(field.MemoryExpression);
}
return string.Join(" + ", parts);
@@ -2114,6 +2125,11 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
private static string FieldGraphMemoryExpr(MemberModel member)
{
+ if (member.IsNullableValueType)
+ {
+ return $"global::System.Runtime.CompilerServices.Unsafe.SizeOf<{member.TypeName}>()";
+ }
+
if (member.Classification.IsPrimitive && member.Classification.PrimitiveSize > 0)
{
return $"{member.Classification.PrimitiveSize}";
@@ -2129,8 +2145,45 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
return $"{member.FixedValueBytes}";
}
- string typeName = StripNullableForTypeOf(member.TypeName);
- return $"global::System.Runtime.CompilerServices.Unsafe.SizeOf<{typeName}>()";
+ return $"global::System.Runtime.CompilerServices.Unsafe.SizeOf<{member.TypeName}>()";
+ }
+
+ private static string FieldGraphMemoryExpr(
+ ITypeSymbol fieldType,
+ IFieldSymbol? targetField = null)
+ {
+ if (targetField is { IsFixedSizeBuffer: true })
+ {
+ ITypeSymbol elementType = targetField.Type is IPointerTypeSymbol pointer
+ ? pointer.PointedAtType
+ : targetField.Type;
+ return $"({targetField.FixedSize} * {FieldGraphMemoryExpr(elementType)})";
+ }
+
+ if (fieldType.TypeKind is TypeKind.Pointer or TypeKind.FunctionPointer)
+ {
+ return "global::System.IntPtr.Size";
+ }
+
+ if (!fieldType.IsValueType)
+ {
+ return "4";
+ }
+
+ if (fieldType is INamedTypeSymbol nullableType &&
+ nullableType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)
+ {
+ return $"global::System.Runtime.CompilerServices.Unsafe.SizeOf<{fieldType.ToDisplayString(FullNameFormat)}>()";
+ }
+
+ TypeClassification classification = ClassifyType(fieldType);
+ int fixedValueBytes = FixedGraphValueBytes(fieldType, classification);
+ if (fixedValueBytes > 0)
+ {
+ return fixedValueBytes.ToString(CultureInfo.InvariantCulture);
+ }
+
+ return $"global::System.Runtime.CompilerServices.Unsafe.SizeOf<{fieldType.ToDisplayString(FullNameFormat)}>()";
}
private static bool IsConstGraphMemoryExpr(string expression)
@@ -2944,6 +2997,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
return new TypeModel(
declarationName,
declarationName,
+ typeSymbol,
serializerName,
DeclKind.Unknown,
evolving,
@@ -2985,6 +3039,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
return new TypeModel(
declarationName,
declarationName,
+ typeSymbol,
serializerName,
DeclKind.Unknown,
evolving,
@@ -3007,6 +3062,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
return new TypeModel(
declarationName,
declarationName,
+ typeSymbol,
serializerName,
DeclKind.Unknown,
evolving,
@@ -3052,6 +3108,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
_ => new TypeModel(
declarationName,
declarationName,
+ target,
serializerName,
DeclKind.Unknown,
evolving,
@@ -3075,11 +3132,37 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
bool evolving,
Location? declarationLocation)
{
+ ImmutableArray<Diagnostic> ignoredFieldDiagnostics = typeSymbol.GetMembers()
+ .Where(member =>
+ member is IFieldSymbol or IPropertySymbol &&
+ TryGetIgnoredField(member, out _))
+ .Select(member => Diagnostic.Create(
+ InvalidIgnoredField,
+ member.Locations.FirstOrDefault(location => location.IsInSource),
+ member.Name,
+ "Ignore is supported only by external ForyStruct serializer declarations"))
+ .ToImmutableArray();
+ if (!ignoredFieldDiagnostics.IsEmpty)
+ {
+ return new TypeModel(
+ declarationName,
+ declarationName,
+ typeSymbol,
+ serializerName,
+ DeclKind.Unknown,
+ evolving,
+ declarationLocation,
+ ImmutableArray<MemberModel>.Empty,
+ ImmutableArray<MemberModel>.Empty,
+ ignoredFieldDiagnostics);
+ }
+
if (HasGenericContext(typeSymbol))
{
return new TypeModel(
declarationName,
declarationName,
+ typeSymbol,
serializerName,
DeclKind.Unknown,
evolving,
@@ -3099,6 +3182,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
return new TypeModel(
declarationName,
declarationName,
+ typeSymbol,
serializerName,
DeclKind.Unknown,
evolving,
@@ -3117,6 +3201,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
return new TypeModel(
declarationName,
declarationName,
+ typeSymbol,
serializerName,
DeclKind.Enum,
evolving,
@@ -3133,6 +3218,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
return new TypeModel(
declarationName,
declarationName,
+ typeSymbol,
serializerName,
DeclKind.Unknown,
evolving,
@@ -3158,6 +3244,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
return new TypeModel(
declarationName,
declarationName,
+ typeSymbol,
serializerName,
DeclKind.Union,
evolving,
@@ -3179,6 +3266,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
return new TypeModel(
declarationName,
declarationName,
+ typeSymbol,
serializerName,
kind,
evolving,
@@ -3197,6 +3285,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
return new TypeModel(
declarationName,
declarationName,
+ typeSymbol,
serializerName,
kind,
evolving,
@@ -3272,6 +3361,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
return new TypeModel(
declarationName,
declarationName,
+ typeSymbol,
serializerName,
kind,
evolving,
@@ -3307,6 +3397,8 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
out DeclKind targetKind);
string targetTypeName = targetSymbol.ToDisplayString(FullNameFormat);
List<MemberModel> members = [];
+ List<GraphFieldModel> graphFields = [];
+ HashSet<ISymbol> countedTargetFields = new(SymbolEqualityComparer.Default);
if (validDeclaration && validTarget)
{
foreach (IPropertySymbol schemaProperty in declaration.GetMembers().OfType<IPropertySymbol>())
@@ -3316,12 +3408,60 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
continue;
}
+ if (TryGetIgnoredField(schemaProperty, out AttributeData? fieldAttribute))
+ {
+ if (IgnoredFieldHasWireOptions(fieldAttribute!))
+ {
+ diagnostics.Add(Diagnostic.Create(
+ InvalidIgnoredField,
+ schemaProperty.Locations.FirstOrDefault(location => location.IsInSource),
+ schemaProperty.Name,
+ "Id and Type cannot be combined with Ignore"));
+ continue;
+ }
+
+ Location? ignoredLocation = schemaProperty.Locations.FirstOrDefault(
+ location => location.IsInSource);
+ if (schemaProperty.Type.IsRefLikeType)
+ {
+ diagnostics.Add(Diagnostic.Create(
+ InvalidExternalMember,
+ ignoredLocation,
+ schemaProperty.Name,
+ targetTypeName,
+ "a ref-like type cannot represent target object storage"));
+ continue;
+ }
+
+ if (RequiresExternAlias(schemaProperty.Type, compilation))
+ {
+ diagnostics.Add(Diagnostic.Create(
+ UnsupportedExternalAlias,
+ ignoredLocation,
+ schemaProperty.Type.ToDisplayString(FullNameFormat)));
+ continue;
+ }
+
+ IFieldSymbol? ignoredTargetField = MatchingPublicTargetField(
+ target!,
+ schemaProperty);
+ graphFields.Add(new GraphFieldModel(
+ FieldGraphMemoryExpr(schemaProperty.Type, ignoredTargetField)));
+ if (ignoredTargetField is not null)
+ {
+ countedTargetFields.Add(ignoredTargetField);
+ }
+
+ continue;
+ }
+
if (!TryBindExternalMember(
compilation,
target!,
schemaProperty,
targetTypeName,
- diagnostics))
+ diagnostics,
+ out ISymbol? targetMember))
{
continue;
}
@@ -3335,6 +3475,13 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
if (member is not null)
{
members.Add(member);
+ IFieldSymbol? targetField = targetMember as IFieldSymbol;
+ graphFields.Add(new GraphFieldModel(
+ FieldGraphMemoryExpr(schemaProperty.Type, targetField)));
+ if (targetField is not null)
+ {
+ countedTargetFields.Add(targetField);
+ }
}
else if (diagnostics.Count == diagnosticCount)
{
@@ -3346,6 +3493,17 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
"the schema property type or descriptor is not supported"));
}
}
+
+ foreach (IFieldSymbol targetField in PublicInstanceFields(target!))
+ {
+ if (!countedTargetFields.Add(targetField))
+ {
+ continue;
+ }
+
+ graphFields.Add(new GraphFieldModel(
+ FieldGraphMemoryExpr(targetField.Type, targetField)));
+ }
}
ImmutableArray<MemberModel> ordered = members
@@ -3354,13 +3512,15 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
return new TypeModel(
declarationName,
targetTypeName,
+ targetSymbol,
serializerName,
targetKind,
evolving,
declarationLocation,
ordered,
SortMembers(ordered),
- diagnostics.ToImmutableArray());
+ diagnostics.ToImmutableArray(),
+ graphFields: graphFields.ToImmutableArray());
}
private static TypeModel BuildExternalEnumModel(
@@ -3392,6 +3552,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
return new TypeModel(
declarationName,
targetSymbol.ToDisplayString(FullNameFormat),
+ targetSymbol,
serializerName,
DeclKind.Enum,
true,
@@ -3692,8 +3853,10 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
INamedTypeSymbol target,
IPropertySymbol schemaProperty,
string targetTypeName,
- List<Diagnostic> diagnostics)
+ List<Diagnostic> diagnostics,
+ out ISymbol? targetMember)
{
+ targetMember = null;
Location? location = schemaProperty.Locations.FirstOrDefault(sourceLocation => sourceLocation.IsInSource);
if (schemaProperty.Type.TypeKind is TypeKind.Pointer or TypeKind.FunctionPointer ||
schemaProperty.Type.IsRefLikeType)
@@ -3707,7 +3870,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
return false;
}
- if (!TryFindTargetMember(target, schemaProperty.Name, out ISymbol? targetMember, out string reason))
+ if (!TryFindTargetMember(target, schemaProperty.Name, out targetMember, out string reason))
{
diagnostics.Add(Diagnostic.Create(
InvalidExternalMember,
@@ -3793,6 +3956,50 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
return true;
}
+ private static IEnumerable<IFieldSymbol> PublicInstanceFields(INamedTypeSymbol target)
+ {
+ for (INamedTypeSymbol? current = target; current is not null; current = current.BaseType)
+ {
+ foreach (IFieldSymbol field in current.GetMembers()
+ .OfType<IFieldSymbol>()
+ .Where(field =>
+ !field.IsImplicitlyDeclared &&
+ !field.IsStatic &&
+ !field.IsConst &&
+ field.DeclaredAccessibility == Accessibility.Public)
+ .OrderBy(field => field.MetadataName, StringComparer.Ordinal))
+ {
+ yield return field;
+ }
+ }
+ }
+
+ private static IFieldSymbol? MatchingPublicTargetField(
+ INamedTypeSymbol target,
+ IPropertySymbol schemaProperty)
+ {
+ if (!TryFindTargetMember(
+ target,
+ schemaProperty.Name,
+ out ISymbol? targetMember,
+ out _) ||
+ targetMember is not IFieldSymbol targetField)
+ {
+ return null;
+ }
+
+ if (targetField.IsImplicitlyDeclared ||
+ targetField.IsStatic ||
+ targetField.IsConst ||
+ targetField.DeclaredAccessibility != Accessibility.Public ||
+ !ExternalMemberTypesMatch(schemaProperty.Type, targetField.Type))
+ {
+ return null;
+ }
+
+ return targetField;
+ }
+
private static bool TryFindTargetMember(
INamedTypeSymbol target,
string name,
@@ -4456,6 +4663,48 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
return attributeKind;
}
+ private static bool TryGetIgnoredField(
+ ISymbol member,
+ out AttributeData? fieldAttribute)
+ {
+ fieldAttribute = null;
+ foreach (AttributeData attribute in member.GetAttributes())
+ {
+ if (!string.Equals(
+ attribute.AttributeClass?.ToDisplayString(),
+ "Apache.Fory.ForyFieldAttribute",
+ StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ fieldAttribute = attribute;
+ foreach (KeyValuePair<string, TypedConstant> namedArgument in attribute.NamedArguments)
+ {
+ if (string.Equals(namedArgument.Key, "Ignore", StringComparison.Ordinal) &&
+ namedArgument.Value.Value is true)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ return false;
+ }
+
+ private static bool IgnoredFieldHasWireOptions(AttributeData fieldAttribute)
+ {
+ if (!fieldAttribute.ConstructorArguments.IsEmpty)
+ {
+ return true;
+ }
+
+ return fieldAttribute.NamedArguments.Any(
+ argument => argument.Key is "Id" or "Type");
+ }
+
private static MemberModel? BuildMemberModel(
string name,
ITypeSymbol memberType,
@@ -5848,11 +6097,163 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
public ImmutableArray<FieldCodecModel> Generics { get; }
}
+ private sealed class RuntimeTypeComparer : IEqualityComparer<ITypeSymbol>
+ {
+ // CLR registration erases source-only distinctions such as tuple names,
+ // dynamic, and native-integer aliases; equality and hashing must erase
+ // the same distinctions before selecting one generated owner.
+ public static readonly RuntimeTypeComparer Instance = new();
+
+ public bool Equals(ITypeSymbol? left, ITypeSymbol? right)
+ {
+ if (ReferenceEquals(left, right))
+ {
+ return true;
+ }
+
+ if (left is null || right is null)
+ {
+ return false;
+ }
+
+ if (IsDynamicOrObject(left) || IsDynamicOrObject(right))
+ {
+ return IsDynamicOrObject(left) && IsDynamicOrObject(right);
+ }
+
+ if (left is IArrayTypeSymbol || right is IArrayTypeSymbol)
+ {
+ if (left is not IArrayTypeSymbol leftArray ||
+ right is not IArrayTypeSymbol rightArray)
+ {
+ return false;
+ }
+
+ return leftArray.Rank == rightArray.Rank &&
+ leftArray.IsSZArray == rightArray.IsSZArray &&
+ Equals(leftArray.ElementType, rightArray.ElementType);
+ }
+
+ if (left is INamedTypeSymbol leftNamed &&
+ right is INamedTypeSymbol rightNamed)
+ {
+ return NamedEquals(
+ NormalizeNamed(leftNamed),
+ NormalizeNamed(rightNamed));
+ }
+
+ return SymbolEqualityComparer.Default.Equals(left, right);
+ }
+
+ public int GetHashCode(ITypeSymbol type)
+ {
+ if (IsDynamicOrObject(type))
+ {
+ return (int)SpecialType.System_Object;
+ }
+
+ if (type is IArrayTypeSymbol array)
+ {
+ int hash = CombineHash(17, (int)TypeKind.Array);
+ hash = CombineHash(hash, array.Rank);
+ hash = CombineHash(hash, array.IsSZArray ? 1 : 0);
+ return CombineHash(hash, GetHashCode(array.ElementType));
+ }
+
+ if (type is INamedTypeSymbol named)
+ {
+ return NamedHash(NormalizeNamed(named));
+ }
+
+ return SymbolEqualityComparer.Default.GetHashCode(type);
+ }
+
+ private bool NamedEquals(
+ INamedTypeSymbol left,
+ INamedTypeSymbol right)
+ {
+ if (!SymbolEqualityComparer.Default.Equals(
+ left.OriginalDefinition,
+ right.OriginalDefinition) ||
+ left.TypeArguments.Length != right.TypeArguments.Length)
+ {
+ return false;
+ }
+
+ INamedTypeSymbol? leftContaining = left.ContainingType;
+ INamedTypeSymbol? rightContaining = right.ContainingType;
+ if ((leftContaining is null) != (rightContaining is null) ||
+ leftContaining is not null &&
+ !Equals(leftContaining, rightContaining))
+ {
+ return false;
+ }
+
+ for (int i = 0; i < left.TypeArguments.Length; i++)
+ {
+ if (!Equals(left.TypeArguments[i], right.TypeArguments[i]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private int NamedHash(INamedTypeSymbol type)
+ {
+ int hash = CombineHash(
+ 23,
+ SymbolEqualityComparer.Default.GetHashCode(type.OriginalDefinition));
+ if (type.ContainingType is not null)
+ {
+ hash = CombineHash(hash, GetHashCode(type.ContainingType));
+ }
+
+ foreach (ITypeSymbol typeArgument in type.TypeArguments)
+ {
+ hash = CombineHash(hash, GetHashCode(typeArgument));
+ }
+
+ return hash;
+ }
+
+ private static INamedTypeSymbol NormalizeNamed(INamedTypeSymbol type)
+ {
+ type = NormalizeTuple(type);
+
+ if (type.IsNativeIntegerType &&
+ type.NativeIntegerUnderlyingType is INamedTypeSymbol nativeUnderlying)
+ {
+ return nativeUnderlying;
+ }
+
+ return type;
+ }
+
+ private static INamedTypeSymbol NormalizeTuple(INamedTypeSymbol type)
+ {
+ return type.TupleUnderlyingType ?? type;
+ }
+
+ private static bool IsDynamicOrObject(ITypeSymbol type)
+ {
+ return type.TypeKind == TypeKind.Dynamic ||
+ type.SpecialType == SpecialType.System_Object;
+ }
+
+ private static int CombineHash(int current, int value)
+ {
+ return unchecked(current * 31 + value);
+ }
+ }
+
private sealed class TypeModel
{
public TypeModel(
string declarationName,
string targetTypeName,
+ ITypeSymbol targetType,
string serializerName,
DeclKind kind,
bool evolving,
@@ -5860,10 +6261,12 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
ImmutableArray<MemberModel> members,
ImmutableArray<MemberModel> sortedMembers,
ImmutableArray<Diagnostic> diagnostics,
- ImmutableArray<UnionCaseModel> unionCases = default)
+ ImmutableArray<UnionCaseModel> unionCases = default,
+ ImmutableArray<GraphFieldModel> graphFields = default)
{
DeclarationName = declarationName;
TargetTypeName = targetTypeName;
+ TargetType = targetType;
SerializerName = serializerName;
Kind = kind;
Evolving = evolving;
@@ -5874,10 +6277,16 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
UnionCases = unionCases.IsDefault
? ImmutableArray<UnionCaseModel>.Empty
: unionCases;
+ GraphFields = graphFields.IsDefault
+ ? sortedMembers
+ .Select(member => new GraphFieldModel(FieldGraphMemoryExpr(member)))
+ .ToImmutableArray()
+ : graphFields;
}
public string DeclarationName { get; }
public string TargetTypeName { get; }
+ public ITypeSymbol TargetType { get; }
public string SerializerName { get; }
public DeclKind Kind { get; }
public bool Evolving { get; }
@@ -5886,6 +6295,17 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
public ImmutableArray<MemberModel> SortedMembers { get; }
public ImmutableArray<Diagnostic> Diagnostics { get; }
public ImmutableArray<UnionCaseModel> UnionCases { get; }
+ public ImmutableArray<GraphFieldModel> GraphFields { get; }
+ }
+
+ private sealed class GraphFieldModel
+ {
+ public GraphFieldModel(string memoryExpression)
+ {
+ MemoryExpression = memoryExpression;
+ }
+
+ public string MemoryExpression { get; }
}
private sealed class MemberModel
diff --git a/csharp/src/Fory/Attributes.cs b/csharp/src/Fory/Attributes.cs
index 644721f4a..efa494aab 100644
--- a/csharp/src/Fory/Attributes.cs
+++ b/csharp/src/Fory/Attributes.cs
@@ -39,6 +39,8 @@ public sealed class ForyStructAttribute : Attribute
/// <summary>
/// Marks an enum as a generated Fory enum type, or declares an external enum serializer.
+/// Enum numeric values are the wire tags and must be in the range
+/// <c>0</c> through <see cref="uint.MaxValue"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Enum)]
public sealed class ForyEnumAttribute : Attribute
@@ -140,6 +142,12 @@ public sealed class ForyFieldAttribute : Attribute
/// </summary>
public Type? Type { get; set; }
+ /// <summary>
+ /// Gets or sets whether an external serializer declaration excludes this member from
+ /// the wire schema while retaining its storage in the graph-memory estimate.
+ /// </summary>
+ public bool Ignore { get; set; }
+
private static void ValidateId(short id)
{
if (id < 0)
diff --git a/csharp/tests/Fory.ExternalTypes/ExternalModels.cs b/csharp/tests/Fory.ExternalTypes/ExternalModels.cs
index 63e6f3977..7b06f507f 100644
--- a/csharp/tests/Fory.ExternalTypes/ExternalModels.cs
+++ b/csharp/tests/Fory.ExternalTypes/ExternalModels.cs
@@ -145,6 +145,41 @@ public sealed class ExternalEvolutionOff
public int Value { get; set; }
}
+public struct ExternalBudgetValue
+{
+ public long Left;
+
+ public long Right;
+}
+
+public class ExternalBudgetBase
+{
+ public ExternalBudgetValue BaseState;
+}
+
+public sealed class ExternalBudgetModel : ExternalBudgetBase
+{
+ public int Value;
+
+ public ExternalBudgetValue PublicState;
+
+ private readonly ExternalBudgetValue HiddenState;
+
+ public ExternalBudgetModel()
+ {
+ }
+
+ public ExternalBudgetModel(ExternalBudgetValue hiddenState)
+ {
+ HiddenState = hiddenState;
+ }
+
+ public ExternalBudgetValue ReadHiddenState()
+ {
+ return HiddenState;
+ }
+}
+
public sealed class XlangUser
{
public int Id { get; set; }
@@ -162,8 +197,8 @@ public struct XlangPoint
public enum XlangStatus : uint
{
Unknown = 0,
- Ready = 1,
- Done = 2,
+ Ready = 7,
+ Done = 23,
}
public sealed class XlangHolder
diff --git a/csharp/tests/Fory.Tests/ExternalTypeSerializers.cs b/csharp/tests/Fory.Tests/ExternalTypeSerializers.cs
index 37f1d689b..679aacb4a 100644
--- a/csharp/tests/Fory.Tests/ExternalTypeSerializers.cs
+++ b/csharp/tests/Fory.Tests/ExternalTypeSerializers.cs
@@ -144,6 +144,16 @@ internal abstract class ExternalEvolutionOffSerializer
public abstract int Value { get; }
}
+[ForyStruct(Target = typeof(ExternalBudgetModel))]
+internal abstract class ExternalBudgetModelSerializer
+{
+ [ForyField(1)]
+ public abstract int Value { get; }
+
+ [ForyField(Ignore = true)]
+ public abstract ExternalBudgetValue HiddenState { get; }
+}
+
[ForyStruct]
internal sealed class OrdinaryUser
{
diff --git a/csharp/tests/Fory.Tests/ForyGeneratorTests.cs b/csharp/tests/Fory.Tests/ForyGeneratorTests.cs
index 42338b87a..5248dc853 100644
--- a/csharp/tests/Fory.Tests/ForyGeneratorTests.cs
+++ b/csharp/tests/Fory.Tests/ForyGeneratorTests.cs
@@ -377,6 +377,50 @@ public sealed class ForyGeneratorTests
StringComparison.Ordinal);
}
+ [Fact]
+ public void ExternalIgnoredFieldsOnlyAffectGraphMemory()
+ {
+ const string source = """
+ using Apache.Fory;
+ namespace GeneratedDiagnostics;
+
+ public struct LargeValue
+ {
+ public long Left;
+ public long Right;
+ }
+
+ public sealed class ExternalTarget
+ {
+ public int Value;
+ public LargeValue PublicState;
+ private readonly LargeValue HiddenState;
+
+ public LargeValue ReadHiddenState() => HiddenState;
+ }
+
+ [ForyStruct(Target = typeof(ExternalTarget))]
+ internal abstract class ExternalTargetSerializer
+ {
+ [ForyField(1)]
+ public abstract int Value { get; }
+
+ [ForyField(Ignore = true)]
+ public abstract LargeValue HiddenState { get; }
+ }
+ """;
+
+ string generated = GenerateSource(source);
+ const string largeValueSize =
+ "global::System.Runtime.CompilerServices.Unsafe.SizeOf<global::GeneratedDiagnostics.LargeValue>()";
+
+ Assert.Equal(
+ 2,
+ generated.Split(largeValueSize, StringSplitOptions.None).Length - 1);
+ Assert.Contains("value.Value", generated, StringComparison.Ordinal);
+ Assert.DoesNotContain("HiddenState", generated, StringComparison.Ordinal);
+ }
+
public static TheoryData<string, string> ExternalDiagnosticCases => new()
{
{
@@ -694,6 +738,33 @@ public sealed class ForyGeneratorTests
""",
"FORY014"
},
+ {
+ """
+ using Apache.Fory;
+ namespace GeneratedDiagnostics;
+ [ForyStruct]
+ public sealed class InvalidTarget
+ {
+ [ForyField(Ignore = true)]
+ public int Value { get; set; }
+ }
+ """,
+ "FORY015"
+ },
+ {
+ """
+ using Apache.Fory;
+ namespace GeneratedDiagnostics;
+ public sealed class InvalidTarget { public int Value; }
+ [ForyStruct(Target = typeof(InvalidTarget))]
+ internal abstract class InvalidSerializer
+ {
+ [ForyField(1, Ignore = true)]
+ public abstract int Value { get; }
+ }
+ """,
+ "FORY015"
+ },
{
"""
using Apache.Fory;
@@ -774,6 +845,131 @@ public sealed class ForyGeneratorTests
diagnostic => diagnostic.Id == "FORY013");
}
+ [Fact]
+ public void DynamicAndObjectTargetsAreDuplicate()
+ {
+ const string source = """
+ using Apache.Fory;
+ namespace GeneratedDiagnostics;
+
+ [ForyStruct(Target = typeof(Fory.ExternalTypes.ExternalBox<dynamic>))]
+ internal abstract class DynamicBoxSerializer
+ {
+ }
+
+ [ForyStruct(Target = typeof(Fory.ExternalTypes.ExternalBox<object>))]
+ internal abstract class ObjectBoxSerializer
+ {
+ }
+ """;
+ CSharpCompilation compilation = CreateCompilation(source);
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(new ForyModelGenerator());
+ driver = driver.RunGeneratorsAndUpdateCompilation(
+ compilation,
+ out _,
+ out _);
+
+ Assert.Equal(
+ 2,
+ driver.GetRunResult().Diagnostics
+ .Count(diagnostic => diagnostic.Id == "FORY010"));
+ }
+
+ [Fact]
+ public void TupleNamesShareTargetIdentity()
+ {
+ const string source = """
+ using Apache.Fory;
+ namespace GeneratedDiagnostics;
+
+ [ForyStruct(Target = typeof(
+ Fory.ExternalTypes.ExternalBox<(int Left, string Name)>))]
+ internal abstract class NamedTupleBoxSerializer
+ {
+ }
+
+ [ForyStruct(Target = typeof(
+ Fory.ExternalTypes.ExternalBox<(int X, string Y)>))]
+ internal abstract class OtherTupleBoxSerializer
+ {
+ }
+ """;
+ CSharpCompilation compilation = CreateCompilation(source);
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(new ForyModelGenerator());
+ driver = driver.RunGeneratorsAndUpdateCompilation(
+ compilation,
+ out _,
+ out _);
+
+ Assert.Equal(
+ 2,
+ driver.GetRunResult().Diagnostics
+ .Count(diagnostic => diagnostic.Id == "FORY010"));
+ }
+
+ [Fact]
+ public void NativeIntsShareTargetIdentity()
+ {
+ const string source = """
+ using Apache.Fory;
+ namespace GeneratedDiagnostics;
+
+ [ForyStruct(Target = typeof(Fory.ExternalTypes.ExternalBox<nint>))]
+ internal abstract class NativeIntBoxSerializer
+ {
+ }
+
+ [ForyStruct(Target = typeof(
+ Fory.ExternalTypes.ExternalBox<System.IntPtr>))]
+ internal abstract class IntPtrBoxSerializer
+ {
+ }
+ """;
+ CSharpCompilation compilation = CreateCompilation(source);
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(new ForyModelGenerator());
+ driver = driver.RunGeneratorsAndUpdateCompilation(
+ compilation,
+ out _,
+ out _);
+
+ Assert.Equal(
+ 2,
+ driver.GetRunResult().Diagnostics
+ .Count(diagnostic => diagnostic.Id == "FORY010"));
+ }
+
+ [Fact]
+ public void ArrayElementsShareTargetIdentity()
+ {
+ const string source = """
+ using Apache.Fory;
+ namespace GeneratedDiagnostics;
+
+ [ForyStruct(Target = typeof(
+ Fory.ExternalTypes.ExternalBox<dynamic[]>))]
+ internal abstract class DynamicArrayBoxSerializer
+ {
+ }
+
+ [ForyStruct(Target = typeof(
+ Fory.ExternalTypes.ExternalBox<object[]>))]
+ internal abstract class ObjectArrayBoxSerializer
+ {
+ }
+ """;
+ CSharpCompilation compilation = CreateCompilation(source);
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(new ForyModelGenerator());
+ driver = driver.RunGeneratorsAndUpdateCompilation(
+ compilation,
+ out _,
+ out _);
+
+ Assert.Equal(
+ 2,
+ driver.GetRunResult().Diagnostics
+ .Count(diagnostic => diagnostic.Id == "FORY010"));
+ }
+
[Fact]
public void InternalTargetWithIvtCompiles()
{
diff --git a/csharp/tests/Fory.Tests/GraphMemoryBudgetTests.cs b/csharp/tests/Fory.Tests/GraphMemoryBudgetTests.cs
index e2bcfd813..9659c00e1 100644
--- a/csharp/tests/Fory.Tests/GraphMemoryBudgetTests.cs
+++ b/csharp/tests/Fory.Tests/GraphMemoryBudgetTests.cs
@@ -70,6 +70,12 @@ public sealed class BudgetValueHolder
public BudgetValue Value { get; set; }
}
+[ForyStruct]
+public sealed class NullableValueHolder
+{
+ public int? Value { get; set; }
+}
+
[ForyStruct]
public sealed class BudgetValueCompatWriter
{
@@ -164,6 +170,7 @@ public sealed class GraphMemoryBudgetTests
.Register<GeneratedPackedListBudget>(1007)
.Register<GeneratedSchemaMapBudget>(1008)
.Register<BudgetValueHolder>(1009)
+ .Register<NullableValueHolder>(1015)
.Register<BudgetSelfNode>(1012);
}
@@ -254,6 +261,45 @@ public sealed class GraphMemoryBudgetTests
Assert.Equal(value, exact.Deserialize<List<ExternalPoint>>(bytes));
}
+ [Fact]
+ public void ExternalClassStorageBudget()
+ {
+ ExternalBudgetValue publicState = new() { Left = 1, Right = 2 };
+ ExternalBudgetValue hiddenState = new() { Left = 3, Right = 4 };
+ ExternalBudgetModel value = new(hiddenState)
+ {
+ Value = 7,
+ PublicState = publicState,
+ BaseState = new ExternalBudgetValue { Left = 5, Right = 6 },
+ };
+ ForyRuntime writer = ForyRuntime.Builder().Compatible(false).Build();
+ writer.Register<ExternalBudgetModel>(1014);
+ byte[] bytes = writer.Serialize(value);
+ long required =
+ ObjectOwnerBytes
+ + sizeof(int)
+ + 3L * Unsafe.SizeOf<ExternalBudgetValue>();
+
+ ForyRuntime tooSmall = ForyRuntime.Builder()
+ .Compatible(false)
+ .MaxGraphMemoryBytes(required - 1)
+ .Build();
+ tooSmall.Register<ExternalBudgetModel>(1014);
+ Assert.Throws<InvalidDataException>(
+ () => tooSmall.Deserialize<ExternalBudgetModel>(bytes));
+
+ ForyRuntime exact = ForyRuntime.Builder()
+ .Compatible(false)
+ .MaxGraphMemoryBytes(required)
+ .Build();
+ exact.Register<ExternalBudgetModel>(1014);
+ ExternalBudgetModel decoded = exact.Deserialize<ExternalBudgetModel>(bytes);
+ Assert.Equal(value.Value, decoded.Value);
+ Assert.Equal(default(ExternalBudgetValue), decoded.PublicState);
+ Assert.Equal(default(ExternalBudgetValue), decoded.BaseState);
+ Assert.Equal(default(ExternalBudgetValue), decoded.ReadHiddenState());
+ }
+
[Fact]
public void ReadOnlySequenceUsesSameBudget()
{
@@ -388,6 +434,20 @@ public sealed class GraphMemoryBudgetTests
Assert.Equal(holder.Value.Id, NewFory(BudgetValueHolderBytes).Deserialize<BudgetValueHolder>(holderBytes).Value.Id);
}
+ [Fact]
+ public void NullableValueStorageUsesFullWidth()
+ {
+ NullableValueHolder holder = new() { Value = 9 };
+ byte[] bytes = Serialize(holder);
+ long required = ObjectOwnerBytes + Unsafe.SizeOf<int?>();
+
+ Assert.Throws<InvalidDataException>(
+ () => NewFory(required - 1).Deserialize<NullableValueHolder>(bytes));
+ Assert.Equal(
+ holder.Value,
+ NewFory(required).Deserialize<NullableValueHolder>(bytes).Value);
+ }
+
[Fact]
public void GeneratedSchemaContainersAreCharged()
{
diff --git a/dart/packages/fory/CHANGELOG.md b/dart/packages/fory/CHANGELOG.md
index 37de8770c..eeb9729eb 100644
--- a/dart/packages/fory/CHANGELOG.md
+++ b/dart/packages/fory/CHANGELOG.md
@@ -3,6 +3,9 @@
- Start the next development cycle after the 1.4.0 release.
- Add generated external-type serialization with direct target construction
and existing registration, carrier, reference, and schema-evolution support.
+- Replace `ForyField.skip` with `ForyField.ignore`, remove `skip` from container
+ field annotations, and include ignored and discoverable external target
+ fields in graph-memory accounting.
## 1.4.0
diff --git a/dart/packages/fory/README.md b/dart/packages/fory/README.md
index 6162f1106..9b4206790 100644
--- a/dart/packages/fory/README.md
+++ b/dart/packages/fory/README.md
@@ -111,7 +111,11 @@ abstract final class UserSerializer {
}
```
-The declaration is schema-only. Register and serialize the target:
+The declaration is schema-only.
+Use `@ForyField(ignore: true)` for declaration-only storage that should count
+toward the graph-memory budget without being serialized.
+
+Register and serialize the target:
```dart
ExternalSerializersForyModule.register(
@@ -200,7 +204,7 @@ class NodeList {
| Option | Description |
| ---------- | ------------------------------------------------ |
-| `skip` | Skip the field during serialization |
+| `ignore` | Exclude the field from serialization |
| `id` | Stable field ID for compatible-mode evolution |
| `nullable` | Override nullability inference |
| `ref` | Enable reference tracking for this field |
diff --git a/dart/packages/fory/lib/src/annotation/fory_field.dart b/dart/packages/fory/lib/src/annotation/fory_field.dart
index c68e10842..6df0a0928 100644
--- a/dart/packages/fory/lib/src/annotation/fory_field.dart
+++ b/dart/packages/fory/lib/src/annotation/fory_field.dart
@@ -20,7 +20,7 @@
import 'package:fory/src/annotation/type_spec.dart';
final class ForyField {
- final bool skip;
+ final bool ignore;
final int? id;
final bool? nullable;
final bool ref;
@@ -28,17 +28,24 @@ final class ForyField {
final TypeSpec? type;
const ForyField({
- this.skip = false,
+ this.ignore = false,
this.id,
this.nullable,
this.ref = false,
this.dynamic,
this.type,
- });
+ }) : assert(
+ !ignore ||
+ (id == null &&
+ nullable == null &&
+ !ref &&
+ dynamic == null &&
+ type == null),
+ 'Ignored fields cannot define wire metadata.',
+ );
}
final class ListField {
- final bool skip;
final int? id;
final bool? nullable;
final bool ref;
@@ -46,7 +53,6 @@ final class ListField {
final TypeSpec? element;
const ListField({
- this.skip = false,
this.id,
this.nullable,
this.ref = false,
@@ -56,7 +62,6 @@ final class ListField {
}
final class ArrayField {
- final bool skip;
final int? id;
final bool? nullable;
final bool ref;
@@ -64,7 +69,6 @@ final class ArrayField {
final TypeSpec element;
const ArrayField({
- this.skip = false,
this.id,
this.nullable,
this.ref = false,
@@ -74,7 +78,6 @@ final class ArrayField {
}
final class SetField {
- final bool skip;
final int? id;
final bool? nullable;
final bool ref;
@@ -82,7 +85,6 @@ final class SetField {
final TypeSpec? element;
const SetField({
- this.skip = false,
this.id,
this.nullable,
this.ref = false,
@@ -92,7 +94,6 @@ final class SetField {
}
final class MapField {
- final bool skip;
final int? id;
final bool? nullable;
final bool ref;
@@ -101,7 +102,6 @@ final class MapField {
final TypeSpec? value;
const MapField({
- this.skip = false,
this.id,
this.nullable,
this.ref = false,
diff --git a/dart/packages/fory/lib/src/codegen/fory_generator.dart b/dart/packages/fory/lib/src/codegen/fory_generator.dart
index ee5f091d8..710c02b90 100644
--- a/dart/packages/fory/lib/src/codegen/fory_generator.dart
+++ b/dart/packages/fory/lib/src/codegen/fory_generator.dart
@@ -182,13 +182,16 @@ final class ForyGenerator extends Generator {
element: element,
);
}
+ final declarationFields = element.fields
+ .where(_isStoredInstanceField)
+ .toList(growable: false);
final targetType =
annotatedTarget == null
? element.thisType
: _validateExternalTarget(element, annotatedTarget);
if (annotatedTarget != null) {
- _validateExternalDeclaration(element, targetType);
+ _validateExternalDeclaration(element, targetType, declarationFields);
}
final targetTypeLiteral =
annotatedTarget == null
@@ -202,16 +205,17 @@ final class ForyGenerator extends Generator {
external: annotatedTarget != null,
);
- final fields = element.fields
- .where(
- (field) => !field.isStatic && identical(field.nonSynthetic, field),
- )
- .where((field) => !_isSkipped(field))
+ final fields = declarationFields
+ .where((field) => !_isIgnored(field))
.map(
(field) =>
_analyzeField(element, targetType, targetTypeLiteral, field),
)
.toList(growable: false);
+ final graphFieldCount =
+ annotatedTarget == null
+ ? declarationFields.length
+ : _externalGraphFieldCount(element, targetType, declarationFields);
final sortedFields = _sortFields(fields);
final constructionModel = _buildConstructionModel(
@@ -228,6 +232,7 @@ final class ForyGenerator extends Generator {
targetTypeLiteral: targetTypeLiteral,
evolving: evolving,
fields: sortedFields,
+ graphFieldCount: graphFieldCount,
constructionModel: constructionModel,
);
}
@@ -303,6 +308,7 @@ final class ForyGenerator extends Generator {
void _validateExternalDeclaration(
ClassElement declaration,
InterfaceType targetType,
+ List<FieldElement> declarationFields,
) {
final targetName = targetType.getDisplayString();
if (!declaration.isAbstract || !declaration.isFinal) {
@@ -321,12 +327,7 @@ final class ForyGenerator extends Generator {
element: declaration,
);
}
- for (final field in declaration.fields.where(
- (field) =>
- !field.isStatic &&
- identical(field.nonSynthetic, field) &&
- !_isSkipped(field),
- )) {
+ for (final field in declarationFields) {
if (!field.isLate || !field.isFinal || field.hasInitializer) {
throw InvalidGenerationSourceError(
'Schema field ${declaration.displayName}.${field.displayName} for '
@@ -1646,14 +1647,73 @@ final class ForyGenerator extends Generator {
}
int _graphObjectBytes(_GeneratedStructSpec structSpec) =>
- _structObjectOwnerBytes + structSpec.fields.length * _referenceBytes;
+ _structObjectOwnerBytes + structSpec.graphFieldCount * _referenceBytes;
+
+ int _externalGraphFieldCount(
+ ClassElement declaration,
+ InterfaceType targetType,
+ List<FieldElement> declarationFields,
+ ) {
+ final publicFields = <FieldElement>[];
+ // Analyzer keeps each class's direct mixins separate from its superclass,
+ // so this visits every applied mixin storage slot exactly once.
+ for (
+ InterfaceType? current = targetType;
+ current != null;
+ current = current.superclass
+ ) {
+ _addPublicStoredFields(publicFields, current);
+ for (final mixin in current.mixins) {
+ _addPublicStoredFields(publicFields, mixin);
+ }
+ }
+
+ final claimed = <int>{};
+ for (final declarationField in declarationFields) {
+ final getter = targetType.lookUpGetter(
+ declarationField.displayName,
+ declaration.library,
+ );
+ final variable = getter?.variable;
+ if (variable is! FieldElement || !_isPublicStoredField(variable)) {
+ continue;
+ }
+ for (var index = 0; index < publicFields.length; index += 1) {
+ if (!claimed.contains(index) &&
+ identical(publicFields[index].baseElement, variable.baseElement)) {
+ claimed.add(index);
+ break;
+ }
+ }
+ }
+ return publicFields.length + declarationFields.length - claimed.length;
+ }
+
+ void _addPublicStoredFields(List<FieldElement> fields, InterfaceType type) {
+ fields.addAll(type.element.fields.where(_isPublicStoredField));
+ }
+
+ bool _isStoredInstanceField(FieldElement field) {
+ final baseField = field.baseElement;
+ return !field.isStatic &&
+ !field.isAbstract &&
+ !field.isExternal &&
+ (identical(baseField.nonSynthetic, baseField) ||
+ baseField.nonSynthetic is FieldFormalParameterElement);
+ }
+
+ bool _isPublicStoredField(FieldElement field) =>
+ field.isPublic && _isStoredInstanceField(field);
- bool _isSkipped(FieldElement field) {
+ bool _isIgnored(FieldElement field) {
final annotation = _fieldAnnotationOf(field);
- if (annotation == null) {
+ final annotationType = annotation?.type;
+ if (annotation == null ||
+ annotationType == null ||
+ !_foryFieldChecker.isExactlyType(annotationType)) {
return false;
}
- return ConstantReader(annotation).peek('skip')?.boolValue ?? false;
+ return ConstantReader(annotation).peek('ignore')?.boolValue ?? false;
}
String _fieldInfoLiteral(_GeneratedFieldSpec field) {
@@ -4243,6 +4303,7 @@ final class _GeneratedStructSpec {
final String targetTypeLiteral;
final bool evolving;
final List<_GeneratedFieldSpec> fields;
+ final int graphFieldCount;
final _ConstructionModel constructionModel;
const _GeneratedStructSpec({
@@ -4251,6 +4312,7 @@ final class _GeneratedStructSpec {
required this.targetTypeLiteral,
required this.evolving,
required this.fields,
+ required this.graphFieldCount,
required this.constructionModel,
});
}
diff --git a/dart/packages/fory/test/external_type_generator_test.dart b/dart/packages/fory/test/external_type_generator_test.dart
index 8e749f803..e806428b2 100644
--- a/dart/packages/fory/test/external_type_generator_test.dart
+++ b/dart/packages/fory/test/external_type_generator_test.dart
@@ -310,6 +310,20 @@ abstract final class TargetSerializer {
);
});
+ test('validates ignored schema field shape', () async {
+ await _expectGenerationError(
+ targetSource: 'class Target {}',
+ declarationSource: '''
+@ForyStruct(target: Target)
+abstract final class TargetSerializer {
+ @ForyField(ignore: true)
+ late String hidden;
+}
+''',
+ message: 'must be a late final field without an initializer',
+ );
+ });
+
test('rejects duplicate target', () async {
await _expectGenerationError(
targetSource: '''
@@ -552,6 +566,48 @@ abstract final class TargetSerializer {
);
});
+ test('accounts for external target storage without serializing it', () async {
+ await _expectGenerationOutput(
+ targetSource: '''
+mixin TargetMixin {
+ final bool mixed = false;
+}
+
+class Base<T> {
+ Base(this.inherited);
+
+ final T inherited;
+}
+
+class Target extends Base<String> with TargetMixin {
+ Target(this.value, String inherited) : super(inherited);
+
+ final int value;
+ final String omitted = 'omitted';
+ final Object _hidden = Object();
+}
+''',
+ declarationSource: '''
+@ForyStruct(target: Target)
+abstract final class TargetSerializer {
+ late final int value;
+ late final String inherited;
+
+ @ForyField(ignore: true)
+ late final Object _hidden;
+}
+''',
+ output: allOf(
+ contains('context.reserveGraphMemory(44);'),
+ contains("name: 'value',"),
+ contains("name: 'inherited',"),
+ isNot(contains("name: 'omitted',")),
+ isNot(contains('_hidden')),
+ isNot(contains("name: 'mixed',")),
+ ),
+ );
+ });
+
test('renders nullable prefixed re-exported types', () async {
await _expectGenerationOutput(
targetSource: '''
diff --git a/dart/packages/fory/test/graph_memory_budget_test.dart b/dart/packages/fory/test/graph_memory_budget_test.dart
index eddc1279a..ee5218aac 100644
--- a/dart/packages/fory/test/graph_memory_budget_test.dart
+++ b/dart/packages/fory/test/graph_memory_budget_test.dart
@@ -55,6 +55,16 @@ class BudgetGeneratedEnvelope {
Map<String, int> counts = <String, int>{};
}
+@ForyStruct()
+class BudgetIgnoredEnvelope {
+ BudgetIgnoredEnvelope();
+
+ int value = 0;
+
+ @ForyField(ignore: true)
+ Object? ignored;
+}
+
@ForyStruct()
class BudgetSelfNode {
BudgetSelfNode();
@@ -114,6 +124,14 @@ void _registerGenerated(Fory fory) {
);
}
+void _registerIgnored(Fory fory) {
+ GraphMemoryBudgetTestForyModule.register(
+ fory,
+ BudgetIgnoredEnvelope,
+ name: 'test.BudgetIgnoredEnvelope',
+ );
+}
+
void _registerSelfNode(Fory fory) {
GraphMemoryBudgetTestForyModule.register(
fory,
@@ -241,6 +259,30 @@ void main() {
expect(identical(roundTrip, roundTrip.children.single), isTrue);
});
+ test('reserves ignored field storage', () {
+ final writer = Fory();
+ _registerIgnored(writer);
+ final bytes = writer.serialize(
+ BudgetIgnoredEnvelope()
+ ..value = 7
+ ..ignored = Object(),
+ );
+ final required = _objectGraphBytes(2);
+
+ final failingReader = Fory(maxGraphMemoryBytes: required - 1);
+ _registerIgnored(failingReader);
+ expect(
+ () => failingReader.deserialize<BudgetIgnoredEnvelope>(bytes),
+ _throwsGraphBudget,
+ );
+
+ final passingReader = Fory(maxGraphMemoryBytes: required);
+ _registerIgnored(passingReader);
+ final roundTrip = passingReader.deserialize<BudgetIgnoredEnvelope>(bytes);
+ expect(roundTrip.value, equals(7));
+ expect(roundTrip.ignored, isNull);
+ });
+
test('reserves map entries', () {
final value = <Object?, Object?>{'a': 1};
diff --git a/dart/packages/fory/test/runtime_validation_test.dart b/dart/packages/fory/test/runtime_validation_test.dart
index 5fa7c64a0..13a74ce2a 100644
--- a/dart/packages/fory/test/runtime_validation_test.dart
+++ b/dart/packages/fory/test/runtime_validation_test.dart
@@ -90,35 +90,35 @@ class ExplicitUnknownEnvelope {
}
@ForyStruct()
-class SkipEnvelope {
- SkipEnvelope();
+class IgnoreEnvelope {
+ IgnoreEnvelope();
String visible = '';
- @ForyField(skip: true)
- String skipped = 'local-default';
+ @ForyField(ignore: true)
+ String ignored = 'local-default';
}
@ForyStruct()
-class SkipCompatibleV1 {
- SkipCompatibleV1();
+class IgnoreCompatibleV1 {
+ IgnoreCompatibleV1();
@ForyField(id: 1)
String visible = '';
@ForyField(id: 2)
- String skipped = 'writer-default';
+ String ignored = 'writer-default';
}
@ForyStruct()
-class SkipCompatibleV2 {
- SkipCompatibleV2();
+class IgnoreCompatibleV2 {
+ IgnoreCompatibleV2();
@ForyField(id: 1)
String visible = '';
- @ForyField(id: 2, skip: true)
- String skipped = 'reader-default';
+ @ForyField(ignore: true)
+ String ignored = 'reader-default';
}
@ForyStruct()
@@ -170,24 +170,24 @@ void _registerValidationTypes(Fory fory) {
);
RuntimeValidationTestForyModule.register(
fory,
- SkipEnvelope,
- name: 'validation.SkipEnvelope',
+ IgnoreEnvelope,
+ name: 'validation.IgnoreEnvelope',
);
}
-void _registerSkipV1(Fory fory) {
+void _registerIgnoreV1(Fory fory) {
RuntimeValidationTestForyModule.register(
fory,
- SkipCompatibleV1,
- name: 'validation.SkipCompatible',
+ IgnoreCompatibleV1,
+ name: 'validation.IgnoreCompatible',
);
}
-void _registerSkipV2(Fory fory) {
+void _registerIgnoreV2(Fory fory) {
RuntimeValidationTestForyModule.register(
fory,
- SkipCompatibleV2,
- name: 'validation.SkipCompatible',
+ IgnoreCompatibleV2,
+ name: 'validation.IgnoreCompatible',
);
}
@@ -236,20 +236,20 @@ void main() {
);
});
- test('skip fields stay local only after round trip', () {
+ test('ignored fields stay local only after round trip', () {
final fory = Fory();
_registerValidationTypes(fory);
- final roundTrip = fory.deserialize<SkipEnvelope>(
+ final roundTrip = fory.deserialize<IgnoreEnvelope>(
fory.serialize(
- SkipEnvelope()
+ IgnoreEnvelope()
..visible = 'kept'
- ..skipped = 'discarded',
+ ..ignored = 'discarded',
),
);
expect(roundTrip.visible, equals('kept'));
- expect(roundTrip.skipped, equals('local-default'));
+ expect(roundTrip.ignored, equals('local-default'));
});
test('dynamic fields preserve concrete runtime payload types', () {
@@ -289,22 +289,22 @@ void main() {
expect(roundTrip.value, equals('dynamic-payload'));
});
- test('compatible mode ignores skipped fields from older writers', () {
+ test('compatible mode ignores fields from older writers', () {
final writer = Fory(compatible: true);
final reader = Fory(compatible: true);
- _registerSkipV1(writer);
- _registerSkipV2(reader);
+ _registerIgnoreV1(writer);
+ _registerIgnoreV2(reader);
- final migrated = reader.deserialize<SkipCompatibleV2>(
+ final migrated = reader.deserialize<IgnoreCompatibleV2>(
writer.serialize(
- SkipCompatibleV1()
+ IgnoreCompatibleV1()
..visible = 'seen'
- ..skipped = 'legacy',
+ ..ignored = 'legacy',
),
);
expect(migrated.visible, equals('seen'));
- expect(migrated.skipped, equals('reader-default'));
+ expect(migrated.ignored, equals('reader-default'));
});
});
diff --git a/dart/packages/fory/test/type_spec_annotation_test.dart b/dart/packages/fory/test/type_spec_annotation_test.dart
index f8f281ad1..2a87f2675 100644
--- a/dart/packages/fory/test/type_spec_annotation_test.dart
+++ b/dart/packages/fory/test/type_spec_annotation_test.dart
@@ -54,9 +54,7 @@ void main() {
group('container type specs', () {
test('ListType stores nested element specs', () {
const lt = ListType(
- element: MapType(
- value: DeclaredType(ref: true),
- ),
+ element: MapType(value: DeclaredType(ref: true)),
nullable: true,
);
expect(lt.nullable, isTrue);
@@ -66,10 +64,7 @@ void main() {
});
test('ArrayType stores dense element specs', () {
- const array = ArrayType(
- element: Int32Type(),
- nullable: true,
- );
+ const array = ArrayType(element: Int32Type(), nullable: true);
expect(array.nullable, isTrue);
expect(array.element, isA<Int32Type>());
});
@@ -78,10 +73,7 @@ void main() {
const mt = MapType(
key: StringType(),
value: ListType(
- element: Int32Type(
- nullable: true,
- encoding: Encoding.fixed,
- ),
+ element: Int32Type(nullable: true, encoding: Encoding.fixed),
),
ref: true,
);
@@ -103,9 +95,7 @@ void main() {
dynamic: true,
type: MapType(
key: StringType(),
- value: ListType(
- element: Int32Type(encoding: Encoding.fixed),
- ),
+ value: ListType(element: Int32Type(encoding: Encoding.fixed)),
),
);
expect(field.id, equals(3));
@@ -114,17 +104,29 @@ void main() {
expect(field.type, isA<MapType>());
});
+ test('ForyField stores ignore without wire metadata', () {
+ const field = ForyField(ignore: true);
+ expect(field.ignore, isTrue);
+ });
+
+ test('ignored fields reject wire metadata', () {
+ final invalidFields = <ForyField Function()>[
+ () => ForyField(ignore: true, id: 1),
+ () => ForyField(ignore: true, nullable: true),
+ () => ForyField(ignore: true, ref: true),
+ () => ForyField(ignore: true, dynamic: true),
+ () => ForyField(ignore: true, type: StringType()),
+ ];
+ for (final invalidField in invalidFields) {
+ expect(invalidField, throwsA(isA<AssertionError>()));
+ }
+ });
+
test('container sugar stores nested element and value overrides', () {
- const listField = ListField(
- element: DeclaredType(ref: true),
- );
- const arrayField = ArrayField(
- element: Uint8Type(),
- );
+ const listField = ListField(element: DeclaredType(ref: true));
+ const arrayField = ArrayField(element: Uint8Type());
const mapField = MapField(
- value: ListType(
- element: Int32Type(encoding: Encoding.fixed),
- ),
+ value: ListType(element: Int32Type(encoding: Encoding.fixed)),
);
expect((listField.element as DeclaredType).ref, isTrue);
expect(arrayField.element, isA<Uint8Type>());
diff --git a/docs/guide/csharp/external-types.md b/docs/guide/csharp/external-types.md
index d9678d5d3..1af23e798 100644
--- a/docs/guide/csharp/external-types.md
+++ b/docs/guide/csharp/external-types.md
@@ -91,7 +91,8 @@ internal static class StatusSerializer
The target enum's numeric values are authoritative. Do not copy its constants
into the declaration. Every serialized numeric value must fit in the unsigned
-32-bit Fory enum tag range.
+32-bit Fory enum tag range. Cross-language peers must use matching explicit
+enum tags, such as Java's `@ForyEnumId`.
## Registration and Root Values
@@ -102,6 +103,7 @@ Fory fory = Fory.Builder().Build();
fory.Register<ThirdParty.User>(100);
fory.Register<ThirdParty.Status>("example.Status");
+ThirdParty.User user = new() { Name = "Alice", Age = 30 };
byte[] bytes = fory.Serialize(user);
ThirdParty.User decoded = fory.Deserialize<ThirdParty.User>(bytes);
```
@@ -128,6 +130,7 @@ public sealed class Group
Root carrier composition also uses the ordinary typed API:
```csharp
+ThirdParty.User user = new() { Name = "Alice", Age = 30 };
byte[] bytes = fory.Serialize(new List<ThirdParty.User> { user });
List<ThirdParty.User> decoded =
@@ -151,7 +154,8 @@ typed roots.
## Declaration and Target Requirements
An external structural serializer declaration must be a non-generic abstract
-class containing only abstract get-only schema properties. Each property:
+class containing only abstract get-only schema properties. Each serialized
+property:
- matches an accessible target field or property with the same case-sensitive
name;
@@ -165,13 +169,17 @@ nullability. Only members declared by the serializer are serialized; other
target state keeps its normal default or derived behavior after
deserialization.
+Use `[ForyField(Ignore = true)]` on an additional declaration property when
+non-serialized target storage still needs to contribute to the graph-memory
+estimate. Public target fields are included automatically.
+
The target must be an accessible concrete class or struct with a legal
parameterless construction path and writable state. Closed generic targets,
such as `ThirdParty.Box<string>`, are supported; open generic targets are not.
-Targets that require private access, readonly or init-only assignment,
-constructor arguments, factories, member renaming, or value conversion require
-a [manual serializer](manual-serializers.md).
+Serialized members that require private access, readonly or init-only
+assignment, constructor arguments, factories, member renaming, or value
+conversion require a [manual serializer](manual-serializers.md).
## Schema Evolution
diff --git a/docs/guide/csharp/supported-types.md b/docs/guide/csharp/supported-types.md
index db6236377..1b1eccdf7 100644
--- a/docs/guide/csharp/supported-types.md
+++ b/docs/guide/csharp/supported-types.md
@@ -82,6 +82,9 @@ This page summarizes built-in and generated type support in Apache Fory™ C#.
- Manual serializer types registered through `Register<T, TSerializer>(...)`
- `Union` / `Union2<...>` typed union support
+`[ForyEnum]` numeric values are unsigned 32-bit wire tags and must be in the
+range `0..uint.MaxValue`.
+
## Dynamic Types
Dynamic object payloads via `Serialize<object?>` / `Deserialize<object?>` support:
diff --git a/docs/guide/dart/external-types.md b/docs/guide/dart/external-types.md
index 0d9e96c6c..38314a33b 100644
--- a/docs/guide/dart/external-types.md
+++ b/docs/guide/dart/external-types.md
@@ -59,8 +59,12 @@ abstract final class UserSerializer {
The declaration must be `abstract final`, cannot have type parameters, and
must declare each schema field as `late final` without an initializer. Each
-field name and Dart type, including nullability and generic arguments, must
-exactly match an accessible getter on the target.
+serialized field name and Dart type, including nullability and generic
+arguments, must exactly match an accessible getter on the target.
+
+Public target fields count toward the graph-memory budget automatically. Use
+`@ForyField(ignore: true)` on an extra declaration field to count other storage
+without serializing it.
Run the generator as usual:
diff --git a/docs/guide/dart/schema-metadata.md b/docs/guide/dart/schema-metadata.md
index 63d4da595..9d189ffa8 100644
--- a/docs/guide/dart/schema-metadata.md
+++ b/docs/guide/dart/schema-metadata.md
@@ -28,7 +28,7 @@ The same annotations apply to fields in an
```dart
@ForyField(
- skip: false, // include the field; set true to exclude it
+ ignore: false, // include the field; set true to exclude it
id: 10, // stable field ID for schema evolution
nullable: true, // override nullability detection
ref: true, // enable reference tracking for this field
@@ -36,12 +36,15 @@ The same annotations apply to fields in an
)
```
-## `skip`
+## `ignore`
-Exclude a field from serialization entirely. Useful for cached, computed, or UI-only values that should not land in a persisted or transmitted message.
+Exclude a field from serialization entirely. Useful for cached, computed, or
+UI-only values that should not land in a persisted or transmitted message. The
+field still contributes to graph-memory accounting; because it has no wire
+representation, do not combine `ignore` with other `ForyField` options.
```dart
-@ForyField(skip: true)
+@ForyField(ignore: true)
String cachedDisplayName = '';
```
diff --git a/docs/guide/swift/external-types.md b/docs/guide/swift/external-types.md
index 5307bf1f4..c1e8b8086 100644
--- a/docs/guide/swift/external-types.md
+++ b/docs/guide/swift/external-types.md
@@ -84,6 +84,10 @@ The target class must have an accessible zero-argument initializer and writable
serialized properties. These requirements allow Fory to preserve shared and
circular references.
+Swift budgets only fields listed in this declaration. Add
+`@ForyField(ignore: true)` fields for substantial omitted storage; they count
+toward the graph budget but are not serialized.
+
Use a manual serializer if the class is immutable, requires constructor
arguments, or cannot be safely observed before all fields are assigned.
diff --git a/docs/security/deserialization.md b/docs/security/deserialization.md
index 7f09f57a5..3afe08c52 100644
--- a/docs/security/deserialization.md
+++ b/docs/security/deserialization.md
@@ -287,6 +287,27 @@ type-erased materialization paths reserve the shallow storage for the heap value
Parents must not recursively include child object, collection, map, string, binary, or primitive
dense-array contents; the child owner reserves its own shallow memory when it is materialized.
+### External Structural Targets
+
+An external structural serializer does not own or necessarily expose the target type's complete
+storage layout. Its shallow graph-memory formula starts with every field declared by the external
+schema. A field marked `ignore` is a budget-only declaration: it contributes its declared storage
+width but must not enter wire metadata, generated reads or writes, target member access, or target
+construction.
+
+C# and Dart generators may additionally include public instance fields that are visible on the
+target at generation time. A public field represented by a schema declaration is counted once.
+Properties, accessors, interfaces, and runtime layout inspection must not be treated as additional
+stored fields. Swift macros cannot inspect another type's stored layout and therefore use only the
+external declaration.
+
+Other omitted target storage is outside this approximate formula. When it contains a large value
+field or enough fields to matter, the external declaration should list that storage and mark it
+ignored. The normal owner rules still apply: a reference target reserves its shallow owner and
+field storage, while an inline value target is charged by the holder that owns its storage. These
+formulas must be resolved during generation and must not add reflection, layout probing,
+allocation, or field enumeration to deserialization hot paths.
+
### Runtime-Specific Owner Notes
#### C++
diff --git a/docs/specification/xlang_implementation_guide.md b/docs/specification/xlang_implementation_guide.md
index 7a7163734..85d2fd8f8 100644
--- a/docs/specification/xlang_implementation_guide.md
+++ b/docs/specification/xlang_implementation_guide.md
@@ -260,7 +260,13 @@ targets. Runtime type positions, construction, member access, `TypeInfo`,
compatible metadata, graph-memory ownership, reference publication, generated
factory keys, root lookup, and dynamic lookup use the target. The declaration
supplies only field names, IDs, schema descriptors, nullability, and the
-structural `Evolving` setting.
+structural `Evolving` setting for wire fields. An ignored declaration field
+supplies only graph-memory storage and has no wire metadata or target access.
+For external classes, the shallow graph estimate includes declared wire
+fields, ignored declaration fields, and discoverable public instance target
+fields. An ignored declaration does not require its target storage to be
+visible to the generator. When it resolves to the same public field, the field
+symbol is used only to avoid counting that storage twice.
Every generated ordinary or external struct uses
`TypeResolver.RegisterGeneratedStruct<T, TSerializer>(bool evolving)` to carry
@@ -276,14 +282,15 @@ uses the resolver's target replacement rules.
External structural targets require an accessible concrete class or struct,
legal parameterless construction, and directly readable and assignable
-members. Schema properties match target fields or properties by case-sensitive
-name and exact CLR/generic type. Explicit nullability must match; when target
-metadata is nullable-oblivious, the declaration supplies schema nullability.
-Immutable, constructor-only, factory-only, init-only, readonly, renamed,
-converted, or inaccessible shapes use a manual `Serializer<T>`. Generated
-class reads allocate and publish the final target before recursive children;
-target structs are inline values. The declaration is never a temporary graph
-owner.
+wire members. Schema properties match target fields or properties by
+case-sensitive name and exact CLR/generic type. Explicit nullability must
+match; when target metadata is nullable-oblivious, the declaration supplies
+schema nullability. Ignored properties are declaration-owned budget hints and
+do not bind or access target members. Immutable, constructor-only,
+factory-only, init-only, readonly, renamed, converted, or inaccessible wire
+shapes use a manual `Serializer<T>`. Generated class reads allocate and
+publish the final target before recursive children; target structs are inline
+values. The declaration is never a temporary graph owner.
C# carrier composition is target-based. The resolver recursively
binds `Nullable<T>`, one-dimensional `T[]`, `List<T>`, `LinkedList<T>`,
@@ -575,6 +582,12 @@ manual serializer; the implementation must not use reflection, unsafe layout
access, unavailable-overload tricks, schema mirror values, conversion wrappers,
or builders as a fallback.
+Swift macros cannot inspect another type's stored layout. An external class's
+shallow graph-memory formula therefore uses its declaration fields only.
+`@ForyField(ignore: true)` adds a budget-only declaration field without schema,
+target access, construction, or wire code. Applications must use this form for
+substantial omitted storage.
+
An external structural union requires the target to expose a lossless
`unknown(UnknownCase)` case. A dependency-free target module may expose a
generic unknown payload and let the application select its `UnknownCase`
@@ -1278,6 +1291,10 @@ In Java:
- `@ForyEnumId` can override that with a stable explicit tag
- `serializeEnumByName(true)` affects native Java mode, not xlang mode
+In C#, the enum's underlying numeric value is the xlang tag. Java peers for
+sparse C# enums must declare matching `@ForyEnumId` values instead of relying
+on declaration ordinals.
+
Other language implementations should preserve the same wire rule even if the configuration or
annotation surface differs.
@@ -1334,15 +1351,21 @@ name. Every runtime type position uses the target type: `Serializer<Target>`,
`GeneratedStructSchema<Target>`, read and write signatures, constructor calls,
schema `type`, and generated-module dispatch.
-For each declaration field, resolve an accessible target getter with the same
-name and exact instantiated Dart type. Constructor parameters and any
-post-construction setter must also match exactly. A public named generative
-constructor is selected only when the annotation names it. Factory
+For each serialized declaration field, resolve an accessible target getter
+with the same name and exact instantiated Dart type. Constructor parameters
+and any post-construction setter must also match exactly. A public named
+generative constructor is selected only when the annotation names it. Factory
constructors, abstract targets, open target types, and constructor-based
reference-tracked paths back to the target are rejected during generation.
The recursive check includes target elements, keys, and values nested in the
supported list, set, and map field metadata.
+An external object's shallow graph-memory formula is the union of declaration
+fields and public instance fields discovered on the target, its superclasses,
+and applied mixins. A public target field represented by a declaration is
+counted once. `@ForyField(ignore: true)` adds budget-only declaration storage
+without target access, construction, metadata, or wire code.
+
Generated code reads getters and invokes target constructors or setters
directly. It must not allocate the declaration, copy values through an
intermediate object, invoke runtime callbacks, perform member-name lookup, or
diff --git a/java/fory-core/src/test/java/org/apache/fory/xlang/CSharpXlangTest.java b/java/fory-core/src/test/java/org/apache/fory/xlang/CSharpXlangTest.java
index 95a94d061..78b3474be 100644
--- a/java/fory-core/src/test/java/org/apache/fory/xlang/CSharpXlangTest.java
+++ b/java/fory-core/src/test/java/org/apache/fory/xlang/CSharpXlangTest.java
@@ -31,6 +31,7 @@ import java.util.Map;
import java.util.concurrent.TimeUnit;
import lombok.Data;
import org.apache.fory.Fory;
+import org.apache.fory.annotation.ForyEnumId;
import org.apache.fory.annotation.ForyField;
import org.apache.fory.annotation.ForyStruct;
import org.apache.fory.memory.MemoryBuffer;
@@ -495,8 +496,13 @@ public class CSharpXlangTest extends XlangTestBase {
}
enum CSharpExternalStatus {
+ @ForyEnumId(0)
UNKNOWN,
+
+ @ForyEnumId(7)
READY,
+
+ @ForyEnumId(23)
DONE,
}
diff --git a/swift/Sources/Fory/MacroDeclarations.swift b/swift/Sources/Fory/MacroDeclarations.swift
index bdba1db72..3f824e54a 100644
--- a/swift/Sources/Fory/MacroDeclarations.swift
+++ b/swift/Sources/Fory/MacroDeclarations.swift
@@ -242,9 +242,11 @@ public struct ForyFieldType: Sendable {
}
@attached(peer)
-/// Configures a generated field and optionally selects its exact serializer.
+/// Configures a generated field, selects its exact serializer, or declares
+/// budget-only storage for an external structural serializer.
public macro ForyField(
id: Int? = nil,
+ ignore: Bool = false,
encoding: ForyFieldEncoding? = nil,
type: ForyFieldType? = nil,
with serializer: Any.Type? = nil
diff --git a/swift/Sources/ForyMacro/ForyObjectMacro.swift b/swift/Sources/ForyMacro/ForyObjectMacro.swift
index 3797ec6da..064dea84d 100644
--- a/swift/Sources/ForyMacro/ForyObjectMacro.swift
+++ b/swift/Sources/ForyMacro/ForyObjectMacro.swift
@@ -52,7 +52,10 @@ public struct ForyStructMacro: MemberMacro, ExtensionMacro {
throw MacroExpansionErrorMessage("@ForyStruct supports struct and class declarations only")
}
- let parsed = try parseFields(declaration)
+ let parsed = try parseFields(
+ declaration,
+ isExternal: objectConfig.targetType != nil
+ )
let sortedFields = sortFields(parsed.fields)
let targetType =
objectConfig.targetType
@@ -98,6 +101,7 @@ public struct ForyStructMacro: MemberMacro, ExtensionMacro {
stringLiteral: buildDefaultDecl(
isClass: parsed.isClass,
fields: parsed.fields,
+ graphFields: parsed.graphFields,
accessPrefix: accessPrefix
)
)
@@ -125,8 +129,7 @@ public struct ForyStructMacro: MemberMacro, ExtensionMacro {
)
let readDecl: DeclSyntax = DeclSyntax(
stringLiteral: buildReadDataDecl(
- isClass: parsed.isClass,
- fields: parsed.fields,
+ declaration: parsed,
sortedFields: sortedFields,
accessPrefix: accessPrefix,
successBodyAttribute: successBodyAttribute
@@ -134,8 +137,7 @@ public struct ForyStructMacro: MemberMacro, ExtensionMacro {
)
let readCompatibleDecl: DeclSyntax = DeclSyntax(
stringLiteral: buildReadCompatibleDataDecl(
- isClass: parsed.isClass,
- fields: parsed.fields,
+ declaration: parsed,
sortedFields: sortedFields,
accessPrefix: accessPrefix
)
@@ -368,9 +370,17 @@ struct ParsedField {
let customCodecType: String?
}
-private struct ParsedDecl {
+enum ParsedGraphField {
+ case serialized(ParsedField)
+ // An ignored external field declares storage only; retaining just its type
+ // keeps it out of schema, target access, and construction generation.
+ case ignored(typeText: String)
+}
+
+struct ParsedDecl {
let isClass: Bool
let fields: [ParsedField]
+ let graphFields: [ParsedGraphField]
}
private enum ParsedEnumKind: Equatable {
@@ -417,6 +427,7 @@ private indirect enum FieldTypeHint {
private struct ParsedForyFieldConfiguration {
let encoding: FieldEncoding?
let id: Int?
+ let ignore: Bool?
let typeHint: FieldTypeHint?
let serializerType: String?
}
@@ -941,13 +952,17 @@ private func enumCaseDefaultExpr(_ enumCase: ParsedEnumCase) -> String {
return ".\(enumCase.name)(\(args))"
}
-private func parseFields(_ declaration: some DeclGroupSyntax) throws -> ParsedDecl {
+private func parseFields(
+ _ declaration: some DeclGroupSyntax,
+ isExternal: Bool
+) throws -> ParsedDecl {
let isClass = declaration.is(ClassDeclSyntax.self)
guard isClass || declaration.is(StructDeclSyntax.self) else {
throw MacroExpansionErrorMessage("@ForyStruct supports struct and class only")
}
var fields: [ParsedField] = []
+ var graphFields: [ParsedGraphField] = []
var originalIndex = 0
for member in declaration.memberBlock.members {
@@ -955,14 +970,12 @@ private func parseFields(_ declaration: some DeclGroupSyntax) throws -> ParsedDe
continue
}
- if varDecl.modifiers.contains(where: { $0.name.tokenKind == .keyword(.static) || $0.name.tokenKind == .keyword(.class) }) {
- continue
- }
-
let fieldConfig = try parseForyFieldConfiguration(
from: varDecl.attributes,
supportsEncoding: true
)
+ let ignoreSpecified = fieldConfig?.ignore != nil
+ let isIgnored = fieldConfig?.ignore == true
let directTypeHint =
fieldConfig?.serializerType.map { FieldTypeHint.with(serializerType: $0) }
?? fieldConfig?.typeHint
@@ -970,15 +983,49 @@ private func parseFields(_ declaration: some DeclGroupSyntax) throws -> ParsedDe
from: varDecl.attributes,
existing: directTypeHint
)
+ if ignoreSpecified && !isExternal {
+ throw MacroExpansionErrorMessage(
+ "@ForyField(ignore:) is only supported by external @ForyStruct declarations"
+ )
+ }
+ if isIgnored,
+ fieldConfig?.id != nil || fieldConfig?.encoding != nil || fieldTypeHint != nil
+ {
+ throw MacroExpansionErrorMessage(
+ "@ForyField(ignore: true) cannot be combined with wire or nested field options"
+ )
+ }
+ let isStatic = varDecl.modifiers.contains {
+ $0.name.tokenKind == .keyword(.static)
+ || $0.name.tokenKind == .keyword(.class)
+ }
+ if ignoreSpecified && isStatic {
+ throw MacroExpansionErrorMessage(
+ "@ForyField(ignore:) requires a named instance stored property"
+ )
+ }
+ if isStatic {
+ continue
+ }
if fieldConfig != nil || fieldTypeHint != nil, varDecl.bindings.count != 1 {
throw MacroExpansionErrorMessage("Fory field annotations can only be used on a single stored property")
}
for binding in varDecl.bindings {
guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else {
+ if ignoreSpecified {
+ throw MacroExpansionErrorMessage(
+ "@ForyField(ignore:) requires a named instance stored property"
+ )
+ }
continue
}
- guard binding.accessorBlock == nil else {
+ if binding.accessorBlock != nil {
+ if ignoreSpecified {
+ throw MacroExpansionErrorMessage(
+ "@ForyField(ignore:) requires a named instance stored property"
+ )
+ }
continue
}
guard let typeAnnotation = binding.typeAnnotation else {
@@ -987,6 +1034,11 @@ private func parseFields(_ declaration: some DeclGroupSyntax) throws -> ParsedDe
let name = pattern.identifier.text
let rawType = typeAnnotation.type.trimmedDescription
+ if isIgnored {
+ graphFields.append(.ignored(typeText: rawType))
+ originalIndex += 1
+ continue
+ }
let optionalUnwrapped = unwrapOptional(rawType)
let isOptional = optionalUnwrapped.isOptional
let concreteType = optionalUnwrapped.type
@@ -1021,25 +1073,25 @@ private func parseFields(_ declaration: some DeclGroupSyntax) throws -> ParsedDe
group = 3
}
- fields.append(
- ParsedField(
- name: name,
- typeText: rawType,
- typeHint: fieldTypeHint,
- originalIndex: originalIndex,
- isOptional: isOptional,
- isCollection: classification.isCollection || classification.isMap,
- fieldID: fieldID,
- schemaIdentifier: schemaIdentifier,
- fieldIdentifier: fieldIdentifier,
- group: group,
- typeID: classification.typeID,
- isCompressedNumeric: classification.isCompressedNumeric,
- primitiveSize: classification.primitiveSize,
- customCodecType:
- typeResolution.customCodecType ?? dynamicCodecType ?? carrierCodecType
- )
+ let field = ParsedField(
+ name: name,
+ typeText: rawType,
+ typeHint: fieldTypeHint,
+ originalIndex: originalIndex,
+ isOptional: isOptional,
+ isCollection: classification.isCollection || classification.isMap,
+ fieldID: fieldID,
+ schemaIdentifier: schemaIdentifier,
+ fieldIdentifier: fieldIdentifier,
+ group: group,
+ typeID: classification.typeID,
+ isCompressedNumeric: classification.isCompressedNumeric,
+ primitiveSize: classification.primitiveSize,
+ customCodecType:
+ typeResolution.customCodecType ?? dynamicCodecType ?? carrierCodecType
)
+ fields.append(field)
+ graphFields.append(.serialized(field))
originalIndex += 1
}
}
@@ -1057,7 +1109,11 @@ private func parseFields(_ declaration: some DeclGroupSyntax) throws -> ParsedDe
seenFieldIDs[fieldID] = field.name
}
- return ParsedDecl(isClass: isClass, fields: fields)
+ return ParsedDecl(
+ isClass: isClass,
+ fields: fields,
+ graphFields: graphFields
+ )
}
private func parseForyFieldConfiguration(
@@ -1066,6 +1122,7 @@ private func parseForyFieldConfiguration(
) throws -> ParsedForyFieldConfiguration? {
var parsedEncoding: FieldEncoding?
var parsedID: Int?
+ var parsedIgnore: Bool?
var parsedTypeHint: FieldTypeHint?
var parsedSerializerType: String?
for element in attributes {
@@ -1111,6 +1168,20 @@ private func parseForyFieldConfiguration(
continue
}
+ if label == "ignore" {
+ let ignore = try parseBoolLiteralExpression(
+ arg.expression,
+ message: "@ForyField ignore must be a boolean literal"
+ )
+ if let existing = parsedIgnore, existing != ignore {
+ throw MacroExpansionErrorMessage(
+ "conflicting @ForyField ignore values on the same declaration"
+ )
+ }
+ parsedIgnore = ignore
+ continue
+ }
+
if label == "type" {
if parsedTypeHint != nil {
throw MacroExpansionErrorMessage("conflicting @ForyField type hints on the same declaration")
@@ -1131,7 +1202,7 @@ private func parseForyFieldConfiguration(
}
throw MacroExpansionErrorMessage(
- "@ForyField supports only 'id', 'encoding', 'type', and 'with' arguments"
+ "@ForyField supports only 'id', 'ignore', 'encoding', 'type', and 'with' arguments"
)
}
}
@@ -1147,13 +1218,19 @@ private func parseForyFieldConfiguration(
)
}
- if parsedEncoding == nil, parsedID == nil, parsedTypeHint == nil, parsedSerializerType == nil {
+ if parsedEncoding == nil,
+ parsedID == nil,
+ parsedIgnore == nil,
+ parsedTypeHint == nil,
+ parsedSerializerType == nil
+ {
return nil
}
return ParsedForyFieldConfiguration(
encoding: parsedEncoding,
id: parsedID,
+ ignore: parsedIgnore,
typeHint: parsedTypeHint,
serializerType: parsedSerializerType
)
@@ -2758,11 +2835,16 @@ private func buildSchemaTypeFingerprint(
return "\"\\(__foryNormalizeSchemaFingerprintTypeID(\(typeIDExpr))),\\(\(trackFlagExpr)),\(nullableLiteral)\""
}
-private func buildDefaultDecl(isClass: Bool, fields: [ParsedField], accessPrefix: String) -> String {
+private func buildDefaultDecl(
+ isClass: Bool,
+ fields: [ParsedField],
+ graphFields: [ParsedGraphField],
+ accessPrefix: String
+) -> String {
if isClass {
return """
\(accessPrefix)static func defaultValue(_ context: ReadContext) throws -> Target {
- try context.reserveGraphMemory(\(classGraphOwnerBytesExpr(fields)))
+ try context.reserveGraphMemory(\(classGraphOwnerBytesExpr(graphFields)))
return Target.init()
}
"""
diff --git a/swift/Sources/ForyMacro/ForyObjectMacroReadGeneration.swift b/swift/Sources/ForyMacro/ForyObjectMacroReadGeneration.swift
index f592f810e..f533ae257 100644
--- a/swift/Sources/ForyMacro/ForyObjectMacroReadGeneration.swift
+++ b/swift/Sources/ForyMacro/ForyObjectMacroReadGeneration.swift
@@ -16,27 +16,27 @@
// under the License.
func buildReadDataDecl(
- isClass: Bool,
- fields: [ParsedField],
+ declaration: ParsedDecl,
sortedFields: [ParsedField],
accessPrefix: String,
successBodyAttribute: String
) -> String {
- if isClass {
+ if declaration.isClass {
return buildClassReadDataDecl(
sortedFields: sortedFields,
+ graphFields: declaration.graphFields,
accessPrefix: accessPrefix,
successBodyAttribute: successBodyAttribute
)
}
- if fields.isEmpty {
+ if declaration.fields.isEmpty {
return buildEmptyStructReadDataDecl(
accessPrefix: accessPrefix,
successBodyAttribute: successBodyAttribute
)
}
return buildStructReadDataDecl(
- fields: fields,
+ fields: declaration.fields,
sortedFields: sortedFields,
accessPrefix: accessPrefix,
successBodyAttribute: successBodyAttribute
@@ -44,22 +44,28 @@ func buildReadDataDecl(
}
func buildReadCompatibleDataDecl(
- isClass: Bool,
- fields: [ParsedField],
+ declaration: ParsedDecl,
sortedFields: [ParsedField],
accessPrefix: String
) -> String {
- if isClass {
- return buildClassReadCompatibleDataDecl(sortedFields: sortedFields, accessPrefix: accessPrefix)
+ if declaration.isClass {
+ return buildClassReadCompatibleDataDecl(
+ sortedFields: sortedFields,
+ graphFields: declaration.graphFields,
+ accessPrefix: accessPrefix
+ )
}
- if fields.isEmpty {
+ if declaration.fields.isEmpty {
return buildEmptyStructReadCompatibleDataDecl(accessPrefix: accessPrefix)
}
return buildStructReadCompatibleDataDecl(
- fields: fields, sortedFields: sortedFields, accessPrefix: accessPrefix)
+ fields: declaration.fields,
+ sortedFields: sortedFields,
+ accessPrefix: accessPrefix
+ )
}
-private func graphFieldBytesExpr(_ field: ParsedField) -> String {
+private func serializedGraphFieldBytesExpr(_ field: ParsedField) -> String {
if field.primitiveSize > 0 {
return "\(field.primitiveSize)"
}
@@ -73,7 +79,16 @@ private func graphFieldBytesExpr(_ field: ParsedField) -> String {
return "(\(field.typeText).isRefType ? 4 : max(1, MemoryLayout<\(field.typeText)>.stride))"
}
-func classGraphOwnerBytesExpr(_ fields: [ParsedField]) -> String {
+private func graphFieldBytesExpr(_ field: ParsedGraphField) -> String {
+ switch field {
+ case .serialized(let serializedField):
+ return serializedGraphFieldBytesExpr(serializedField)
+ case .ignored(let typeText):
+ return "max(1, MemoryLayout<\(typeText)>.stride)"
+ }
+}
+
+func classGraphOwnerBytesExpr(_ fields: [ParsedGraphField]) -> String {
let ownerBytes = "(2 * MemoryLayout<Int>.stride)"
if fields.isEmpty {
return ownerBytes
@@ -81,7 +96,10 @@ func classGraphOwnerBytesExpr(_ fields: [ParsedField]) -> String {
return ownerBytes + " + " + fields.map(graphFieldBytesExpr).joined(separator: " + ")
}
-private func reserveClassGraphOwnerLine(fields: [ParsedField], indent: String) -> String {
+private func reserveClassGraphOwnerLine(
+ fields: [ParsedGraphField],
+ indent: String
+) -> String {
"\(indent)try context.reserveGraphMemory(\(classGraphOwnerBytesExpr(fields)))"
}
@@ -216,6 +234,7 @@ func buildStructReadWrapperDecl(
private func buildClassReadDataDecl(
sortedFields: [ParsedField],
+ graphFields: [ParsedGraphField],
accessPrefix: String,
successBodyAttribute: String
) -> String {
@@ -228,7 +247,7 @@ private func buildClassReadDataDecl(
private static func __foryReadDataImpl(_ context: ReadContext, reservedRefID: UInt32?) throws -> Target {
let __buffer = context.buffer
\(schemaHashCheckExpr())
- \(reserveClassGraphOwnerLine(fields: sortedFields, indent: " "))
+ \(reserveClassGraphOwnerLine(fields: graphFields, indent: " "))
let value = Target.init()
if let reservedRefID {
context.refReader.storeRef(value, at: reservedRefID)
@@ -287,6 +306,7 @@ private func buildStructReadDataDecl(
private func buildClassReadCompatibleDataDecl(
sortedFields: [ParsedField],
+ graphFields: [ParsedGraphField],
accessPrefix: String
) -> String {
let primitiveFastFields = leadingPrimitiveFastPathFields(sortedFields)
@@ -320,7 +340,7 @@ private func buildClassReadCompatibleDataDecl(
\(bufferBinding)guard let typeMeta = remoteTypeInfo.compatibleTypeMeta else {
throw ForyError.invalidData("compatible type metadata is required")
}
- \(reserveClassGraphOwnerLine(fields: sortedFields, indent: " "))
+ \(reserveClassGraphOwnerLine(fields: graphFields, indent: " "))
let value = Target.init()
if let reservedRefID {
context.refReader.storeRef(value, at: reservedRefID)
diff --git a/swift/Tests/ForyExternalModels/ExternalModels.swift b/swift/Tests/ForyExternalModels/ExternalModels.swift
index e60e7f3d5..a7df43dbe 100644
--- a/swift/Tests/ForyExternalModels/ExternalModels.swift
+++ b/swift/Tests/ForyExternalModels/ExternalModels.swift
@@ -95,6 +95,7 @@ public struct Group: Equatable {
public final class Node {
public var value: Int32 = 0
public var next: Node?
+ private var omittedState: (UInt64, UInt64) = (0, 0)
public init() {}
}
diff --git a/swift/Tests/ForyTests/ExternalTypeSerializationTests.swift b/swift/Tests/ForyTests/ExternalTypeSerializationTests.swift
index 2732c0858..0bfb347ce 100644
--- a/swift/Tests/ForyTests/ExternalTypeSerializationTests.swift
+++ b/swift/Tests/ForyTests/ExternalTypeSerializationTests.swift
@@ -117,10 +117,19 @@ private struct CarrierSelections: Equatable {
@ForyStruct(target: Node.self)
private final class NodeSerializer {
+ @ForyField(ignore: false)
var value: Int32 = 0
@ForyField(with: OptionalSerializer<NodeSerializer>.self)
var next: Node?
+
+ @ForyField(ignore: true)
+ var omittedState: (UInt64, UInt64) = (0, 0)
+}
+
+@ForyStruct(target: Node.self)
+private final class NodeValueSerializer {
+ var value: Int32 = 0
}
@ForyEnum(target: Status.self)
@@ -482,6 +491,64 @@ func externalClassCycleRoundTrip() throws {
#expect(decoded.next === decoded)
}
+@Test
+func externalIgnoredFieldBudget() throws {
+ let value = Node()
+ value.value = 42
+ let required =
+ 2 * MemoryLayout<Int>.stride
+ + MemoryLayout<Int32>.stride
+ + 4
+ + MemoryLayout<(UInt64, UInt64)>.stride
+
+ for compatible in [false, true] {
+ let writer = Fory(
+ config: .init(
+ trackRef: false,
+ compatible: compatible
+ )
+ )
+ let bytes: Data
+ if compatible {
+ try writer.register(NodeValueSerializer.self, id: 142)
+ bytes = try writer.serialize(value, with: NodeValueSerializer.self)
+ } else {
+ try writer.register(NodeSerializer.self, id: 142)
+ bytes = try writer.serialize(value, with: NodeSerializer.self)
+ }
+
+ let limited = Fory(
+ config: .init(
+ trackRef: false,
+ compatible: compatible,
+ maxGraphMemoryBytes: Int64(required - 1)
+ )
+ )
+ try limited.register(NodeSerializer.self, id: 142)
+ #expect(throws: ForyError.self) {
+ let _: Node = try limited.deserialize(
+ bytes,
+ with: NodeSerializer.self
+ )
+ }
+
+ let exact = Fory(
+ config: .init(
+ trackRef: false,
+ compatible: compatible,
+ maxGraphMemoryBytes: Int64(required)
+ )
+ )
+ try exact.register(NodeSerializer.self, id: 142)
+ let decoded: Node = try exact.deserialize(
+ bytes,
+ with: NodeSerializer.self
+ )
+ #expect(decoded.value == value.value)
+ #expect(decoded.next == nil)
+ }
+}
+
@Test
func externalEnumAndUnionRoundTrip() throws {
let fory = Fory(config: .init(trackRef: false, compatible: false))
diff --git a/swift/Tests/ForyTests/MacroDiagnosticTests.swift b/swift/Tests/ForyTests/MacroDiagnosticTests.swift
index 1e1d7d50b..2b6bf5ef5 100644
--- a/swift/Tests/ForyTests/MacroDiagnosticTests.swift
+++ b/swift/Tests/ForyTests/MacroDiagnosticTests.swift
@@ -395,3 +395,176 @@ func serializerExistentialRejected() {
"fields cannot use 'any Serializer' as an application value; select a concrete application protocol with DynamicSerializer"
)
}
+
+@Test
+func ignoredFieldRequiresExternalTarget() {
+ assertForyDiagnostic(
+ """
+ @ForyStruct
+ struct BadIgnoredField {
+ @ForyField(ignore: true)
+ var value: Int32 = 0
+ }
+ """,
+ expandedSource:
+ """
+ struct BadIgnoredField {
+ var value: Int32 = 0
+ }
+ """,
+ message: "@ForyField(ignore:) is only supported by external @ForyStruct declarations"
+ )
+
+ assertForyDiagnostic(
+ """
+ @ForyStruct
+ struct BadExplicitFalse {
+ @ForyField(ignore: false)
+ var value: Int32 = 0
+ }
+ """,
+ expandedSource:
+ """
+ struct BadExplicitFalse {
+ var value: Int32 = 0
+ }
+ """,
+ message: "@ForyField(ignore:) is only supported by external @ForyStruct declarations"
+ )
+}
+
+@Test
+func ignoredFieldRequiresLiteral() {
+ assertForyDiagnostic(
+ """
+ @ForyStruct(target: External.self)
+ struct BadIgnoredField {
+ @ForyField(ignore: enabled)
+ var value: Int32 = 0
+ }
+ """,
+ expandedSource:
+ """
+ struct BadIgnoredField {
+ var value: Int32 = 0
+ }
+ """,
+ message: "@ForyField ignore must be a boolean literal"
+ )
+}
+
+@Test
+func ignoredFieldRejectsWireOptions() {
+ assertForyDiagnostic(
+ """
+ @ForyStruct(target: External.self)
+ struct BadIgnoredField {
+ @ForyField(id: 1, ignore: true)
+ var value: Int32 = 0
+ }
+ """,
+ expandedSource:
+ """
+ struct BadIgnoredField {
+ var value: Int32 = 0
+ }
+ """,
+ message: "@ForyField(ignore: true) cannot be combined with wire or nested field options"
+ )
+}
+
+@Test
+func ignoredFieldRejectsNestedOptions() {
+ assertForyDiagnostic(
+ """
+ @ForyStruct(target: External.self)
+ struct BadIgnoredField {
+ @ForyField(ignore: true)
+ @ListField(element: .int32())
+ var value: [Int32] = []
+ }
+ """,
+ expandedSource:
+ """
+ struct BadIgnoredField {
+ var value: [Int32] = []
+ }
+ """,
+ message: "@ForyField(ignore: true) cannot be combined with wire or nested field options"
+ )
+}
+
+@Test
+func ignoredFieldShapeDiagnostics() {
+ assertForyDiagnostic(
+ """
+ @ForyStruct(target: External.self)
+ final class BadStaticField {
+ @ForyField(ignore: true)
+ static var value: Int32 = 0
+ }
+ """,
+ expandedSource:
+ """
+ final class BadStaticField {
+ static var value: Int32 = 0
+ }
+ """,
+ message: "@ForyField(ignore:) requires a named instance stored property"
+ )
+
+ assertForyDiagnostic(
+ """
+ @ForyStruct(target: External.self)
+ struct BadPatternField {
+ @ForyField(ignore: true)
+ var (first, second): (Int32, Int32) = (0, 0)
+ }
+ """,
+ expandedSource:
+ """
+ struct BadPatternField {
+ var (first, second): (Int32, Int32) = (0, 0)
+ }
+ """,
+ message: "@ForyField(ignore:) requires a named instance stored property"
+ )
+
+ assertForyDiagnostic(
+ """
+ @ForyStruct(target: External.self)
+ final class BadComputedField {
+ @ForyField(ignore: true)
+ var value: Int32 {
+ 0
+ }
+ }
+ """,
+ expandedSource:
+ """
+ final class BadComputedField {
+ var value: Int32 {
+ 0
+ }
+ }
+ """,
+ message: "@ForyField(ignore:) requires a named instance stored property"
+ )
+
+ assertForyDiagnostic(
+ """
+ @ForyStruct(target: External.self)
+ struct BadMultiBinding {
+ @ForyField(ignore: true)
+ var first: Int32 = 0, second: Int32 = 0
+ }
+ """,
+ expandedSource:
+ """
+ struct BadMultiBinding {
+ var first: Int32 = 0, second: Int32 = 0
+ }
+ """,
+ message: "Fory field annotations can only be used on a single stored property"
+ )
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@fory.apache.org
For additional commands, e-mail: commits-help@fory.apache.org