Skip to content

Commit facb7f8

Browse files
committed
Use classes with toStringTags rather than tagged unions for complex expression values
1 parent 2bad97e commit facb7f8

16 files changed

+517
-150
lines changed

packages/dynamodb-auto-marshaller/src/Marshaller.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,8 @@ export class Marshaller {
156156
Iterable<any> |
157157
{[key: string]: any} |
158158
null |
159-
NumberValue
159+
NumberValue |
160+
BinaryValue
160161
): AttributeValue|undefined {
161162
if (value === null) {
162163
return {NULL: true};

packages/dynamodb-expressions/src/AttributePath.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const ATTRIBUTE_PATH_TAG = 'AttributePath';
1+
const ATTRIBUTE_PATH_TAG = 'AmazonDynamoDbAttributePath';
22
const EXPECTED_TAG = `[object ${ATTRIBUTE_PATH_TAG}]`;
33

44
export class AttributePath {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {AttributeValue} from "./AttributeValue";
2+
3+
describe('AttributeValue', () => {
4+
describe('::isAttributeValue', () => {
5+
it('should accept valid attribute values', () => {
6+
const value = new AttributeValue({
7+
S: 'string',
8+
});
9+
10+
expect(
11+
AttributeValue.isAttributeValue(value)
12+
).toBe(true);
13+
});
14+
15+
it('should reject non-matching values', () => {
16+
for (const notAttributeValue of [
17+
false,
18+
true,
19+
null,
20+
void 0,
21+
'string',
22+
123,
23+
[],
24+
{},
25+
new Uint8Array(12),
26+
{foo: 'bar'},
27+
{name: 'foo', arguments: 'bar'},
28+
{S: 'string'}
29+
]) {
30+
expect(
31+
AttributeValue.isAttributeValue(notAttributeValue)
32+
).toBe(false);
33+
}
34+
});
35+
});
36+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import {BinaryValue} from "@aws/dynamodb-auto-marshaller";
2+
3+
const MARSHALLED_ATTRIBUTE_VALUE_TAG = 'AmazonDynamoDbAttributeValue';
4+
const EXPECTED_TOSTRING = `[object ${MARSHALLED_ATTRIBUTE_VALUE_TAG}]`;
5+
6+
export class AttributeValue {
7+
readonly [Symbol.toStringTag] = MARSHALLED_ATTRIBUTE_VALUE_TAG;
8+
9+
constructor(
10+
public readonly marshalled: AttributeValueModel
11+
) {}
12+
13+
static isAttributeValue(arg: any): arg is AttributeValue {
14+
return arg instanceof AttributeValue
15+
|| Object.prototype.toString.call(arg) === EXPECTED_TOSTRING;
16+
}
17+
}
18+
19+
/**
20+
* An attribute of type Binary.
21+
*
22+
* @example {B: Uint8Array.from([0xde, 0xad, 0xbe, 0xef])}
23+
*/
24+
export interface BinaryAttributeValue {
25+
B: BinaryValue;
26+
}
27+
28+
/**
29+
* An attribute of type Binary Set.
30+
*
31+
* @example {
32+
* BS: [
33+
* Uint8Array.from([0xde, 0xad]),
34+
* Uint8Array.from([0xbe, 0xef]),
35+
* Uint8Array.from([0xca, 0xfe]),
36+
* Uint8Array.from([0xba, 0xbe]),
37+
* ],
38+
* }
39+
*/
40+
export interface BinarySetAttributeValue {
41+
BS: Array<BinaryValue>;
42+
}
43+
44+
/**
45+
* An attribute of type Boolean.
46+
*
47+
* @example {BOOL: true}
48+
*/
49+
export interface BooleanAttributeValue {
50+
BOOL: boolean;
51+
}
52+
53+
/**
54+
* An attribute of type List.
55+
*
56+
* @example {L: [{S: "Cookies"}, {S: "Coffee"}, {N: "3.14159"}]}
57+
*/
58+
export interface ListAttributeValue {
59+
L: Array<AttributeValue>;
60+
}
61+
62+
/**
63+
* An attribute of type Map.
64+
*
65+
* @example {M: {Name: {S: "Joe"}, Age: {N: "35"}}
66+
*/
67+
export interface MapAttributeValue {
68+
M: {[key: string]: AttributeValue};
69+
}
70+
71+
/**
72+
* An attribute of type Null.
73+
*/
74+
export interface NullAttributeValue {
75+
NULL: true;
76+
}
77+
78+
/**
79+
* An attribute of type Number.
80+
*
81+
* Numbers are sent across the network to DynamoDB as strings, to maximize
82+
* compatibility across languages and libraries. However, DynamoDB treats them
83+
* as number type attributes for mathematical operations.
84+
*
85+
* @example {N: "123.45"}
86+
*/
87+
export interface NumberAttributeValue {
88+
N: string;
89+
}
90+
91+
/**
92+
* An attribute of type Number Set.
93+
*
94+
* Numbers are sent across the network to DynamoDB as strings, to maximize
95+
* compatibility across languages and libraries. However, DynamoDB treats them
96+
* as number type attributes for mathematical operations.
97+
*
98+
* @example {NS: ["42.2", "-19", "7.5", "3.14"]}
99+
*/
100+
export interface NumberSetAttributeValue {
101+
NS: Array<string>;
102+
}
103+
104+
/**
105+
* An attribute of type String.
106+
*
107+
* @example {S: "Hello"}
108+
*/
109+
export interface StringAttributeValue {
110+
S: string;
111+
}
112+
113+
/**
114+
* An attribute of type String Set.
115+
*
116+
* @example {SS: ["Giraffe", "Hippo" ,"Zebra"]}
117+
*/
118+
export interface StringSetAttributeValue {
119+
SS: Array<string>;
120+
}
121+
122+
export type AttributeValueModel =
123+
BinaryAttributeValue |
124+
BinarySetAttributeValue |
125+
BooleanAttributeValue |
126+
ListAttributeValue |
127+
MapAttributeValue |
128+
NullAttributeValue |
129+
NumberAttributeValue |
130+
NumberSetAttributeValue |
131+
StringAttributeValue |
132+
StringSetAttributeValue;

packages/dynamodb-expressions/src/ConditionExpression.spec.ts

+156-7
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ import {
77
greaterThanOrEqualTo,
88
between,
99
inList,
10+
isConditionExpression,
1011
isConditionExpressionPredicate,
12+
isConditionExpressionSubject,
1113
serializeConditionExpression,
1214
} from "./ConditionExpression";
1315
import {ExpressionAttributes} from "./ExpressionAttributes";
1416
import {AttributePath} from "./AttributePath";
17+
import {FunctionExpression} from "./FunctionExpression";
1518

1619
describe('equals', () => {
1720
it('should return an equality condition predicate', () => {
@@ -89,6 +92,154 @@ describe('inList', () => {
8992
});
9093
});
9194

95+
describe('isConditionExpressionPredicate', () => {
96+
it('should return true for a valid predicate', () => {
97+
expect(isConditionExpressionPredicate({type: 'Equals', object: 0}))
98+
.toBe(true);
99+
});
100+
101+
it('should reject non-matching values', () => {
102+
for (const notPredicate of [
103+
false,
104+
true,
105+
null,
106+
void 0,
107+
'string',
108+
123,
109+
[],
110+
{},
111+
new Uint8Array(12),
112+
{foo: 'bar'},
113+
{name: 'foo', arguments: 'bar'},
114+
{S: 'string'}
115+
]) {
116+
expect(isConditionExpressionPredicate(notPredicate)).toBe(false);
117+
}
118+
});
119+
});
120+
121+
describe('isConditionExpressionSubject', () => {
122+
it('should return true for a string subject', () => {
123+
expect(isConditionExpressionSubject({subject: 'foo'})).toBe(true);
124+
});
125+
126+
it('should return true for an AttributePath subject', () => {
127+
expect(isConditionExpressionSubject({
128+
subject: new AttributePath('foo.bar[3]'),
129+
})).toBe(true);
130+
});
131+
132+
it('should reject non-matching values', () => {
133+
for (const notSubject of [
134+
false,
135+
true,
136+
null,
137+
void 0,
138+
'string',
139+
123,
140+
[],
141+
{},
142+
new Uint8Array(12),
143+
{foo: 'bar'},
144+
{name: 'foo', arguments: 'bar'},
145+
{S: 'string'},
146+
{subject: 123},
147+
]) {
148+
expect(isConditionExpressionSubject(notSubject)).toBe(false);
149+
}
150+
});
151+
});
152+
153+
describe('isConditionExpression', () => {
154+
it('should return true for valid expressions', () => {
155+
expect(isConditionExpression({
156+
type: 'Equals',
157+
subject: 'foo',
158+
object: 'bar',
159+
})).toBe(true);
160+
});
161+
162+
it('should return true for function expressions', () => {
163+
expect(isConditionExpression(
164+
new FunctionExpression('attribute_not_exists', 'foo')
165+
)).toBe(true);
166+
});
167+
168+
it('should return true for negation expressions', () => {
169+
expect(isConditionExpression({
170+
type: 'Not',
171+
condition: {
172+
type: 'Between',
173+
subject: 'foo',
174+
lowerBound: 100,
175+
upperBound: 200,
176+
}
177+
})).toBe(true);
178+
});
179+
180+
it('should return true for compound expressions', () => {
181+
for (const type of ['And', 'Or']) {
182+
expect(isConditionExpression({
183+
type,
184+
conditions: [
185+
{
186+
type: 'Between',
187+
subject: 'foo',
188+
lowerBound: 100,
189+
upperBound: 200,
190+
},
191+
{
192+
type: 'Between',
193+
subject: 'foo',
194+
lowerBound: 400,
195+
upperBound: 600,
196+
},
197+
]
198+
})).toBe(true);
199+
}
200+
});
201+
202+
it('should reject compound expressions without a conditions list', () => {
203+
for (const type of ['And', 'Or']) {
204+
expect(isConditionExpression({type})).toBe(false);
205+
}
206+
});
207+
208+
it(
209+
'should reject compound expressions whose list contains invalid members',
210+
() => {
211+
212+
for (const type of ['And', 'Or']) {
213+
expect(isConditionExpression({
214+
type,
215+
conditions: ['foo', 123],
216+
})).toBe(false);
217+
}
218+
}
219+
);
220+
221+
it('should reject non-matching values', () => {
222+
for (const notExpression of [
223+
false,
224+
true,
225+
null,
226+
void 0,
227+
'string',
228+
123,
229+
[],
230+
{},
231+
new Uint8Array(12),
232+
{foo: 'bar'},
233+
{name: 'foo', arguments: 'bar'},
234+
{S: 'string'},
235+
{subject: 'foo', object: 'bar'},
236+
{type: 'UnknownType', subject: 'foo', object: 'bar'},
237+
]) {
238+
expect(isConditionExpression(notExpression)).toBe(false);
239+
}
240+
});
241+
});
242+
92243
describe('serializeConditionExpression', () => {
93244
it('should serialize equality expressions', () => {
94245
const attributes = new ExpressionAttributes();
@@ -320,13 +471,11 @@ describe('serializeConditionExpression', () => {
320471
it('should serialize function expressions', () => {
321472
const attributes = new ExpressionAttributes();
322473
const serialized = serializeConditionExpression(
323-
{
324-
name: 'attribute_type',
325-
arguments: [
326-
new AttributePath('foo'),
327-
'S'
328-
]
329-
},
474+
new FunctionExpression(
475+
'attribute_type',
476+
new AttributePath('foo'),
477+
'S'
478+
),
330479
attributes
331480
);
332481

0 commit comments

Comments
 (0)