-
-
Notifications
You must be signed in to change notification settings - Fork 7.7k
[swift][swift6] fix: recursive schemas generate structs of infinite size #24898
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -746,6 +746,7 @@ private static List<String> splitAdditionalModelOption(String value) { | |
| @Override | ||
| public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) { | ||
| objs = super.postProcessAllModels(objs); | ||
| markModelClassRendering(objs); | ||
| if (additionalModelObjectAttributes.isEmpty() | ||
| && additionalModelEnumAttributes.isEmpty() | ||
| && additionalModelImports.isEmpty()) { | ||
|
|
@@ -766,6 +767,74 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) | |
| return objs; | ||
| } | ||
|
|
||
|
|
||
| /** | ||
| * A struct that stores itself inline - through any chain of model-typed properties, | ||
| * Optional included - has infinite size and does not compile ("value type cannot have a | ||
| * stored property that recursively contains it"). Containers store their elements on the | ||
| * heap and break the recursion, so only bare model-to-model properties form the edges. | ||
| * Every model on such a reference cycle is generated as a final class instead: heap | ||
| * allocation provides the indirection the struct cannot have, and the wire format is | ||
| * unchanged. See https://github.com/OpenAPITools/openapi-generator/issues/15240. | ||
| * | ||
| * @param objs the models | ||
| */ | ||
| private void markModelClassRendering(Map<String, ModelsMap> objs) { | ||
| Map<String, CodegenModel> modelsByClassname = new HashMap<>(); | ||
| for (ModelsMap modelsMap : objs.values()) { | ||
| for (ModelMap modelMap : modelsMap.getModels()) { | ||
| CodegenModel cm = modelMap.getModel(); | ||
| modelsByClassname.put(cm.classname, cm); | ||
| } | ||
| } | ||
|
|
||
| Map<String, Set<String>> inlineRefs = new HashMap<>(); | ||
| for (CodegenModel cm : modelsByClassname.values()) { | ||
| Set<String> refs = new LinkedHashSet<>(); | ||
| collectInlineModelRefs(cm.allVars, modelsByClassname, refs); | ||
| if (cm.getComposedSchemas() != null) { | ||
| // oneOf/anyOf render as enums with inline associated values, so they carry the | ||
| // recursion; allOf is flattened into allVars and is deliberately not an edge | ||
| collectInlineModelRefs(cm.getComposedSchemas().getOneOf(), modelsByClassname, refs); | ||
| collectInlineModelRefs(cm.getComposedSchemas().getAnyOf(), modelsByClassname, refs); | ||
| } | ||
| inlineRefs.put(cm.classname, refs); | ||
| } | ||
|
|
||
| for (CodegenModel cm : modelsByClassname.values()) { | ||
| boolean recursive = !useClasses && isOnInlineReferenceCycle(cm.classname, inlineRefs); | ||
| if (useClasses || recursive) { | ||
| cm.vendorExtensions.put("x-swift-use-class", true); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The template now renders based on the vendor extension Prompt for AI agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deliberate, and additive-only. |
||
| } | ||
| } | ||
| } | ||
|
|
||
| private void collectInlineModelRefs(List<CodegenProperty> vars, Map<String, CodegenModel> modelsByClassname, Set<String> refs) { | ||
| if (vars == null) { | ||
| return; | ||
| } | ||
| for (CodegenProperty var : vars) { | ||
| if (!var.isContainer && var.complexType != null && modelsByClassname.containsKey(var.complexType)) { | ||
| refs.add(var.complexType); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private boolean isOnInlineReferenceCycle(String classname, Map<String, Set<String>> inlineRefs) { | ||
| Deque<String> toVisit = new ArrayDeque<>(inlineRefs.getOrDefault(classname, Collections.emptySet())); | ||
| Set<String> visited = new HashSet<>(); | ||
| while (!toVisit.isEmpty()) { | ||
| String current = toVisit.pop(); | ||
| if (classname.equals(current)) { | ||
| return true; | ||
| } | ||
| if (visited.add(current)) { | ||
| toVisit.addAll(inlineRefs.getOrDefault(current, Collections.emptySet())); | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| @Override | ||
| protected boolean isReservedWord(String word) { | ||
| return word != null && reservedWords.contains(word); //don't lowercase as super does | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -794,6 +794,7 @@ private static List<String> splitAdditionalModelOption(String value) { | |
| @Override | ||
| public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) { | ||
| objs = super.postProcessAllModels(objs); | ||
| markModelClassRendering(objs); | ||
| if (additionalModelObjectAttributes.isEmpty() | ||
| && additionalModelEnumAttributes.isEmpty() | ||
| && additionalModelImports.isEmpty()) { | ||
|
|
@@ -814,6 +815,80 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) | |
| return objs; | ||
| } | ||
|
|
||
|
|
||
| /** | ||
| * A struct that stores itself inline - through any chain of model-typed properties, | ||
| * Optional included - has infinite size and does not compile ("value type cannot have a | ||
| * stored property that recursively contains it"). Containers store their elements on the | ||
| * heap and break the recursion, so only bare model-to-model properties form the edges. | ||
| * Every model on such a reference cycle is generated as a final class instead: heap | ||
| * allocation provides the indirection the struct cannot have, and the wire format is | ||
| * unchanged. See https://github.com/OpenAPITools/openapi-generator/issues/15240. | ||
| * | ||
| * @param objs the models | ||
| */ | ||
| private void markModelClassRendering(Map<String, ModelsMap> objs) { | ||
| Map<String, CodegenModel> modelsByClassname = new HashMap<>(); | ||
| for (ModelsMap modelsMap : objs.values()) { | ||
| for (ModelMap modelMap : modelsMap.getModels()) { | ||
| CodegenModel cm = modelMap.getModel(); | ||
| modelsByClassname.put(cm.classname, cm); | ||
| } | ||
| } | ||
|
|
||
| Map<String, Set<String>> inlineRefs = new HashMap<>(); | ||
| for (CodegenModel cm : modelsByClassname.values()) { | ||
| Set<String> refs = new LinkedHashSet<>(); | ||
| collectInlineModelRefs(cm.allVars, modelsByClassname, refs); | ||
| if (cm.getComposedSchemas() != null) { | ||
| // oneOf/anyOf render as enums with inline associated values, so they carry the | ||
| // recursion; allOf is flattened into allVars and is deliberately not an edge | ||
| collectInlineModelRefs(cm.getComposedSchemas().getOneOf(), modelsByClassname, refs); | ||
| collectInlineModelRefs(cm.getComposedSchemas().getAnyOf(), modelsByClassname, refs); | ||
| } | ||
| inlineRefs.put(cm.classname, refs); | ||
| } | ||
|
|
||
| for (CodegenModel cm : modelsByClassname.values()) { | ||
| boolean recursive = !useClasses && isOnInlineReferenceCycle(cm.classname, inlineRefs); | ||
| if (useClasses || recursive) { | ||
| cm.vendorExtensions.put("x-swift-use-class", true); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: When a cycle is expressed through Prompt for AI agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do not think this holds. A cycle running through a oneOf enum ( |
||
| } | ||
| if ((useClasses && readonlyProperties) || recursive) { | ||
| // a struct embedding one of these classes is still declared Sendable, so the | ||
| // class must conform; a recursion-breaking class is treated as unchecked the | ||
| // way readonlyProperties classes already are | ||
| cm.vendorExtensions.put("x-swift-unchecked-sendable", true); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private void collectInlineModelRefs(List<CodegenProperty> vars, Map<String, CodegenModel> modelsByClassname, Set<String> refs) { | ||
| if (vars == null) { | ||
| return; | ||
| } | ||
| for (CodegenProperty var : vars) { | ||
| if (!var.isContainer && var.complexType != null && modelsByClassname.containsKey(var.complexType)) { | ||
| refs.add(var.complexType); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private boolean isOnInlineReferenceCycle(String classname, Map<String, Set<String>> inlineRefs) { | ||
| Deque<String> toVisit = new ArrayDeque<>(inlineRefs.getOrDefault(classname, Collections.emptySet())); | ||
| Set<String> visited = new HashSet<>(); | ||
| while (!toVisit.isEmpty()) { | ||
| String current = toVisit.pop(); | ||
| if (classname.equals(current)) { | ||
| return true; | ||
| } | ||
| if (visited.add(current)) { | ||
| toVisit.addAll(inlineRefs.getOrDefault(current, Collections.emptySet())); | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| @Override | ||
| protected boolean isReservedWord(String word) { | ||
| return word != null && reservedWords.contains(word); //don't lowercase as super does | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| {{#additionalModelObjectAttributes}}{{{.}}} | ||
| {{/additionalModelObjectAttributes}}{{^objcCompatible}}{{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} {{#useClasses}}final class{{/useClasses}}{{^useClasses}}struct{{/useClasses}} {{{classname}}}: {{#useVapor}}Content{{/useVapor}}{{^useVapor}}Codable{{#useJsonEncodable}}, JSONEncodable{{/useJsonEncodable}}{{/useVapor}}{{#vendorExtensions.x-swift-hashable}}, Hashable{{/vendorExtensions.x-swift-hashable}} { | ||
| {{/additionalModelObjectAttributes}}{{^objcCompatible}}{{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} {{#vendorExtensions.x-swift-use-class}}final class{{/vendorExtensions.x-swift-use-class}}{{^vendorExtensions.x-swift-use-class}}struct{{/vendorExtensions.x-swift-use-class}} {{{classname}}}: {{#useVapor}}Content{{/useVapor}}{{^useVapor}}Codable{{#useJsonEncodable}}, JSONEncodable{{/useJsonEncodable}}{{/useVapor}}{{#vendorExtensions.x-swift-hashable}}, Hashable{{/vendorExtensions.x-swift-hashable}} { | ||
| {{/objcCompatible}}{{#objcCompatible}}@objcMembers {{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} class {{classname}}: NSObject, Codable{{#useJsonEncodable}}, JSONEncodable{{/useJsonEncodable}} { | ||
| {{/objcCompatible}} | ||
|
|
||
|
|
@@ -121,7 +121,7 @@ | |
| {{/allVars}} | ||
| let additionalPropertiesContainer = try decoder.container(keyedBy: String.self) | ||
| additionalProperties = try additionalPropertiesContainer.decodeMap({{{additionalPropertiesType}}}.self, excludedKeys: nonAdditionalPropertyKeys) | ||
| }{{/additionalPropertiesType}}{{/generateModelAdditionalProperties}}{{^objcCompatible}}{{#useClasses}}{{#vendorExtensions.x-swift-hashable}} | ||
| }{{/additionalPropertiesType}}{{/generateModelAdditionalProperties}}{{^objcCompatible}}{{#vendorExtensions.x-swift-use-class}}{{#vendorExtensions.x-swift-hashable}} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: When the default Prompt for AI agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This one predates the PR rather than being introduced by it: with |
||
|
|
||
| {{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} static func == (lhs: {{classname}}, rhs: {{classname}}) -> Bool { | ||
| {{#allVars}} | ||
|
|
@@ -135,5 +135,5 @@ | |
| hasher.combine({{{name}}}{{^vendorExtensions.x-null-encodable}}{{^required}}?{{/required}}{{/vendorExtensions.x-null-encodable}}.hashValue) | ||
| {{/allVars}} | ||
| {{#generateModelAdditionalProperties}}{{#additionalPropertiesType}}hasher.combine(additionalProperties.hashValue){{/additionalPropertiesType}}{{/generateModelAdditionalProperties}} | ||
| }{{/vendorExtensions.x-swift-hashable}}{{/useClasses}}{{/objcCompatible}} | ||
| }{{/vendorExtensions.x-swift-hashable}}{{/vendorExtensions.x-swift-use-class}}{{/objcCompatible}} | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| openapi: 3.0.3 | ||
| info: | ||
| title: recursive models | ||
| version: 1.0.0 | ||
| paths: | ||
| /contacts: | ||
| get: | ||
| operationId: getContact | ||
| responses: | ||
| '200': | ||
| description: ok | ||
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: '#/components/schemas/DomainInfo' | ||
| components: | ||
| schemas: | ||
| ContactInfo: | ||
| type: object | ||
| properties: | ||
| name: | ||
| type: string | ||
| internationalizedPostalInfo: | ||
| $ref: '#/components/schemas/ContactInfo' | ||
| DomainInfo: | ||
| type: object | ||
| properties: | ||
| domainName: | ||
| type: string | ||
| registrant: | ||
| $ref: '#/components/schemas/ContactInfo' | ||
| NodeA: | ||
| type: object | ||
| properties: | ||
| b: | ||
| $ref: '#/components/schemas/NodeB' | ||
| NodeB: | ||
| type: object | ||
| properties: | ||
| a: | ||
| $ref: '#/components/schemas/NodeA' | ||
| Category: | ||
| type: object | ||
| properties: | ||
| name: | ||
| type: string | ||
| children: | ||
| type: array | ||
| items: | ||
| $ref: '#/components/schemas/Category' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: The three new methods (markModelClassRendering, collectInlineModelRefs, isOnInlineReferenceCycle) are copied verbatim into both Swift5ClientCodegen and Swift6ClientCodegen (~70 lines each). Since both generators extend DefaultCodegen with no shared Swift parent, any future fix to cycle detection must be replicated twice and will drift. Move the graph construction and cycle detection into a shared helper (e.g. a static util or a common parent) parameterized by the models map and the useClasses/readonlyProperties flags, and have both generators call it.
Prompt for AI agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed that it is not pretty. It follows the existing relationship between the two generators, which share no Swift base class and already duplicate their reservedWords lists, option handling and postProcess logic - the swift6 codegen is a full copy of swift5 with its own divergences. Extracting a shared helper would be the first piece of common ground between them, which felt like a bigger call than this PR should make on its own. If maintainers want that refactor here, I am glad to do it.