Skip to content

Commit 827f3cf

Browse files
authored
improve merging logic for "not" schemas (#831)
1 parent 1710967 commit 827f3cf

3 files changed

Lines changed: 295 additions & 72 deletions

File tree

typify-impl/src/merge.rs

Lines changed: 103 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -415,13 +415,35 @@ fn try_merge_with_each_subschema(
415415
joined_schemas
416416
}
417417

418+
fn merge_schema_not(
419+
schema: &Schema,
420+
not_schema: &Schema,
421+
defs: &BTreeMap<RefKey, Schema>,
422+
) -> Schema {
423+
match (schema, not_schema) {
424+
(_, Schema::Bool(true)) | (Schema::Bool(false), _) => Schema::Bool(false),
425+
426+
(any, Schema::Bool(false)) => any.clone(),
427+
428+
// TODO I don't know how to subtract something from nothing...
429+
(Schema::Bool(true), Schema::Object(_)) => todo!(),
430+
431+
(Schema::Object(schema_object), any_not) => {
432+
match try_merge_schema_not(schema_object.clone(), any_not, defs) {
433+
Ok(schema_obj) => Schema::Object(schema_obj),
434+
Err(_) => Schema::Bool(false),
435+
}
436+
}
437+
}
438+
}
439+
418440
/// "Subtract" the "not" schema from the schema object.
419441
///
420442
/// TODO Exactly where and how we handle not constructions is... tricky! As we
421443
/// find and support more and more useful uses of not we will likely move some
422444
/// of this into the conversion methods.
423445
fn try_merge_schema_not(
424-
mut schema_object: SchemaObject,
446+
schema_object: SchemaObject,
425447
not_schema: &Schema,
426448
defs: &BTreeMap<RefKey, Schema>,
427449
) -> Result<SchemaObject, ()> {
@@ -435,77 +457,8 @@ fn try_merge_schema_not(
435457
Schema::Bool(true) => Err(()),
436458
// ... whereas subtracting nothing leaves everything.
437459
Schema::Bool(false) => Ok(schema_object),
438-
439-
Schema::Object(SchemaObject {
440-
// I don't think there's any significance to the schema metadata
441-
// with respect to the types we might generate.
442-
metadata: _,
443-
// TODO we should should check instance_type and then walk through
444-
// validation of each type based on the specific validation.
445-
instance_type: _,
446-
format: _,
447-
enum_values: _,
448-
const_value: _,
449-
subschemas,
450-
number: _,
451-
string: _,
452-
array: _,
453-
object,
454-
// TODO we might want to chase these references but need to take
455-
// care to handle circular references.
456-
reference: _,
457-
extensions: _,
458-
}) => {
459-
if let Some(not_object) = object {
460-
// TODO this is incomplete, but seems sufficient for the
461-
// schemas we've seen in the wild.
462-
if let Some(ObjectValidation {
463-
required,
464-
properties,
465-
..
466-
}) = schema_object.object.as_deref_mut()
467-
{
468-
// TODO This is completely wrong for arrays of len > 1.
469-
// We need to treat required: [x, y] like it's:
470-
// not:
471-
// allOf:
472-
// required: [x]
473-
// required: [y]
474-
// Then we can transform them into:
475-
// anyOf:
476-
// not:
477-
// required: [x]
478-
// not:
479-
// required: [y]
480-
// Which in turn can become:
481-
// oneOf:
482-
// not:
483-
// required: [x]
484-
// not:
485-
// required: [y]
486-
// not:
487-
// required: [x, y]
488-
for not_required in &not_object.required {
489-
// A property can't be both required and not required
490-
// therefore this schema is unsatisfiable.
491-
if required.contains(not_required) {
492-
return Err(());
493-
}
494-
// Set the property's schema to false i.e. that the
495-
// presence of any value would be invalid. We ignore
496-
// the return value as it doesn't matter if the
497-
// property was there previously or not.
498-
let _ = properties.insert(not_required.clone(), Schema::Bool(false));
499-
}
500-
}
501-
}
502-
503-
if let Some(not_subschemas) = subschemas {
504-
schema_object = try_merge_with_subschemas_not(schema_object, not_subschemas, defs)?;
505-
}
506-
507-
Ok(schema_object)
508-
}
460+
// Do the real work.
461+
Schema::Object(not_object) => try_merge_schema_object_not(schema_object, not_object, defs),
509462
}
510463
}
511464

@@ -604,6 +557,84 @@ fn try_merge_with_subschemas_not(
604557
}
605558
}
606559

