@fjell/core
Version:
Core Item and Key Framework for Fjell
361 lines (340 loc) • 11.7 kB
text/typescript
import { Item, ReferenceItem, References } from "../items";
import { isItemKeyEqual, isPriKey } from "../key/KUtils";
import { ComKey, PriKey } from "../keys";
import LibLogger from "../logger";
import * as luxon from 'luxon';
import { CompoundCondition, Condition, EventQuery, isCondition, ItemQuery, QueryParams } from "./ItemQuery";
const logger = LibLogger.get('IQUtils');
/**
* When we query or search, we're sending a GET request. This converts everything in ItemQuery into a flat
* object that can be sent over a GET request.
*
* Note that there is some discussion about this. Evidently Elastic supports search with POST, but that also
* feels like a bit of a hack. It's not a RESTful way to do things. So we're sticking with GET for now.
*
* For reference, look at RFC 9110 "HTTP Semantics", June which clarified on top of and RFC 7231. It's possible
* but there are so many caveats and conditions in the standard, it's not worth it.
*
* Anticipating the next question - "isn't there a limit to the length of a URL?" The specification does not
* specify a limit, and there are limits in various browsers and servers - Apache is 4,000 chars, Chrome is 2M chars.
* Short answer is that if this query is being used to craft something that complex, it is probably a better idea
* to provide an action or a custom query endpoint on the server.
*
* @param query
* @returns QueryParams ready to be get over a GET request.
*/
export const queryToParams = (query: ItemQuery): QueryParams => {
const params: QueryParams = {};
if (query.compoundCondition) {
params.compoundCondition = JSON.stringify(query.compoundCondition);
}
if (query.refs) {
params.refs = JSON.stringify(query.refs);
}
if (query.limit) {
params.limit = query.limit;
}
if (query.offset) {
params.offset = query.offset;
}
if (query.aggs) {
params.aggs = JSON.stringify(query.aggs);
}
if (query.events) {
params.events = JSON.stringify(query.events);
}
return params;
}
// This is a dateTimeReviver used for JSON parse - when we convert a param back to a query, we need this.
const dateTimeReviver = function (key: string, value: string) {
if (typeof value === 'string') {
const parsedDate = luxon.DateTime.fromISO(value);
if (parsedDate.isValid) {
return parsedDate.toJSDate();;
}
}
return value;
}
/**
* This method translates from a flat QueryParams object with stringify'd JSON back to a full ItemQuery.
*
* @param params Parameters sent over a GET request
* @returns A fully hydrated ItemQuery object.
*/
export const paramsToQuery = (params: QueryParams): ItemQuery => {
const query: ItemQuery = {};
if (params.compoundCondition) {
query.compoundCondition = JSON.parse(params.compoundCondition as string) as CompoundCondition;
}
if (params.refs) {
query.refs = JSON.parse(params.refs as string) as References;
}
if (params.limit) {
query.limit = Number(params.limit);
}
if (params.offset) {
query.offset = Number(params.offset);
}
if (params.aggs) {
query.aggs = JSON.parse(params.aggs as string) as Record<string, ItemQuery>;
}
if (params.events) {
query.events = JSON.parse(params.events as string, dateTimeReviver) as Record<string, { start?: Date, end?: Date }>;
}
return query;
}
const isRefQueryMatch =
<
S extends string,
L1 extends string = never,
L2 extends string = never,
L3 extends string = never,
L4 extends string = never,
L5 extends string = never
>(
refKey: string,
queryRef: ComKey<S, L1, L2, L3, L4, L5> | PriKey<S>,
references: References,
): boolean => {
logger.trace('doesRefMatch', { queryRef, references });
logger.debug('Comparing Ref', { refKey, itemRef: references[refKey], queryRef });
return isItemKeyEqual(queryRef, references[refKey]);
}
const isCompoundConditionQueryMatch = <
S extends string,
L1 extends string = never,
L2 extends string = never,
L3 extends string = never,
L4 extends string = never,
L5 extends string = never
>(
queryCondition: CompoundCondition,
item: Item<S, L1, L2, L3, L4, L5>,
): boolean => {
if (queryCondition.compoundType === 'AND') {
// If this is an AND compound condition, we need to check if all of the conditions match
return queryCondition.conditions.every(
(condition: Condition | CompoundCondition) =>
isCondition(condition) ?
isConditionQueryMatch(condition, item) :
isCompoundConditionQueryMatch(condition, item)
);
} else {
// If this is an OR compound condition, we need to check if any of the conditions match
return queryCondition.conditions.some(
(condition: Condition | CompoundCondition) =>
isCondition(condition) ?
isConditionQueryMatch(condition, item) :
isCompoundConditionQueryMatch(condition, item)
);
}
}
const isConditionQueryMatch = <
S extends string,
L1 extends string = never,
L2 extends string = never,
L3 extends string = never,
L4 extends string = never,
L5 extends string = never
>(
queryCondition: Condition,
item: Item<S, L1, L2, L3, L4, L5>,
): boolean => {
const propKey = queryCondition.column;
logger.trace('doesConditionMatch', { propKey, queryCondition, item });
// eslint-disable-next-line no-undefined
if (item[propKey] === undefined) {
logger.debug('Item does not contain prop under key', { propKey, item });
return false;
}
logger.debug('Comparing Condition', { propKey, itemProp: item[propKey], queryCondition });
let result = false;
switch (queryCondition.operator) {
case '==':
result = item[propKey] === queryCondition.value;
break;
case '!=':
result = item[propKey] !== queryCondition.value;
break;
case '>':
result = item[propKey] > queryCondition.value;
break;
case '>=':
result = item[propKey] >= queryCondition.value;
break;
case '<':
result = item[propKey] < queryCondition.value;
break;
case '<=':
result = item[propKey] <= queryCondition.value;
break;
case 'in':
result = (queryCondition.value as unknown as string[]).includes(item[propKey] as string);
break;
case 'not-in':
result = !(queryCondition.value as unknown as string[]).includes(item[propKey] as string);
break;
case 'array-contains':
result = (item[propKey] as unknown as string[]).includes(queryCondition.value as string);
break;
case 'array-contains-any':
result = (queryCondition.value as unknown as string[])
.some(value => (item[propKey] as unknown as string[]).includes(value));
break;
}
return result;
}
const isAggQueryMatch = <
S extends string,
L1 extends string = never,
L2 extends string = never,
L3 extends string = never,
L4 extends string = never,
L5 extends string = never
>(
aggKey: string,
aggQuery: ItemQuery,
agg: ReferenceItem<S, L1, L2, L3, L4, L5>
): boolean => {
const aggItem = agg.item;
logger.debug('Comparing Agg', { aggKey, aggItem, aggQuery });
// Fancy, right? This is a recursive call to isQueryMatch
return isQueryMatch(aggItem, aggQuery);
}
const isEventQueryMatch = <
S extends string,
L1 extends string = never,
L2 extends string = never,
L3 extends string = never,
L4 extends string = never,
L5 extends string = never
>(
eventKey: string,
eventQuery: EventQuery,
item: Item<S, L1, L2, L3, L4, L5>,
): boolean => {
if (!item.events[eventKey]) {
logger.debug('Item does not contain event under key', { eventKey, events: item.events });
return false;
} else {
const itemEvent = item.events[eventKey];
if (itemEvent.at !== null) {
if (eventQuery.start && !(eventQuery.start.getTime() <= itemEvent.at.getTime())) {
logger.debug('Item date before event start query', { eventQuery, itemEvent });
return false;
}
if (eventQuery.end && !(eventQuery.end.getTime() > itemEvent.at.getTime())) {
logger.debug('Item date after event end query', { eventQuery, itemEvent });
return false;
}
} else {
logger.debug('Item event does contains a null at', { itemEvent });
return false;
}
return true;
}
}
export const isQueryMatch = <
S extends string,
L1 extends string = never,
L2 extends string = never,
L3 extends string = never,
L4 extends string = never,
L5 extends string = never
>(item: Item<S, L1, L2, L3, L4, L5>, query: ItemQuery): boolean => {
logger.trace('isMatch', { item, query });
if (query.refs && item.refs) {
for (const key in query.refs) {
const queryRef = query.refs[key];
if (!isRefQueryMatch(key, queryRef, item.refs)) return false;
}
} else if (query.refs && !item.refs) {
logger.debug('Query contains refs but item does not have refs', { query, item });
return false;
}
if (query.compoundCondition && item) {
if (!isCompoundConditionQueryMatch(query.compoundCondition, item)) return false;
}
if (query.events && item.events) {
for (const key in query.events) {
const queryEvent = query.events[key];
if (!isEventQueryMatch(key, queryEvent, item)) return false
}
return true;
}
if (query.aggs && item.aggs) {
for (const key in query.aggs) {
const aggQuery = query.aggs[key];
if (item.aggs[key] && !isAggQueryMatch(key, aggQuery, item.aggs[key])) return false;
}
} if (query.aggs && !item.aggs) {
logger.debug('Query contains aggs but item does not have aggs', { query, item });
return false;
}
// If it hasn't returned false by now, it must be a match
return true;
}
export const abbrevQuery = (query: ItemQuery | null | undefined): string => {
const abbrev = ['IQ'];
if( query ) {
if (query.refs) {
for (const key in query.refs) {
const ref = abbrevRef(key, query.refs[key]);
abbrev.push(ref);
}
}
if (query.compoundCondition) {
const props = abbrevCompoundCondition(query.compoundCondition);
abbrev.push(props);
}
if (query.aggs) {
for (const key in query.aggs) {
const agg = abbrevAgg(key, query.aggs[key]);
abbrev.push(agg);
}
}
if (query.events) {
const events = `(E${Object.keys(query.events).join(',')})`;
abbrev.push(events);
}
if (query.limit) {
abbrev.push(`L${query.limit}`);
}
if (query.offset) {
abbrev.push(`O${query.offset}`);
}
} else {
abbrev.push('(empty)');
}
return abbrev.join(' ');
}
export const abbrevRef = <
S extends string,
L1 extends string = never,
L2 extends string = never,
L3 extends string = never,
L4 extends string = never,
L5 extends string = never
>(key: string, ref: ComKey<S, L1, L2, L3, L4, L5> | PriKey<S>): string => {
if (isPriKey(ref)) {
const priKey = ref as PriKey<S>;
return `R(${key},${priKey.kt},${priKey.pk})`;
} else {
const comKey = ref as ComKey<S, L1, L2, L3, L4, L5>;
return `R(${key},${JSON.stringify(comKey)})`;
}
}
export const abbrevAgg = (key: string, agg: ItemQuery): string => {
return `A(${key},${abbrevQuery(agg)})`;
}
export const abbrevCompoundCondition = (compoundCondition: CompoundCondition): string => {
return `CC(${compoundCondition.compoundType},` +
`${compoundCondition.conditions ? compoundCondition.conditions.map(abbrevCondition).join(',') : 'No Conditions'})`;
}
export const abbrevCondition = (condition: Condition | CompoundCondition): string => {
if (isCondition(condition)) {
return `(${condition.column},${condition.value},${condition.operator})`;
} else {
return abbrevCompoundCondition(condition);
}
}