scheming

    2.1.7 • Public • Published

    Scheming!

    Build Status

    Define powerful object schemas in javascript. Includes default values, getters, setters, validators, type checking or coercion. Builds reactive objects which you can watch for changes. Written to be extensible and customizable. Works on the browser or the server.

    So what does it look like?

    Scheming = require('Scheming')
    
    User = Scheming.create 'User',
      name :
        type : String
        required : true
      email :
        type : String,
        required : true
        validate : (val) ->
          if val.match('@')
            return true
          else
            return 'An email address must have an @ symbol!'
      birthday : Date
      password :
        type : String
        setter : (val) ->
          return md5(val)
    
    Group = Scheming.create 'Group',
      name : String
      dateCreated : {type : Date, default : -> Date.now()}
      users : [User]
    
    jane = new User
      email : 'jane.gmail.com'
      birthday : '9/14/86'
      password : 'p@$$w0rd!'
    
    console.log User.validate jane
    # {name : 'Field is required.', email: 'An email address must have an @ symbol!'}
    
    jane.name = 'jane'
    jane.email = 'jane@gmail.com'
    
    console.log User.validate jane
    # null
    
    

    API Docs

    Scheming

    Scheming.THROTTLE

    Defines throttling strategies for resolution of changes to scheming models. If the selected option is not supported in the current environment, a console.warn will be issued and no change is made. Options are:

    • TIMEOUT sets a timeout of 0
    • IMMEDIATE uses setImmediate
    • ANIMATION_FRAME uses requestAnimationFrame

    Scheming.TYPES

    Defines the primitive types that can be assigned to a field in a schema definition. Each type defines a string name, an identifier, and a parser. A type may also optionally provide a constructor reference. For detailed reference, see the TYPE definitions in source. Note the Mixed type, which is effectively untyped, and will allow for any value to be assigned.

    Below are the default types and the ways that you can reference them when defining a schema:

    • String Scheming.TYPES.String 'string' String
    • Number Scheming.TYPES.Number 'number' Number
    • Integer Scheming.TYPES.Integer 'integer'
    • Date Scheming.TYPES.Date 'date' Date
    • Boolean Scheming.TYPES.Boolean 'boolean' Boolean
    • Mixed Scheming.TYPES.Mixed '*'

    For example, the following are equivalent:

    Scheming.create {name : Scheming.TYPES.String}
    Scheming.create {name : 'string'}
    Scheming.create {name : String}
    

    The Mixed type is intended for arbitrary untyped data, including nested objects, arrays, etc. Note that when watching properties of the Mixed type, a watch will fire on reference change (re-assignment), but will not fire on mutations of the value. If you need watches to propagate, then you need to use nested schemas so that Scheming knows about nested properties and can manage changes.

    Custom types

    You can extend the Scheming TYPES object to add support for custom types. For example:

    Scheming.TYPES.Symbol =
      constructor : Symbol
      string : 'symbol'
      identifier : (val) ->
        typeof val == "symbol"
      parser : (val) ->
        Symbol(val)
    
    # I can now declare Schemas with my new type
    Scheming.create {name : Symbol}
    

    Custom parsing and identifiers

    In addition to declaring new types, you can modify the currently existing types. For example, say you don't like dealing with javascript Date objects, and would rather use with moment.js.

    Scheming.TYPES.Date.identifier = moment.isMoment
    Scheming.TYPES.Date.parser = moment
    
    Person = Scheming.create birthday : Date
    
    bill = new Person {birthday : '9/14/86'}
    
    bill.birthday.format "YYYY MM DD"
    # "1986 09 14"
    # Bill's birthday is a momentjs object, and has the format method!
    

    Scheming.NESTED_TYPES

    In addition to the 'primitive' types defined in Scheming.TYPES, Schemas also support arrays values and nested schemas. Anywhere you can provide a type declaration, you can use the following nested types. This section assumes some knowledge of property configuration syntax. Take a look at the Schema.defineProperty docs.

    Arrays

    For any schema you can declare a property whose type is an array of values.

    Simple arrays:

    BlogPost = Scheming.create
      comments : [String] # an array of strings
      miscellaneous : ['*'] # an untyped array
    
    post = new BlogPost()
    post.comments = ['Hello', 'World']
    post.miscellaneous = ['Stuff', 2, null, {}]x
    

    Arrays with validation, defaults, etc. Here is a blog post which requires 2 or more comments to be valid. Note that the configuration is being applied to the array itself, not to the members of the array.

    BlogPost = Scheming.create
      comments :
        type : [String]
        default : []
        required : true
        validate : (comments) ->
          return comments.length >= 2
    

    Explicit Schemas

    Any Schema can have a property whose type is another nested schema. This can be used to create any depth of nesting, or to create circular type definitions. In all cases, a Schema constructor is a valid type definition. When a value is a assigned to a property whose type is Schema, if it is not already an instance of that Schema, it value will be run through the Schema constructor as part of parsing.

    Simple nested schemas:

    Car = Schema.create
      make : String
      model : String
    
    Person = Schema.create
      name : String
      car : Car
    
    mark = new Person {name : 'mark'}
    
    # Explicit construction and assignment
    # At the time of assignment, civic is already an instance of Car
    # so the Car constructor will not be invoked a second time
    civic = new Car {make : 'honda', model : 'civic'}
    mark.car = civic
    
    # Implicit construction
    # At the time of assignment, the value is a plain object. Therefore
    # the object is passed to the Car  constructor (or in strict mode,
    # an error is thrown)
    mark.car = {make : 'toyota', model : 'corolla'}
    mark.car instanceof Car # true
    

    This is fine for one-way type references. However, it is easy to conceive of data models with circular type references. What do we do in this case? The simple solution is to create both schemas first, then define their properties afterwards, so that the Schema references are valid.

    Simple circular type references:

    Person = Schema.create()
    Car = Schema.create()
    
    Person.defineProperties
      name : String
      car : Car
    
    Car.defineProperties
      make : String
      model : String
      owner : Person
    

    This model still presents a problem. What if my schemas are declared in different files? What if I don't want to juggle references, or be careful about the order in which schemas are declared? This is where lazy initialization comes in. When you create a Schema, you have the option to name it. See the docs on Scheming.create and Scheming.get to understand how to name and retrieve named schemas.

    If you have registered a named schema, you can create a type reference to that schema using the syntax 'Schema:Name'. Scheming will accept this as a valid type reference without evaluating immediately or throwing errors. The Schema reference will not be retrieved until the first time the identifier or parser is invoked. So as long as you have declared all of your schemas before you start creating instances, you don't have to worry about it. Note that lazy initialization will throw an error if the Schema reference does not exist at the time of initialization.

    Lazy initialization:

    # I am registering the Schema with the name 'Person'
    Person = Schema.create 'Person',
      name : String
      car : 'Schema:Car'
    
    # This would throw an error, because 'Schema:Car' does not resolve to a registered Schema
    bill = new Person
      name : 'Bill'
      car : {make : 'honda', model : 'civic'}
    
    # Now I am creating and registering the 'Car' Schema
    Car = Schema.create 'Car',
      make : String
      model : String
      # This reference is using the registered name of the Schema 'Person'
      owner : 'Schema:Person'
    
    # Success!
    bill = new Person
      name : 'Bill'
      car : {make : 'honda', model : 'civic'}
    

    Implicit Schemas

    What we have seen so far is the ability to explicitly create Schemas and reference them as types. While this is extremely powerful, sometimes you just want to declare nested objects on your Schema. When you do this, new anonymous Schemas are implicitly created and assigned as the type.

    In the example below, we create a blog post that has some flat properties, and creates two implicit schemas. The first is the author property, the second is the comments property. Each of these cause an anonymous schema to be created, and any assignment to that value will run the assigned object through the corresponding Schema constructor

    Blog = Scheming.create
      title : String
      content : String
      posted : Date
      author :
        name : String
        age : Number
      comments : [{
        text : String
        posted : Date
      }]
    

    Note one subtlety: the syntax for Complex Configuration and implicit schemas is basically the same. In both cases you are using property names and nested objects. Scheming determines whether to treat a nested object as property configuration or a nested schema based on the presence of the type key. This effectively makes type a reserved word for implicit Schemas.

    # Oops! In the example below, author is not a nested schema.
    # It is a property with a primitive type of string.
    Blog = Scheming.create
      author :
        name : String
        age : Number
        type : String
    

    Scheming.DEFAULT_OPTIONS

    The default options used when Scheming.create is invoked. If you prefer for all schemas to be created with the seal or strict options set to true, you can modify the default options. See the options on Scheming.create for details.

    Scheming.setThrottle(strategy)

    Sets the throttling strategy for resolving changes. Valid options are defined by Scheming.THROTTLE.

    Scheming.registerQueueCallback(callback)

    Registers a callback for when the first change is queued. This callback is guaranteed to be called only once before changes are resolved. This is useful for testing.

    Scheming.unregisterQueueCallback(callback)

    Unregisters the callback registered with Scheming.registerQueueCallback

    Scheming.registerResolveCallback(callback)

    Registers a callback to be called when a change is resolved. This callback is guaranteed to be called only once after a queued change and won't be called again until new queued changes are resolved. This is useful for testing.

    it 'should update my DOM', (done) ->
      Scheming.registerResolveCallback ->
        expect($('.bill')[0]).to.have.text 'bill'
      bill.name = 'bill'
    

    This can also be used for test runners like Protractor to hook into Angular:

    Scheming.registerQueueCallback $browser.$$incOutstandingRequestCount
    Scheming.registerResolveCallback -> $browser.$$completeOutstandingRequest(angular.noop)
    

    Scheming.unregisterResolveCallback

    Unregisters the callback registered with Scheming.registerResolveCallback

    Scheming.get(name)

    Retrieves a schema that has been built using Scheming.create.

    Scheming.create([name], schema, [opts])

    Creates a new Schema constructor.

    • name string optional If provided, registers the scheme with the given name. This must be defined if you wish to retrieve the schema later using the Scheming.get method. It is also necessary for lazy initialization of nested Schema types.
    • schema object A configuration which defines your new schema. Each key represents a supported field, each value a property configuration. See Schema.defineProperty for full specification.
    • opts object optional Allows for some additional configuration of your Schema. All options default to false, but the default values can be modified via Scheming.defaultOptions.
      • opts.seal boolean If true, instances of the schema object are sealed. That is, you will not be able to attach arbitrary values to the objects not explicitly defined in the schema.
      Person = Scheming.create {name : String}, {seal : true}
      
      bill = new Person {name : 'bill', age : 19}
      bill.home = 'Colorado'
      bill.name # 'bill'
      bill.age  # undefined
      bill.home # undefined
      
      • opts.strict boolean If true, when values are assigned to an instance of the schema object, they will not be type coerced. Instead, assignment will throw an error if the assigned value does not match the expected type. This allows for strict typing checking at runtime.
      Person = Scheming.create {age : Number}, {strict : true}
      
      bill = new Person()
      bill.age = 9   # success
      bill.age = '9' # Error : Error assigning '9' to age. Value is not of type number.
      
    • returns Schema

    Schema

    The constructor function returned by Scheming.create. Constructs objects based on the property definitions outlined in the schema. When you invoke the constructor, you can pass a model with initial values to be applied to the instance.

    Person = Scheming.create
      name : String
      age : Number
    
    lisa = new Person
      name : 'lisa'
      age : 8
    

    Schema.defineProperty(property, configuration)

    Defines properties on your Schema. This is where you specify properties and their expected type, define default values, getters, setters, and validators.

    • property string The property name.
    • configuration object or TYPE The property configuration. This object determines how the property is configured, and can get a bit complicated.

    Simple type configuration

    If you do not need any of the other features, you can simply provide a type. You can reference the primitive types in any of the ways outlined in Schema.TYPES

    Schema.create 'Person'
        name : String
        age : Number
        birthday : Date
    

    Complex configuration

    For more complex field configuration, pass a configuration object. The configuration object supports the following keys, outlined below:

    Schema.create
      age :
        type : Number
        default : 2
        getter : (val) -> val * 2
        setter : (val) -> val * 2
        validate : (val) -> val % 2 == 0
        required : true
    
    • type TYPE A valid type reference as outlined in Schema.TYPES
    • default value or function Specifies the default value a field should take if it is not defined in the constructor. If a function, the function is executed and the return value is set as the default.
    • getter function A getter function that is invoked on the data value before retrieval. Takes the original value as input, the returned value is returned on retrieval. Getter functions are invoked with the this context of the instance, and can be used to define virtual fields.
    • setter function A setter function that is invoked on the data before assignment. Setters are executed BEFORE type checking and parsing. The result of your setter function will still be run through type parsing to guarantee type integrity. Setter functions are not invoked if the value assigned is null or undefined. Setter functions are invoked with the this context of the instance.
    • validate function or Array of functions Validator functions, which are invoked when you run validation on a schema instance. Validators take the value as an input, and should return true if validation passes. They should return a string or throw an error indicating the error if validation occurs. If a validator returns any value that is not true or a string, validation will fail with a generic error message. See Schema.validate for details on how validation works. Validation functions are invoked with the this context of the instance.
    • required boolean A special validator that indicates whether the field is required.

    Schema.defineProperties(properties)

    A convenience method for defining Schema properties in bulk.

    Schema.validate(instance)

    Validates an instance of the schema and all child schema instances. Checks for required fields and runs all validators. Validators are not run on fields which are not defined.

    • returns errors object An object with any validation errors, where each key is the path that failed validation, and each value is an array of error messages. If validation passes, will return null.

    Validation success

    If validation succeeds (including if no validators are defined), validate() returns null

    Person = Scheming.create {name : String}
    
    bill = new Person {name : 'bill}
    
    errors = Person.validate bill # null
    

    Validation failure messages

    A validator function should return true if it passes. Any other return value will be treated as validation failure. If a validator returns a string, that string will be treated as the failure message. If a validator throws an error at any point, the error.message property will be treated as the failure message. Otherwise, the validation will fail with a generic error message.

    If multiple validators are defined, all will be run against the value. The errors object will return error messages for all validators that failed.

    Person = Scheming.create
      name :
        type : String
        validate : [
          -> return "Error number one"
          -> throw new Error "Error number two"
          -> return true
          -> return false
        ]
    
    bill = new Person()
    
    errors = Person.validate bill
    # returns null, because bill object does not have a name defined, and name is not required
    
    bill.name = 'bill'
    errors = Person.validate bill
    # {name : ["Error number one", "Error number two", "Validation error occurred."]}
    

    Required and Validators

    The required configuration is a special validator that checks if the value is defined. If required validation fails, other validators will not be run. This means that validators are guaranteed to receive a value, and do not need to do null checking.

    Person = Scheming.create
      name :
        type : String
        required : true
        validate : [
          -> return "Error number one"
          -> throw new Error "Error number two"
          -> return true
          -> return false
        ]
    
    bill = new Person()
    
    errors = Person.validate bill
    # {name : ["Field is required."]}
    
    

    Instance

    The object instance returned by newing up a Schema constructor.

    Instance.watch([properties], listener)

    Watches a schema instance for changes. The registered listener function will fire asynchronously each time one or more of the specified properties change.

    • properties [optional] String || [String] Specifies the properties to watch on the instance. Accepts a string representing a single property or an array of strings representing one or more properties. If no properties are specified, the entire object will be watched for a change on any property. Will throw an error if any property being watched is not declared as part of the Schema.
    • listener Function function(newVal, oldVal) The listener function that will be called each time the watched properties change.
      • Listener functions are called asynchronously with a setTimeout of 0. If multiple changes are made to an instance in a synchronous block of code, the listener will not be called for each change. It will be called once, with all changes aggregated.
      • A listener function is always called when the watch is set, even if there were no changes to the watched properties.
      • Listener functions are invoked with the current and previous values of the watched properties. If a watch is set against a single property, newVal and oldVal will be the new and previous values. If the watch is set against multiple properties, newVal and oldVal will be objects whose key / value pairs represent the watcher property names and their respective values.
      • In the case of a watch against the entire object, newVal and oldVal will represent the current and previous state of all properties of the object. However. Note that newVal is NOT a reference to the instance object, it is a plain object whose key / value pairs represent the current state of the instance.
      • Watches are "deep". This means changes to nested schemas are propagated up to the parent elements.
    • returns Function The unwatch function. Setting a watch always returns a function that, when invoked, will clear the watch listeners and allow for garbage collection. When setting a watch it is always your responsibility to clean up when you are done with it to avoid memory leaks & unexpected behavior.

    IMPORTANT Watchers and change detection depend on the set functionality of Object.defineProperty. This means that changes made by mutating a value will not necessarily be detected. The scheming library does overwrite the most common array mutators (splice, pop, push, etc.) for a seamless experience. But other less common mutations like Date setters will not be picked up. When in doubt, only manipulate schema instance data via assignment with the = operator, and do not use mutators.

    Examples

    For all examples, we will use the following schema. For more examples, see the tests.

    Person = Scheming.create 'Person',
      name : String
      age : Number
      mother : 'Schema:Person'
      friends : ['Schema:Person']
    
    lisa = new Person()
    

    Watching a single property

    When a watch is set, it will always be called with the current value, even if no changes were made to the object.

    lisa.watch 'name', (newVal, oldVal) ->
      # will be called when the event queue clears
      newVal == undefined
      oldVal == undefined
    

    Synchronous changes will be reflected when the watcher fires.

    lisa.name = 'lisa'
    lisa.watch 'name', (newVal, oldVal) ->
      newVal == 'lisa'
      oldVal == undefined
    

    Multiple synchronous changes are rolled up

    lisa.watch 'name', (newVal, oldVal) ->
      # this listener is called once
      newVal == 'lisa'
      oldVal == undefined
    
    lisa.name = 'a'
    lisa.name = 'b'
    lisa.name = 'lisa'
    

    Watching multiple properties

    lisa.watch ['name', 'age'], (newVal, oldVal) ->
      # newVal == {name : 'lisa', age : 7}
      # oldVal == {name : undefined, age : undefined}
    
    lisa.name = 'lisa'
    lisa.age = 7
    

    Cleaning up a watch

    unwatch = lisa.watch 'name', (newVal, oldVal) ->
      newVal == 'lisa'
    
    lisa.name = 'lisa'
    # some time later...
    unwatch()
    lisa.name = 'a' # watch no longer fires
    

    Changelog

    v2.1.6

    • Removed lodash as a dependency. Utility methods used were implemented in this project to decrease upgrade pains when lodash comes out with new versions (especially in browser usage)
    • Added property/fuzz testing for utility functions. These tests can be moved into their own task later if it become a problem

    v2.1.5

    • Update docs with unwatch, better explanation of Mixed types.
    • Update browser build so that lodash is not included. Lodash is already listed as a bower dependency, and this drops the size of the deliverable significantly. NOTE In most cases this should be a non-breaking change. However, if you are using scheming without using bower-main-files or similar, you may need to add a <script> tag to your html to include lodash on your page.

    v2.1.4

    • Set bower lodash dependency to 2.x || 3.x to reduce bower version conflicts on the client. Because they suck.

    v2.1.3

    • Add defensive semicolons to the built browser file to avoid conflicts in concatenation.

    v2.1.1

    • Fixed an issue where queueCallback was being called more than once within a resolve cycle if any changes were propagated.

    v2.1.0

    • Fixed an issue with array mutators where assigning an array to a skeeping model and then mutating that array would cause unexpected behavior
    • Significantly update code structure, breaking source up into multiple files

    v2.0.0

    • Reverse order of setter and parser functions. It makes more sense for parsers to be invoked AFTER setter function to guarantee typing, and to allow for custom parsing in setters.

    v1.4.0

    • TravisCI!
    • Adds registerQueueCallback, registerResolveCallback, unregisterQueueCallback, and unregisterResolveCallback functions, exposing hooks into Scheming's change management events.
    • Fixed issues with watching of arrays and propagating of changes in arrays of nested schemas, dodging the need to ever cloneDeep. Performance improvement!

    v1.3.0

    • Flag construction of schema instances as initialization, so that initializing a scheming model does not count as changes that will propagate on internal watches.
    • Fix a bug where multiple changes to an array of nested schemas was not correctly capturing the change state.
    • Polyfill array mutating functions that forces a clone and re-assignment so that otherwise mutating changes are captured.

    v1.2.0

    • Added setThrottle method to allow for different throttling strategies for change resolution on scheming models.

    v1.1.0

    • Update addWatcher so that internal watchers do not queue an immediate fire for performance improvements
    • Setters and getters are now invoked with the this context of the calling instance
    • Add flush to the public API, add deprecated warning for _flush

    v1.0.0

    • Initial release

    Keywords

    none

    Install

    npm i scheming

    DownloadsWeekly Downloads

    59

    Version

    2.1.7

    License

    none

    Last publish

    Collaborators

    • autoric
    • nicholasboll