this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

encoding/jsonschema: recognize pattern constraints in Generate

We can now generate a `patternProperties` keyword from pattern
constraints. Note that we need to do a bit of work to remove the
constraints from regular fields; for example if we look up the field `x`
in `{[_]: =~"^y", x: string}`, we'll see `string & =~"^y"`. These
constraints are redundant and we don't want to see them in the resulting
JSON Schema, so remove any redundant elements. Initially I tried doing
this with the CUE conjuncts before generating the JSON Schema items, but
it proved too difficult to determine identical values, so we now do it
at the JSON Schema level. The result could be considered a little _too_
minimal from some points of view, but it's correct, and we could decide
to keep some conjuncts denormalized in the future (for example the field
type).

The comparison logic is rather inefficient for now, but a fix for that
is coming in the next few CLs.

Signed-off-by: Roger Peppe <rogpeppe@gmail.com>
Change-Id: Ibf954e1d964848f1df1d587d1c3e57cc56af6845
Reviewed-on: https://review.gerrithub.io/c/cue-lang/cue/+/1224508
Reviewed-by: Daniel Martí <mvdan@mvdan.cc>
Unity-Result: CUE porcuepine <cue.porcuepine@gmail.com>
TryBot-Result: CUEcueckoo <cueckoo@cuelang.org>

