> {
+ if (!is.object(options)) {
+ throw new Error('Options must be an Object');
+ }
+ const readAll = !!options.readAll || false;
+ const virtuals = !!options.virtuals || false;
+ const showKey = !!options.showKey || false;
+
+ if (virtuals) {
+ // Add any possible virtual properties to the object
+ this.entityData = this.__getEntityDataWithVirtuals();
+ }
+
+ const data = datastoreSerializer.fromDatastore(this.entityData, this.constructor as Model, {
+ readAll,
+ showKey,
+ });
+
+ return data;
+ }
+
+ get(path: P): any {
+ if ({}.hasOwnProperty.call(this.schema.__virtuals, path)) {
+ return this.schema.__virtuals[path as string].applyGetters(this.entityData);
+ }
+ return this.entityData[path];
+ }
+
+ set
(path: P, value: any): EntityResponse {
+ if ({}.hasOwnProperty.call(this.schema.__virtuals, path)) {
+ this.schema.__virtuals[path as string].applySetters(value, this.entityData);
+ return (this as unknown) as EntityResponse;
+ }
+
+ this.entityData[path] = value;
+ return (this as unknown) as EntityResponse;
+ }
+
+ /**
+ * Access any gstore Model from the entity instance.
+ *
+ * @param {string} entityKind The entity kind
+ * @returns {Model} The Model
+ * @example
+ ```
+ const user = new User({ name: 'john', pictId: 123});
+ const ImageModel = user.model('Image');
+ ImageModel.get(user.pictId).then(...);
+ ```
+ * @link https://sebloix.gitbook.io/gstore-node/entity/methods/model
+ */
+ model(name: string): Model {
+ return this.gstore.model(name);
+ }
+
+ // TODO: Rename this function "fetch" (and create alias to this for backward compatibility)
+ /**
+ * Fetch entity from Datastore
+ *
+ * @link https://sebloix.gitbook.io/gstore-node/entity/methods/datastoreentity
+ */
+ datastoreEntity(options = {}): Promise | null> {
+ const onEntityFetched = (result: [EntityData | null]): EntityResponse | null => {
+ const entityData = result ? result[0] : null;
+
+ if (!entityData) {
+ if (this.gstore.config.errorOnEntityNotFound) {
+ const error = new Error('Entity not found');
+ (error as any).code = ERROR_CODES.ERR_ENTITY_NOT_FOUND;
+ throw error;
+ }
+
+ return null;
+ }
+
+ this.entityData = entityData;
+ return (this as unknown) as EntityResponse;
+ };
+
+ if ((this.constructor as Model).__hasCache(options)) {
+ return this.gstore.cache!.keys.read(this.entityKey, options).then(onEntityFetched);
+ }
+ return this.gstore.ds.get(this.entityKey).then(onEntityFetched);
+ }
+
+ /**
+ * Populate entity references (whose properties are an entity Key) and merge them in the entity data.
+ *
+ * @param refs The entity references to fetch from the Datastore. Can be one (string) or multiple (array of string)
+ * @param properties The properties to return from the reference entities. If not specified, all properties will be returned
+ * @link https://sebloix.gitbook.io/gstore-node/entity/methods/populate
+ */
+ populate(
+ path?: U,
+ propsToSelect?: U extends Array ? never : string | string[],
+ ): PromiseWithPopulate> {
+ const refsToPopulate: PopulateRef[][] = [];
+
+ const promise = Promise.resolve(this).then((this.constructor as Model).__populate(refsToPopulate));
+
+ ((promise as any).populate as PopulateHandler) = populateFactory(refsToPopulate, promise, this.schema);
+ ((promise as any).populate as PopulateHandler)(path, propsToSelect);
+ return promise as any;
+ }
+
+ get id(): string | number {
+ return this.entityKey.id || this.entityKey.name!;
+ }
+
+ /**
+ * The gstore instance
+ */
+ get gstore(): Gstore {
+ if (this.__gstore === undefined) {
+ throw new Error('No gstore instance attached to entity');
+ }
+ return this.__gstore;
+ }
+
+ /**
+ * The entity Model Schema
+ */
+ get schema(): Schema {
+ if (this.__schema === undefined) {
+ throw new Error('No schema instance attached to entity');
+ }
+ return this.__schema;
+ }
+
+ /**
+ * The Datastore entity kind
+ */
+ get entityKind(): string {
+ if (this.__entityKind === undefined) {
+ throw new Error('No entity kind attached to entity');
+ }
+ return this.__entityKind;
+ }
+
+ __buildEntityData(data: GenericObject): void {
+ const { schema } = this;
+ const isJoiSchema = schema.isJoi;
+
+ // If Joi schema, get its default values
+ if (isJoiSchema) {
+ const { error, value } = schema.validateJoi(data);
+
+ if (!error) {
+ this.entityData = { ...value };
+ }
+ }
+
+ this.entityData = { ...this.entityData, ...data };
+
+ let isArray;
+ let isObject;
+
+ Object.entries(schema.paths as { [k: string]: SchemaPathDefinition }).forEach(([key, prop]) => {
+ const hasValue = {}.hasOwnProperty.call(this.entityData, key);
+ const isOptional = {}.hasOwnProperty.call(prop, 'optional') && prop.optional !== false;
+ const isRequired = {}.hasOwnProperty.call(prop, 'required') && prop.required === true;
+
+ // Set Default Values
+ if (!isJoiSchema && !hasValue && !isOptional) {
+ let value = null;
+
+ if ({}.hasOwnProperty.call(prop, 'default')) {
+ if (typeof prop.default === 'function') {
+ value = prop.default();
+ } else {
+ value = prop.default;
+ }
+ }
+
+ if ({}.hasOwnProperty.call(defaultValues.__map__, value)) {
+ /**
+ * If default value is in the gstore.defaultValue hashTable
+ * then execute the handler for that shortcut
+ */
+ value = defaultValues.__handler__(value);
+ } else if (value === null && {}.hasOwnProperty.call(prop, 'values') && !isRequired) {
+ // Default to first value of the allowed values if **not** required
+ [value] = prop.values as any[];
+ }
+
+ this.entityData[key as keyof T] = value;
+ }
+
+ // Set excludeFromIndexes
+ // ----------------------
+ isArray = prop.type === Array || (prop.joi && prop.joi._type === 'array');
+ isObject = prop.type === Object || (prop.joi && prop.joi._type === 'object');
+
+ if (prop.excludeFromIndexes === true) {
+ if (isArray) {
+ // We exclude both the array values + all the child properties of object items
+ this.__excludeFromIndexes[key as keyof T] = [`${key}[]`, `${key}[].*`];
+ } else if (isObject) {
+ // We exclude the emmbeded entity + all its properties
+ this.__excludeFromIndexes[key as keyof T] = [key, `${key}.*`];
+ } else {
+ this.__excludeFromIndexes[key as keyof T] = [key];
+ }
+ } else if (prop.excludeFromIndexes !== false) {
+ const excludedArray = arrify(prop.excludeFromIndexes) as string[];
+ if (isArray) {
+ // The format to exclude a property from an embedded entity inside
+ // an array is: "myArrayProp[].embeddedKey"
+ this.__excludeFromIndexes[key as keyof T] = excludedArray.map(propExcluded => `${key}[].${propExcluded}`);
+ } else if (isObject) {
+ // The format to exclude a property from an embedded entity
+ // is: "myEmbeddedEntity.key"
+ this.__excludeFromIndexes[key as keyof T] = excludedArray.map(propExcluded => `${key}.${propExcluded}`);
+ }
+ }
+ });
+
+ // add Symbol Key to the entityData
+ (this.entityData as any)[this.gstore.ds.KEY] = this.entityKey;
+ }
+
+ __createKey(id?: IdType, ancestors?: Ancestor, namespace?: string): EntityKey {
+ if (id && !is.number(id) && !is.string(id)) {
+ throw new Error('id must be a string or a number');
+ }
+
+ const hasAncestors = typeof ancestors !== 'undefined' && ancestors !== null && is.array(ancestors);
+
+ let path: (string | number)[] = hasAncestors ? [...ancestors!] : [];
+
+ if (id) {
+ path = [...path, this.entityKind, id];
+ } else {
+ path.push(this.entityKind);
+ }
+
+ return namespace ? this.gstore.ds.key({ namespace, path }) : this.gstore.ds.key(path);
+ }
+
+ __addAliasAndVirtualProperties(): void {
+ const { schema } = this;
+
+ // Create virtual properties (getters and setters for entityData object)
+ Object.keys(schema.paths)
+ .filter(pathKey => ({}.hasOwnProperty.call(schema.paths, pathKey)))
+ .forEach(pathKey =>
+ Object.defineProperty(this, pathKey, {
+ get: function getProp() {
+ return this.entityData[pathKey];
+ },
+ set: function setProp(newValue) {
+ this.entityData[pathKey] = newValue;
+ },
+ }),
+ );
+
+ // Create virtual properties (getters and setters for "virtuals" defined on the Schema)
+
+ Object.keys(schema.__virtuals)
+ .filter(key => ({}.hasOwnProperty.call(schema.__virtuals, key)))
+ .forEach(key =>
+ Object.defineProperty(this, key, {
+ get: function getProp() {
+ return schema.__virtuals[key].applyGetters({ ...this.entityData });
+ },
+ set: function setProp(newValue) {
+ schema.__virtuals[key].applySetters(newValue, this.entityData);
+ },
+ }),
+ );
+ }
+
+ __registerHooksFromSchema(): Entity {
+ const callQueue = this.schema.__callQueue.entity;
+
+ if (!Object.keys(callQueue).length) {
+ return this;
+ }
+
+ Object.keys(callQueue).forEach(method => {
+ if (!(this as any)[method]) {
+ return;
+ }
+
+ // Add Pre hooks
+ callQueue[method].pres.forEach(fn => {
+ (this as any).pre(method, fn);
+ });
+
+ // Add Pre hooks
+ callQueue[method].post.forEach(fn => {
+ (this as any).post(method, fn);
+ });
+ });
+
+ return this;
+ }
+
+ __addCustomMethodsFromSchema(): void {
+ Object.entries(this.schema.methods).forEach(([method, handler]) => {
+ (this as any)[method] = handler;
+ });
+ }
+
+ __getEntityDataWithVirtuals(): EntityData & { [key: string]: any } {
+ const { __virtuals } = this.schema;
+ const entityData: EntityData & { [key: string]: any } = { ...this.entityData };
+
+ Object.keys(__virtuals).forEach(k => {
+ if ({}.hasOwnProperty.call(entityData, k)) {
+ __virtuals[k].applySetters(entityData[k], entityData);
+ } else {
+ __virtuals[k].applyGetters(entityData);
+ }
+ });
+
+ return entityData;
+ }
+}
+
+export default Entity;
+
+export type EntityResponse = Entity & T;
+
+interface SaveOptions {
+ method: DatastoreSaveMethod;
+ sanitizeEntityData: boolean;
+ cache?: any;
+}
+
+interface PlainOptions {
+ /**
+ * Output all the entity data properties, regardless of the Schema `read` setting.
+ *
+ * @type {boolean}
+ * @default false
+ */
+ readAll?: boolean;
+ /**
+ * Add the _virtual_ properties defined for the entity on the Schema.
+ *
+ * @type {boolean}
+ * @default false
+ * @link https://sebloix.gitbook.io/gstore-node/schema/methods/virtual
+ */
+ virtuals?: boolean;
+ /**
+ * Add the full entity _Key_ object at the a "__key" property
+ *
+ * @type {boolean}
+ * @default false
+ */
+ showKey?: boolean;
+}
diff --git a/src/errors.ts b/src/errors.ts
new file mode 100755
index 0000000..19ed279
--- /dev/null
+++ b/src/errors.ts
@@ -0,0 +1,88 @@
+/* eslint-disable max-classes-per-file, no-use-before-define */
+
+import util from 'util';
+import is from 'is';
+
+type MessageGetter = (...args: any[]) => string;
+
+export const ERROR_CODES = {
+ ERR_ENTITY_NOT_FOUND: 'ERR_ENTITY_NOT_FOUND',
+ ERR_GENERIC: 'ERR_GENERIC',
+ ERR_VALIDATION: 'ERR_VALIDATION',
+ ERR_PROP_TYPE: 'ERR_PROP_TYPE',
+ ERR_PROP_VALUE: 'ERR_PROP_VALUE',
+ ERR_PROP_NOT_ALLOWED: 'ERR_PROP_NOT_ALLOWED',
+ ERR_PROP_REQUIRED: 'ERR_PROP_REQUIRED',
+ ERR_PROP_IN_RANGE: 'ERR_PROP_IN_RANGE',
+};
+
+export const message = (text: string, ...args: any[]): string => util.format(text, ...args);
+
+const messages: { [key: string]: string | (MessageGetter) } = {
+ ERR_GENERIC: 'An error occured',
+ ERR_VALIDATION: (entityKind: string) =>
+ message('The entity data does not validate against the "%s" Schema', entityKind),
+ ERR_PROP_TYPE: (prop, type) => message('Property "%s" must be a %s', prop, type),
+ ERR_PROP_VALUE: (value, prop) => message('"%s" is not a valid value for property "%s"', value, prop),
+ ERR_PROP_NOT_ALLOWED: (prop, entityKind) =>
+ message('Property "%s" is not allowed for entityKind "%s"', prop, entityKind),
+ ERR_PROP_REQUIRED: prop => message('Property "%s" is required but no value has been provided', prop),
+ ERR_PROP_IN_RANGE: (prop, range) => message('Property "%s" must be one of [%s]', prop, range && range.join(', ')),
+};
+
+export class GstoreError extends Error {
+ public code: string;
+
+ constructor(code: string, msg?: string, args?: any) {
+ if (!msg && code && code in messages) {
+ if (is.function(messages[code])) {
+ msg = (messages[code] as MessageGetter)(...args.messageParams);
+ } else {
+ msg = messages[code] as string;
+ }
+ }
+
+ if (!msg) {
+ msg = messages.ERR_GENERIC as string;
+ }
+
+ super(msg);
+ this.name = 'GstoreError';
+ this.message = msg;
+ this.code = code || ERROR_CODES.ERR_GENERIC;
+
+ if (args) {
+ Object.keys(args).forEach(k => {
+ if (k !== 'messageParams') {
+ (this as any)[k] = args[k];
+ }
+ });
+ }
+
+ Error.captureStackTrace(this, this.constructor);
+ }
+}
+
+export class ValidationError extends GstoreError {
+ constructor(code: string, msg?: string, args?: any) {
+ super(code, msg, args);
+ this.name = 'ValidationError';
+ Error.captureStackTrace(this, this.constructor);
+ }
+}
+
+export class TypeError extends GstoreError {
+ constructor(code: string, msg?: string, args?: any) {
+ super(code, msg, args);
+ this.name = 'TypeError';
+ Error.captureStackTrace(this, this.constructor);
+ }
+}
+
+export class ValueError extends GstoreError {
+ constructor(code: string, msg?: string, args?: any) {
+ super(code, msg, args);
+ this.name = 'ValueError';
+ Error.captureStackTrace(this, this.constructor);
+ }
+}
diff --git a/src/helpers/defaultValues.ts b/src/helpers/defaultValues.ts
new file mode 100644
index 0000000..b8249ce
--- /dev/null
+++ b/src/helpers/defaultValues.ts
@@ -0,0 +1,29 @@
+const NOW = 'CURRENT_DATETIME';
+
+const returnCurrentTime = (): Date => new Date();
+
+const mapDefaultValueIdToHandler: Record void> = {
+ [NOW]: returnCurrentTime,
+};
+
+const handler = (key: string): unknown => {
+ if ({}.hasOwnProperty.call(mapDefaultValueIdToHandler, key)) {
+ return mapDefaultValueIdToHandler[key]();
+ }
+
+ return null;
+};
+
+export interface DefaultValues {
+ NOW: 'CURRENT_DATETIME';
+ __handler__: (key: string) => unknown;
+ __map__: { [key: string]: () => any };
+}
+
+const defaultValues: DefaultValues = {
+ NOW,
+ __handler__: handler,
+ __map__: mapDefaultValueIdToHandler,
+};
+
+export default defaultValues;
diff --git a/src/helpers/index.ts b/src/helpers/index.ts
new file mode 100644
index 0000000..fbdd51c
--- /dev/null
+++ b/src/helpers/index.ts
@@ -0,0 +1,9 @@
+import queryHelpers from './queryhelpers';
+import validation from './validation';
+import populateHelpers from './populateHelpers';
+
+export default {
+ queryHelpers,
+ validation,
+ populateHelpers,
+};
diff --git a/src/helpers/populateHelpers.ts b/src/helpers/populateHelpers.ts
new file mode 100644
index 0000000..339bdc8
--- /dev/null
+++ b/src/helpers/populateHelpers.ts
@@ -0,0 +1,92 @@
+import arrify from 'arrify';
+
+import Schema, { SchemaPathDefinition } from '../schema';
+import { PopulateRef } from '../types';
+
+/**
+ * Returns all the schema properties that are references
+ * to other entities (their value is an entity Key)
+ */
+const getEntitiesRefsFromSchema = (schema: Schema): string[] =>
+ Object.entries(schema.paths)
+ .filter(([, pathConfig]) => (pathConfig as SchemaPathDefinition).type === 'entityKey')
+ .map(([property]) => property);
+
+/**
+ *
+ * @param {*} initialPath Path to add to the refs array
+ * @param {*} select Array of properties to select from the populated ref
+ * @param {*} refs Array of refs, each index is one level deep in the entityData tree
+ *
+ * @example
+ *
+ * const entityData = {
+ * user: Key, // ---> once fetched it will be { name: String, company: Key }
+ * address: Key
+ * }
+ *
+ * To fetch the "address", the "user" and the user's "conmpany", the array of refs
+ * to retrieve will have the following shape
+ *
+ * [
+ * [{ path: 'user', select: ['*'] }, [ path: 'address', select: ['*'] ], // tree depth at level 0
+ * [{ path: 'user.company', select: ['*'] }], // tree depth at level 1 (will be fetched after level 0 has been fetched)
+ * ]
+ */
+const addPathToPopulateRefs = (
+ initialPath: string,
+ _select: string | string[] | never = ['*'],
+ refs: PopulateRef[][],
+): void => {
+ const pathToArray = initialPath.split('.');
+ const select = arrify(_select);
+ let prefix = '';
+
+ pathToArray.forEach((prop, i) => {
+ const currentPath = prefix ? `${prefix}.${prop}` : prop;
+ const nextPath = pathToArray[i + 1];
+ const hasNextPath = typeof nextPath !== 'undefined';
+ const refsAtCurrentTreeLevel = refs[i] || [];
+
+ // Check if we alreday have a config for this tree level
+ const pathConfig = refsAtCurrentTreeLevel.find(ref => ref.path === currentPath);
+
+ if (!pathConfig) {
+ refsAtCurrentTreeLevel.push({ path: currentPath, select: hasNextPath ? [nextPath] : select });
+ } else if (hasNextPath && !pathConfig.select.some(s => s === nextPath)) {
+ // Add the next path to the selected properties on the ref
+ pathConfig.select.push(nextPath);
+ } else if (!hasNextPath && select.length) {
+ pathConfig.select.push(...select);
+ }
+ refs[i] = refsAtCurrentTreeLevel;
+
+ prefix = currentPath;
+ });
+};
+
+export type PopulateHandler = (
+ path?: U,
+ propsToSelect?: U extends Array ? never : string | string[],
+) => Promise;
+
+const populateFactory = (
+ refsToPopulate: PopulateRef[][],
+ promise: Promise,
+ schema: Schema,
+): PopulateHandler => {
+ const populateHandler: PopulateHandler = (path, propsToSelect) => {
+ if (propsToSelect && Array.isArray(path)) {
+ throw new Error('Only 1 property can be populated when fields to select are provided');
+ }
+
+ // If no path is specified, we fetch all the schema properties that are references to entities (Keys)
+ const paths: string[] = path ? arrify(path) : getEntitiesRefsFromSchema(schema);
+ paths.forEach(p => addPathToPopulateRefs(p, propsToSelect, refsToPopulate));
+ return promise;
+ };
+
+ return populateHandler;
+};
+
+export default { addPathToPopulateRefs, populateFactory };
diff --git a/src/helpers/queryhelpers.ts b/src/helpers/queryhelpers.ts
new file mode 100644
index 0000000..837b09e
--- /dev/null
+++ b/src/helpers/queryhelpers.ts
@@ -0,0 +1,109 @@
+import { Datastore, Transaction, Query as DatastoreQuery } from '@google-cloud/datastore';
+import is from 'is';
+import arrify from 'arrify';
+
+import { GstoreQuery, QueryListOptions } from '../query';
+import { EntityData } from '../types';
+import Model from '../model';
+
+const buildQueryFromOptions = (
+ query: GstoreQuery, Outputformat>,
+ options: QueryListOptions,
+ ds: Datastore,
+): GstoreQuery, Outputformat> => {
+ if (!query || query.constructor.name !== 'Query') {
+ throw new Error('Query not passed');
+ }
+
+ if (!options || typeof options !== 'object') {
+ return query;
+ }
+
+ if (options.limit) {
+ query.limit(options.limit);
+ }
+
+ if (options.offset) {
+ query.offset(options.offset);
+ }
+
+ if (options.order) {
+ const orderArray = arrify(options.order);
+ orderArray.forEach(order => {
+ query.order(order.property, {
+ descending: {}.hasOwnProperty.call(order, 'descending') ? order.descending : false,
+ });
+ });
+ }
+
+ if (options.select) {
+ query.select(options.select);
+ }
+
+ if (options.ancestors) {
+ if (!ds || ds.constructor.name !== 'Datastore') {
+ throw new Error('Datastore instance not passed');
+ }
+
+ const ancestorKey = options.namespace
+ ? ds.key({ namespace: options.namespace, path: options.ancestors.slice() })
+ : ds.key(options.ancestors.slice());
+
+ query.hasAncestor(ancestorKey);
+ }
+
+ if (options.filters) {
+ if (!is.array(options.filters)) {
+ throw new Error('Wrong format for filters option');
+ }
+
+ if (!is.array(options.filters[0])) {
+ options.filters = [options.filters];
+ }
+
+ if (options.filters[0].length > 1) {
+ options.filters.forEach(filter => {
+ // We check if the value is a function
+ // if it is, we execute it.
+ let value = filter[filter.length - 1];
+ value = is.fn(value) ? value() : value;
+ const f = filter.slice(0, -1).concat([value]);
+
+ (query.filter as any)(...f);
+ });
+ }
+ }
+
+ if (options.start) {
+ query.start(options.start);
+ }
+
+ return query;
+};
+
+const createDatastoreQueryForModel = (
+ model: Model,
+ namespace?: string,
+ transaction?: Transaction,
+): DatastoreQuery => {
+ if (transaction && transaction.constructor.name !== 'Transaction') {
+ throw Error('Transaction needs to be a gcloud Transaction');
+ }
+
+ const createQueryArgs: any[] = [model.entityKind];
+
+ if (namespace) {
+ createQueryArgs.unshift(namespace);
+ }
+
+ if (transaction) {
+ return (transaction.createQuery as any)(...createQueryArgs);
+ }
+
+ return model.gstore.ds.createQuery(...createQueryArgs);
+};
+
+export default {
+ buildQueryFromOptions,
+ createDatastoreQueryForModel,
+};
diff --git a/src/helpers/validation.ts b/src/helpers/validation.ts
new file mode 100755
index 0000000..0a27cc5
--- /dev/null
+++ b/src/helpers/validation.ts
@@ -0,0 +1,326 @@
+import moment from 'moment';
+import validator from 'validator';
+import is from 'is';
+import { Datastore } from '@google-cloud/datastore';
+
+import { ValidationError, ValueError, TypeError, ERROR_CODES } from '../errors';
+import { EntityData } from '../types';
+import Schema, { SchemaPathDefinition, Validator } from '../schema';
+
+const isValidDate = (value: any): boolean => {
+ if (
+ value.constructor.name !== 'Date' &&
+ (typeof value !== 'string' ||
+ !/\d{4}-\d{2}-\d{2}([ ,T])?(\d{2}:\d{2}:\d{2})?(\.\d{1,3})?/.exec(value) ||
+ !moment(value).isValid())
+ ) {
+ return false;
+ }
+ return true;
+};
+
+const isInt = (n: unknown): boolean => Number(n) === n && n % 1 === 0;
+
+const isFloat = (n: unknown): boolean => Number(n) === n && n % 1 !== 0;
+
+const isValueEmpty = (v: unknown): boolean =>
+ v === null || v === undefined || (typeof v === 'string' && v.trim().length === 0);
+
+const isValidLngLat = (data: any): boolean => {
+ const validLatitude = (isInt(data.latitude) || isFloat(data.latitude)) && data.latitude >= -90 && data.latitude <= 90;
+ const validLongitude =
+ (isInt(data.longitude) || isFloat(data.longitude)) && data.longitude >= -180 && data.longitude <= 180;
+
+ return validLatitude && validLongitude;
+};
+
+const errorToObject = (error: any): { code: string; message: string; ref: string } => ({
+ code: error.code,
+ message: error.message,
+ ref: error.ref,
+});
+
+const validatePropType = (
+ value: any,
+ propType: unknown,
+ prop: unknown,
+ pathConfig: SchemaPathDefinition,
+ datastore: Datastore,
+): TypeError | null => {
+ let isValid;
+ let ref;
+ let type = propType;
+ if (typeof propType === 'function') {
+ type = propType.name.toLowerCase();
+ }
+
+ switch (type) {
+ case 'entityKey':
+ isValid = datastore.isKey(value);
+ ref = 'key.base';
+ if (isValid && pathConfig.ref) {
+ // Make sure the Entity Kind is also valid (if any)
+ const entityKind = value.path[value.path.length - 2];
+ isValid = entityKind === pathConfig.ref;
+ ref = 'key.entityKind';
+ }
+ break;
+ case 'string':
+ isValid = typeof value === 'string';
+ ref = 'string.base';
+ break;
+ case 'date':
+ isValid = isValidDate(value);
+ ref = 'datetime.base';
+ break;
+ case 'array':
+ isValid = is.array(value);
+ ref = 'array.base';
+ break;
+ case 'number': {
+ const isIntInstance = value.constructor.name === 'Int';
+ if (isIntInstance) {
+ isValid = !isNaN(parseInt(value.value, 10));
+ } else {
+ isValid = isInt(value);
+ }
+ ref = 'int.base';
+ break;
+ }
+ case 'double': {
+ const isIntInstance = value.constructor.name === 'Double';
+ if (isIntInstance) {
+ isValid = isFloat(parseFloat(value.value)) || isInt(parseFloat(value.value));
+ } else {
+ isValid = isFloat(value) || isInt(value);
+ }
+ ref = 'double.base';
+ break;
+ }
+ case 'buffer':
+ isValid = value instanceof Buffer;
+ ref = 'buffer.base';
+ break;
+ case 'geoPoint': {
+ if (
+ is.object(value) &&
+ Object.keys(value).length === 2 &&
+ {}.hasOwnProperty.call(value, 'longitude') &&
+ {}.hasOwnProperty.call(value, 'latitude')
+ ) {
+ isValid = isValidLngLat(value);
+ } else {
+ isValid = value.constructor.name === 'GeoPoint';
+ }
+ ref = 'geopoint.base';
+ break;
+ }
+ default:
+ if (Array.isArray(value)) {
+ isValid = false;
+ } else {
+ isValid = typeof value === type;
+ }
+ ref = 'prop.type';
+ }
+
+ if (!isValid) {
+ return new TypeError(ERROR_CODES.ERR_PROP_TYPE, undefined, { ref, messageParams: [prop, type], property: prop });
+ }
+
+ return null;
+};
+
+const validatePropValue = (prop: string, value: any, validationRule?: Validator): ValueError | null => {
+ let validationArgs = [];
+ let validationFn: ((...args: any[]) => any) | undefined;
+
+ /**
+ * If the validate property is an object, then it's assumed that
+ * it contains the 'rule' property, which will be the new
+ * validationRule's value.
+ * If the 'args' prop was passed then we concat them to 'validationArgs'.
+ */
+
+ if (typeof validationRule === 'object') {
+ const { rule } = validationRule;
+ validationArgs = validationRule.args || [];
+
+ if (typeof rule === 'function') {
+ validationFn = rule;
+ validationArgs = [value, validator, ...validationArgs];
+ } else {
+ validationRule = rule;
+ }
+ }
+
+ if (!validationFn) {
+ /**
+ * Validator.js only works with string values
+ * let's make sure we are working with a string.
+ */
+ const isObject = typeof value === 'object';
+ const strValue = typeof value !== 'string' && !isObject ? String(value) : value;
+ validationArgs = [strValue, ...validationArgs];
+
+ validationFn = (validator as any)[validationRule as string];
+ }
+
+ if (!validationFn!.apply(validator, validationArgs)) {
+ return new ValueError(ERROR_CODES.ERR_PROP_VALUE, undefined, {
+ type: 'prop.validator',
+ messageParams: [value, prop],
+ property: prop,
+ });
+ }
+
+ return null;
+};
+
+export interface ValidateResponse {
+ error: ValidationError | null;
+ value: EntityData;
+ then: (onSuccess: (entityData: EntityData) => any, onError: (error: ValidationError) => any) => Promise;
+ catch: (onError: (error: ValidationError) => any) => Promise | undefined;
+}
+
+const validate = (
+ entityData: EntityData,
+ schema: Schema,
+ entityKind: string,
+ datastore: Datastore,
+): ValidateResponse => {
+ const errors = [];
+
+ let prop;
+ let skip;
+ let schemaHasProperty;
+ let pathConfig;
+ let propertyType;
+ let propertyValue;
+ let isEmpty;
+ let isRequired;
+ let error;
+
+ const props = Object.keys(entityData);
+ const totalProps = Object.keys(entityData).length;
+
+ if (schema.isJoi) {
+ // We leave the validation to Joi
+ return schema.validateJoi(entityData);
+ }
+
+ for (let i = 0; i < totalProps; i += 1) {
+ prop = props[i];
+ skip = false;
+ error = null;
+ schemaHasProperty = {}.hasOwnProperty.call(schema.paths, prop);
+ pathConfig = schema.paths[prop as keyof T] || {};
+ propertyType = schemaHasProperty ? pathConfig.type : null;
+ propertyValue = entityData[prop];
+ isEmpty = isValueEmpty(propertyValue);
+
+ if (typeof propertyValue === 'string') {
+ propertyValue = propertyValue.trim();
+ }
+
+ if ({}.hasOwnProperty.call(schema.__virtuals, prop)) {
+ // Virtual, remove it and skip the rest
+ delete entityData[prop];
+ skip = true;
+ } else if (!schemaHasProperty && schema.options.explicitOnly === false) {
+ // No more validation, key does not exist but it is allowed
+ skip = true;
+ }
+
+ if (!skip) {
+ // ... is allowed?
+ if (!schemaHasProperty) {
+ error = new ValidationError(ERROR_CODES.ERR_PROP_NOT_ALLOWED, undefined, {
+ type: 'prop.not.allowed',
+ messageParams: [prop, entityKind],
+ property: prop,
+ });
+ errors.push(errorToObject(error));
+ }
+
+ // ...is required?
+ isRequired = schemaHasProperty && {}.hasOwnProperty.call(pathConfig, 'required') && pathConfig.required === true;
+
+ if (isRequired && isEmpty) {
+ error = new ValueError(ERROR_CODES.ERR_PROP_REQUIRED, undefined, {
+ type: 'prop.required',
+ messageParams: [prop],
+ property: prop,
+ });
+ errors.push(errorToObject(error));
+ }
+
+ // ... is valid prop Type?
+ if (schemaHasProperty && !isEmpty && {}.hasOwnProperty.call(pathConfig, 'type')) {
+ error = validatePropType(propertyValue, propertyType, prop, pathConfig, datastore);
+
+ if (error) {
+ errors.push(errorToObject(error));
+ }
+ }
+
+ // ... is valid prop Value?
+ if (error === null && schemaHasProperty && !isEmpty && {}.hasOwnProperty.call(pathConfig, 'validate')) {
+ error = validatePropValue(prop, propertyValue, pathConfig.validate);
+ if (error) {
+ errors.push(errorToObject(error));
+ }
+ }
+
+ // ... is value in range?
+ if (
+ schemaHasProperty &&
+ !isEmpty &&
+ {}.hasOwnProperty.call(pathConfig, 'values') &&
+ !pathConfig.values!.includes(propertyValue)
+ ) {
+ error = new ValueError(ERROR_CODES.ERR_PROP_IN_RANGE, undefined, {
+ type: 'value.range',
+ messageParams: [prop, pathConfig.values],
+ property: prop,
+ });
+
+ errors.push(errorToObject(error));
+ }
+ }
+ }
+
+ let validationError: ValidationError | null = null;
+
+ if (Object.keys(errors).length > 0) {
+ validationError = new ValidationError(ERROR_CODES.ERR_VALIDATION, undefined, {
+ errors,
+ messageParams: [entityKind],
+ });
+ }
+
+ const validateResponse: ValidateResponse = {
+ error: validationError,
+ value: entityData,
+ then: (onSuccess, onError) => {
+ if (validationError) {
+ return Promise.resolve(onError(validationError));
+ }
+
+ return Promise.resolve(onSuccess(entityData));
+ },
+ catch: onError => {
+ if (validationError) {
+ return Promise.resolve(onError(validationError));
+ }
+ return undefined;
+ },
+ };
+
+ return validateResponse;
+};
+
+export default {
+ validate,
+};
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..ff89519
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,302 @@
+import is from 'is';
+import extend from 'extend';
+import hooks from 'promised-hooks';
+import NsqlCache, { NsqlCacheConfig } from 'nsql-cache';
+import dsAdapter from 'nsql-cache-datastore';
+import DataLoader from 'dataloader';
+import { Datastore, Transaction } from '@google-cloud/datastore';
+import pkg from '../package.json';
+import GstoreSchema from './schema';
+import GstoreEntity from './entity';
+import GstoreModel, { generateModel } from './model';
+import defaultValues, { DefaultValues } from './helpers/defaultValues';
+import { GstoreError, ValidationError, TypeError, ValueError, ERROR_CODES } from './errors';
+import { datastoreSerializer } from './serializers';
+import { createDataLoader } from './dataloader';
+import { EntityKey, EntityData, DatastoreSaveMethod, CustomEntityFunction } from './types';
+
+export interface CacheConfig {
+ stores: any[];
+ config: NsqlCacheConfig;
+}
+
+export interface GstoreConfig {
+ cache?: boolean | CacheConfig;
+ /**
+ * If set to `true` (defaut), when fetching an entity by key and the entity is not found in the Datastore,
+ * gstore will throw an `"ERR_ENTITY_NOT_FOUND"` error.
+ * If set to `false`, `null` will be returned
+ */
+ errorOnEntityNotFound?: boolean;
+}
+const DEFAULT_GSTORE_CONFIG = {
+ cache: undefined,
+ errorOnEntityNotFound: true,
+};
+
+const DEFAULT_CACHE_SETTINGS = {
+ config: {
+ wrapClient: false,
+ },
+};
+
+export class Gstore {
+ /**
+ * Map of Gstore Model created
+ */
+ public models: { [key: string]: GstoreModel };
+
+ /**
+ * Gstore Schema class
+ */
+ public Schema: typeof GstoreSchema;
+
+ /**
+ * Gstore instance configuration
+ */
+ public config: GstoreConfig;
+
+ /**
+ * The underlying gstore-cache instance
+ */
+ public cache: NsqlCache | undefined;
+
+ /**
+ * The symbol to access possible errors thrown
+ * in a "post" hooks
+ */
+ public ERR_HOOKS: symbol;
+
+ public errors: {
+ GstoreError: typeof GstoreError;
+ ValidationError: typeof ValidationError;
+ TypeError: typeof TypeError;
+ ValueError: typeof ValueError;
+ codes: typeof ERROR_CODES;
+ };
+
+ public __ds: Datastore | undefined;
+
+ public __defaultValues: DefaultValues;
+
+ public __pkgVersion = pkg.version;
+
+ constructor(config: GstoreConfig = {}) {
+ if (!is.object(config)) {
+ throw new Error('Gstore config must be an object.');
+ }
+
+ this.models = {};
+ this.config = { ...DEFAULT_GSTORE_CONFIG, ...config };
+ this.Schema = GstoreSchema;
+ this.__defaultValues = defaultValues;
+ // this.__pkgVersion = pkg.version;
+
+ this.errors = {
+ GstoreError,
+ ValidationError,
+ TypeError,
+ ValueError,
+ codes: ERROR_CODES,
+ };
+
+ this.ERR_HOOKS = hooks.ERRORS;
+ }
+
+ /**
+ * Create or access a gstore Model
+ *
+ * @param {string} entityKind The Google Entity Kind
+ * @param {Schema} schema A gstore schema instance
+ * @returns {Model} A gstore Model
+ */
+ model }>(
+ entityKind: string,
+ schema?: GstoreSchema,
+ ): GstoreModel {
+ if (this.models[entityKind]) {
+ // Don't allow overriding Model schema
+ if (schema instanceof GstoreSchema && schema !== undefined) {
+ throw new Error(`Trying to override ${entityKind} Model Schema`);
+ }
+ return this.models[entityKind];
+ }
+
+ if (!schema) {
+ throw new Error('A Schema needs to be provided to create a Model.');
+ }
+
+ const model = generateModel(entityKind, schema, this);
+
+ this.models[entityKind] = model;
+
+ return this.models[entityKind];
+ }
+
+ /**
+ * Initialize a @google-cloud/datastore Transaction
+ */
+ transaction(): Transaction {
+ return this.ds.transaction();
+ }
+
+ /**
+ * Return an array of model names created on this instance of Gstore
+ * @returns {Array}
+ */
+ modelNames(): string[] {
+ const names = Object.keys(this.models);
+ return names;
+ }
+
+ /**
+ * Alias to the underlying @google-cloud/datastore `save()` method
+ * but instead of passing entity _keys_ this methods accepts one or multiple gstore **_entity_** instance(s).
+ *
+ * @param {(Entity | Entity[])} entity The entity(ies) to delete (any Entity Kind). Can be one or many (Array).
+ * @param {Transaction} [transaction] An Optional transaction to save the entities into
+ * @returns {Promise}
+ * @link https://sebloix.gitbook.io/gstore-node/gstore-methods/save
+ */
+ save(
+ entities: GstoreEntity | GstoreEntity[],
+ transaction?: Transaction,
+ options: { method?: DatastoreSaveMethod; validate?: boolean } | undefined = {},
+ ): Promise<
+ [
+ {
+ mutationResults?: any;
+ indexUpdates?: number | null;
+ },
+ ]
+ > {
+ if (!entities) {
+ throw new Error('No entities passed');
+ }
+
+ // Validate entities before saving
+ if (options.validate) {
+ let error;
+ const validateEntity = (entity: GstoreEntity): void => {
+ ({ error } = entity.validate());
+ if (error) {
+ throw error;
+ }
+ };
+ try {
+ if (Array.isArray(entities)) {
+ entities.forEach(validateEntity);
+ } else {
+ validateEntity(entities);
+ }
+ } catch (err) {
+ return Promise.reject(err);
+ }
+ }
+
+ // Convert gstore entities to datastore forma ({key, data})
+ const entitiesSerialized = datastoreSerializer.entitiesToDatastore(entities, options);
+
+ if (transaction) {
+ return transaction.save(entitiesSerialized);
+ }
+
+ // We forward the call to google-datastore
+ return this.ds.save(entitiesSerialized);
+ }
+
+ /**
+ * Connect gstore node to the Datastore instance
+ *
+ * @param {Datastore} datastore A Datastore instance
+ */
+ connect(datastore: any): void {
+ if (!datastore.constructor || datastore.constructor.name !== 'Datastore') {
+ throw new Error('No @google-cloud/datastore instance provided.');
+ }
+
+ this.__ds = datastore;
+
+ if (this.config.cache) {
+ const cacheSettings =
+ this.config.cache === true
+ ? extend(true, {}, DEFAULT_CACHE_SETTINGS)
+ : extend(true, {}, DEFAULT_CACHE_SETTINGS, this.config.cache);
+
+ const { stores, config } = cacheSettings as CacheConfig;
+
+ const db = dsAdapter(datastore);
+ this.cache = new NsqlCache({ db, stores, config });
+ delete this.config.cache;
+ }
+ }
+
+ /**
+ * Create a DataLoader instance.
+ * Follow the link below for more info about Dataloader.
+ *
+ * @returns {DataLoader} The DataLoader instance
+ * @link https://sebloix.gitbook.io/gstore-node/cache-dataloader/dataloader
+ */
+ createDataLoader(): DataLoader {
+ return createDataLoader(this.ds);
+ }
+
+ /**
+ * Default values for schema properties
+ */
+ get defaultValues(): DefaultValues {
+ return this.__defaultValues;
+ }
+
+ get version(): string {
+ return this.__pkgVersion;
+ }
+
+ /**
+ * The unerlying google-cloud Datastore instance
+ */
+ get ds(): Datastore {
+ if (this.__ds === undefined) {
+ throw new Error('Trying to access Datastore instance but none was provided.');
+ }
+ return this.__ds;
+ }
+}
+
+export const instances = {
+ __refs: new Map(),
+ /**
+ * Retrieve a previously saved gstore instance.
+ *
+ * @param id The instance id
+ */
+ get(id: string): Gstore {
+ const instance = this.__refs.get(id);
+ if (!instance) {
+ throw new Error(`Could not find gstore instance with id "${id}"`);
+ }
+ return instance;
+ },
+ /**
+ * Save a gstore instance.
+ *
+ * @param id A unique name for the gstore instance
+ * @param instance A gstore instance
+ */
+ set(id: string, instance: Gstore): void {
+ this.__refs.set(id, instance);
+ },
+};
+
+export type Entity = GstoreEntity;
+
+export type Model = GstoreModel;
+
+export type Schema = GstoreSchema;
+
+export { QUERIES_FORMATS } from './constants';
+
+export { ValidateResponse } from './helpers/validation';
+
+export default Gstore;
diff --git a/src/model.ts b/src/model.ts
new file mode 100755
index 0000000..5290973
--- /dev/null
+++ b/src/model.ts
@@ -0,0 +1,1204 @@
+import is from 'is';
+import arrify from 'arrify';
+import extend from 'extend';
+import hooks from 'promised-hooks';
+import dsAdapterFactory from 'nsql-cache-datastore';
+import get from 'lodash.get';
+import set from 'lodash.set';
+
+import { Transaction } from '@google-cloud/datastore';
+
+import Gstore from './index';
+import Schema, { JoiConfig } from './schema';
+import Entity, { EntityResponse } from './entity';
+import Query, { QueryResponse, GstoreQuery } from './query';
+import { GstoreError, ERROR_CODES } from './errors';
+import helpers from './helpers';
+import {
+ FuncReturningPromise,
+ CustomEntityFunction,
+ IdType,
+ Ancestor,
+ EntityKey,
+ EntityData,
+ PopulateRef,
+ PopulateMetaForEntity,
+ PopulateFunction,
+ PromiseWithPopulate,
+ GenericObject,
+ JSONFormatType,
+ EntityFormatType,
+} from './types';
+
+const dsAdapter = dsAdapterFactory();
+const { populateHelpers } = helpers;
+
+const { keyToString } = dsAdapter;
+const { populateFactory } = populateHelpers;
+
+export interface Model<
+ T extends object = GenericObject,
+ M extends object = { [key: string]: CustomEntityFunction }
+> {
+ new (data: EntityData, id?: IdType, ancestors?: Ancestor, namespace?: string, key?: EntityKey): EntityResponse &
+ M;
+
+ /**
+ * The gstore instance
+ */
+ gstore: Gstore;
+
+ /**
+ * The Model Schema
+ */
+ schema: Schema;
+
+ /**
+ * The Model Datastore entity kind
+ */
+ entityKind: string;
+
+ __hooksEnabled: boolean;
+
+ /**
+ * Generates one or several entity key(s) for the Model.
+ *
+ * @param {(string | number)} id Entity id or name
+ * @param {(Array)} [ancestors] The entity Ancestors
+ * @param {string} [namespace] The entity Namespace
+ * @returns {entity.Key}
+ * @link https://sebloix.gitbook.io/gstore-node/model/methods/key
+ */
+ key(
+ id: U,
+ ancestors?: Array,
+ namespace?: string,
+ ): U extends Array ? EntityKey[] : EntityKey;
+
+ /**
+ * Fetch an Entity from the Datastore by _key_.
+ *
+ * @param {(string | number | string[] | number[])} id The entity ID
+ * @param {(Array)} [ancestors] The entity Ancestors
+ * @param {string} [namespace] The entity Namespace
+ * @param {*} [transaction] The current Datastore Transaction (if any)
+ * @param [options] Additional configuration
+ * @returns {Promise} The entity fetched from the Datastore
+ * @link https://sebloix.gitbook.io/gstore-node/model/methods/get
+ */
+ get>(
+ id: U,
+ ancestors?: Array,
+ namespace?: string,
+ transaction?: Transaction,
+ options?: GetOptions,
+ ): PromiseWithPopulate ? EntityResponse[] : EntityResponse>;
+
+ /**
+ * Update an Entity in the Datastore. This method _partially_ updates an entity data in the Datastore
+ * by doing a get() + merge the data + save() inside a Transaction. Unless you set `replace: true` in the parameter options.
+ *
+ * @param {(string | number)} id Entity id or name
+ * @param {*} data The data to update (it will be merged with the data in the Datastore
+ * unless options.replace is set to "true")
+ * @param {(Array)} [ancestors] The entity Ancestors
+ * @param {string} [namespace] The entity Namespace
+ * @param {*} [transaction] The current transaction (if any)
+ * @param {{ dataloader?: any, replace?: boolean }} [options] Additional configuration
+ * @returns {Promise} The entity updated in the Datastore
+ * @link https://sebloix.gitbook.io/gstore-node/model/methods/update
+ */
+ update(
+ id: IdType,
+ data: EntityData,
+ ancestors?: Ancestor,
+ namespace?: string,
+ transaction?: Transaction,
+ options?: GenericObject,
+ ): Promise>;
+
+ /**
+ * Delete an Entity from the Datastore
+ *
+ * @param {(string | number)} id Entity id or name
+ * @param {(Array)} [ancestors] The entity Ancestors
+ * @param {string} [namespace] The entity Namespace
+ * @param {*} [transaction] The current transaction (if any)
+ * @param {(entity.Key | entity.Key[])} [keys] If you already know the Key, you can provide it instead of passing
+ * an id/ancestors/namespace. You might then as well just call "gstore.ds.delete(Key)",
+ * but then you would not have the "hooks" triggered in case you have added some in your Schema.
+ * @returns {Promise<{ success: boolean, key: entity.Key, apiResponse: any }>}
+ * @link https://sebloix.gitbook.io/gstore-node/model/methods/delete
+ */
+ delete(
+ id?: IdType | IdType[],
+ ancestors?: Ancestor,
+ namespace?: string,
+ transaction?: Transaction,
+ key?: EntityKey | EntityKey[],
+ options?: DeleteOptions,
+ ): Promise;
+
+ /**
+ * Delete all the entities of a Model.
+ * It runs a query to fetch the entities by batches of 500 (limit set by the Datastore) and delete them.
+ * It then repeat the operation until no more entities are found.
+ *
+ * @static
+ * @param ancestors Optional Ancestors to add to the Query
+ * @param namespace Optional Namespace to run the Query into
+ * @link https://sebloix.gitbook.io/gstore-node/queries/deleteall
+ */
+ deleteAll(ancestors?: Ancestor, namespace?: string): Promise;
+
+ /**
+ * Clear all the Queries from the cache *linked* to the Model Entity Kind.
+ * One or multiple keys can also be passed to delete them from the cache. We normally don't have to call this method
+ * as gstore-node does it automatically each time an entity is added/edited or deleted.
+ *
+ * @param {(entity.Key | entity.Key[])} [keys] Optional entity Keys to remove from the cache with the Queries
+ * @returns {Promise}
+ * @link https://sebloix.gitbook.io/gstore-node/model/methods/clearcache
+ */
+ clearCache(keys?: EntityKey | EntityKey[]): Promise<{ success: boolean }>;
+
+ /**
+ * Dynamically remove a property from indexes. If you have set `explicityOnly: false` in your Schema options,
+ * then all the properties not declared in the Schema will be included in the indexes.
+ * This method allows you to dynamically exclude from indexes certain properties.
+ *
+ * @param {(string | string[])} propName Property name (can be one or an Array of properties)
+ * @link https://sebloix.gitbook.io/gstore-node/model/methods/exclude-from-indexes
+ */
+ excludeFromIndexes(propName: string | string[]): void;
+
+ /**
+ * Sanitize the entity data. It will remove all the properties marked as "write: false" on the schema.
+ * It will also convert "null" (string) to `null` value.
+ *
+ * @param {*} data The entity data to sanitize
+ * @link https://sebloix.gitbook.io/gstore-node/model/methods/sanitize
+ */
+ sanitize(data: { [propName: string]: any }, options: { disabled: string[] }): EntityData;
+
+ /**
+ * Initialize a Datastore Query for the Model's entity kind.
+ *
+ * @param {String} namespace Namespace for the Query
+ * @param {Object} transaction The transactioh to execute the query in (optional)
+ *
+ * @returns {Object} The Datastore query object.
+ * @link https://sebloix.gitbook.io/gstore-node/queries/google-cloud-queries
+ */
+ query<
+ F extends JSONFormatType | EntityFormatType = JSONFormatType,
+ R = F extends EntityFormatType ? QueryResponse[]> : QueryResponse[]>
+ >(
+ namespace?: string,
+ transaction?: Transaction,
+ ): GstoreQuery;
+
+ /**
+ * Shortcut for listing entities from a Model. List queries are meant to quickly list entities
+ * with predefined settings without having to manually create a query.
+ *
+ * @param {QueryListOptions} [options]
+ * @returns {Promise}
+ * @link https://sebloix.gitbook.io/gstore-node/queries/list
+ */
+ list: Query['list'];
+
+ /**
+ * Quickly find an entity by passing key/value pairs.
+ *
+ * @param keyValues Key / Values pairs
+ * @param ancestors Optional Ancestors to add to the Query
+ * @param namespace Optional Namespace to run the Query into
+ * @param options Additional configuration.
+ * @example
+ ```
+ UserModel.findOne({ email: 'john[at]snow.com' }).then(...);
+ ```
+ * @link https://sebloix.gitbook.io/gstore-node/queries/findone
+ */
+ findOne: Query['findOne'];
+
+ /**
+ * Find entities before or after an entity based on a property and a value.
+ *
+ * @param {string} propName The property to look around
+ * @param {*} value The property value
+ * @param {({ before: number, readAll?:boolean, format?: 'JSON' | 'ENTITY', showKey?: boolean } | { after: number, readAll?:boolean, format?: 'JSON' | 'ENTITY', showKey?: boolean } & QueryOptions)} options Additional configuration
+ * @returns {Promise}
+ * @example
+ ```
+ // Find the next 20 post after March 1st 2019
+ BlogPost.findAround('publishedOn', '2019-03-01', { after: 20 })
+ ```
+ * @link https://sebloix.gitbook.io/gstore-node/queries/findaround
+ */
+ findAround: Query['findAround'];
+
+ __compile(kind: string, schema: Schema): Model;
+
+ __fetchEntityByKey(key: EntityKey, transaction?: Transaction, dataloader?: any, options?: GetOptions): Promise;
+
+ __hasCache(options: { cache?: any }, type?: string): boolean;
+
+ __populate(refs?: PopulateRef[][], options?: PopulateOptions): PopulateFunction;
+
+ __hooksTransaction(transaction: Transaction, postHooks: FuncReturningPromise[]): void;
+
+ __scopeHook(hook: string, args: GenericObject, hookName: string, hookType: 'pre' | 'post'): any;
+}
+
+/**
+ * To improve performance and avoid looping over and over the entityData or Schema config
+ * we generate a meta object to cache useful data used later in models and entities methods.
+ */
+const extractMetaFromSchema = (schema: Schema): GenericObject => {
+ const meta: GenericObject = {};
+
+ Object.keys(schema.paths).forEach(k => {
+ switch (schema.paths[k as keyof T].type) {
+ case 'geoPoint':
+ // This allows us to automatically convert valid lng/lat objects
+ // to Datastore.geoPoints
+ meta.geoPointsProps = meta.geoPointsProps || [];
+ meta.geoPointsProps.push(k);
+ break;
+ case 'entityKey':
+ meta.refProps = meta.refProps || {};
+ meta.refProps[k] = true;
+ break;
+ default:
+ }
+ });
+
+ return meta;
+};
+
+/**
+ * Pass all the "pre" and "post" hooks from schema to
+ * the current ModelInstance
+ */
+const registerHooksFromSchema = (model: Model, schema: Schema): void => {
+ const callQueue = schema.__callQueue.model;
+
+ if (!Object.keys(callQueue).length) {
+ return;
+ }
+
+ Object.keys(callQueue).forEach((method: string) => {
+ // Add Pre hooks
+ callQueue[method].pres.forEach(fn => {
+ (model as any).pre(method, fn);
+ });
+
+ // Add Post hooks
+ callQueue[method].post.forEach(fn => {
+ (model as any).post(method, fn);
+ });
+ });
+};
+
+/**
+ * Dynamically generate a new Gstore Model
+ *
+ * @param kind The Entity Kind
+ * @param schema The Gstore Schema
+ * @param gstore The Gstore instance
+ */
+export const generateModel = (
+ kind: string,
+ schema: Schema,
+ gstore: Gstore,
+): Model => {
+ if (!schema.__meta || Object.keys(schema.__meta).length === 0) {
+ schema.__meta = extractMetaFromSchema(schema);
+ }
+ const model: Model = class GstoreModel extends Entity {
+ static gstore: Gstore = gstore;
+
+ static schema: Schema = schema;
+
+ static entityKind: string = kind;
+
+ static __hooksEnabled = true;
+
+ static key ? EntityKey[] : EntityKey>(
+ ids: U,
+ ancestors?: Ancestor,
+ namespace?: string,
+ ): R {
+ const keys: EntityKey[] = [];
+
+ let isMultiple = false;
+
+ const getPath = (id?: IdType | null): IdType[] => {
+ let path: IdType[] = [this.entityKind];
+
+ if (typeof id !== 'undefined' && id !== null) {
+ path.push(id);
+ }
+
+ if (ancestors && is.array(ancestors)) {
+ path = ancestors.concat(path);
+ }
+
+ return path;
+ };
+
+ const getKey = (id?: IdType | null): EntityKey => {
+ const path = getPath(id);
+ let key;
+
+ if (typeof namespace !== 'undefined' && namespace !== null) {
+ key = this.gstore.ds.key({
+ namespace,
+ path,
+ });
+ } else {
+ key = this.gstore.ds.key(path);
+ }
+ return key;
+ };
+
+ if (typeof ids !== 'undefined' && ids !== null) {
+ const idsArray = arrify(ids);
+
+ isMultiple = idsArray.length > 1;
+
+ idsArray.forEach(id => {
+ const key = getKey(id);
+ keys.push(key);
+ });
+ } else {
+ const key = getKey(null);
+ keys.push(key);
+ }
+
+ return isMultiple ? ((keys as unknown) as R) : ((keys[0] as unknown) as R);
+ }
+
+ static get>(
+ id: U,
+ ancestors?: Ancestor,
+ namespace?: string,
+ transaction?: Transaction,
+ options: GetOptions = {},
+ ): PromiseWithPopulate ? Entity[] : Entity> {
+ const ids = arrify(id);
+
+ const key = this.key(ids, ancestors, namespace);
+ const refsToPopulate: PopulateRef[][] = [];
+ const { dataloader } = options;
+
+ const onEntity = (
+ entityDataFetched: EntityData | EntityData[],
+ ): Entity | null | Array | null> => {
+ const entityData = arrify(entityDataFetched);
+
+ if (
+ ids.length === 1 &&
+ (entityData.length === 0 || typeof entityData[0] === 'undefined' || entityData[0] === null)
+ ) {
+ if (this.gstore.config.errorOnEntityNotFound) {
+ throw new GstoreError(
+ ERROR_CODES.ERR_ENTITY_NOT_FOUND,
+ `${this.entityKind} { ${ids[0].toString()} } not found`,
+ );
+ }
+
+ return null;
+ }
+
+ // Convert entityData to Entity instance
+ const entity = (entityData as EntityData[]).map(data => {
+ if (typeof data === 'undefined' || data === null) {
+ return null;
+ }
+ return new this(data, undefined, undefined, undefined, (data as any)[this.gstore.ds.KEY]);
+ });
+
+ // TODO: Check if this is still useful??
+ if (Array.isArray(id) && options.preserveOrder && entity.every(e => typeof e !== 'undefined' && e !== null)) {
+ (entity as Entity[]).sort((a, b) => id.indexOf(a.entityKey.id) - id.indexOf(b.entityKey.id));
+ }
+
+ return Array.isArray(id) ? (entity as Entity[]) : entity[0];
+ };
+
+ /**
+ * If gstore has been initialize with a cache we first fetch
+ * the key(s) from it.
+ * gstore-cache underneath will call the "fetchHandler" with only the keys that haven't
+ * been found. The final response is the merge of the cache result + the fetch.
+ */
+ const promise = this.__fetchEntityByKey(key, transaction, dataloader, options)
+ .then(onEntity)
+ .then(this.__populate(refsToPopulate, { ...options, transaction }));
+
+ (promise as any).populate = populateFactory(refsToPopulate, promise, this.schema);
+
+ return promise as PromiseWithPopulate ? Entity[] : Entity>;
+ }
+
+ static update(
+ id: IdType,
+ data: EntityData,
+ ancestors?: Ancestor,
+ namespace?: string,
+ transaction?: Transaction,
+ options?: GenericObject,
+ ): Promise> {
+ this.__hooksEnabled = true;
+
+ let entityDataUpdated: Entity;
+ let internalTransaction = false;
+
+ const key = this.key(id, ancestors, namespace);
+ const replace = options && options.replace === true;
+
+ const getEntity = (): Promise<{ key: EntityKey; data: EntityData }> => {
+ return transaction!.get(key).then(([entityData]: [EntityData]) => {
+ if (typeof entityData === 'undefined') {
+ throw new GstoreError(ERROR_CODES.ERR_ENTITY_NOT_FOUND, `Entity { ${id.toString()} } to update not found`);
+ }
+
+ extend(false, entityData, data);
+
+ const result = {
+ key: (entityData as any)[this.gstore.ds.KEY as any] as EntityKey,
+ data: entityData,
+ };
+
+ return result;
+ });
+ };
+
+ const saveEntity = (datastoreFormat: { key: EntityKey; data: EntityData }): Promise> => {
+ const { key: entityKey, data: entityData } = datastoreFormat;
+ const entity = new this(entityData, undefined, undefined, undefined, entityKey);
+
+ /**
+ * If a DataLoader instance is passed in the options
+ * attach it to the entity so it is available in "pre" hooks
+ */
+ if (options && options.dataloader) {
+ entity.dataloader = options.dataloader;
+ }
+
+ return entity.save(transaction);
+ };
+
+ const onTransactionSuccess = (): Promise> => {
+ /**
+ * Make sure to delete the cache for this key
+ */
+ if (this.__hasCache(options)) {
+ return this.clearCache(key)
+ .then(() => entityDataUpdated)
+ .catch(err => {
+ let msg = 'Error while clearing the cache after updating the entity.';
+ msg += 'The entity has been updated successfully though. ';
+ msg += 'Both the cache error and the entity updated have been attached.';
+ const cacheError = new Error(msg);
+ (cacheError as any).__entityUpdated = entityDataUpdated;
+ (cacheError as any).__cacheError = err;
+ throw cacheError;
+ });
+ }
+
+ return Promise.resolve(entityDataUpdated);
+ };
+
+ const onEntityUpdated = (entity: Entity): Promise> => {
+ entityDataUpdated = entity;
+
+ if (options && options.dataloader) {
+ options.dataloader.clear(key);
+ }
+
+ if (internalTransaction) {
+ // If we created the Transaction instance internally for the update, we commit it
+ // otherwise we leave the commit() call to the transaction creator
+ return transaction!
+ .commit()
+ .then(() =>
+ transaction!.execPostHooks().catch((err: any) => {
+ (entityDataUpdated as any)[entityDataUpdated.gstore.ERR_HOOKS] = (
+ (entityDataUpdated as any)[entityDataUpdated.gstore.ERR_HOOKS] || []
+ ).push(err);
+ }),
+ )
+ .then(onTransactionSuccess);
+ }
+
+ return onTransactionSuccess();
+ };
+
+ const getAndUpdate = (): Promise> =>
+ getEntity()
+ .then(saveEntity)
+ .then(onEntityUpdated);
+
+ const onUpdateError = (err: Error | Error[]): Promise => {
+ const error = Array.isArray(err) ? err[0] : err;
+ if (internalTransaction) {
+ // If we created the Transaction instance internally for the update, we rollback it
+ // otherwise we leave the rollback() call to the transaction creator
+
+ // TODO: Check why transaction!.rollback does not return a Promise by default
+ return (transaction!.rollback as any)().then(() => {
+ throw error;
+ });
+ }
+
+ throw error;
+ };
+
+ /**
+ * If options.replace is set to true we don't fetch the entity
+ * and save the data directly to the specified key, overriding any previous data.
+ */
+ if (replace) {
+ return saveEntity({ key, data })
+ .then(onEntityUpdated)
+ .catch(onUpdateError);
+ }
+
+ if (typeof transaction === 'undefined' || transaction === null) {
+ internalTransaction = true;
+ transaction = this.gstore.ds.transaction();
+ return transaction
+ .run()
+ .then(getAndUpdate)
+ .catch(onUpdateError);
+ }
+
+ if (transaction.constructor.name !== 'Transaction') {
+ return Promise.reject(new Error('Transaction needs to be a gcloud Transaction'));
+ }
+
+ return getAndUpdate();
+ }
+
+ static delete(
+ id?: IdType | IdType[],
+ ancestors?: Ancestor,
+ namespace?: string,
+ transaction?: Transaction,
+ key?: EntityKey | EntityKey[],
+ options: DeleteOptions = {},
+ ): Promise {
+ this.__hooksEnabled = true;
+
+ if (!key) {
+ key = this.key(id!, ancestors, namespace);
+ }
+
+ if (transaction && transaction.constructor.name !== 'Transaction') {
+ return Promise.reject(new Error('Transaction needs to be a gcloud Transaction'));
+ }
+
+ /**
+ * If it is a transaction, we create a hooks.post array that will be executed
+ * when transaction succeeds by calling transaction.execPostHooks() ---> returns a Promise
+ */
+ if (transaction) {
+ // disable (post) hooks, to only trigger them if transaction succeeds
+ this.__hooksEnabled = false;
+ this.__hooksTransaction(transaction, (this as any).__posts ? (this as any).__posts.delete : undefined);
+ transaction.delete(key);
+ return Promise.resolve({ key });
+ }
+
+ return ((this.gstore.ds.delete(key) as unknown) as Promise).then((results?: [{ indexUpdates?: number }]) => {
+ const response: DeleteResponse = results ? results[0] : {};
+ response.key = key;
+
+ /**
+ * If we passed a DataLoader instance, we clear its cache
+ */
+ if (options.dataloader) {
+ options.dataloader.clear(key);
+ }
+
+ if (response.indexUpdates !== undefined) {
+ response.success = response.indexUpdates > 0;
+ }
+
+ /**
+ * Make sure to delete the cache for this key
+ */
+ if (this.__hasCache(options)) {
+ return this.clearCache(key!, options.clearQueries)
+ .then(() => response)
+ .catch(err => {
+ let msg = 'Error while clearing the cache after deleting the entity.';
+ msg += 'The entity has been deleted successfully though. ';
+ msg += 'The cache error has been attached.';
+ const cacheError = new Error(msg);
+ (cacheError as any).__response = response;
+ (cacheError as any).__cacheError = err;
+ throw cacheError;
+ });
+ }
+
+ return response;
+ });
+ }
+
+ static deleteAll(ancestors?: Ancestor, namespace?: string): Promise {
+ const maxEntitiesPerBatch = 500;
+ const timeoutBetweenBatches = 500;
+
+ /**
+ * We limit the number of entities fetched to 100.000 to avoid hang up the system when
+ * there are > 1 million of entities to delete
+ */
+ const QUERY_LIMIT = 100000;
+
+ let currentBatch: number;
+ let totalBatches: number;
+ let entities: EntityData[];
+
+ const runQueryAndDeleteEntities = (): Promise