Skip to content

Commit 5e9483b

Browse files
committed
1 parent 723eeeb commit 5e9483b

7 files changed

Lines changed: 194 additions & 6 deletions

File tree

datamodel/low/extraction_functions.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/pb33f/libopenapi/utils"
1414
"go.yaml.in/yaml/v4"
1515
"hash/maphash"
16+
"math/big"
1617
"net/url"
1718
"os"
1819
"path/filepath"
@@ -1265,10 +1266,12 @@ func hashNodeTree(h *maphash.Hash, n *yaml.Node, visited map[*yaml.Node]bool) {
12651266
}
12661267
visited[n] = true
12671268

1268-
// Hash node metadata
1269+
// Hash node metadata. Numeric scalars are normalized so semantically equivalent
1270+
// values like `1e-08` and `1e-8` compare equal.
1271+
tag, value := comparableScalarTagAndValue(n)
12691272
h.Write([]byte{byte(n.Kind)})
1270-
h.Write([]byte(n.Tag))
1271-
h.Write([]byte(n.Value))
1273+
h.Write([]byte(tag))
1274+
h.Write([]byte(value))
12721275
if n.Anchor != "" {
12731276
h.Write([]byte(n.Anchor))
12741277
}
@@ -1354,6 +1357,23 @@ func hashNodeTree(h *maphash.Hash, n *yaml.Node, visited map[*yaml.Node]bool) {
13541357
}
13551358
}
13561359

1360+
func comparableScalarTagAndValue(n *yaml.Node) (string, string) {
1361+
if n == nil {
1362+
return "", ""
1363+
}
1364+
if n.Kind != yaml.ScalarNode {
1365+
return n.Tag, n.Value
1366+
}
1367+
if n.Tag != "!!int" && n.Tag != "!!float" {
1368+
return n.Tag, n.Value
1369+
}
1370+
rat, ok := new(big.Rat).SetString(n.Value)
1371+
if !ok {
1372+
return n.Tag, n.Value
1373+
}
1374+
return "!!number", rat.RatString()
1375+
}
1376+
13571377
// CompareYAMLNodes compares two YAML nodes for equality without marshaling to YAML.
13581378
// This reuses the hashNodeTree logic to generate consistent hashes for comparison,
13591379
// avoiding the expensive yaml.Marshal operations that cause massive allocations.

datamodel/low/extraction_functions_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2861,6 +2861,118 @@ func TestCompareYAMLNodes_ComplexNodes(t *testing.T) {
28612861
assert.False(t, result2)
28622862
}
28632863