+219 -43
+2 -2
encoding/jsonschema/external_teststats.txt
··· 9 9 10 10 v3-roundtrip: 11 11 schema extract (pass / total): 233 / 1363 = 17.1% 12 - tests (pass / total): 814 / 4803 = 16.9% 13 - tests on extracted schemas (pass / total): 814 / 900 = 90.4% 12 + tests (pass / total): 815 / 4803 = 17.0% 13 + tests on extracted schemas (pass / total): 815 / 900 = 90.6% 14 14 15 15 Optional tests: 16 16
+139 -17
encoding/jsonschema/generate.go
··· 15 15 package jsonschema 16 16 17 17 import ( 18 - "cmp" 19 18 "fmt" 20 19 "iter" 21 20 "maps" 22 21 "reflect" 22 + "regexp" 23 23 "slices" 24 24 "strings" 25 25 "time" ··· 150 150 return it1 151 151 default: 152 152 return it.apply(mergeAllOf) 153 + } 154 + } 155 + 156 + func conjuncts(it item) iter.Seq[item] { 157 + return func(yield func(item) bool) { 158 + it1, ok := it.(*itemAllOf) 159 + if !ok { 160 + yield(it) 161 + return 162 + } 163 + yieldSiblings(it1, yield) 153 164 } 154 165 } 155 166 ··· 788 799 } 789 800 790 801 func (g *generator) makeStructItem(v cue.Value) item { 791 - var props itemProperties 802 + props := itemProperties{ 803 + properties: make(map[string]item), 804 + } 792 805 793 - ellipsis := v.LookupPath(cue.MakePath(cue.AnyString)) 794 - if ellipsis.Exists() { 795 - // All fields are explicitly allowed (either with `...` or `[_]: T`) 796 - props.additionalProperties = g.makeItem(ellipsis) 797 - if _, ok := props.additionalProperties.(*itemTrue); ok && !g.cfg.ExplicitOpen { 806 + explicitlyOpen := func(constraint item) { 807 + props.additionalProperties = constraint 808 + if _, ok := constraint.(*itemTrue); ok && !g.cfg.ExplicitOpen { 798 809 // additionalProperties: true is a no-op in JSON Schema in general 799 810 // so omit it unless we're explicitly opening up schemas. 800 811 props.additionalProperties = nil 801 812 } 813 + } 814 + 815 + ellipsis := v.LookupPath(cue.MakePath(cue.AnyString)) 816 + if ellipsis.Exists() { 817 + // All fields are explicitly allowed (either with `...` or `[_]: T`) 818 + explicitlyOpen(g.makeItem(ellipsis)) 802 819 } else if v.IsClosed() && !g.cfg.ExplicitOpen { 803 820 props.additionalProperties = &itemFalse{} 804 821 } 805 822 806 - // TODO include pattern constraints in the results when that's implemented 807 - iter, err := v.Fields(cue.Optional(true)) 823 + iter, err := v.Fields(cue.Optional(true), cue.Patterns(true)) 808 824 if err != nil { 809 825 g.addError(v, err) 810 826 return &itemFalse{} 811 827 } 828 + type pat struct { 829 + pattern *regexp.Regexp 830 + constraint item 831 + } 832 + var patternConstraints []pat 833 + outer: 812 834 for iter.Next() { 813 835 sel := iter.Selector() 814 836 switch sel.ConstraintType() { 837 + case cue.PatternConstraint: 838 + re, ok := regexpForValue(sel.Pattern()) 839 + if ok { 840 + if props.patternProperties == nil { 841 + props.patternProperties = make(map[string]item) 842 + } 843 + constraint := g.makeItem(iter.Value()) 844 + props.patternProperties[re.String()] = constraint 845 + patternConstraints = append(patternConstraints, pat{re, constraint}) 846 + } else { 847 + // We can't express the constraint in JSON Schema, and it 848 + // might cover any number of possible labels, so the 849 + // only thing we can do is treat the whole thing as explicitly 850 + // open. 851 + explicitlyOpen(&itemTrue{}) 852 + } 853 + continue outer 815 854 case cue.OptionalConstraint: 816 855 case cue.RequiredConstraint: 817 856 props.required = append(props.required, sel.Unquoted()) ··· 823 862 props.required = append(props.required, sel.Unquoted()) 824 863 } 825 864 } 826 - props.elems = append(props.elems, property{ 827 - name: sel.Unquoted(), 828 - item: g.makeItem(iter.Value()), 829 - }) 865 + propItem := g.makeItem(iter.Value()) 866 + fieldName := sel.Unquoted() 867 + if len(patternConstraints) == 0 { 868 + props.properties[fieldName] = propItem 869 + continue 870 + } 871 + // There are pattern constraints which will have been unified in with 872 + // the constraints of any matching field. They're redundant with 873 + // respect to patternProperties, so remove them. 874 + // This has the potential to remove explicit constraints on the fields 875 + // themselves, but this will not change behavior, just result in a slightly 876 + // smaller resulting schema. 877 + allof, ok := propItem.(*itemAllOf) 878 + if !ok || len(allof.elems) <= 1 { 879 + // No possibility of removing any conjuncts. 880 + props.properties[fieldName] = propItem 881 + continue 882 + } 883 + var elems []item 884 + for _, c := range patternConstraints { 885 + if !c.pattern.MatchString(fieldName) { 886 + continue 887 + } 888 + if elems == nil { 889 + elems = slices.Collect(siblings(allof)) 890 + } 891 + // We've found a pattern constraint that unifies with the field name. 892 + // Its constraint will have been added to this property's constraints 893 + // but are redundant, so remove them. 894 + elems = slices.DeleteFunc(elems, func(it item) bool { 895 + // TODO this is unacceptably inefficient. We should fix that 896 + // by making comparisons more efficient somehow. 897 + for itc := range conjuncts(c.constraint) { 898 + if reflect.DeepEqual(it, itc) { 899 + return true 900 + } 901 + } 902 + return false 903 + }) 904 + } 905 + if len(elems) == 0 { 906 + propItem = &itemTrue{} 907 + } else { 908 + propItem = &itemAllOf{elems: elems} 909 + } 910 + props.properties[fieldName] = propItem 830 911 } 831 - slices.SortFunc(props.elems, func(e1, e2 property) int { 832 - return cmp.Compare(e1.name, e2.name) 833 - }) 834 - if len(props.elems) == 0 && len(props.required) == 0 { 912 + if len(props.properties) == 0 && len(props.required) == 0 { 835 913 return &itemTrue{} 836 914 } 837 915 return &props ··· 932 1010 types = append(types, t) 933 1011 } 934 1012 return types 1013 + } 1014 + 1015 + // regexpForValue tries to interpret v as a regular expression constraint, 1016 + // It returns the regular expression and reports whether it succeeded. 1017 + func regexpForValue(v cue.Value) (*regexp.Regexp, bool) { 1018 + s, ok := regexpForValue1(v) 1019 + if !ok { 1020 + return nil, false 1021 + } 1022 + pat, err := regexp.Compile(s) 1023 + return pat, err == nil 1024 + } 1025 + 1026 + func regexpForValue1(v cue.Value) (string, bool) { 1027 + op, args := v.Expr() 1028 + if op == cue.RegexMatchOp { 1029 + if len(args) != 1 { 1030 + return "", false 1031 + } 1032 + s, err := args[0].String() 1033 + if err != nil { 1034 + return "", false 1035 + } 1036 + return s, true 1037 + } 1038 + s, err := v.String() 1039 + if err == nil { 1040 + // Exact match. 1041 + return "^" + regexp.QuoteMeta(s) + "$", true 1042 + } 1043 + if acceptsAllString(v) { 1044 + // It matches all possible string labels: return 1045 + // a regular expression that matches all possible 1046 + // labels too. 1047 + return "", true 1048 + } 1049 + return "", false 1050 + } 1051 + 1052 + func acceptsAllString(v cue.Value) bool { 1053 + // TODO return v.AcceptsAll(cue.StringKind) if/when that 1054 + // method is implemented. 1055 + sv := v.Context().CompileString("string") 1056 + return v.Unify(sv).Subsume(sv, cue.Final()) == nil 935 1057 } 936 1058 937 1059 // trueAsNil returns the nil item if the item
+48 -20
encoding/jsonschema/generate_items.go
··· 17 17 import ( 18 18 "cmp" 19 19 "fmt" 20 + "maps" 20 21 "slices" 21 22 22 23 "cuelang.org/go/cue" ··· 493 494 494 495 // itemProperties represents object properties and associated keywords. 495 496 type itemProperties struct { 496 - elems []property 497 + properties map[string]item 497 498 required []string 498 499 additionalProperties item 499 - // TODO patternProperties 500 + patternProperties map[string]item 500 501 } 501 502 502 503 func (i *itemProperties) generate(g *generator) ast.Expr { 503 - propFields := make([]ast.Decl, len(i.elems)) 504 - for j, prop := range i.elems { 505 - propFields[j] = makeField(prop.name, prop.item.generate(g)) 504 + propFields := make([]ast.Decl, 0, len(i.properties)) 505 + for name, it := range i.properties { 506 + propFields = append(propFields, makeField(name, it.generate(g))) 506 507 } 508 + slices.SortFunc(propFields, func(a, b ast.Decl) int { 509 + return cmp.Compare(fieldLabel(a), fieldLabel(b)) 510 + }) 511 + 507 512 fields := []ast.Decl{makeField("properties", &ast.StructLit{Elts: propFields})} 508 513 if len(i.required) > 0 { 509 514 reqExprs := make([]ast.Expr, len(i.required)) ··· 515 520 if i.additionalProperties != nil { 516 521 fields = append(fields, makeField("additionalProperties", i.additionalProperties.generate(g))) 517 522 } 523 + if len(i.patternProperties) > 0 { 524 + pp := &ast.StructLit{} 525 + for _, p := range slices.Sorted(maps.Keys(i.patternProperties)) { 526 + pp.Elts = append(pp.Elts, makeField(p, i.patternProperties[p].generate(g))) 527 + } 528 + fields = append(fields, makeField("patternProperties", pp)) 529 + } 518 530 return makeSchemaStructLit(fields...) 519 531 } 520 532 521 533 func (i *itemProperties) apply(f func(item) item) item { 522 - changed := false 523 - elems := i.elems 524 - for j, prop := range elems { 525 - if it := f(prop.item); it != prop.item { 526 - if !changed { 527 - elems = slices.Clone(elems) 528 - changed = true 529 - } 530 - elems[j] = property{ 531 - name: prop.name, 532 - item: it, 533 - } 534 - } 535 - } 534 + properties, changed0 := applyMap(i.properties, f) 535 + patternProperties, changed1 := applyMap(i.patternProperties, f) 536 + changed := changed0 || changed1 536 537 additionalProperties := i.additionalProperties 537 538 if additionalProperties != nil { 538 539 if ap := f(additionalProperties); ap != additionalProperties { ··· 544 545 return i 545 546 } 546 547 return &itemProperties{ 547 - elems: elems, 548 + properties: properties, 548 549 required: i.required, 549 550 additionalProperties: additionalProperties, 551 + patternProperties: patternProperties, 550 552 } 553 + } 554 + 555 + func applyMap(m map[string]item, f func(item) item) (map[string]item, bool) { 556 + var m1 map[string]item 557 + for key, e := range m { 558 + e1 := f(e) 559 + if e1 == e { 560 + continue 561 + } 562 + if m1 == nil { 563 + m1 = make(map[string]item) 564 + } 565 + m1[key] = e1 566 + } 567 + if m1 == nil { 568 + return m, false 569 + } 570 + if len(m1) == len(m) { 571 + return m1, true 572 + } 573 + for key, e := range m { 574 + if _, ok := m1[key]; !ok { 575 + m1[key] = e 576 + } 577 + } 578 + return m1, true 551 579 } 552 580 553 581 func applyElems(elems []item, f func(item) item) ([]item, bool) {
+1 -4
encoding/jsonschema/testdata/external/tests/draft2020-12/additionalProperties.json
··· 54 54 "foo": 1, 55 55 "vroom": 2 56 56 }, 57 - "valid": true, 58 - "skip": { 59 - "v3-roundtrip": "conflicting values [...] and {foo:1,vroom:2} (mismatched types list and struct):\n instance.json:1:1\nconflicting values bool and {foo:1,vroom:2} (mismatched types bool and struct):\n instance.json:1:1\nconflicting values null and {foo:1,vroom:2} (mismatched types null and struct):\n instance.json:1:1\nconflicting values number and {foo:1,vroom:2} (mismatched types number and struct):\n instance.json:1:1\nconflicting values string and {foo:1,vroom:2} (mismatched types string and struct):\n instance.json:1:1\ninvalid value {foo:1,vroom:2} (does not satisfy matchN): 0 matched, expected \u003e=1:\n instance.json:1:1\nvroom: field not allowed:\n instance.json:1:18\n" 60 - } 57 + "valid": true 61 58 } 62 59 ] 63 60 },
+29
encoding/jsonschema/testdata/generate/struct.txtar
··· 7 7 t5?: {[_]: int} 8 8 t6?: {a?: int} 9 9 t7?: close({a!: string, b!: int}) 10 + t8?: { 11 + [=~"^a"]: =~"b" 12 + [=~"b$"]: =~"x" 13 + a?: string 14 + b?: string 15 + ab?: string 16 + x?: string 17 + } 10 18 #S1: { 11 19 a?: int 12 20 } ··· 86 94 } 87 95 } 88 96 required: ["a", "b"] 97 + } 98 + t8: { 99 + type: "object" 100 + patternProperties: { 101 + "^a": { 102 + type: "string" 103 + pattern: "b" 104 + } 105 + b$: { 106 + type: "string" 107 + pattern: "x" 108 + } 109 + } 110 + properties: { 111 + a: true 112 + ab: true 113 + b: true 114 + x: { 115 + type: "string" 116 + } 117 + } 89 118 } 90 119 } 91 120 }