UNPKG

wizard-ql

Version:

WizardQL is a natural-language-like query language for constructing data queries for resources that meet conditions.

1,103 lines (1,098 loc) 42.5 kB
// src/spec.ts var OPERATION_ALIAS_DICTIONARY = { AND: "AND", "&&": "AND", "&": "AND", "^": "AND", OR: "OR", "||": "OR", "|": "OR", V: "OR", GEQ: "GEQ", ">=": "GEQ", "=>": "GEQ", LEQ: "LEQ", "<=": "LEQ", "=<": "LEQ", NOTEQUALS: "NOTEQUAL", NOTEQUAL: "NOTEQUAL", NEQ: "NOTEQUAL", ISNT: "NOTEQUAL", "!==": "NOTEQUAL", "!=": "NOTEQUAL", EQUALS: "EQUAL", EQUAL: "EQUAL", EQ: "EQUAL", IS: "EQUAL", "==": "EQUAL", "=": "EQUAL", LESS: "LESS", "<": "LESS", GREATER: "GREATER", ">": "GREATER", MORE: "GREATER", NOTIN: "NOTIN", "!:": "NOTIN", IN: "IN", ":": "IN", NOTMATCHES: "NOTMATCH", NOTMATCH: "NOTMATCH", "!~": "NOTMATCH", MATCHES: "MATCH", MATCH: "MATCH", "~": "MATCH" }; var ALIASES = Object.keys(OPERATION_ALIAS_DICTIONARY); var OPERATION_PURPOSE_DICTIONARY = { AND: "junction", OR: "junction", EQUAL: "comparison", NOTEQUAL: "comparison", LESS: "comparison", GREATER: "comparison", GEQ: "comparison", LEQ: "comparison", IN: "comparison", NOTIN: "comparison", MATCH: "comparison", NOTMATCH: "comparison" }; var COMPARISON_TYPE_DICTIONARY = { EQUAL: "primitive", NOTEQUAL: "primitive", GEQ: "numeric", GREATER: "numeric", LEQ: "numeric", LESS: "numeric", IN: "array", NOTIN: "array", MATCH: "string", NOTMATCH: "string" }; var TYPE_PRIORITY = ["boolean", "date", "number", "string"]; // src/regex.ts var ESCAPE_REGEX = "(?<=(?<!\\\\)(?:\\\\\\\\)*)"; var QUOTES = ["'", '"', "`"]; var QUOTE_REGEX_STR = `${ESCAPE_REGEX}(?<quote>${QUOTES.map((q) => RegExp.escape(q)).join("|")})(?<quotecontent>.*?)${ESCAPE_REGEX}\\k<quote>`; // src/errors.ts function constructHeader(startToken, startIndex, endToken, endIndex) { let header = "Token #"; header += startIndex === undefined ? "??" : startIndex; if (endIndex !== undefined && endIndex !== startIndex) header += " -> #" + endIndex; if (startToken) { header += " (char "; header += startToken.index; if (endToken && endToken !== startToken) { header += " -> " + endToken.index; } header += ' "'; header += startToken.content.replaceAll('"', "\\\""); if (endToken && endToken !== startToken) { header += '" -> "' + endToken.content.replaceAll('"', "\\\""); } header += '"'; header += ")"; } header += ": "; return header; } class ParseError extends Error { name = "ParseError"; rawMessage; startToken; endToken; startIndex; endIndex; constructor(message, startToken, startIndex, endToken = startToken, endIndex = startIndex) { const header = constructHeader(startToken, startIndex, endToken, endIndex); super(header + message); this.rawMessage = message; this.startToken = startToken; this.endToken = endToken; this.startIndex = startIndex; this.endIndex = endIndex; } } class ConstraintError extends Error { name = "ConstraintError"; rawMessage; startToken; endToken; startIndex; endIndex; constructor(message, startToken, startIndex, endToken = startToken, endIndex = startIndex) { const header = constructHeader(startToken, startIndex, endToken, endIndex); super(header + message); this.rawMessage = message; this.startToken = startToken; this.endToken = endToken; this.startIndex = startIndex; this.endIndex = endIndex; } } // src/parse.ts var TOKEN_REGEX = new RegExp("(?<=(?<!\\\\)(?:\\\\\\\\)*)(?<quote>\\x27|\\x22|\\x60)(?<quotecontent>.*?)(?<=(?<!\\\\)(?:\\\\\\\\)*)\\k<quote>|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x41ND(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x26\\x26|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x26|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\^|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x4fR(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\|\\||(?<=(?<!\\\\)(?:\\\\\\\\)*)\\||(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x56(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x47EQ(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x3e\\x3d|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x3d\\x3e|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x4cEQ(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x3c\\x3d|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x3d\\x3c|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x4eOTEQUALS(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x4eOTEQUAL(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x4eEQ(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x49SNT(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x21\\x3d\\x3d|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x21\\x3d|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x45QUALS(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x45QUAL(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x45Q(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x49S(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x3d\\x3d|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x3d|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x4cESS(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x3c|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x47REATER(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x3e|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x4dORE(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x4eOTIN(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x21\\x3a|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x49N(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x3a|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x4eOTMATCHES(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x4eOTMATCH(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x21\\x7e|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x4dATCHES(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|^)\\x4dATCH(?=(?<=(?<!\\\\)(?:\\\\\\\\)*)\\s|$)|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x7e|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\(|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\)|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\[|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\]|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\{|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\}|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x2c|(?<=(?<!\\\\)(?:\\\\\\\\)*)\\x21", "g"); var QUOTE_REGEX = new RegExp("(?<=(?<!\\\\)(?:\\\\\\\\)*)(?<quote>\\x27|\\x22|\\x60)(?<quotecontent>.*?)(?<=(?<!\\\\)(?:\\\\\\\\)*)\\k<quote>"); var QUOTE_EDGE_REGEX = new RegExp(`^${"(?<=(?<!\\\\)(?:\\\\\\\\)*)(?<quote>\\x27|\\x22|\\x60)(?<quotecontent>.*?)(?<=(?<!\\\\)(?:\\\\\\\\)*)\\k<quote>"}$`); function pushSanitized(array, item, index) { let trimmed = item.trimEnd(); const pretrimLength = trimmed.length; trimmed = trimmed.trimStart(); const lengthDiff = trimmed.length - pretrimLength; if (trimmed) { const token = { content: trimmed, index: index - lengthDiff }; array.push(token); return token; } } function _tokenize(expression, pattern) { const tokens = []; const matches = expression.toUpperCase().matchAll(pattern); let lastMatchEnd = null; for (const match of matches) { pushSanitized(tokens, expression.slice(lastMatchEnd ?? 0, match.index), lastMatchEnd === null ? 0 : lastMatchEnd); if (["[", "{"].includes(match[0])) { const startToken = { content: match[0], index: match.index }; tokens.push(startToken); let endToken; for (const submatch of matches) { if (match[0] === "[" && submatch[0] === "]" || match[0] === "{" && submatch[0] === "}") { endToken = { content: submatch[0], index: submatch.index }; break; } } const subtokens = endToken ? _tokenize(expression.slice(startToken.index + startToken.content.length, endToken.index), new RegExp(`${"(?<=(?<!\\\\)(?:\\\\\\\\)*)(?<quote>\\x27|\\x22|\\x60)(?<quotecontent>.*?)(?<=(?<!\\\\)(?:\\\\\\\\)*)\\k<quote>"}|${ESCAPE_REGEX},`, "g")) : _tokenize(expression.slice(startToken.index + startToken.content.length), TOKEN_REGEX); for (const subtoken of subtokens) subtoken.index += match.index + 1; tokens.push(...subtokens); if (endToken) { tokens.push(endToken); lastMatchEnd = endToken.index + endToken.content.length; } else { lastMatchEnd = expression.length; break; } continue; } else { pushSanitized(tokens, match.groups?.quotecontent !== undefined ? expression.slice(match.index, match.index + match[0].length) : match[0], match.index); } lastMatchEnd = match.index + match[0].length; } pushSanitized(tokens, expression.slice(lastMatchEnd ?? 0), lastMatchEnd ?? 0); return tokens; } function tokenize(expression) { return _tokenize(expression, TOKEN_REGEX); } function processToken(token) { const unquoted = token.match(QUOTE_EDGE_REGEX)?.groups?.quotecontent; const escaped = unquoted ?? token; const unescaped = escaped.replaceAll(new RegExp(`(?<!${ESCAPE_REGEX}\\\\)\\\\`, "g"), ""); return { unquoted, escaped, unescaped }; } function getClosingIndex(tokens, start, opening, closing) { let openingCount = 1; for (let index = start + 1;index < tokens.length; ++index) { switch (tokens[index].content) { case opening: ++openingCount; break; case closing: --openingCount; break; } if (openingCount === 0) return index; } return -1; } function complementExpression(expression) { switch (expression.type) { case "group": switch (expression.operation) { case "AND": expression.operation = "OR"; break; case "OR": expression.operation = "AND"; break; } expression.constituents.forEach(complementExpression); break; case "condition": switch (expression.operation) { case "EQUAL": expression.operation = "NOTEQUAL"; break; case "NOTEQUAL": expression.operation = "EQUAL"; break; case "GEQ": expression.operation = "LESS"; break; case "LESS": expression.operation = "GEQ"; break; case "LEQ": expression.operation = "GREATER"; break; case "GREATER": expression.operation = "LEQ"; break; case "IN": expression.operation = "NOTIN"; break; case "NOTIN": expression.operation = "IN"; break; case "MATCH": expression.operation = "NOTMATCH"; break; case "NOTMATCH": expression.operation = "MATCH"; break; } break; } } function coerceType(value, fieldTypes, operatorTypes, dateInterpreter) { const { unescaped } = processToken(value); let operatorHits = 0; let fieldHits = 0; for (const type of TYPE_PRIORITY) { const operatorHit = operatorTypes.includes(type); const fieldHit = fieldTypes.includes(type); if (operatorHit) ++operatorHits; if (fieldHit) ++fieldHits; if (!operatorHit || !fieldHit) continue; switch (type) { case "boolean": if (value === "true") return true; else if (value === "false") return false; break; case "number": { const num = Number(value); if (isNaN(num)) break; return num; } case "string": return unescaped; case "date": { const num = Number(value); const date = dateInterpreter(isNaN(num) ? unescaped : num); if (isNaN(+date)) break; return date; } } if (operatorTypes.includes(type)) --operatorHits; if (fieldTypes.includes(type)) --fieldHits; } if (!operatorHits) throw new Error("operator"); if (!fieldHits) throw new Error("field"); throw new Error("operator"); } function validateCondition(condition, constraints, valueIsImplicit, ctx) { let validated = false; const field = constraints?.caseInsensitive ? [...Object.keys(constraints.types ?? {}), ...Object.keys(constraints.restricted ?? {})].find((k) => k.toLowerCase() === condition.field.toLowerCase()) ?? condition.field : condition.field; const restriction = constraints?.restricted?.[field]; const type = constraints?.types?.[field]; if (constraints?.disallowUnvalidated && restriction === undefined && type === undefined) throw new ConstraintError(`Unknown field "${condition.field}"`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex); const operationType = COMPARISON_TYPE_DICTIONARY[condition.operation]; const types = type && (Array.isArray(type) ? type : [type]); const values = Array.isArray(condition.value) ? condition.value : [condition.value]; if (restriction === true) throw new ConstraintError(`Field "${condition.field}" is restricted`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex); else if (Array.isArray(restriction)) { const [philosophy, checks] = restriction; if (philosophy === "deny") { for (const check of checks) { if (check instanceof RegExp) { if (values.some((v) => check.test(v.toString()))) { throw new ConstraintError(`Value for field "${condition.field}" violates prohibitive pattern constraint "${check.toString()}". Prohibited values/patterns: Allowed values/patterns: ${checks.join(", ")}`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex); } } else { if (values.includes(check)) { throw new ConstraintError(`Forbidden value "${check}" for field "${condition.field}". Prohibited values/patterns: ${checks.join(", ")}`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex); } } } } else { for (const value of values) { if (!checks.some((c) => c instanceof RegExp && c.test(value.toString()) || c === value)) { throw new ConstraintError(`Value for field "${condition.field}" does not meet any allowed value/pattern. Allowed values/patterns: ${checks.join(", ")}`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex); } } } validated = true; } if (operationType === "array" && !Array.isArray(condition.value)) throw new ConstraintError(`Value "${condition.value.toString()}" is not permitted for "${condition.operation}" which expects an array`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex); else if (operationType !== "array" && Array.isArray(condition.value)) throw new ConstraintError(`Value "${condition.value.toString()}" is not permitted for "${condition.operation}" which expects a non-array value`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex); const dateInterpreter = constraints?.dateInterpreter ?? ((v) => new Date(v)); for (let i = 0;i < values.length; ++i) { const v = values[i]; let opTypes; if (valueIsImplicit) opTypes = ["boolean"]; else { switch (operationType) { case "primitive": case "array": opTypes = ["boolean", "number", "string"]; if (types?.includes("date")) opTypes.push("date"); break; case "numeric": opTypes = ["number"]; if (types?.includes("date")) opTypes.push("date"); break; case "string": opTypes = ["string"]; break; } } try { const value = coerceType(v, types ?? opTypes, opTypes, dateInterpreter); values[i] = value; if (!Array.isArray(condition.value)) condition.value = value; } catch (err) { switch (err.message) { case "operator": throw new ConstraintError(`Value "${condition.value.toString()}" not allowed for operation "${condition.operation}" which only allows for "${operationType}" type`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex); case "field": throw new ConstraintError(`Value "${condition.value.toString()}" includes a type not permitted for field "${condition.field}". Allowed types: ${(types ?? opTypes).join(", ")}`, ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex); } } } if (types) validated = true; const edit = condition; edit.field = field; edit.validated = validated; return edit; } function _parse(tokens, _offset, constraints) { let field; let comparisonOperation; let value; let inConjunction = false; let groupOperation; let expectingExpression = true; const expressions = []; function getExpressionGroup(ctx) { if (inConjunction) { const prior = expressions.at(-1); if (!prior) throw new ParseError("Unexpected: Expression list empty when parser is meant to append to an AND group", ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex); if (prior.type !== "group" || prior.operation !== "AND") throw new ParseError("Unexpected: Last expression is not an AND group yet parser thinks it's appending to one", ctx?.startToken, ctx?.startIndex, ctx?.endToken, ctx?.endIndex); return prior.constituents; } return expressions; } function resolveCondition(ctx) { const baseCtx = { startToken: field?.token ?? comparisonOperation?.token ?? value?.token, startIndex: field?.index ?? comparisonOperation?.index ?? value?.index, endToken: value?.token ?? comparisonOperation?.token ?? field?.token, endIndex: value?.index ?? comparisonOperation?.index ?? field?.index }; if (!ctx) ctx = baseCtx; const group = getExpressionGroup(ctx); if (field && comparisonOperation && value) { if (!expectingExpression) throw new ParseError("Unexpected expression resolution before junctive operator", ctx.startToken, ctx.startIndex, ctx.endToken, ctx.endIndex); group.push(validateCondition({ type: "condition", field: field.content, operation: comparisonOperation.content, value: value.content }, constraints, value.implicit, baseCtx)); inConjunction = false; expectingExpression = false; } else if (field && !comparisonOperation && !value) { if (!expectingExpression) throw new ParseError("Unexpected expression resolution before junctive operator", ctx.startToken, ctx.startIndex, ctx.endToken, ctx.endIndex); group.push(validateCondition({ type: "condition", field: field.content, operation: "EQUAL", value: "true" }, constraints, true, baseCtx)); inConjunction = false; expectingExpression = false; } else if (field || comparisonOperation || value !== undefined) throw new ParseError("Failed to resolve condition; missing operand or operator", ctx.startToken, ctx.startIndex, ctx.endToken, ctx.endIndex); field = undefined; comparisonOperation = undefined; value = undefined; } for (let t = 0;t < tokens.length; ++t) { const token = tokens[t]; if (token.content === ")") throw new ParseError("Unexpected closing parenthesis", token, _offset + t); if (["]", "}"].includes(token.content)) throw new ParseError("Unexpected closing bracket/brace", token, _offset + t); if (token.content === "(") { if (field || comparisonOperation || value) throw new ParseError("Tried to open a group during an operation", token, _offset + t); const closingIndex = getClosingIndex(tokens, t, "(", ")"); if (closingIndex === -1) throw new ParseError("Missing closing parenthesis for group", token, _offset + t); ++t; const subExpression = _parse(tokens.slice(t, closingIndex), _offset + t, constraints); if (subExpression) { if (subExpression.type === "group" && subExpression.operation === groupOperation) expressions.push(...subExpression.constituents); else { const group = getExpressionGroup({ startToken: token, startIndex: _offset + t }); group.push(subExpression); inConjunction = false; } } t = closingIndex; continue; } const op = OPERATION_ALIAS_DICTIONARY[token.content]; if (op && OPERATION_PURPOSE_DICTIONARY[op] === "junction") { resolveCondition({ startToken: field?.token ?? token, startIndex: field?.index ?? _offset + t, endToken: token, endIndex: _offset + t }); const prior = expressions.at(-1); if (!prior) throw new ParseError("Unexpected junction operator with no preceding expression", token, _offset + t); expectingExpression = true; if (groupOperation && groupOperation !== op) { if (expressions.length < 2) throw new ParseError("Unexpected junction operator with no preceding expression", token, _offset + t); switch (groupOperation) { case "AND": { const futureSubgroup = _parse(tokens.slice(t + 1), _offset + t, constraints); if (futureSubgroup === null) throw new ParseError("Dangling junction operator", token, _offset + t); return { type: "group", operation: "OR", constituents: [ { type: "group", operation: "AND", constituents: expressions }, futureSubgroup ] }; } case "OR": inConjunction = true; if (prior.type === "group" && prior.operation === "AND") continue; expressions.splice(-1, 1); expressions.push({ type: "group", operation: "AND", constituents: [ prior ] }); continue; } } groupOperation = op; if (expressions.length === 1 && expressions[0]?.type === "group" && expressions[0].operation === groupOperation) { const exp = expressions[0]; expressions.splice(0, 1); expressions.push(...exp.constituents); } continue; } if (token.content === "!") { const nextToken = tokens[t + 1]; if (nextToken?.content === "(") { resolveCondition({ startToken: field?.token ?? token, startIndex: field?.index ?? _offset + t, endToken: value?.token ?? comparisonOperation?.token ?? field?.token, endIndex: value?.index ?? comparisonOperation?.index ?? field?.index }); ++t; const closingIndex = getClosingIndex(tokens, t, "(", ")"); if (closingIndex === -1) throw new ParseError("Missing closing parenthesis for group", token, _offset + t); ++t; const futureSubExpression = _parse(tokens.slice(t, closingIndex), _offset + t, constraints); if (futureSubExpression) { complementExpression(futureSubExpression); if (futureSubExpression.type === "group" && futureSubExpression.operation === groupOperation) expressions.push(...futureSubExpression.constituents); else { const group = getExpressionGroup({ startToken: token, startIndex: _offset + t, endToken: tokens[closingIndex], endIndex: closingIndex }); group.push(futureSubExpression); inConjunction = false; } } t = closingIndex; continue; } } if (!comparisonOperation || op && OPERATION_PURPOSE_DICTIONARY[op] === "comparison") { if (op && OPERATION_PURPOSE_DICTIONARY[op] === "comparison") { if (comparisonOperation || !field) throw new ParseError("Unexpected comparison operator", field?.token ?? token, field?.index ?? _offset + t, token, _offset + t); comparisonOperation = { content: op, token, index: _offset + t }; continue; } else if (field) throw new ParseError("Expected a comparison operator", field.token, field.index, token, _offset + t); } if (!field) { if (token.content === "!") { const nextToken = tokens[t + 1]; if (!nextToken) throw new ParseError('Unexpected "!"', token, _offset + t); resolveCondition({ startToken: token, startIndex: _offset + t, endToken: nextToken, endIndex: _offset + t + 1 }); ++t; field = { content: processToken(nextToken.content).unescaped, token, index: _offset + t }; comparisonOperation = { content: "EQUAL" }; value = { content: "false", implicit: true }; resolveCondition({ startToken: field.token, startIndex: field.index, endToken: nextToken, endIndex: _offset + t }); } else { field = { content: processToken(token.content).unescaped, token, index: _offset + t }; } continue; } if (!value) { if (["[", "{"].includes(token.content)) { let resolveEntry = function(subtoken, subindex) { if (workingEntry) { const { unquoted: unquotedWorkingEntry } = processToken(workingEntry); const subquotes = workingEntry.match(QUOTE_REGEX); if (subquotes && (subquotes.index !== 0 || subquotes[0].length !== workingEntry.length)) throw new ParseError("Quotes must surround entire values in arrays", firstEntryToken ?? subtoken, firstEntryTokenIndex ?? subindex, lastEntryToken ?? subtoken, lastEntryTokenIndex ?? subindex); if (!unquotedWorkingEntry && (token.content === "[" && new RegExp(`${ESCAPE_REGEX}(?:\\[|\\])`).test(workingEntry) || token.content === "{" && new RegExp(`${ESCAPE_REGEX}(?:\\{|\\})`).test(workingEntry))) throw new ParseError("Unescaped bracket in an array value", firstEntryToken ?? subtoken, firstEntryTokenIndex ?? subindex, lastEntryToken ?? subtoken, lastEntryTokenIndex ?? subindex); arr.push(workingEntry); workingEntry = ""; firstEntryToken = undefined; firstEntryTokenIndex = undefined; lastEntryToken = undefined; lastEntryTokenIndex = undefined; } }; const closingIndex = getClosingIndex(tokens, t, token.content, token.content === "[" ? "]" : "}"); if (closingIndex === -1) throw new ParseError("Missing closing bracket/brace for array value", token, _offset + t); ++t; const arr = []; value = { content: arr, token: tokens[closingIndex], index: closingIndex }; const arrayContents = tokens.slice(t, closingIndex); let workingEntry = ""; let firstEntryToken; let firstEntryTokenIndex; let lastEntryToken; let lastEntryTokenIndex; for (let ct = 0;ct < arrayContents.length; ++ct) { const contentToken = arrayContents[ct]; if (contentToken.content === ",") { if (!workingEntry) throw new ParseError("Unexpected blank entry in array", contentToken, _offset + t + ct); resolveEntry(contentToken, _offset + t + ct); } else { lastEntryToken = contentToken; lastEntryTokenIndex = _offset + t + ct; if (!firstEntryToken) firstEntryToken = lastEntryToken; if (!firstEntryTokenIndex) firstEntryTokenIndex = lastEntryTokenIndex; workingEntry += contentToken.content; } } resolveEntry(arrayContents.at(-1), _offset + t + arrayContents.length - 1); if (!arr.length) { throw new ParseError("Empty array provided as value", token, _offset + t - 1, tokens[closingIndex], _offset + closingIndex); } t = closingIndex; } else { value = { content: token.content, token, index: _offset + t }; } resolveCondition({ startToken: field.token, startIndex: field.index, endToken: value.token ?? comparisonOperation?.token ?? field.token, endIndex: value.index ?? comparisonOperation?.index ?? field.index }); } } resolveCondition({ startToken: field?.token, startIndex: field?.index, endToken: value?.token ?? comparisonOperation?.token ?? field?.token, endIndex: value?.index ?? comparisonOperation?.index ?? field?.index }); if (inConjunction) throw new ParseError("Dangling junction operator", tokens.at(-1), _offset + tokens.length - 1); if (groupOperation) { if (expressions.length === 1) throw new ParseError("Dangling junction operator", tokens.at(-1), _offset + tokens.length - 1); return { type: "group", operation: groupOperation, constituents: expressions }; } else if (expressions.length > 1) throw new ParseError("Group possesses multiple conditions without disjunctive operators", tokens[0], _offset); else return expressions[0] ?? null; } function parse(expression, constraints) { let tokens; if (Array.isArray(expression)) { tokens = []; let type; for (let t = 0;t < expression.length; ++t) { const token = expression[t]; if (!type) type = typeof token; if (typeof token !== type) console.warn("WizardQL: parse was called with a mixed array of string tokens and token objects"); tokens.push(typeof token === "string" ? { content: token, index: type === "string" ? t : -1 } : token); } } else tokens = tokenize(expression); return _parse(tokens, 0, constraints); } // src/execute.ts function executeAsKnex(query, expression) { switch (expression.type) { case "group": { let firstHappened = false; for (const constituent of expression.constituents) { query[firstHappened ? expression.operation === "AND" ? "andWhere" : "orWhere" : "where"]((clause) => executeAsKnex(clause, constituent)); firstHappened = true; } break; } case "condition": switch (expression.operation) { case "EQUAL": query.where(expression.field, "=", expression.value); break; case "NOTEQUAL": query.where(expression.field, "!=", expression.value); break; case "GEQ": query.where(expression.field, ">=", expression.value); break; case "LEQ": query.where(expression.field, "<=", expression.value); break; case "GREATER": query.where(expression.field, ">", expression.value); break; case "LESS": query.where(expression.field, "<", expression.value); break; case "IN": query.whereIn(expression.field, expression.value); break; case "NOTIN": query.whereNotIn(expression.field, expression.value); break; case "MATCH": query.whereRaw("?? ~* ?", [expression.field, expression.value]); break; case "NOTMATCH": query.whereRaw("?? !~* ?", [expression.field, expression.value]); break; } } } // src/summarize.ts function getOrPutArrayInMap(map, key) { const existingEntry = map.get(key); if (existingEntry) return existingEntry; else { const arr = []; map.set(key, arr); return arr; } } function summarize(expressions) { const array = Array.isArray(expressions) ? expressions : [expressions]; const summary = new Map; for (const expression of array) { if (expression.type === "group") { const constituents = summarize(expression.constituents); for (const [field, values] of constituents.entries()) { const collection = getOrPutArrayInMap(summary, field); collection.push(...values); } } else { const collection = getOrPutArrayInMap(summary, expression.field); collection.push({ operation: expression.operation, value: expression.value, exclusionary: ["NOTEQUAL", "LESS", "GREATER", "NOTIN"].includes(expression.operation) }); } } return summary; } // src/stringify.ts var formats = { programmatic: { AND: "&", OR: "|", EQUAL: "=", NOTEQUAL: "!=", GEQ: ">=", GREATER: ">", LEQ: "<=", LESS: "<", IN: ":", NOTIN: "!:", MATCH: "~", NOTMATCH: "!~" }, linguistic: { AND: "AND", OR: "OR", EQUAL: "EQUALS", NOTEQUAL: "NOTEQUALS", GEQ: "GEQ", GREATER: "GREATER", LEQ: "LEQ", LESS: "LESS", IN: "IN", NOTIN: "NOTIN", MATCH: "MATCHES", NOTMATCH: "NOTMATCHES" }, formal: { AND: "^", OR: "V" } }; function addQuotesIfNecessary(value) { if (typeof value !== "string") return value.toString(); if (value === "true") return '"true"'; if (value === "false") return '"false"'; if (!isNaN(Number(value))) return `"${value}"`; const escaped = value.replaceAll("\\", "\\\\"); if (new RegExp(TOKEN_REGEX, "gi").test(escaped)) return `"${escaped.replaceAll('"', "\\\"")}"`; return escaped; } function stringify(expression, opts = {}) { const { junctionNotation = "programmatic", comparisonNotation = "programmatic", alwaysParenthesize = false, compact = false, condenseBooleans = false } = opts; let string = ""; switch (expression.type) { case "group": for (const constituent of expression.constituents) { if (string.length) { if (!compact || junctionNotation === "linguistic") string += " "; string += formats[junctionNotation][expression.operation]; if (!compact || junctionNotation === "linguistic") string += " "; } if (alwaysParenthesize && constituent.type === "group" || expression.operation === "AND" && constituent.operation === "OR") string += "("; string += stringify(constituent, opts); if (alwaysParenthesize && constituent.type === "group" || expression.operation === "AND" && constituent.operation === "OR") string += ")"; } break; case "condition": if (condenseBooleans && ["EQUAL", "NOTEQUAL"].includes(expression.operation) && typeof expression.value === "boolean") { const negative = expression.operation === "EQUAL" && !expression.value || expression.operation === "NOTEQUAL" && expression.value; string += `${negative ? "!" : ""}${expression.field}`; } else { string += expression.field; if (!compact || comparisonNotation === "linguistic") string += " "; string += formats[comparisonNotation][expression.operation]; if (!compact || comparisonNotation === "linguistic") string += " "; if (Array.isArray(expression.value)) { const join = expression.value.map(addQuotesIfNecessary).join(compact ? "," : ", "); string += `[${join}]`; } else string += addQuotesIfNecessary(expression.value); } break; } return string; } // src/dominput.ts function getCursorIndex(element) { const selection = window.getSelection(); const cursorNode = selection?.anchorNode; const cursorOffset = selection?.anchorOffset; if (!cursorNode || !element.contains(cursorNode)) return -1; let absoluteIndex = 0; if (cursorOffset) { for (const node of element.childNodes) { if (node.contains(cursorNode)) { absoluteIndex += (node.textContent?.length ?? 0) - (cursorNode.textContent?.length ?? 0); absoluteIndex += cursorOffset; break; } else absoluteIndex += node.textContent?.length ?? 0; } } return absoluteIndex; } function setCursor(element, index) { const selection = window.getSelection(); let accumulated = 0; for (const node of element.childNodes) { const length = node.textContent?.length ?? 0; if (accumulated + length >= index) { selection?.setPosition(index > 0 ? node.childNodes.item(0) : node, index - accumulated); break; } else accumulated += length; } } function createDOMInput({ input, constraints, onUpdate, parseOnInitialize }) { const history = []; let historyIndex = -1; let savedCursor; function update() { const focused = input.contains(document.activeElement); const text = input.textContent.replaceAll(` `, ""); const endPadding = input.textContent?.match(/\s*$/)?.[0].length ?? 0; const newTokens = tokenize(text); const lastToken = newTokens.at(-1); if (lastToken && lastToken.content in OPERATION_ALIAS_DICTIONARY && focused && (!endPadding && lastToken.content.match(/^[A-Za-z]+?$/))) return; const absoluteIndex = focused ? getCursorIndex(input) : 0; observer.disconnect(); let inArray; let offset = 0; for (let t = 0;t < newTokens.length; ++t) { const token = newTokens[t]; const prior = newTokens[t - 1]; const differenceFromLast = prior ? token.index - (prior.index + prior.content.length) : token.index; if (differenceFromLast > 0) { const spacer = document.createElement("span"); spacer.textContent = " ".repeat(differenceFromLast); spacer.classList.add("whitespace-pre"); spacer.toggleAttribute("data-spacer", true); const existing2 = input.childNodes.item(t + offset); if (existing2) input.replaceChild(spacer, existing2); else input.appendChild(spacer); ++offset; } const element = document.createElement("span"); element.textContent = token.content; element.toggleAttribute("data-node", true); if (token.content.match(QUOTE_EDGE_REGEX)) element.toggleAttribute("data-quoted", true); if (!isNaN(Number(token.content))) element.toggleAttribute("data-number", true); if (["(", ")", "[", "]", "{", "}"].includes(token.content)) { if (inArray === "[" && token.content === "]" || inArray === "{" && token.content === "}") inArray = undefined; if (!inArray) element.setAttribute("data-bracket", token.content); if (!inArray && ["[", "{"].includes(token.content)) inArray = token.content; } if (inArray && token.content === ",") element.toggleAttribute("data-delimiter", true); if (!inArray && token.content === "!") element.toggleAttribute("data-negation", true); if (!inArray && token.content in OPERATION_ALIAS_DICTIONARY) { element.setAttribute("data-operation", OPERATION_PURPOSE_DICTIONARY[OPERATION_ALIAS_DICTIONARY[token.content]]); } const existing = input.childNodes.item(t + offset); if (existing) input.replaceChild(element, existing); else input.appendChild(element); } while (newTokens.length + offset < input.childNodes.length) input.lastChild?.remove(); const last = input.lastElementChild; if (last?.tagName === "BR") last.remove(); if (endPadding) { const spacer = document.createElement("span"); spacer.textContent = " ".repeat(endPadding); spacer.classList.add("whitespace-pre"); spacer.toggleAttribute("data-spacer", true); input.appendChild(spacer); } observer.observe(input, { characterData: true, childList: true, subtree: true }); if (focused) setCursor(input, savedCursor ?? absoluteIndex); savedCursor = undefined; if (input.textContent && input.textContent !== history[historyIndex]?.text) { ++historyIndex; history.splice(historyIndex, history.length - historyIndex, { text: input.textContent, cursor: absoluteIndex }); } let result; try { result = parse(newTokens, constraints); input.removeAttribute("data-error-message"); input.removeAttribute("data-error-start"); input.removeAttribute("data-error-end"); } catch (err) { if (err instanceof ParseError || err instanceof ConstraintError) { result = err; input.setAttribute("data-error-message", err.rawMessage); input.setAttribute("data-error-start", err.startIndex.toString()); input.setAttribute("data-error-end", err.endIndex.toString()); const nodes = input.querySelectorAll("[data-node]"); if (!nodes.length) return; for (let n = 0;n < nodes.length; ++n) { const node = nodes.item(n); node.toggleAttribute("data-error", n >= err.startIndex && n <= err.endIndex); } } else { result = null; if (err instanceof Error) input.setAttribute("data-error-message", err.message); input.removeAttribute("data-error-start"); input.removeAttribute("data-error-end"); } } onUpdate?.(result, newTokens, text); } const observer = new MutationObserver(update); function onKey(e) { if (e.key === "Enter" || e.key === "Escape") { e.stopPropagation(); e.preventDefault(); input.blur(); } else if (e.key === "z" && (e.metaKey || e.ctrlKey)) { e.stopPropagation(); e.preventDefault(); historyIndex = Math.min(history.length - 1, Math.max(0, historyIndex + (e.shiftKey ? 1 : -1))); const prior = history[historyIndex]; if (prior !== undefined) { savedCursor = prior.cursor; input.replaceChildren(prior.text); } } } input.setAttribute("role", "textbox"); input.setAttribute("contenteditable", "plaintext-only"); input.setAttribute("spellcheck", "false"); input.addEventListener("keydown", onKey); input.addEventListener("blur", update, { passive: true }); observer.observe(input, { characterData: true, childList: true, subtree: true }); if (parseOnInitialize) update(); return () => { observer.disconnect(); input.removeEventListener("keydown", onKey); input.removeEventListener("blur", update); }; } export { tokenize, summarize, stringify, parse, executeAsKnex, createDOMInput, QUOTE_EDGE_REGEX, ParseError, OPERATION_PURPOSE_DICTIONARY, OPERATION_ALIAS_DICTIONARY, ConstraintError, COMPARISON_TYPE_DICTIONARY };