@netlify/content-engine
Version:
303 lines (293 loc) • 12.1 kB
JavaScript
/*
## 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
;