senecajs/seneca-entity

View on GitHub
src/lib/make_entity.ts

Summary

Maintainability
F
5 days
Test Coverage
/* Copyright (c) 2012-2023 Richard Rodger and other contributors, MIT License */

import { Canon, CanonSpec } from '../types'

const proto = Object.getPrototypeOf

const toString_map: any = {
  // '': make_toString(),
}

// `null` represents no entity found.
const NO_ENTITY = null

// `null` represents no error.
const NO_ERROR = null

const DisallowAsDirective: Record<string, any> = {
  id$: true,
  custom$: true,
  directive$: true,
  merge$: true,
}

function entargs(this: any, ent: Entity, args: any) {
  args.ent = ent

  // TODO: should this be: null != ?

  if (this.canon.name !== null) {
    args.name = this.canon.name
  }
  if (this.canon.base !== null) {
    args.base = this.canon.base
  }
  if (this.canon.zone !== null) {
    args.zone = this.canon.zone
  }

  let directives = Object.keys(ent.directive$).filter(
    (dname) => dname.endsWith('$') && !DisallowAsDirective[dname],
  )

  for (let dname of directives) {
    args[dname] = (ent as any).directive$[dname]
  }

  return args
}

class Entity implements Record<string, any> {
  // Canon spec in string format: "zone/base/name".
  entity$: string

  // Debugging mark.
  mark$?: string

  // NOTE: this will be moved to a per-instance prototype
  private$ = {
    canon: null as any,
    promise: false,
    get_instance: (): any => null,
    entargs,
    options: {} as any,
  }

  constructor(canon: any, seneca: any, options: any) {
    const private$: any = this.private$

    private$.get_instance = function () {
      return seneca
    }
    private$.canon = canon
    private$.entargs = entargs
    private$.options = options

    this.private$ = this.private$

    // use as a quick test to identify Entity objects
    // returns compact string zone/base/name
    this.entity$ = this.canon$()
  }

  // Properties without '$' suffix are persisted
  // id property is special: created if not present when saving
  //   lack of id indicates new data record to create
  //   to set id of a new record, use id$
  // func$ functions provide persistence operations
  // args: (<zone>,<base>,<name>,<props>)
  // can be partially specified:
  // make$(name)
  // make$(base,name)
  // make$(zone,base,name)
  // make$(zone,base,null)
  // make$(zone,null,null)
  // props can specify zone$,base$,name$, but args override if present
  // escaped names: foo_$ is converted to foo
  make$(...args: any[]) {
    const self = this
    let first = args[0]
    let last = args[args.length - 1]
    let promise = self.private$.promise

    if ('boolean' === typeof last) {
      promise = last
      args = args.slice(0, args.length - 1)
    }

    let instance = self.private$.get_instance()

    // Set seneca instance, if provided as first arg.
    if (first && first.seneca) {
      instance = first
      // const seneca = first
      // self.private$.get_instance = function() {
      //  return seneca
      // }
      first = args[1]
      args = args.slice(1)
    }

    if (first && first.entity$ && 'function' === typeof first.canon$) {
      return first
    }

    // Pull out props, if present.
    const argprops = args[args.length - 1]
    let props: any = {}
    if (argprops && typeof argprops === 'object') {
      args.pop()
      props = { ...argprops }
    }

    // Normalize args.
    while (args.length < 3) {
      args.unshift(null)
    }

    let canon: any
    if ('string' === typeof props.entity$) {
      canon = parsecanon(props.entity$)
    } else if (props.entity$ && 'object' === typeof props.entity$) {
      canon = {}
      canon.zone = props.entity$.zone
      canon.base = props.entity$.base
      canon.name = props.entity$.name
    } else {
      let argsname = args.pop()
      argsname = argsname == null ? props.name$ : argsname
      canon = parsecanon(argsname)
    }

    const name = canon.name

    let base = args.pop()
    base = base == null ? canon.base : base
    base = base == null ? props.base$ : base

    let zone = args.pop()
    zone = zone == null ? canon.zone : zone
    zone = zone == null ? props.zone$ : zone

    const new_canon: any = {}
    new_canon.name = name == null ? self.private$.canon.name : name
    new_canon.base = base == null ? self.private$.canon.base : base
    new_canon.zone = zone == null ? self.private$.canon.zone : zone

    const entity: Entity = MakeEntity(new_canon, instance, {
      ...self.private$.options,
      promise,
    })

    for (const p in props) {
      if (Object.prototype.hasOwnProperty.call(props, p)) {
        if (!~p.indexOf('$')) {
          ;(entity as any)[p] = props[p]
        } else if (p.length > 2 && p.slice(-2) === '_$') {
          ;(entity as any)[p.slice(0, -2)] = props[p]
        }
      }
    }

    if (Object.prototype.hasOwnProperty.call(props, 'id$')) {
      ;(entity as any).id$ = props.id$
    }

    ;(self as any).log$ &&
      (self as any).log$('make', entity.canon$({ string: true }), entity)

    return entity
  }

