sylvainpolletvillard / ObjectModel

Strong Dynamically Typed Object Modeling for JavaScript
http://objectmodel.js.org
MIT License
467 stars 30 forks source link

How to nest SealedModels? #151

Closed eponymous301 closed 1 year ago

eponymous301 commented 2 years ago

Using the SealedModel() function from http://objectmodel.js.org/docs/examples/sealed.js, if I try to nest one sealed model inside another I get a validation error, e.g.:

    const NameModel = SealedModel({first: String, last: String})

    const PersonModel = SealedModel({age: Number, name: NameModel})

    const input = {
      age: 20,
      name: {
        first: 'John',
        last: 'Smith'
      }
    }

    PersonModel(input)

produces

TypeError: Undeclared properties in the sealed model definition: name.first,name.last

Is there a way to allow above composition?

(I am using v4.3.0 with node.js v16.15.0)

Thanks!

sylvainpolletvillard commented 2 years ago

Hello,

Yeah I did not think about nested models when writing this example of SealedModel, but that's an easy fix.

We will consider than when encoutering another nested model in the definition, the parent model is trusting its child model to validate its properties. So if you use SealedModel for child models as well, you should be fine.

Here is the completed SealedModel code:

import { ObjectModel } from "objectmodel";

const SealedModel = def => {
    let model = ObjectModel(def);
    model.sealed = true;
    model.extend = () => {
        throw new Error(`Sealed models cannot be extended`);
    };

    const checkUndeclaredProps = (obj, def, undeclaredProps, path) => {
        Object.keys(obj).forEach(key => {
            let val = obj[key],
                subpath = path ? path + "." + key : key;
            if(def instanceof Model){
                // trust nested model props validation
            } else if (!Object.prototype.hasOwnProperty.call(def, key)) {
                undeclaredProps.push(subpath);
            } else if (val && typeof val === "object" && Object.getPrototypeOf(val) === Object.prototype) {
                checkUndeclaredProps(val, def[key], undeclaredProps, subpath);
            }
        });
    };

    return model.assert(
        function hasNoUndeclaredProps(obj) {
            if (!model.sealed) return true;
            let undeclaredProps = [];
            checkUndeclaredProps(obj, this.definition, undeclaredProps);
            return undeclaredProps.length === 0 ? true : undeclaredProps;
        },
        undeclaredProps =>
            `Undeclared properties in the sealed model definition: ${undeclaredProps}`
    );
};

export default SealedModel;

Thanks for your report, I'll update it on the website as well.

eponymous301 commented 2 years ago

Excellent, thank you!

eponymous301 commented 1 year ago

Doc note - missing line import {Model} from 'objectmodel' at top of http://objectmodel.js.org/docs/examples/sealed.js

I noticed an issue when trying to add a nested sealed model as an optional field:

const NameModel = SealedModel({
  first: String,
  last: String
})

const PossiblyAnonymousPersonModel = SealedModel({
  age: Number,
  name: [NameModel]
})

const x = PossiblyAnonymousPersonModel({
  age: 20,
  name: {
    first: 'John',
    last: 'Doe'
  }
})

// TypeError: Undeclared properties in the sealed model definition: name.first,name.last

Omitting the optional nested model altogether works fine:

const y = PossiblyAnonymousPersonModel({
  age: 20
})

Setting the fields in the inner model to optional still produces the undeclared properties message, although only for provided input fields:

const NameModel = SealedModel({
  first: [String],
  last: [String]
})

const PossiblyAnonymousPersonModel = SealedModel({
  age: Number,
  name: [NameModel]
})

const z = PossiblyAnonymousPersonModel({
  age: 20,
  name: {
    first: "John"
  }
})

//  Undeclared properties in the sealed model definition: name.first
sylvainpolletvillard commented 1 year ago

Yes, the code of the SealedModel example is perfectible. I updated it , is it better now ? http://objectmodel.js.org/docs/examples/sealed.js

eponymous301 commented 1 year ago

Yes, optional nested models working now with SealedModel(). Thank you!

eponymous301 commented 1 year ago

Ah, sorry, now getting Undeclared properties in the sealed model definition: 0,1,2... appended to errors if I pass in a string, e.g.

const z = PossiblyAnonymousPersonModel('foobar')

// TypeError: expecting {
//         age: Number, 
//         name: [{
//                         first: [String], 
//                         last: [String] 
//                 }] 
// }, got String "foobar"
// Undeclared properties in the sealed model definition: 0,1,2,3,4,5
sylvainpolletvillard commented 1 year ago

Oh yeah that's an easy fix, the assertion should not be run at all if the argument is not an object

Add if(typeof obj !== "object") return; at the first line of checkUndeclaredProps assertion, like so:

import { ObjectModel } from "objectmodel";

const SealedModel = def => {
    const model = ObjectModel(def);
    model.sealed = true;
    model.extend = () => {
        throw new Error(`Sealed models cannot be extended`);
    };

    const isPlainObject = obj => typeof obj === "object" && Object.getPrototypeOf(obj) === Object.prototype
    const checkUndeclaredProps = (obj, def, undeclaredProps, path) => {
                if(typeof obj !== "object" || obj === null) return;
        Object.keys(obj).forEach(key => {
            let val = obj[key],
                subpath = path ? path + "." + key : key;
            if(isPlainObject(def) && !Object.prototype.hasOwnProperty.call(def, key)) {
                undeclaredProps.push(subpath);
            } else if (isPlainObject(val) && isPlainObject(def)) {
                checkUndeclaredProps(val, def[key], undeclaredProps, subpath);
            }
        });
    };

    return model.assert(
        function hasNoUndeclaredProps(obj) {
            if (!model.sealed) return true;
            let undeclaredProps = [];
            checkUndeclaredProps(obj, this.definition, undeclaredProps);
            return undeclaredProps.length === 0 ? true : undeclaredProps;
        },
        undeclaredProps =>
            `Undeclared properties in the sealed model definition: ${undeclaredProps}`
    );
};

export default SealedModel;
eponymous301 commented 1 year ago

That fixed, thank you!