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
42 changes: 42 additions & 0 deletions issue611_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley
// SPDX-License-Identifier: MIT

package libopenapi

import (
"fmt"
"testing"

"github.com/pb33f/testify/assert"
"github.com/pb33f/testify/require"
)

func TestIssue611CompareDocumentsObjectComposition(t *testing.T) {
const object = `{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}}`
const nullable = `{"type":["object","null"],"required":["id"],"properties":{"id":{"type":"string"}}}`
const stringUnion = `{"type":["object","string"],"required":["id"],"properties":{"id":{"type":"string"}}}`
const spec = `{"openapi":"3.1.0","info":{"title":"Composition","version":"1.0.0"},"paths":{},"components":{"schemas":{"Entry":%s}}}`
for _, tc := range []struct {
name, old, next string
changed, breaking bool
}{
{"equivalent nullable refactor", nullable, `{"oneOf":[` + object + `,{"type":"null"}]}`, false, false},
{"array widening", object, `{"anyOf":[` + object + `,{"type":"array"}]}`, true, false},
{"null widening", object, `{"oneOf":[` + object + `,{"type":"null"}]}`, true, false},
{"overlapping oneOf", object, `{"oneOf":[` + object + `,{"type":"object"}]}`, true, true},
{"wrapper constraint", object, `{"anyOf":[` + object + `,{"type":"null"}],"maxProperties":0}`, true, true},
{"dropped string alternative", stringUnion, `{"anyOf":[` + object + `,{"type":"null"}]}`, true, true},
{"narrowed object enum", `{"type":"object","properties":{"id":{"type":"string"}},"enum":[{"id":"a"},{"id":"b"}]}`, `{"anyOf":[{"type":"object","properties":{"id":{"type":"string"}},"enum":[{"id":"a"}]},{"type":"null"}]}`, true, true},
} {
t.Run(tc.name, func(t *testing.T) {
left, err := NewDocument([]byte(fmt.Sprintf(spec, tc.old)))
require.NoError(t, err)
right, err := NewDocument([]byte(fmt.Sprintf(spec, tc.next)))
require.NoError(t, err)
changes, err := CompareDocuments(left, right)
require.NoError(t, err)
assert.Equal(t, tc.changed, changes.TotalChanges() > 0)
assert.Equal(t, tc.breaking, changes.TotalBreakingChanges() > 0)
})
}
}
10 changes: 8 additions & 2 deletions what-changed/model/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -496,10 +496,15 @@ func CompareSchemas(l, r *base.SchemaProxy) *SchemaChanges {

lSchema := l.Schema()
rSchema := r.Schema()
preserved := preservedObjectBranch(l, r, lSchema, rSchema)
if preserved != nil {
checkObjectCompositionAlternatives(lSchema, rSchema, preserved, &changes)
rSchema = preserved
}
comparisonLSchema := schemaComparisonViewForSimpleAllOfObject(l, lSchema)
comparisonRSchema := schemaComparisonViewForSimpleAllOfObject(r, rSchema)

if low.AreEqual(lSchema, rSchema) {
if low.AreEqual(lSchema, rSchema) && len(changes) == 0 {
// there is no point going on, we know nothing changed!
return nil
}
Expand All @@ -513,7 +518,8 @@ func CompareSchemas(l, r *base.SchemaProxy) *SchemaChanges {
skipSimpleScalarUnionDiff := schemasUseEquivalentSimpleScalarUnion(l, r)

// check schema core properties for changes.
checkSchemaPropertyChanges(comparisonLSchema, comparisonRSchema, l, r, &changes, sc, skipSimpleScalarUnionDiff)
checkSchemaPropertyChanges(comparisonLSchema, comparisonRSchema, l, r, &changes, sc,
skipSimpleScalarUnionDiff || preserved != nil)

// now for the confusing part, there is also a schema's 'properties' property to parse.
// inception, eat your heart out.
Expand Down
152 changes: 152 additions & 0 deletions what-changed/model/schema_composition_wrap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley
// SPDX-License-Identifier: MIT

package model

import (
"github.com/pb33f/libopenapi/datamodel/low"
"github.com/pb33f/libopenapi/datamodel/low/base"
"github.com/pb33f/libopenapi/datamodel/low/v3"
)

// preservedObjectBranch returns a comparison view for an object moved into a
// composition. Uncertain wrappers retain the normal structural comparison.
func preservedObjectBranch(l, r *base.SchemaProxy, old, wrapper *base.Schema) *base.Schema {
if l.IsReference() || r.IsReference() || old == nil || wrapper == nil ||
old.Properties.Value == nil || old.Properties.Value.Len() == 0 || hasObjectComposition(old) {
return nil
}
oldNull, ok := objectOrNullType(old)
if !ok {
return nil
}
branches := wrapper.AnyOf.Value
oneOf := schemaNodeHasSingleKey(r.GetValueNode(), v3.OneOfLabel)
if oneOf {
branches = wrapper.OneOf.Value
if len(branches) != 2 {
return nil
}
} else if !schemaNodeHasSingleKey(r.GetValueNode(), v3.AnyOfLabel) {
return nil
}

var selected *base.Schema
hasNull := false
for _, entry := range branches {
proxy := entry.Value
if proxy == nil || base.CheckSchemaProxyForCircularRefs(proxy) {
return nil
}
branch := proxy.Schema()
if branch == nil {
return nil
}
// A pure null alternative proves null admission without evaluating a schema.
if !proxy.IsReference() && schemaNodeHasSingleKey(proxy.GetValueNode(), v3.TypeLabel) &&
branch.Type.Value.A == "null" {
hasNull = true
continue
}
branchNull, object := objectOrNullType(branch)
if object && !branchNull && !hasObjectComposition(branch) {
if selected != nil {
return nil
}
selected = branch
} else if oneOf {
// Only object versus null is proven disjoint here. Other oneOf
// alternatives can reject values by matching more than once.
return nil
}
}
if (oldNull || oneOf) && !hasNull {
return nil
}
// The structural comparison must remain when root value constraints change:
// the ordinary enum comparator cannot distinguish object-valued entries.
if selected != nil && (low.GenerateHashString(old.Enum.ValueNode) != low.GenerateHashString(selected.Enum.ValueNode) ||
low.GenerateHashString(old.Const.ValueNode) != low.GenerateHashString(selected.Const.ValueNode)) {
return nil
}
return selected
}

// objectOrNullType accepts only explicit object types, optionally with null.
// Other unions and OAS 3.0 nullable need a wider proof than this shortcut makes.
func objectOrNullType(schema *base.Schema) (nullable, ok bool) {
if schema.Nullable.Value || schema.Type.IsEmpty() {
return false, false
}
if schema.Type.Value.IsA() {
return false, schema.Type.Value.A == "object"
}
object := false
for _, value := range schema.Type.Value.B {
switch value.Value {
case "object":
object = true
case "null":
nullable = true
default:
return false, false
}
}
return nullable, object
}

func hasObjectComposition(schema *base.Schema) bool {
return len(schema.OneOf.Value) > 0 || len(schema.AnyOf.Value) > 0 ||
len(schema.AllOf.Value) > 0 || len(schema.PrefixItems.Value) > 0 || schema.Not.Value != nil
}

// Keep genuine widening visible while suppressing the moved object's removal.
func checkObjectCompositionAlternatives(old, wrapper, selected *base.Schema, changes *[]*Change) {
branches, label := wrapper.AnyOf.Value, v3.AnyOfLabel
if len(wrapper.OneOf.Value) > 0 {
branches, label = wrapper.OneOf.Value, v3.OneOfLabel
}
oldNull, _ := objectOrNullType(old)
oldNull = oldNull && objectConstraintsAdmitNull(old)
for _, entry := range branches {
proxy := entry.Value
branch := proxy.Schema()
if branch == selected || (oldNull && !proxy.IsReference() &&
schemaNodeHasSingleKey(proxy.GetValueNode(), v3.TypeLabel) && branch.Type.Value.A == "null") {
continue
}
CreateChange(changes, ObjectAdded, label, nil, proxy.GetValueNode(),
schemaCompositionChangeBreaking(label, ObjectAdded), nil, proxy)
}
}

// Object assertions do not restrict null. Other assertions need an explicit
// proof before an added null alternative can be treated as already present.
var nullableObjectKeys = map[string]struct{}{
v3.TypeLabel: {}, v3.PropertiesLabel: {}, v3.RequiredLabel: {},
v3.AdditionalPropertiesLabel: {}, v3.PatternPropertiesLabel: {},
v3.PropertyNamesLabel: {}, v3.MinPropertiesLabel: {}, v3.MaxPropertiesLabel: {},
v3.DependentSchemasLabel: {}, base.DependentRequiredLabel: {}, v3.UnevaluatedPropertiesLabel: {},
v3.TitleLabel: {}, v3.DescriptionLabel: {}, v3.DefaultLabel: {},
v3.ExampleLabel: {}, v3.ExamplesLabel: {}, v3.DeprecatedLabel: {},
v3.ReadOnlyLabel: {}, v3.WriteOnlyLabel: {}, v3.XMLLabel: {}, v3.ExternalDocsLabel: {},
v3.EnumLabel: {}, v3.ConstLabel: {},
}

func objectConstraintsAdmitNull(schema *base.Schema) bool {
if !schemaNodeHasOnlyAllowedKeys(schema.RootNode, nullableObjectKeys) {
return false
}
if schema.Const.Value != nil && schema.Const.Value.Tag != "!!null" {
return false
}
if !schema.Enum.IsEmpty() {
for _, value := range schema.Enum.Value {
if value.Value.Tag == "!!null" {
return true
}
}
return false
}
return true
}
Loading
Loading