UNPKG

massive

Version:

A small query tool for Postgres that embraces json and makes life simpler

155 lines (131 loc) 5.77 kB
'use strict'; 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); };