keynest
v1.1.1
Published
Transform flat SQL join rows into nested objects and arrays using column-name conventions. Supports associations (single underscore), collections (double underscore), JSON Schema, and custom ID fields.
Maintainers
Readme
keynest
A JavaScript library that maps flat SQL join results into nested objects and arrays. Ideal for converting the denormalized rows returned by relational database queries into rich, hierarchical structures ready to be consumed by your API or application.
Table of contents
Installation
npm install keynestRequires Node.js >= 14.
Quick start
const keynest = require('keynest');
// Static helper — maps an array of flat rows to a list of nested objects
const rows = [
{ id: 1, name: 'Alice', orders__id: 10, orders__total: 99.9 },
{ id: 1, name: 'Alice', orders__id: 11, orders__total: 49.5 },
{ id: 2, name: 'Bob', orders__id: 12, orders__total: 19.0 },
];
const result = keynest.mapDynamic(rows);
// [
// { id: 1, name: 'Alice', orders: [{ id: 10, total: 99.9 }, { id: 11, total: 49.5 }] },
// { id: 2, name: 'Bob', orders: [{ id: 12, total: 19.0 }] }
// ]Naming conventions
keynest uses column name prefixes to determine how each field should be nested. The convention must be applied to the SQL column aliases in your query.
| Pattern | Result | Example column | Mapped to |
| --- | --- | --- | --- |
| field | Root-level property | name | obj.name |
| assoc_field | Nested object (single _) | address_city | obj.address.city |
| collection__field | Array item property (double __) | tags__name | obj.tags[n].name |
| collection__ | Array of native values (empty suffix) | roles__ | obj.roles[n] |
The separator for collections is
__(two underscores). The separator for associations is_(one underscore). When both separators appear in a key, the leftmost one wins. A column ending in__(empty suffix after the double underscore) produces an array of primitive values instead of an array of objects.
Escaping the convention
When your data already contains underscores for other reasons (e.g. updated_at, created_at, snake_case_columns) you have two ways to tell keynest to leave those fields alone.
Option A — literalFields (field allow-list)
Explicitly mark certain fields as scalars. keynest will never apply the _ / __ convention to them.
const kn = new keynest();
kn.literalFields(['updated_at', 'created_at']);
// or pass a RegExp to match by pattern:
kn.literalFields(/_at$/);
// or a mixed array:
kn.literalFields(['updated_at', /_id$/]);Static API:
keynest.mapDynamic(rows, schema, ids, { literalFields: ['updated_at', 'created_at'] });Option B — separators (custom separator characters)
Change the characters that trigger nesting. This is useful when you can control the SQL aliases and prefer to keep underscores in plain field names.
const kn = new keynest();
kn.separators({ association: '$', collection: '$$' });
// Now:
// currency$code → { currency: { code: '...' } }
// tags$$name → { tags: [{ name: '...' }] }
// updated_at → { updated_at: '...' } ✓ (plain, no $)Static API:
keynest.mapDynamic(rows, schema, ids, { separators: { association: '$', collection: '$$' } });Both options can be combined.
API reference
Static methods
keynest.mapDynamic(rows, schema?, ids?, options?)
Maps an array of flat rows to a list of nested objects.
| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| rows | Array | — | Flat rows from a SQL query |
| schema | Object | undefined | Optional JSON Schema to define the output shape |
| ids | string[] | ['id'] | Field names used as unique identifiers |
| options | Object | {} | See below |
options
| Key | Type | Description |
| --- | --- | --- |
| literalFields | string \| RegExp \| Array<string\|RegExp> | Fields that bypass the naming convention |
| separators | { association?: string, collection?: string } | Override the default _ / __ separators |
Returns Array.
keynest.mapDynamicToOne(rows, schema?, ids?, options?)
Maps an array of flat rows to a single nested object.
Same parameters as mapDynamic. Returns Object | undefined.
Instance methods
Use the instance API when you need to reuse the same configuration across multiple calls.
new keynest()
Creates a new mapper instance.
instance.toList(rows, schema?, objectsIds?)
Equivalent to mapDynamic but on an instance.
instance.toSingle(rows, schema?, objectsIds?)
Equivalent to mapDynamicToOne but on an instance.
instance.uniqueConventions(ids)
Overrides the default ID field (id) used to identify and deduplicate objects.
const kn = new keynest();
kn.uniqueConventions(['uuid', 'id']);instance.literalFields(fields)
Mark fields that should bypass the naming convention and always remain as flat scalar properties.
| Parameter | Type | Description |
| --- | --- | --- |
| fields | string \| RegExp \| Array<string\|RegExp> | Field name(s) or pattern(s) to treat as literals |
kn.literalFields(['updated_at', 'created_at']); // exact names
kn.literalFields(/_at$/); // RegExp
kn.literalFields(['updated_at', /_id$/]); // mixed arrayinstance.separators(seps)
Override the characters used to detect associations (_) and collections (__).
| Parameter | Type | Description |
| --- | --- | --- |
| seps | { association?: string, collection?: string } | New separator characters |
kn.separators({ association: '$', collection: '$$' });
// currency$code → { currency: { code: '...' } }
// tags$$name → { tags: [{ name: '...' }] }
// updated_at → stays as-is (no '$' in the name)Examples
Simple flat rows
const rows = [
{ id: 1, name: 'Boston Celtics' },
{ id: 2, name: 'Los Angeles Lakers' },
];
keynest.mapDynamic(rows);
// [{ id: 1, name: 'Boston Celtics' }, { id: 2, name: 'Los Angeles Lakers' }]Nested object (association)
Single underscore (_) maps a group of columns into a nested object.
// SQL: SELECT id, name, currency_code, currency_name FROM ...
const rows = [
{ id: '734a0722', name: 'Pangea Fund', currency_code: 'EUR', currency_name: 'Euro' }
];
keynest.mapDynamic(rows);
// [{
// id: '734a0722',
// name: 'Pangea Fund',
// currency: { code: 'EUR', name: 'Euro' }
// }]Array of objects (collection)
Double underscore (__) maps repeated rows into an array of nested objects.
// SQL: SELECT u.id, u.alias, a.scope AS authorizations__scope FROM users u
// JOIN authorizations a ON a.user_id = u.id
const rows = [
{ id: 'U1', alias: 'alice', authorizations__scope: 'profile' },
{ id: 'U1', alias: 'alice', authorizations__scope: 'accounts' },
{ id: 'U2', alias: 'bob', authorizations__scope: 'profile' },
];
keynest.mapDynamic(rows);
// [
// { id: 'U1', alias: 'alice', authorizations: [{ scope: 'profile' }, { scope: 'accounts' }] },
// { id: 'U2', alias: 'bob', authorizations: [{ scope: 'profile' }] }
// ]Mixed nesting
Associations and collections can be combined at any depth.
// Each fund row has scalar fields, a nested `currency` object and a nested `fees` array
const rows = [
{
id: '734a0722',
name: 'Pangea Fund',
currency_code: 'EUR',
currency_name: 'Euro',
fees__name: 'Redemption Fee',
fees__value: 0,
},
{
id: '734a0722',
name: 'Pangea Fund',
currency_code: 'EUR',
currency_name: 'Euro',
fees__name: 'Total Expense Ratio',
fees__value: 1.5,
},
];
keynest.mapDynamic(rows);
// [{
// id: '734a0722',
// name: 'Pangea Fund',
// currency: { code: 'EUR', name: 'Euro' },
// fees: [
// { name: 'Redemption Fee', value: 0 },
// { name: 'Total Expense Ratio', value: 1.5 }
// ]
// }]Arrays of native values
When a column name ends with __ and has no field name after it, the raw value becomes the array element directly — useful for arrays of strings, numbers, booleans, etc.
// SQL: SELECT u.id, r.name AS roles__ FROM users u JOIN user_roles r ON r.user_id = u.id
const rows = [
{ id: 'U1', roles__: 'admin' },
{ id: 'U1', roles__: 'editor' },
{ id: 'U2', roles__: 'viewer' },
];
keynest.mapDynamic(rows);
// [
// { id: 'U1', roles: ['admin', 'editor'] },
// { id: 'U2', roles: ['viewer'] }
// ]This also works nested inside a collection:
// organizations__roles__ → obj.organizations[n].roles = ['adviser', ...]
const rows = [
{ id: 'U1', organizations__id: 'ORG1', organizations__roles__: 'adviser' },
{ id: 'U1', organizations__id: 'ORG1', organizations__roles__: 'manager' },
];
keynest.mapDynamic(rows);
// [{ id: 'U1', organizations: [{ id: 'ORG1', roles: ['adviser', 'manager'] }] }]Mapping with a JSON Schema
Pass a JSON Schema to pre-initialize the output shape. This is useful to guarantee that optional arrays or objects always appear in the result even when no rows match them.
const schema = {
properties: {
code: { type: 'number' },
internalCode: { type: 'string' },
message: { type: 'string' }, // will be null if missing in rows
}
};
const kn = new keynest();
kn.toSingle([{ code: 200, internalCode: '-3000' }], schema);
// { code: 200, internalCode: '-3000', message: null }JSON Schema allOf is automatically resolved via json-schema-merge-allof.
Custom ID fields
By default, keynest uses id to identify and deduplicate objects. Override this with uniqueConventions or the ids parameter.
// Instance API
const kn = new keynest();
kn.uniqueConventions(['uuid']);
kn.toList(rows);
// Static API
keynest.mapDynamic(rows, undefined, ['uuid']);Literal fields
Prevent specific snake_case fields from being treated as associations.
const rows = [
{ id: 1, name: 'Alice', updated_at: '2024-01-01', address_city: 'NY' }
];
// Instance API
const kn = new keynest();
kn.literalFields(['updated_at']);
kn.toList(rows);
// [{ id: 1, name: 'Alice', updated_at: '2024-01-01', address: { city: 'NY' } }]
// Static API — pass a 4th options argument
keynest.mapDynamic(rows, undefined, ['id'], { literalFields: ['updated_at'] });
// RegExp — mark all *_at and *_by fields as literals
kn.literalFields(/_(at|by)$/);Custom separators
Switch to a different separator character so that underscores in column names are never interpreted as nesting markers.
// SQL: SELECT id, updated_at,
// currency$code, currency$name,
// tags$$name
// FROM ...
const rows = [
{ id: 1, updated_at: '2024-01-01', currency$code: 'EUR', currency$name: 'Euro', tags$$name: 'bonds' },
{ id: 1, updated_at: '2024-01-01', currency$code: 'EUR', currency$name: 'Euro', tags$$name: 'equity' },
];
const kn = new keynest();
kn.separators({ association: '$', collection: '$$' });
kn.toList(rows);
// [{
// id: 1,
// updated_at: '2024-01-01', // ← plain, untouched
// currency: { code: 'EUR', name: 'Euro' },
// tags: [{ name: 'bonds' }, { name: 'equity' }]
// }]
// Static API
keynest.mapDynamic(rows, undefined, ['id'], { separators: { association: '$', collection: '$$' } });Debug
keynest uses the debug package. Enable verbose output by setting the DEBUG environment variable:
DEBUG=keynest:map node your-app.jsOperations that exceed 300 ms are automatically flagged in the debug output.
Testing
npm testThe test suite uses Mocha with 22 test cases covering simple mappings, associations, collections, nested structures, schema-driven output, and edge cases. Coverage is collected via nyc in lcov format.
22 passingLicense
MIT
