mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-09-13 06:09:21 -04:00
175 lines
5.2 KiB
Go
175 lines
5.2 KiB
Go
package mapping
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
// ErrManualActionRequired marks schema changes that cannot be applied in place.
|
|
var ErrManualActionRequired = errors.New("manual action required")
|
|
|
|
// ManualActionRequiredError reports a breaking schema change. Only reachable in
|
|
// development: a released instance versions the index by search.SchemaVersion,
|
|
// so a bump builds a fresh index instead.
|
|
func ManualActionRequiredError(index string, reasons []string) error {
|
|
return fmt.Errorf(
|
|
"%w: the search mapping in code differs from index %s in a breaking way:\n - %s\n"+
|
|
"bump search.SchemaVersion to build a fresh index, or revert the mapping change",
|
|
ErrManualActionRequired, index, strings.Join(reasons, "\n - "),
|
|
)
|
|
}
|
|
|
|
type Verdict string
|
|
|
|
const (
|
|
VerdictEqual Verdict = "equal"
|
|
VerdictAdditive Verdict = "additive"
|
|
VerdictBreaking Verdict = "breaking"
|
|
)
|
|
|
|
// Classification is the outcome of diffing a stored index schema against the
|
|
// schema generated from code.
|
|
type Classification struct {
|
|
Verdict Verdict
|
|
// NewFields are dotted paths of fields that only exist in the code schema.
|
|
NewFields []string
|
|
// Reasons are human-readable breaking differences.
|
|
Reasons []string
|
|
}
|
|
|
|
// AddBreaking records engine-specific breaking reasons (e.g. analyzer drift)
|
|
// found outside the properties tree and forces the verdict to breaking. It is a
|
|
// no-op when reasons is empty, so callers can pass their findings unconditionally.
|
|
func (c *Classification) AddBreaking(reasons ...string) {
|
|
if len(reasons) == 0 {
|
|
return
|
|
}
|
|
c.Verdict = VerdictBreaking
|
|
// clone so we never append into the caller's variadic slice
|
|
c.Reasons = append(slices.Clone(reasons), c.Reasons...)
|
|
}
|
|
|
|
// Classify recursively compares a stored `properties` tree against the one from
|
|
// code (both generic JSON-decoded, not marshaled structs). dataFields reports
|
|
// whether a code-only field already holds data in the index (bleve dynamic
|
|
// fields make a new field breaking); engines without that blind spot pass nil.
|
|
func Classify(stored, code map[string]any, dataFields func(path string) bool) Classification {
|
|
c := Classification{Verdict: VerdictEqual}
|
|
classifyProperties(stored, code, dataFields, "", &c)
|
|
if c.Verdict == VerdictEqual && len(c.NewFields) > 0 {
|
|
c.Verdict = VerdictAdditive
|
|
}
|
|
return c
|
|
}
|
|
|
|
func classifyProperties(stored, code map[string]any, dataFields func(string) bool, prefix string, c *Classification) {
|
|
for _, k := range slices.Sorted(maps.Keys(stored)) {
|
|
path := joinPath(prefix, k)
|
|
codeNode, ok := code[k]
|
|
if !ok {
|
|
c.breaking(fmt.Sprintf("field %s exists in the index but not in the code schema (removed or renamed)", path))
|
|
continue
|
|
}
|
|
classifyNode(stored[k], codeNode, dataFields, path, c)
|
|
}
|
|
|
|
for _, k := range slices.Sorted(maps.Keys(code)) {
|
|
if _, ok := stored[k]; ok {
|
|
continue
|
|
}
|
|
path := joinPath(prefix, k)
|
|
if dataFields != nil && dataFields(path) {
|
|
c.breaking(fmt.Sprintf("field %s is explicitly mapped now, but the index already holds data that was indexed dynamically for it, of an unknown type", path))
|
|
continue
|
|
}
|
|
c.NewFields = append(c.NewFields, leafPaths(code[k], path)...)
|
|
}
|
|
}
|
|
|
|
func classifyNode(stored, code any, dataFields func(string) bool, path string, c *Classification) {
|
|
storedMap, sOK := stored.(map[string]any)
|
|
codeMap, cOK := code.(map[string]any)
|
|
if !sOK || !cOK {
|
|
if !reflect.DeepEqual(stored, code) {
|
|
c.breaking(fmt.Sprintf("field %s changed: index %s, code %s", path, compactJSON(stored), compactJSON(code)))
|
|
}
|
|
return
|
|
}
|
|
|
|
for _, k := range SortedUnionKeys(storedMap, codeMap) {
|
|
if k == "properties" {
|
|
continue
|
|
}
|
|
sv, sHas := storedMap[k]
|
|
cv, cHas := codeMap[k]
|
|
if sHas && cHas && reflect.DeepEqual(sv, cv) {
|
|
continue
|
|
}
|
|
c.breaking(fmt.Sprintf("field %s: %s changed: index %s, code %s", path, k, optJSON(sv, sHas), optJSON(cv, cHas)))
|
|
}
|
|
|
|
storedProps, _ := storedMap["properties"].(map[string]any)
|
|
codeProps, _ := codeMap["properties"].(map[string]any)
|
|
if len(storedProps) > 0 || len(codeProps) > 0 {
|
|
classifyProperties(storedProps, codeProps, dataFields, path, c)
|
|
}
|
|
}
|
|
|
|
// leafPaths lists the dotted paths of all leaf fields at or below node.
|
|
func leafPaths(node any, path string) []string {
|
|
if nodeMap, ok := node.(map[string]any); ok {
|
|
if props, ok := nodeMap["properties"].(map[string]any); ok && len(props) > 0 {
|
|
var leaves []string
|
|
for _, k := range slices.Sorted(maps.Keys(props)) {
|
|
leaves = append(leaves, leafPaths(props[k], path+"."+k)...)
|
|
}
|
|
return leaves
|
|
}
|
|
}
|
|
return []string{path}
|
|
}
|
|
|
|
func (c *Classification) breaking(reason string) {
|
|
c.Verdict = VerdictBreaking
|
|
c.Reasons = append(c.Reasons, reason)
|
|
}
|
|
|
|
func joinPath(prefix, k string) string {
|
|
if prefix == "" {
|
|
return k
|
|
}
|
|
return prefix + "." + k
|
|
}
|
|
|
|
// SortedUnionKeys returns the sorted union of the keys of a and b.
|
|
func SortedUnionKeys(a, b map[string]any) []string {
|
|
keys := slices.Collect(maps.Keys(a))
|
|
for k := range b {
|
|
if _, ok := a[k]; !ok {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
slices.Sort(keys)
|
|
return keys
|
|
}
|
|
|
|
func optJSON(v any, present bool) string {
|
|
if !present {
|
|
return "(unset)"
|
|
}
|
|
return compactJSON(v)
|
|
}
|
|
|
|
func compactJSON(v any) string {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
return string(b)
|
|
}
|