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
JavaScript
// 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
};