  /** Save the entity.
   *  param {object} [data] - Subset of entity field values.
   *  param {callback~save$} done - Callback function providing saved entity.
   */
  save$(data: any, done?: any) {
    const self = this
    const si = self.private$.get_instance()

    let entmsg = { cmd: 'save', q: {}, ...self.private$.options.pattern_fix }
    let done$ = prepareCmd(self, data, entmsg, done)
    entmsg = self.private$.entargs(self, entmsg)

    const promise = self.private$.promise && !done$

    let res = promise
      ? entityPromise(si, entmsg)
      : (si.act(entmsg, done$), promise ? NO_ENTITY : self)
    return res // Sync: Enity self, Async: Entity Promise, Async+Callback: null
  }

  /** Callback for Entity.save$.
   *  @callback callback~save$
   *  @param {error} error - Error object, if any.
   *  @param {Entity} entity - Saved Entity object containing updated data fields (in particular, `id`, if auto-generated).
   */

  // provide native database driver
  native$(done?: any) {
    const self = this
    const si = self.private$.get_instance()
    const promise = self.private$.promise

    let entmsg = { cmd: 'native', ...self.private$.options.pattern_fix }
    let done$ = prepareCmd(self, undefined, entmsg, done)
    entmsg = self.private$.entargs(self, entmsg)

    let res =
      promise && !done
        ? entityPromise(si, entmsg)
        : (si.act(entmsg, done$), promise ? NO_ENTITY : self)
    return res // Sync: Enity self, Async: Entity Promise, Async+Callback: null
  }

  // load one
  // TODO: qin can be an entity, in which case, grab the id and reload
  // qin omitted => reload self

  /** Load the entity.
   *  param {object|string|number} [query] - Either a entity id, or a query object with field values that must match.
   *  param {callback~load$} done - Callback function providing loaded entity, if found.
   */
  load$(query: any, done?: any) {
    const self = this

    if ('function' === typeof query) {
      done = query
      query = null
    }

    const si = self.private$.get_instance()

    const q = normalize_query(query, self)
    let entmsg = {
      cmd: 'load',
      q,
      qent: self,
      ...self.private$.options.pattern_fix,
    }

    let done$ = prepareCmd(self, undefined, entmsg, done)
    entmsg = self.private$.entargs(self, entmsg)

    const promise = self.private$.promise && !done$

    // Empty query gives empty result.
    if (emptyQuery(q)) {
      return promise
        ? NO_ENTITY
        : (done && done.call(si, NO_ERROR, NO_ENTITY), self)
    }

    let res = promise
      ? entityPromise(si, entmsg)
      : (si.act(entmsg, done$), promise ? NO_ENTITY : self)

    // Sync: Enity self, Async: Entity Promise, Async+Callback: null
    return res
  }

  /** Callback for Entity.load$.
   *  @callback callback~load$
   *  @param {error} error - Error object, if any.
   *  @param {Entity} entity - Matching `Entity` object, if found.
   */

  // TODO: need an update$ - does an atomic upsert

  // list zero or more
  // qin is optional, if omitted, list all

  /** Load the entity.
   *  param {object|string|number} [query] - A query object with field values that must match, can be empty.
   *  param {callback~list$} done - Callback function providing list of matching `Entity` objects, if any.
   */

