scrivito
Version:
Scrivito is a professional, yet easy to use SaaS Enterprise Content Management Service, built for digital agencies and medium to large businesses. It is completely maintenance-free, cost-effective, and has unprecedented performance and security.
499 lines (425 loc) • 12.6 kB
text/typescript
import isDate from 'lodash-es/isDate';
import {
BackendSearchOperator,
BackendValueBoost,
FieldBoost,
ObjSpaceId,
OrderByItem,
Query,
SingleBackendSearchValue,
} from 'scrivito_sdk/client';
import {
ArgumentError,
extractFromIterator,
formatDateToString,
isCamelCase,
prettyPrint,
transformContinueIterable,
underscore,
} from 'scrivito_sdk/common';
import {
DataQuery,
DataQueryContinuation,
FacetQuery,
FacetQueryOptions,
QueryParams,
SuggestOptions,
getObjQuery,
getObjQueryCount,
suggest,
} from 'scrivito_sdk/data';
import { objSpaceFor } from 'scrivito_sdk/models';
import { BasicObj } from 'scrivito_sdk/models/basic_obj';
import { BasicObjFacetValue } from 'scrivito_sdk/models/basic_obj_facet_value';
export type { FieldBoost } from 'scrivito_sdk/client';
export type BasicSearchValue =
| SingleBasicSearchValue
| SingleBasicSearchValue[];
type SingleBasicSearchValue = SingleBackendSearchValue | Date | BasicObj;
export type FullTextSearchOperator = 'contains' | 'containsPrefix' | 'matches';
export type SearchOperator =
| FullTextSearchOperator
| 'equals'
| 'startsWith'
| 'isGreaterThan'
| 'isLessThan'
| 'linksTo'
| 'refersTo';
export type SearchField = string | string[];
export type ObjSearchParams = QueryParams & {
batchSize?: number;
};
export const FULL_TEXT_OPERATORS: FullTextSearchOperator[] = [
'contains',
'containsPrefix',
'matches',
];
export const OPERATORS: SearchOperator[] = [
'contains',
'containsPrefix',
'matches',
'equals',
'startsWith',
'isGreaterThan',
'isLessThan',
'linksTo',
'refersTo',
];
const NEGATABLE_OPERATORS: SearchOperator[] = [
'equals',
'startsWith',
'isGreaterThan',
'isLessThan',
];
const BOOSTABLE_OPERATORS: SearchOperator[] = [
'contains',
'containsPrefix',
'matches',
];
export type OrderAttributes = Array<
string | [string] | [string, 'asc' | 'desc' | undefined]
>;
export class BasicObjSearch implements DataQuery<BasicObj> {
private _query: Query[];
private _boost: BackendValueBoost[];
private _batchSize?: number;
private _offset?: number;
private _orderBy?: OrderByItem[];
private _includeDeleted?: true;
private _includeEditingAssets?: true;
static fromParams(
workspaceId: string,
params: ObjSearchParams
): BasicObjSearch {
return new BasicObjSearch(objSpaceFor(workspaceId), params);
}
constructor(
private readonly _objSpaceId: ObjSpaceId,
params?: ObjSearchParams
) {
this._query = params ? [...params.query] : [];
this._boost = params?.boost || [];
this._batchSize = params?.batchSize;
this._offset = params?.offset;
this._orderBy = params?.orderBy;
this._includeDeleted = params?.includeDeleted;
this._includeEditingAssets = params?.includeEditingAssets;
}
and(searchToExtend: BasicObjSearch): this;
and(
field: SearchField,
operator: SearchOperator,
value: BasicSearchValue,
boost?: FieldBoost
): this;
and(
attributeOrSearch: SearchField | BasicObjSearch,
operator?: SearchOperator,
value?: BasicSearchValue,
boost?: FieldBoost
): this {
if (attributeOrSearch instanceof BasicObjSearch) {
this._query = [...this._query, ...attributeOrSearch._query];
} else {
if (operator === undefined) {
throw new ArgumentError('Missing operator to search with');
}
if (value === undefined) {
throw new ArgumentError(
'Missing value to search (specify "null" for missing)'
);
}
const field = attributeOrSearch;
const subQuery = buildSubQuery(field, operator, value);
if (boost) {
assertBoostableOperator(operator);
subQuery.boost = underscoreBoostAttributes(boost);
}
this._query.push(subQuery);
}
return this;
}
andNot(
attribute: SearchField,
operator: SearchOperator,
value: BasicSearchValue
): this {
const subQuery = buildSubQuery(attribute, operator, value);
assertNegatableOperator(operator);
subQuery.negate = true;
this._query.push(subQuery);
return this;
}
andIsChildOf(obj: BasicObj): this {
const siteId = obj.siteId();
const path = obj.path();
return siteId && path
? this.onSite(siteId).and('_parentPath', 'equals', path)
: this.and('_id', 'equals', null);
}
andIsInsideSubtreeOf(obj: BasicObj): this {
const siteId = obj.siteId();
const path = obj.path();
return siteId && path
? this.onSite(siteId).and('_path', 'startsWith', path)
: this.and('_id', 'equals', obj.id());
}
boost(
field: SearchField,
operator: SearchOperator,
value: BasicSearchValue,
factor: number
): this {
const subQuery = buildSubQuery(field, operator, value);
this._boost.push({ condition: [subQuery], factor });
return this;
}
offset(offset: number): this {
this._offset = offset || undefined;
return this;
}
order(attribute: string, direction?: 'asc' | 'desc'): this;
order(attributes: OrderAttributes): this;
order(
attributeOrAttributes: string | OrderAttributes,
direction?: 'asc' | 'desc'
): this {
const attributes: OrderAttributes = Array.isArray(attributeOrAttributes)
? attributeOrAttributes
: [[attributeOrAttributes, direction]];
this._orderBy = attributes.map((attr) => {
if (Array.isArray(attr)) {
const [innerAttr, innerDirection] = attr;
return normalizeOrderByItem(innerAttr, innerDirection);
}
return normalizeOrderByItem(attr);
});
return this;
}
batchSize(batchSize: number): this {
this._batchSize = batchSize;
return this;
}
includeDeleted(): this {
this._includeDeleted = true;
return this;
}
excludeDeleted(): this {
this._includeDeleted = undefined;
return this;
}
includeEditingAssets(): this {
this._includeEditingAssets = true;
return this;
}
count(): number {
return getObjQueryCount(this.objSpaceId(), this.queryParams()) || 0;
}
first(): BasicObj | null {
return this.take(1)[0] || null;
}
take(count: number): BasicObj[] {
return this.internalTake(count);
}
dangerouslyUnboundedTake(): BasicObj[] {
return this.internalTake(undefined);
}
iterator() {
return this.getObjDataQuery().iterator();
}
iteratorFromContinuation(continuation: DataQueryContinuation) {
return this.getObjDataQuery().iteratorFromContinuation(continuation);
}
getObjDataQuery(): DataQuery<BasicObj> {
const objDataQuery = getObjQuery(
this.objSpaceId(),
this.queryParams(),
this.getBatchSize()
);
return transformContinueIterable(objDataQuery, (iterator) =>
iterator.map((data) => new BasicObj(data))
);
}
getBatchSize() {
return this._batchSize || 100;
}
suggest(prefix: string, options?: SuggestOptions): string[] {
const { attributes, limit } = { attributes: ['*'], limit: 5, ...options };
return suggest(
this.objSpaceId(),
prefix,
{ attributes, limit },
this.queryParams()
);
}
facet(attribute: string, options?: FacetQueryOptions): BasicObjFacetValue[] {
let facetOptions: FacetQueryOptions;
if (options === undefined) {
facetOptions = {};
} else {
facetOptions = assertValidFacetOptions(options);
}
const facetQuery = new FacetQuery(
this.objSpaceId(),
underscoreAttribute(attribute),
facetOptions,
this._query
);
return facetQuery
.result()
.map((facetData) => new BasicObjFacetValue(this.objSpaceId(), facetData));
}
objSpaceId(): ObjSpaceId {
return this._objSpaceId;
}
params(): ObjSearchParams {
return {
...this.queryParams(),
batchSize: this._batchSize,
};
}
queryParams(): QueryParams {
const params: QueryParams = { query: this._query };
if (this._boost !== undefined && this._boost.length) {
params.boost = this._boost;
}
if (this._offset !== undefined) params.offset = this._offset;
if (this._orderBy !== undefined) params.orderBy = this._orderBy;
if (this._includeDeleted !== undefined) {
params.includeDeleted = this._includeDeleted;
}
if (this._includeEditingAssets !== undefined) {
params.includeEditingAssets = this._includeEditingAssets;
}
return params;
}
private internalTake(count?: number): BasicObj[] {
const oldBatchSize = this._batchSize;
try {
this._batchSize = count === undefined ? 1000 : count;
return extractFromIterator(this.iterator(), count);
} finally {
this._batchSize = oldBatchSize;
}
}
private onSite(siteId: string) {
return this.and('_siteId', 'equals', siteId);
}
}
function buildSubQuery(
fieldInput: SearchField,
operatorInput: SearchOperator,
valueInput: BasicSearchValue
): Query {
const field = convertAttribute(fieldInput);
const operator = convertOperator(operatorInput);
const value = convertValue(valueInput, operator);
return { field, operator, value };
}
function assertBoostableOperator(operator: SearchOperator) {
if (!BOOSTABLE_OPERATORS.includes(operator)) {
throw new ArgumentError(
`Boosting operator "${operator}" is invalid. ${explainValidOperators(
BOOSTABLE_OPERATORS
)}`
);
}
}
function assertNegatableOperator(operator: SearchOperator) {
if (!NEGATABLE_OPERATORS.includes(operator)) {
throw new ArgumentError(
`Negating operator "${operator}" is invalid. ${explainValidOperators(
NEGATABLE_OPERATORS
)}`
);
}
}
function convertValue(
value: BasicSearchValue,
operator: BackendSearchOperator
) {
if (Array.isArray(value)) {
return value.map((v) => convertSingleValue(v, operator));
}
return convertSingleValue(value, operator);
}
function convertSingleValue(
value: SingleBasicSearchValue,
operator: BackendSearchOperator
): SingleBackendSearchValue {
if (isDate(value)) return convertDate(value, operator);
if (value instanceof BasicObj) {
return value.id();
}
return value;
}
function convertDate(value: Date, operator: BackendSearchOperator) {
if (operator !== 'is_greater_than' && operator !== 'is_less_than') {
return formatDateToString(value);
}
const roundedDate = roundToNearestMinute(value);
const isInCurrentDateRange = Math.abs(Date.now() - value.getTime()) < 30_000;
return formatDateToString(isInCurrentDateRange ? roundedDate : value);
}
function roundToNearestMinute(value: Date) {
const oneMinuteInMs = 60_000;
return new Date(Math.round(value.getTime() / oneMinuteInMs) * oneMinuteInMs);
}
function convertOperator(operator: SearchOperator): BackendSearchOperator {
if (!OPERATORS.includes(operator)) {
throw new ArgumentError(
`Operator "${operator}" is invalid. ${explainValidOperators(OPERATORS)}`
);
}
return underscore(operator) as BackendSearchOperator;
}
function explainValidOperators(operators: string[]): string {
return `Valid operators are ${operators.join(', ')}.`;
}
function convertAttribute(attribute: string | string[]) {
if (Array.isArray(attribute)) {
return attribute.map((a) => underscoreAttribute(a));
}
return underscoreAttribute(attribute);
}
function underscoreBoostAttributes(boost: FieldBoost) {
const boostWithUnderscoreAttributes: FieldBoost = {};
Object.keys(boost).forEach((attributeName) => {
const value = boost[attributeName];
const underscoredAttributeName = underscoreAttribute(attributeName);
boostWithUnderscoreAttributes[underscoredAttributeName] = value;
});
return boostWithUnderscoreAttributes;
}
function underscoreAttribute(attributeName: string) {
if (!isCamelCase(attributeName)) {
throw new ArgumentError(
`Attribute name "${attributeName}" is not camel case.`
);
}
return underscore(attributeName);
}
function normalizeOrderByItem(
attribute: string,
direction: 'asc' | 'desc' | undefined = 'asc'
): OrderByItem {
const sortBy = underscoreAttribute(attribute);
return [sortBy, direction];
}
const VALID_FACET_OPTIONS = ['limit', 'includeObjs'];
function assertValidFacetOptions(
options: FacetQueryOptions
): FacetQueryOptions {
const invalidOptions = Object.keys(options).filter(
(key) => !VALID_FACET_OPTIONS.includes(key)
);
if (invalidOptions.length) {
throw new ArgumentError(
'Invalid facet options: ' +
`${prettyPrint(
invalidOptions
)}. Valid options: ${VALID_FACET_OPTIONS.join()}`
);
}
return options;
}