massive
Version:
A small query tool for Postgres that embraces json and makes life simpler
155 lines (131 loc) • 5.77 kB
JavaScript
;
const _ = require('lodash');
const murmurhash = require('murmurhash').v3;
// TODO column mapping functions?
/**
* Collapses tabular result sets into a hierarchical object graph based on the
* provided schema.
*
* @module decompose
* @param {Object} schema - An object describing the shape of the output data.
* Schema objects are nested to arbitrary depth, with a single top-level schema
* describing the "origin" record format and containing other schemas which
* describe descendant records.
* @param {String} schema.pk - The name of a field in the result set which
* uniquely identifies a single record.
* @param {Object|Array} schema.columns - A map of field names in the result set
* to keys of an output object, or an array of field names if no transformation
* of names is desired.
* @param {Boolean} schema.array - When true, entities built with this schema
* are coerced into arrays of entities. This is only valid for nested schemas.
* If an object described by this schema's parent schema does not contain any
* records for this schema, an empty array will be generated.
* @param {Object} schema.anythingElse - Nests a descendant schema under this
* one.
* @param {Array} data - Array of database records to decompose.
* @return {Array} An array of nested objects generated by applying the schema
* to each data element in turn.
*/
exports = module.exports = function (schema, data) {
if (!data || data.length === 0) {
return [];
}
schema.pk = _.castArray(schema.pk);
data = _.castArray(data);
/* Generate a nested dictionary of id:entity in the form of the final
* structure we're trying to build, effectively hashing ids to ensure we
* don't duplicate any entities in cases where multiple dependent tables are
* joined into the source query.
* Output: {1: {id: 1, name: 'hi', children: {111: {id: 111, name: 'ih'}}}
*/
const mapping = data.reduce(function (acc, row) {
if (schema.pk.every(c => row[c] === null)) {
throw new Error('Attempted to decompose a row where the root object has a null PK. This can happen if tables in your SELECT list share column names. Ensure that all columns are aliased uniquely and update your decomposition schema if necessary.');
}
return (function build (obj, objSchema) {
objSchema.pk = _.castArray(objSchema.pk);
const id = _.pick(row, objSchema.pk);
// Add a prefix to ensure strid doesn't get treated as a number, which
// could lead to Object.keys reordering the mapping in the transform step
const strid = '_' + murmurhash(JSON.stringify(id));
if (_.every(id, v => v === null)) {
// null id means this entity doesn't exist (eg outer join)
return undefined;
} else if (!Object.prototype.hasOwnProperty.call(obj, strid)) {
// this entity is new
obj[strid] = {};
}
let mapper;
if (_.isArray(objSchema.columns)) {
// columns is just a list of field names
mapper = val => {
if (row[val] !== undefined) {
obj[strid][val] = row[val];
}
};
} else {
// the columns object maps field names in the row to object key names
mapper = (val, key) => {
if (row[key] !== undefined) {
obj[strid][val] = row[key];
}
};
}
_.map(objSchema.columns, mapper);
Object.keys(objSchema).forEach(function (c) {
switch (c) {
case 'pk': case 'columns': case 'decomposeTo': break;
default: {
const descendant = build(_.isObjectLike(obj[strid][c]) ? obj[strid][c] : {}, objSchema[c]);
if (descendant) {
obj[strid][c] = descendant;
} else if (objSchema[c].decomposeTo !== 'object') {
// we always want an array if there could be multiple descendants
obj[strid][c] = [];
}
break;
}
}
});
return obj;
})(acc, schema);
}, {});
/* Build the final graph. The structure and data already exists in mapping,
* but we need to transform the {id: entity} structures into arrays of
* entities (or flat objects if required).
*
* Output: [{id: 1, name: 'hi', children: [{id: 111, name: 'ih'}]}] */
return (function transform (objSchema, map, decomposeTo) {
// for every id:entity pair in the current level of mapping, if the schema
// defines any dependent entities recurse and transform them, then push the
// current object into the output and return
return _.reduce(map, function (acc, val) {
const transformed = _.reduce(objSchema, (obj, v, k) => {
if (k === 'pk' || k === 'columns' || typeof v !== 'object') {
// only the object-tree structure matters now, so prune schema fields
return obj;
}
if (obj[k]) {
// don't recurse for null descendants
obj[k] = transform(v, obj[k], v.decomposeTo);
}
return obj;
}, val);
switch (decomposeTo) {
case 'object': acc = transformed; break;
case 'dictionary':
if (_.isArray(objSchema.columns)) {
// no field name mapping, just look it up directly
acc[transformed[objSchema.pk[0]]] = transformed;
} else {
// transformed has new field names at this point, so since
// objSchema.pk references the original names, it has to be looked up
acc[transformed[objSchema.columns[objSchema.pk[0]]]] = transformed;
}
break;
default: acc.push(transformed); break;
}
return acc;
}, (!decomposeTo || decomposeTo === 'array') ? [] : {});
})(schema, mapping);
};