kristianmandrup / schema-to-yup

Schema to Yup validation
Other
283 stars 50 forks source link

Handling oneOf #102

Closed BreizhReloaded closed 2 years ago

BreizhReloaded commented 2 years ago

Hello,

I've been working with schema-to-yup a bit and it's working great so far. But I faced an issue I can't solve at the moment. My schema looks like this at some point:

{
  "myField": {
    "type": "string",
    "oneOf": [
       {
         "const": "optionA",
         "title": "titleOptionA"
       },
       {
         "const": "optionB",
         "title": "titleOptionB"
       },
     ],
  }
}

When validating with optionA or optionB as value, I get myField must be one of the following values: [object Object], [object Object]. I seems it uses the whole const/title pair as requested values. When using type object instead of string, the field doen't trigger any validation.

My schema works perfectly when checking on https://www.jsonschemavalidator.net/. What am I missing?

Thanks!

kristianmandrup commented 2 years ago

Hi @BreizhReloaded,

I don't believe you are missing anything really. I think the gist of it is that I likely never got around to supporting oneOf (or there might be a bug?).

https://github.com/kristianmandrup/schema-to-yup/blob/master/src/types/mixed/mixed.js#L308

It is essentially the same as multi type, such as passing an array of types or an array of constraints.

myField: ["string", {
  "type": "number",
  // ... more specific number constraints
}

This is where the ultiPropertyValueResolver at https://github.com/kristianmandrup/schema-to-yup/blob/master/src/multi-property-value-resolver.js is supposed to take over.

As you can see, this has never been implemented, but you can supply your own functionality via the config object

    const toMultiType = this.config.toMultiType;
    if (toMultiType) {
      return toMultiType(this);
    }

It should basically just call yup.oneOf with the list of resolved yup objects for each element in the array. For your case, it should likely normalise to this array in https://github.com/kristianmandrup/schema-to-yup/blob/master/src/entry.js

class YupSchemaEntry extends Base {
  constructor(opts) {
    super(opts.config);
    const { schema, name, key, value, config, builder } = opts;
    this.builder = builder
    this.opts = opts;
    this.schema = schema;
    this.key = key;
    this.value = value || {};
    this.config = config || {};
    this.name = name;
    const type = Array.isArray(value) ? "array" : value.type;
    this.kind = type === "array" ? "multi" : "single";
    this.type = type;
    this.setTypeHandlers();
    this.setPropertyHandler();
  }

Ideally, try subclassing YupSchemaEntry and pass your custom factory method createYupSchemaEntry in config which returns an instance of this subclass.

See https://github.com/kristianmandrup/schema-to-yup/blob/master/src/yup-builder.js#L25

Would be amazing to then integrate your solution in the library source code so it comes out of the box.

It should be resolved similar to how refValueFor does it: https://github.com/kristianmandrup/schema-to-yup/blob/master/src/types/mixed/mixed.js#L298

field.required().oneOf([
  yup.ref(propRefName),
  // more resolved yup field schemas
])
kristianmandrup commented 2 years ago

I can see there are tests for oneOf for the string type: https://github.com/kristianmandrup/schema-to-yup/blob/master/test/types/string/oneOf.test.js

Perhaps try running these tests: jest oneOf and work with them, adding your particular scenarios. The latest version of the lib has support for much more detailed logging:

{
  logging: true,
  logDetailed: [{
    propName: 'exclusiveMinimum',
    key: 'age'
  }],
}
kristianmandrup commented 2 years ago

The latest commit to master contains a sample implementation for https://github.com/kristianmandrup/schema-to-yup/blob/master/src/multi-property-value-resolver.js#L25 multi type resolution

BreizhReloaded commented 2 years ago

Such a detailed answer, thank you!

Should I then run some tests with your latest commit?

kristianmandrup commented 2 years ago

I can see there are already tests for oneOf but rather simple ones (testing oneOf on a list of strings). I suggest you start from there and expand on those tests to include your specific scenario that fails, then turn on detailed logging for your particular field where oneOf constraint is used.

Currently oneOf assumes it will receive a list of simple values, as in: values = Array.isArray(values) ? values : [values]; which are passed directly to addConstraint which builds the yup field constraint for those values.

  oneOf() {
    let values =
      this.constraints.enum || this.constraints.oneOf || this.constraints.anyOf;
    if (this.isNothing(values)) return this;
    values = Array.isArray(values) ? values : [values];
    // using alias
    const alias = ["oneOf", "enum", "anyOf"].find(key => {
      return this.constraints[key] !== undefined;
    });
    return this.addConstraint(alias, { values });
  }

  notOneOf() {
    const { not, notOneOf } = this.constraints;
    let values = notOneOf || (not && (not.enum || not.oneOf));
    if (this.isNothing(values)) return this;
    values = Array.isArray(values) ? values : [values];
    return this.addConstraint("notOneOf", { values });
  }

What needs to happen is a further step, something like this

  oneOf() {
    let values =
      this.constraints.enum || this.constraints.oneOf || this.constraints.anyOf;
    if (this.isNothing(values)) return this;
    values = Array.isArray(values) ? values : [values];
    const resolvedValues = this.resolveValues(values)
    // using alias
    const alias = ["oneOf", "enum", "anyOf"].find(key => {
      return this.constraints[key] !== undefined;
    });
    return this.addConstraint(alias, { values: resolvedValues });
  }

  notOneOf() {
    const { not, notOneOf } = this.constraints;
    let values = notOneOf || (not && (not.enum || not.oneOf));
    if (this.isNothing(values)) return this;
    values = Array.isArray(values) ? values : [values];
    const resolvedValues = this.resolveValues(values)
    return this.addConstraint("notOneOf", { values: resolvedValues });
  }

  resolveValues(values) {
    const schemaValues = values
    const resolvedValidatorSchemas = schemaValues.map(value => {
      return this.isObjectType(value) ? resolveValue(value) : value
    })
    return this.mixed().oneOf(resolvedValidatorSchemas)
  }  

  resolveValue(value) {
    const { createYupSchemaEntry } = this.config
    const opts = { schema: this.schema, key: this.key, value, config: this.config }
    return createYupSchemaEntry(opts)  
  }

I don't believe the library currently supports const, which seem to be one of the core issues.

A simple const handler could be implemented something like this

  const() {
    let value =this.constraints.const
    if (this.isNothing(value)) return this;
    // TODO: resolve const data ref if valid format
    if (this.isDataRef(value)) {
      const dataRefPath = this.normalizeDataRefPath(value)
      value = yup.ref(dataRefPath)
    }    
    return this.addConstraint('const', { value });
  }

  // TODO: investigate yup.ref
  normalizeDataRefPath(value) {
    // remove first part before /
    const parts = value.split('/').shift()
    return parts.join('/')
  }

  isDataRef(value) {
    return this.isPresent(value.$data)
  }

You can expand with additional constraint handlers (such as for const) using the config object as well as described in https://github.com/kristianmandrup/schema-to-yup#custom-constraint-handler-functions

https://ajv.js.org/json-schema.html#const

const The value of this keyword can be anything. The data is valid if it is deeply equal to the value of the keyword.

Example

schema: {const: "foo"}

valid: "foo"

invalid: any other value

The same can be achieved with enum keyword using the array with one item. But const keyword is more than just a syntax sugar for enum. In combination with the $data reference it allows to define equality relations between different parts of the data. This cannot be achieved with enum keyword even with $data reference because $data cannot be used in place of one item - it can only be used in place of the whole array in enum keyword.

Example

schema:

{
  type: "object",
  properties: {
    foo: {type: "number"},
    bar: {const: {$data: "1/foo"}}
  }
}

valid: {foo: 1, bar: 1}, {}

invalid: {foo: 1}, {bar: 1}, {foo: 1, bar: 2}

#

kristianmandrup commented 2 years ago

See the latest commit for the changes highlighted above. Should provide the guiding light to build on.

kristianmandrup commented 2 years ago

The latest release should include everything you need. Now has proper support for oneOf, const and even title and description metadata for use in error messages etc. If you still have issues/questions, please let me know.