Skip to content

Latest commit

 

History

History
472 lines (375 loc) · 15 KB

property.litcoffee

File metadata and controls

472 lines (375 loc) · 15 KB

Property - controller of Object properties

The Property class is the secretive shadowy element that governs Object behavior and are bound to the Object via Object.defineProperty. It acts like a shadow Proxy/Reflector to the Object instance and provides tight control via the Getter/Setter interfaces.

Below are list of properties available to every instance of Property:

property type mapping description
name string direct name of the property
schema object direct a schema instance (usually Yang)
state object direct private object holding internal state
configurable boolean getter(state) defines whether this property can be redefined
enumerable boolean getter(state) defines whether this property is enumerable
content any computed getter/setter for state.value
context object computed dynamically generated using context
root Property computed dynamically returns the root Property instance
path XPath computed dynamically generate XPath for this Property from root

Class Property

debug    = require('debug')
delegate = require 'delegates'
context  = require './context'
Yang     = require './yang'
XPath    = require './xpath'

class Property
  @property: (prop, desc) ->
    Object.defineProperty @prototype, prop, desc

  logger: debug('yang:property')
  debug: (f) -> switch
    when debug.enabled @logger.namespace then switch
      when typeof f is 'function' then @logger @uri, [].concat(f())...
      else @logger @uri, arguments...
 
  constructor: (spec={}) ->
    # NOTE: ES6/CS2 does not support below
    # unless this instanceof Property then return new Property arguments...

    # 1. parse if spec is YANG definition (string)
    spec = Yang.parse spec if typeof spec is 'string'
    
    # 2. assign if spec is an instance of Yang schema
    schema = spec if spec instanceof Yang
    
    # 3. destructure spec as an object if not schema as instance
    { name, schema } = spec unless schema?
    
    # 4. parse if schema is YANG definition (string)
    schema = Yang.parse schema if typeof schema is 'string'
    schema ?= kind: 'anydata'

    # 5. initialize property instance
    @name = name ? schema.datakey
    @schema = schema
    @state = 
      strict:   false
      private:  false
      mutable:  `schema.config != false`
      attached: false
      replaced: false
      changed:  false
      locked:   false
      prior:    undefined
      value:    undefined
      parent:   null

    # 6. soft freeze this instance
    Object.preventExtensions this

  delegate @prototype, 'state'
    .access 'parent'
    .access 'strict'
    .getter 'private'
    .getter 'mutable'
    .getter 'attached'
    .getter 'replaced'
    .getter 'changed'
    .getter 'locked'
    .getter 'prior'
    .getter 'value'

  delegate @prototype, 'schema'
    .getter 'tag'
    .getter 'kind'
    .getter 'type'
    .getter 'default'
    .getter 'external'
    .getter 'binding'
    .method 'locate'
    .method 'lookup'

Computed Properties

  @property 'key',
    get: -> @name

  @property 'value',
    get: -> @state.value

  @property 'data',
    set: (value) -> @set value, { force: true }
    get: -> switch
      when @binding?.get? then @binding.get @context
      else @value

  @property 'enumerable',
    get: -> not @private and (@value? or @binding?)

  @property 'active',
    get: -> @enumerable and @value?

  @property 'change',
    get: -> switch
      when @changed and not @active then null
      when @changed then @data

  @property 'context',
    get: ->
      ctx = Object.create(context)
      ctx.opts = {}
      ctx.node = this
      Object.preventExtensions ctx
      return ctx

  @property 'root',
    get: ->
      return this if @kind is 'module'
      root = switch
        when @parent is this then this
        when @parent instanceof Property then @parent.root
        else this
      @state.path = undefined unless @state.root is root
      return @state.root = root

  @property 'path',
    get: ->
      if this is @root
        entity = switch
          when @kind is 'module' then '/'
          else '.'
        return XPath.parse entity, @schema
      @state.path ?= @parent.path.clone().append @key
      return @state.path

  @property 'uri',
    get: -> switch
      when @parent? and @parent.uri? then "#{@parent.uri}/#{@key}"
      when @parent? then @key
      else @schema.datapath ? @schema.uri

