UNPKG

@netlify/content-engine

Version:
303 lines (293 loc) 12.1 kB
"use strict"; /* ## Incrementally track the structure of nodes with metadata This metadata can be later utilized for schema inference (via building `exampleValue` or directly) ### Usage example: ```javascript const node1 = { id: '1', foo: 25, bar: 'str' } const node2 = { id: '1', foo: 'conflict' } let meta = { ignoredFields: new Set(['id']) } meta = addNode(meta, node1) meta = addNode(meta, node2) console.log(meta.fieldMap) // outputs: { // foo: { // int: { total: 1, example: 25 }, // string: { total: 1, example: 'conflict' }, // }, // bar: { // string: { total: 1, example: 'str' }, // }, // } const example1 = getExampleObject({ meta, typeName, typeConflictReporter }) console.log(example1) // outputs { bar: 'str' } // and reports conflicts discovered meta = deleteNode(meta, node2) console.log(meta.fieldMap) // outputs: { // foo: { // int: { total: 1, example: 25 }, // string: { total: 0, example: 'conflict' }, // }, // bar: { string: { total: 1, example: 'str' } }, // } const example2 = getExampleObject({ meta, typeName, typeConflictReporter }) // outputs: { foo: 25, bar: 'str' } ``` `addNode`, `deleteNode`, `getExampleObject` are O(N) where N is the number of fields in the node object (including nested fields) ### Caveats * Conflict tracking for arrays is tricky, i.e.: { a: [5, "foo"] } and { a: [5] }, { a: ["foo"] } are represented identically in metadata. To workaround it we additionally track first NodeId: { a: { array: { item: { int: { total: 1, first: `1` }, string: { total: 1, first: `1` } }} { a: { array: { item: { int: { total: 1, first: `1` }, string: { total: 1, first: `2` } }} This way we can produce more useful conflict reports (still rare edge cases possible when reporting may be confusing, i.e. when node is deleted) */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.initialMetadata = exports.haveEqualFields = exports.hasNodes = exports.isEmpty = exports.disable = exports.ignore = exports.deleteNode = exports.addNodes = exports.addNode = void 0; const lodash_isequal_1 = __importDefault(require("lodash.isequal")); const is_32_bit_integer_1 = require("../../utils/is-32-bit-integer"); const date_1 = require("../types/date"); const getType = (value, key) => { // Staying as close as possible to GraphQL types switch (typeof value) { case `number`: return (0, is_32_bit_integer_1.is32BitInteger)(value) ? `int` : `float`; case `string`: if (key.includes(`___NODE`)) { return `relatedNode`; } return (0, date_1.looksLikeADate)(value) ? `date` : `string`; case `boolean`: return `boolean`; case `object`: if (value === null) return `null`; if (value instanceof Date) return `date`; if (value instanceof String) return `string`; if (Array.isArray(value)) { if (value.length === 0) { return `null`; } return key.includes(`___NODE`) ? `relatedNodeList` : `array`; } if (!Object.keys(value).length) return `null`; return `object`; default: // bigint, symbol, function, unknown (host objects in IE were typeof "unknown", for example) return `null`; } }; const updateValueDescriptorObject = (value, typeInfo, nodeId, operation, metadata, path) => { path.push(value); const { dprops = {} } = typeInfo; typeInfo.dprops = dprops; Object.keys(value).forEach((key) => { const v = value[key]; let descriptor = dprops[key]; if (descriptor === undefined) { descriptor = {}; dprops[key] = descriptor; } updateValueDescriptor(nodeId, key, v, operation, descriptor, metadata, path); }); path.pop(); }; const updateValueDescriptorArray = (value, key, typeInfo, nodeId, operation, metadata, path) => { value.forEach((item) => { let descriptor = typeInfo.item; if (descriptor === undefined) { descriptor = {}; typeInfo.item = descriptor; } updateValueDescriptor(nodeId, key, item, operation, descriptor, metadata, path); }); }; const updateValueDescriptorRelNodes = (listOfNodeIds, delta, operation, typeInfo, metadata) => { const { nodes = {} } = typeInfo; typeInfo.nodes = nodes; listOfNodeIds.forEach((nodeId) => { nodes[nodeId] = (nodes[nodeId] || 0) + delta; // Treat any new related node addition or removal as a structural change // FIXME: this will produce false positives as this node can be // of the same type as another node already in the map (but we don't know it here) if (nodes[nodeId] === 0 || (operation === `add` && nodes[nodeId] === 1)) { metadata.dirty = true; } }); }; const updateValueDescriptorString = (value, delta, typeInfo) => { if (value === ``) { const { empty = 0 } = typeInfo; typeInfo.empty = empty + delta; } typeInfo.example = typeof typeInfo.example !== `undefined` ? typeInfo.example : value; }; const updateValueDescriptor = (nodeId, key, value, operation = `add`, descriptor, metadata, path) => { // The object may be traversed multiple times from root. // Each time it does it should not revisit the same node twice if (path.includes(value)) { return; } const typeName = getType(value, key); if (typeName === `null`) { return; } const delta = operation === `del` ? -1 : 1; let typeInfo = descriptor[typeName]; if (typeInfo === undefined) { typeInfo = descriptor[typeName] = { total: 0 }; } typeInfo.total += delta; // Keeping track of structural changes // (when value of a new type is added or an existing type has no more values assigned) if (typeInfo.total === 0 || (operation === `add` && typeInfo.total === 1)) { metadata.dirty = true; } // Keeping track of the first node for this type. Only used for better conflict reporting. // (see Caveats section in the header comments) if (operation === `add`) { if (!typeInfo.first) { typeInfo.first = nodeId; } } else if (operation === `del`) { if (typeInfo.first === nodeId || typeInfo.total === 0) { typeInfo.first = undefined; } } switch (typeName) { case `object`: updateValueDescriptorObject(value, typeInfo, nodeId, operation, metadata, path); return; case `array`: updateValueDescriptorArray(value, key, typeInfo, nodeId, operation, metadata, path); return; case `relatedNode`: updateValueDescriptorRelNodes([value], delta, operation, typeInfo, metadata); return; case `relatedNodeList`: updateValueDescriptorRelNodes(value, delta, operation, typeInfo, metadata); return; case `string`: updateValueDescriptorString(value, delta, typeInfo); return; } // int, float, boolean, null typeInfo.example = typeof typeInfo.example !== `undefined` ? typeInfo.example : value; }; const mergeObjectKeys = (dpropsKeysA = {}, dpropsKeysB = {}) => { const dprops = Object.keys(dpropsKeysA); const otherProps = Object.keys(dpropsKeysB); return [...new Set(dprops.concat(otherProps))]; }; const descriptorsAreEqual = (descriptor, otherDescriptor) => { const types = possibleTypes(descriptor); const otherTypes = possibleTypes(otherDescriptor); const childDescriptorsAreEqual = (type) => { switch (type) { case `array`: return descriptorsAreEqual(descriptor?.array?.item, otherDescriptor?.array?.item); case `object`: { const dpropsKeys = mergeObjectKeys(descriptor?.object?.dprops, otherDescriptor?.object?.dprops); return dpropsKeys.every((prop) => descriptorsAreEqual(descriptor?.object?.dprops[prop], otherDescriptor?.object?.dprops[prop])); } case `relatedNode`: { const nodeIds = mergeObjectKeys(descriptor?.relatedNode?.nodes, otherDescriptor?.relatedNode?.nodes); // Must be present in both descriptors or absent in both // in order to be considered equal return nodeIds.every((id) => Boolean(descriptor?.relatedNode?.nodes[id]) === Boolean(otherDescriptor?.relatedNode?.nodes[id])); } case `relatedNodeList`: { const nodeIds = mergeObjectKeys(descriptor?.relatedNodeList?.nodes, otherDescriptor?.relatedNodeList?.nodes); return nodeIds.every((id) => Boolean(descriptor?.relatedNodeList?.nodes[id]) === Boolean(otherDescriptor?.relatedNodeList?.nodes[id])); } default: return true; } }; // Equal when all possible types are equal (including conflicts) return (0, lodash_isequal_1.default)(types, otherTypes) && types.every(childDescriptorsAreEqual); }; const nodeFields = (node, ignoredFields = new Set()) => Object.keys(node).filter((key) => !ignoredFields.has(key)); const updateTypeMetadata = (metadata = initialMetadata(), operation, node) => { if (metadata.disabled) { return metadata; } metadata.total = (metadata.total || 0) + (operation === `add` ? 1 : -1); if (metadata.ignored) { return metadata; } const { ignoredFields, fieldMap = {} } = metadata; nodeFields(node, ignoredFields).forEach((field) => { let descriptor = fieldMap[field]; if (descriptor === undefined) { descriptor = {}; fieldMap[field] = descriptor; } updateValueDescriptor(node.id, field, node[field], operation, descriptor, metadata, []); }); metadata.fieldMap = fieldMap; return metadata; }; const ignore = (metadata = initialMetadata(), set = true) => { metadata.ignored = set; metadata.fieldMap = {}; return metadata; }; exports.ignore = ignore; const disable = (metadata = initialMetadata(), set = true) => { metadata.disabled = set; return metadata; }; exports.disable = disable; const addNode = (metadata, node) => updateTypeMetadata(metadata, `add`, node); exports.addNode = addNode; const deleteNode = (metadata, node) => updateTypeMetadata(metadata, `del`, node); exports.deleteNode = deleteNode; const addNodes = (metadata = initialMetadata(), nodes) => { let state = metadata; for (const node of nodes) { state = addNode(state, node); } return state; }; exports.addNodes = addNodes; const possibleTypes = (descriptor = {}) => Object.keys(descriptor).filter((type) => descriptor[type].total > 0); const isEmpty = ({ fieldMap }) => Object.keys(fieldMap).every((field) => possibleTypes(fieldMap[field]).length === 0); exports.isEmpty = isEmpty; // Even empty type may still have nodes const hasNodes = (typeMetadata) => (typeMetadata.total ?? 0) > 0; exports.hasNodes = hasNodes; const haveEqualFields = ({ fieldMap = {} } = {}, { fieldMap: otherFieldMap = {} } = {}) => { const fields = mergeObjectKeys(fieldMap, otherFieldMap); return fields.every((field) => descriptorsAreEqual(fieldMap[field], otherFieldMap[field])); }; exports.haveEqualFields = haveEqualFields; const initialMetadata = (state) => { return { typeName: undefined, disabled: false, ignored: false, dirty: false, total: 0, ignoredFields: undefined, fieldMap: {}, ...state, }; }; exports.initialMetadata = initialMetadata; //# sourceMappingURL=inference-metadata.js.map