  // TODO: refactor list, remove, etc, as per save, load
  list$(query: any, done?: any) {
    const self = this

    if ('function' === typeof query) {
      done = query
      query = null
    }

    const si = self.private$.get_instance()
    const q = normalize_query(query, self, { inject_id: false })

    let entmsg = {
      cmd: 'list',
      q,
      qent: self,
      ...self.private$.options.pattern_fix,
    }

    const done$ = prepareCmd(self, undefined, entmsg, done)
    entmsg = self.private$.entargs(self, entmsg)

    const promise = self.private$.promise && !done$

    let res = promise
      ? entityPromise(si, entmsg)
      : (si.act(entmsg, done$),
        promise
          ? NO_ENTITY // NOTE: [] is *not* valid here, as result is async
          : self)

    // Sync: Enity self, Async: Entity Promise, Async+Callback: null
    return res
  }

  /** Callback for Entity.list$.
   *  @callback callback~list$
   *  @param {error} error - Error object, if any.
   *  @param {Entity} entity - Array of `Entity` objects matching query.
   */

  // remove one or more
  // TODO: make qin optional, in which case, use id

  /** Remove the `Entity`.
   *  param {object|string|number} [query] - Either a entity id, or a query object with field values that must match.
   *  param {callback~remove$} done - Callback function to confirm removal.
   */
  remove$(query: any, done?: any) {
    const self = this

    if ('function' === typeof query) {
      done = query
      query = null
    }

    const si = self.private$.get_instance()

    const q = normalize_query(query, self)
    let entmsg = self.private$.entargs(self, {
      cmd: 'remove',
      q,
      qent: self,
      ...self.private$.options.pattern_fix,
    })

    let done$ = prepareCmd(self, undefined, entmsg, done)
    const promise = self.private$.promise && !done$

    // empty query means take no action
    if (emptyQuery(q)) {
      return promise
        ? NO_ENTITY
        : (done$ && done$.call(si, NO_ERROR, NO_ENTITY), self)
    }

    let res = promise
      ? entityPromise(si, entmsg)
      : (si.act(entmsg, done$), promise ? NO_ENTITY : self)
    return res // Sync: Enity self, Async: Entity Promise, Async+Callback: null
  }

  // DEPRECATE: legacy
  delete$(query: any, done?: any) {
    return this.remove$(query, done)
  }

  /** Callback for Entity.remove$.
   *  @callback callback~remove$
   *  @param {error} error - Error object, if any.
   */

  fields$() {
    const self = this

    const fields = []
    for (const p in self) {
      if (
        Object.prototype.hasOwnProperty.call(self, p) &&
        typeof self[p] !== 'function' &&
        p.charAt(p.length - 1) !== '$'
      ) {
        fields.push(p)
      }
    }
    return fields
  }

  // TODO: remove
  close$(done?: any) {
    const self = this
    const si = self.private$.get_instance()

    let entmsg = self.private$.entargs(self, {
      cmd: 'close',
      ...self.private$.options.pattern_fix,
    })
    let done$ = prepareCmd(self, undefined, entmsg, done)

    const promise = self.private$.promise && !done$

    ;(self as any).log$ && (self as any).log$('close')

    return promise ? si.post(entmsg) : (si.act(entmsg, done$), self)
  }

  is$(canonspec: any) {
    const self = this

    const canon = canonspec
      ? canonspec.entity$
        ? canonspec.canon$({ object: true })
        : parsecanon(canonspec)
      : null

    if (!canon) return false

    let selfcanon = self.canon$({ object: true })
    let sckeys = Object.keys(selfcanon)

    let match = sckeys.length === Object.keys(canon).length

    if (match) {
      for (let key of sckeys) {
        match = match && selfcanon[key] === canon[key]
      }
    }

    return match
  }

