UNPKG

@instantdb/core

Version:
536 lines (485 loc) • 15.1 kB
import { IContainEntitiesAndLinks, DataAttrDef, ValueTypes, } from './schemaTypes.ts'; import { validate as validateUUID } from 'uuid'; export class QueryValidationError extends Error { constructor(message: string, path?: string) { const fullMessage = path ? `At path '${path}': ${message}` : message; super(fullMessage); this.name = 'QueryValidationError'; } } const dollarSignKeys = [ 'where', 'order', 'limit', 'last', 'first', 'offset', 'after', 'afterInclusive', 'before', 'beforeInclusive', 'fields', 'aggregate', ]; type PossibleAttrTypes = ValueTypes | 'unknown'; const getAttrType = ( attrDef: DataAttrDef<any, any, any>, ): PossibleAttrTypes => { return attrDef.valueType || 'unknown'; }; const isValidValueForType = ( value: unknown, expectedType: PossibleAttrTypes, isAnyType: boolean = false, ): boolean => { if (isAnyType) return true; if (value === null || value === undefined) return true; switch (expectedType) { case 'string': return typeof value === 'string'; case 'number': return typeof value === 'number' && !isNaN(value); case 'boolean': return typeof value === 'boolean'; case 'date': return ( value instanceof Date || typeof value === 'string' || typeof value === 'number' ); default: return true; } }; const validateOperator = ( op: string, opValue: unknown, expectedType: PossibleAttrTypes, attrName: string, entityName: string, attrDef: DataAttrDef<any, any, any>, path: string, ) => { const isAnyType = attrDef.valueType === 'json'; const assertValidValue = ( op: string, expectedType: PossibleAttrTypes, opValue: unknown, ) => { if (!isValidValueForType(opValue, expectedType, isAnyType)) { throw new QueryValidationError( `Invalid value for operator '${op}' on attribute '${attrName}' in entity '${entityName}'. Expected ${expectedType}, but received: ${typeof opValue}`, path, ); } }; switch (op) { case 'in': case '$in': if (!Array.isArray(opValue)) { throw new QueryValidationError( `Operator '${op}' for attribute '${attrName}' in entity '${entityName}' must be an array, but received: ${typeof opValue}`, path, ); } for (const item of opValue) { assertValidValue(op, expectedType, item); } break; case '$not': case '$ne': case '$gt': case '$lt': case '$gte': case '$lte': assertValidValue(op, expectedType, opValue); break; case '$like': case '$ilike': assertValidValue(op, 'string', opValue); if (op === '$ilike') { if (!attrDef.isIndexed) { throw new QueryValidationError( `Operator '${op}' can only be used with indexed attributes, but '${attrName}' in entity '${entityName}' is not indexed`, path, ); } } break; case '$isNull': assertValidValue(op, 'boolean', opValue); break; default: throw new QueryValidationError( `Unknown operator '${op}' for attribute '${attrName}' in entity '${entityName}'`, path, ); } }; const validateWhereClauseValue = ( value: unknown, attrName: string, attrDef: DataAttrDef<any, any, any>, entityName: string, path: string, ): void => { const expectedType = getAttrType(attrDef); const isAnyType = attrDef.valueType === 'json'; const isComplexObject = typeof value === 'object' && value !== null && !Array.isArray(value); if (isComplexObject) { // For any type, allow complex objects without treating them as operators if (isAnyType) { return; // Any type accepts any value, including complex objects } const operators = value as Record<string, unknown>; for (const [op, opValue] of Object.entries(operators)) { validateOperator( op, opValue, expectedType, attrName, entityName, attrDef, `${path}.${op}`, ); } } else { if (!isValidValueForType(value, expectedType, isAnyType)) { throw new QueryValidationError( `Invalid value for attribute '${attrName}' in entity '${entityName}'. Expected ${expectedType}, but received: ${typeof value}`, path, ); } } }; const validateDotNotationAttribute = ( dotPath: string, value: unknown, startEntityName: string, schema: IContainEntitiesAndLinks<any, any>, path: string, ): void => { const pathParts = dotPath.split('.'); if (pathParts.length < 2) { throw new QueryValidationError( `Invalid dot notation path '${dotPath}'. Must contain at least one dot.`, path, ); } let currentEntityName = startEntityName; // Traverse all path parts except the last one (which should be an attribute) for (let i = 0; i < pathParts.length - 1; i++) { const linkName = pathParts[i]; const currentEntity = schema.entities[currentEntityName]; if (!currentEntity) { throw new QueryValidationError( `Entity '${currentEntityName}' does not exist in schema while traversing dot notation path '${dotPath}'.`, path, ); } const link = currentEntity.links[linkName]; if (!link) { const availableLinks = Object.keys(currentEntity.links); throw new QueryValidationError( `Link '${linkName}' does not exist on entity '${currentEntityName}' in dot notation path '${dotPath}'. Available links: ${availableLinks.length > 0 ? availableLinks.join(', ') : 'none'}`, path, ); } currentEntityName = link.entityName; } // Validate the final attribute const finalAttrName = pathParts[pathParts.length - 1]; const finalEntity = schema.entities[currentEntityName]; if (!finalEntity) { throw new QueryValidationError( `Target entity '${currentEntityName}' does not exist in schema for dot notation path '${dotPath}'.`, path, ); } // Handle 'id' field specially - every entity has an id field if (finalAttrName === 'id') { if (typeof value == 'string' && !validateUUID(value)) { throw new QueryValidationError( `Invalid value for id field in entity '${currentEntityName}'. Expected a UUID, but received: ${value}`, path, ); } validateWhereClauseValue( value, dotPath, new DataAttrDef('string', false, true), startEntityName, path, ); return; } const attrDef = finalEntity.attrs[finalAttrName]; if (Object.keys(finalEntity.links).includes(finalAttrName)) { if (typeof value === 'string' && !validateUUID(value)) { throw new QueryValidationError( `Invalid value for link '${finalAttrName}' in entity '${currentEntityName}'. Expected a UUID, but received: ${value}`, path, ); } validateWhereClauseValue( value, dotPath, new DataAttrDef('string', false, true), startEntityName, path, ); return; } if (!attrDef) { const availableAttrs = Object.keys(finalEntity.attrs); throw new QueryValidationError( `Attribute '${finalAttrName}' does not exist on entity '${currentEntityName}' in dot notation path '${dotPath}'. Available attributes: ${availableAttrs.length > 0 ? availableAttrs.join(', ') + ', id' : 'id'}`, path, ); } // Validate the value against the attribute type validateWhereClauseValue(value, dotPath, attrDef, startEntityName, path); }; const validateWhereClause = ( whereClause: Record<string, unknown>, entityName: string, schema: IContainEntitiesAndLinks<any, any>, path: string, ): void => { for (const [key, value] of Object.entries(whereClause)) { if (key === 'or' || key === 'and') { if (Array.isArray(value)) { for (const clause of value) { if (typeof clause === 'object' && clause !== null) { validateWhereClause( clause as Record<string, unknown>, entityName, schema, `${path}.${key}[${clause}]`, ); } } } continue; } if (key === 'id') { validateWhereClauseValue( value, 'id', new DataAttrDef('string', false, true), entityName, `${path}.id`, ); continue; } if (key.includes('.')) { validateDotNotationAttribute( key, value, entityName, schema, `${path}.${key}`, ); continue; } const entityDef = schema.entities[entityName]; if (!entityDef) continue; const attrDef = entityDef.attrs[key]; const linkDef = entityDef.links[key]; if (!attrDef && !linkDef) { const availableAttrs = Object.keys(entityDef.attrs); const availableLinks = Object.keys(entityDef.links); throw new QueryValidationError( `Attribute or link '${key}' does not exist on entity '${entityName}'. Available attributes: ${availableAttrs.length > 0 ? availableAttrs.join(', ') : 'none'}. Available links: ${availableLinks.length > 0 ? availableLinks.join(', ') : 'none'}`, `${path}.${key}`, ); } if (attrDef) { validateWhereClauseValue( value, key, attrDef, entityName, `${path}.${key}`, ); } else if (linkDef) { // For links, we expect the value to be a string (ID of the linked entity) // Create a synthetic string attribute definition for validation if (typeof value === 'string' && !validateUUID(value)) { throw new QueryValidationError( `Invalid value for link '${key}' in entity '${entityName}'. Expected a UUID, but received: ${value}`, `${path}.${key}`, ); } const syntheticAttrDef = new DataAttrDef('string', false, true); validateWhereClauseValue( value, key, syntheticAttrDef, entityName, `${path}.${key}`, ); } } }; const validateDollarObject = ( dollarObj: Record<string, unknown>, entityName: string, schema?: IContainEntitiesAndLinks<any, any> | null | undefined, path?: string, depth: number = 0, ): void => { for (const key of Object.keys(dollarObj)) { if (!dollarSignKeys.includes(key)) { throw new QueryValidationError( `Invalid query parameter '${key}' in $ object. Valid parameters are: ${dollarSignKeys.join(', ')}. Found: ${key}`, path, ); } } // Validate that pagination parameters are only used at top-level const paginationParams = [ // 'limit', // only supported client side 'offset', 'before', 'beforeInclusive', 'after', 'afterInclusive', 'first', 'last', ]; for (const param of paginationParams) { if (dollarObj[param] !== undefined && depth > 0) { throw new QueryValidationError( `'${param}' can only be used on top-level namespaces. It cannot be used in nested queries.`, path, ); } } if (dollarObj.where && schema) { if (typeof dollarObj.where !== 'object' || dollarObj.where === null) { throw new QueryValidationError( `'where' clause must be an object in entity '${entityName}', but received: ${typeof dollarObj.where}`, path ? `${path}.where` : undefined, ); } validateWhereClause( dollarObj.where as Record<string, unknown>, entityName, schema, path ? `${path}.where` : 'where', ); } }; const validateEntityInQuery = ( queryPart: Record<string, unknown>, entityName: string, schema: IContainEntitiesAndLinks<any, any> | null | undefined, path: string, depth: number = 0, ): void => { if (!queryPart || typeof queryPart !== 'object') { throw new QueryValidationError( `Query part for entity '${entityName}' must be an object, but received: ${typeof queryPart}`, path, ); } for (const key of Object.keys(queryPart)) { if (key !== '$') { // Validate link exists if (schema && !(key in schema.entities[entityName].links)) { const availableLinks = Object.keys(schema.entities[entityName].links); throw new QueryValidationError( `Link '${key}' does not exist on entity '${entityName}'. Available links: ${availableLinks.length > 0 ? availableLinks.join(', ') : 'none'}`, `${path}.${key}`, ); } // Recursively validate nested query const nestedQuery = queryPart[key]; if (typeof nestedQuery === 'object' && nestedQuery !== null) { const linkedEntityName = schema?.entities[entityName].links[key]?.entityName; if (linkedEntityName) { validateEntityInQuery( nestedQuery as Record<string, unknown>, linkedEntityName, schema, `${path}.${key}`, depth + 1, ); } } } else { // Validate $ object const dollarObj = queryPart[key]; if (typeof dollarObj !== 'object' || dollarObj === null) { throw new QueryValidationError( `Query parameter '$' must be an object in entity '${entityName}', but received: ${typeof dollarObj}`, `${path}.$`, ); } validateDollarObject( dollarObj as Record<string, unknown>, entityName, schema, `${path}.$`, depth, ); } } }; export const validateQuery = ( q: unknown, schema?: IContainEntitiesAndLinks<any, any>, ): void => { if (typeof q !== 'object' || q === null) { throw new QueryValidationError( `Query must be an object, but received: ${typeof q}${q === null ? ' (null)' : ''}`, ); } if (Array.isArray(q)) { throw new QueryValidationError( `Query must be an object, but received: ${typeof q}`, ); } const queryObj = q as Record<string, unknown>; for (const topLevelKey of Object.keys(queryObj)) { if (Array.isArray(q[topLevelKey])) { throw new QueryValidationError( `Query keys must be strings, but found key of type: ${typeof topLevelKey}`, topLevelKey, ); } if (typeof topLevelKey !== 'string') { throw new QueryValidationError( `Query keys must be strings, but found key of type: ${typeof topLevelKey}`, topLevelKey, ); } if (topLevelKey === '$$ruleParams') { continue; } // Check if the key is top level entity if (schema) { if (!schema.entities[topLevelKey]) { const availableEntities = Object.keys(schema.entities); throw new QueryValidationError( `Entity '${topLevelKey}' does not exist in schema. Available entities: ${availableEntities.length > 0 ? availableEntities.join(', ') : 'none'}`, topLevelKey, ); } } validateEntityInQuery( queryObj[topLevelKey] as Record<string, unknown>, topLevelKey, schema, topLevelKey, 0, // Start at depth 0 for top-level entities ); } };