ember-m3
Version:
Alternative to @ember-data/model in which attributes and relationships are derived from API Payloads
297 lines (262 loc) • 8.79 kB
JavaScript
import { Promise as RSVPPromise } from 'rsvp';
import { assert } from '@ember/debug';
import { get } from '@ember/object';
import { assign } from '@ember/polyfills';
import MegamorphicModel from './model';
import M3QueryArray from './query-array';
import { CUSTOM_MODEL_CLASS } from 'ember-m3/-infra/features';
import serializeQueryParams from './utils/serialize-query-params';
function stripSlash(str, stripLeading, stripTrailing) {
let startSlash = stripLeading && str.charAt(0) === '/';
let endSlash = stripTrailing && str.charAt(str.length - 1) === '/';
return str.slice(startSlash ? 1 : 0, endSlash ? -1 : str.length);
}
export default class QueryCache {
constructor({ store }) {
this._store = store;
this._recordArrayManager = this._store.recordArrayManager;
this._queryCache = new Object(null);
this._reverseQueryCache = new Object(null);
this.__adapter = null;
this.__serializer = null;
}
queryURL(
url,
{
params = null,
queryParams = null,
method = 'GET',
cacheKey = null,
reload = false,
backgroundReload = false,
adapterOptions = undefined,
} = {},
array
) {
let options = {};
if (params) {
options.params = params;
}
if (adapterOptions) {
options.adapterOptions = adapterOptions;
}
let cachedPromise = cacheKey ? this._queryCache[cacheKey] : undefined;
let adapterUrl = this._buildUrl(url, queryParams);
let loadPromise;
if (backgroundReload || reload || cachedPromise === undefined) {
loadPromise = this._adapterQueryURL(adapterUrl, method, options)
.then(rawPayload => {
let payload = this._serializer.normalizeResponse(
this._store,
MegamorphicModel,
rawPayload,
cacheKey,
'queryURL'
);
if (
payload === null ||
payload === undefined ||
typeof payload !== 'object' ||
Array.isArray(payload)
) {
return payload;
}
let result = this._createResult(payload, { url, params, method, cacheKey }, array);
// Add result to reverseCache.
if (cacheKey) {
this._addResultToReverseCache(result, cacheKey);
}
return result;
})
.catch(error => {
// If the Promise rejects, evict the query cache to allow for retries.
if (cacheKey) {
this.unloadURL(cacheKey);
}
throw error;
});
}
if (reload || cachedPromise === undefined) {
// Store the promise in the cache until it is fulfilled and
// return same promise for subsequent request.
if (cacheKey) {
this._queryCache[cacheKey] = loadPromise;
}
return loadPromise;
} else {
return cachedPromise;
}
}
cacheURL(cacheKey, result) {
assert(
'cacheKey must be a non-empty string',
typeof cacheKey === 'string' && cacheKey.length > 0
);
// We will wrap `result` as a promise to be type consistent.
this._queryCache[cacheKey] = RSVPPromise.resolve(result);
this._addResultToReverseCache(result, cacheKey);
}
unloadRecord(record) {
let { id } = record;
let matchingQueryCacheKeys = this._reverseQueryCache[id];
if (!matchingQueryCacheKeys) {
return;
}
for (let i = 0; i < matchingQueryCacheKeys.length; ++i) {
let invalidatedCacheKey = matchingQueryCacheKeys[i];
delete this._queryCache[invalidatedCacheKey];
}
delete this._reverseQueryCache[id];
}
unloadURL(cacheKey) {
delete this._queryCache[cacheKey];
}
contains(cacheKey) {
return !!this._queryCache[cacheKey];
}
_adapterQueryURL(url, method, options) {
let adapter = this._adapter;
if (adapter.queryURL) {
return adapter.queryURL(url, method, options);
}
let ajaxOptions = {};
if (options.params) {
ajaxOptions.data = options.params;
}
if (options.adapterOptions && options.adapterOptions.headers) {
ajaxOptions.headers = options.adapterOptions.headers;
}
return adapter.ajax(url, method, ajaxOptions);
}
_buildUrl(url, queryParams) {
let parts = [];
let needsHost = false;
let needsNamespace = false;
let host;
let namespace;
// add a hoost if we need to, ie if
// 1. no host is present in url and
// 2. a host is specified on the adapter
if (/^\/\//.test(url) || /http(s)?:\/\//.test(url)) {
needsHost = false;
needsNamespace = false;
} else {
needsHost = true;
needsNamespace = !(url.charAt(0) === '/');
}
if (needsHost) {
host = stripSlash(get(this._adapter, 'host') || '', false, true);
if (host.length > 0) {
parts.push(host);
}
}
if (needsNamespace) {
// if we have a host we'll get '/' from joining, otherwise if we're
// producing only a path respect whatever the namespace is configured as
let stripLeadingSlash = parts.length > 0;
namespace = stripSlash(get(this._adapter, 'namespace') || '', stripLeadingSlash, true);
if (namespace.length > 0) {
parts.push(namespace);
}
}
if (parts.length > 0) {
parts.push(stripSlash(url, true, true));
url = parts.join('/');
}
if (needsHost && !host && url.charAt(0) !== '/') {
if (needsNamespace && namespace) {
// relative namespaces are implicitly converted to absolute, as with
// ember data.
//
// namespace: 'library'
// queryURL('books/1')
// => '/library/books/1'
url = `/${url}`;
} else {
// With no host, no namespace and a relative url we don't know what
// request to make. We could just make a relative request but then it
// will resolved relative to either the base href (if a BASE tag is
// present) or the current `location.pathname`
throw new Error(
`store.queryURL('${url}') is invalid. Absolute paths are required. Either add a 'host' or 'namespace' property to your -ember-m3 adapter or call 'queryURL' with an absolute path.`
);
}
}
if (queryParams) {
const queryParamDelimiter = url.indexOf('?') > -1 ? '&' : '?';
url += `${queryParamDelimiter}${serializeQueryParams(queryParams)}`;
}
return url;
}
_createResult(payload, query, array) {
let internalModelOrModels;
if (CUSTOM_MODEL_CLASS) {
internalModelOrModels = this._store.push(payload);
} else {
internalModelOrModels = this._store._push(payload);
}
if (array) {
if (CUSTOM_MODEL_CLASS) {
array._setObjects(internalModelOrModels);
} else {
array._setInternalModels(internalModelOrModels);
}
return array;
} else if (Array.isArray(internalModelOrModels)) {
return this._createQueryArray(internalModelOrModels, query, payload);
} else {
if (CUSTOM_MODEL_CLASS) {
return internalModelOrModels;
} else {
return internalModelOrModels.getRecord();
}
}
}
_addResultToReverseCache(result, cacheKey) {
if (result.constructor === M3QueryArray) {
if (CUSTOM_MODEL_CLASS) {
for (let i = 0; i < result._objects.length; ++i) {
this._addRecordToReverseCache(result._objects[i], cacheKey);
}
} else {
for (let i = 0; i < result._internalModels.length; ++i) {
this._addRecordToReverseCache(result._internalModels[i], cacheKey);
}
}
} else {
this._addRecordToReverseCache(result, cacheKey);
}
}
_addRecordToReverseCache({ id }, cacheKey) {
let cacheKeys = (this._reverseQueryCache[id] = this._reverseQueryCache[id] || []);
// no need to check for presence as we're only here b/c of a cache miss
cacheKeys.push(cacheKey);
}
_createQueryArray(internalModelsOrModels, query, payload) {
let array = M3QueryArray.create({
modelName: '-ember-m3',
store: this._store,
manager: this._recordArrayManager,
queryCache: this,
query,
meta: assign({}, payload.meta),
});
if (CUSTOM_MODEL_CLASS) {
//TODO consider not setting models eagerly
array._setObjects(internalModelsOrModels);
} else {
array._setInternalModels(internalModelsOrModels, false);
this._recordArrayManager._adapterPopulatedRecordArrays.push(array);
}
return array;
}
get _adapter() {
return this.__adapter || (this.__adapter = this._store.adapterFor('-ember-m3'));
}
get _serializer() {
return this.__serializer || (this.__serializer = this._store.serializerFor('-ember-m3'));
}
toString() {
return 'QueryCache';
}
}