adding save & load controllers

This commit is contained in:
Michael Shanks 2020-04-09 10:13:19 +01:00 committed by Martin McKeaveney
parent 195275fc9e
commit ebc1c44343
26 changed files with 243 additions and 164 deletions

View File

@ -27,7 +27,7 @@ import {
includes, includes,
filter, filter,
} from "lodash/fp" } from "lodash/fp"
import { events, eventsList } from "./events" import { events, eventsList } from "./events.mjs"
// this is the combinator function // this is the combinator function
export const $$ = (...funcs) => arg => flow(funcs)(arg) export const $$ = (...funcs) => arg => flow(funcs)(arg)
@ -241,7 +241,7 @@ export const retry = async (fn, retries, delay, ...args) => {
} }
} }
export { events } from "./events" export { events } from "./events.mjs"
export default { export default {
ifExists, ifExists,

View File

@ -0,0 +1,32 @@
export const getNewRecordValidationRule = (
invalidField,
messageWhenInvalid,
expressionWhenValid
) => ({
invalidField,
messageWhenInvalid,
expressionWhenValid,
})
export const commonRecordValidationRules = {
fieldNotEmpty: fieldName =>
getNewRecordValidationRule(
fieldName,
`${fieldName} is empty`,
`record['${fieldName}'] && record['${fieldName}'].length > 0`
),
fieldBetween: (fieldName, min, max) =>
getNewRecordValidationRule(
fieldName,
`${fieldName} must be between ${min.toString()} and ${max.toString()}`,
`record['${fieldName}'] >= ${min} && record['${fieldName}'] <= ${max} `
),
fieldGreaterThan: (fieldName, min, max) =>
getNewRecordValidationRule(
fieldName,
`${fieldName} must be greater than ${min.toString()} and ${max.toString()}`,
`record['${fieldName}'] >= ${min} `
),
}

View File

@ -1,18 +1,19 @@
import { map, reduce, filter, isEmpty, flatten, each } from "lodash/fp" import { map, reduce, filter, isEmpty, flatten, each } from "lodash/fp"
import { compileCode } from "../common/compileCode" import { compileCode } from "../common/compileCode.mjs"
import _ from "lodash" import _ from "lodash"
import { getExactNodeForKey } from "../templateApi/hierarchy" import {
import { validateFieldParse, validateTypeConstraints } from "../types" validateFieldParse,
import { $, isNothing, isNonEmptyString } from "../common" validateTypeConstraints,
import { _getContext } from "./getContext" } from "../schema/types/index.mjs"
import { $, isNonEmptyString } from "../common/index.mjs"
const fieldParseError = (fieldName, value) => ({ const fieldParseError = (fieldName, value) => ({
fields: [fieldName], fields: [fieldName],
message: `Could not parse field ${fieldName}:${value}`, message: `Could not parse field ${fieldName}:${value}`,
}) })
const validateAllFieldParse = (record, recordNode) => const validateAllFieldParse = (record, model) =>
$(recordNode.fields, [ $(model.fields, [
map(f => ({ name: f.name, parseResult: validateFieldParse(f, record) })), map(f => ({ name: f.name, parseResult: validateFieldParse(f, record) })),
reduce((errors, f) => { reduce((errors, f) => {
if (f.parseResult.success) return errors if (f.parseResult.success) return errors
@ -21,10 +22,10 @@ const validateAllFieldParse = (record, recordNode) =>
}, []), }, []),
]) ])
const validateAllTypeConstraints = async (record, recordNode, context) => { const validateAllTypeConstraints = async (record, model) => {
const errors = [] const errors = []
for (const field of recordNode.fields) { for (const field of model.fields) {
$(await validateTypeConstraints(field, record, context), [ $(await validateTypeConstraints(field, record), [
filter(isNonEmptyString), filter(isNonEmptyString),
map(m => ({ message: m, fields: [field.name] })), map(m => ({ message: m, fields: [field.name] })),
each(e => errors.push(e)), each(e => errors.push(e)),
@ -33,10 +34,10 @@ const validateAllTypeConstraints = async (record, recordNode, context) => {
return errors return errors
} }
const runRecordValidationRules = (record, recordNode) => { const runRecordValidationRules = (record, model) => {
const runValidationRule = rule => { const runValidationRule = rule => {
const isValid = compileCode(rule.expressionWhenValid) const isValid = compileCode(rule.expressionWhenValid)
const expressionContext = { record, _ } const expressionContext = { record }
return isValid(expressionContext) return isValid(expressionContext)
? { valid: true } ? { valid: true }
: { : {
@ -46,7 +47,7 @@ const runRecordValidationRules = (record, recordNode) => {
} }
} }
return $(recordNode.validationRules, [ return $(model.validationRules, [
map(runValidationRule), map(runValidationRule),
flatten, flatten,
filter(r => r.valid === false), filter(r => r.valid === false),
@ -54,23 +55,17 @@ const runRecordValidationRules = (record, recordNode) => {
]) ])
} }
export const validate = app => async (record, context) => { export const validateRecord = async (schema, record) => {
context = isNothing(context) ? _getContext(app, record.key) : context const model = schema.findModel(record.modelId)
const fieldParseFails = validateAllFieldParse(record, model)
const recordNode = getExactNodeForKey(app.hierarchy)(record.key)
const fieldParseFails = validateAllFieldParse(record, recordNode)
// non parsing would cause further issues - exit here // non parsing would cause further issues - exit here
if (!isEmpty(fieldParseFails)) { if (!isEmpty(fieldParseFails)) {
return { isValid: false, errors: fieldParseFails } return { isValid: false, errors: fieldParseFails }
} }
const recordValidationRuleFails = runRecordValidationRules(record, recordNode) const recordValidationRuleFails = runRecordValidationRules(record, model)
const typeContraintFails = await validateAllTypeConstraints( const typeContraintFails = await validateAllTypeConstraints(record, model)
record,
recordNode,
context
)
if ( if (
isEmpty(fieldParseFails) && isEmpty(fieldParseFails) &&

View File

@ -1,15 +1,23 @@
export const fullSchema = (models, views) => { export const fullSchema = (models, views) => {
const findModel = idOrName => const findModel = idOrName =>
models.find(m => m.id === idOrName || m.name === idOrName) models.find(
m => m.id === idOrName || m.name.toLowerCase() === idOrName.toLowerCase()
)
const findView = idOrName => const findView = idOrName =>
views.find(m => m.id === idOrName || m.name === idOrName) views.find(
m => m.id === idOrName || m.name.toLowerCase() === idOrName.toLowerCase()
)
const findField = (modelIdOrName, fieldName) => { const findField = (modelIdOrName, fieldName) => {
const model = models.find( const model = models.find(
m => m.id === modelIdOrName || m.name === modelIdOrName m =>
m.id === modelIdOrName ||
m.name.toLowerCase() === modelIdOrName.toLowerCase()
)
return model.fields.find(
f => f.name.toLowerCase() === fieldName.toLowerCase()
) )
return model.fields.find(f => f.name === fieldName)
} }
const viewsForModel = modelId => views.filter(v => v.modelId === modelId) const viewsForModel = modelId => views.filter(v => v.modelId === modelId)

View File

@ -12,7 +12,7 @@ import {
toNumberOrNull, toNumberOrNull,
$$, $$,
isSafeInteger, isSafeInteger,
} from "../../common" } from "../../common/index.mjs"
const arrayFunctions = () => const arrayFunctions = () =>
typeFunctions({ typeFunctions({

View File

@ -6,7 +6,7 @@ import {
parsedSuccess, parsedSuccess,
getDefaultExport, getDefaultExport,
} from "./typeHelpers" } from "./typeHelpers"
import { switchCase, defaultCase, isOneOf, toBoolOrNull } from "../../common" import { switchCase, defaultCase, isOneOf, toBoolOrNull } from "../../common/index.mjs"
const boolFunctions = typeFunctions({ const boolFunctions = typeFunctions({
default: constant(null), default: constant(null),

View File

@ -6,7 +6,7 @@ import {
parsedSuccess, parsedSuccess,
getDefaultExport, getDefaultExport,
} from "./typeHelpers" } from "./typeHelpers"
import { switchCase, defaultCase, toDateOrNull, isNonEmptyArray } from "../../common" import { switchCase, defaultCase, toDateOrNull } from "../../common"
const dateFunctions = typeFunctions({ const dateFunctions = typeFunctions({
default: constant(null), default: constant(null),
@ -21,13 +21,9 @@ const parseStringToDate = s =>
[defaultCase, parsedFailed] [defaultCase, parsedFailed]
)(new Date(s)) )(new Date(s))
const isNullOrEmpty = d => const isNullOrEmpty = d => isNull(d) || (d || "").toString() === ""
isNull(d)
|| (d || "").toString() === ""
const isDateOrEmpty = d => const isDateOrEmpty = d => isDate(d) || isNullOrEmpty(d)
isDate(d)
|| isNullOrEmpty(d)
const dateTryParse = switchCase( const dateTryParse = switchCase(
[isDateOrEmpty, parsedSuccess], [isDateOrEmpty, parsedSuccess],

View File

@ -5,7 +5,7 @@ import {
parsedSuccess, parsedSuccess,
getDefaultExport, getDefaultExport,
} from "./typeHelpers" } from "./typeHelpers"
import { switchCase, defaultCase, none, $, splitKey } from "../../common" import { switchCase, defaultCase, none, $, splitKey } from "../../common/index.mjs"
const illegalCharacters = "*?\\/:<>|\0\b\f\v" const illegalCharacters = "*?\\/:<>|\0\b\f\v"

View File

@ -10,7 +10,7 @@ import {
isArray, isArray,
has, has,
} from "lodash/fp" } from "lodash/fp"
import { $ } from "../../common" import { $ } from "../../common/index.mjs"
import { parsedSuccess } from "./typeHelpers" import { parsedSuccess } from "./typeHelpers"
import string from "./string" import string from "./string"
import bool from "./bool" import bool from "./bool"
@ -19,7 +19,7 @@ import datetime from "./datetime"
import array from "./array" import array from "./array"
import link from "./link" import link from "./link"
import file from "./file" import file from "./file"
import { BadRequestError } from "../../common/errors" import { BadRequestError } from "../../common/errors.mjs"
const allTypes = () => { const allTypes = () => {
const basicTypes = { const basicTypes = {
@ -67,8 +67,8 @@ export const validateFieldParse = (field, record) =>
export const getDefaultOptions = type => getType(type).getDefaultOptions() export const getDefaultOptions = type => getType(type).getDefaultOptions()
export const validateTypeConstraints = async (field, record, context) => export const validateTypeConstraints = async (field, record) =>
await getType(field.type).validateTypeConstraints(field, record, context) await getType(field.type).validateTypeConstraints(field, record)
export const detectType = value => { export const detectType = value => {
if (isString(value)) return string if (isString(value)) return string
@ -76,8 +76,7 @@ export const detectType = value => {
if (isNumber(value)) return number if (isNumber(value)) return number
if (isDate(value)) return datetime if (isDate(value)) return datetime
if (isArray(value)) return array(detectType(value[0])) if (isArray(value)) return array(detectType(value[0]))
if (isObject(value) && has("key")(value) && has("value")(value)) if (isObject(value) && has("key")(value) && has("value")(value)) return link
return link
if (isObject(value) && has("relativePath")(value) && has("size")(value)) if (isObject(value) && has("relativePath")(value) && has("size")(value))
return file return file

View File

@ -1,7 +1,6 @@
import { isString, isObjectLike, isNull, has, isEmpty } from "lodash/fp" import { isString, isObjectLike, isNull, has } from "lodash/fp"
import { import {
typeFunctions, typeFunctions,
makerule,
parsedSuccess, parsedSuccess,
getDefaultExport, getDefaultExport,
parsedFailed, parsedFailed,
@ -11,7 +10,7 @@ import {
defaultCase, defaultCase,
isNonEmptyString, isNonEmptyString,
isArrayOfString, isArrayOfString,
} from "../../common" } from "../../common/index.mjs"
const linkNothing = () => ({ key: "" }) const linkNothing = () => ({ key: "" })
@ -65,20 +64,7 @@ const options = {
}, },
} }
const isEmptyString = s => isString(s) && isEmpty(s) const typeConstraints = []
const ensurelinkExists = async (val, opts, context) =>
isEmptyString(val.key) || (await context.linkExists(opts, val.key))
const typeConstraints = [
makerule(
ensurelinkExists,
(val, opts) =>
`"${val[opts.displayValue]}" does not exist in options list (key: ${
val.key
})`
),
]
export default getDefaultExport( export default getDefaultExport(
"link", "link",

View File

@ -11,7 +11,7 @@ import {
defaultCase, defaultCase,
toNumberOrNull, toNumberOrNull,
isSafeInteger, isSafeInteger,
} from "../../common" } from "../../common/index.mjs"
const numberFunctions = typeFunctions({ const numberFunctions = typeFunctions({
default: constant(null), default: constant(null),

View File

@ -5,7 +5,7 @@ import {
parsedSuccess, parsedSuccess,
getDefaultExport, getDefaultExport,
} from "./typeHelpers" } from "./typeHelpers"
import { switchCase, defaultCase, $ } from "../../common" import { switchCase, defaultCase, $ } from "../../common/index.mjs"
const objectFunctions = (definition, allTypes) => const objectFunctions = (definition, allTypes) =>
typeFunctions({ typeFunctions({

View File

@ -12,7 +12,7 @@ import {
toNumberOrNull, toNumberOrNull,
isSafeInteger, isSafeInteger,
isArrayOfString, isArrayOfString,
} from "../../common" } from "../../common/index.mjs"
const stringFunctions = typeFunctions({ const stringFunctions = typeFunctions({
default: constant(null), default: constant(null),

View File

@ -1,6 +1,6 @@
import { merge } from "lodash" import { merge } from "lodash"
import { constant, isUndefined, has, mapValues, cloneDeep } from "lodash/fp" import { constant, isUndefined, has, mapValues, cloneDeep } from "lodash/fp"
import { isNotEmpty } from "../../common" import { isNotEmpty } from "../../common/index.mjs"
export const getSafeFieldParser = (tryParse, defaultValueFunctions) => ( export const getSafeFieldParser = (tryParse, defaultValueFunctions) => (
field, field,
@ -48,12 +48,11 @@ export const typeFunctions = specificFunctions =>
export const validateTypeConstraints = validationRules => async ( export const validateTypeConstraints = validationRules => async (
field, field,
record, record
context
) => { ) => {
const fieldValue = record[field.name] const fieldValue = record[field.name]
const validateRule = async r => const validateRule = async r =>
!(await r.isValid(fieldValue, field.typeOptions, context)) !(await r.isValid(fieldValue, field.typeOptions))
? r.getMessage(fieldValue, field.typeOptions) ? r.getMessage(fieldValue, field.typeOptions)
: "" : ""

View File

@ -2,6 +2,10 @@ import { newModel } from "../src/schema/models.mjs"
import { newView } from "../src/schema/views.mjs" import { newView } from "../src/schema/views.mjs"
import { getNewField } from "../src/schema/fields.mjs" import { getNewField } from "../src/schema/fields.mjs"
import { fullSchema } from "../src/schema/fullSchema.mjs" import { fullSchema } from "../src/schema/fullSchema.mjs"
import {
recordValidationRules,
commonRecordValidationRules,
} from "../src/schema/recordValidationRules.mjs"
export function testSchema() { export function testSchema() {
const addFieldToModel = (model, { type, name }) => { const addFieldToModel = (model, { type, name }) => {
@ -18,6 +22,10 @@ export function testSchema() {
addFieldToModel(contactModel, { name: "Is Active", type: "bool" }) addFieldToModel(contactModel, { name: "Is Active", type: "bool" })
addFieldToModel(contactModel, { name: "Created", type: "datetime" }) addFieldToModel(contactModel, { name: "Created", type: "datetime" })
contactModel.validationRules.push(
recordValidationRules(commonRecordValidationRules.fieldNotEmpty)
)
const activeContactsView = newView(contactModel.id) const activeContactsView = newView(contactModel.id)
activeContactsView.name = "Active Contacts" activeContactsView.name = "Active Contacts"
activeContactsView.map = "if (doc['Is Active']) emit(doc.Name, doc)" activeContactsView.map = "if (doc['Is Active']) emit(doc.Name, doc)"

View File

@ -1,95 +1,55 @@
import {
setupApphierarchy,
stubEventHandler,
basicAppHierarchyCreator_WithFields,
basicAppHierarchyCreator_WithFields_AndIndexes,
hierarchyFactory,
withFields,
} from "./specHelpers"
import { find } from "lodash"
import { addHours } from "date-fns" import { addHours } from "date-fns"
import { events } from "../src/common" import { events } from "../src/common"
import { testSchema } from "./testSchema.mjs"
import { validateRecord } from "../src/records/validateRecord.mjs"
import { getNewRecord } from "../src/records/getNewRecord.mjs"
describe("recordApi > validate", () => { describe("recordApi > validate", () => {
it("should return errors when any fields do not parse", async () => { it("should return errors when any fields do not parse", () => {
const { recordApi } = await setupApphierarchy( const schema = testSchema()
basicAppHierarchyCreator_WithFields const record = getNewRecord(schema, "Contact")
)
const record = recordApi.getNew("/customers", "customer")
record.surname = "Ledog" record.Name = "Ledog"
record.isalive = "hello" record["Is Active"] = "hello"
record.age = "nine" record.Created = "not a date"
record.createddate = "blah"
const validationResult = await recordApi.validate(record) const validationResult = validateRecord(schema, record)
expect(validationResult.isValid).toBe(false) expect(validationResult.isValid).toBe(false)
expect(validationResult.errors.length).toBe(3) expect(validationResult.errors.length).toBe(2)
}) })
it("should return errors when mandatory field is empty", async () => { it("should return errors when mandatory field is empty", () => {
const withValidationRule = (hierarchy, templateApi) => { const schema = testSchema()
templateApi.addRecordValidationRule(hierarchy.customerRecord)( const record = getNewRecord(schema, "Contact")
templateApi.commonRecordValidationRules.fieldNotEmpty("surname") record.Name = ""
)
}
const hierarchyCreator = hierarchyFactory(withFields, withValidationRule) const validationResult = validateRecord(schema, record)
const { recordApi } = await setupApphierarchy(hierarchyCreator)
const record = recordApi.getNew("/customers", "customer")
record.surname = ""
const validationResult = await recordApi.validate(record)
expect(validationResult.isValid).toBe(false) expect(validationResult.isValid).toBe(false)
expect(validationResult.errors.length).toBe(1) expect(validationResult.errors.length).toBe(1)
}) })
it("should return error when string field is beyond maxLength", async () => { it("should return error when string field is beyond maxLength", () => {
const withFieldWithMaxLength = hierarchy => { const schema = testSchema()
const surname = find( schema.findField("Contact", "Name").typeOptions.maxLength = 5
hierarchy.customerRecord.fields, const record = getNewRecord(schema, "Contact")
f => f.name === "surname" record.name = "more than 5 characters"
)
surname.typeOptions.maxLength = 5
}
const hierarchyCreator = hierarchyFactory( const validationResult = validateRecord(schema, record)
withFields,
withFieldWithMaxLength
)
const { recordApi } = await setupApphierarchy(hierarchyCreator)
const record = recordApi.getNew("/customers", "customer")
record.surname = "more than 5 chars"
const validationResult = await recordApi.validate(record)
expect(validationResult.isValid).toBe(false) expect(validationResult.isValid).toBe(false)
expect(validationResult.errors.length).toBe(1) expect(validationResult.errors.length).toBe(1)
}) })
it("should return error when number field is > maxValue", async () => { it("should return error when number field is > maxValue", () => {
const withFieldWithMaxLength = hierarchy => { const schema = testSchema()
const age = find(hierarchy.customerRecord.fields, f => f.name === "age") schema.findField("Deal", "Estimated Value").typeOptions.maxValue = 5
age.typeOptions.maxValue = 10 const record = getNewRecord(schema, "Deal")
age.typeOptions.minValue = 5 record["Estimated Value"] = 10
}
const hierarchyCreator = hierarchyFactory( const validationResult = recordApi.validate(schema, record)
withFields, expect(validationResult.isValid).toBe(false)
withFieldWithMaxLength expect(validationResult.errors.length).toBe(1)
)
const { recordApi } = await setupApphierarchy(hierarchyCreator)
const tooOldRecord = recordApi.getNew("/customers", "customer")
tooOldRecord.age = 11
const tooOldResult = await recordApi.validate(tooOldRecord)
expect(tooOldResult.isValid).toBe(false)
expect(tooOldResult.errors.length).toBe(1)
}) })
it("should return error when number field is < minValue", async () => { it("should return error when number field is < minValue", async () => {

View File

@ -1,21 +1,115 @@
const couchdb = require("../../db"); const couchdb = require("../../db")
const { cloneDeep, mapValues, keyBy, filter, includes } = require("lodash/fp")
const {
validateRecord,
} = require("../../../common/src/records/validateRecord.mjs")
const { events } = require("../../../common/src/common/events.mjs")
const { $, isNonEmptyString } = require("../../../common/src/common")
import { safeParseField } from "../../../common/src/schema/types"
const controller = { async function save(ctx) {
save: async ctx => { const db = couchdb.use(ctx.databaseId)
}, const record = cloneDeep(ctx.body)
fetch: async ctx => {
const db = couchdb.db.use(ctx.params.databaseId)
ctx.body = await db.view("database", "all_somemodel", { if (!ctx.schema.findModel(record._modelId)) {
include_docs: true, ctx.status = 400
key: ["app"] ctx.message = `do not recognise modelId : ${record._modelId}`
return
}
const validationResult = await validateRecord(ctx.schema, record)
if (!validationResult.isValid) {
await app.publish(events.recordApi.save.onInvalid, {
record,
validationResult,
}) })
}, ctx.status = 400
destroy: async ctx => { ctx.message = "record failed validation rules"
const databaseId = ctx.params.databaseId; ctx.body = validationResult
const database = couchdb.db.use(databaseId) }
ctx.body = await database.destroy(ctx.params.recordId);
}, if (!record._rev) {
await db.insert(record)
await app.publish(events.recordApi.save.onRecordCreated, {
record: record,
})
} else {
const oldRecord = await _findRecord(db, ctx.schema, record._id)
await db.insert(record)
await app.publish(events.recordApi.save.onRecordUpdated, {
old: oldRecord,
new: record,
})
}
const savedHead = await db.head(record._id)
record._rev = savedHead._rev
return record
} }
module.exports = controller; async function fetch(ctx) {
const db = couchdb.db.use(ctx.params.databaseId)
ctx.body = await db.view("database", "all_somemodel", {
include_docs: true,
key: ["app"]
})
}
async function find(ctx) {
const db = couchdb.db.use(ctx.params.databaseId)
const { body, status } = await _findRecord(db, ctx.schema, ctx.params.id)
ctx.status = status
ctx.body = body
}
async function _findRecord(db, schema, id) {
let storedData
try {
storedData = await db.get(id)
} catch (err) {
return err
}
const model = schema.findModel(storedData._modelId)
const loadedRecord = $(model.fields, [
keyBy("name"),
mapValues(f => safeParseField(f, storedData)),
])
const links = $(model.fields, [
filter(
f => f.type === "reference" && isNonEmptyString(loadedRecord[f.name].key)
),
map(f => ({
promise: _findRecord(db, schema, loadedRecord[f.name]._id),
index: getNode(app.hierarchy, f.typeOptions.indexNodeKey),
field: f,
})),
])
if (links.length > 0) {
const refRecords = await Promise.all(map(p => p.promise)(links))
for (const ref of links) {
loadedRecord[ref.field.name] = mapRecord(
refRecords[links.indexOf(ref)],
ref.index
)
}
}
loadedRecord._rev = storedData._rev
loadedRecord._id = storedData._id
loadedRecord._modelId = storedData._modelId
return loadedRecord
}
async function destroy(ctx) {
const databaseId = ctx.params.databaseId;
const database = couchdb.db.use(databaseId)
ctx.body = await database.destroy(ctx.params.recordId);
}
module.exports = {dave, fetch, destroy, find};

View File

@ -0,0 +1,9 @@
const { testSchema } = require("../../common/test/testSchema")
describe("record persistence", () => {
it("should save a record", async () => {
})
})

View File

@ -1,7 +0,0 @@
const { testSchema } = require("../../common/test/testSchema")
describe("record persistence", async () => {
it("should ")
})

View File

@ -2983,7 +2983,7 @@ lodash.sortby@^4.7.0:
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.15, lodash@^4.17.4, lodash@^4.2.1: lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.4, lodash@^4.2.1:
version "4.17.15" version "4.17.15"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==