diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index b61d86e..21896ea 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -182,7 +182,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`type`][js-type] with `"null"` | `nullable: true` beside the other type. `type: "null"` alone becomes `type: "string", nullable: true, enum: [null]`, since 3.0 allows `nullable` only beside `type`, and code generators drop a bare `enum: [null]`. | | [`type`][js-type] with several types | `anyOf` with one branch per type, which takes `items` for `"array"`. `"null"` gets its own branch, written as for `type: "null"` alone, since code generators ignore `nullable` inside an `anyOf` branch. | -| An [`anyOf`][js-any-of] of `{ type: "null" }` and one other branch, as Zod writes a nullable object, array, integer, enum, or `$ref` | That branch with `nullable: true` in place of the `anyOf` when it has a single `type`, since code generators read the pair as a union. A branch with a `$ref`, an `enum`, `allOf`, `anyOf`, `oneOf`, or `not` stays, since `nullable` would not let null through those, and so does one that already has `nullable` or shares a keyword with the schema around it. The pair becomes a `oneOf` when that branch has a `type` and no `nullable`, or is a `$ref` to such a schema, since null then matches one branch only, and NSwag reads that as nullable but the `anyOf` as an empty class. | +| An [`anyOf`][js-any-of] of `{ type: "null" }` and one other branch, as Zod writes a nullable object, array, integer, enum, or `$ref`, and Effect a nullish one with `{ type: "null" }` twice | That branch with `nullable: true` in place of the `anyOf` when it has a single `type`, since code generators read the pair as a union. A branch with a `$ref`, an `enum`, `allOf`, `anyOf`, `oneOf`, or `not` stays, since `nullable` would not let null through those, and so does one that already has `nullable` or shares a keyword with the schema around it. The pair becomes a `oneOf` when that branch has a `type` and no `nullable`, or is a `$ref` to such a schema, since null then matches one branch only, and NSwag reads that as nullable but the `anyOf` as an empty class. | | [`const`][js-const] | `enum` with its one value, and the `type` of that value when it has none, since code generators drop or leave untyped a bare `enum`. `const: null` becomes the same schema as `type: "null"`. | | Numeric [`exclusiveMinimum`][js-exclusive-minimum] and [`exclusiveMaximum`][js-exclusive-maximum] | `minimum` or `maximum` with a boolean `exclusiveMinimum` or `exclusiveMaximum`, unless `minimum` or `maximum` is stricter. | | [`examples`][js-examples] | The first entry fills `example` when that is missing. | diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index b478c9b..7e57e40 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -124,10 +124,10 @@ const IN_PLACE_APPLICATORS = [ // Converted schemas that lost a restriction, directly or in a subschema. const LOOSE = new WeakSet() -// Converted schemas with an `anyOf` of two branches, for `mergeNullable`. It -// runs once the whole input is converted, since a cycle can meet a branch +// Converted schemas with an `anyOf` of several branches, for `mergeNullable`. +// It runs once the whole input is converted, since a cycle can meet a branch // before its conversion is done. -const twoBranchAnyOfs: [schema: Record, input: object][] = [] +const nullableAnyOfs: [schema: Record, input: object][] = [] // Input schemas whose `anyOf` a `$ref` kept as written points into. They keep // it, so the `$ref` still resolves. @@ -514,9 +514,11 @@ function rejectsNull(schema: unknown): boolean { // rejects null, as null then matches one branch only. NSwag reads that // `oneOf` as nullable, and the `anyOf` as an empty class. function mergeNullable(out: Record, root: unknown): void { - const [first, second] = out.anyOf as unknown[] - const other = isOnlyNullSchema(first) ? second : isOnlyNullSchema(second) ? first : undefined - if (!isRecord(other)) { + const branches = out.anyOf as unknown[] + // Effect writes null twice for a nullish value, once for `undefined`. + const others = branches.filter((branch) => !isOnlyNullSchema(branch)) + const [other] = others + if (others.length !== 1 || !isRecord(other)) { return } if ( @@ -530,7 +532,9 @@ function mergeNullable(out: Record, root: unknown): void { // A `$ref` kept as written rejects null when its target does. const ref = getRef(other) if (rejectsNull(ref === undefined ? other : find(root, ref))) { - out.oneOf = out.anyOf + // Null once, since `oneOf` rejects a value that matches two branches. + const nullBranch = branches.find(isOnlyNullSchema) + out.oneOf = branches[0] === other ? [other, nullBranch] : [nullBranch, other] delete out.anyOf } } @@ -667,8 +671,8 @@ function finishSchema( delete out[key] } } - if (Array.isArray(out.anyOf) && out.anyOf.length === 2) { - twoBranchAnyOfs.push([out, schema]) + if (Array.isArray(out.anyOf) && out.anyOf.length > 1) { + nullableAnyOfs.push([out, schema]) } if (loose) { LOOSE.add(out) @@ -796,7 +800,7 @@ function convertDocument(value: unknown, ctx: Context): unknown { function downgradeV30(root: unknown, convert: Convert): unknown { try { const out = downgrade(root, convert) - for (const [schema, input] of twoBranchAnyOfs) { + for (const [schema, input] of nullableAnyOfs) { if (!anyOfTargets.has(input)) { mergeNullable(schema, root) } @@ -804,7 +808,7 @@ function downgradeV30(root: unknown, convert: Convert): unknown { return out } finally { // Cleared even when conversion throws, so nothing keeps the input alive. - twoBranchAnyOfs.length = 0 + nullableAnyOfs.length = 0 anyOfTargets.clear() } } diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index 629c267..6f760e9 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -520,6 +520,47 @@ describe('downgradeSchemaV31ToV30', () => { type: 'string', nullable: true, }) + // As Effect writes a nullish value, with null twice. + expect( + downgradeSchemaV31ToV30({ + anyOf: [{ type: 'string' }, { type: 'null' }, { type: 'null' }], + }), + ).toEqual({ type: 'string', nullable: true }) + // One branch holds no null to merge, as Valibot writes a union of one schema. + expect(downgradeSchemaV31ToV30({ anyOf: [{ type: 'string' }] })).toEqual({ + anyOf: [{ type: 'string' }], + }) + expect( + downgradeSchemaV31ToV30({ + anyOf: [{ type: 'null' }, { type: 'string', enum: ['a'] }, { type: 'null' }], + }), + ).toEqual({ + oneOf: [ + { type: 'string', nullable: true, enum: [null] }, + { type: 'string', enum: ['a'] }, + ], + }) + // One null object twice still gives null once, or the oneOf rejects it. + const nullType: OpenAPIV3_1.SchemaObject = { type: 'null' } + expect( + downgradeSchemaV31ToV30({ anyOf: [{ type: 'string', enum: ['a'] }, nullType, nullType] }), + ).toEqual({ + oneOf: [ + { type: 'string', enum: ['a'] }, + { type: 'string', nullable: true, enum: [null] }, + ], + }) + expect( + downgradeSchemaV31ToV30({ + anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'null' }], + }), + ).toEqual({ + anyOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'string', nullable: true, enum: [null] }, + ], + }) }) it('keeps an anyOf with null whose other branch null would not pass, or shares a key', () => { @@ -561,11 +602,6 @@ describe('downgradeSchemaV31ToV30', () => { anyOf: [{ type: 'string', enum: ['a'] }, onlyNull], oneOf: [{ minLength: 1 }, { maxLength: 0 }], }) - expect( - downgradeSchemaV31ToV30({ - anyOf: [{ type: 'string' }, { type: 'null' }, { type: 'null' }], - }), - ).toEqual({ anyOf: [{ type: 'string' }, onlyNull, onlyNull] }) // A null branch with more than its type is not only null. expect( downgradeSchemaV31ToV30({