Skip to content

Commit 917ea4e

Browse files
committed
add prefix, suffix, and composite key delimiters
1 parent ac7fb5e commit 917ea4e

12 files changed

+197
-25
lines changed

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,9 @@ For more control over an attribute's behavior, you can specify an object as the
369369
| alias | `string` | all | Adds a bidirectional alias to the attribute. All input methods can use either the attribute name or the alias when passing in data. Auto-parsing and the `parse` method will map attributes to their alias. |
370370
| map | `string` | all | The inverse of the `alias` option, allowing you to specify your alias as the key and map it to an attribute name. |
371371
| setType | `string` | `set` | Specifies the type for `set` attributes. Allowed values are `string`,`number`,`binary` |
372+
| delimiter | `string` | *composite keys* | Specifies the delimiter to use if this attribute stores a composite key (see [Using an `array` for composite keys](#using-an-array-for-composite-keys)) |
373+
| prefix | `string` | `string` | A prefix to be added to an attribute when saved to DynamoDB. This prefix will be removed when parsing the data. |
374+
| suffix | `string` | `string` | A suffix to be added to an attribute when saved to DynamoDB. This suffix will be removed when parsing the data. |
372375
| partitionKey | `boolean` or `string` | all | Flags an attribute as the 'partitionKey' for this Entity. If set to `true`, it will be mapped to the Table's `partitionKey`. If set to the name of an **index** defined on the Table, it will be mapped to the secondary index's `partitionKey` |
373376
| sortKey | `boolean` or `string` | all | Flags an attribute as the 'sortKey' for this Entity. If set to `true`, it will be mapped to the Table's `sortKey`. If set to the name of an **index** defined on the Table, it will be mapped to the secondary index's `sortKey` |
374377

@@ -390,7 +393,7 @@ attributes: {
390393

391394
**NOTE:** The interface for composite keys may be changing in v0.2 to make it easier to customize.
392395

393-
Composite keys in DynamoDB are incredibly useful for creating hierarchies, one-to-many relationships, and other powerful querying capabilities (see [here](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-sort-keys.html)). The DynamoDB Toolbox lets you easily work with composite keys in a number of ways. In many cases, there is no need to store the data in the same record twice if you are already combining it into a single attribute. By using composite key mappings, you can store data together in a single field, but still be able to structure input data *and* parse the output into separate attributes.
396+
Composite keys in DynamoDB are incredibly useful for creating hierarchies, one-to-many relationships, and other powerful querying capabilities (see [here](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-sort-keys.html)). The DynamoDB Toolbox lets you easily work with composite keys in a number of ways. In some cases, there is no need to store the data in the same record twice if you are already combining it into a single attribute. By using composite key mappings, you can store data together in a single field, but still be able to structure input data *and* parse the output into separate attributes.
394397

395398
The basic syntax is to specify an `array` with the mapped attribute name as the first element, and the index in the composite key as the second element. For example:
396399

@@ -404,7 +407,7 @@ attributes: {
404407
}
405408
```
406409

407-
This maps the `status` and `date` attributes to the `sk` attribute. If a `status` and `date` are supplied, they will be combined into the `sk` attribute as `[status]#[date]`. When the data is retrieved, the `parse` method will automatically split the `sk` attribute and return the values with `status` and `date` keys. By default, the values of composite keys are not stored as separate attributes, but that can be changed by adding in an option configuration as the third array element.
410+
This maps the `status` and `date` attributes to the `sk` attribute. If a `status` and `date` are supplied, they will be combined into the `sk` attribute as `[status]#[date]`. When the data is retrieved, the `parse` method will automatically split the `sk` attribute and return the values with `status` and `date` keys. By default, the values of composite keys are stored as separate attributes, but that can be changed by adding in an option configuration as the third array element.
408411

409412
**Passing in a configuration**
410413
Composite key mappings are `string`s by default, but can be overridden by specifying either `string`,`number`, or `boolean` as the third element in the array. Composite keys are automatically coerced into `string`s, so only the aforementioned types are allowed. You can also pass in a configuration `object` as the third element. This uses the same configuration properties as above. In addition to these properties, you can also specify a `boolean` property of `save`. This will write the value to the mapped composite key, but also add a separate attribute that stores the value.
@@ -413,7 +416,7 @@ Composite key mappings are `string`s by default, but can be overridden by specif
413416
attributes: {
414417
user_id: { partitionKey: true },
415418
sk: { hidden: true, sortKey: true },
416-
status: ['sk',0, { type: 'boolean', save: true, default: true }],
419+
status: ['sk',0, { type: 'boolean', save: false, default: true }],
417420
date: ['sk',1, { required: true }],
418421
...
419422
}

__tests__/entities/simple-entity.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ module.exports = {
1010
sk: { type: 'string', hidden: true, sortKey: true },
1111
test: { type: 'string' },
1212
test_composite: ['sk',0, { save: true }],
13-
test_composite2: ['sk',1],
13+
test_composite2: ['sk',1, { save: false }],
1414
test_undefined: { default: () => undefined }
1515
}
1616
}

__tests__/entity-creation.unit.test.js

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
const Table = require('../classes/Table')
33
const Entity = require('../classes/Entity')
44

5+
const { DocumentClient } = require('./bootstrap-tests')
6+
57
describe('Entity creation', ()=> {
68

79
it('creates basic entity w/ defaults', async () => {
@@ -343,4 +345,133 @@ describe('Entity creation', ()=> {
343345
expect(TestEntity._etAlias).toBe('entity')
344346
}) // creates entity w/ table
345347

346-
})
348+
349+
it('creates entity composite key delimiter, prefix and suffix', async () => {
350+
351+
// Create basic table
352+
const TestTable = new Table({
353+
name: 'test-table',
354+
partitionKey: 'pk',
355+
DocumentClient
356+
})
357+
358+
// Create basic entity
359+
const TestEntity = new Entity({
360+
name: 'TestEnt',
361+
attributes: {
362+
pk: { partitionKey: true },
363+
sk: { delimiter: '|', prefix: 'test---', suffix: '-end', map: 'skx' },
364+
test0: ['sk',0],
365+
test1: ['sk',1],
366+
test2: ['sk',2],
367+
comp1: {},
368+
test0c: ['comp1',0, { save: false }],
369+
test1c: ['comp1',1],
370+
test2c: ['comp1',2]
371+
},
372+
table: TestTable,
373+
timestamps: false
374+
})
375+
376+
let result = TestEntity.putParams({
377+
pk: 'test',
378+
test0: '0',
379+
test1: '1',
380+
test2: '2',
381+
test0c: '0',
382+
test1c: 1,
383+
test2c: '2'
384+
})
385+
386+
expect(result).toEqual({
387+
TableName: 'test-table',
388+
Item: {
389+
skx: 'test---0|1|2-end',
390+
comp1: '0#1#2',
391+
_et: 'TestEnt',
392+
pk: 'test',
393+
test0: '0',
394+
test1: '1',
395+
test2: '2',
396+
test1c: '1',
397+
test2c: '2'
398+
}
399+
})
400+
401+
expect(TestEntity.parse({
402+
skx: 'test---0|1|2-end',
403+
comp1: '0#1#2',
404+
_et: 'TestEnt',
405+
pk: 'test',
406+
test0: '0',
407+
test1: '1',
408+
test2: '2',
409+
test1c: '1',
410+
test2c: '2'
411+
})).toEqual({
412+
sk: '0|1|2',
413+
test0c: '0',
414+
comp1: '0#1#2',
415+
entity: 'TestEnt',
416+
pk: 'test',
417+
test0: '0',
418+
test1: '1',
419+
test2: '2',
420+
test1c: '1',
421+
test2c: '2'
422+
})
423+
424+
425+
})
426+
427+
428+
it('creates an attribute with a prefix and suffix', async () => {
429+
430+
// Create basic table
431+
const TestTable = new Table({
432+
name: 'test-table',
433+
partitionKey: 'pk',
434+
DocumentClient
435+
})
436+
437+
// Create basic entity
438+
const TestEntity = new Entity({
439+
name: 'TestEnt',
440+
attributes: {
441+
pk: { partitionKey: true, prefix: '#user#' },
442+
test: { prefix: 'startX--', suffix: '--endX' },
443+
num: { type: 'number' }
444+
},
445+
table: TestTable,
446+
timestamps: false
447+
})
448+
449+
let result = TestEntity.putParams({
450+
pk: 'test',
451+
test: 'testx',
452+
num: 5
453+
})
454+
455+
expect(result).toEqual({
456+
TableName: 'test-table',
457+
Item: {
458+
_et: 'TestEnt',
459+
pk: '#user#test',
460+
test: 'startX--testx--endX',
461+
num: 5
462+
}
463+
})
464+
465+
expect(TestEntity.parse({
466+
_et: 'TestEnt',
467+
pk: '#user#test',
468+
test: 'startX--testx--endX',
469+
num: 5
470+
})).toEqual({
471+
entity: 'TestEnt', pk: 'test', test: 'testx', num: 5
472+
})
473+
474+
475+
}) // creates attribute with prefix/suffix
476+
477+
})

__tests__/entity.parse.unit.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ describe('parse',()=>{
7979
expect(item).toEqual({
8080
pk: 'test@test.com',
8181
test_composite: 'test',
82-
test_composite2: 'email',
82+
test_composite2: 'email'
8383
})
8484
})
8585

__tests__/entity.put.unit.test.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ const TestEntity = new Entity({
3535
test_number_set_type_coerce: { type: 'set', setType: 'number', coerce: true },
3636
test_binary: { type: 'binary' },
3737
simple_string: 'string',
38-
test_composite: ['sort',0, { save: true }],
39-
test_composite2: ['sort',1]
38+
test_composite: ['sort',0],
39+
test_composite2: ['sort',1, { save: false }]
4040
},
4141
table: TestTable
4242
})
@@ -53,8 +53,8 @@ const TestEntity2 = new Entity({
5353
attributes: {
5454
email: { type: 'string', partitionKey: true },
5555
sort: { type: 'string', map: 'sk' },
56-
test_composite: ['sort',0, { save: true }],
57-
test_composite2: ['sort',1]
56+
test_composite: ['sort',0],
57+
test_composite2: ['sort',1, { save: false }]
5858
},
5959
table: TestTable2
6060
})
@@ -145,6 +145,7 @@ describe('put',()=>{
145145
test_composite: 'test',
146146
test_composite2: 'test2'
147147
})
148+
148149
expect(Item.pk).toBe('test-pk')
149150
expect(Item.sk).toBe('override')
150151
expect(Item.test_composite).toBe('test')

__tests__/formatItem.unit.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ DefaultTable.entities = new Entity({
2727
list: { type: 'list', alias: 'list_alias' },
2828
list_alias2: { type: 'list', map: 'list2' },
2929
test: 'map',
30-
linked1: ['sk',0],
31-
linked2: ['sk',1]
30+
linked1: ['sk',0, { save: false }],
31+
linked2: ['sk',1, { save: false }]
3232
}
3333
})
3434

