/** * For some RxStorage implementations, * we need to use our custom crafted indexes * so we can easily iterate over them. And sort plain arrays of document data. * * We really often have to craft an index string for a given document. * Performance of everything in this file is very important * which is why the code sometimes looks strange. * Run performance tests before and after you touch anything here! */ import { getSchemaByObjectPath } from './rx-schema-helper.ts'; import type { JsonSchema, RxDocumentData, RxJsonSchema } from './types/index.d.ts'; import { ensureNotFalsy, objectPathMonad, ObjectPathMonadFunction } from './plugins/utils/index.ts'; import { INDEX_MAX, INDEX_MIN } from './query-planner.ts'; import { newRxError } from './rx-error.ts'; /** * Prepare all relevant information * outside of the returned function * from getIndexableStringMonad() * to save performance when the returned * function is called many times. */ type IndexMetaField = { fieldName: string; schemaPart: JsonSchema; /* * Only in number fields. */ parsedLengths?: ParsedLengths; getValue: ObjectPathMonadFunction; getIndexStringPart: (docData: RxDocumentData) => string; }; export function getIndexMeta( schema: RxJsonSchema>, index: string[] ): IndexMetaField[] { const fieldNameProperties: IndexMetaField[] = index.map(fieldName => { const schemaPart = getSchemaByObjectPath( schema, fieldName ); if (!schemaPart) { throw newRxError('CI1', { fieldName }); } const type = schemaPart.type; let parsedLengths: ParsedLengths | undefined; if (type === 'number' || type === 'integer') { parsedLengths = getStringLengthOfIndexNumber( schemaPart ); } const getValue = objectPathMonad(fieldName); const maxLength = schemaPart.maxLength ? schemaPart.maxLength : 0; let getIndexStringPart: (docData: RxDocumentData) => string; if (type === 'string') { getIndexStringPart = docData => { let fieldValue = getValue(docData); if (!fieldValue) { fieldValue = ''; } return fieldValue.padEnd(maxLength, ' '); }; } else if (type === 'boolean') { getIndexStringPart = docData => { const fieldValue = getValue(docData); return fieldValue ? '1' : '0'; }; } else { // number /** * @performance * Inline the number index string generation to avoid * function call overhead and redundant boundary checks. * Document data in the hot path is assumed to be valid. */ const pLengths = parsedLengths as ParsedLengths; const pMin = pLengths.minimum; const pMax = pLengths.maximum; const pRoundedMin = pLengths.roundedMinimum; const pNonDecimals = pLengths.nonDecimals; const pDecimals = pLengths.decimals; const pMultiplier = pLengths.multiplier; if (pDecimals === 0) { getIndexStringPart = docData => { let fieldValue = getValue(docData); if (typeof fieldValue === 'undefined') { fieldValue = 0; } if (fieldValue < pMin) { fieldValue = pMin; } if (fieldValue > pMax) { fieldValue = pMax; } return (Math.floor(fieldValue) - pRoundedMin).toString().padStart(pNonDecimals, '0'); }; } else { getIndexStringPart = docData => { let fieldValue = getValue(docData); if (typeof fieldValue === 'undefined') { fieldValue = 0; } if (fieldValue < pMin) { fieldValue = pMin; } if (fieldValue > pMax) { fieldValue = pMax; } const flooredValue = Math.floor(fieldValue); const shifted = Math.min( Math.round((fieldValue - flooredValue) * pMultiplier), pMultiplier - 1 ); const str = (flooredValue - pRoundedMin).toString().padStart(pNonDecimals, '0'); return str + shifted.toString().padStart(pDecimals, '0'); }; } } const ret: IndexMetaField = { fieldName, schemaPart, parsedLengths, getValue, getIndexStringPart }; return ret; }); return fieldNameProperties; } /** * Crafts an indexable string that can be used * to check if a document would be sorted below or above * another documents, dependent on the index values. * @monad for better performance * * IMPORTANT: Performance is really important here * which is why we code so 'strange'. * Always run performance tests when you want to * change something in this method. */ export function getIndexableStringMonad( schema: RxJsonSchema>, index: string[] ): (docData: RxDocumentData) => string { const fieldNameProperties = getIndexMeta(schema, index); const fieldNamePropertiesAmount = fieldNameProperties.length; const indexPartsFunctions = fieldNameProperties.map(r => r.getIndexStringPart); /** * @hotPath Performance of this function is very critical! * Specialize for common field counts to avoid loop overhead. */ if (fieldNamePropertiesAmount === 1) { return indexPartsFunctions[0]; } if (fieldNamePropertiesAmount === 2) { const fn0 = indexPartsFunctions[0]; const fn1 = indexPartsFunctions[1]; return (docData: RxDocumentData): string => fn0(docData) + fn1(docData); } if (fieldNamePropertiesAmount === 3) { const fn0 = indexPartsFunctions[0]; const fn1 = indexPartsFunctions[1]; const fn2 = indexPartsFunctions[2]; return (docData: RxDocumentData): string => fn0(docData) + fn1(docData) + fn2(docData); } const ret = function (docData: RxDocumentData): string { let str = ''; for (let i = 0; i < fieldNamePropertiesAmount; ++i) { str += indexPartsFunctions[i](docData); } return str; }; return ret; } declare type ParsedLengths = { minimum: number; maximum: number; nonDecimals: number; decimals: number; roundedMinimum: number; /** * Pre-computed Math.pow(10, decimals) to avoid * recomputing on every getNumberIndexString call. */ multiplier: number; }; export function getStringLengthOfIndexNumber( schemaPart: JsonSchema ): ParsedLengths { const minimum = Math.floor(schemaPart.minimum as number); const maximum = Math.ceil(schemaPart.maximum as number); const multipleOf: number = schemaPart.multipleOf as number; const valueSpan = maximum - minimum; const nonDecimals = valueSpan.toString().length; const multipleOfParts = multipleOf.toString().split('.'); let decimals = 0; if (multipleOfParts.length > 1) { decimals = multipleOfParts[1].length; } return { minimum, maximum, nonDecimals, decimals, roundedMinimum: minimum, multiplier: Math.pow(10, decimals) }; } export function getIndexStringLength( schema: RxJsonSchema>, index: string[] ): number { const fieldNameProperties = getIndexMeta(schema, index); let length = 0; fieldNameProperties.forEach(props => { const schemaPart = props.schemaPart; const type = schemaPart.type; if (type === 'string') { length += schemaPart.maxLength as number; } else if (type === 'boolean') { length += 1; } else { const parsedLengths = props.parsedLengths as ParsedLengths; length = length + parsedLengths.nonDecimals + parsedLengths.decimals; } }); return length; } export function getPrimaryKeyFromIndexableString( indexableString: string, primaryKeyLength: number ): string { const paddedPrimaryKey = indexableString.slice(primaryKeyLength * -1); // we can safely trim here because the primary key is not allowed to start or end with a space char. const primaryKey = paddedPrimaryKey.trim(); return primaryKey; } export function getNumberIndexString( parsedLengths: ParsedLengths, fieldValue: number ): string { /** * Ensure that the given value is in the boundaries * of the schema, otherwise it would create a broken index string. * This can happen for example if you have a minimum of 0 * and run a query like * selector { * numField: { $gt: -1000 } * } */ if (typeof fieldValue === 'undefined') { fieldValue = 0; } if (fieldValue < parsedLengths.minimum) { fieldValue = parsedLengths.minimum; } if (fieldValue > parsedLengths.maximum) { fieldValue = parsedLengths.maximum; } const nonDecimalsValueAsString = (Math.floor(fieldValue) - parsedLengths.roundedMinimum).toString(); let str = nonDecimalsValueAsString.padStart(parsedLengths.nonDecimals, '0'); if (parsedLengths.decimals > 0) { /** * @performance * Use math to extract decimal digits instead of toString().split('.') * which creates intermediate strings and arrays. * multiplier is pre-computed in ParsedLengths to avoid Math.pow() per call. */ const multiplier = parsedLengths.multiplier; const shifted = Math.min( Math.round((fieldValue - Math.floor(fieldValue)) * multiplier), multiplier - 1 ); const decimalPart = shifted.toString(); str += decimalPart.padStart(parsedLengths.decimals, '0'); } return str; } export function getStartIndexStringFromLowerBound( schema: RxJsonSchema, index: string[], lowerBound: (string | boolean | number | null | undefined)[] ): string { let str = ''; index.forEach((fieldName, idx) => { const schemaPart = getSchemaByObjectPath( schema, fieldName ); const bound = lowerBound[idx]; const type = schemaPart.type; switch (type) { case 'string': const maxLength = ensureNotFalsy(schemaPart.maxLength, 'maxLength not set'); if (typeof bound === 'string') { str += (bound as string).padEnd(maxLength, ' '); } else { // str += ''.padStart(maxLength, inclusiveStart ? ' ' : INDEX_MAX); str += ''.padEnd(maxLength, ' '); } break; case 'boolean': if (bound === null) { str += '0'; } else if (bound === INDEX_MIN) { str += '0'; } else if (bound === INDEX_MAX) { str += '1'; } else { const boolToStr = bound ? '1' : '0'; str += boolToStr; } break; case 'number': case 'integer': const parsedLengths = getStringLengthOfIndexNumber( schemaPart ); if (bound === null || bound === INDEX_MIN) { const fillChar = '0'; str += fillChar.repeat(parsedLengths.nonDecimals + parsedLengths.decimals); } else if (bound === INDEX_MAX) { str += getNumberIndexString( parsedLengths, parsedLengths.maximum ); } else { const add = getNumberIndexString( parsedLengths, bound as number ); str += add; } break; default: throw newRxError('CI2', { type: type as string }); } }); return str; } export function getStartIndexStringFromUpperBound( schema: RxJsonSchema, index: string[], upperBound: (string | boolean | number | null | undefined)[] ): string { let str = ''; index.forEach((fieldName, idx) => { const schemaPart = getSchemaByObjectPath( schema, fieldName ); const bound = upperBound[idx]; const type = schemaPart.type; switch (type) { case 'string': const maxLength = ensureNotFalsy(schemaPart.maxLength, 'maxLength not set'); if (typeof bound === 'string' && bound !== INDEX_MAX) { str += (bound as string).padEnd(maxLength, ' '); } else if (bound === INDEX_MIN) { str += ''.padEnd(maxLength, ' '); } else { str += ''.padEnd(maxLength, INDEX_MAX); } break; case 'boolean': if (bound === null || bound === INDEX_MAX) { str += '1'; } else if (bound === INDEX_MIN) { str += '0'; } else { const boolToStr = bound ? '1' : '0'; str += boolToStr; } break; case 'number': case 'integer': const parsedLengths = getStringLengthOfIndexNumber( schemaPart ); if (bound === null || bound === INDEX_MAX) { const fillChar = '9'; str += fillChar.repeat(parsedLengths.nonDecimals + parsedLengths.decimals); } else if (bound === INDEX_MIN) { const fillChar = '0'; str += fillChar.repeat(parsedLengths.nonDecimals + parsedLengths.decimals); } else { str += getNumberIndexString( parsedLengths, bound as number ); } break; default: throw newRxError('CI2', { type: type as string }); } }); return str; } /** * Used in storages where it is not possible * to define inclusiveEnd/inclusiveStart */ export function changeIndexableStringByOneQuantum(str: string, direction: 1 | -1): string { const lastChar = str.slice(-1); let charCode = lastChar.charCodeAt(0); charCode = charCode + direction; const withoutLastChar = str.slice(0, -1); return withoutLastChar + String.fromCharCode(charCode); }