kristianmandrup/schema-to-yup

Support for Yup.ref

Closed this issue · 9 comments

Hi! First, thanks for the awesome library and all the work you do. I'm wondering if there is way to use (or if there are plans to support) Yup.ref as documented here in the Yup readme.

Basically ref allows you to reference the value of a sibling (or sibling descendant) field to validate the current field. I see refValue in the docs for comparing equality of field values, but it would be really useful to be able to compare in different ways.

Yup.ref is supported in the Yup docs for the following:

For instance, if you want to ensure that a start date is after an end date:

Test Schema:

const schema = {
  type: "object",
  properties: {
    startDate: {
      type: "number",
      required: true,
    },
    endDate: {
      type: "number",
      required: true,
      min: "startDate",
    },
  },
};

With support for .ref the schema above could output

const yupSchema = yup.object().shape({
  startDate: yup.number().required(),
  endDate: yup.number().required().min(yup.ref("startDate")),
});

Hey @kristianmandrup I hate to be a bother but am curious if you have any thoughts on the above. Cheers!

I think that is an excellent idea. I will use your test/scenario case which demonstrates the schema and expected results in my attempt at implementing it. I've been busy at (new) work lately. Just moved to the UK, London again as well.

Awesome. Basically the expected behavior would be for fields to parse any string provided to length, min, max, lessThan, moreThan as referring to the value of the field with matching key. Test case might look roughly like this:

describe("name schema", () => {
  const fieldReferenceJsonSchema = {
    type: "object",
    properties: {
      startDate: {
        type: "number",
        required: true,
      },
      endDate: {
        type: "number",
        required: true,
        min: "startDate",
      },
    },
  };

  const schema = buildYup(fieldReferenceJsonSchema);

  test("invalid json is invalid", async () => {
    try {
      const valid = schema.isValid({ startDate: 10, endDate: 5 });
      expect(valid).toBe(false);
    } catch (e) {
      console.log(e);
    }
  });

  test("valid json is valid", async () => {
    try {
      const valid = schema.isValid({ startDate: 10, endDate: 15 });
      expect(valid).toBe(false);
    } catch (e) {
      console.log(e);
    }
  });
});

Is that kind of what you were thinking of?

So let's start with min and max. These are all handled in the generic Constraint handlers.

Constraint class

  addConstraints(method, names = []) {
    names.map(name => {
      const value = this.validateAndTransform(name);
      this.addConstraint(name, { method, value });
    });
    return this;
  }

constraint-builder.js

  addConstraint(propName, opts) {
    const constraint = this.build(propName, opts);
    if (constraint) {
      this.typeHandler.base = constraint;
      // const { _whitelist } = constraint;
      // const list = _whitelist && _whitelist.list;
      return constraint;
    }
    return false;
  }

If we go into the build method of ConstraintBuilder

    const constrainFnNames = [
      "multiValueConstraint",
      "presentConstraintValue",
      "nonPresentConstraintValue"
    ];
    let newBase;
    for (let name of constrainFnNames) {
      const fnName = this[name].bind(this)
      const constrValue = this.getFirstValue([value, values, constraintValue])
      newBase = fnName(constrValue, constrOpts);
      if (newBase) break;
    }

This essentially means that it tries to call each of the methods multiValueConstraint, presentConstraintValue and nonPresentConstraintValue in that order until one of them returns something "truthy". For each it simply passes the arguments for that constraint such as min.

So for it to work as min(yup.ref("startDate")) the getFirstValue used to set the constrValue must be more "powerful" and able to determine when to use Yup.ref rather than the value "as is".

We have acces to constraintName which is the name of the constraint such as min. We also have this.typeHandler which references the type class such as YupString which has the type instance var (string).

We could then use a refMap with type: contraint names to determine if we should try to use Yup.ref

  // ... add more info to call
  const constrValue = this.getFirstValue([value, values, constraintValue], {constraintName, type})

  // change method and use additional info to decide on use of Yup.ref
  getFirstValue(potentialValues, {constraintName, type}) {
    const isDefined = this.isPresent.bind(this)
    const yupRefConstraints = this.yupRefMap[type]
    const value = potentialValues.filter(isDefined)[0]
    if (yupRefConstraints) {
      const useYupRef = yupRefConstraints.includes(constraintName)
      if (useYupRef && this.isStringType(value)) {
        return Yup.ref(value)
      }        
    }    
    return value
  }

  yupRefMap() {
    return {
      "string": ["length", "min", "max"],
      "number": ["min", "max", "lessThan", "moreThan"],
      "date": ["min", "max"],
      "array": ["length", "min", "max"],
    }
  }

Something like this could be a starting point for you to try out. I've created a yup-ref branch with the changes outlined above which you can try out with your sample test case and work from there.

Hey @kristianmandrup, thank you so much for looking into this. I think something like this is going to work great, but I can't seem to get a test case up and running. I have to admit it's a bit over my head. I've been trying to follow the basic pattern used for refValueFor but am still stuck. I've included the test file I'm attempting to use and an error message below. I'd really like to help see this feature added but am a bit out of my depth -- can you help guide me in the right direction?

/test/yup-ref.test.js

const innerSchema = {
  required: ["startDate", "endDate"],
  properties: {
    startDate: {
      type: "number",
    },
    endDate: {
      type: "number",
      min: "startDate",
    },
  },
  type: "object",
};

const { buildYup } = require("../src");

describe("Date schema for yup.ref", () => {
  let dateSchema, config, schema;
  beforeEach(() => {
    dateSchema = {
      $schema: "http://json-schema.org/draft-07/schema#",
      $id: "http://example.com/login.schema.json",
      title: "Date",
      description: "Date form",
      type: "object",
      ...innerSchema,
    };

    config = {
      logging: true,
    };
    schema = buildYup(dateSchema, config);
  });

  it("is invalid with invalid data", async () => {
    try {
      const valid = schema.isValid({ startDate: 10, endDate: 5 });
      expect(valid).toBe(false);
    } catch (e) {
      console.log(e);
    }
  });

  it("is valid with valid data", async () => {
    try {
      const valid = schema.isValid({ startDate: 10, endDate: 15 });
      expect(valid).toBe(true);
    } catch (e) {
      console.log(e);
    }
  });
});

Attempting to test the file produces the following error for each test:
Screen Shot 2022-07-13 at 20 27 57

Alright, I'll have a look this weekend.

I fixed the issue. Was an error in the code regarding validating constraints. I've added your test case for yup-ref and both tests pass. I've also added a section to the Readme regarding this new functionality.

Should now be available on master and npm release schema-to-yup@1.11.13

Please note that it has only been tested for your example test case above. We need to add test cases to cover the other cases. Could you help expand the test case? Simply clone from latest master or yup-ref branch ;)

See branch yup-ref-date for latest developments, including attempt to make date type work with Yup.ref
It seems to work as well now, however I had to "loosen the constraints" a bit. Was merged into master I think