  canon$(opt?: any) {
    const self = this

    const canon = self.private$.canon

    if (opt) {
      if (opt.isa) {
        const isa = parsecanon(opt.isa)

        // NOTE: allow null == void 0
        return (
          isa.zone == canon.zone &&
          isa.base == canon.base &&
          isa.name == canon.name
        )
      } else if (opt.parse) {
        return parsecanon(opt.parse)
      } else if (opt.change) {
        // DEPRECATED
        // change type, undef leaves untouched
        canon.zone = opt.change.zone == null ? canon.zone : opt.change.zone
        canon.base = opt.change.base == null ? canon.base : opt.change.base
        canon.name = opt.change.name == null ? canon.name : opt.change.name

        // explicit nulls+undefs delete
        if (null == opt.zone) delete canon.zone
        if (null == opt.base) delete canon.base
        if (null == opt.name) delete canon.name

        self.entity$ = self.canon$()
      }
    }

    return null == opt || opt.string || opt.string$
      ? // ? [
        //   (opt && opt.string$ ? '$' : '') +
        //   (null == canon.zone ? '-' : canon.zone),
        //   null == canon.base ? '-' : canon.base,
        //   null == canon.name ? '-' : canon.name,
        // ].join('/') // TODO: make joiner an option
        (opt && opt.string$ ? '$' : '') + canonstr(canon)
      : opt.array
        ? [canon.zone, canon.base, canon.name]
        : opt.array$
          ? [canon.zone, canon.base, canon.name]
          : opt.object
            ? { zone: canon.zone, base: canon.base, name: canon.name }
            : opt.object$
              ? { zone$: canon.zone, base$: canon.base, name$: canon.name }
              : [canon.zone, canon.base, canon.name]
  }

  // data = object, or true|undef = include $, false = exclude $
  data$(data?: any, canonkind?: any) {
    const self: any = this
    let val

    // TODO: test for entity$ consistent?

    // Update entity fields from a plain data object.
    if (data && 'object' === typeof data) {
      // does not remove fields by design!
      for (const f in data) {
        if (f.charAt(0) !== '$' && f.charAt(f.length - 1) !== '$') {
          val = data[f]
          if (val && 'object' === typeof val && val.entity$) {
            self[f] = val.id
          } else {
            self[f] = val
          }
        }
      }

      if (data.id$ != null) {
        self.id$ = data.id$
      }

      if (null != data.merge$) {
        self.merge$ = data.merge$
      }

      if (null != data.custom$) {
        self.custom$(data.custom$)
      }

      if (null != data.directive$) {
        self.directive$(data.directive$)
      }

      return self
    }

    // Generate a plain data object from entity fields.
    else {
      const include_$ = null == data ? true : !!data
      data = {}

      if (include_$) {
        canonkind = canonkind || 'object'
        let canonformat: any = {}
        canonformat[canonkind] = true
        data.entity$ = self.canon$(canonformat)

        if (0 < Object.keys(self.custom$).length) {
          data.custom$ = self.private$.get_instance().util.deep(self.custom$)
        }
      }

      const fields = self.fields$()
      for (let fI = 0; fI < fields.length; fI++) {
        if (!~fields[fI].indexOf('$')) {
          val = self[fields[fI]]
          if (val && 'object' === typeof val && val.entity$) {
            data[fields[fI]] = val.id
          }

          // NOTE: null is allowed, but not undefined
          else if (void 0 !== val) {
            data[fields[fI]] = val
          }
        }
      }

      return data
    }
  }

  clone$() {
    const self: any = this
    let deep = this.private$.get_instance().util.deep
    let clone = self.make$(deep({}, self.data$()))

    if (0 < Object.keys(self.custom$).length) {
      clone.custom$(self.custom$)
    }

    if (0 < Object.keys(self.directive$).length) {
      clone.directive$(self.directive$)
    }

    return clone
  }

  custom$(_props: any): any {
    return this
  }

  directive$(this: any, _directiveMap: Record<string, any>): any {
    return this
  }
}

// Return an entity operation result as a promise,
// attaching the meta callback argument to the result object for easier access.
function entityPromise(si: any, entmsg: any) {
  let attachMeta = true === entmsg.q?.meta$
  return new Promise((res, rej) => {
    si.act(entmsg, (err: any, out: any, meta: any) => {
      err
        ? rej((attachMeta ? (err.meta$ = meta) : null, err))
        : res(
            (attachMeta
              ? ((out?.entity$
                  ? proto(out)
                  : out || (out = { entity$: null })
                ).meta$ = meta)
              : null,
            out),
          )
    })
  })
}

function prepareCmd(ent: any, data: any, entmsg: any, done: any): any {
  if ('function' === typeof data) {
    done = data
  } else if (data && 'object' === typeof data) {
    // TODO: this needs to be deprecated as first param needed for
    // directives, not data - that's already in entity object
    ent.data$(data)

    entmsg.q = data
  }

  return null == done ? undefined : ent.done$ ? ent.done$(done) : done
}

