sylvainpolletvillard / ObjectModel

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

Is there a way to compute default values? #107

Closed whitfin closed 4 years ago

whitfin commented 5 years ago

For example, if I have an object with an id field, can I autogenerate an id by default?

const T = new ObjectModel({
  id: String
})
.defaults({
  id: function () {
    // some function to compute an id
  }
});
sylvainpolletvillard commented 5 years ago

Hello,

No, because default values are actually set in the model prototype ; in your example, it will be T.prototype.id. So it is a unique reference for all model instances, shared by all instances. This is literally the value by default, it can't be different from one instance to another.

To do what you want, use a factory function instead to instanciate your models. You can declare as many different factories as needed, which makes this pattern both simple and flexible.:

T.create = function(properties){
    if(!properties.id) properties.id = generateUID();
    return new T(properties);
}
sylvainpolletvillard commented 5 years ago

closed due to inactivity

fpw23 commented 4 years ago

Is this still true in V4 with the reworked defaults/defaultTo? A factory function works on simple models but when I start nesting models there is no way to have the creation use the factory function.

sylvainpolletvillard commented 4 years ago

Right, this is now possible in v4 :

Breaking changes in v4 REWORK defaults assignment: defaults for object models are no longer assigned to the model prototype, but applied only once at model instanciation, and now work for nested properties default values.

You can use a getter/setter pair instead of a plain property value to get this computed behaviour.

I assume you want the computed defaut property to be computed only once at model instanciation. This can be done by using another undeclared private property as "cache" for the computed value:

const T = new ObjectModel({
  id: String
})
.defaultTo({
  get id() {
    if(this._id === undefined) this._id = generateUID()
    return this._id
  },
  set id(val){ // should not be required, see note below
    this._id = val 
  }
});

For more complex usecases, I still think factory functions are your best option.

Does that answer your problem?

Note: currently in v4 you need to add a setter too, otherwise you get an exception. It is because default values are autocasted too, and invalid default values are rejected before any model instanciation:

T.defaultTo({ id: 42 }) // TypeError: expecting id to be String, got Number 42

With computed default properties, this autocast check is not feasible since the value is retrieved only at model instanciation. So I consider this as a bug, and will try to fix it. You should expect a new version soon that will no longer require setters in the examples above.

sylvainpolletvillard commented 4 years ago

After a bit of thinking, I think this getter/setter solution should be done on the prototype, not through defaultTo method.

My reasoning is that while computed default values are different from one instance to another, and computed at instanciation time, the computation logic on the other hand is the same for all instances. So this computation logic should be part of the model prototype.

Take this example:

const Character = new ObjectModel({
    firstName: String,
    nickName: [String]
});

Character.prototype = {
    get nickName() {
        return this._nickName || this.firstName
    },
    set nickName(val) {
        this._nickName = val
    }
}

When the default value computation relies on another property value, using defaultTo no longer works. It is because default properties values are applied before properties passed in the constructor. So when the default nickName value is computed, this.firstName is yet to be defined. When using prototype, this is no longer a problem because getter/setters are preserved and the computation is done every time we read the property. Note that this is a plain old JavaScript solution, nothing specific to the lib here.

Now, one could have different expectations regarding whether computed defaults are computed once at instanciation or at every property read. But this "once at instanciation" behaviour can also be implemented with getter using another property as cache as explained previously. So I believe getters in prototype should answer all cases.

sylvainpolletvillard commented 4 years ago

Added to http://objectmodel.js.org/#common-questions

fpw23 commented 4 years ago

This works but the issue is having to name the property id then reference it as _id in the functions. The database I am using (ArangoDB) has a _key property that must have that name. I usually set this to a new guid thru a factory function. I was hoping to remove the factory function altogether and use the defaultTo feature to set this but I don't thing that is possible.

fpw23 commented 4 years ago
import * as sc from 'common-core/Schema'
import { UniqueId } from 'common-core/UniqueId'

export const OasisRuleCategoryAddRequestInfo = new sc.ObjectModel({
  _key: [sc.StringValidUUID],
  Name: sc.StringNotBlank,
  Description: sc.StringNotBlank,
  Color: sc.StringNotBlank,
  Icon: sc.StringNotBlank
})

OasisRuleCategoryAddRequestInfo.new = sc.SchemaNew(OasisRuleCategoryAddRequestInfo, {}, (v) => { v._key = UniqueId.uuId() })

-------------- here is the definition of SchemaNew --------------------------------

export const SchemaNew = (SchemaConstructor, defaultValues = {}, beforeCreate) => {
  return (fromData, options = {
    stripUnkown: true // will remove any props that do not match the model
  }) => {
    try {
      let fromObject = _.merge({}, defaultValues, fromData)
      if (options.stripUnkown) {
        let modelKeys = _.keys(SchemaConstructor.definition)
        fromObject = _.pick(fromObject, modelKeys)
      }
      if (_.isFunction(beforeCreate)) {
        beforeCreate(fromObject)
      }
      let newItem = new SchemaConstructor(fromObject)
      // log.Trace('Schema.SchemaNew', { fromObject: fromObject, fromData: fromData, newItem: newItem })
      return newItem
    } catch (err) {
      if (err.name === 'ModelErrors') {
        throw err
      } else {
        throw new Error(`Unexpected Error Building New Schema: ${err.message}`)
      }
    }
  }
}

I wrapped some common functions but here is basically what I want to achieve. The new function gets attached to the Info object. When I want to create a new object from the model I call the new function with the raw object, the raw object gets some defaults set and then is passed to the last param of SchemaNew where I set the new _key value to a uuid. Can't do this on the prototype level cause I need a new different UUID everytime I make an add request.

sylvainpolletvillard commented 4 years ago

Can't do this on the prototype level cause I need a new different UUID everytime I make an add request.

This should not be a problem, while the computation logic is the same for all instances, the computed UUID will be different for each instance because it is set as an own property in the getter.

How about this :

export const OasisRuleCategoryAddRequestInfo = new sc.ObjectModel({
  _key: [sc.StringValidUUID],
  Name: sc.StringNotBlank,
  Description: sc.StringNotBlank,
  Color: sc.StringNotBlank,
  Icon: sc.StringNotBlank
})

const KEY_CACHE = new Map();

OasisRuleCategoryAddRequestInfo.prototype = {
   get _key(){
      if(!KEY_CACHE.has(this)){
          KEY_CACHE.set(this, UniqueId.uuId())
      }
      return KEY_CACHE.get(this)
   },
   set _key(newKey){
     KEY_CACHE.set(this, newKey)
   }
}

Using a Map as cache for computed values is an alternative to using another property, if you don't want to "pollute" your objects with an additional property.

sylvainpolletvillard commented 4 years ago

closed after inactivity