UNPKG

@instantdb/core

Version:
288 lines • 13.8 kB
import { DataAttrDef, } from "./schemaTypes.js"; import { validate as validateUUID } from 'uuid'; export class QueryValidationError extends Error { constructor(message, path) { 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', ]; const getAttrType = (attrDef) => { return attrDef.valueType || 'unknown'; }; const isValidValueForType = (value, expectedType, isAnyType = false) => { 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, opValue, expectedType, attrName, entityName, attrDef, path) => { const isAnyType = attrDef.valueType === 'json'; const assertValidValue = (op, expectedType, opValue) => { 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, attrName, attrDef, entityName, path) => { 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; 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, value, startEntityName, schema, path) => { 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, entityName, schema, path) => { 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, 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, entityName, schema, path, depth = 0) => { 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, entityName, schema, path ? `${path}.where` : 'where'); } }; const validateEntityInQuery = (queryPart, entityName, schema, path, depth = 0) => { 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, 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, entityName, schema, `${path}.$`, depth); } } }; export const validateQuery = (q, schema) => { 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; 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], topLevelKey, schema, topLevelKey, 0); } }; //# sourceMappingURL=queryValidation.js.map