2864+
func TestComparableScalarTagAndValue(t *testing.T) {
2865+
t.Run("nil node", func(t *testing.T) {
2866+
tag, value := comparableScalarTagAndValue(nil)
2867+
assert.Empty(t, tag)
2868+
assert.Empty(t, value)
2869+
})
2870+
2871+
t.Run("non scalar node", func(t *testing.T) {
2872+
node := utils.CreateEmptyMapNode()
2873+
tag, value := comparableScalarTagAndValue(node)
2874+
assert.Equal(t, node.Tag, tag)
2875+
assert.Equal(t, node.Value, value)
2876+
})
2877+
2878+
t.Run("non numeric scalar", func(t *testing.T) {
2879+
node := utils.CreateStringNode("pizza")
2880+
tag, value := comparableScalarTagAndValue(node)
2881+
assert.Equal(t, "!!str", tag)
2882+
assert.Equal(t, "pizza", value)
2883+
})
2884+
2885+
t.Run("numeric scalar normalizes", func(t *testing.T) {
2886+
node := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: "1e-08"}
2887+
tag, value := comparableScalarTagAndValue(node)
2888+
assert.Equal(t, "!!number", tag)
2889+
assert.Equal(t, "1/100000000", value)
2890+
})
2891+
2892+
t.Run("numeric scalar fallback", func(t *testing.T) {
2893+
node := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: ".inf"}
2894+
tag, value := comparableScalarTagAndValue(node)
2895+
assert.Equal(t, "!!float", tag)
2896+
assert.Equal(t, ".inf", value)
2897+
})
2898+
}
2899+
2900+
func TestCompareYAMLNodes_NumericScalarEquivalence(t *testing.T) {
2901+
tests := []struct {
2902+
name string
2903+
left *yaml.Node
2904+
right *yaml.Node
2905+
equal bool
2906+
}{
2907+
{
2908+
name: "equivalent exponent formatting",
2909+
left: &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: "1e-08"},
2910+
right: &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: "1e-8"},
2911+
equal: true,
2912+
},
2913+
{
2914+
name: "equivalent int and float formatting",
2915+
left: &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: "1"},
2916+
right: &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: "1.0"},
2917+
equal: true,
2918+
},
2919+
{
2920+
name: "different numeric values",
2921+
left: &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: "1e-08"},
2922+
right: &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: "2e-08"},
2923+
equal: false,
2924+
},
2925+
{
2926+
name: "string and numeric stay different",
2927+
left: &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "1.0"},
2928+
right: &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: "1.0"},
2929+
equal: false,
2930+
},
2931+
}
2932+
2933+
for _, tt := range tests {
2934+
t.Run(tt.name, func(t *testing.T) {
2935+
assert.Equal(t, tt.equal, CompareYAMLNodes(tt.left, tt.right))
2936+
})
2937+
}
2938+
}
2939+
2940+
func TestCompareYAMLNodes_ComplexNodesNumericEquivalence(t *testing.T) {
2941+
left := &yaml.Node{
2942+
Kind: yaml.MappingNode,
2943+
Content: []*yaml.Node{
2944+
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "default"},
2945+
{Kind: yaml.ScalarNode, Tag: "!!float", Value: "0.10"},
2946+
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "items"},
2947+
{
2948+
Kind: yaml.SequenceNode,
2949+
Content: []*yaml.Node{
2950+
{Kind: yaml.ScalarNode, Tag: "!!int", Value: "1"},
2951+
{Kind: yaml.ScalarNode, Tag: "!!float", Value: "1e-08"},
2952+
},
2953+
},
2954+
},
2955+
}
2956+
2957+
right := &yaml.Node{
2958+
Kind: yaml.MappingNode,
2959+
Content: []*yaml.Node{
2960+
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "items"},
2961+
{
2962+
Kind: yaml.SequenceNode,
2963+
Content: []*yaml.Node{
2964+
{Kind: yaml.ScalarNode, Tag: "!!float", Value: "1.0"},
2965+
{Kind: yaml.ScalarNode, Tag: "!!float", Value: "1e-8"},
2966+
},
2967+
},
2968+
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "default"},
2969+
{Kind: yaml.ScalarNode, Tag: "!!float", Value: "0.1"},
2970+
},
2971+
}
2972+
2973+
assert.True(t, CompareYAMLNodes(left, right))
2974+
}
2975+
28642976
func TestGenerateHashString_SchemaProxyNoCache(t *testing.T) {
28652977
// Test that SchemaProxy types don't get cached (shouldCache = false)
28662978
// We can't easily test this without creating actual SchemaProxy objects

datamodel/low/v3/paths_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,8 @@ func TestPaths_Build_BrokenOp(t *testing.T) {
523523
}
524524

525525
func TestPaths_Hash(t *testing.T) {
526+
low.ClearHashCache()
527+
526528
yml := `/french/toast:
527529
description: toast
528530
/french/hen:

document_examples_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ func TestExampleCompareDocuments_openAPI(t *testing.T) {
315315
schemaChanges := documentChanges.ComponentsChanges.SchemaChanges
316316

317317
// Print out some interesting stats about the OpenAPI document changes.
318-
assert.Equal(t, `There are 77 changes, of which 19 are breaking. 6 schemas have changes.`, fmt.Sprintf("There are %d changes, of which %d are breaking. %v schemas have changes.",
318+
assert.Equal(t, `There are 77 changes, of which 20 are breaking. 6 schemas have changes.`, fmt.Sprintf("There are %d changes, of which %d are breaking. %v schemas have changes.",
319319
documentChanges.TotalChanges(), documentChanges.TotalBreakingChanges(), len(schemaChanges)))
320320
}
321321

what-changed/model/comparison_functions.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,8 +386,10 @@ func checkForModificationInternal[T any](l, r *yaml.Node, label string, changes
386386
if withEncoding {
387387
createFn = CreateChangeWithEncoding
388388
}
389-
if l != nil && l.Value != EMPTY_STR && r != nil && r.Value != EMPTY_STR && (r.Value != l.Value || r.Tag != l.Tag) {
390-
createFn(changes, Modified, label, l, r, breaking, orig, new)
389+
if l != nil && l.Value != EMPTY_STR && r != nil && r.Value != EMPTY_STR {
390+
if !low.CompareYAMLNodes(l, r) {
391+
createFn(changes, Modified, label, l, r, breaking, orig, new)
392+
}
391393
return
392394
}
393395
if l != nil && utils.IsNodeArray(l) && r != nil && !utils.IsNodeArray(r) {

what-changed/model/comparison_functions_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ func Test_CheckForModification(t *testing.T) {
2424
{"Same string quoted", `value`, `"value"`, false},
2525
{"Same string", `value`, `value`, false},
2626
{"Same boolean", `true`, `true`, false},
27+
{"Equivalent exponent formatting", `1e-08`, `1e-8`, false},
28+
{"Equivalent integer and float formatting", `1`, `1.0`, false},
29+
{"Equivalent decimal formatting", `0.10`, `0.1`, false},
2730
{"Different boolean", `true`, `false`, true},
2831
{"Different string", `value_a`, `value_b`, true},
2932
{"Different int", `123`, `"123"`, true},
@@ -576,6 +579,17 @@ func TestCheckForModificationWithEncoding(t *testing.T) {
576579
}
577580
}
578581

582+
func TestCheckForModificationWithEncoding_NumericScalarEquivalence(t *testing.T) {
583+
var lNode, rNode yaml.Node
584+
_ = yaml.Unmarshal([]byte(`1e-08`), &lNode)
585+
_ = yaml.Unmarshal([]byte(`1e-8`), &rNode)
586+
587+
changes := []*Change{}
588+
CheckForModificationWithEncoding(lNode.Content[0], rNode.Content[0], "numeric", &changes, false, "old", "new")
589+
590+
assert.Empty(t, changes)
591+
}
592+
579593
// TestCheckMapForChangesWithComp tests the deprecated CheckMapForChangesWithComp function
580594
func TestCheckMapForChangesWithComp(t *testing.T) {
581595
t.Run("detects removal", func(t *testing.T) {

what-changed/model/schema_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3911,6 +3911,44 @@ components:
39113911
changes.PropertiesOnly() // this does nothing in this lib.
39123912
}
39133913

3914+
// https://github.com/pb33f/openapi-changes/issues/207
3915+
func TestCompareSchemas_DefaultNumericFormattingIsSemanticallyEqual(t *testing.T) {
3916+
low.ClearHashCache()
3917+
left := `openapi: 3.0.0
3918+
components:
3919+
schemas:
3920+
Config:
3921+
type: object
3922+
properties:
3923+
angle_threshold:
3924+
type: number
3925+
exclusiveMinimum: 0.0
3926+
title: Angle Threshold
3927+
default: 1e-08
3928+
`
3929+
3930+
right := `openapi: 3.0.0
3931+
components:
3932+
schemas:
3933+
Config:
3934+
type: object
3935+
properties:
3936+
angle_threshold:
3937+
type: number
3938+
exclusiveMinimum: 0.0
3939+
title: Angle Threshold
3940+
default: 1e-8
3941+
`
3942+
3943+
leftDoc, rightDoc := test_BuildDoc(left, right)
3944+
3945+
lSchemaProxy := leftDoc.Components.Value.FindSchema("Config").Value
3946+
rSchemaProxy := rightDoc.Components.Value.FindSchema("Config").Value
3947+
3948+
changes := CompareSchemas(lSchemaProxy, rSchemaProxy)
3949+
assert.Nil(t, changes)
3950+
}
3951+
39143952
func TestCompareSchemas_ExclusiveMaximumNodeSwap(t *testing.T) {
39153953
// Clear hash cache to ensure deterministic results in concurrent test environments
39163954
low.ClearHashCache()

0 commit comments

Comments
 (0)