import type { JsonValue } from "../jsonValue.js"; import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; import { literalSubstringPredicate } from "../analysisSearch.js"; import { isObject, recordedItemCount, formatItemCount, pointerTo, renderStateList, replaceAt, scalarText, type JsonObject, type StateEntity, type StateListView, } from "./environmentStateRules.js"; /** What one list query parameter does to a list, learned from the recorded lines it explains. */ export type QueryParameter = | { readonly role: "filter"; readonly field: string } | { readonly role: "search"; readonly fields: string[] } | { readonly role: "sort" } | { readonly role: "direction" } | { readonly role: "limit" } | { readonly role: "offset" } | { readonly role: "page"; readonly first: number } | { readonly role: "inert"; readonly values: string[] }; /** A list view's query model: each parameter's role, the checked lines it answers, and the members that count matches before paging. */ export interface ListQuery { readonly parameters: Record; readonly record_ordinals: number[]; readonly totals: string[]; } /** One recorded line a query may explain: its query values, the identities it listed in order, the roles each parameter may play and the collection's entities. */ export interface QueryLine { readonly ordinal: number; readonly values: ReadonlyMap; readonly listed: readonly string[]; readonly choices: readonly (readonly [string, readonly QueryParameter[]])[]; readonly entities: readonly StateEntity[]; } const SORT = new Set([ "order", "orderby", "order_by", "ordering", "sort", "sortby", "sort_by", ]); const DIRECTION = new Set([ "dir", "direction", "order", "orderdirection", "order_direction", "sortdir", "sort_dir", "sortorder", "sort_order", ]); const LIMIT = new Set([ "count", "itemsperpage", "limit", "page_size", "pagesize", "per_page", "perpage", "size", "take", ]); const OFFSET = new Set(["_start", "offset", "skip", "start"]); const SEARCH = new Set([ "keyword", "keywords", "q", "query", "search", "search_term", "searchterm", "term", ]); const DIRECTIONS = new Set(["asc", "desc"]); const IDENTIFYING = ["id", "slug", "name"]; /** At most this many role combinations are tried for one line. */ const MAX_COMBINATIONS = 256; /** A line with more query parameters than this is left unexplained, which bounds the search's depth and work. */ const MAX_QUERY_NAMES = 12; /** Only plain substring terms are modeled; syntax-like operators require recorded replay. */ const plainSearch = (value: string): boolean => !/[:#"()[\]{}<>|*?\\]/u.test(value) && !/(?:^|\s)(?:AND|OR|NOT)(?:\s|$)/u.test(value) && !/(?:^|\s)[+-]\S/u.test(value); const textOf = (value: JsonValue | undefined): string | undefined => typeof value === "boolean" ? String(value) : scalarText(value); /** Whether a field holds a value: equal as text, or in an array directly or as an object's id, slug or name. */ const holds = (field: JsonValue | undefined, value: string): boolean => { const names = (item: JsonValue | undefined) => isObject(item) && IDENTIFYING.some((key) => textOf(item[key]) === value); return Array.isArray(field) ? field.some((item) => textOf(item) === value || names(item)) : textOf(field) === value || names(field); }; const contains = ( fields: readonly string[], term: string, ): ((entity: StateEntity) => boolean) => { if (term === "") return () => true; const matches = literalSubstringPredicate(term, false); return (entity) => fields.some((field) => { const value = entity.fields[field]; return typeof value === "string" && matches(value); }); }; const normalized = (name: string): string => name.toLowerCase().replace(/[^a-z0-9]/gu, ""); /** The field a sort value names and its direction: `name`, `-name`, `name:desc`, `name-ASC`; names match ignoring case and separators. */ const sortKey = ( value: string, names: readonly string[], ): { readonly field: string; readonly descending: boolean } | undefined => { const field = (spelled: string) => { if (names.includes(spelled)) return spelled; const found = names.filter( (name) => normalized(name) === normalized(spelled), ); return found.length === 1 ? found[0] : undefined; }; if (names.includes(value)) return { field: value, descending: false }; // A leading `-` descends; matching ignores separators, so it is read before the name is matched loosely. if (value.startsWith("-")) { const named = field(value.slice(1)); return named === undefined ? undefined : { field: named, descending: true }; } const whole = field(value); if (whole !== undefined) return { field: whole, descending: false }; const suffixed = /^(.+?)[\s:_-](asc|desc)$/iu.exec(value); const named = suffixed === null ? undefined : field(suffixed[1]!); return named === undefined ? undefined : { field: named, descending: suffixed![2]!.toLowerCase() === "desc" }; }; /** Ascending order: numbers by value, text ignoring case, missing and null values last. */ const ascending = ( left: JsonValue | undefined, right: JsonValue | undefined, ): number => { const absent = (value: JsonValue | undefined) => value === undefined || value === null; if (absent(left) || absent(right)) return absent(left) ? (absent(right) ? 0 : 1) : -1; if (typeof left === "number" && typeof right === "number") return left - right; const a = textOf(left) ?? JSON.stringify(left); const b = textOf(right) ?? JSON.stringify(right); return compare(a.toLowerCase(), b.toLowerCase()); }; const integer = (value: string): number | undefined => { const number = Number(value); return /^-?\d+$/u.test(value) && Number.isSafeInteger(number) ? number : undefined; }; /** What a call's values ask of the collection, or undefined when one has no role or cannot apply. */ interface QueryCall { readonly selections: ((entity: StateEntity) => boolean)[]; sort?: string; direction?: string; limit?: number | undefined; offset: number; page?: { readonly index: number; readonly first: number }; } /** Roles that only sort or page an answer; one value sets each for a call, as two parameters in one would answer by whichever applied last. */ const PAGING_ROLES = new Set(["sort", "direction", "limit", "offset", "page"]); const readCall = ( parameters: Readonly>, values: ReadonlyMap, ): QueryCall | undefined => { const call: QueryCall = { selections: [], offset: 0 }; const taken = new Set(); for (const [name, value] of values) { const parameter = Object.hasOwn(parameters, name) ? parameters[name] : undefined; const number = integer(value); if (parameter === undefined || taken.has(parameter.role)) return undefined; if (PAGING_ROLES.has(parameter.role)) taken.add(parameter.role); if (parameter.role === "filter") call.selections.push((entity) => holds(entity.fields[parameter.field], value), ); else if (parameter.role === "search") { if (!plainSearch(value)) return undefined; call.selections.push(contains(parameter.fields, value)); } else if (parameter.role === "sort") call.sort = value; else if (parameter.role === "direction") { if (!DIRECTIONS.has(value.toLowerCase())) return undefined; call.direction = value.toLowerCase(); } else if (parameter.role === "inert") { if (!parameter.values.includes(value)) return undefined; } else if (number === undefined) return undefined; else if (parameter.role === "limit") call.limit = number > 0 ? number : undefined; else if (parameter.role === "offset" && number >= 0) call.offset = number; else if (parameter.role === "page" && number >= parameter.first) call.page = { index: number, first: parameter.first }; else return undefined; } return call; }; /** Entities in the order a sort value names, ties in state order, and those whose place state order decides: all without a sort, else those tied with a neighbour; undefined when it names no field. */ const sorted = ( matched: readonly StateEntity[], entities: readonly StateEntity[], call: QueryCall, ): | { readonly entities: StateEntity[]; readonly positional: readonly StateEntity[] | "all"; } | undefined => { // Nothing to order: an emptied collection answers no entities rather than a recording. if (call.sort === undefined || matched.length === 0) return { entities: [...matched], positional: "all" }; const names = [ ...new Set(entities.flatMap((entity) => Object.keys(entity.fields))), ]; const key = sortKey(call.sort, names); if (key === undefined) return undefined; const sign = ( call.direction === undefined ? key.descending : call.direction === "desc" ) ? -1 : 1; const ordered = matched .map((entity, index) => ({ entity, index })) .sort( (left, right) => sign * ascending( left.entity.fields[key.field], right.entity.fields[key.field], ) || left.index - right.index, ) .map(({ entity }) => entity); const tied = ( left: StateEntity | undefined, right: StateEntity | undefined, ) => left !== undefined && right !== undefined && ascending(left.fields[key.field], right.fields[key.field]) === 0; return { entities: ordered, positional: ordered.filter( (entity, index) => tied(ordered[index - 1], entity) || tied(entity, ordered[index + 1]), ), }; }; /** The entities a query selects, in its order, and how many matched before paging; undefined when a value cannot be applied. */ export const evaluateQuery = ( parameters: Readonly>, values: ReadonlyMap, entities: readonly StateEntity[], ): | { readonly page: StateEntity[]; readonly total: number; /** The matches whose place in the answer state order decides: all without a sort, else those the sort leaves tied. */ readonly positional: readonly StateEntity[] | "all"; } | undefined => { const call = readCall(parameters, values); if (call === undefined) return undefined; const selection = sorted( entities.filter((entity) => call.selections.every((selects) => selects(entity)), ), entities, call, ); if (selection === undefined) return undefined; const matched = selection.entities; let { offset } = call; const { limit, page } = call; if (page !== undefined && page.index > page.first) { if (limit === undefined) return undefined; offset += (page.index - page.first) * limit; } return { page: matched.slice( offset, limit === undefined ? undefined : offset + limit, ), total: matched.length, positional: selection.positional, }; }; /** A query's answer and whether it shows the whole collection: every parameter only sorts or pages, and the page holds every match. */ export const answerQuery = ( query: Pick, values: ReadonlyMap, entities: readonly StateEntity[], ): | { readonly page: StateEntity[]; readonly total: number; readonly positional: readonly StateEntity[] | "all"; readonly whole: boolean; } | undefined => { const result = evaluateQuery(query.parameters, values, entities); return result === undefined ? undefined : { ...result, whole: result.page.length === result.total && // Every name has its own role, or evaluating the call would have refused it. [...values.keys()].every((name) => PAGING_ROLES.has(query.parameters[name]!.role), ), }; }; /** Fields every listed item holds the value in, those named like the parameter first. */ const filterFields = ( items: readonly JsonObject[], name: string, value: string, ): string[] => items.length === 0 ? [] : Object.keys(items[0]!) .filter((field) => items.every((item) => holds(item[field], value))) .sort( (left, right) => Number(normalized(right) === normalized(name)) - Number(normalized(left) === normalized(name)), ) .slice(0, 4); const isUrl = (value: string): boolean => /^[a-z][a-z0-9+.-]*:\/\//iu.test(value); /** * The string fields the term matched in the listed items: for each item the fields that contain it, ignoring case, * other than URLs, which a name or slug often repeats; an item the term matched only in URLs adds those. */ const searchFields = (items: readonly JsonObject[], term: string): string[] => { if (term === "") return []; const matchesTerm = literalSubstringPredicate(term, false); const fields = new Set(); for (const item of items) { const matching = Object.keys(item).filter((field) => { const value = item[field]; return typeof value === "string" && matchesTerm(value); }); const plain = matching.filter((field) => !isUrl(item[field] as string)); for (const field of plain.length > 0 ? plain : matching) fields.add(field); } return [...fields].sort(compare); }; /** The roles a parameter may play on a line that listed `items`, most specific first; inert last. */ const choicesFor = ( name: string, value: string, items: readonly JsonObject[], ): QueryParameter[] => { const key = name.toLowerCase(); const choices: QueryParameter[] = []; const number = integer(value); if ( ((LIMIT.has(key) || OFFSET.has(key) || key === "page") && (number === undefined || ((OFFSET.has(key) || key === "page") && number < 0))) || (SEARCH.has(key) && !plainSearch(value)) ) return []; if (DIRECTION.has(key) && DIRECTIONS.has(value.toLowerCase())) choices.push({ role: "direction" }); else if (SORT.has(key)) choices.push({ role: "sort" }); else if (LIMIT.has(key)) choices.push({ role: "limit" }); else if (OFFSET.has(key)) choices.push({ role: "offset" }); else if (key === "page") choices.push({ role: "page", first: 1 }, { role: "page", first: 0 }); else if (SEARCH.has(key)) { // An empty or unmatched term shows no searchable field, so it stays inert until a term matches one. const fields = searchFields(items, value); if (fields.length > 0) choices.push({ role: "search", fields }); } if (choices.length === 0 || SEARCH.has(key)) for (const field of filterFields(items, name, value)) choices.push({ role: "filter", field }); choices.push({ role: "inert", values: [value] }); return choices; }; /** The roles each of a line's parameters may play, read from the items it listed. */ export const lineChoices = ( values: ReadonlyMap, items: readonly JsonObject[], ): [string, QueryParameter[]][] => values.size > MAX_QUERY_NAMES ? [] : [...values].map(([name, value]) => [ name, choicesFor(name, value, items), ]); const listsAsRecorded = ( line: QueryLine, parameters: Readonly>, ): boolean => { const result = evaluateQuery(parameters, line.values, line.entities); return ( result !== undefined && result.page.length === line.listed.length && result.page.every((entity, index) => entity.id === line.listed[index]) ); }; /** A filter that keeps every entity of the line's collection shows nothing a flag would not: it stays inert for that value. */ const withoutIdleFilters = ( line: QueryLine, chosen: Record, ): Record => Object.fromEntries( Object.entries(chosen).map(([name, role]) => { const value = line.values.get(name)!; return role.role === "filter" && line.entities.every((entity) => holds(entity.fields[role.field], value)) ? [name, { role: "inert", values: [value] }] : [name, role]; }), ); /** The first combination of roles, most specific first, that lists exactly what the line recorded. */ export const explainLine = ( line: QueryLine, ): Record | undefined => { const options = line.choices; let tried = 0; const search = ( index: number, chosen: Record, ): Record | undefined => { if (index === options.length) { tried += 1; return listsAsRecorded(line, chosen) ? withoutIdleFilters(line, chosen) : undefined; } const [name, choices] = options[index]!; for (const choice of choices) { if (tried >= MAX_COMBINATIONS) return undefined; const found = search(index + 1, { ...chosen, [name]: choice }); if (found !== undefined) return found; } return undefined; }; const found = search(0, {}); if (found === undefined) return undefined; // An empty/out-of-range page can fit both conventions. Do not turn a // preferred candidate's position into evidence of a zero-/one-based API. for (const [name, role] of Object.entries(found)) { if (role.role !== "page") continue; for (const candidate of line.choices.find(([key]) => key === name)?.[1] ?? []) { if (candidate.role !== "page" || candidate.first === role.first) continue; if (tried >= MAX_COMBINATIONS) return undefined; tried++; if (listsAsRecorded(line, { ...found, [name]: candidate })) return undefined; } } return found; }; /** One role per parameter across the lines that explained it: inert values gather, search fields unite, and any other disagreement drops the parameter. */ const mergeRoles = ( explained: readonly Record[], ): Record => { const roles = new Map(); for (const parameters of explained) for (const [name, role] of Object.entries(parameters)) roles.set(name, [...(roles.get(name) ?? []), role]); // Entries become own members even for names such as `__proto__`, which assignment would treat as the prototype. const merged: [string, QueryParameter][] = []; for (const [name, seen] of roles) { const active = seen.filter((role) => role.role !== "inert"); if (active.length === 0) { merged.push([ name, { role: "inert", values: [ ...new Set( seen.flatMap((role) => role.role === "inert" ? role.values : [], ), ), ].sort(compare), }, ]); continue; } const [first] = active; if (first!.role === "search") { if (active.every((role) => role.role === "search")) merged.push([ name, { role: "search", fields: [ ...new Set( active.flatMap((role) => role.role === "search" ? role.fields : [], ), ), ].sort(compare), }, ]); continue; } if (active.every((role) => JSON.stringify(role) === JSON.stringify(first))) merged.push([name, first!]); } return Object.fromEntries(merged); }; /** The query model that lists exactly what each explained line recorded, or undefined when none does. */ export const learnQuery = ( lines: readonly QueryLine[], ): Pick | undefined => { const explained = lines.flatMap((line) => { const parameters = explainLine(line); return parameters === undefined ? [] : [parameters]; }); if (explained.length === 0) return undefined; const parameters = mergeRoles(explained); const record_ordinals = lines .filter((line) => listsAsRecorded(line, parameters)) .map((line) => line.ordinal); return record_ordinals.length === 0 ? undefined : { parameters, record_ordinals }; }; /** A count whose numeric or canonical decimal-string representation can be reproduced without guessing formatting. */ export const queryTotalCount = ( value: JsonValue | undefined, ): number | undefined => { const count = recordedItemCount(value); return count !== undefined && Number.isSafeInteger(count) && count >= 0 && (typeof value !== "string" || String(count) === value) ? count : undefined; }; /** A query answer: the base line's envelope with the selected entities, counters at the number listed and totals at the number matched. */ export const renderQueryList = ( view: Pick & { readonly query: Pick; }, base: JsonValue, items: readonly JsonObject[], total: number, ): JsonValue => view.query.totals.reduce( (body, name) => isObject(base) && Object.hasOwn(base, name) && queryTotalCount(base[name]) !== undefined ? replaceAt(body, pointerTo(name), formatItemCount(total, base[name]!)) : body, renderStateList(view, base, items), );