dyngoose
Version:
Elegant DynamoDB object modeling for Typescript
665 lines • 25.9 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Table = void 0;
const _ = __importStar(require("lodash"));
const document_client_1 = require("./document-client");
const search_1 = require("./query/search");
const create_table_1 = require("./tables/create-table");
const delete_table_1 = require("./tables/delete-table");
const describe_table_1 = require("./tables/describe-table");
const migrate_table_1 = require("./tables/migrate-table");
const schema_1 = require("./tables/schema");
const truly_empty_1 = require("./utils/truly-empty");
class Table {
// #region static
// #region static properties
static get schema() {
if (this.__schema == null) {
this.__schema = new schema_1.Schema(this);
}
return this.__schema;
}
static set schema(schema) {
this.__schema = schema;
}
static get documentClient() {
if (this.__documentClient == null) {
this.__documentClient = new document_client_1.DocumentClient(this);
}
return this.__documentClient;
}
static set documentClient(documentClient) {
this.__documentClient = documentClient;
}
// #endregion static properties
// #region static methods
/**
* Creates a new record for this table.
*
* This method is strongly typed and it is recommended you use over `new Table(…)`
*/
static new(values) {
const record = new this().applyDefaults();
if (values != null) {
record.setValues(_.omitBy(values, (v) => v === undefined));
}
return record;
}
/**
* Creates a new instance of Table with values from a given `AttributeMap`.
*
* This assumes the record exists in DynamoDB and saving this record will
* default to using an `UpdateItem` operation rather than a `PutItem` operation
* upon being saved.
*/
static fromDynamo(attributes, entireDocument = true) {
return new this().fromDynamo(attributes, entireDocument);
}
/**
* Creates an instance of Table from raw user input. Designs to be used for creating
* records from requests, like:
*
* express.js:
* ```app.post('/api/create', (req, res) => {
* const card = Card.fromJSON(req.body)
* })```
*
* Each attribute can optionally define additional validation logic or sanitization
* of the user input, @see {@link https://github.com/benhutchins/dyngoose/blob/master/docs/Attributes}.
*/
static fromJSON(json) {
return new this().applyDefaults().fromJSON(json);
}
/**
* Query DynamoDB for what you need.
*
* This is a powerful all-around querying method. It will detect the best index available for use,
* but it ignores indexes that are not set to Projection of `ALL`. To please use the index-specific
* querying when necessary.
*
* This will avoid performing a scan at all cost, but it will fall back to using a scan if necessary.
*
* By default, this returns you one "page" of results (allows DynamoDB) to process and return the
* maximum of items DynamoDB allows. If you want it to internally page for you to return all possible
* results (be cautious as that can easily cause timeouts for Lambda), specify `{ all: true }` as an
* input argument for the second argument.
*/
static search(filters, input = {}) {
return new search_1.MagicSearch(this, filters, input);
}
/**
* Creates the table in DynamoDB.
*
* You can also use {@link Table.migrateTable} to create and automatically
* migrate and indexes that need changes.
*/
static async createTable(waitForReady = true) {
return await (0, create_table_1.createTable)(this.schema, waitForReady);
}
/**
* Migrates the table to match updated specifications.
*
* This will create new indexes and delete legacy indexes.
*/
static async migrateTable() {
return await (0, migrate_table_1.migrateTable)(this.schema);
}
/**
* Deletes the table from DynamoDB.
*
* Be a bit careful with this in production.
*/
static async deleteTable() {
return await (0, delete_table_1.deleteTable)(this.schema);
}
static async describeTable(requestOptions) {
return await (0, describe_table_1.describeTable)(this.schema, requestOptions);
}
// #endregion static methods
// #endregion static
// #region properties
get table() {
return this.constructor;
}
// #endregion properties
/**
* Create a new Table record by attribute names, not property names.
*
* @see {@link Table.new} To create a strongly-typed record by property names.
*/
constructor(values) {
// raw storage for all attributes this record (instance) has
this.__attributes = {};
this.__original = {};
this.__updatedAttributes = new Set();
this.__removedAttributes = new Set();
this.__updateOperators = {};
this.__putRequired = true; // true when this is a new record and a putItem is required, false when updateItem can be used
this.__entireDocumentIsKnown = true;
if (values != null) {
for (const key of Object.keys(values)) {
this.setAttribute(key, values[key]);
}
}
}
// #region public methods
/**
* Apply any default values for attributes.
*/
applyDefaults() {
const attributes = this.table.schema.getAttributes();
for (const [, attribute] of attributes) {
const defaultValue = attribute.getDefaultValue();
if (defaultValue != null) {
this.setByAttribute(attribute, defaultValue);
}
}
return this;
}
/**
* Load values from an a AttributeMap into this Table record.
*
* This assumes the values are loaded directly from DynamoDB, and after
* setting the attributes it resets the attributes pending update and
* deletion.
*/
fromDynamo(values, entireDocument = true) {
this.__attributes = values;
// this is an existing record in the database, so when we save it, we need to update
this.__updatedAttributes = new Set();
this.__removedAttributes = new Set();
this.__putRequired = false;
this.__entireDocumentIsKnown = entireDocument;
return this;
}
/**
* Converts the current attribute values into a AttributeMap which
* can be sent directly to DynamoDB within a PutItem, UpdateItem, or similar
* request.
*
* @param {boolean} updateOnSaveAttributes If true, update all attributes that have logic to update with every save.
*/
toDynamo(updateOnSaveAttributes = false) {
if (updateOnSaveAttributes) {
this.updateOnSaveAttributes();
}
// anytime toDynamo is called, it can generate new default values or manipulate values
// this keeps the record in sync, so the instance can be used after the record is saved
const attributeMap = this.table.schema.toDynamo(this);
for (const attributeName of Object.keys(attributeMap)) {
if (!_.isEqual(this.__attributes[attributeName], attributeMap[attributeName])) {
this.__updatedAttributes.add(attributeName);
}
}
this.__attributes = attributeMap;
return this.__attributes;
}
/**
* Get the DynamoDB.Key for this record.
*/
getDynamoKey() {
const hash = this.getAttribute(this.table.schema.primaryKey.hash.name);
const key = {
[this.table.schema.primaryKey.hash.name]: this.table.schema.primaryKey.hash.toDynamoAssert(hash),
};
if (this.table.schema.primaryKey.range != null) {
const range = this.getAttribute(this.table.schema.primaryKey.range.name);
key[this.table.schema.primaryKey.range.name] = this.table.schema.primaryKey.range.toDynamoAssert(range);
}
return key;
}
/**
* Get the list of attributes pending update.
*
* The result includes attributes that have also been deleted. To get just
* the list of attributes pending removal, use {@link Table.getRemovedAttributes}.
*
* If you want to easily know if this record has updates pending, use {@link Table.hasChanges}.
*/
getUpdatedAttributes() {
return [...this.__updatedAttributes];
}
/**
* Get the list of attributes pending removal.
*
* To get all the attributes that have been updated, use {@link Table.getUpdatedAttributes}.
*
* If you want to easily know if this record has updates pending, use {@link Table.hasChanges}.
*/
getRemovedAttributes() {
return [...this.__removedAttributes];
}
/**
* Use getRemovedAttributes.
*
* @deprecated
*/
getDeletedAttributes() {
return this.getRemovedAttributes();
}
/**
* While similar to setAttributes, this method runs the attribute's defined fromJSON
* methods to help standardize the attribute values as much as possible.
*
* @param {any} json A JSON object
* @param {boolean} [ignoreArbitrary] Whether arbitrary attributes should be ignored.
* When false, unknown attributes will result in an error being thrown.
* When true, any non-recognized attribute will be ignored. Useful if you're
* passing in raw request body objects or dealing with user input.
* Defaults to false.
*/
fromJSON(json, ignoreArbitrary = false) {
const blacklist = this.table.getBlacklist();
_.each(json, (value, propertyName) => {
let attribute;
try {
attribute = this.table.schema.getAttributeByPropertyName(propertyName);
}
catch (ex) {
if (ignoreArbitrary) {
return;
}
else {
throw ex;
}
}
if (!_.includes(blacklist, attribute.name)) {
// allow the attribute to transform the value via a custom fromJSON method
if (!(0, truly_empty_1.isTrulyEmpty)(value) && typeof attribute.type.fromJSON === 'function') {
value = attribute.type.fromJSON(value);
}
const currentValue = this.getAttribute(attribute.name);
// compare to current value, to avoid unnecessarily marking attributes as needing to be saved
if (!_.isEqual(currentValue, value)) {
if ((0, truly_empty_1.isTrulyEmpty)(value)) {
this.removeAttribute(attribute.name);
}
else {
this.setByAttribute(attribute, value);
}
}
}
});
return this;
}
/**
* Returns the AttributeValue value for an attribute.
*
* To get the transformed value, use {@link Table.getAttribute}
*/
getAttributeDynamoValue(attributeName) {
return this.__attributes[attributeName];
}
/**
* Gets the JavaScript transformed value for an attribute.
*
* While you can read values directly on the Table record by its property name,
* sometimes you need to get attribute.
*
* Unlike {@link Table.get}, this excepts the attribute name, not the property name.
*/
getAttribute(attributeName) {
const attribute = this.table.schema.getAttributeByName(attributeName);
return this.getByAttribute(attribute);
}
/**
* Get the update operator for an attribute.
*/
getUpdateOperator(propertyName) {
const attribute = this.table.schema.getAttributeByPropertyName(propertyName);
return this.getAttributeUpdateOperator(attribute.name);
}
getAttributeUpdateOperator(attributeName) {
var _a;
return (_a = this.__updateOperators[attributeName]) !== null && _a !== void 0 ? _a : 'set';
}
/**
* Set the update operator for a property.
*/
setUpdateOperator(propertyName, operator) {
const attribute = this.table.schema.getAttributeByPropertyName(propertyName);
return this.setAttributeUpdateOperator(attribute.name, operator);
}
/**
* Set the update operator for an attribute.
*/
setAttributeUpdateOperator(attributeName, operator) {
this.__updateOperators[attributeName] = operator;
return this;
}
/**
* Sets the AttributeValue for an attribute.
*
* To set the value from a JavaScript object, use {@link Table.setAttribute}
*/
setAttributeDynamoValue(attributeName, attributeValue) {
// store the original value
this.saveOriginalValue(attributeName);
// store the new value
this.__attributes[attributeName] = attributeValue;
// track that this value was updated
this.__updatedAttributes.add(attributeName);
// ensure the attribute is not marked for removal
this.__removedAttributes.delete(attributeName);
return this;
}
/**
* Sets the value of an attribute by attribute name from a JavaScript object.
*
* - To set an attribute value by property name, use {@link Table.set}.
*/
setAttribute(attributeName, value, params) {
const attribute = this.table.schema.getAttributeByName(attributeName);
return this.setByAttribute(attribute, value, params);
}
/**
* Sets several attribute values on this record by attribute names.
*
* - To set several values by property names, use {@link Table.setValues}.
* - To set a single attribute value by attribute name, use {@link Table.setAttribute}.
* - To set a single attribute value by property name, use {@link Table.set}.
*
* @param {object} values An object, where the keys are the attribute names,
* and the values are the values you'd like to set.
*/
setAttributes(values) {
_.forEach(values, (value, attributeName) => {
this.setAttribute(attributeName, value);
});
return this;
}
/**
* Remove a single attribute by its attribute name.
*
* @see {@link Table.remove} Remove an attribute by its property name.
* @see {@link Table.removeAttributes} Remove several attributes by their property names.
*/
removeAttribute(...attributeNames) {
for (const attributeName of attributeNames) {
// delete the attribute as long as it existed and wasn't already null
if (!_.isNil(this.__attributes[attributeName]) || !this.__entireDocumentIsKnown) {
this.saveOriginalValue(attributeName);
this.__attributes[attributeName] = { NULL: true };
this.__removedAttributes.add(attributeName);
this.__updatedAttributes.delete(attributeName);
}
}
return this;
}
/**
* Remove several attributes by their property names.
*
* @see {@link Table.remove} Remove an attribute by its property name.
* @see {@link Table.removeAttribute} Remove a single attribute by its attribute name.
*
* @deprecated You can now pass multiple attributes to removeAttribute.
*/
removeAttributes(attributes) {
for (const attribute of attributes) {
this.removeAttribute(attribute);
}
return this;
}
/**
* Sets a value of an attribute by its property name.
*
* @see {@link Table.setValues} To set several attribute values by property names.
* @see {@link Table.setAttribute} To set an attribute value by an attribute name.
* @see {@link Table.setAttributes} To set several attribute values by attribute names.
*/
set(propertyName, value, params) {
const attribute = this.table.schema.getAttributeByPropertyName(propertyName);
return this.setByAttribute(attribute, value, params);
}
/**
* Gets a value of an attribute by its property name.
*
* @see {@link Table.getAttribute} To get a value by an attribute name.
* @see {@link Table.toJSON} To get the entire record.
*/
get(propertyName) {
const attribute = this.table.schema.getAttributeByPropertyName(propertyName);
return this.getByAttribute(attribute);
}
/**
* Remove an attribute by its property name.
*
* @see {@link Table.removeAttribute} Remove a single attribute by its attribute name.
*/
remove(...propertyNames) {
for (const propertyName of propertyNames) {
const attribute = this.table.schema.getAttributeByPropertyName(propertyName);
this.removeAttribute(attribute.name);
}
return this;
}
/**
* Update several attribute values on this record by property names.
*
* @see {@link Table.set} To set an attribute value by property name.
* @see {@link Table.setAttribute} To set an attribute value by an attribute names.
* @see {@link Table.setAttributes} To set several attribute values by attribute names.
*/
setValues(values) {
for (const key in values) {
this.set(key, values[key]);
}
return this;
}
/**
* Determines if this record has any attributes pending an update or deletion.
*/
hasChanges() {
return this.__updatedAttributes.size > 0 || this.__removedAttributes.size > 0;
}
/**
* Return the original values for the record, if it was loaded from DynamoDB.
*/
getOriginalValues() {
return this.__original;
}
async save(event) {
var _a;
const operator = (_a = event === null || event === void 0 ? void 0 : event.operator) !== null && _a !== void 0 ? _a : this.getSaveOperation();
const beforeSaveEvent = {
...event,
operator,
};
const allowSave = await this.beforeSave(beforeSaveEvent);
if (beforeSaveEvent.force === true || (allowSave !== false && this.hasChanges())) {
let output;
if (beforeSaveEvent.operator === 'put') {
output = await this.table.documentClient.put(this, beforeSaveEvent);
}
else {
output = await this.table.documentClient.update(this, beforeSaveEvent);
}
// grab the current changes to pass to the afterSave event
const originalValues = this.getOriginalValues();
const updatedAttributes = this.getUpdatedAttributes();
const removedAttributes = this.getRemovedAttributes();
// reset internal tracking of changes attributes
this.__putRequired = false;
this.__original = {};
this.__removedAttributes = new Set();
this.__updatedAttributes = new Set();
this.__updateOperators = {};
// trigger afterSave before clearing values, so the hook can determine what has been changed
await this.afterSave({
...beforeSaveEvent,
output,
originalValues,
updatedAttributes,
removedAttributes,
deletedAttributes: removedAttributes,
});
if (beforeSaveEvent.returnOutput === true) {
return output;
}
}
}
/**
* Returns whether this is a newly created record that hasn't been saved
* It is not a guarantee that the hash key is not already in use
*/
isNew() {
return this.__putRequired;
}
/**
* Determine the best save operation method to use based upon the item's current state
*/
getSaveOperation() {
let type;
if (this.__putRequired || !this.hasChanges()) {
this.__putRequired = false;
type = 'put';
}
else {
type = 'update';
}
return type;
}
async delete(event) {
const beforeDeleteEvent = { ...event };
const allowDeletion = await this.beforeDelete(beforeDeleteEvent);
if (allowDeletion) {
const output = await this.table.documentClient.delete(this, event === null || event === void 0 ? void 0 : event.conditions);
const afterDeleteEvent = {
...beforeDeleteEvent,
output,
};
await this.afterDelete(afterDeleteEvent);
if (beforeDeleteEvent.returnOutput === true) {
return output;
}
}
}
/**
* Convert this record to a JSON-exportable object.
*
* Has no consideration for "views" or "permissions", so all attributes
* will be exported.
*
* Export object uses the property names as the object keys. To convert
* a JSON object back into a Table record, use {@link Table.fromJSON}.
*
* Each attribute type can define a custom toJSON and fromJSON method,
* @see {@link https://github.com/benhutchins/dyngoose/blog/master/docs/Attributes.md#custom-attribute-types}.
*/
toJSON() {
const json = {};
for (const [attributeName, attribute] of this.table.schema.getAttributes()) {
const propertyName = attribute.propertyName;
const value = this.getAttribute(attributeName);
if (!(0, truly_empty_1.isTrulyEmpty)(value)) {
if (_.isFunction(attribute.type.toJSON)) {
json[propertyName] = attribute.type.toJSON(value, attribute);
}
else {
json[propertyName] = value;
}
}
}
return json;
}
// #endregion public methods
// #region protected methods
async beforeSave(event) {
return true;
}
/**
* After a record is deleted, this handler is called.
*/
async afterSave(event) {
return undefined;
}
/**
* Before a record is deleted, this handler is called and if the promise
* resolves as false, the delete request will be ignored.
*/
async beforeDelete(event) {
return true;
}
/**
* After a record is deleted, this handler is called.
*/
async afterDelete(event) {
return undefined;
}
/**
* Ensures Date attributes with nowOnUpdate are updated whenever the record is
* being saved.
*/
updateOnSaveAttributes() {
// ensure now on update is updated whenever the record is being saved
for (const [attributeName, attribute] of this.table.schema.getAttributes()) {
if (attribute.metadata.nowOnUpdate === true) {
this.setAttribute(attributeName, new Date());
}
}
}
setByAttribute(attribute, value, params = {}) {
var _a;
const attributeValue = attribute.toDynamo(value);
// avoid recording the value if it is unchanged, so we do not send it as an updated value during a save
if (params.force !== true && !_.isNil(this.__attributes[attribute.name]) && _.isEqual(this.__attributes[attribute.name], attributeValue)) {
return this;
}
if (attributeValue == null) {
this.removeAttribute(attribute.name);
}
else {
this.setAttributeDynamoValue(attribute.name, attributeValue);
this.setAttributeUpdateOperator(attribute.name, (_a = params.operator) !== null && _a !== void 0 ? _a : 'set');
}
return this;
}
getByAttribute(attribute) {
const attributeValue = this.getAttributeDynamoValue(attribute.name);
const value = attribute.fromDynamo(_.cloneDeep(attributeValue));
return value;
}
saveOriginalValue(attributeName) {
// save the original value before we remove the attribute's value
if (!_.isNil(this.__attributes[attributeName]) && _.isNil(this.__original[attributeName])) {
this.__original[attributeName] = this.getAttributeDynamoValue(attributeName);
}
}
/**
* Returns a list of attributes that should not be allowed when Table.fromJSON is used.
*/
static getBlacklist() {
const blacklist = [
this.schema.primaryKey.hash.name,
];
if (this.schema.primaryKey.range != null) {
blacklist.push(this.schema.primaryKey.range.name);
}
return blacklist;
}
}
exports.Table = Table;
//# sourceMappingURL=table.js.map