You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
412 lines
15 KiB
JavaScript
412 lines
15 KiB
JavaScript
/*
|
|
* Copyright DataStax, Inc.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const utils = require('../utils');
|
|
const QueryGenerator = require('./query-generator');
|
|
const ResultMapper = require('./result-mapper');
|
|
const Result = require('./result');
|
|
const Cache = require('./cache');
|
|
const Tree = require('./tree');
|
|
const ObjectSelector = require('./object-selector');
|
|
const DocInfoAdapter = require('./doc-info-adapter');
|
|
|
|
const cacheHighWaterMark = 100;
|
|
|
|
/**
|
|
* @ignore
|
|
*/
|
|
class MappingHandler {
|
|
/**
|
|
* @param {Client} client
|
|
* @param {ModelMappingInfo} mappingInfo
|
|
*/
|
|
constructor(client, mappingInfo) {
|
|
this._client = client;
|
|
this._cache = {
|
|
select: new Tree().on('add', length => this._validateCacheLength(length)),
|
|
selectAll: new Tree().on('add', length => this._validateCacheLength(length)),
|
|
insert: new Tree().on('add', length => this._validateCacheLength(length)),
|
|
update: new Tree().on('add', length => this._validateCacheLength(length)),
|
|
remove: new Tree().on('add', length => this._validateCacheLength(length)),
|
|
customQueries: new Map()
|
|
};
|
|
|
|
/**
|
|
* Gets the mapping information of the document.
|
|
* @type {ModelMappingInfo}
|
|
*/
|
|
this.info = mappingInfo;
|
|
}
|
|
|
|
/**
|
|
* Gets a function to be used to execute SELECT the query using the document.
|
|
* @param {Object} doc
|
|
* @param {{fields, orderBy, limit}} docInfo
|
|
* @param {Boolean} allPKsDefined Determines whether all primary keys must be defined in the doc for the query to
|
|
* be valid.
|
|
* @return {Promise<Function>}
|
|
*/
|
|
getSelectExecutor(doc, docInfo, allPKsDefined) {
|
|
const docKeys = Object.keys(doc);
|
|
if (docKeys.length === 0) {
|
|
return Promise.reject(new Error('Expected object with keys'));
|
|
}
|
|
|
|
const cacheKey = Cache.getSelectKey(docKeys, doc, docInfo);
|
|
// Cache the executor and the result mapper under the same key
|
|
// That way, those can get evicted together
|
|
const cacheItem = this._cache.select.getOrCreate(cacheKey, () => ({ executor: null, resultAdapter: null }));
|
|
|
|
if (cacheItem.executor !== null) {
|
|
return Promise.resolve(cacheItem.executor);
|
|
}
|
|
|
|
const propertiesInfo = DocInfoAdapter.getPropertiesInfo(docKeys, null, doc, this.info);
|
|
const fieldsInfo = DocInfoAdapter.getPropertiesInfo(utils.emptyArray, docInfo, doc, this.info);
|
|
const orderByColumns = DocInfoAdapter.adaptOrderBy(docInfo, this.info);
|
|
const limit = docInfo && docInfo.limit;
|
|
|
|
return this._client.connect()
|
|
.then(() =>
|
|
ObjectSelector.getForSelect(this._client, this.info, allPKsDefined, propertiesInfo, fieldsInfo, orderByColumns))
|
|
.then(tableName => {
|
|
// Part of the closure
|
|
const query = QueryGenerator.getSelect(tableName, this.info.keyspace, propertiesInfo, fieldsInfo,
|
|
orderByColumns, limit);
|
|
const paramsGetter = QueryGenerator.selectParamsGetter(propertiesInfo, limit);
|
|
const self = this;
|
|
|
|
cacheItem.executor = function selectExecutor(doc, docInfo, executionOptions) {
|
|
return self._executeSelect(query, paramsGetter, doc, docInfo, executionOptions, cacheItem);
|
|
};
|
|
|
|
return cacheItem.executor;
|
|
});
|
|
}
|
|
|
|
getSelectAllExecutor(docInfo) {
|
|
const cacheKey = Cache.getSelectAllKey(docInfo);
|
|
const cacheItem = this._cache.selectAll.getOrCreate(cacheKey, () => ({ executor: null, resultAdapter: null }));
|
|
|
|
if (cacheItem.executor !== null) {
|
|
return cacheItem.executor;
|
|
}
|
|
|
|
const fieldsInfo = DocInfoAdapter.getPropertiesInfo(utils.emptyArray, docInfo, utils.emptyObject, this.info);
|
|
const orderByColumns = DocInfoAdapter.adaptOrderBy(docInfo, this.info);
|
|
const limit = docInfo && docInfo.limit;
|
|
|
|
const tableName = ObjectSelector.getForSelectAll(this.info);
|
|
|
|
// Part of the closure
|
|
const query = QueryGenerator.getSelect(
|
|
tableName, this.info.keyspace, utils.emptyArray, fieldsInfo, orderByColumns, limit);
|
|
const paramsGetter = QueryGenerator.selectParamsGetter(utils.emptyArray, limit);
|
|
const self = this;
|
|
|
|
cacheItem.executor = function selectAllExecutor(docInfo, executionOptions) {
|
|
return self._executeSelect(query, paramsGetter, null, docInfo, executionOptions, cacheItem);
|
|
};
|
|
|
|
return cacheItem.executor;
|
|
}
|
|
|
|
/**
|
|
* Executes a SELECT query and returns the adapted results.
|
|
* When a result adapter is not yet created, it gets a new one and caches it.
|
|
* @private
|
|
*/
|
|
_executeSelect(query, paramsGetter, doc, docInfo, executionOptions, cacheItem) {
|
|
const options = DocInfoAdapter.adaptAllOptions(executionOptions, true);
|
|
|
|
return this._client.execute(query, paramsGetter(doc, docInfo, this.info), options)
|
|
.then(rs => {
|
|
if (cacheItem.resultAdapter === null) {
|
|
cacheItem.resultAdapter = ResultMapper.getSelectAdapter(this.info, rs);
|
|
}
|
|
return new Result(rs, this.info, cacheItem.resultAdapter);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets a function to be used to execute INSERT the query using the document.
|
|
* @param {Object} doc
|
|
* @param {{ifNotExists, ttl, fields}} docInfo
|
|
* @return {Promise<Function>}
|
|
*/
|
|
getInsertExecutor(doc, docInfo) {
|
|
const docKeys = Object.keys(doc);
|
|
if (docKeys.length === 0) {
|
|
return Promise.reject(new Error('Expected object with keys'));
|
|
}
|
|
|
|
const cacheKey = Cache.getInsertKey(docKeys, docInfo);
|
|
const cacheItem = this._cache.insert.getOrCreate(cacheKey, () => ({ executor: null }));
|
|
|
|
if (cacheItem.executor !== null) {
|
|
return Promise.resolve(cacheItem.executor);
|
|
}
|
|
|
|
return this.createInsertQueries(docKeys, doc, docInfo)
|
|
.then(queries => {
|
|
if (queries.length === 1) {
|
|
return this._setSingleExecutor(cacheItem, queries[0]);
|
|
}
|
|
|
|
return this._setBatchExecutor(cacheItem, queries);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Creates an Array containing the query and the params getter function for each table affected by the INSERT.
|
|
* @param {Array<String>} docKeys
|
|
* @param {Object} doc
|
|
* @param {{ifNotExists, ttl, fields}} docInfo
|
|
* @returns {Promise<Array<{query, paramsGetter}>>}
|
|
*/
|
|
createInsertQueries(docKeys, doc, docInfo) {
|
|
const propertiesInfo = DocInfoAdapter.getPropertiesInfo(docKeys, docInfo, doc, this.info);
|
|
const ifNotExists = docInfo && docInfo.ifNotExists;
|
|
|
|
// Get all the tables affected
|
|
return this._client.connect()
|
|
.then(() => ObjectSelector.getForInsert(this._client, this.info, propertiesInfo))
|
|
.then(tables => {
|
|
|
|
if (tables.length > 1 && ifNotExists) {
|
|
throw new Error('Batch with ifNotExists conditions cannot span multiple tables');
|
|
}
|
|
|
|
// For each tables affected, Generate query and parameter getters
|
|
return tables.map(table =>
|
|
QueryGenerator.getInsert(table, this.info.keyspace, propertiesInfo, docInfo,ifNotExists));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets a function to be used to execute the UPDATE queries with the provided document.
|
|
* @param {Object} doc
|
|
* @param {{ifExists, when, ttl, fields}} docInfo
|
|
* @return {Promise<Function>}
|
|
*/
|
|
getUpdateExecutor(doc, docInfo) {
|
|
const docKeys = Object.keys(doc);
|
|
if (docKeys.length === 0) {
|
|
return Promise.reject(new Error('Expected object with keys'));
|
|
}
|
|
|
|
const cacheKey = Cache.getUpdateKey(docKeys, doc, docInfo);
|
|
const cacheItem = this._cache.update.getOrCreate(cacheKey, () => ({ executor: null }));
|
|
|
|
if (cacheItem.executor !== null) {
|
|
return Promise.resolve(cacheItem.executor);
|
|
}
|
|
|
|
return this.createUpdateQueries(docKeys, doc, docInfo)
|
|
.then(queries => {
|
|
if (queries.length === 1) {
|
|
return this._setSingleExecutor(cacheItem, queries[0]);
|
|
}
|
|
|
|
return this._setBatchExecutor(cacheItem, queries);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Creates an Array containing the query and the params getter function for each table affected by the UPDATE.
|
|
* @param {Array<String>} docKeys
|
|
* @param {Object} doc
|
|
* @param {Object} docInfo
|
|
* @returns {Promise<Array<{query, paramsGetter, isIdempotent}>>}
|
|
*/
|
|
createUpdateQueries(docKeys, doc, docInfo) {
|
|
const propertiesInfo = DocInfoAdapter.getPropertiesInfo(docKeys, docInfo, doc, this.info);
|
|
const ifExists = docInfo && docInfo.ifExists;
|
|
const when = docInfo && docInfo.when
|
|
? DocInfoAdapter.getPropertiesInfo(Object.keys(docInfo.when), null, docInfo.when, this.info)
|
|
: utils.emptyArray;
|
|
|
|
if (when.length > 0 && ifExists) {
|
|
throw new Error('Both when and ifExists conditions can not be applied to the same statement');
|
|
}
|
|
|
|
// Get all the tables affected
|
|
return this._client.connect()
|
|
.then(() => ObjectSelector.getForUpdate(this._client, this.info, propertiesInfo, when))
|
|
.then(tables => {
|
|
|
|
if (tables.length > 1 && (when.length > 0 || ifExists)) {
|
|
throw new Error('Batch with when or ifExists conditions cannot span multiple tables');
|
|
}
|
|
|
|
// For each table affected, Generate query and parameter getters
|
|
return tables.map(table =>
|
|
QueryGenerator.getUpdate(table, this.info.keyspace, propertiesInfo, docInfo, when, ifExists));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets a function to be used to execute the DELETE queries with the provided document.
|
|
* @param {Object} doc
|
|
* @param {{when, ifExists, fields, deleteOnlyColumns}} docInfo
|
|
* @return {Promise<Function>}
|
|
*/
|
|
getDeleteExecutor(doc, docInfo) {
|
|
const docKeys = Object.keys(doc);
|
|
if (docKeys.length === 0) {
|
|
return Promise.reject(new Error('Expected object with keys'));
|
|
}
|
|
|
|
const cacheKey = Cache.getRemoveKey(docKeys, doc, docInfo);
|
|
const cacheItem = this._cache.remove.getOrCreate(cacheKey, () => ({ executor: null }));
|
|
|
|
if (cacheItem.executor !== null) {
|
|
return Promise.resolve(cacheItem.executor);
|
|
}
|
|
|
|
return this.createDeleteQueries(docKeys, doc, docInfo)
|
|
.then(queries => {
|
|
if (queries.length === 1) {
|
|
return this._setSingleExecutor(cacheItem, queries[0]);
|
|
}
|
|
|
|
return this._setBatchExecutor(cacheItem, queries);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Creates an Array containing the query and the params getter function for each table affected by the DELETE.
|
|
* @param {Array<String>} docKeys
|
|
* @param {Object} doc
|
|
* @param {{when, ifExists, fields, deleteOnlyColumns}} docInfo
|
|
* @returns {Promise<Array<{query, paramsGetter}>>}
|
|
*/
|
|
createDeleteQueries(docKeys, doc, docInfo) {
|
|
const propertiesInfo = DocInfoAdapter.getPropertiesInfo(docKeys, docInfo, doc, this.info);
|
|
const ifExists = docInfo && docInfo.ifExists;
|
|
const when = docInfo && docInfo.when
|
|
? DocInfoAdapter.getPropertiesInfo(Object.keys(docInfo.when), null, docInfo.when, this.info)
|
|
: utils.emptyArray;
|
|
|
|
if (when.length > 0 && ifExists) {
|
|
throw new Error('Both when and ifExists conditions can not be applied to the same statement');
|
|
}
|
|
|
|
// Get all the tables affected
|
|
return this._client.connect()
|
|
.then(() => ObjectSelector.getForDelete(this._client, this.info, propertiesInfo, when))
|
|
.then(tables => {
|
|
|
|
if (tables.length > 1 && (when.length > 0 || ifExists)) {
|
|
throw new Error('Batch with when or ifExists conditions cannot span multiple tables');
|
|
}
|
|
|
|
// For each tables affected, Generate query and parameter getters
|
|
return tables.map(table =>
|
|
QueryGenerator.getDelete(table, this.info.keyspace, propertiesInfo, docInfo, when, ifExists));
|
|
});
|
|
}
|
|
|
|
getExecutorFromQuery(query, paramsHandler, commonExecutionOptions) {
|
|
// Use the current instance in the closure
|
|
// as there is no guarantee of how the returned function will be invoked
|
|
const self = this;
|
|
const commonOptions = commonExecutionOptions ? DocInfoAdapter.adaptAllOptions(commonExecutionOptions) : null;
|
|
|
|
return (function queryMappedExecutor(doc, executionOptions) {
|
|
// When the executionOptions were already specified,
|
|
// use it and skip the ones provided in each invocation
|
|
const options = commonOptions
|
|
? commonOptions
|
|
: DocInfoAdapter.adaptAllOptions(executionOptions);
|
|
|
|
return self._client.execute(query, paramsHandler(doc), options).then(rs => {
|
|
// Cache the resultAdapter based on the query
|
|
let resultAdapter = self._cache.customQueries.get(query);
|
|
|
|
if (resultAdapter === undefined) {
|
|
const resultAdapterInfo = ResultMapper.getCustomQueryAdapter(self.info, rs);
|
|
resultAdapter = resultAdapterInfo.fn;
|
|
if (resultAdapterInfo.canCache) {
|
|
// Avoid caching conditional updates results as the amount of columns change
|
|
// depending on the parameter values.
|
|
self._cache.customQueries.set(query, resultAdapter);
|
|
|
|
if (self._cache.customQueries.size === cacheHighWaterMark) {
|
|
self._client.log('warning',
|
|
`Custom queries cache reached ${cacheHighWaterMark} items, this could be caused by ` +
|
|
`hard-coding parameter values inside the query, which should be avoided`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return new Result(rs, self.info, resultAdapter);
|
|
});
|
|
});
|
|
}
|
|
|
|
_setSingleExecutor(cacheItem, queryInfo) {
|
|
// Parameters and this instance are part of the closure
|
|
const self = this;
|
|
|
|
// Set the function to execute the request in the cache
|
|
cacheItem.executor = function singleExecutor(doc, docInfo, executionOptions) {
|
|
const options = DocInfoAdapter.adaptOptions(executionOptions, queryInfo.isIdempotent);
|
|
|
|
return self._client.execute(queryInfo.query, queryInfo.paramsGetter(doc, docInfo, self.info), options)
|
|
.then(rs => new Result(rs, self.info, ResultMapper.getMutationAdapter(rs)));
|
|
};
|
|
|
|
return cacheItem.executor;
|
|
}
|
|
|
|
_setBatchExecutor(cacheItem, queries) {
|
|
// Parameters and the following fields are part of the closure
|
|
const self = this;
|
|
const isIdempotent = queries.reduce((acc, q) => acc && q.isIdempotent, true);
|
|
|
|
// Set the function to execute the batch request in the cache
|
|
cacheItem.executor = function batchExecutor(doc, docInfo, executionOptions) {
|
|
// Use the params getter function to obtain the parameters each time
|
|
const queryAndParams = queries.map(q => ({
|
|
query: q.query,
|
|
params: q.paramsGetter(doc, docInfo, self.info)
|
|
}));
|
|
|
|
const options = DocInfoAdapter.adaptOptions(executionOptions, isIdempotent);
|
|
|
|
// Execute using a Batch
|
|
return self._client.batch(queryAndParams, options)
|
|
.then(rs => new Result(rs, self.info, ResultMapper.getMutationAdapter(rs)));
|
|
};
|
|
|
|
return cacheItem.executor;
|
|
}
|
|
|
|
_validateCacheLength(length) {
|
|
if (length !== cacheHighWaterMark) {
|
|
return;
|
|
}
|
|
|
|
this._client.log('warning', `ModelMapper cache reached ${cacheHighWaterMark} items, this could be caused by ` +
|
|
`building the object to map in different ways (with different shapes) each time. Use the same or few object ` +
|
|
`structures for a model and represent unset values with undefined or types.unset`);
|
|
}
|
|
}
|
|
|
|
module.exports = MappingHandler; |