560+
fn try_merge_schema_object_not(
561+
mut schema_object: SchemaObject,
562+
not_object: &SchemaObject,
563+
defs: &BTreeMap<RefKey, Schema>,
564+
) -> Result<SchemaObject, ()> {
565+
// Examine enum values
566+
match (&mut schema_object.enum_values, &not_object.enum_values) {
567+
// Nothing to do.
568+
(_, None) => {}
569+
// TODO not sure quite what to do, so we'll ignore for now.
570+
(None, Some(_)) => {}
571+
(Some(values), Some(not_values)) => {
572+
values.retain(|value| !not_values.contains(value));
573+
if values.is_empty() {
574+
return Err(());
575+
}
576+
}
577+
}
578+
579+
match (&mut schema_object.object, &not_object.object) {
580+
// Nothing to do.
581+
(_, None) => {}
582+
583+
// TODO Not sure how to enforce the inverse here...
584+
(None, Some(_)) => {}
585+
586+
// In the interesting case, we need to "subtract" object attributes.
587+
(Some(obj), Some(not_obj)) => {
588+
for (prop_name, prop_schema) in &mut obj.properties {
589+
if let Some(not_prop_schema) = not_obj.properties.get(prop_name) {
590+
// For properties in both, we merge those schemas. Note
591+
// that if such a merging is unsatisfiable *and* the
592+
// property is required, we'll take the appropriate action
593+
// later.
594+
*prop_schema = merge_schema_not(prop_schema, not_prop_schema, defs);
595+
}
596+
}
597+
598+
for prop_name in not_obj.properties.keys() {
599+
if !obj.properties.contains_key(prop_name) {
600+
// There's a property in the "not" that isn't in the
601+
// object. Most precisely we would say "this property may
602+
// have any value as long as it doesn't match this schema".
603+
// That's a little tricky right now, so instead we'll say
604+
// "you may not have a property with this name".
605+
let _ = obj
606+
.properties
607+
.insert(prop_name.clone(), Schema::Bool(false));
608+
}
609+
}
610+
611+
for not_required in &not_obj.required {
612+
if !not_obj.properties.contains_key(not_required) {
613+
// No value is permissible
614+
let _ = obj
615+
.properties
616+
.insert(not_required.clone(), Schema::Bool(false));
617+
}
618+
}
619+
620+
// If any of the previous steps resulted in a required property
621+
// being invalid, we note that here and identify the full schema as
622+
// invalid.
623+
for required in &obj.required {
624+
if let Some(Schema::Bool(false)) = obj.properties.get(required) {
625+
return Err(());
626+
}
627+
}
628+
}
629+
}
630+
631+
if let Some(not_subschemas) = &not_object.subschemas {
632+
schema_object = try_merge_with_subschemas_not(schema_object, not_subschemas, defs)?;
633+
}
634+
635+
Ok(schema_object)
636+
}
637+
607638
/// Merge instance types which could be None (meaning type is valid), a
608639
/// singleton type, or an array of types. An error result indicates that the
609640
/// types were non-overlappin and therefore incompatible.

typify/tests/schemas/merged-schemas.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,38 @@
490490
"additionalProperties": false
491491
}
492492
]
493+
},
494+
"unchanged-by-merge": {
495+
"allOf": [
496+
{
497+
"type": "object",
498+
"properties": {
499+
"tag": {
500+
"enum": [
501+
"something"
502+
]
503+
}
504+
},
505+
"required": [
506+
"tag"
507+
]
508+
},
509+
{
510+
"not": {
511+
"type": "object",
512+
"properties": {
513+
"tag": {
514+
"enum": [
515+
"something_else"
516+
]
517+
}
518+
},
519+
"required": [
520+
"tag"
521+
]
522+
}
523+
}
524+
]
493525
}
494526
}
495527
}

0 commit comments

Comments
 (0)