Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions compiler/fory_compiler/generators/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,21 @@ def to_camel_case(self, name: str) -> str:
return pascal
return pascal[0].lower() + pascal[1:]

def unknown_case_name(
self, union_name: str, used_names: Set[str], name: str = "Unknown"
) -> str:
# Schema cases own their names. Rename only the generated carrier, with
# one fixed suffix so adding cases cannot silently renumber its API name.
if name not in used_names:
return name
alternate = f"{name}_"
if alternate not in used_names:
return alternate
raise ValueError(
f"{self.language_name} union {union_name}: generated unknown case names "
f"{name!r} and {alternate!r} are both already used"
)

def to_snake_case(self, name: str) -> str:
"""Convert name to snake_case.

Expand Down
7 changes: 6 additions & 1 deletion compiler/fory_compiler/generators/csharp.py
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,11 @@ def generate_union(
lines: List[str] = []
ind = self.indent_str * indent
type_name = self.safe_type_identifier(union.name)
# A nested record also cannot have the enclosing union's name.
case_names = {type_name} | {
self._union_case_type_name(field) for field in union.fields
}
unknown_name = self.unknown_case_name(union.name, case_names)
module_class = self.get_module_class_name()
full_type_ref = self._type_reference_for_local(union)

Expand All @@ -847,7 +852,7 @@ def generate_union(

lines.append(f"{ind}{self.indent_str}[ForyUnknownCase]")
lines.append(
f"{ind}{self.indent_str}public sealed partial record Unknown(UnknownCase Value) : {type_name};"
f"{ind}{self.indent_str}public sealed partial record {unknown_name}(UnknownCase Value) : {type_name};"
)
lines.append("")

Expand Down
21 changes: 13 additions & 8 deletions compiler/fory_compiler/generators/kotlin.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,14 +356,11 @@ def generate_enum(self, enum: Enum, parent_stack: List[Message]) -> List[str]:

def generate_union(self, union: Union, parent_stack: List[Message]) -> List[str]:
union_name = self.type_name(union, parent_stack)
lines = ["@ForyUnion", f"public sealed class {union_name} {{"]
lines.append(" @ForyUnknownCase")
lines.append(" public data class Unknown(")
lines.append(" public val value: UnknownCase,")
lines.append(f" ) : {union_name}()")
case_lines: List[str] = []
case_names: Set[str] = set()
for field in union.fields:
lines.append("")
lines.append(f" @ForyCase(id = {field.number})")
case_lines.append("")
case_lines.append(f" @ForyCase(id = {field.number})")
case_name = self.to_pascal_case(field.name)
field_type = self.generate_type(
field.field_type,
Expand All @@ -374,12 +371,20 @@ def generate_union(self, union: Union, parent_stack: List[Message]) -> List[str]
parent_stack=parent_stack,
)
case_name = self.union_case_name(field.field_type, field_type, case_name)
case_names.add(case_name)
field_type = self.qualify_union_payload_type(
field.field_type, field_type, case_name, parent_stack
)
lines.append(
case_lines.append(
f" public data class {case_name}(public val value: {field_type}) : {union_name}()"
)
unknown_name = self.unknown_case_name(union.name, case_names)
lines = ["@ForyUnion", f"public sealed class {union_name} {{"]
lines.append(" @ForyUnknownCase")
lines.append(f" public data class {unknown_name}(")
lines.append(" public val value: UnknownCase,")
lines.append(f" ) : {union_name}()")
lines.extend(case_lines)
lines.append("}")
return lines

Expand Down
6 changes: 5 additions & 1 deletion compiler/fory_compiler/generators/rust.py
Original file line number Diff line number Diff line change
Expand Up @@ -789,8 +789,12 @@ def generate_union(
derives.append(trait)
lines.append(f"#[derive({', '.join(derives)})]")
lines.append(f"pub enum {union_name} {{")
case_names = {
self.get_union_case_identifier(union, field) for field in union.fields
}
unknown_name = self.unknown_case_name(union.name, case_names)
lines.append(" #[fory(unknown)]")
lines.append(" Unknown(::fory::UnknownCase),")
lines.append(f" {unknown_name}(::fory::UnknownCase),")

for index, field in enumerate(union.fields):
variant_name = self.get_union_case_identifier(union, field)
Expand Down
25 changes: 15 additions & 10 deletions compiler/fory_compiler/generators/scala.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,15 +415,10 @@ def generate_union(
parent_stack: Optional[List[Message]] = None,
) -> List[str]:
ind = self.indent_str * indent
lines = [
f"{ind}@ForyUnion",
f"{ind}enum {union.name} derives ForySerializer {{",
]
lines.append(f"{ind} @ForyUnknownCase")
lines.append(f"{ind} case Unknown(value: UnknownCase)")
lines.append("")
case_lines: List[str] = []
case_names: Set[str] = set()
for field in union.fields:
lines.append(f"{ind} @ForyCase(id = {field.number})")
case_lines.append(f"{ind} @ForyCase(id = {field.number})")
case_name = self.to_pascal_case(field.name)
field_type = self.generate_type(
field.field_type,
Expand All @@ -434,9 +429,19 @@ def generate_union(
parent_stack=parent_stack,
)
case_name = self.union_case_name(field.field_type, field_type, case_name)
case_names.add(case_name)
field_type = self.qualify_union_payload_type(field_type, case_name)
lines.append(f"{ind} case {case_name}(value: {field_type})")
lines.append("")
case_lines.append(f"{ind} case {case_name}(value: {field_type})")
case_lines.append("")
unknown_name = self.unknown_case_name(union.name, case_names)
lines = [
f"{ind}@ForyUnion",
f"{ind}enum {union.name} derives ForySerializer {{",
f"{ind} @ForyUnknownCase",
f"{ind} case {unknown_name}(value: UnknownCase)",
"",
]
lines.extend(case_lines)
lines.append(f"{ind}}}")
lines.append("")
return lines
Expand Down
4 changes: 3 additions & 1 deletion compiler/fory_compiler/generators/swift.py
Original file line number Diff line number Diff line change
Expand Up @@ -1217,8 +1217,10 @@ def generate_union(
indirect = "indirect " if id(union) in self._indirect_unions else ""
lines.append(f"{ind}public {indirect}enum {type_name}{conformances} {{")
lineage = parent_stack or []
case_names = {self.safe_enum_case_name(field.name) for field in union.fields}
unknown_name = self.unknown_case_name(union.name, case_names, "unknown")
lines.append(f"{ind}{self.indent_str}@ForyUnknownCase")
lines.append(f"{ind}{self.indent_str}case unknown(UnknownCase)")
lines.append(f"{ind}{self.indent_str}case {unknown_name}(UnknownCase)")
lines.append("")
for field in union.fields:
field_type = self.generate_type(
Expand Down
24 changes: 24 additions & 0 deletions compiler/fory_compiler/tests/test_csharp_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import warnings
from pathlib import Path

import pytest

from fory_compiler.cli import main as foryc_main, resolve_imports
from fory_compiler.frontend.fdl.lexer import Lexer
from fory_compiler.frontend.fdl.parser import Parser
Expand Down Expand Up @@ -534,3 +536,25 @@ def test_csharp_namespace_option_is_known():

assert schema.get_option("csharp_namespace") == "MyCorp.MyApp"
assert not caught


def test_union_unknown_carrier_renamed():
source = """
package demo;