function emptyQuery(q: any): boolean {
  return null == q || 0 === Object.keys(q).length
}

// Query values can be a scalar id, array of scalar ids, or a query object.
function normalize_query(qin: any, ent: any, flags?: { inject_id: boolean }) {
  let q = qin

  let inject_id = flags ? (false === flags.inject_id ? false : true) : true

  if (inject_id) {
    if ((null == qin || 'function' === typeof qin) && ent.id != null) {
      q = { id: ent.id }
    } else if ('string' === typeof qin || 'number' === typeof qin) {
      q = qin === '' ? null : { id: qin }
    } else if ('function' === typeof qin) {
      q = null
    }
  }

  // TODO: test needed
  // Remove undefined values.
  if (null != q) {
    for (let k in q) {
      if (undefined === q[k]) {
        delete q[k]
      }
    }
  }

  return q
}

// parse a canon string:
// $zone-base-name
// $, zone, base are optional
function parsecanon(str: CanonSpec) {
  let out: any = {}

  if (Array.isArray(str)) {
    return {
      zone: str[0],
      base: str[1],
      name: str[2],
    }
  }

  if (str && 'object' === typeof str && 'function' !== typeof str) return str

  if ('string' !== typeof str) return out

  const m = /\$?((\w+|-)\/)?((\w+|-)\/)?(\w+|-)/.exec(str)
  if (m) {
    const zi = m[4] == null ? 4 : 2
    const bi = m[4] == null ? 2 : 4

    out.zone = m[zi] === '-' ? void 0 : m[zi]
    out.base = m[bi] === '-' ? void 0 : m[bi]
    out.name = m[5] === '-' ? void 0 : m[5]
  } else {
    throw new Error(
      `Invalid entity canon: ${str}; expected format: zone/base/name.`,
    )
  }

  return out
}

function canonstr(canon: Canon) {
  canon = canon || { name: '' }
  return [
    null == canon.zone || '' === canon.zone ? '-' : canon.zone,
    null == canon.base || '' === canon.base ? '-' : canon.base,
    null == canon.name || '' === canon.name ? '-' : canon.name,
  ].join('/')
}

function handle_options(entopts: any, seneca: any): any {
  entopts = entopts || Object.create(null)
  let Jsonic = seneca.util.Jsonic

  if (entopts.hide) {
    Object.keys(entopts.hide).forEach((hidden_fields) => {
      //, function (hidden_fields, canon_in) {
      const canon_in = entopts.hide[hidden_fields]
      const canon = parsecanon(canon_in)

      const canon_str = [
        canon.zone == null ? '-' : canon.zone,
        canon.base == null ? '-' : canon.base,
        canon.name == null ? '-' : canon.name,
      ].join('/')

      toString_map[canon_str] = make_toString(
        canon_str,
        hidden_fields,
        entopts,
        Jsonic,
      )
    })
  }

  if (false === entopts.meta?.provide) {
    // Drop meta argument from callback
    ;(Entity.prototype as any).done$ = (done: any) => {
      return null == done
        ? undefined
        : function (this: any, err: any, out: any) {
            done.call(this, err, out)
          }
    }
  }

  return entopts
}

function make_toString(
  canon_str: string | undefined,
  hidden_fields_spec: any | undefined,
  opts: any | undefined,
  Jsonic: any,
) {
  opts = opts || { jsonic: {} }

  let hidden_fields: any[] = []

  if (Array.isArray(hidden_fields_spec)) {
    hidden_fields.concat(hidden_fields_spec)
  } else if (hidden_fields_spec && 'object' === typeof hidden_fields_spec) {
    Object.keys(hidden_fields_spec).forEach((k) => {
      hidden_fields.push(k)
    })
  }

  hidden_fields.push('id')

  return function (this: any) {
    return [
      '$',
      canon_str || this.canon$({ string: true }),
      ';id=',
      this.id,
      ';',
      jsonic_stringify(this, {
        omit: hidden_fields,
        depth: opts.jsonic.depth,
        maxitems: opts.jsonic.maxitems,
        maxchars: opts.jsonic.maxchars,
      }),
    ].join('')
  }
}

