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
183 changes: 183 additions & 0 deletions packages/core/src/getters/combine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ const context = {
name: { type: 'string' },
},
},
Status: {
type: 'string',
enum: ['new', 'in_progress'],
},
},
},
},
Expand Down Expand Up @@ -85,6 +89,185 @@ describe('combineSchemas (allOf required handling)', () => {
expect(result.value).toContain('Required<Pick');
});

// OAS 3.1's `{type: 'null'}` variant inside an anyOf/oneOf is the
// nullable-enum spelling used by code generators like FastAPI. The result
// should be flagged as an enum so the caller can extract a named type,
// matching the equivalent `{type: ['string','null'], enum: [...]}` form.
// See issue #2710.
describe('nullable enum composition (#2710)', () => {
it('flags anyOf [enum, null] as a nullable enum', () => {
const schema: OpenApiSchemaObject = {
anyOf: [{ enum: ['new', 'in_progress'] }, { type: 'null' }],
};
Comment on lines +97 to +101

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 399db48. Two new tests in combine.test.ts: does not flag anyOf [$ref enum, null] as a nullable enum (asserts isEnum: false), and an integration test in query-params.test.ts (queryParam with anyOf [$ref enum, null] reuses the referenced type) asserting the param emits status?: Status | null and no parameter-scoped enum is added to deps.


const result = combineSchemas({
schema,
name: 'Status',
separator: 'anyOf',
context,
nullable: '',
});

expect(result.isEnum).toBe(true);
expect(result.value).toContain(`'new' | 'in_progress'`);
expect(result.value).toContain('null');
});

it('flags oneOf [enum, null] as a nullable enum', () => {
const schema: OpenApiSchemaObject = {
oneOf: [{ enum: ['new', 'in_progress'] }, { type: 'null' }],
};

const result = combineSchemas({
schema,
name: 'Status',
separator: 'oneOf',
context,
nullable: '',
});

expect(result.isEnum).toBe(true);
expect(result.value).toContain(`'new' | 'in_progress'`);
expect(result.value).toContain('null');
});

// Detection must not depend on the order of subschemas — `{type: 'null'}`
// can appear before or after the enum.
it('flags anyOf [null, enum] (null first) as a nullable enum', () => {
const schema: OpenApiSchemaObject = {
anyOf: [{ type: 'null' }, { enum: ['new', 'in_progress'] }],
};

const result = combineSchemas({
schema,
name: 'Status',
separator: 'anyOf',
context,
nullable: '',
});

expect(result.isEnum).toBe(true);
});

// The pattern is type-agnostic: numeric enums combined with null should
// also be recognized.
it('flags anyOf [numeric enum, null] as a nullable enum', () => {
const schema: OpenApiSchemaObject = {
anyOf: [{ type: 'integer', enum: [1, 2, 3] }, { type: 'null' }],
};

const result = combineSchemas({
schema,
name: 'Code',
separator: 'anyOf',
context,
nullable: '',
});

expect(result.isEnum).toBe(true);
expect(result.value).toContain('1 | 2 | 3');
expect(result.value).toContain('null');
});

// Pin behavior for the multi-enum + null variant. Each enum branch
// contributes its values; the result is still a nullable enum union.
it('flags multiple inline enums + null as a nullable enum', () => {
const schema: OpenApiSchemaObject = {
anyOf: [{ enum: ['a', 'b'] }, { enum: ['c', 'd'] }, { type: 'null' }],
};

const result = combineSchemas({
schema,
name: 'Status',
separator: 'anyOf',
context,
nullable: '',
});

expect(result.isEnum).toBe(true);
expect(result.value).toContain(`'a' | 'b'`);
expect(result.value).toContain(`'c' | 'd'`);
expect(result.value).toContain('null');
});

// Negative: a plain nullable string (no enum) must stay a generic union
// and not be flagged as an enum. This is the case the existing
// query-params.test.ts:169 test already pins at the integration level.
it('does not flag anyOf [non-enum scalar, null] as a nullable enum', () => {
const schema: OpenApiSchemaObject = {
anyOf: [{ type: 'string', format: 'uuid' }, { type: 'null' }],
};

const result = combineSchemas({
schema,
name: 'AffiliationId',
separator: 'anyOf',
context,
nullable: '',
});

expect(result.isEnum).toBe(false);
});

// Negative: a `$ref` branch already resolves to an existing named enum
// schema. Treating this composition as an inline-enum would route the
// caller through `getEnum`, which emits a parallel const that nests the
// original ref (e.g. `{Status: Status}`) instead of reusing it.
it('does not flag anyOf [$ref enum, null] as a nullable enum', () => {
const schema: OpenApiSchemaObject = {
anyOf: [{ $ref: '#/components/schemas/Status' }, { type: 'null' }],
};

const result = combineSchemas({
schema,
name: 'Status',
separator: 'anyOf',
context,
nullable: '',
});

expect(result.isEnum).toBe(false);
});

// Negative: `allOf` is an intersection, not a union. `allOf: [{enum}, {null}]`
// is semantically empty (no value can satisfy both); regardless, it must
// not be misclassified as a nullable enum union.
it('does not flag allOf [enum, null] as a nullable enum', () => {
const schema: OpenApiSchemaObject = {
allOf: [{ enum: ['new', 'in_progress'] }, { type: 'null' }],
};

const result = combineSchemas({
schema,
name: 'Status',
separator: 'allOf',
context,
nullable: '',
});

expect(result.isEnum).toBe(false);
});

// Negative: an enum combined with a non-null scalar is a genuine union,
// not a nullable enum. Extracting it as a named enum would change the
// semantics (the other branch's values would be lost).
it('does not flag anyOf [enum, non-null scalar] as a nullable enum', () => {
const schema: OpenApiSchemaObject = {
anyOf: [{ enum: ['new', 'in_progress'] }, { type: 'string' }],
};

const result = combineSchemas({
schema,
name: 'Status',
separator: 'anyOf',
context,
nullable: '',
});

expect(result.isEnum).toBe(false);
});
});