@@ -64,7 +64,7 @@ describe('formatItem', () => {
6464
})
6565

6666
it('formats item with linked fields', () => {
67-
let result = formatItem(DocumentClient)(DefaultTable.User.schema.attributes,DefaultTable.User.linked,{ sk: 'test1#test2' })
67+
let result = formatItem(DocumentClient)(DefaultTable.User.schema.attributes,DefaultTable.User.linked,{ sk: 'test1#test2' })
6868
expect(result).toEqual({ sk: 'test1#test2', linked1: 'test1', linked2: 'test2' })
6969
})
7070

__tests__/parseCompositeKey.unit.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ const attributes = {
88
describe('parseCompositeKey', () => {
99

1010
it('converts item config to linked mapping', async () => {
11-
let result = parseCompositeKey('linked',['sk',0, { save:true }],{linked:{}},attributes)
12-
expect(result).toEqual({ linked: { save: true, type: 'string', coerce: true, link: 'sk', pos: 0 } })
11+
let result = parseCompositeKey('linked',['sk',0, { save:false }],{linked:{}},attributes)
12+
expect(result).toEqual({ linked: { save: false, type: 'string', coerce: true, link: 'sk', pos: 0 } })
1313
let result2 = parseCompositeKey('linked2',['sk',1],{linked:{}},attributes)
14-
expect(result2).toEqual({ linked2: { type: 'string', coerce: true, link: 'sk', pos: 1 } })
14+
expect(result2).toEqual({ linked2: { save: true, type: 'string', coerce: true, link: 'sk', pos: 1 } })
1515
})
1616

1717
it('fails on missing mapped field', async () => {

__tests__/tables/create-table.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
},
1515
{
1616
"AttributeName": "GSI1sk",
17-
"AttributeType": "S"
17+
"AttributeType": "N"
1818
}
1919
],
2020
"KeySchema": [

lib/formatItem.js

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,16 @@ module.exports = (DocumentClient) => (attributes,linked,item,include=[]) => {
1919

2020
return Object.keys(item).reduce((acc,field) => {
2121

22-
if (linked[field]) {
23-
Object.assign(acc, linked[field].reduce((acc,f,i) => {
24-
if (attributes[f].save || attributes[f].hidden || (include.length > 0 && !include.includes(f))) return acc
22+
if (linked[field] || linked[attributes[field].alias]) {
23+
Object.assign(acc, (linked[field] || linked[attributes[field].alias]).reduce((acc,f,i) => {
24+
if (attributes[f].save || attributes[f].hidden || (include.length > 0 && !include.includes(f))) return acc
2525
return Object.assign(acc,{
26-
[attributes[f].alias || f]: validateType(attributes[f],f,item[field].split('#')[i])
26+
[attributes[f].alias || f]: validateType(attributes[f],f,
27+
item[field]
28+
.replace(new RegExp(`^${escapeRegExp(attributes[field].prefix)}`),'')
29+
.replace(new RegExp(`${escapeRegExp(attributes[field].suffix)}$`),'')
30+
.split(attributes[field].delimiter || '#')[i]
31+
)
2732
})
2833
},{}))
2934
}
@@ -32,7 +37,17 @@ module.exports = (DocumentClient) => (attributes,linked,item,include=[]) => {
3237
// Extract values from sets
3338
if (attributes[field] && attributes[field].type === 'set' && Array.isArray(item[field].values)) { item[field] = item[field].values }
3439
return Object.assign(acc,{
35-
[(attributes[field] && attributes[field].alias) || field]: item[field]
40+
[(attributes[field] && attributes[field].alias) || field]: (
41+
attributes[field].prefix || attributes[field].suffix
42+
? item[field]
43+
.replace(new RegExp(`^${escapeRegExp(attributes[field].prefix)}`),'')
44+
.replace(new RegExp(`${escapeRegExp(attributes[field].suffix)}$`),'')
45+
: item[field]
46+
)
3647
})
3748
},{})
3849
}
50+
51+
function escapeRegExp(text) {
52+
return text ? text.replace(/[-[\]{}()*+?.,\\^$|#]/g, '\\$&') : ''
53+
}

lib/normalizeData.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ module.exports = (DocumentClient) => (schema,linked,data,filter=false) => {
1818
let _data = Object.keys(data).reduce((acc,field) => {
1919

2020
return Object.assign(acc,
21-
schema[field] ? { [schema[field].map || field] : data[field] }
21+
schema[field] ? { [schema[field].map || field] : (
22+
schema[field].prefix || schema[field].suffix
23+
? `${schema[field].prefix || ''}${data[field]}${schema[field].suffix || ''}`
24+
: data[field]
25+
)}
2226
: filter ? {} // this will filter out non-mapped fields
2327
: field === '$remove' ? { $remove: data[field] } // support for removes
2428
: error(`Field '${field}' does not have a mapping or alias`)
@@ -41,9 +45,12 @@ module.exports = (DocumentClient) => (schema,linked,data,filter=false) => {
4145
// TODO: add required fields
4246
// if (values.length > 0 && values.length !== linked[field].length) {
4347
// error(`${linked[field].join(', ')} are all required for composite key`)
44-
// } else
48+
// } else
49+
4550
if (values.length === linked[attr].length) {
46-
return Object.assign(acc, { [field]: values.join('#') })
51+
return Object.assign(acc, {
52+
[field]: `${schema[attr].prefix || ''}${values.join(schema[attr].delimiter || '#')}${schema[attr].suffix || ''}`
53+
})
4754
} else {
4855
return acc
4956
}

0 commit comments

Comments
 (0)