Instance-level methods

  clone: (state = {}) ->
    throw @error 'must clone with state typeof object' unless typeof state is 'object'
    copy = new @constructor this
    copy.state = Object.assign(Object.create(@state), state, origin: this)
    return copy

  equals: (a, b) -> switch @kind
    when 'leaf-list'
      return false unless a and b
      a = Array.from(new Set([].concat(a)))
      b = Array.from(new Set([].concat(b)))
      return false unless a.length is b.length
      a.every (x) => b.some (y) => x is y
    else a is b

get (key)

This is the main Getter for the target object's property value. When called with optional key it will perform an internal find operation to traverse/locate that value being requested instead of returning its own @data.

It also provides special handling based on different types of @data currently held.

  get: (key) -> switch
    when key?
      try match = @find key
      return unless match? and match.length
      switch
        when match.length is 1 then match[0].data
        when match.length > 1  then match.map (x) -> x.data
        else undefined
    else @data

set (value)

This is the main Setter for the target object's property value. It utilizes internal @schema attribute if available to enforce schema validations.

  set: (value, opts={}) ->
    opts.origin ?= this
    
    # @debug => "[set] enter..."
    unless @mutable or not value? or opts.force
      throw @error "cannot set data on read-only (config false) element", 'set'

    try value = @binding.set @context.with(opts), value if @binding?.set?
    catch e
      throw @error e, 'set'

    return this if value? and @equals value, @value # return if same value
    
    bypass = opts.bypass and @kind in ["leaf", "leaf-list"]
    @debug => "[set] applying schema..."        
    value = switch
      when @schema.apply? and not bypass
        subopts = Object.assign {}, opts, inner: true
        try @schema.apply value, this, subopts
        catch e then throw @error e, 'set'
      else value
    @debug => "[set] done applying schema..."
    return this if value instanceof Error
    return this if value? and @equals value, @value # return if same value

    @debug => "[set] replaced? #{@state.value?}"
    @state.replaced = @state.prior? or @state.value?
    @update value, opts

merge (value)

Performs a granular merge of value into existing @value if available, otherwise performs set operation.

  merge: (value, opts={}) ->
    opts.origin ?= this
    return @delete opts if value is null
    return @set value, opts

delete

  delete: (opts={}) ->
    opts.origin ?= this
    #return this if @state.value is null
    return this unless @state.value?
    if not opts.force and @binding?.delete?
      try @binding.delete @context.with(opts), null
      catch e
        throw @error e, 'delete'
    @state.replaced = @state.prior? # revisit if we need to do this
    @update null, opts

update

Updates the value to the data model. Called once for each node that is part of the change branch.

  update: (value, opts={}) ->
    opts.origin ?= this

    @state.changed or= @state.value isnt value
    @state.value = value

    @parent?.update this, opts
    return this

commit async transaction

Commits the changes to the data model. Called once for each node that is part of the change branch.

  lock: (opts={}) ->
    @state.locked = true
    @state.delta = @change
    opts.lock = this
    return this

  unlock: (opts={}) ->
    @state.locked = false
    # @state.delta = undefined
    delete opts.lock
    return this
  
  commit: (opts={}) ->
    return this unless @changed
    
    try
      await @lock opts
      
      # 1. perform the bound commit transaction
      if not opts.sync and @binding?.commit?
        (@debug => "[commit] execute commit binding...")
        await @binding?.commit? @context.with(opts)

      # 2. wait for the parent to commit unless called by parent
      await @parent?.commit? opts unless opts.inner

      # 3. self-clean only if no parent
      @clean opts if not @parent?

      
    catch err
      @debug => "[commit] revert due to #{err.message}"
      await @revert opts
      throw @error err, 'commit'
      
    finally
      @unlock opts
      
    return this

  revert: (opts={}) ->
    return unless @changed

    id = opts.seq
    @debug "[revert:#{id}] changing back from:", @state.value
    @debug "[revert:#{id}] changing back to:", @state.prior
    temp = @state.value
    @state.value = @state.prior
    @state.prior = temp # preserve what we were trying to change to within commit context

    @debug "[revert:#{id}] execute binding..." unless opts.sync
    try
      await @binding?.commit? @context.with(opts) unless opts.sync
    catch err
      @debug "[revert:#{id}] failed due to: #{err.message}"
      # throw @error err, 'revert'
    @debug "[revert:#{id}] cleaning up..."
    @clean opts

  clean: (opts={}) ->
    # @state.prior = @state.value # save current value as prior
    @state.prior = @toJSON() # save copy of current data as prior
    @state.changed = false
    @state.replaced = false
    @debug "[clean:#{opts.seq}] finalized commit, persisted:", @state.prior