it('normalizes inline object in allOf to match parent object form', () => {
const variantA: OpenApiSchemaObject = {
allOf: [
Expand Down
28 changes: 27 additions & 1 deletion packages/core/src/getters/combine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,32 @@ export function combineSchemas({
}

const isAllEnums = resolvedData.isEnum.every(Boolean);
// OAS 3.1 spells a nullable enum as `anyOf: [{enum: [...]}, {type: 'null'}]`.
// Without this, the {type: 'null'} variant flips `isEnum` to false and the
// enum gets inlined instead of extracted as a named type — the
// {type: ['string','null'], enum: [...]} spelling already extracts. Treat
// null-only variants as transparent so the caller's `isEnum && !isRef`
// branch (query-params, schema-definition, resolvers/object) extracts via
// getEnum, whose `stripNullUnion` handling already preserves the trailing
// ` | null`. See issue #2710.
//
// Guards:
// - `allOf` semantics are intersection, not union — `allOf: [{enum}, {null}]`
// does not describe a nullable enum, so restrict to `anyOf`/`oneOf`.
// - Non-null branches must be inline enums (`!isRef`). For `$ref + null`
// the existing referenced enum should be reused; routing through
// `getEnum` would emit a parallel const that nests the original ref
// (e.g. `{Status: Status}`) instead of spreading or aliasing it.
const isUnionLikeSeparator = separator === 'anyOf' || separator === 'oneOf';
const isNullableEnumComposition =
isUnionLikeSeparator &&
!isAllEnums &&
resolvedData.isEnum.some(Boolean) &&
resolvedData.isEnum.every(
(isEnum, index) =>
(isEnum && !resolvedData.isRef[index]) ||
resolvedData.types[index] === 'null',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const isAvailableToGenerateCombinedEnum =
isAllEnums &&
name &&
Expand Down Expand Up @@ -460,7 +486,7 @@ export function combineSchemas({
dependencies: resolvedValue
? [...resolvedData.dependencies, ...resolvedValue.dependencies]
: resolvedData.dependencies,
isEnum: false,
isEnum: isNullableEnumComposition,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix as the L359 thread — addressed in 399db48. The detection now requires isUnionLikeSeparator (anyOf/oneOf only) and inline enum branches (!resolvedData.isRef[index]).

type: 'object' as SchemaType,
isRef: false,
hasReadonlyProps:
Expand Down
Loading
Loading