function MakeEntity(canon: any, seneca: any, opts: any): Entity {
  opts = handle_options(opts, seneca)

  const deep = seneca.util.deep

  const ent = new Entity(canon, seneca, opts)
  let canon_str = ent.canon$({ string: true })

  let toString = (
    toString_map[canon_str] ||
    toString_map[''] ||
    (toString_map[''] = make_toString(
      undefined,
      undefined,
      undefined,
      seneca.util.Jsonic,
    ))
  ).bind(ent)

  let custom$ = function (this: any, props: any) {
    if (
      null != props &&
      ('object' === typeof props || 'function' === typeof props)
    ) {
      Object.assign(this.custom$, deep(props))
    }
    return ent
  }

  // Place instance specific properties into a per-instance prototype,
  // replacing Entity.prototype.prototype

  let hidden = Object.create(Object.getPrototypeOf(ent))

  hidden.toString = toString
  hidden.custom$ = custom$

  hidden.directive$ = function (this: any, directiveMap: Record<string, any>) {
    if (null != directiveMap && 'object' === typeof directiveMap) {
      Object.assign(this.directive$, deep(directiveMap))
    }
    return ent
  }

  hidden.private$ = ent.private$
  hidden.private$.promise = !!opts.promise

  Object.setPrototypeOf(ent, hidden)

  delete (ent as any).private$
  return ent as Entity
}

MakeEntity.parsecanon = parsecanon
MakeEntity.canonstr = canonstr

function jsonic_strify(val: any, opts: any, depth: number) {
  depth++
  if (null == val) return 'null'

  var type = Object.prototype.toString.call(val).charAt(8)
  if ('F' === type && !opts.showfunc) return null

  // WARNING: output may not be jsonically parsable!
  if (opts.custom) {
    if (val.hasOwnProperty('toString')) {
      return val.toString()
    } else if (val.hasOwnProperty('inspect')) {
      return val.inspect()
    }
  }

  var out,
    i = 0,
    j,
    k

  if ('N' === type) {
    return isNaN(val) ? 'null' : val.toString()
  } else if ('O' === type) {
    out = []
    if (depth <= opts.depth) {
      j = 0
      for (let i in val) {
        if (j >= opts.maxitems) break

        var pass = true
        for (k = 0; k < opts.exclude.length && pass; k++) {
          pass = !~i.indexOf(opts.exclude[k])
        }
        pass = pass && !opts.omit[i]

        var str: string = jsonic_strify(val[i], opts, depth)

        if (null != str && pass) {
          var n = i.match(/^[a-zA-Z0-9_$]+$/) ? i : JSON.stringify(i)
          out.push(n + ':' + str)
          j++
        }
      }
    }
    return '{' + out.join(',') + '}'
  } else if ('A' === type) {
    out = []
    if (depth <= opts.depth) {
      for (; i < val.length && i < opts.maxitems; i++) {
        var str: string = jsonic_strify(val[i], opts, depth)
        if (null != str) {
          out.push(str)
        }
      }
    }
    return '[' + out.join(',') + ']'
  } else {
    var valstr = val.toString()

    if (
      ~' "\'\r\n\t,}]'.indexOf(valstr[0]) ||
      !~valstr.match(/,}]/) ||
      ~' \r\n\t'.indexOf(valstr[valstr.length - 1])
    ) {
      valstr = "'" + valstr.replace(/'/g, "\\'") + "'"
    }

    return valstr
  }
}

// Legacy Jsonic stringify
function jsonic_stringify(val: any, callopts: any) {
  try {
    var callopts = callopts || {}
    var opts: any = {}

    opts.showfunc = callopts.showfunc || callopts.f || false
    opts.custom = callopts.custom || callopts.c || false
    opts.depth = callopts.depth || callopts.d || 3
    opts.maxitems = callopts.maxitems || callopts.mi || 11
    opts.maxchars = callopts.maxchars || callopts.mc || 111
    opts.exclude = callopts.exclude || callopts.x || ['$']
    var omit = callopts.omit || callopts.o || []

    opts.omit = {}
    for (var i = 0; i < omit.length; i++) {
      opts.omit[omit[i]] = true
    }

    var str: string = jsonic_strify(val, opts, 0)
    str = null == str ? '' : str.substring(0, opts.maxchars)
    return str
  } catch (e) {
    return (
      'ERROR: jsonic.stringify: ' + e + ' input was: ' + JSON.stringify(val)
    )
  }
}

export { MakeEntity, Entity }