attach (obj, parent, opts)

This call is the primary mechanism via which the Property instance attaches itself to the provided target obj. It defines itself in the target obj via Object.defineProperty.

  attach: (obj, parent, opts) ->
    return obj unless obj instanceof Object
    opts ?= { replace: false, force: false }
    @parent = parent

    # if joining for the first time, apply existing data unless explicit replace
    unless @attached
      # @debug "[attach] applying existing data for #{@name} (external: #{@external}) to:"
      # @debug obj
      name = switch
        when @parent?.external and @tag of obj then @tag
        when @external then @name
        when @name of obj then @name
        else "#{@root.name}:#{@name}" # should we ensure root is kind = module?

      @set obj[name], Object.assign {}, opts, inner: true

    unless opts.preserve
      try Object.defineProperty obj, @name,
          configurable: true
          enumerable: @enumerable
          get: (args...) => @get args...
          set: (args...) => @set args...
        
    @state.attached = true
    @debug => "[attach] attached into #{obj.constructor.name} container"
    return obj

find (pattern)

This helper routine can be used to allow traversal to other elements in the data tree from the relative location of the current Property instance. It returns matching Property instances based on the provided pattern in the form of XPATH or YPATH.

It is internally used via get and generally used inside controller logic bound inside the Yang expression as well as event handler listening on Model events.

It always returns an array (empty to denote no match) unless it encounters an error, in which case it will throw an Error.

  find: (pattern='.', opts={}) ->
    @debug => "[find] #{pattern}"
    pattern = XPath.parse pattern, @schema
    switch
      when pattern.tag is '/'  and this isnt @root then @root.find(pattern)
      when pattern.tag is '..' then @parent?.find pattern.xpath
      else pattern.apply(this) ? []

in (pattern)

A convenience routine to locate one or more matching Property instances based on pattern (XPATH or YPATH) from this Model.

  in: (pattern) ->
    try props = @find pattern
    return unless props? and props.length
    return switch
      when props.length > 1 then props
      else props[0]

defer (value)

Optionally defer setting the value to the property until root has been updated.

  defer: (value) ->
    @debug => "deferring '#{@kind}(#{@name})' until update at #{@root.name}"
    @root.once 'update', =>
      @debug => "applying deferred data (#{typeof value})"
      @data = value
    return value

error (msg)

Provides more contextual error message pertaining to the Property instance.

  error: (err, ctx) ->
    err = new Error err unless err instanceof Error
    err.uri ?= @uri
    err.src ?= this
    err.ctx ?= ctx
    return switch
      when @binding?.error? then @binding.error err
      else err

toJSON

This call creates a new copy of the current Property.content completely detached/unbound to the underlying data schema. It's main utility is to represent the current data state for subsequent serialization/transmission. It accepts optional argument tag which when called with true will tag the produced object with the current property's @name.

  toJSON: (key, state = true) ->
    value = switch
      when @kind is 'anydata' then undefined
      when state isnt true and not @mutable then undefined
      else @data
    value = "#{@name}": value if key is true
    return value

inspect

  inspect: ->
    return {
      name:    @name
      kind:    @kind
      path:    @path.toString()
      active:  @active
      private: @private
      mutable: @mutable
      changed: @changed
      schema: switch
        when @schema.uri?
          uri:      @schema.uri
          summary:  @schema.description?.tag
          datakey:  @schema.datakey
          datapath: @schema.datapath
          external: @schema.external
          children: @schema.children.map (x) -> x.uri
        else false
      value: @toJSON()
    }

Export Property Class

module.exports = Property