UNPKG

dyngoose

Version:

Elegant DynamoDB object modeling for Typescript

665 lines 25.9 kB
"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