Skip to content

Commit b482540

Browse files
committed
Add hoistAnyOfFromAllOf transformation to prevent protobuf generator from flattening allOf+anyOf structures
Signed-off-by: xil <fridalu66@gmail.com>
1 parent 0c949e1 commit b482540

3 files changed

Lines changed: 167 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
99
- Fix simplifySingleMapSchema to generate named wrapper schemas. ([#406](https://github.com/opensearch-project/opensearch-protobufs/pull/406))
1010
- Change vendorExtension protobuf type handling to use protobuf type instead of openApi type ([#409](https://github.com/opensearch-project/opensearch-protobufs/pull/409))
1111
- Normalize mixed oneOf patterns ([#416](https://github.com/opensearch-project/opensearch-protobufs/pull/416))
12+
- Add hoistAnyOfFromAllOf transformation to prevent protobuf generator from flattening allOf+anyOf structures
1213
### Removed
1314

1415
### Fixed

tools/proto-convert/src/SchemaModifier.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export class SchemaModifier {
2323
this.deduplicateOneOfWithArrayType(schema)
2424
this.collapseSingleItemComposite(schema);
2525
this.normalizeMixedOneOf(schema)
26+
this.hoistAnyOfFromAllOf(schema);
2627
},
2728
onSchema: (schema, schemaName) => {
2829
if (!schema || isReferenceObject(schema)) return;
@@ -33,6 +34,7 @@ export class SchemaModifier {
3334
this.handleOneOfConst(schema, schemaName)
3435
this.deduplicateOneOfWithArrayType(schema)
3536
this.collapseSingleItemComposite(schema);
37+
this.hoistAnyOfFromAllOf(schema);
3638
this.collapseOneOfObjectPropContainsTitleSchema(schema)
3739
this.normalizeMixedOneOf(schema)
3840
this.convertOneOfToMinMaxProperties(schema)
@@ -146,6 +148,65 @@ export class SchemaModifier {
146148
}
147149
}
148150

151+
/**
152+
* Hoists anyOf from within allOf to the top level and moves all base items into the anyOf.
153+
*
154+
* Transforms:
155+
* allOf: [base1, { anyOf: [{ $ref: 'Variant1' }, { $ref: 'Variant2' }] }]
156+
*
157+
* Into:
158+
* anyOf: [base1, { $ref: 'Variant1' }, { $ref: 'Variant2' }]
159+
*
160+
* This prevents OpenAPI Generator from flattening named schema variants while preserving
161+
* inline property alternatives.
162+
*/
163+
hoistAnyOfFromAllOf(schema: OpenAPIV3.SchemaObject): void {
164+
if (!Array.isArray(schema.allOf) || schema.allOf.length === 0) {
165+
return;
166+
}
167+
168+
// Find if any allOf item contains anyOf
169+
let variantItem: OpenAPIV3.SchemaObject | null = null;
170+
const baseItems: Array<OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject> = [];
171+
172+
for (const item of schema.allOf) {
173+
if (!item || typeof item !== 'object') continue;
174+
175+
const schemaItem = item as OpenAPIV3.SchemaObject;
176+
if ('anyOf' in schemaItem && Array.isArray(schemaItem.anyOf)) {
177+
variantItem = schemaItem;
178+
} else {
179+
baseItems.push(item);
180+
}
181+
}
182+
183+
// If we found anyOf, hoist it and move all items into it
184+
if (variantItem) {
185+
const variants = variantItem.anyOf as Array<OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject>;
186+
187+
// Check if at least one variant is a $ref
188+
const hasRefVariants = variants.some(variant => {
189+
if (!variant || typeof variant !== 'object') return false;
190+
return '$ref' in variant;
191+
});
192+
193+
// Only apply transformation if variants are $ref (not inline objects)
194+
if (!hasRefVariants) {
195+
logger.info(`Skipping anyOf hoist: variants are inline objects (preserving property alternatives)`);
196+
return;
197+
}
198+
199+
// Combine base items + variant items into a single anyOf array
200+
const newAnyOf = [...baseItems, ...variants];
201+
202+
// Replace the schema with the hoisted structure
203+
delete schema.allOf;
204+
schema.anyOf = newAnyOf;
205+
206+
logger.info(`Hoisted anyOf from allOf (moved ${baseItems.length} base items + ${variants.length} variants into anyOf)`);
207+
}
208+
}
209+
149210
isArraySchemaObject(schema: any): schema is OpenAPIV3.ArraySchemaObject {
150211
return (
151212
typeof schema === 'object' &&

tools/proto-convert/test/SchemaModifier.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1695,4 +1695,109 @@ describe('SchemaModifier', () => {
16951695
expect(secondAllOfItem.maxProperties).toBe(1);
16961696
});
16971697
});
1698+
1699+
describe('hoistAnyOfFromAllOf', () => {
1700+
it('should move $ref variants into anyOf array', () => {
1701+
const doc = createDocument();
1702+
doc.components!.schemas = {
1703+
TestSchema: {
1704+
allOf: [
1705+
{ $ref: '#/components/schemas/BaseSchema' },
1706+
{
1707+
anyOf: [
1708+
{ title: 'variant1', $ref: '#/components/schemas/Variant1' },
1709+
{ title: 'variant2', $ref: '#/components/schemas/Variant2' }
1710+
]
1711+
}
1712+
]
1713+
},
1714+
BaseSchema: { type: 'object' },
1715+
Variant1: { type: 'object' },
1716+
Variant2: { type: 'object' }
1717+
};
1718+
1719+
const modifier = new SchemaModifier(doc) as any;
1720+
const schema = doc.components!.schemas!.TestSchema as OpenAPIV3.SchemaObject;
1721+
1722+
modifier.hoistAnyOfFromAllOf(schema);
1723+
1724+
expect(schema.allOf).toBeUndefined();
1725+
expect(schema.anyOf).toBeDefined();
1726+
expect(schema.anyOf).toHaveLength(3);
1727+
expect(schema.anyOf![0]).toEqual({ $ref: '#/components/schemas/BaseSchema' });
1728+
expect(schema.anyOf![1]).toEqual({ title: 'variant1', $ref: '#/components/schemas/Variant1' });
1729+
expect(schema.anyOf![2]).toEqual({ title: 'variant2', $ref: '#/components/schemas/Variant2' });
1730+
});
1731+
1732+
it('should NOT hoist anyOf when variants are inline objects (like field/script alternatives)', () => {
1733+
const doc = createDocument();
1734+
doc.components!.schemas!.TermsAggregationFields = {
1735+
allOf: [
1736+
{
1737+
type: 'object',
1738+
properties: {
1739+
collect_mode: { type: 'string' },
1740+
min_doc_count: { type: 'integer' }
1741+
}
1742+
},
1743+
{
1744+
anyOf: [
1745+
{
1746+
type: 'object',
1747+
properties: {
1748+
field: { type: 'string' }
1749+
}
1750+
},
1751+
{
1752+
type: 'object',
1753+
properties: {
1754+
script: { type: 'string' }
1755+
}
1756+
}
1757+
]
1758+
}
1759+
]
1760+
};
1761+
1762+
const modifier = new SchemaModifier(doc) as any;
1763+
const schema = doc.components!.schemas!.TermsAggregationFields as OpenAPIV3.SchemaObject;
1764+
const originalSchema = JSON.parse(JSON.stringify(schema));
1765+
1766+
modifier.hoistAnyOfFromAllOf(schema);
1767+
1768+
// Should NOT modify schema when variants are inline objects
1769+
expect(schema).toEqual(originalSchema);
1770+
expect(schema.allOf).toBeDefined();
1771+
expect(schema.anyOf).toBeUndefined();
1772+
});
1773+
1774+
it('should hoist even when $ref variants have additional properties like title', () => {
1775+
const doc = createDocument();
1776+
doc.components!.schemas = {
1777+
Aggregate: {
1778+
allOf: [
1779+
{ $ref: '#/components/schemas/AggregateBase' },
1780+
{
1781+
anyOf: [
1782+
{ title: 'adjacency_matrix', $ref: '#/components/schemas/AdjacencyMatrixAggregate' },
1783+
{ title: 'avg', $ref: '#/components/schemas/AvgAggregate' }
1784+
]
1785+
}
1786+
]
1787+
},
1788+
AggregateBase: { type: 'object' },
1789+
AdjacencyMatrixAggregate: { type: 'object' },
1790+
AvgAggregate: { type: 'object' }
1791+
};
1792+
1793+
const modifier = new SchemaModifier(doc) as any;
1794+
const schema = doc.components!.schemas!.Aggregate as OpenAPIV3.SchemaObject;
1795+
1796+
modifier.hoistAnyOfFromAllOf(schema);
1797+
1798+
expect(schema.allOf).toBeUndefined();
1799+
expect(schema.anyOf).toBeDefined();
1800+
expect(schema.anyOf).toHaveLength(3);
1801+
});
1802+
});
16981803
});

0 commit comments

Comments
 (0)