Skip to content

Commit caffc1b

Browse files
authored
Fixes Issue 366 (#376)
* Initial fix, prior to understanding logical issue with `update`, `patch`, and `upsert`. The operations `update`, `patch`, and `upsert` will need to change their current behavior when encountering a `false` value from `condition`. In that case, these operations will actually need to `delete` the `pk` and `sk` for that index. * Solidifies new `condition` logic The condition callback will be invoked only when a composite attribute associated with an index is set via an update, patch, or upsert. [existing behavior] The condition callback is provided the attributes being set on that particular operation, including the item's identifying composite attributes. [existing behavior] If the condition callback returns true, ElectroDB will attempt to create the index and all of its associated keys. If an index cannot be created because an update operation only has enough context for a partial key, ElectroDB will throw. [the original issue here, fixed] If the condition callback returns false, the index and all of its associated keys will be removed from the item. [new behavior] Item #1 above is the key to solving the issue you bring up in your first comment, and it's actually what we do currently. This means that condition would only be called when an index must be recalculated. furthermore, as described in #3, ElectroDB will actually throw if your update operation (set and remove) lacks a full composite context and would result in a "partial" key. This would mean that all * -> true transitions are already validated to have all the composite parts necessary to recreate the complete index already. * Checkpoint commit Checkpointing initial pass at new condition tests, tests not passing. * All current tests working * Clean up and test fix * Clean up and test fix * Clean up and test fix * Clean up and test fix * Clean up and test fix * Clean up and adds new test cases * adds new test case * Adds changelog documentation * Fixes test that used dynamic datetime * Adds additional tests
1 parent 320ae2c commit caffc1b

File tree

12 files changed

+1604
-127
lines changed

12 files changed

+1604
-127
lines changed

CHANGELOG.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,10 @@ All notable changes to this project will be documented in this file. Breaking ch
513513
### Added
514514
- Adds new query execution option `count` which allows you to specify a specific item count to return from a query. This is useful for cases where you must return a specific/consistent number of items from a query, a deceptively difficult task with DynamoDB and Single Table Design.
515515

516-
## [2.13.1] - 2023-01-23
516+
## [2.13.1] - 2024-01-23
517517
### Fixed
518-
- Fixes custom attribute type extraction for union types with RecordItem. Patch provided by GitHub user @wentsul via [PR #346](https://github.com/tywalch/electrodb/pull/346). Thank you for another great addition!
518+
- Fixes custom attribute type extraction for union types with RecordItem. Patch provided by GitHub user @wentsul via [PR #346](https://github.com/tywalch/electrodb/pull/346). Thank you for another great addition!
519+
520+
## [2.14.0] - 2024-04-29
521+
### Fixed/Changed
522+
- Addresses [Issue #366](https://github.com/tywalch/electrodb/issues/366) with unexpected outcomes from index `condition` usage. Discussion [inside the issue ticket](https://github.com/tywalch/electrodb/issues/366) revealed complexities associated with the implementation of the `condition` callback. Previously, a callback returning `false` would simply not write the fields associated with an index on update. Through discussion with [@sam3d](https://github.com/sam3d) and [@nonken](https://github.com/nonken), it was revealed that this behavior could lead to inconsistencies between indexes and attributes. Furthermore, this behavior did not align with user expectations/intuitions, which expected a `false` response to trigger the removal of the item from the index. To achieve this, it was discussed that the presence of a `condition` callback should add a _new_ runtime validation check on all mutations to verify all member attributes of the index must be provided if a mutation operation affects one of the attributes. Previously ElectroDB would validate only that composite members of an index field (a partition or sort key) within an index were fully provided; now, when a condition callback is present, it will validate that all members from both fields are provided. If you are unable to update/patch all member attributes, because some are readOnly, you can also use the [composite](https://electrodb.dev/en/mutations/patch#composite) method on [update](https://electrodb.dev/en/mutations/update#composite) and [patch](https://electrodb.dev/en/mutations/patch#composite). More information and the discussion around the reasoning behind this change can be found [here](https://github.com/tywalch/electrodb/issues/366). Failure to provide all attributes will result in an [Invalid Index Composite Attributes Provided Error](https://electrodb.dev/en/reference/errors#invalid-index-composite-attributes-provided).

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "electrodb",
3-
"version": "2.13.1",
3+
"version": "2.14.0",
44
"description": "A library to more easily create and interact with multiple entities and heretical relationships in dynamodb",
55
"main": "index.js",
66
"scripts": {

src/clauses.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,12 +432,16 @@ let clauses = {
432432
const { pk } = state.query.keys;
433433
const sk = state.query.keys.sk[0];
434434

435-
const { updatedKeys, setAttributes, indexKey } = entity._getPutKeys(
435+
const { updatedKeys, setAttributes, indexKey, deletedKeys = [] } = entity._getPutKeys(
436436
pk,
437437
sk && sk.facets,
438438
onlySetAppliedData,
439439
);
440440

441+
for (const deletedKey of deletedKeys) {
442+
state.query.update.remove(deletedKey)
443+
}
444+
441445
// calculated here but needs to be used when building the params
442446
upsert.indexKey = indexKey;
443447

src/entity.js

Lines changed: 168 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ const u = require("./util");
3939
const e = require("./errors");
4040
const v = require("./validations");
4141

42+
const ImpactedIndexTypeSource = {
43+
composite: 'composite',
44+
provided: 'provided',
45+
}
46+
4247
class Entity {
4348
constructor(model, config = {}) {
4449
config = c.normalizeConfig(config);
@@ -2354,14 +2359,13 @@ class Entity {
23542359
// change, and we also don't want to trigger the setters of any attributes watching these facets because that
23552360
// should only happen when an attribute is changed.
23562361
const attributesAndComposites = {
2357-
...update.composites,
23582362
...preparedUpdateValues,
23592363
};
23602364
const {
23612365
indexKey,
23622366
updatedKeys,
23632367
deletedKeys = [],
2364-
} = this._getUpdatedKeys(pk, sk, attributesAndComposites, removed);
2368+
} = this._getUpdatedKeys(pk, sk, attributesAndComposites, removed, update.composites);
23652369
const accessPattern =
23662370
this.model.translations.indexes.fromIndexToAccessPattern[TableIndex];
23672371
for (const path of Object.keys(preparedUpdateValues)) {
@@ -2926,11 +2930,13 @@ class Entity {
29262930
return params;
29272931
}
29282932

2929-
_expectIndexFacets(attributes, facets) {
2933+
_expectIndexFacets(attributes, facets, { utilizeIncludedOnlyIndexes, skipConditionCheck } = {}) {
29302934
let [isIncomplete, { incomplete, complete }] = this._getIndexImpact(
29312935
attributes,
29322936
facets,
2937+
{ utilizeIncludedOnlyIndexes, skipConditionCheck },
29332938
);
2939+
29342940
if (isIncomplete) {
29352941
let incompleteAccessPatterns = incomplete.map(
29362942
({ index }) =>
@@ -2940,6 +2946,7 @@ class Entity {
29402946
(result, { missing }) => [...result, ...missing],
29412947
[],
29422948
);
2949+
29432950
throw new e.ElectroError(
29442951
e.ErrorCodes.IncompleteCompositeAttributes,
29452952
`Incomplete composite attributes: Without the composite attributes ${u.commaSeparatedString(
@@ -2953,11 +2960,11 @@ class Entity {
29532960
return complete;
29542961
}
29552962

2956-
_makeKeysFromAttributes(indexes, attributes) {
2963+
_makeKeysFromAttributes(indexes, attributes, conditions) {
29572964
let indexKeys = {};
29582965
for (let [index, keyTypes] of Object.entries(indexes)) {
2959-
const shouldMakeKeys = this.model.indexes[this.model.translations.indexes.fromIndexToAccessPattern[index]].condition(attributes);
2960-
if (!shouldMakeKeys) {
2966+
const shouldMakeKeys = !this._indexConditionIsDefined(index) || conditions[index];
2967+
if (!shouldMakeKeys && index !== TableIndex) {
29612968
continue;
29622969
}
29632970

@@ -3008,8 +3015,19 @@ class Entity {
30083015
let completeFacets = this._expectIndexFacets(
30093016
{ ...setAttributes, ...validationAssistance },
30103017
{ ...keyAttributes },
3018+
{ set },
30113019
);
30123020

3021+
let deletedKeys = [];
3022+
for (const [indexName, condition] of Object.entries(completeFacets.conditions)) {
3023+
if (!condition) {
3024+
deletedKeys.push(this.model.translations.keys[indexName][KeyTypes.pk]);
3025+
if (this.model.translations.keys[indexName][KeyTypes.sk]) {
3026+
deletedKeys.push(this.model.translations.keys[indexName][KeyTypes.sk]);
3027+
}
3028+
}
3029+
}
3030+
30133031
// complete facets, only includes impacted facets which likely does not include the updateIndex which then needs to be added here.
30143032
if (!completeFacets.indexes.includes(updateIndex)) {
30153033
completeFacets.indexes.push(updateIndex);
@@ -3036,20 +3054,24 @@ class Entity {
30363054
}
30373055
}
30383056

3039-
return { indexKey, updatedKeys, setAttributes };
3057+
return { indexKey, updatedKeys, setAttributes, deletedKeys };
30403058
}
30413059

3042-
_getUpdatedKeys(pk, sk, set, removed) {
3060+
_getUpdatedKeys(pk, sk, set, removed, composite = {}) {
30433061
let updateIndex = TableIndex;
30443062
let keyTranslations = this.model.translations.keys;
30453063
let keyAttributes = { ...sk, ...pk };
3064+
30463065
let completeFacets = this._expectIndexFacets(
30473066
{ ...set },
3048-
{ ...keyAttributes },
3067+
{ ...composite, ...keyAttributes },
3068+
{ utilizeIncludedOnlyIndexes: true },
30493069
);
3070+
30503071
const removedKeyImpact = this._expectIndexFacets(
30513072
{ ...removed },
30523073
{ ...keyAttributes },
3074+
{ skipConditionCheck: true }
30533075
);
30543076

30553077
// complete facets, only includes impacted facets which likely does not include the updateIndex which then needs to be added here.
@@ -3059,17 +3081,29 @@ class Entity {
30593081
sk: "sk",
30603082
};
30613083
}
3084+
30623085
let composedKeys = this._makeKeysFromAttributes(
30633086
completeFacets.impactedIndexTypes,
3064-
{ ...set, ...keyAttributes },
3087+
{ ...composite, ...set, ...keyAttributes },
3088+
completeFacets.conditions,
30653089
);
30663090

30673091
let updatedKeys = {};
30683092
let deletedKeys = [];
30693093
let indexKey = {};
3094+
for (const [indexName, condition] of Object.entries(completeFacets.conditions)) {
3095+
if (!condition) {
3096+
deletedKeys.push(this.model.translations.keys[indexName][KeyTypes.pk]);
3097+
if (this.model.translations.keys[indexName][KeyTypes.sk]) {
3098+
deletedKeys.push(this.model.translations.keys[indexName][KeyTypes.sk]);
3099+
}
3100+
}
3101+
}
3102+
30703103
for (const keys of Object.values(removedKeyImpact.impactedIndexTypes)) {
30713104
deletedKeys = deletedKeys.concat(Object.values(keys));
30723105
}
3106+
30733107
for (let [index, keys] of Object.entries(composedKeys)) {
30743108
let { pk, sk } = keyTranslations[index];
30753109
if (index === updateIndex) {
@@ -3103,58 +3137,111 @@ class Entity {
31033137
return { indexKey, updatedKeys, deletedKeys };
31043138
}
31053139

3140+
_indexConditionIsDefined(index) {
3141+
const definition = this.model.indexes[this.model.translations.indexes.fromIndexToAccessPattern[index]];
3142+
return definition && definition.conditionDefined;
3143+
}
3144+
31063145
/* istanbul ignore next */
3107-
_getIndexImpact(attributes = {}, included = {}) {
3146+
_getIndexImpact(attributes = {}, included = {}, { utilizeIncludedOnlyIndexes, skipConditionCheck } = {}) {
3147+
// beware: this entire algorithm stinks and needs to be completely refactored. It does redundant loops and fights
3148+
// itself the whole way through. I am sorry.
31083149
let includedFacets = Object.keys(included);
31093150
let impactedIndexes = {};
3110-
let skippedIndexes = new Set();
3151+
let conditions = {};
31113152
let impactedIndexTypes = {};
3153+
let impactedIndexTypeSources = {};
31123154
let completedIndexes = [];
31133155
let facets = {};
31143156
for (let [attribute, indexes] of Object.entries(this.model.facets.byAttr)) {
31153157
if (attributes[attribute] !== undefined) {
31163158
facets[attribute] = attributes[attribute];
3117-
indexes.forEach(({ index, type }) => {
3159+
indexes.forEach((definition) => {
3160+
const { index, type } = definition;
31183161
impactedIndexes[index] = impactedIndexes[index] || {};
31193162
impactedIndexes[index][type] = impactedIndexes[index][type] || [];
31203163
impactedIndexes[index][type].push(attribute);
31213164
impactedIndexTypes[index] = impactedIndexTypes[index] || {};
3122-
impactedIndexTypes[index][type] =
3123-
this.model.translations.keys[index][type];
3165+
impactedIndexTypes[index][type] = this.model.translations.keys[index][type];
3166+
3167+
impactedIndexTypeSources[index] = impactedIndexTypeSources[index] || {};
3168+
impactedIndexTypeSources[index][type] = ImpactedIndexTypeSource.provided;
31243169
});
31253170
}
31263171
}
31273172

3128-
for (const indexName in impactedIndexes) {
3129-
const accessPattern = this.model.translations.indexes.fromIndexToAccessPattern[indexName];
3130-
const shouldMakeKeys = this.model.indexes[accessPattern].condition({ ...attributes, ...included });
3131-
if (!shouldMakeKeys) {
3132-
skippedIndexes.add(indexName);
3173+
// this function is used to determine key impact for update `set`, update `delete`, and `put`. This block is currently only used by update `set`
3174+
if (utilizeIncludedOnlyIndexes) {
3175+
for (const [index, { pk, sk }] of Object.entries(this.model.facets.byIndex)) {
3176+
// The main table index is handled somewhere else (messy I know), and we only want to do this processing if an
3177+
// index condition is defined for backwards compatibility. Backwards compatibility is not required for this
3178+
// change, but I have paranoid concerns of breaking changes around sparse indexes.
3179+
if (index === TableIndex || !this._indexConditionIsDefined(index)) {
3180+
continue;
3181+
}
3182+
3183+
if (pk && pk.length && pk.every(attr => included[attr] !== undefined)) {
3184+
pk.forEach((attr) => {
3185+
facets[attr] = included[attr];
3186+
});
3187+
impactedIndexes[index] = impactedIndexes[index] || {};
3188+
impactedIndexes[index][KeyTypes.pk] = [...pk];
3189+
impactedIndexTypes[index] = impactedIndexTypes[index] || {};
3190+
impactedIndexTypes[index][KeyTypes.pk] = this.model.translations.keys[index][KeyTypes.pk];
3191+
3192+
// flagging the impactedIndexTypeSource as `composite` means the entire key is only being impacted because
3193+
// all composites are in `included`. This will help us determine if we need to evaluate the `condition`
3194+
// callback for the index. If both the `sk` and `pk` were impacted because of `included` then we can skip
3195+
// the condition check because the index doesn't need to be recalculated;
3196+
impactedIndexTypeSources[index] = impactedIndexTypeSources[index] || {};
3197+
impactedIndexTypeSources[index][KeyTypes.pk] = impactedIndexTypeSources[index][KeyTypes.pk] || ImpactedIndexTypeSource.composite;
3198+
}
3199+
3200+
if (sk && sk.length && sk.every(attr => included[attr] !== undefined)) {
3201+
if (this.model.translations.keys[index][KeyTypes.sk]) {
3202+
sk.forEach((attr) => {
3203+
facets[attr] = included[attr];
3204+
});
3205+
impactedIndexes[index] = impactedIndexes[index] || {};
3206+
impactedIndexes[index][KeyTypes.sk] = [...sk];
3207+
impactedIndexTypes[index] = impactedIndexTypes[index] || {};
3208+
impactedIndexTypes[index][KeyTypes.sk] = this.model.translations.keys[index][KeyTypes.sk];
3209+
3210+
// flagging the impactedIndexTypeSource as `composite` means the entire key is only being impacted because
3211+
// all composites are in `included`. This will help us determine if we need to evaluate the `condition`
3212+
// callback for the index. If both the `sk` and `pk` were impacted because of `included` then we can skip
3213+
// the condition check because the index doesn't need to be recalculated;
3214+
impactedIndexTypeSources[index] = impactedIndexTypeSources[index] || {};
3215+
impactedIndexTypeSources[index][KeyTypes.sk] = impactedIndexTypeSources[index][KeyTypes.sk] || ImpactedIndexTypeSource.composite;
3216+
}
3217+
}
31333218
}
31343219
}
31353220

3136-
let incomplete = Object.entries(this.model.facets.byIndex)
3137-
.map(([index, { pk, sk }]) => {
3221+
let indexesWithMissingComposites = Object.entries(this.model.facets.byIndex)
3222+
.map(([index, definition]) => {
3223+
const { pk, sk } = definition;
31383224
let impacted = impactedIndexes[index];
31393225
let impact = {
31403226
index,
3227+
definition,
31413228
missing: []
31423229
};
3143-
if (impacted && !skippedIndexes.has(index)) {
3230+
if (impacted) {
31443231
let missingPk =
31453232
impacted[KeyTypes.pk] && impacted[KeyTypes.pk].length !== pk.length;
31463233
let missingSk =
31473234
impacted[KeyTypes.sk] && impacted[KeyTypes.sk].length !== sk.length;
31483235
if (missingPk) {
3149-
impact.missing = [
3150-
...impact.missing,
3151-
...pk.filter((attr) => {
3152-
return (
3153-
!impacted[KeyTypes.pk].includes(attr) &&
3154-
!includedFacets.includes(attr)
3155-
);
3156-
}),
3157-
];
3236+
impact.missing = [
3237+
...impact.missing,
3238+
...pk.filter((attr) => {
3239+
return (
3240+
!impacted[KeyTypes.pk].includes(attr) &&
3241+
!includedFacets.includes(attr)
3242+
);
3243+
}),
3244+
];
31583245
}
31593246
if (missingSk) {
31603247
impact.missing = [
@@ -3170,12 +3257,55 @@ class Entity {
31703257
completedIndexes.push(index);
31713258
}
31723259
}
3260+
31733261
return impact;
3174-
})
3175-
.filter(({ missing }) => missing.length);
3262+
});
3263+
3264+
let incomplete = [];
3265+
for (const { index, missing, definition } of indexesWithMissingComposites) {
3266+
const indexConditionIsDefined = this._indexConditionIsDefined(index);
3267+
3268+
// `skipConditionCheck` is being used by update `remove`. If Attributes are being removed then the condition check
3269+
// is meaningless and ElectroDB should uphold its obligation to keep keys and attributes in sync.
3270+
// `index === TableIndex` is a special case where we don't need to check the condition because the main table is immutable
3271+
// `!this._indexConditionIsDefined(index)` means the index doesn't have a condition defined, so we can skip the check
3272+
if (skipConditionCheck || index === TableIndex || !indexConditionIsDefined) {
3273+
incomplete.push({ index, missing });
3274+
conditions[index] = true;
3275+
continue;
3276+
}
3277+
3278+
const memberAttributeIsImpacted = impactedIndexTypeSources[index] && (impactedIndexTypeSources[index][KeyTypes.pk] === ImpactedIndexTypeSource.provided || impactedIndexTypeSources[index][KeyTypes.sk] === ImpactedIndexTypeSource.provided);
3279+
const allMemberAttributesAreIncluded = definition.all.every(({name}) => included[name] !== undefined);
3280+
3281+
if (memberAttributeIsImpacted || allMemberAttributesAreIncluded) {
3282+
// the `missing` array will contain indexes that are partially provided, but that leaves cases where the pk or
3283+
// sk of an index is complete but not both. Both cases are invalid if `indexConditionIsDefined=true`
3284+
const missingAttributes = definition.all
3285+
.filter(({name}) => !attributes[name] && !included[name] || missing.includes(name))
3286+
.map(({name}) => name)
3287+
3288+
if (missingAttributes.length) {
3289+
throw new e.ElectroError(e.ErrorCodes.IncompleteIndexCompositesAttributesProvided, `Incomplete composite attributes provided for index ${index}. Write operations that include composite attributes, for indexes with a condition callback defined, must always provide values for every index composite. This is to ensure consistency between index values and attribute values. Missing composite attributes identified: ${u.commaSeparatedString(missingAttributes)}`);
3290+
}
3291+
3292+
const accessPattern = this.model.translations.indexes.fromIndexToAccessPattern[index];
3293+
let shouldMakeKeys = !!this.model.indexes[accessPattern].condition({...attributes, ...included});
3294+
3295+
// this helps identify which conditions were checked (key is present) and what the result was (true/false)
3296+
conditions[index] = shouldMakeKeys;
3297+
if (!shouldMakeKeys) {
3298+
continue;
3299+
}
3300+
} else {
3301+
incomplete.push({ index, missing });
3302+
}
3303+
}
3304+
3305+
incomplete = incomplete.filter(({ missing }) => missing.length);
31763306

31773307
let isIncomplete = !!incomplete.length;
3178-
let complete = { facets, indexes: completedIndexes, impactedIndexTypes };
3308+
let complete = { facets, indexes: completedIndexes, impactedIndexTypes, conditions };
31793309
return [isIncomplete, { incomplete, complete }];
31803310
}
31813311

@@ -3944,6 +4074,8 @@ class Entity {
39444074
`The index option 'condition' is only allowed on secondary indexes`,
39454075
);
39464076
}
4077+
4078+
let conditionDefined = v.isFunction(index.condition);
39474079
let indexCondition = index.condition || (() => true);
39484080

39494081
if (indexType === "clustered") {
@@ -4054,6 +4186,7 @@ class Entity {
40544186
index: indexName,
40554187
scope: indexScope,
40564188
condition: indexCondition,
4189+
conditionDefined: conditionDefined,
40574190
};
40584191

40594192
indexHasSubCollections[indexName] =

0 commit comments

Comments
 (0)