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

/*
* 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;