diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 47a4e4a..207c925 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -962,7 +962,8 @@ function admitsNull(schema: unknown): boolean { // becomes. A target that holds such a pair, beside annotations that 3.0 may // drop, such as `$comment`, can become its own `$ref` in turn, so a loop of // them, which in 3.1 admits only null, would become `$ref`s that match -// nothing. One that does not admit null keeps its pair. +// nothing. One that does not admit null keeps its pair. A pair nested in the +// target's `anyOf`, as `anyOf: [{ anyOf: [$ref, null] }, null]`, leads on too. function refAdmitsNull(ref: string, ctx: Context): boolean { const seen = new Set() for (let next: string | undefined = ref; next !== undefined;) { @@ -975,12 +976,29 @@ function refAdmitsNull(ref: string, ctx: Context): boolean { } seen.add(next) const { anyOf } = target as Record - const others = Array.isArray(anyOf) ? anyOf.filter((branch) => !acceptsNull(branch)) : [] + const others = Array.isArray(anyOf) ? nonNullBranches(anyOf) : [] next = others.length === 1 ? getRef(others[0]) : undefined } return true } +// Met again, around a cycle of objects, a union adds nothing. +function nonNullBranches(branches: unknown[], seen = new Set()): unknown[] { + if (seen.has(branches)) { + return [] + } + seen.add(branches) + return branches.flatMap((branch) => { + if (acceptsNull(branch)) { + return [] + } + const nested = isRecord(branch) ? (branch.anyOf ?? branch.oneOf) : undefined + return Array.isArray(nested) && nested.some(acceptsNull) + ? nonNullBranches(nested, seen) + : [branch] + }) +} + // A `type` with no `nullable: true` beside it, in 3.1 or 3.0, rejects null. function rejectsNull(schema: unknown): boolean { if (!isRecord(schema) || schema.nullable === true) { 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 64d3d55..888e513 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1374,6 +1374,26 @@ describe('downgradeSchemaV31ToV30', () => { A: { anyOf: [ref('B'), onlyNull] }, B: { description: 'B', anyOf: [ref('A'), onlyNull] }, }) + // So does a pair nested in the target's union. + const nested = { anyOf: [{ anyOf: [ref('A'), onlyNull] }, onlyNull] } + expect(schemas({ A: { anyOf: [pair('A'), { type: 'null' }] } })).toEqual({ A: nested }) + expect(schemas({ A: { anyOf: [{ oneOf: pair('A').anyOf }, { type: 'null' }] } })).toEqual({ + A: nested, + }) + expect( + schemas({ + A: pair('B'), + B: { anyOf: [{ anyOf: [ref('A'), ref('B')] }, { type: 'null' }] }, + }), + ).toEqual({ A: ref('B'), B: { anyOf: [{ anyOf: [ref('A'), ref('B')] }, onlyNull] } }) + expect(schemas({ A: pair('B'), B: { anyOf: [false, ref('A'), { type: 'null' }] } })).toEqual({ + A: ref('B'), + B: { anyOf: [{ not: {} }, ref('A'), onlyNull] }, + }) + // A cycle of objects ends the walk. + const cyclic: Record = { anyOf: [{ type: 'null' }] } + cyclic.anyOf.unshift({ anyOf: [cyclic, { type: 'null' }] }) + expect(schemas({ A: pair('B'), B: cyclic })?.A).toEqual(ref('B')) }) it('merges a branch in full where a cycle meets it before its conversion is done', () => {