union Payload {
string unknown = 1;
int32 unknown_value = 2;
}
"""

code = generate(source).content

assert code.count("record Unknown_(UnknownCase Value)") == 1
assert "public sealed partial record Unknown(string Value) : Payload;" in code
assert "public sealed partial record UnknownValue(int Value) : Payload;" in code


def test_union_unknown_carrier_collision():
with pytest.raises(ValueError, match="Unknown_"):
generate("union Unknown_ { string unknown = 1; }")
51 changes: 51 additions & 0 deletions compiler/fory_compiler/tests/test_generated_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@
from fory_compiler.generators.go import GoGenerator
from fory_compiler.generators.java import JavaGenerator
from fory_compiler.generators.javascript import JavaScriptGenerator
from fory_compiler.generators.kotlin import KotlinGenerator
from fory_compiler.generators.python import PythonGenerator
from fory_compiler.generators.rust import RustGenerator
from fory_compiler.generators.scala import ScalaGenerator
from fory_compiler.generators.swift import SwiftGenerator
from fory_compiler.ir.ast import Schema
from fory_compiler.ir.validator import SchemaValidator
Expand Down Expand Up @@ -80,6 +82,55 @@ def render_files(files: dict[str, str]) -> str:
return "\n".join(content for _, content in sorted(files.items()))


@pytest.mark.parametrize(
"generator_cls, carrier, known, other",
[
(
CSharpGenerator,
"record Unknown_(UnknownCase",
"record Unknown(string",
"record UnknownValue(int",
),
(
KotlinGenerator,
"class Unknown_(",
"class Unknown(public val value: String)",
"class UnknownValue(public val value: Int)",
),
(
ScalaGenerator,
"case Unknown_(value: UnknownCase)",
"case Unknown(value: String)",
"case UnknownValue(value: Int)",
),
(
RustGenerator,
"Unknown_(::fory::UnknownCase)",
"Unknown(::std::string::String)",
"UnknownValue(i32)",
),
(
SwiftGenerator,
"case unknown_(UnknownCase)",
"case unknown(String)",
"case unknownValue(Int32)",
),
],
)
def test_union_unknown_carrier_names(generator_cls, carrier, known, other):
schema = parse_fdl("""
package demo;
union Choice {
string unknown = 0;
int32 unknown_value = 1;
}
""")
code = render_files(generate_files(schema, generator_cls))
assert carrier in code
assert known in code
assert other in code


def assert_language_outputs_equal(
schemas: dict[str, Schema], generator_cls: type[BaseGenerator]
) -> None:
Expand Down
12 changes: 6 additions & 6 deletions csharp/src/Fory.Generator/ForyModelGenerator.Analysis.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1022,14 +1022,14 @@ private static ImmutableArray<UnionCaseModel> BuildUnionCases(
continue;
}

if (!string.Equals(caseType.Name, "Unknown", StringComparison.Ordinal) ||
!HasUnknownCaseValueProperty(caseType))
// The marker owns carrier identity; its name may avoid a schema case.
if (!HasUnknownCaseValueProperty(caseType))
{
diagnostics.Add(Diagnostic.Create(
InvalidUnionCase,
caseType.Locations.FirstOrDefault(),
unknownCaseTypeName,
"unknown case must be named Unknown and expose Value:UnknownCase"));
"unknown case must expose Value:UnknownCase"));
continue;
}

Expand Down Expand Up @@ -1107,23 +1107,23 @@ private static ImmutableArray<UnionCaseModel> BuildUnionCases(
InvalidUnionCase,
unionType.Locations.FirstOrDefault(),
unionType.ToDisplayString(FullNameFormat),
"union must declare exactly one [ForyUnknownCase] Unknown"));
"union must declare exactly one [ForyUnknownCase] carrier"));
}
else if (!cases.Any(c => c.IsUnknown))
{
diagnostics.Add(Diagnostic.Create(
InvalidUnionCase,
unionType.Locations.FirstOrDefault(),
unionType.ToDisplayString(FullNameFormat),
"union must declare [ForyUnknownCase] Unknown"));
"union must declare a [ForyUnknownCase] carrier"));
}
else if (!cases.Any(c => !c.IsUnknown))
{
diagnostics.Add(Diagnostic.Create(
InvalidUnionCase,
unionType.Locations.FirstOrDefault(),
unionType.ToDisplayString(FullNameFormat),
"union must declare at least one non-Unknown case; Unknown is a forward-compatibility carrier and cannot be the default"));
"union must declare at least one schema-defined case; the unknown carrier cannot be the default"));
}

return cases
Expand Down
2 changes: 1 addition & 1 deletion csharp/tests/Fory.Tests/ForyGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ public sealed partial record Unknown(UnknownCase Value) : OnlyUnknown;
generatorDiagnostics.Concat(diagnostics),
diagnostic =>
diagnostic.Id == "FORY006" &&
diagnostic.GetMessage().Contains("at least one non-Unknown case", StringComparison.Ordinal));
diagnostic.GetMessage().Contains("at least one schema-defined case", StringComparison.Ordinal));
Assert.DoesNotContain(output.GetDiagnostics(), diagnostic => diagnostic.Severity == DiagnosticSeverity.Error && diagnostic.Id != "FORY006");
}

Expand Down
3 changes: 2 additions & 1 deletion docs/compiler/generated-code/csharp.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ table. Schema-defined cases use non-negative `[ForyCase]` IDs. If a case needs
non-default schema encoding, the generated `[ForyCase]` carries `Type`. Known
case record names are PascalCase FDL case names; payload types are emitted as
qualified references when needed to avoid name conflicts. A typed union must
have at least one non-`Unknown` case.
have at least one schema-defined case. On a name collision, the generated carrier
is named `Unknown_`; a second collision is an error. Schema case names are preserved.

```csharp
[ForyUnion]
Expand Down
9 changes: 9 additions & 0 deletions docs/compiler/generated-code/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ package or namespace, names such as `AddressbookForyModule` or
`ComplexPbForyModule` let multiple IDL files target the same package or
namespace without producing colliding `ForyModule` types.

## Unknown Union Case Names

C#, Kotlin, Scala, and Rust generate an `Unknown` case to preserve unknown union
values; Swift uses `unknown`. Schema-defined case names keep their normal
language-specific spelling. If the generated carrier name conflicts, the compiler
uses `Unknown_` or Swift's `unknown_`. If that alternate name is also occupied,
generation fails. The unknown-case annotation or attribute identifies the carrier;
its name is not required to be `Unknown` or `unknown`.

## Reference Schemas

The examples below use two real schemas:
Expand Down
3 changes: 2 additions & 1 deletion docs/compiler/generated-code/kotlin.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ sealed classes with `@ForyUnion`; the Fory-provided `Unknown(UnknownCase)`
carrier is marked with `@ForyUnknownCase`. The marker only selects the carrier
and does not add an entry to the schema case table. Schema-defined cases may use
case IDs `0..N` and hold a single `value` property. A typed union must have at
least one non-`Unknown` case.
least one schema-defined case. On a name collision, the generated carrier is named
`Unknown_`; a second collision is an error. Schema case names are preserved.

```kotlin
package addressbook
Expand Down
6 changes: 4 additions & 2 deletions docs/compiler/generated-code/rust.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ Unions map to Rust enums with `#[fory(id = ...)]` schema case attributes.
`#[fory(unknown)] Unknown(::fory::UnknownCase)` marks the Fory-provided
forward-compatibility carrier. The marker only selects the carrier and does not
add an entry to the schema case table; schema cases still use the full `0..N`
ID range. A generated typed union must have at least one non-`Unknown` case. The
compiler marks the first declared non-`Unknown` case as `#[fory(default)]`.
ID range. On a name collision, the generated carrier is named `Unknown_`; a second
collision is an error. Schema case names are preserved. A generated typed union
must have at least one schema-defined case. The compiler marks the first declared
schema-defined case as `#[fory(default)]`.
When that case's payload implements Rust's standard `Default` trait, the
compiler also emits a standard `Default` implementation from that case:

Expand Down
3 changes: 2 additions & 1 deletion docs/compiler/generated-code/scala.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ forward-compatibility carrier marked with `@ForyUnknownCase`. It is omitted
from the schema case table because the marker only selects the carrier and does
not add a schema entry. Schema-defined cases use non-negative `@ForyCase` IDs.
A typed union must have at least one
non-`Unknown` case.
schema-defined case. On a name collision, the generated carrier is named
`Unknown_`; a second collision is an error. Schema case names are preserved.

```scala
package addressbook
Expand Down
8 changes: 5 additions & 3 deletions docs/compiler/generated-code/swift.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ Swift output is one `.swift` file per schema, for example:
## Type Generation

The generator creates Swift models with split model macros and stable field/case IDs.
A typed union must include `@ForyUnknownCase case unknown(UnknownCase)` and at
least one non-`unknown` case; `unknown(UnknownCase)` is only the
A typed union must include a case marked `@ForyUnknownCase` and at
least one schema-defined case; `unknown(UnknownCase)` is the default name of the
Fory-provided forward-compatibility carrier. The marker only selects the carrier
and does not add an entry to the schema case table.
and does not add an entry to the schema case table. On a name collision, the
generated carrier is named `unknown_`; a second collision is an error. Schema
case names are preserved.

When package/namespace is non-empty, namespace shaping is controlled by `swift_namespace_style`:

Expand Down
6 changes: 3 additions & 3 deletions docs/object-serialization/csharp/schema-metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,9 @@ Generated union cases use `[ForyCase]` for both the stable case ID and optional
case payload schema type. Do not put `[ForyField]` on union case payload
members. Known case record names use PascalCase FDL case names; payload types
use qualified references when needed to avoid name conflicts. A typed union must
declare at least one non-`Unknown` case; `Unknown(UnknownCase)` is only the
Fory-owned forward-compatibility carrier. The marker only selects the carrier and
does not add an entry to the schema case table.
declare at least one schema-defined case and exactly one `[ForyUnknownCase]`
case exposing `Value: UnknownCase`. The carrier name is unrestricted. The marker
only selects the carrier and does not add an entry to the schema case table.

```csharp
using Apache.Fory;
Expand Down
Loading
Loading