Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m22s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17m57s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 20m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 25m57s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 44m33s
138 lines
5.4 KiB
TypeScript
138 lines
5.4 KiB
TypeScript
import type { FieldType, Group, Operator, Rule } from './types';
|
|
|
|
// Split a group body into top-level parts separated by a single connective, respecting quotes and
|
|
// one level of parens. Returns the parts and the connective, or null if the split is malformed or the
|
|
// connectives are mixed (mixed AND/OR at one level is out of subset).
|
|
function splitTopLevel(input: string): { parts: string[]; match: 'all' | 'any' } | null {
|
|
const parts: string[] = [];
|
|
const ops: string[] = [];
|
|
let depth = 0;
|
|
let quoted = false;
|
|
let token = '';
|
|
|
|
for (let i = 0; i < input.length; i++) {
|
|
const ch = input[i];
|
|
if (ch === '\\') { token += ch + (input[i + 1] ?? ''); i++; continue; } // escape pair, anywhere
|
|
if (ch === '"') { quoted = !quoted; token += ch; continue; }
|
|
if (quoted) { token += ch; continue; }
|
|
if (ch === '(') { depth++; token += ch; continue; }
|
|
if (ch === ')') { depth--; token += ch; continue; }
|
|
if (depth === 0 && input.startsWith(' AND ', i)) { parts.push(token); ops.push('AND'); token = ''; i += 4; continue; }
|
|
if (depth === 0 && input.startsWith(' OR ', i)) { parts.push(token); ops.push('OR'); token = ''; i += 3; continue; }
|
|
token += ch;
|
|
}
|
|
if (depth !== 0 || quoted) return null;
|
|
parts.push(token);
|
|
|
|
if (ops.length === 0) return { parts, match: 'all' };
|
|
const allAnd = ops.every((o) => o === 'AND');
|
|
const allOr = ops.every((o) => o === 'OR');
|
|
if (!allAnd && !allOr) return null; // mixed connectives
|
|
return { parts, match: allAnd ? 'all' : 'any' };
|
|
}
|
|
|
|
// Reverse of compile's date normalization: the index format yyyyMMdd back to the input's yyyy-MM-dd.
|
|
function fromIndexDate(value: string): string {
|
|
return /^\d{8}$/.test(value) ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}` : value;
|
|
}
|
|
|
|
function unquote(value: string): string {
|
|
return value.replace(/\\(["\\])/g, '$1');
|
|
}
|
|
|
|
// A trailing '*' is an operator wildcard iff it is not escaped — i.e. preceded by an even run of backslashes.
|
|
function endsWithOperatorStar(s: string): boolean {
|
|
if (!s.endsWith('*')) return false;
|
|
let backslashes = 0;
|
|
let i = s.length - 2;
|
|
while (i >= 0 && s[i] === '\\') { backslashes++; i--; }
|
|
return backslashes % 2 === 0;
|
|
}
|
|
|
|
function parseAtom(atom: string, fieldTypes: Record<string, FieldType>): Rule | null {
|
|
const trimmed = atom.trim();
|
|
const negate = trimmed.startsWith('NOT ');
|
|
const body = negate ? trimmed.slice(4).trim() : trimmed;
|
|
|
|
const colon = body.indexOf(':');
|
|
if (colon < 0) return null;
|
|
const field = body.slice(0, colon);
|
|
const raw = body.slice(colon + 1);
|
|
const type = fieldTypes[field];
|
|
if (!type) return null;
|
|
|
|
// Ranges: {a TO b} (exclusive) and [a TO b] (inclusive between)
|
|
const range = raw.match(/^([[{])(\S+) TO (\S+)([\]}])$/);
|
|
if (range) {
|
|
const [, , lo, hi, close] = range;
|
|
if (type !== 'number' && type !== 'date') return null;
|
|
const fmt = (x: string) => (type === 'date' ? fromIndexDate(x) : x);
|
|
if (close === ']') return mk(field, 'between', fmt(lo), fmt(hi));
|
|
if (lo === '*') return mk(field, type === 'date' ? 'before' : 'lt', fmt(hi));
|
|
if (hi === '*') return mk(field, type === 'date' ? 'after' : 'gt', fmt(lo));
|
|
return null;
|
|
}
|
|
|
|
// Quoted phrase → is/isNot (text, enum) or matches/notMatches (fulltext) by field type
|
|
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
|
|
const v = unquote(raw.slice(1, -1));
|
|
if (type === 'fulltext') return mk(field, negate ? 'notMatches' : 'matches', v);
|
|
if (type === 'text' || type === 'enum') return mk(field, negate ? 'isNot' : 'is', v);
|
|
return null;
|
|
}
|
|
|
|
if (negate) return null; // NOT only valid with quoted forms above
|
|
|
|
// Wildcards (text only): *v* → contains, v* → startsWith. The operator stars are the
|
|
// unescaped boundary stars; any literal * in the value was escaped to \* by the compiler.
|
|
if (type === 'text' && endsWithOperatorStar(raw)) {
|
|
if (raw.startsWith('*') && raw.length >= 2) {
|
|
return mk(field, 'contains', unescapeWild(raw.slice(1, -1)));
|
|
}
|
|
if (raw[0] !== '*') {
|
|
return mk(field, 'startsWith', unescapeWild(raw.slice(0, -1)));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Bare token → numeric eq only (reject fuzzy ~, boosts ^, etc.)
|
|
if (type === 'number' && /^[0-9.]+$/.test(raw)) return mk(field, 'eq', raw);
|
|
|
|
return null;
|
|
}
|
|
|
|
function unescapeWild(value: string): string {
|
|
return value.replace(/\\([\s\S])/g, '$1');
|
|
}
|
|
|
|
function mk(field: string, operator: Operator, value: string, value2?: string): Rule {
|
|
return value2 === undefined ? { field, operator, value } : { field, operator, value, value2 };
|
|
}
|
|
|
|
function parseGroup(input: string, fieldTypes: Record<string, FieldType>, allowNested: boolean): Group | null {
|
|
const split = splitTopLevel(input.trim());
|
|
if (!split) return null;
|
|
|
|
const children: Array<Rule | Group> = [];
|
|
for (const part of split.parts) {
|
|
const p = part.trim();
|
|
if (p.startsWith('(') && p.endsWith(')')) {
|
|
if (!allowNested) return null; // deeper than one level
|
|
const inner = parseGroup(p.slice(1, -1), fieldTypes, false);
|
|
if (!inner) return null;
|
|
children.push(inner);
|
|
} else {
|
|
const rule = parseAtom(p, fieldTypes);
|
|
if (!rule) return null;
|
|
children.push(rule);
|
|
}
|
|
}
|
|
if (children.length === 0) return null;
|
|
return { match: split.match, children };
|
|
}
|
|
|
|
export function parse(input: string, fieldTypes: Record<string, FieldType>): Group | null {
|
|
if (!input.trim()) return null;
|
|
return parseGroup(input, fieldTypes, true);
|
|
}
|