diff --git a/common/json-schema.cpp b/common/json-schema.cpp index b0cd7889c3..c09a4df1a5 100644 --- a/common/json-schema.cpp +++ b/common/json-schema.cpp @@ -1,10 +1,13 @@ #include "json-schema.h" #include "common.h" +#include #include +#include #include #include #include +#include #include class common_schema_parser { @@ -336,3 +339,1067 @@ class common_schema_parser { common_schema_document common_schema_parse(const common_json & schema) { return common_schema_parser(schema).parse(); } + +class common_schema_optimizer { + common_schema_document & doc_; + + // set when a $ref got replaced by the none its target became, the pass is then repeated + bool changed_ = false; + + // the target pairs an intersection is working through, to stop a recursive schema from recursing forever + std::set> active_; + + // whether a value matches a schema, unknown where the check would need a regex + enum match_t { + MATCH_NO, + MATCH_YES, + MATCH_UNKNOWN, + }; + + static match_t both(match_t a, match_t b) { + if (a == MATCH_NO || b == MATCH_NO) { + return MATCH_NO; + } + if (a == MATCH_UNKNOWN || b == MATCH_UNKNOWN) { + return MATCH_UNKNOWN; + } + return MATCH_YES; + } + + template + static const T & as(const common_schema & node) { + return static_cast(node); + } + + static bool is(const common_schema & node, common_schema_kind kind) { + return node.kind() == kind; + } + + static bool is(const common_schema_ptr & node, common_schema_kind kind) { + return node && node->kind() == kind; + } + + static common_schema_ptr none() { + return std::make_unique(); + } + + // Follows a chain of $refs to a node of another kind. + // Gives nullptr for a target that is being rewritten right now, or a chain that loops back on itself. + const common_schema * resolve(const common_schema & node) const { + const common_schema * cur = &node; + for (size_t hops = 0; is(*cur, COMMON_SCHEMA_KIND_REF); hops++) { + if (hops > doc_.refs.size()) { + return nullptr; + } + auto it = doc_.refs.find(as(*cur).ref); + if (it == doc_.refs.end() || !it->second) { + return nullptr; + } + cur = it->second.get(); + } + return cur; + } + + static std::vector clone_all(const std::vector & nodes) { + std::vector out; + out.reserve(nodes.size()); + for (const auto & node : nodes) { + out.push_back(clone(*node)); + } + return out; + } + + static common_schema_ptr clone(const common_schema & node) { + switch (node.kind()) { + case COMMON_SCHEMA_KIND_ANY: return std::make_unique(); + case COMMON_SCHEMA_KIND_NONE: return none(); + case COMMON_SCHEMA_KIND_NULL: return std::make_unique(); + case COMMON_SCHEMA_KIND_BOOLEAN: return std::make_unique(); + case COMMON_SCHEMA_KIND_NUMBER: return std::make_unique(); + case COMMON_SCHEMA_KIND_INTEGER: return std::make_unique(as(node)); + case COMMON_SCHEMA_KIND_STRING: return std::make_unique(as(node)); + case COMMON_SCHEMA_KIND_CONST: return std::make_unique(as(node).value); + case COMMON_SCHEMA_KIND_REF: { + const auto & ref = as(node); + auto out = std::make_unique(ref.ref); + out->target = ref.target; + return out; + } + case COMMON_SCHEMA_KIND_ENUM: { + auto out = std::make_unique(); + out->values = as(node).values; + return out; + } + case COMMON_SCHEMA_KIND_ANY_OF: { + auto out = std::make_unique(); + out->children = clone_all(as(node).children); + return out; + } + case COMMON_SCHEMA_KIND_ALL_OF: { + auto out = std::make_unique(); + out->children = clone_all(as(node).children); + return out; + } + case COMMON_SCHEMA_KIND_ARRAY: { + const auto & arr = as(node); + auto out = std::make_unique(); + out->items = clone(*arr.items); + out->min_items = arr.min_items; + out->max_items = arr.max_items; + return out; + } + case COMMON_SCHEMA_KIND_TUPLE: { + auto out = std::make_unique(); + out->items = clone_all(as(node).items); + return out; + } + case COMMON_SCHEMA_KIND_OBJECT: { + const auto & obj = as(node); + auto out = std::make_unique(); + for (const auto & prop : obj.properties) { + out->properties.push_back({prop.name, clone(*prop.schema), prop.required}); + } + if (obj.additional_properties) { + out->additional_properties = clone(*obj.additional_properties); + } + return out; + } + } + return none(); + } + + static bool equal_all(const std::vector & a, const std::vector & b) { + if (a.size() != b.size()) { + return false; + } + for (size_t i = 0; i < a.size(); i++) { + if (!equal(*a[i], *b[i])) { + return false; + } + } + return true; + } + + // structural equality, a $ref only equals the same $ref + static bool equal(const common_schema & a, const common_schema & b) { + if (a.kind() != b.kind()) { + return false; + } + switch (a.kind()) { + case COMMON_SCHEMA_KIND_ANY: + case COMMON_SCHEMA_KIND_NONE: + case COMMON_SCHEMA_KIND_NULL: + case COMMON_SCHEMA_KIND_BOOLEAN: + case COMMON_SCHEMA_KIND_NUMBER: + return true; + case COMMON_SCHEMA_KIND_REF: + return as(a).ref == as(b).ref; + case COMMON_SCHEMA_KIND_CONST: + return as(a).value == as(b).value; + case COMMON_SCHEMA_KIND_ENUM: { + const auto & va = as(a).values; + const auto & vb = as(b).values; + if (va.size() != vb.size()) { + return false; + } + for (size_t i = 0; i < va.size(); i++) { + if (va[i] != vb[i]) { + return false; + } + } + return true; + } + case COMMON_SCHEMA_KIND_INTEGER: { + const auto & ia = as(a); + const auto & ib = as(b); + return ia.minimum == ib.minimum && ia.maximum == ib.maximum; + } + case COMMON_SCHEMA_KIND_STRING: { + const auto & sa = as(a); + const auto & sb = as(b); + return sa.pattern == sb.pattern && sa.format == sb.format && sa.min_length == sb.min_length && sa.max_length == sb.max_length; + } + case COMMON_SCHEMA_KIND_ANY_OF: + return equal_all(as(a).children, as(b).children); + case COMMON_SCHEMA_KIND_ALL_OF: + return equal_all(as(a).children, as(b).children); + case COMMON_SCHEMA_KIND_ARRAY: { + const auto & aa = as(a); + const auto & ab = as(b); + return aa.min_items == ab.min_items && aa.max_items == ab.max_items && equal(*aa.items, *ab.items); + } + case COMMON_SCHEMA_KIND_TUPLE: + return equal_all(as(a).items, as(b).items); + case COMMON_SCHEMA_KIND_OBJECT: { + const auto & oa = as(a); + const auto & ob = as(b); + if (oa.properties.size() != ob.properties.size()) { + return false; + } + for (size_t i = 0; i < oa.properties.size(); i++) { + const auto & pa = oa.properties[i]; + const auto & pb = ob.properties[i]; + if (pa.name != pb.name || pa.required != pb.required || !equal(*pa.schema, *pb.schema)) { + return false; + } + } + if (!oa.additional_properties || !ob.additional_properties) { + return !oa.additional_properties && !ob.additional_properties; + } + return equal(*oa.additional_properties, *ob.additional_properties); + } + } + return false; + } + static const common_schema_property * find_property(const common_schema_object & obj, const std::string & name) { + for (const auto & prop : obj.properties) { + if (prop.name == name) { + return ∝ + } + } + return nullptr; + } + + // code points, the length the grammar's char rule counts + static int utf8_length(const std::string & s) { + int n = 0; + for (unsigned char c : s) { + if ((c & 0xC0) != 0x80) { + n++; + } + } + return n; + } + + // Whether a value matches a schema, unknown where a pattern or format would have to be checked. + match_t satisfies(const common_json & value, const common_schema & schema) const { + const common_schema * node = resolve(schema); + if (!node) { + return MATCH_UNKNOWN; + } + switch (node->kind()) { + case COMMON_SCHEMA_KIND_ANY: + return MATCH_YES; + case COMMON_SCHEMA_KIND_NONE: + case COMMON_SCHEMA_KIND_REF: + return MATCH_NO; + case COMMON_SCHEMA_KIND_ANY_OF: { + match_t res = MATCH_NO; + for (const auto & child : as(*node).children) { + match_t m = satisfies(value, *child); + if (m == MATCH_YES) { + return MATCH_YES; + } + if (m == MATCH_UNKNOWN) { + res = MATCH_UNKNOWN; + } + } + return res; + } + case COMMON_SCHEMA_KIND_ALL_OF: { + match_t res = MATCH_YES; + for (const auto & child : as(*node).children) { + res = both(res, satisfies(value, *child)); + if (res == MATCH_NO) { + return MATCH_NO; + } + } + return res; + } + case COMMON_SCHEMA_KIND_CONST: + return value == as(*node).value ? MATCH_YES : MATCH_NO; + case COMMON_SCHEMA_KIND_ENUM: + for (const auto & v : as(*node).values) { + if (value == v) { + return MATCH_YES; + } + } + return MATCH_NO; + case COMMON_SCHEMA_KIND_NULL: + return value.is_null() ? MATCH_YES : MATCH_NO; + case COMMON_SCHEMA_KIND_BOOLEAN: + return value.is_boolean() ? MATCH_YES : MATCH_NO; + case COMMON_SCHEMA_KIND_NUMBER: + return value.is_number() ? MATCH_YES : MATCH_NO; + case COMMON_SCHEMA_KIND_INTEGER: { + if (!value.is_number_integer()) { + return MATCH_NO; + } + const auto & i = as(*node); + int64_t v = value.get(); + return v >= i.minimum && v <= i.maximum ? MATCH_YES : MATCH_NO; + } + case COMMON_SCHEMA_KIND_STRING: { + if (!value.is_string()) { + return MATCH_NO; + } + const auto & s = as(*node); + int len = utf8_length(value.get()); + if (len < s.min_length || (s.max_length >= 0 && len > s.max_length)) { + return MATCH_NO; + } + return s.pattern.empty() && s.format == COMMON_SCHEMA_FORMAT_NONE ? MATCH_YES : MATCH_UNKNOWN; + } + case COMMON_SCHEMA_KIND_ARRAY: { + if (!value.is_array()) { + return MATCH_NO; + } + const auto & arr = as(*node); + int n = (int) value.size(); + if (n < arr.min_items || (arr.max_items >= 0 && n > arr.max_items)) { + return MATCH_NO; + } + match_t res = MATCH_YES; + for (const auto & item : value) { + res = both(res, satisfies(item, *arr.items)); + if (res == MATCH_NO) { + return MATCH_NO; + } + } + return res; + } + case COMMON_SCHEMA_KIND_TUPLE: { + const auto & tup = as(*node); + if (!value.is_array() || value.size() != tup.items.size()) { + return MATCH_NO; + } + match_t res = MATCH_YES; + for (size_t i = 0; i < tup.items.size(); i++) { + res = both(res, satisfies(value.at(i), *tup.items[i])); + if (res == MATCH_NO) { + return MATCH_NO; + } + } + return res; + } + case COMMON_SCHEMA_KIND_OBJECT: { + if (!value.is_object()) { + return MATCH_NO; + } + const auto & obj = as(*node); + match_t res = MATCH_YES; + for (const auto & prop : obj.properties) { + if (value.contains(prop.name)) { + res = both(res, satisfies(value.at(prop.name), *prop.schema)); + } else if (prop.required) { + return MATCH_NO; + } + if (res == MATCH_NO) { + return MATCH_NO; + } + } + for (const auto & [key, val] : value.items()) { + if (find_property(obj, key)) { + continue; + } + if (!obj.additional_properties) { + return MATCH_NO; + } + res = both(res, satisfies(val, *obj.additional_properties)); + if (res == MATCH_NO) { + return MATCH_NO; + } + } + return res; + } + } + return MATCH_UNKNOWN; + } + + bool subsumes_all(const common_schema & a, const std::vector & items) const { + for (const auto & item : items) { + if (!subsumes(a, *item)) { + return false; + } + } + return true; + } + + // Whether a accepts every value b accepts. False when unsure, so a $ref only subsumes the same $ref. + bool subsumes(const common_schema & a, const common_schema & b) const { + if (is(a, COMMON_SCHEMA_KIND_ANY) || is(b, COMMON_SCHEMA_KIND_NONE) || equal(a, b)) { + return true; + } + if (is(a, COMMON_SCHEMA_KIND_REF) || is(b, COMMON_SCHEMA_KIND_REF)) { + return false; + } + if (is(b, COMMON_SCHEMA_KIND_ANY_OF)) { + return subsumes_all(a, as(b).children); + } + if (is(a, COMMON_SCHEMA_KIND_ANY_OF)) { + for (const auto & child : as(a).children) { + if (subsumes(*child, b)) { + return true; + } + } + return false; + } + if (is(b, COMMON_SCHEMA_KIND_ALL_OF)) { + // b is within each of its children, so within a once one of them is + for (const auto & child : as(b).children) { + if (subsumes(a, *child)) { + return true; + } + } + return false; + } + if (is(a, COMMON_SCHEMA_KIND_ALL_OF)) { + for (const auto & child : as(a).children) { + if (!subsumes(*child, b)) { + return false; + } + } + return true; + } + if (is(b, COMMON_SCHEMA_KIND_CONST)) { + return satisfies(as(b).value, a) == MATCH_YES; + } + if (is(b, COMMON_SCHEMA_KIND_ENUM)) { + for (const auto & v : as(b).values) { + if (satisfies(v, a) != MATCH_YES) { + return false; + } + } + return true; + } + if (is(a, COMMON_SCHEMA_KIND_ARRAY) && is(b, COMMON_SCHEMA_KIND_TUPLE)) { + const auto & arr = as(a); + const auto & tup = as(b); + int n = (int) tup.items.size(); + return n >= arr.min_items && (arr.max_items < 0 || n <= arr.max_items) && subsumes_all(*arr.items, tup.items); + } + if (a.kind() != b.kind()) { + return is(a, COMMON_SCHEMA_KIND_NUMBER) && is(b, COMMON_SCHEMA_KIND_INTEGER); + } + switch (a.kind()) { + case COMMON_SCHEMA_KIND_INTEGER: { + const auto & ia = as(a); + const auto & ib = as(b); + return ia.minimum <= ib.minimum && ia.maximum >= ib.maximum; + } + case COMMON_SCHEMA_KIND_STRING: { + const auto & sa = as(a); + const auto & sb = as(b); + return (sa.pattern.empty() || sa.pattern == sb.pattern) && + (sa.format == COMMON_SCHEMA_FORMAT_NONE || sa.format == sb.format) && + sa.min_length <= sb.min_length && + (sa.max_length < 0 || (sb.max_length >= 0 && sa.max_length >= sb.max_length)); + } + case COMMON_SCHEMA_KIND_ARRAY: { + const auto & aa = as(a); + const auto & ab = as(b); + return aa.min_items <= ab.min_items && + (aa.max_items < 0 || (ab.max_items >= 0 && aa.max_items >= ab.max_items)) && + subsumes(*aa.items, *ab.items); + } + case COMMON_SCHEMA_KIND_TUPLE: { + const auto & ta = as(a); + const auto & tb = as(b); + if (ta.items.size() != tb.items.size()) { + return false; + } + for (size_t i = 0; i < ta.items.size(); i++) { + if (!subsumes(*ta.items[i], *tb.items[i])) { + return false; + } + } + return true; + } + case COMMON_SCHEMA_KIND_OBJECT: { + const auto & oa = as(a); + const auto & ob = as(b); + for (const auto & pb : ob.properties) { + const auto * pa = find_property(oa, pb.name); + if (pa) { + if ((pa->required && !pb.required) || !subsumes(*pa->schema, *pb.schema)) { + return false; + } + } else if (!oa.additional_properties || !subsumes(*oa.additional_properties, *pb.schema)) { + return false; + } + } + for (const auto & pa : oa.properties) { + if (find_property(ob, pa.name)) { + continue; + } + // b may leave it out, or fill it through its additionalProperties + if (pa.required || (ob.additional_properties && !subsumes(*pa.schema, *ob.additional_properties))) { + return false; + } + } + if (ob.additional_properties) { + return oa.additional_properties && subsumes(*oa.additional_properties, *ob.additional_properties); + } + return true; + } + default: + return false; + } + } + template + static T & as(common_schema & node) { + return static_cast(node); + } + + static void add_value(std::vector & values, const common_json & value) { + for (const auto & v : values) { + if (v == value) { + return; + } + } + values.push_back(value); + } + + static common_schema_ptr make_values(std::vector values) { + if (values.size() == 1) { + return std::make_unique(std::move(values[0])); + } + auto node = std::make_unique(); + node->values = std::move(values); + return node; + } + + // whether two ranges overlap or sit next to each other, so that their union is one range + static bool touching(const common_schema_integer & a, const common_schema_integer & b) { + if (a.minimum <= b.maximum && b.minimum <= a.maximum) { + return true; + } + return (b.maximum < INT64_MAX && a.minimum == b.maximum + 1) || (a.maximum < INT64_MAX && b.minimum == a.maximum + 1); + } + + // The union of rewritten alternatives. + common_schema_ptr make_any_of(std::vector children) { + std::vector flat; + for (auto & child : children) { + if (is(child, COMMON_SCHEMA_KIND_ANY_OF)) { + for (auto & c : as(*child).children) { + flat.push_back(std::move(c)); + } + } else { + flat.push_back(std::move(child)); + } + } + + // consts and enums become one enum, in order of first appearance + std::vector values; + std::vector out; + for (auto & child : flat) { + if (is(child, COMMON_SCHEMA_KIND_NONE)) { + continue; + } + if (is(child, COMMON_SCHEMA_KIND_ANY)) { + return std::make_unique(); + } + if (is(child, COMMON_SCHEMA_KIND_CONST)) { + add_value(values, as(*child).value); + continue; + } + if (is(child, COMMON_SCHEMA_KIND_ENUM)) { + for (const auto & v : as(*child).values) { + add_value(values, v); + } + continue; + } + out.push_back(std::move(child)); + } + + // an integer range that overlaps or touches an earlier one widens it instead + for (size_t i = 0; i < out.size();) { + bool merged = false; + if (is(out[i], COMMON_SCHEMA_KIND_INTEGER)) { + auto & b = as(*out[i]); + for (size_t j = 0; j < i; j++) { + if (!is(out[j], COMMON_SCHEMA_KIND_INTEGER)) { + continue; + } + auto & a = as(*out[j]); + if (touching(a, b)) { + a.minimum = std::min(a.minimum, b.minimum); + a.maximum = std::max(a.maximum, b.maximum); + merged = true; + break; + } + } + } + if (merged) { + out.erase(out.begin() + i); + i = 0; // the widened range may now touch one it did not before + } else { + i++; + } + } + + // a value another alternative already accepts is dropped + for (size_t i = 0; i < values.size();) { + bool covered = false; + for (const auto & child : out) { + if (satisfies(values[i], *child) == MATCH_YES) { + covered = true; + break; + } + } + if (covered) { + values.erase(values.begin() + i); + } else { + i++; + } + } + if (!values.empty()) { + out.push_back(make_values(std::move(values))); + } + + // an alternative within another is dropped, of two within each other the first stays + std::vector dropped(out.size(), false); + for (size_t i = 0; i < out.size(); i++) { + for (size_t j = 0; j < out.size() && !dropped[i]; j++) { + if (j == i || dropped[j] || !subsumes(*out[j], *out[i])) { + continue; + } + dropped[i] = j < i || !subsumes(*out[i], *out[j]); + } + } + std::vector kept; + for (size_t i = 0; i < out.size(); i++) { + if (!dropped[i]) { + kept.push_back(std::move(out[i])); + } + } + + if (kept.empty()) { + return none(); + } + if (kept.size() == 1) { + return std::move(kept[0]); + } + auto node = std::make_unique(); + node->children = std::move(kept); + return node; + } + + // An array with rewritten items. + static common_schema_ptr make_array(common_schema_ptr items, int min_items, int max_items) { + if ((max_items >= 0 && min_items > max_items) || (is(items, COMMON_SCHEMA_KIND_NONE) && min_items > 0)) { + return none(); + } + if (max_items == 0 || is(items, COMMON_SCHEMA_KIND_NONE)) { + // only [] is left + return std::make_unique(); + } + auto node = std::make_unique(); + node->items = std::move(items); + node->min_items = min_items; + node->max_items = max_items; + return node; + } + + // An object with rewritten property schemas. + static common_schema_ptr make_object(std::vector properties, common_schema_ptr additional) { + auto node = std::make_unique(); + for (auto & prop : properties) { + if (is(prop.schema, COMMON_SCHEMA_KIND_NONE)) { + // it can never be present + if (prop.required) { + return none(); + } + continue; + } + node->properties.push_back(std::move(prop)); + } + if (additional && !is(additional, COMMON_SCHEMA_KIND_NONE)) { + node->additional_properties = std::move(additional); + } + return node; + } + + std::vector rewrite_all(std::vector nodes) { + for (auto & node : nodes) { + node = rewrite(std::move(node)); + } + return nodes; + } + + // Rewrites a node bottom-up. + common_schema_ptr rewrite(common_schema_ptr node) { + switch (node->kind()) { + case COMMON_SCHEMA_KIND_ANY: + case COMMON_SCHEMA_KIND_NONE: + case COMMON_SCHEMA_KIND_CONST: + case COMMON_SCHEMA_KIND_NULL: + case COMMON_SCHEMA_KIND_BOOLEAN: + case COMMON_SCHEMA_KIND_NUMBER: + return node; + case COMMON_SCHEMA_KIND_REF: { + const common_schema * target = resolve(*node); + if (target && is(*target, COMMON_SCHEMA_KIND_NONE)) { + changed_ = true; + return none(); + } + return node; + } + case COMMON_SCHEMA_KIND_ENUM: { + std::vector values; + for (const auto & v : as(*node).values) { + add_value(values, v); + } + return make_values(std::move(values)); + } + case COMMON_SCHEMA_KIND_INTEGER: { + const auto & i = as(*node); + return i.minimum > i.maximum ? none() : std::move(node); + } + case COMMON_SCHEMA_KIND_STRING: { + const auto & s = as(*node); + return s.max_length >= 0 && s.min_length > s.max_length ? none() : std::move(node); + } + case COMMON_SCHEMA_KIND_ANY_OF: + return make_any_of(rewrite_all(std::move(as(*node).children))); + case COMMON_SCHEMA_KIND_ALL_OF: + return intersect_all(rewrite_all(std::move(as(*node).children))); + case COMMON_SCHEMA_KIND_ARRAY: { + auto & arr = as(*node); + return make_array(rewrite(std::move(arr.items)), arr.min_items, arr.max_items); + } + case COMMON_SCHEMA_KIND_TUPLE: { + auto & tup = as(*node); + tup.items = rewrite_all(std::move(tup.items)); + for (const auto & item : tup.items) { + if (is(item, COMMON_SCHEMA_KIND_NONE)) { + return none(); + } + } + return node; + } + case COMMON_SCHEMA_KIND_OBJECT: { + auto & obj = as(*node); + for (auto & prop : obj.properties) { + prop.schema = rewrite(std::move(prop.schema)); + } + if (obj.additional_properties) { + obj.additional_properties = rewrite(std::move(obj.additional_properties)); + } + return make_object(std::move(obj.properties), std::move(obj.additional_properties)); + } + } + return node; + } + static common_schema_ptr make_all_of(std::vector children) { + if (children.size() == 1) { + return std::move(children[0]); + } + auto node = std::make_unique(); + node->children = std::move(children); + return node; + } + + // what is left when two nodes cannot be combined + static common_schema_ptr irreducible(const common_schema & a, const common_schema & b) { + std::vector children; + children.push_back(clone(a)); + children.push_back(clone(b)); + return make_all_of(std::move(children)); + } + + // The intersection of rewritten nodes, an allOf of those that could not be combined. + common_schema_ptr intersect_all(std::vector children) { + std::vector flat; + for (auto & child : children) { + if (is(child, COMMON_SCHEMA_KIND_ALL_OF)) { + for (auto & c : as(*child).children) { + flat.push_back(std::move(c)); + } + } else { + flat.push_back(std::move(child)); + } + } + + std::vector residual; + for (auto & child : flat) { + bool merged = false; + for (auto & r : residual) { + auto cand = intersect(*r, *child); + if (is(cand, COMMON_SCHEMA_KIND_NONE)) { + return none(); + } + if (!is(cand, COMMON_SCHEMA_KIND_ALL_OF)) { + r = std::move(cand); + merged = true; + break; + } + } + if (!merged) { + residual.push_back(std::move(child)); + } + } + if (residual.empty()) { + return std::make_unique(); + } + return make_all_of(std::move(residual)); + } + + common_schema_ptr intersect_tuple(const common_schema_array & arr, const common_schema_tuple & tup) { + int n = (int) tup.items.size(); + if (n < arr.min_items || (arr.max_items >= 0 && n > arr.max_items)) { + return none(); + } + auto node = std::make_unique(); + for (const auto & item : tup.items) { + auto out = intersect(*arr.items, *item); + if (is(out, COMMON_SCHEMA_KIND_NONE)) { + return none(); + } + node->items.push_back(std::move(out)); + } + return node; + } + + // Properties are merged the way json_schema_to_grammar() merges an allOf: a property the other + // side does not list survives, a side that is closed does not shut it out. + common_schema_ptr intersect_object(const common_schema_object & oa, const common_schema_object & ob) { + std::vector properties; + auto add = [&](const common_schema_object & x, const common_schema_object & y, bool skip_shared) { + for (const auto & px : x.properties) { + const auto * py = find_property(y, px.name); + if (py && skip_shared) { + continue; + } + common_schema_property prop; + prop.name = px.name; + prop.required = px.required || (py && py->required); + if (py) { + prop.schema = intersect(*px.schema, *py->schema); + } else if (y.additional_properties) { + prop.schema = intersect(*px.schema, *y.additional_properties); + } else { + prop.schema = clone(*px.schema); + } + properties.push_back(std::move(prop)); + } + }; + add(oa, ob, false); + add(ob, oa, true); + + common_schema_ptr additional; + if (oa.additional_properties && ob.additional_properties) { + additional = intersect(*oa.additional_properties, *ob.additional_properties); + } + return make_object(std::move(properties), std::move(additional)); + } + + // The intersection of two rewritten nodes, an allOf of both where they cannot be combined. + common_schema_ptr intersect(const common_schema & a, const common_schema & b) { + if (is(a, COMMON_SCHEMA_KIND_ANY)) { + return clone(b); + } + if (is(b, COMMON_SCHEMA_KIND_ANY)) { + return clone(a); + } + if (is(a, COMMON_SCHEMA_KIND_NONE) || is(b, COMMON_SCHEMA_KIND_NONE)) { + return none(); + } + if (equal(a, b)) { + return clone(a); + } + if (is(a, COMMON_SCHEMA_KIND_REF) || is(b, COMMON_SCHEMA_KIND_REF)) { + const common_schema * ta = resolve(a); + const common_schema * tb = resolve(b); + if (!ta || !tb) { + return irreducible(a, b); + } + auto key = std::make_pair(ta, tb); + if (!active_.insert(key).second) { + // the same pair is already being intersected further up, a recursive schema + return irreducible(a, b); + } + auto out = intersect(*ta, *tb); + active_.erase(key); + return out; + } + if (is(a, COMMON_SCHEMA_KIND_ANY_OF) || is(b, COMMON_SCHEMA_KIND_ANY_OF)) { + // (a1 | a2) & b = (a1 & b) | (a2 & b) + std::vector alts; + if (is(a, COMMON_SCHEMA_KIND_ANY_OF)) { + for (const auto & child : as(a).children) { + alts.push_back(intersect(*child, b)); + } + } else { + for (const auto & child : as(b).children) { + alts.push_back(intersect(a, *child)); + } + } + return make_any_of(std::move(alts)); + } + if (is(a, COMMON_SCHEMA_KIND_ALL_OF) || is(b, COMMON_SCHEMA_KIND_ALL_OF)) { + std::vector parts; + for (const auto * node : {&a, &b}) { + if (is(*node, COMMON_SCHEMA_KIND_ALL_OF)) { + for (const auto & child : as(*node).children) { + parts.push_back(clone(*child)); + } + } else { + parts.push_back(clone(*node)); + } + } + return intersect_all(std::move(parts)); + } + if (is(a, COMMON_SCHEMA_KIND_CONST) || is(b, COMMON_SCHEMA_KIND_CONST)) { + const auto & c = as(is(a, COMMON_SCHEMA_KIND_CONST) ? a : b); + const auto & other = is(a, COMMON_SCHEMA_KIND_CONST) ? b : a; + switch (satisfies(c.value, other)) { + case MATCH_YES: return clone(c); + case MATCH_NO: return none(); + default: return irreducible(a, b); + } + } + if (is(a, COMMON_SCHEMA_KIND_ENUM) || is(b, COMMON_SCHEMA_KIND_ENUM)) { + const auto & e = as(is(a, COMMON_SCHEMA_KIND_ENUM) ? a : b); + const auto & other = is(a, COMMON_SCHEMA_KIND_ENUM) ? b : a; + std::vector values; + for (const auto & v : e.values) { + switch (satisfies(v, other)) { + case MATCH_YES: values.push_back(v); break; + case MATCH_NO: break; + default: return irreducible(a, b); + } + } + return values.empty() ? none() : make_values(std::move(values)); + } + if (a.kind() != b.kind()) { + if (is(a, COMMON_SCHEMA_KIND_NUMBER) && is(b, COMMON_SCHEMA_KIND_INTEGER)) { + return clone(b); + } + if (is(a, COMMON_SCHEMA_KIND_INTEGER) && is(b, COMMON_SCHEMA_KIND_NUMBER)) { + return clone(a); + } + if (is(a, COMMON_SCHEMA_KIND_ARRAY) && is(b, COMMON_SCHEMA_KIND_TUPLE)) { + return intersect_tuple(as(a), as(b)); + } + if (is(a, COMMON_SCHEMA_KIND_TUPLE) && is(b, COMMON_SCHEMA_KIND_ARRAY)) { + return intersect_tuple(as(b), as(a)); + } + return none(); + } + switch (a.kind()) { + case COMMON_SCHEMA_KIND_INTEGER: { + const auto & ia = as(a); + const auto & ib = as(b); + auto node = std::make_unique(); + node->minimum = std::max(ia.minimum, ib.minimum); + node->maximum = std::min(ia.maximum, ib.maximum); + return node->minimum > node->maximum ? none() : std::move(node); + } + case COMMON_SCHEMA_KIND_STRING: { + const auto & sa = as(a); + const auto & sb = as(b); + if (!sa.pattern.empty() && !sb.pattern.empty() && sa.pattern != sb.pattern) { + return irreducible(a, b); + } + if (sa.format != COMMON_SCHEMA_FORMAT_NONE && sb.format != COMMON_SCHEMA_FORMAT_NONE && sa.format != sb.format) { + return none(); + } + auto node = std::make_unique(); + node->pattern = sa.pattern.empty() ? sb.pattern : sa.pattern; + node->format = sa.format == COMMON_SCHEMA_FORMAT_NONE ? sb.format : sa.format; + node->min_length = std::max(sa.min_length, sb.min_length); + node->max_length = sa.max_length < 0 ? sb.max_length : sb.max_length < 0 ? sa.max_length : std::min(sa.max_length, sb.max_length); + return node->max_length >= 0 && node->min_length > node->max_length ? none() : std::move(node); + } + case COMMON_SCHEMA_KIND_ARRAY: { + const auto & aa = as(a); + const auto & ab = as(b); + int min_items = std::max(aa.min_items, ab.min_items); + int max_items = aa.max_items < 0 ? ab.max_items : ab.max_items < 0 ? aa.max_items : std::min(aa.max_items, ab.max_items); + return make_array(intersect(*aa.items, *ab.items), min_items, max_items); + } + case COMMON_SCHEMA_KIND_TUPLE: { + const auto & ta = as(a); + const auto & tb = as(b); + if (ta.items.size() != tb.items.size()) { + return none(); + } + auto node = std::make_unique(); + for (size_t i = 0; i < ta.items.size(); i++) { + auto item = intersect(*ta.items[i], *tb.items[i]); + if (is(item, COMMON_SCHEMA_KIND_NONE)) { + return none(); + } + node->items.push_back(std::move(item)); + } + return node; + } + case COMMON_SCHEMA_KIND_OBJECT: + return intersect_object(as(a), as(b)); + default: + // null, boolean and number have no fields, so unequal ones cannot be of the same kind + return none(); + } + } + + // Moves the refs the node reaches into live, and points each ref node at its target there. + void link(common_schema & node, std::map & live) { + switch (node.kind()) { + case COMMON_SCHEMA_KIND_REF: { + auto & ref = as(node); + auto it = live.find(ref.ref); + if (it == live.end()) { + it = live.emplace(ref.ref, std::move(doc_.refs.at(ref.ref))).first; + link(*it->second, live); + } + ref.target = it->second.get(); + return; + } + case COMMON_SCHEMA_KIND_ANY_OF: + for (auto & child : as(node).children) { + link(*child, live); + } + return; + case COMMON_SCHEMA_KIND_ALL_OF: + for (auto & child : as(node).children) { + link(*child, live); + } + return; + case COMMON_SCHEMA_KIND_ARRAY: + link(*as(node).items, live); + return; + case COMMON_SCHEMA_KIND_TUPLE: + for (auto & item : as(node).items) { + link(*item, live); + } + return; + case COMMON_SCHEMA_KIND_OBJECT: { + auto & obj = as(node); + for (auto & prop : obj.properties) { + link(*prop.schema, live); + } + if (obj.additional_properties) { + link(*obj.additional_properties, live); + } + return; + } + default: + return; + } + } + + public: + explicit common_schema_optimizer(common_schema_document & doc) : doc_(doc) {} + + void run() { + // a $ref whose target became none is none too, which the next pass can then prune in its parent + do { + changed_ = false; + for (auto & entry : doc_.refs) { + entry.second = rewrite(std::move(entry.second)); + } + doc_.root = rewrite(std::move(doc_.root)); + } while (changed_); + + // only the refs the root still reaches are kept, and every ref node gets its new target + std::map live; + link(*doc_.root, live); + doc_.refs = std::move(live); + } +}; + +void common_schema_optimize(common_schema_document & doc) { + common_schema_optimizer(doc).run(); +} diff --git a/common/json-schema.h b/common/json-schema.h index 247efacda5..eeb124f7c4 100644 --- a/common/json-schema.h +++ b/common/json-schema.h @@ -13,6 +13,7 @@ enum common_schema_kind { COMMON_SCHEMA_KIND_ANY, + COMMON_SCHEMA_KIND_NONE, COMMON_SCHEMA_KIND_REF, COMMON_SCHEMA_KIND_ANY_OF, COMMON_SCHEMA_KIND_ALL_OF, @@ -49,6 +50,11 @@ struct common_schema_any : common_schema { common_schema_kind kind() const override { return COMMON_SCHEMA_KIND_ANY; } }; +// Matches no value: what common_schema_optimize() leaves where an intersection turned out empty +struct common_schema_none : common_schema { + common_schema_kind kind() const override { return COMMON_SCHEMA_KIND_NONE; } +}; + // {"$ref": "#/..."}, only references into the same document are supported struct common_schema_ref : common_schema { std::string ref; @@ -152,3 +158,11 @@ struct common_schema_document { // Parses a JSON schema into a document. // Throws std::runtime_error when the schema falls outside the supported subset. common_schema_document common_schema_parse(const common_json & schema); + +// Rewrites a document in place into an equivalent one with less redundancy: +// - allOf becomes the intersection of its children, one node where the kinds allow it +// - nested anyOf are flattened, duplicate and subsumed alternatives are dropped, consts and enums merge into one enum +// - branches that can match nothing are pruned, up to a common_schema_none root when nothing is left +// - $refs nothing reaches anymore are dropped from refs +// An allOf survives only where the children cannot be combined, e.g. two different patterns. +void common_schema_optimize(common_schema_document & doc); diff --git a/tests/test-json-schema.cpp b/tests/test-json-schema.cpp index a8bcc67295..16c512e6ec 100644 --- a/tests/test-json-schema.cpp +++ b/tests/test-json-schema.cpp @@ -27,6 +27,85 @@ static const T & root(testing & t, const common_schema_document & doc) { return as(t, doc.root.get(), "root"); } +static std::string dump(const common_schema & node); + +static std::string dump_all(const std::vector & nodes) { + std::string out; + for (const auto & node : nodes) { + out += (out.empty() ? "" : ", ") + dump(*node); + } + return out; +} + +// a bound that is left out when it is the default +static std::string dump_range(int64_t min, int64_t min_def, int64_t max, int64_t max_def) { + if (min == min_def && max == max_def) { + return ""; + } + return "[" + (min == min_def ? "" : std::to_string(min)) + ".." + (max == max_def ? "" : std::to_string(max)) + "]"; +} + +// one line per node, e.g. object{a: string, b?: integer[1..], *: any} +static std::string dump(const common_schema & node) { + switch (node.kind()) { + case COMMON_SCHEMA_KIND_ANY: return "any"; + case COMMON_SCHEMA_KIND_NONE: return "none"; + case COMMON_SCHEMA_KIND_NULL: return "null"; + case COMMON_SCHEMA_KIND_BOOLEAN: return "boolean"; + case COMMON_SCHEMA_KIND_NUMBER: return "number"; + case COMMON_SCHEMA_KIND_REF: return "ref(" + static_cast(node).ref + ")"; + case COMMON_SCHEMA_KIND_ANY_OF: return "anyOf(" + dump_all(static_cast(node).children) + ")"; + case COMMON_SCHEMA_KIND_ALL_OF: return "allOf(" + dump_all(static_cast(node).children) + ")"; + case COMMON_SCHEMA_KIND_CONST: return "const(" + static_cast(node).value.dump() + ")"; + case COMMON_SCHEMA_KIND_TUPLE: return "tuple(" + dump_all(static_cast(node).items) + ")"; + case COMMON_SCHEMA_KIND_ENUM: { + std::string out; + for (const auto & v : static_cast(node).values) { + out += (out.empty() ? "" : ", ") + v.dump(); + } + return "enum(" + out + ")"; + } + case COMMON_SCHEMA_KIND_INTEGER: { + const auto & i = static_cast(node); + return "integer" + dump_range(i.minimum, INT64_MIN, i.maximum, INT64_MAX); + } + case COMMON_SCHEMA_KIND_STRING: { + const auto & s = static_cast(node); + static const char * formats[] = {"", "uuid", "date", "time", "date-time"}; + std::string out = "string"; + if (!s.pattern.empty()) { + out += "/" + s.pattern + "/"; + } + if (s.format != COMMON_SCHEMA_FORMAT_NONE) { + out += std::string(":") + formats[s.format]; + } + return out + dump_range(s.min_length, 0, s.max_length, -1); + } + case COMMON_SCHEMA_KIND_ARRAY: { + const auto & a = static_cast(node); + return "array(" + dump(*a.items) + ")" + dump_range(a.min_items, 0, a.max_items, -1); + } + case COMMON_SCHEMA_KIND_OBJECT: { + const auto & o = static_cast(node); + std::string out; + for (const auto & p : o.properties) { + out += (out.empty() ? "" : ", ") + p.name + (p.required ? ": " : "?: ") + dump(*p.schema); + } + if (o.additional_properties) { + out += (out.empty() ? "" : ", ") + std::string("*: ") + dump(*o.additional_properties); + } + return "object{" + out + "}"; + } + } + return "?"; +} + +static std::string optimize(const std::string & schema) { + auto doc = parse(schema); + common_schema_optimize(doc); + return dump(*doc.root); +} + static void assert_error(testing & t, const std::string & schema, const std::string & needle) { try { parse(schema); @@ -596,6 +675,355 @@ static void test_errors(testing & t) { }); } +static void test_optimize_any_of(testing & t) { + t.test("nested anyOf are flattened", [](testing & t) { + t.assert_equal("anyOf(string, null, boolean)", optimize(R"({"anyOf": [{"anyOf": [{"type": "string"}, {"type": "null"}]}, {"type": "boolean"}]})")); + }); + + t.test("duplicates are dropped", [](testing & t) { + t.assert_equal("anyOf(string, null)", optimize(R"({"anyOf": [{"type": "string"}, {"type": "string"}, {"type": "null"}]})")); + t.assert_equal("anyOf(string, null)", optimize(R"({"type": ["string", "null", "string"]})")); + }); + + t.test("one alternative left unwraps", [](testing & t) { + t.assert_equal("null", optimize(R"({"oneOf": [{"type": "null"}]})")); + }); + + t.test("any absorbs the rest", [](testing & t) { + t.assert_equal("any", optimize(R"({"anyOf": [{"type": "string"}, {}, {"type": "null"}]})")); + }); + + t.test("consts and enums merge into one enum", [](testing & t) { + t.assert_equal("enum(\"a\", \"b\", \"c\")", optimize(R"({"anyOf": [{"const": "a"}, {"enum": ["b", "c"]}, {"const": "b"}]})")); + t.assert_equal("const(1)", optimize(R"({"anyOf": [{"const": 1}, {"const": 1}]})")); + }); + + t.test("a value another alternative accepts is dropped", [](testing & t) { + t.assert_equal("anyOf(integer, const(\"x\"))", optimize(R"({"anyOf": [{"type": "integer"}, {"const": 5}, {"const": "x"}]})")); + t.assert_equal("null", optimize(R"({"anyOf": [{"type": "null"}, {"const": null}]})")); + }); + + t.test("an alternative within another is dropped", [](testing & t) { + t.assert_equal("integer", optimize(R"({"anyOf": [{"type": "integer", "minimum": 1}, {"type": "integer"}]})")); + t.assert_equal("string", optimize(R"({"anyOf": [{"type": "string"}, {"type": "string", "minLength": 2}]})")); + t.assert_equal("number", optimize(R"({"anyOf": [{"type": "integer"}, {"type": "number"}]})")); + t.assert_equal("array(integer)", optimize(R"({"anyOf": [{"items": {"type": "integer"}}, {"prefixItems": [{"type": "integer", "minimum": 0}]}]})")); + t.assert_equal("object{a?: string, *: any}", optimize(R"({"anyOf": [{"properties": {"a": {"type": "string"}}, "additionalProperties": true}, {"properties": {"a": {"type": "string"}}, "required": ["a"]}]})")); + }); + + t.test("of two alternatives within each other the first stays", [](testing & t) { + t.assert_equal("object{a?: any, *: any}", optimize(R"({"anyOf": [{"properties": {"a": {}}, "additionalProperties": true}, {"type": "object"}]})")); + }); + + t.test("touching integer ranges merge", [](testing & t) { + t.assert_equal("anyOf(integer[1..10], integer[20..30])", optimize(R"({"anyOf": [ + {"type": "integer", "minimum": 1, "maximum": 5}, + {"type": "integer", "minimum": 20, "maximum": 30}, + {"type": "integer", "minimum": 6, "maximum": 10} + ]})")); + t.assert_equal("integer", optimize(R"({"anyOf": [{"type": "integer", "minimum": 0}, {"type": "integer", "maximum": -1}]})")); + t.assert_equal("anyOf(integer[..3], integer[5..])", optimize(R"({"anyOf": [{"type": "integer", "maximum": 3}, {"type": "integer", "minimum": 5}]})")); + }); + + t.test("an alternative that matches nothing is pruned", [](testing & t) { + t.assert_equal("string", optimize(R"({"anyOf": [{"type": "integer", "minimum": 5, "maximum": 1}, {"type": "string"}]})")); + t.assert_equal("none", optimize(R"({"anyOf": [{"type": "integer", "minimum": 5, "maximum": 1}]})")); + }); + + t.test("different objects stay apart", [](testing & t) { + t.assert_equal("anyOf(object{a: string}, object{b: string})", optimize(R"({"anyOf": [ + {"properties": {"a": {"type": "string"}}, "required": ["a"]}, + {"properties": {"b": {"type": "string"}}, "required": ["b"]} + ]})")); + }); +} + +static void test_optimize_all_of(testing & t) { + t.test("objects merge their properties", [](testing & t) { + t.assert_equal("object{a: string, b?: integer}", optimize(R"({"allOf": [ + {"properties": {"a": {"type": "string"}}, "required": ["a"]}, + {"properties": {"b": {"type": "integer"}}} + ]})")); + }); + + t.test("a shared property is intersected and required by either side", [](testing & t) { + t.assert_equal("object{a: integer[1..5]}", optimize(R"({"allOf": [ + {"properties": {"a": {"type": "integer", "minimum": 1}}, "required": ["a"]}, + {"properties": {"a": {"type": "integer", "maximum": 5}}} + ]})")); + }); + + t.test("additionalProperties constrains the other side's properties", [](testing & t) { + t.assert_equal("object{a?: integer[0..]}", optimize(R"({"allOf": [ + {"properties": {"a": {"type": "integer"}}}, + {"additionalProperties": {"type": "integer", "minimum": 0}} + ]})")); + t.assert_equal("object{a?: integer, *: integer}", optimize(R"({"allOf": [ + {"properties": {"a": {"type": "number"}}, "additionalProperties": true}, + {"additionalProperties": {"type": "integer"}} + ]})")); + t.assert_equal("object{*: integer}", optimize(R"({"allOf": [ + {"properties": {"a": {"type": "string"}}, "additionalProperties": true}, + {"additionalProperties": {"type": "integer"}} + ]})")); + }); + + t.test("a shared property that matches nothing", [](testing & t) { + t.assert_equal("object{b?: any}", optimize(R"({"allOf": [ + {"properties": {"a": {"type": "string"}, "b": {}}}, + {"properties": {"a": {"type": "integer"}}} + ]})")); + t.assert_equal("none", optimize(R"({"allOf": [ + {"properties": {"a": {"type": "string"}}, "required": ["a"]}, + {"properties": {"a": {"type": "integer"}}} + ]})")); + }); + + t.test("a ref is inlined and dropped", [](testing & t) { + auto doc = parse(R"({ + "allOf": [{"$ref": "#/$defs/base"}, {"properties": {"b": {"type": "string"}}}], + "$defs": {"base": {"properties": {"a": {"type": "string"}}, "required": ["a"]}} + })"); + common_schema_optimize(doc); + t.assert_equal("root", "object{a: string, b?: string}", dump(*doc.root)); + t.assert_true("no refs", doc.refs.empty()); + }); + + t.test("enums intersect", [](testing & t) { + t.assert_equal("const(\"b\")", optimize(R"({"type": "string", "allOf": [{"enum": ["a", "b"]}, {"enum": ["b", "c"]}]})")); + t.assert_equal("enum(1, 2)", optimize(R"({"allOf": [{"enum": [1, "x", 2]}, {"type": "integer"}]})")); + t.assert_equal("none", optimize(R"({"allOf": [{"enum": ["a"]}, {"enum": ["b"]}]})")); + }); + + t.test("const", [](testing & t) { + t.assert_equal("const(5)", optimize(R"({"allOf": [{"const": 5}, {"type": "integer", "minimum": 0}]})")); + t.assert_equal("none", optimize(R"({"allOf": [{"const": 5}, {"type": "string"}]})")); + t.assert_equal("const({\"a\":1})", optimize(R"({"allOf": [{"const": {"a": 1}}, {"properties": {"a": {"type": "integer"}}, "required": ["a"]}]})")); + t.assert_equal("none", optimize(R"({"allOf": [{"const": {"a": 1}}, {"properties": {"a": {"type": "integer"}, "b": {}}, "required": ["b"]}]})")); + t.assert_equal("const([1,2])", optimize(R"({"allOf": [{"const": [1, 2]}, {"items": {"type": "integer"}, "maxItems": 2}]})")); + }); + + t.test("a const against a pattern stays an allOf", [](testing & t) { + t.assert_equal("allOf(const(\"ab\"), string/^a/)", optimize(R"({"allOf": [{"const": "ab"}, {"pattern": "^a"}]})")); + }); + + t.test("integer bounds", [](testing & t) { + t.assert_equal("integer[1..10]", optimize(R"({"allOf": [{"type": "integer", "minimum": 1}, {"type": "integer", "maximum": 10}]})")); + t.assert_equal("integer[3..5]", optimize(R"({"allOf": [{"type": "integer", "minimum": 1, "maximum": 5}, {"type": "integer", "minimum": 3, "maximum": 10}]})")); + t.assert_equal("none", optimize(R"({"allOf": [{"type": "integer", "maximum": 1}, {"type": "integer", "minimum": 2}]})")); + t.assert_equal("integer", optimize(R"({"allOf": [{"type": "number"}, {"type": "integer"}]})")); + }); + + t.test("strings", [](testing & t) { + t.assert_equal("string:date[2..5]", optimize(R"({"type": "string", "allOf": [{"minLength": 2, "type": "string"}, {"type": "string", "maxLength": 5, "format": "date"}]})")); + t.assert_equal("string/^a/[1..3]", optimize(R"({"allOf": [{"pattern": "^a", "maxLength": 3}, {"pattern": "^a", "minLength": 1}]})")); + t.assert_equal("allOf(string/^a/, string/b$/)", optimize(R"({"allOf": [{"pattern": "^a"}, {"pattern": "b$"}]})")); + t.assert_equal("none", optimize(R"({"allOf": [{"format": "date"}, {"format": "uuid"}]})")); + t.assert_equal("none", optimize(R"({"allOf": [{"type": "string", "minLength": 5}, {"type": "string", "maxLength": 2}]})")); + }); + + t.test("kinds that cannot both hold", [](testing & t) { + t.assert_equal("none", optimize(R"({"allOf": [{"type": "string"}, {"type": "integer"}]})")); + t.assert_equal("none", optimize(R"({"allOf": [{"type": "object"}, {"items": {}}]})")); + }); + + t.test("any and equal children fold away", [](testing & t) { + t.assert_equal("boolean", optimize(R"({"allOf": [{}, {"type": "boolean"}, {"type": "boolean"}]})")); + t.assert_equal("any", optimize(R"({"allOf": [{}, {}]})")); + }); + + t.test("arrays", [](testing & t) { + t.assert_equal("array(integer[0..])[1..3]", optimize(R"({"allOf": [ + {"items": {"type": "integer"}, "minItems": 1}, + {"items": {"type": "integer", "minimum": 0}, "maxItems": 3} + ]})")); + t.assert_equal("tuple(integer[1..], integer)", optimize(R"({"allOf": [ + {"items": {"type": "integer"}}, + {"prefixItems": [{"type": "integer", "minimum": 1}, {}]} + ]})")); + t.assert_equal("none", optimize(R"({"allOf": [{"items": {}, "maxItems": 1}, {"prefixItems": [{}, {}]}]})")); + t.assert_equal("none", optimize(R"({"allOf": [{"prefixItems": [{}]}, {"prefixItems": [{}, {}]}]})")); + t.assert_equal("tuple()", optimize(R"({"allOf": [{"items": {"type": "string"}}, {"items": {"type": "integer"}}]})")); + }); + + t.test("distributes over anyOf", [](testing & t) { + t.assert_equal("integer[0..]", optimize(R"({"allOf": [ + {"anyOf": [{"type": "string"}, {"type": "integer"}]}, + {"type": "integer", "minimum": 0} + ]})")); + t.assert_equal("anyOf(object{a?: string, c: integer}, object{b?: string, c: integer})", optimize(R"({"allOf": [ + {"anyOf": [{"properties": {"a": {"type": "string"}}}, {"properties": {"b": {"type": "string"}}}]}, + {"properties": {"c": {"type": "integer"}}, "required": ["c"]} + ]})")); + t.assert_equal("anyOf(integer[..3], integer[5..])", optimize(R"({"allOf": [ + {"anyOf": [{"type": "integer", "maximum": 3}, {"type": "integer", "minimum": 5}]}, + {"anyOf": [{"type": "integer"}, {"type": "string"}]} + ]})")); + }); + + t.test("nested allOf are flattened", [](testing & t) { + t.assert_equal("integer[1..3]", optimize(R"({"allOf": [{"allOf": [{"type": "integer", "minimum": 1}]}, {"type": "integer", "maximum": 3}]})")); + }); + + t.test("what cannot combine keeps the rest merged", [](testing & t) { + t.assert_equal("allOf(string/^a/[..5], string/b$/)", optimize(R"({"allOf": [{"pattern": "^a"}, {"pattern": "b$"}, {"type": "string", "maxLength": 5}]})")); + }); + + t.test("recursive refs do not loop", [](testing & t) { + auto doc = parse(R"({ + "allOf": [{"$ref": "#/$defs/a"}, {"$ref": "#/$defs/b"}], + "$defs": { + "a": {"properties": {"next": {"$ref": "#/$defs/a"}}}, + "b": {"properties": {"next": {"$ref": "#/$defs/b"}}} + } + })"); + common_schema_optimize(doc); + t.assert_equal("root", "object{next?: allOf(ref(#/$defs/a), ref(#/$defs/b))}", dump(*doc.root)); + t.assert_equal("refs", (size_t) 2, doc.refs.size()); + }); +} + +static void test_optimize_prune(testing & t) { + t.test("bounds that leave nothing", [](testing & t) { + t.assert_equal("none", optimize(R"({"type": "integer", "minimum": 5, "maximum": 1})")); + t.assert_equal("none", optimize(R"({"type": "string", "minLength": 5, "maxLength": 1})")); + t.assert_equal("none", optimize(R"({"type": "array", "minItems": 5, "maxItems": 1})")); + t.assert_equal("integer[1..1]", optimize(R"({"type": "integer", "minimum": 1, "maximum": 1})")); + }); + + t.test("an optional property that can never be present is dropped", [](testing & t) { + t.assert_equal("object{b?: any}", optimize(R"({"properties": {"a": {"type": "integer", "minimum": 5, "maximum": 1}, "b": {}}})")); + }); + + t.test("a required property that can never be present empties the object", [](testing & t) { + t.assert_equal("none", optimize(R"({"properties": {"a": {"type": "integer", "minimum": 5, "maximum": 1}}, "required": ["a"]})")); + t.assert_equal("none", optimize(R"({"properties": {"o": {"properties": {"a": {"type": "integer", "minimum": 5, "maximum": 1}}, "required": ["a"]}}, "required": ["o"]})")); + }); + + t.test("additionalProperties that can never be present closes the object", [](testing & t) { + t.assert_equal("object{a?: string}", optimize(R"({"properties": {"a": {"type": "string"}}, "additionalProperties": {"type": "integer", "minimum": 5, "maximum": 1}})")); + }); + + t.test("items that can never be present leave the empty array", [](testing & t) { + t.assert_equal("tuple()", optimize(R"({"items": {"type": "integer", "minimum": 5, "maximum": 1}})")); + t.assert_equal("tuple()", optimize(R"({"type": "array", "maxItems": 0})")); + t.assert_equal("none", optimize(R"({"items": {"type": "integer", "minimum": 5, "maximum": 1}, "minItems": 1})")); + }); + + t.test("a tuple item that can never be present", [](testing & t) { + t.assert_equal("none", optimize(R"({"prefixItems": [{"type": "string"}, {"type": "integer", "minimum": 5, "maximum": 1}]})")); + }); + + t.test("enum values dedupe", [](testing & t) { + t.assert_equal("enum(\"a\", \"b\")", optimize(R"({"enum": ["a", "a", "b"]})")); + t.assert_equal("const(\"a\")", optimize(R"({"enum": ["a", "a"]})")); + }); + + t.test("nested pruning reaches the root", [](testing & t) { + t.assert_equal("none", optimize(R"({"anyOf": [ + {"properties": {"a": {"allOf": [{"type": "string"}, {"type": "null"}]}}, "required": ["a"]}, + {"prefixItems": [{"allOf": [{"const": 1}, {"const": 2}]}]} + ]})")); + }); + + t.test("what is already minimal is left alone", [](testing & t) { + auto schema = R"({ + "properties": { + "name": {"type": "string", "minLength": 1}, + "tags": {"type": "array", "items": {"enum": ["a", "b"]}, "maxItems": 3}, + "kind": {"anyOf": [{"type": "null"}, {"$ref": "#/$defs/kind"}]} + }, + "required": ["name"], + "$defs": {"kind": {"properties": {"id": {"type": "integer", "minimum": 0}}, "required": ["id"]}} + })"; + auto doc = parse(schema); + std::string before = dump(*doc.root); + common_schema_optimize(doc); + t.assert_equal("root", before, dump(*doc.root)); + t.assert_equal("root", "object{name: string[1..], tags?: array(enum(\"a\", \"b\"))[..3], kind?: anyOf(null, ref(#/$defs/kind))}", dump(*doc.root)); + t.assert_equal("refs", (size_t) 1, doc.refs.size()); + }); +} + +static void test_optimize_ref(testing & t) { + t.test("a ref to nothing is nothing", [](testing & t) { + auto doc = parse(R"({"$ref": "#/$defs/t", "$defs": {"t": {"type": "integer", "minimum": 5, "maximum": 1}}})"); + common_schema_optimize(doc); + t.assert_equal("root", "none", dump(*doc.root)); + t.assert_true("no refs", doc.refs.empty()); + }); + + t.test("a branch through a ref to nothing is pruned", [](testing & t) { + auto doc = parse(R"({ + "anyOf": [{"properties": {"x": {"$ref": "#/$defs/t"}}, "required": ["x"]}, {"type": "string"}], + "$defs": {"t": {"allOf": [{"type": "string"}, {"type": "null"}]}} + })"); + common_schema_optimize(doc); + t.assert_equal("root", "string", dump(*doc.root)); + t.assert_true("no refs", doc.refs.empty()); + }); + + t.test("a chain of refs to nothing", [](testing & t) { + auto doc = parse(R"({ + "properties": {"a": {"$ref": "#/$defs/a"}, "b": {}}, + "$defs": {"a": {"$ref": "#/$defs/b"}, "b": {"$ref": "#/$defs/c"}, "c": {"type": "integer", "minimum": 5, "maximum": 1}} + })"); + common_schema_optimize(doc); + t.assert_equal("root", "object{b?: any}", dump(*doc.root)); + t.assert_true("no refs", doc.refs.empty()); + }); + + t.test("reachable refs are kept and relinked", [](testing & t) { + auto doc = parse(R"({ + "properties": {"a": {"$ref": "#/$defs/t"}, "b": {"$ref": "#/$defs/t"}, "c": {"$ref": "#/$defs/u"}}, + "$defs": {"t": {"anyOf": [{"type": "string"}, {"type": "string"}]}, "u": {"type": "null"}, "unused": {"type": "boolean"}} + })"); + common_schema_optimize(doc); + t.assert_equal("root", "object{a?: ref(#/$defs/t), b?: ref(#/$defs/t), c?: ref(#/$defs/u)}", dump(*doc.root)); + t.assert_equal("refs", (size_t) 2, doc.refs.size()); + t.assert_equal("t", "string", dump(*doc.refs.at("#/$defs/t"))); + const auto & o = root(t, doc); + for (const auto & prop : o.properties) { + const auto & r = as(t, prop.schema.get(), prop.name); + t.assert_true(prop.name + " target", r.target != nullptr && r.target == doc.refs.at(r.ref).get()); + } + }); + + t.test("a recursive schema survives", [](testing & t) { + auto doc = parse(R"({ + "$ref": "#/$defs/node", + "$defs": { + "node": { + "properties": { + "value": {"anyOf": [{"type": "number"}, {"type": "integer"}]}, + "next": {"anyOf": [{"$ref": "#/$defs/node"}, {"type": "null"}, {"$ref": "#/$defs/node"}]} + }, + "required": ["value"] + } + } + })"); + common_schema_optimize(doc); + const auto & r = root(t, doc); + t.assert_equal("node", "object{value: number, next?: anyOf(ref(#/$defs/node), null)}", dump(*r.target)); + t.assert_true("target", r.target == doc.refs.at("#/$defs/node").get()); + const auto & node = as(t, r.target, "node"); + const auto & next = as(t, node.properties[1].schema.get(), "next"); + t.assert_true("cycle", as(t, next.children[0].get(), "next[0]").target == r.target); + }); + + t.test("a ref intersected with any stays a ref", [](testing & t) { + auto doc = parse(R"({"allOf": [{}, {"$ref": "#/$defs/t"}], "$defs": {"t": {"type": "boolean"}}})"); + common_schema_optimize(doc); + t.assert_equal("root", "ref(#/$defs/t)", dump(*doc.root)); + t.assert_true("target", root(t, doc).target == doc.refs.at("#/$defs/t").get()); + }); + + t.test("the same ref twice is one", [](testing & t) { + t.assert_equal("ref(#/$defs/t)", optimize(R"({"allOf": [{"$ref": "#/$defs/t"}, {"$ref": "#/$defs/t"}], "$defs": {"t": {"type": "boolean"}}})")); + t.assert_equal("ref(#/$defs/t)", optimize(R"({"anyOf": [{"$ref": "#/$defs/t"}, {"$ref": "#/$defs/t"}], "$defs": {"t": {"type": "boolean"}}})")); + }); +} + int main(int argc, char * argv[]) { testing t(std::cout); if (argc >= 2) { @@ -619,6 +1047,10 @@ int main(int argc, char * argv[]) { t.test("all_of", test_all_of); t.test("ref", test_ref); t.test("errors", test_errors); + t.test("optimize any_of", test_optimize_any_of); + t.test("optimize all_of", test_optimize_all_of); + t.test("optimize prune", test_optimize_prune); + t.test("optimize ref", test_optimize_ref); return t.summary(); }