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
26 changes: 22 additions & 4 deletions docs/patchable-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ An explicit null deletes the member, as defined by [RFC 7396](https://www.rfc-ed
Deletion is permitted only for members that are optional in the original model. A required member
cannot be deleted even when its value is nullable; merge patch cannot assign a literal null to an
object member. Arrays retain their element nullability because an array replaces the entire value.
All patch fields may be omitted, and schema defaults never select updates implicitly. Constraints
Ordinary patch fields may be omitted, and schema defaults never select updates implicitly. Constraints
still apply to supplied values. Nested object members use their own patch companions, while array
elements retain ordinary model types because arrays replace whole values.
Recursive object models are supported; self-recursive map aliases receive a generation diagnostic.
Expand Down Expand Up @@ -99,9 +99,25 @@ RAML `(sunday.patchable): true` generates both the ordinary model and its compan
patchability, with the same rules as native `x-sunday-patchable`. Ordinary JSON bodies and responses keep
the ordinary model. Native AsyncAPI object schemas
can also declare `x-sunday-patchable`; event payloads retain their ordinary type, and the companion is
available to consumers. A patchable declaration must resolve to an object. Native polymorphic
hierarchies cannot themselves become partial discriminated unions; place such unions inside a
patchable containing object instead.
available to consumers. A patchable declaration must resolve to an object. Discriminated object
hierarchies generate a companion hierarchy: for example, `AnimalPatch` selects `CatPatch` or `DogPatch`
using the same discriminator wire values. Both automatic discovery and explicit annotations support
these companions. Externally discriminated declarations and non-object unions are not supported as
patch roots.

The discriminator is a required, non-null dispatch tag, never an update operation. Missing, null, and
unknown tags are rejected, including when the ordinary hierarchy tolerates unknown response variants.
The tag stays fixed to the selected subtype. A discriminator-only patch is a valid no-op. All other
fields, including inherited fields, retain the omission, set, and deletion rules above. A required
nullable property may be omitted but cannot be deleted.

A subtype patch rejects fields known only to other variants. Unrelated extension fields still follow
the schema's additional-property policy. Ordinary JSON models retain their original contracts.

Merge helpers reject changes to the discriminator; Swift exposes concrete subtype helpers so a
`DogPatch` cannot be passed to `Cat.merge`. Difference helpers retain the discriminator even when it
has not changed. Services must compare the patch tag with the persisted resource before mutating it
and may return HTTP 400 for a mismatch; the generator cannot perform that persistence check.

Merge-patch generation requires Sunday Swift `2.0.0-beta.9`, Sunday Kotlin `2.0.0-beta.9`, and Sunday
Python `2.0.0-beta.6` or later. Swift patch companions with inferred `Identifiable` conformance require
Expand All @@ -126,6 +142,8 @@ TypeScript models remain structural values, so their helpers live on the same-na
| Python | `model.patch()` | `updated.patch(from_model=original)` | `base.merge(patch)` | `ItemPatch.from_model(model)` |

Swift also exposes `try ItemPatch.fromModel(model)`, including for recursive reference models.
For discriminated hierarchies, Swift and Python expose conversion factories on concrete patch
variants; their root dispatch types select those variants during decoding.

Member names are allocated to avoid schema-property collisions. As with companion type names, a
collision may add a suffix. Helpers reuse the generated codecs and existing response-mode validation;
Expand Down
2 changes: 1 addition & 1 deletion docs/sunday-ir-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ Object models may carry `closed` when the source object disallows unknown proper

APIs, models, and model properties may carry `targets`, keyed by target id such as `kotlin`, `kotlinClient`, `kotlinServer`, `swift`, and `typescript`. Target entries preserve source annotations for package/module names, model package/module names, target type names, and implementation overrides. Imported RAML declaration models also inherit target defaults from their declaring library, so library-level model package/module annotations remain durable in IR.

Implementation overrides carry source `code` plus ordered `parameters` with `type` and `value`. Models may also carry `nested` metadata with the resolved enclosing type reference and nested name, and `patchable` for declarations representing partial updates. Materialized companions carry `patchOf`, a reference to their ordinary model; RAML annotations follow the same companion-generation rules as native annotations. Request bodies with content type `application/merge-patch+json` automatically derive and select companion patch declarations before serialization, independently of the HTTP method or a schema annotation. Other content alternatives retain their ordinary types. `x-sunday-patchable` (RAML `(sunday.patchable)`) also requests a companion when no merge-patch body references the schema. Projected properties carry `patchDeletionAllowed`, preserving the original member's deletion permission after `required` becomes false. Its false value is serialized explicitly; value nullability alone never grants deletion. See [patchable models](patchable-models.md).
Implementation overrides carry source `code` plus ordered `parameters` with `type` and `value`. Models may also carry `nested` metadata with the resolved enclosing type reference and nested name, and `patchable` for declarations representing partial updates. Materialized companions carry `patchOf`, a reference to their ordinary model; RAML annotations follow the same companion-generation rules as native annotations. Request bodies with content type `application/merge-patch+json` automatically derive and select companion patch declarations before serialization, independently of the HTTP method or a schema annotation. Other content alternatives retain their ordinary types. `x-sunday-patchable` (RAML `(sunday.patchable)`) also requests a companion when no merge-patch body references the schema. Projected properties carry `patchDeletionAllowed`, preserving the original member's deletion permission after `required` becomes false. Its false value is serialized explicitly; value nullability alone never grants deletion. Discriminator members in companion hierarchies instead carry `patchDiscriminator: true`, remain required and non-null, and do not carry `patchDeletionAllowed`. Companion discriminator mappings reference companion variants. `patchExcludedFields` lists other variants’ wire fields forbidden on this companion only; these exclusions do not propagate through inheritance. See [patchable models](patchable-models.md).

## Source And Codegen Boundary

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ data class GeneratedModel(
val patchable: Boolean = false,
/** The ordinary schema represented by this merge-patch companion. */
val patchOf: GeneratedTypeRef? = null,
/** Other variants' wire fields forbidden only on this patch, never inherited by descendants. */
val patchExcludedFields: List<String> = listOf(),
val inherits: List<GeneratedTypeRef> = listOf(),
val discriminator: String? = null,
val discriminatorValue: String? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,6 @@ data class GeneratedModelProperty(
/** Whether a merge-patch member may be deleted; null identifies an ordinary, unprojected property. */
@get:JsonInclude(JsonInclude.Include.NON_NULL)
val patchDeletionAllowed: Boolean? = null,
/** Required immutable dispatch tag in a merge-patch hierarchy, never an update operation. */
val patchDiscriminator: Boolean = false,
)
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ internal object GeneratedPatchModels {
model.name !in visited &&
(model.patchable || parents(model).any { patchable(it, visited + model.name) })

fun discriminatorRoot(model: GeneratedModel): GeneratedModel? =
if (model.discriminator != null) model else parents(model).firstNotNullOfOrNull(::discriminatorRoot)

fun discriminator(model: GeneratedModel): String? = discriminatorRoot(model)?.discriminator

fun discriminatorValue(model: GeneratedModel): String? {
val root = discriminatorRoot(model) ?: return null
return root.discriminatorMappings.entries
.firstOrNull { it.value.name == model.name }
?.key
?: model.discriminatorValue
?: model.name.takeUnless { model == root }
}

lateinit var reserve: (GeneratedModel) -> Unit

fun patchType(
Expand Down Expand Up @@ -124,13 +138,19 @@ internal object GeneratedPatchModels {
if (model.kind != GeneratedModel.Kind.OBJECT && !objectAlias) {
genError("Patchable model '${model.name}' must be an object schema")
}
if (model.discriminator != null || model.discriminatorMappings.isNotEmpty() || model.externallyDiscriminated) {
if (model.externallyDiscriminated) {
genError(
"Patchable model '${model.name}' must not be a discriminator hierarchy; patch a containing object instead",
)
}
patchNames[model.name] = allocator.allocate("${model.name}Patch")
parents(model).forEach { reserve(it) }
model.discriminatorMappings.values
.mapNotNull { models[it.name] }
.forEach { reserve(it) }
if (model.discriminator != null) {
models.values.filter { candidate -> candidate.inherits.any { it.name == model.name } }.forEach { reserve(it) }
}
model.properties.forEach { patchType(it.type) }
model.additionalProperties?.type?.let(::patchType)
model.patternProperties.forEach { patchType(it.type) }
Expand All @@ -148,11 +168,53 @@ internal object GeneratedPatchModels {
val patches =
patchNames.map { (name, patchName) ->
val model = models.getValue(name)
val mappings =
model.discriminatorMappings.ifEmpty {
if (model.discriminator != null) {
models.values
.filter { candidate -> candidate.inherits.any { it.name == model.name } }
.associate { (it.discriminatorValue ?: it.name) to GeneratedTypeRef.named(it.name, scope = it.scope) }
} else {
emptyMap()
}
}
val discriminator = discriminator(model)
val tag =
properties
.fields(model)
.firstOrNull {
it.wireName == discriminator || it.effective.name == discriminator
}?.effective
val patchProperties =
patchFields(model, properties).filterNot {
tag != null && (it.serializationName ?: it.name) == (tag.serializationName ?: tag.name)
} +
listOfNotNull(
tag?.copy(
required = true,
type = tag.type.copy(nullable = false),
defaultValue = null,
patchDiscriminator = true,
patchDeletionAllowed = null,
allowedValues = discriminatorValue(model)?.let { listOf(it) } ?: tag.allowedValues,
),
)
val forbiddenFields =
if (discriminator != null && discriminatorValue(model) != null) {
val ownFields = properties.fields(model).map { it.wireName }.toSet()
models.values
.filter { discriminatorRoot(it) == discriminatorRoot(model) }
.flatMap { properties.fields(it) }
.map { it.wireName }
.toSet() - ownFields
Comment thread
kdubb marked this conversation as resolved.
} else {
emptySet()
}
model.copy(
name = patchName,
patchable = true,
patchOf = GeneratedTypeRef.named(model.name, scope = model.scope),
properties = patchFields(model, properties).map { it.copy(type = patchType(it.type)) },
properties = patchProperties.map { it.copy(type = patchType(it.type)) },
additionalProperties =
model.additionalProperties?.let {
it.copy(
Expand All @@ -170,6 +232,11 @@ internal object GeneratedPatchModels {
type = patchType(it.type).copy(nullable = true),
)
},
patchExcludedFields = forbiddenFields.toList(),
discriminatorMappings =
mappings.mapValues { (_, type) ->
type.copy(name = patchNames.getValue(type.name))
},
inherits = model.inherits.map { it.copy(name = patchNames[it.name] ?: it.name) },
aliases = model.aliases.map { it.copy(name = patchNames[it.name] ?: it.name) },
targets = model.targets.mapValues { (_, target) -> target.copy(typeName = null, implementation = null) },
Expand Down Expand Up @@ -200,6 +267,18 @@ internal object GeneratedPatchModels {
val fields = properties.fields(model).associateBy { it.wireName }
return model.properties.map { property ->
val field = fields.getValue(property.serializationName ?: property.name)
if (property.patchDiscriminator ||
property.name == model.discriminator ||
field.wireName == model.discriminator
) {
return@map property.copy(
required = true,
type = property.type.copy(nullable = false),
defaultValue = null,
patchDiscriminator = true,
patchDeletionAllowed = null,
)
}
// Inherited required members remain protected even if a local declaration is optional.
// Retain explicit false after projection has made every patch field omittable.
val deletionAllowed =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,12 @@
?.copy(externallyDiscriminated = true)
}

private fun GeneratedModel.discriminatorFallbackOrNull(

Check failure on line 98 in generator/src/main/kotlin/io/outfoxx/sunday/generator/ir/emit/GeneratedDiscriminatorFallback.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=outfoxx_sunday-generator&issues=AaFaYVjAKuiNN9YIiCnI&open=AaFaYVjAKuiNN9YIiCnI&pullRequest=286
discriminatorProperty: GeneratedModelProperty,
models: Collection<GeneratedModel>,
lookup: (io.outfoxx.sunday.generator.ir.GeneratedTypeRef) -> GeneratedModel?,
): GeneratedDiscriminatorFallback? {
if (patchOf != null) return null
val enumModel = lookup(discriminatorProperty.type)?.takeIf { it.kind == GeneratedModel.Kind.ENUM }
val fallbackValue = enumModel?.unknownValue
if (fallbackValue == null && tolerance == null) return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,26 @@ internal class GeneratedModelProperties(
} else {
emptyList()
}
return collect(model).distinct()
return (
collect(model) +
model.patchExcludedFields.map { wireName ->
GeneratedPatternProperty(
pattern = exactPropertyPattern(wireName),
type = GeneratedTypeRef.scalar("string"),
allowedValues = emptyList(),
)
}
).distinct()
}

// Use portable escapes: Java's \Q...\E form is not supported by every target's regex engine.
private fun exactPropertyPattern(wireName: String): String =
"^" +
wireName
.map { char ->
if (char in "\\.^$|?*+()[]{}") "\\$char" else char.toString()
}.joinToString("") + "$"

/** All typed additional-property assertions, retaining restrictions inherited from every parent. */
fun additionalProperties(model: GeneratedModel): List<GeneratedAdditionalProperties> {
val visited = Collections.newSetFromMap(IdentityHashMap<GeneratedModel, Boolean>())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2647,7 +2647,7 @@ class KotlinJAXRSIrGenerator(
inheritedProperties.forEach { property ->
addParameter(
property.constructorParameterSpec(
patchable = patchableProperty(property.wireName),
patchable = patchableProperty(property.wireName) && !property.patchDiscriminator,
effective =
modelProperties
.fields(this@classTypeSpec)
Expand All @@ -2661,7 +2661,7 @@ class KotlinJAXRSIrGenerator(
localProperties.forEach { property ->
addParameter(
property.constructorParameterSpec(
patchable = patchable,
patchable = patchable && !property.patchDiscriminator,
declaresProperty =
typeRegistry.options.contains(KotlinTypeRegistry.Option.ValidationConstraints) &&
!(isProblemRoot && property.isBaseProblemProperty()),
Expand Down Expand Up @@ -2741,7 +2741,7 @@ class KotlinJAXRSIrGenerator(
.addAnnotations(property.jacksonExternalDiscriminatorAnnotations(AnnotationSpec.UseSiteTarget.GET))
.apply { if (!patchable) addAnnotations(property.jacksonInclusionAnnotations()) }
.addAnnotations(property.modelValidationAnnotations(patchable = patchable))
.mutable(patchable)
.mutable(patchable && !property.patchDiscriminator)
addProperty(
propertyBuilder
.initializer(property.name.kotlinIdentifierName)
Expand Down Expand Up @@ -3232,7 +3232,13 @@ class KotlinJAXRSIrGenerator(
return
}

val discriminator = model.discriminator ?: model.externalDiscriminatorNameOrNull() ?: return
val discriminatorName = model.discriminator ?: model.externalDiscriminatorNameOrNull() ?: return
val discriminator =
modelProperties
.fields(model)
.firstOrNull {
it.effective.name == discriminatorName || it.wireName == discriminatorName
}?.wireName ?: discriminatorName
val include =
if (model.discriminator == null) {
JACKSON_JSON_TYPEINFO_AS_EXTERNAL_PROPERTY
Expand Down Expand Up @@ -3317,7 +3323,7 @@ class KotlinJAXRSIrGenerator(
.addAnnotations(jacksonExternalDiscriminatorAnnotations(AnnotationSpec.UseSiteTarget.GET))
.apply { if (!patchable) addAnnotations(jacksonInclusionAnnotations()) }
.addAnnotations(modelValidationAnnotations(patchable = patchable))
.mutable(patchable)
.mutable(patchable && !patchDiscriminator)
.build()

private fun GeneratedModelProperty.jacksonInclusionAnnotations(): List<AnnotationSpec> =
Expand Down Expand Up @@ -3387,7 +3393,7 @@ class KotlinJAXRSIrGenerator(
}

private fun GeneratedModelProperty.modelPropertyTypeName(patchable: Boolean = false): TypeName =
if (patchable) {
if (patchable && !patchDiscriminator) {
val operation = if (patchDeletionAllowed == true) PATCH_OP else UPDATE_OP
operation.parameterizedBy(type.kotlinTypeName().copy(nullable = false).withUseSiteValidationAnnotations(type))
} else {
Expand Down
Loading
Loading