feathersjs-ecosystem / feathers-hooks-common

Useful hooks for use with FeathersJS services.
https://hooks-common.feathersjs.com
MIT License
193 stars 89 forks source link

[FR] patch2Update hook #530

Closed FossPrime closed 1 year ago

FossPrime commented 5 years ago

Motivation

Patch calls are an endless pain when it comes to validation

  1. they break 'required' specs
  2. validation will fail with deep object patches such as 'a.b.c': 1
  3. Even an exotic validator who can handle those cases with a fetch, would run into database conflicts. The same issue you'd have with patch2Update.
  4. Require specialized hook chains, update usually works with the same create hooks

Solution

Allow us to set an application level hook that would convert patch calls to update calls before any service hooks notice.

Status

Update 1

While it's easy to do this as a service hook, it's much harder as an App hook, as it needs to run after all before:update for some collision protection. On feathers 3, the work around for hookless services also requires exotic hook placement with an after:all reverse app hook. On feathers 5, with koa hooks and hookless services, this will all be cleaner.

Update 2

The name is probably bad. The point is not to kill patch, but to improve it so that it doesn't have all of it's pitfalls. Patch is essential to the NoSQL way, specially for batch jobs.

FossPrime commented 5 years ago

Proposed code: Setup in application hooks for get before and before patch. Also setup in all service after 'get' hooks. https://github.com/feathersjs/feathers/issues/1548

/*
  Requirements:
  - Either: For App Hooks, with reverse hook support
    1. to be installed on the before:all, app hook
    2. For race condition detection (optional)
      - Install to the last before:update reverse app hook
      - add and update updatedAt records on your services
    3. To be installed in after:all reverse app hook (optional)
      - Cleans up get request to play well with other hooks
      - Skips after hooks to recieve a clean get request for Feathers 3
  - Or: for individual services
    1. Add to before:all
    2. Add as the last before:update hook
    3. Add as an after:all hook

  A hook that 
  1. converts single patch calls to update calls
  2. for multi patch, runs update before/after hooks, once for every record
    - while skipping the running of the service method/adapter
    - if update hooks run without error, will continue running the _patch call

  ToDo:
    - Convert to Feathers 4, 
      - This allows cleaner installation as beforeAfter App hook would not be neeeded

  - Known issues... 
    - sending two patch calls at the same time may lead to a race condition
      - Only one will save. Behavior differs based on latency.
      - Some protections are in place... this is unlikely
    - Do not run this as both an App Hook and a service hook, they will clash
      - Use exceptPath to disable the app hook on a service that has this hook
*/

import { skipRemainingHooks } from 'feathers-hooks-common'
import { merge } from 'lodash'

// const NS = 'patch2Update'
const cm = {
  exceptPath: []
}

// Ex: const blah = expand('a.b.c.d', 'last value')
function expand (str, val = {}) {
  return str.split('.').reduceRight((acc, currentValue) => {
    return { [currentValue]: acc }
  }, val)
}

function isGoodObj (o) {
  return typeof o === 'object' && (!Array.isArray(o)) && o !== null && Object.keys(o).length
}

// Takes dotted keys from an object and returns an object with real keys
// Only does first level
function objPatch (stale, fresh) { // {} || [{}, {}]
  const result = Array.isArray(fresh) ? [] : {}
  for (const k of Object.keys(fresh)) { // empty objects get skipped
    if (k.indexOf('.') > -1) {
      const firstKey = k.slice(0, k.indexOf('.'))
      const res = expand(k, fresh[k])[firstKey]
      result[firstKey] = result[firstKey] === undefined
        ? res
        : isGoodObj(result[firstKey])
          ? merge(result[firstKey], res)
          : res
    } else {
      result[k] = fresh[k]
    }
  }
  return merge(stale, result)
}

const patch2Update = async ctx => {
  ctx.params = ctx.params || {}

  if (cm.exceptPath.includes(ctx.path)) {
    return ctx
  }

  if (ctx.method === 'patch') {
    if (ctx.id === null || ctx.id === undefined) {
      return ctx // TODO: patch2Update has no support for null ID's... yet
    } else if (ctx.type === 'after') {
      return skipRemainingHooks()(ctx) // finish disabling the patch call. Disables softdelete.
    } else {
      return patch2UpdateSolo(ctx)
    }
  }

  // we want data as raw as possible, no hooks for our get
  // clean up, after get
  if (ctx.method === 'get' && ctx.params.$patch2UpdateHooklessGet) {
    if (ctx.type === 'after') {
      delete ctx.params.$patch2UpdateHooklessGet
    }
    return skipRemainingHooks()(ctx) // skip hooks before and after get (F3)
  }

  // to be done on the final, post service, `before` app hook
  if (ctx.method === 'update' && ctx.type === 'before') {
    return addSafetyCheck(ctx)
  }
}

const addSafetyCheck = async (ctx) => {
  ctx.params = ctx.params || {}
  ctx.params.query = ctx.params.query || {}
  if (!ctx.params.$patch2UpdateConverted) {
    return ctx
  } else if (!ctx.params.$patch2UpdateInitialBeforePassed) {
    ctx.params.$patch2UpdateInitialBeforePassed = true
    return ctx // don't run on initial before app hook
  } else if (
    ctx.params.$patch2UpdateConverted &&
    ctx.params.$patch2UpdateInitialBeforePassed
  ) {
    delete ctx.params.$patch2UpdateConverted
    delete ctx.params.$patch2UpdateInitialBeforePassed
  }
  // Apply safety checks
  // Checks for current or updated `updatedAt` by the millisecond
  if (ctx.data.updatedAt) {
    ctx.params.query.updatedAt = {
      $in: [ ctx.data.updatedAt, ctx.params.before.updatedAt ]
    }
  }
  // Todo: for extra collision detection, add sha1/256 support

  return ctx
}

/* 
  - 1. makes a get call
  - 2. merges the result and makes a update call
  - 3. sets result to disable the patch adapter call
*/
const patch2UpdateSolo = async (ctx) => {
  const myParams = {
    $patch2UpdateHooklessGet: true
  }

  const res = await ctx.service.get(ctx.id, myParams)

  const data = objPatch(res, ctx.data)

  // Setting result should bypass call to adapter
  // for safety checks
  ctx.params.$patch2UpdateConverted = true
  ctx.params.before = JSON.parse(JSON.stringify(res))

  ctx.result = await ctx.service.update(ctx.id, data, ctx.params)

  return skipRemainingHooks()(ctx)
}

export default function (opts) {
  const args = opts || {}
  cm.exceptPath = args.exceptPath || cm.exceptPath
  return patch2Update
}

/*
const testOb = {
  'a.b.c': 'first',
  d: {
    'e.f': 'second',
    g: 'third'
  },
  'h.i': {
    'j.k': 'fourth'
  },
  'l': {},
  'm': [],
  n: {
    o: 'fifth',
    p: {},
    q: {
      lostcount: 1,
      r: {
        lostcount: 2
      }
    }
  },
  'n.p': 'sixth',
  'n.q': {
    'r.s': 'seventh'
  }
}

const x = objExpand(testOb)
console.log(x) */
FossPrime commented 1 year ago

Code no longer works on dove. mongoKeys helps.