diff --git a/src/application/environment/environmentStateAnswer.ts b/src/application/environment/environmentStateAnswer.ts index 4cd36e3262..2030dd4115 100644 --- a/src/application/environment/environmentStateAnswer.ts +++ b/src/application/environment/environmentStateAnswer.ts @@ -19,6 +19,10 @@ import { type StateView, } from "../../domain/analysisView/environmentStateRules.js"; import { isAbsence } from "../../domain/analysisView/environmentStateWriteChecks.js"; +import { + evaluateQuery, + renderQueryList, +} from "../../domain/analysisView/environmentStateListQuery.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisInputError, @@ -64,7 +68,7 @@ export type StateProvenance = readonly view: "list"; readonly type: string; readonly collection: string; - readonly membership: "all" | "recorded"; + readonly membership: "all" | "query" | "recorded"; readonly listed: number; } | { @@ -407,7 +411,53 @@ interface ToolCall { readonly exact: boolean; } -/** A list call renders the members of the checked line it names from the state. */ +/** A call's query arguments by parameter name. */ +const callQuery = ( + tool: EnvironmentManifestTool, + args: ReplayArguments, +): Map => + new Map( + [...environmentToolArguments(tool).query].flatMap( + ([name, argument]): [string, string][] => { + const value = args[argument]; + return value === undefined ? [] : [[name, String(value)]]; + }, + ), + ); + +/** For a list with a query, a call whose every query argument has a role answers on a query or whole line of the collection its path arguments name. */ +const queryLine = ( + source: EnvironmentSource, + tool: EnvironmentManifestTool, + view: StateListView, + args: ReplayArguments, +): RecordedExchange | undefined => { + const query = view.query; + const segments = callSegments(tool, args); + if ( + query === undefined || + segments === undefined || + [...callQuery(tool, args).keys()].some( + (name) => query.parameters[name] === undefined, + ) + ) + return undefined; + const lines = new Map(); + for (const ordinal of [...query.record_ordinals, ...allLines(view)]) { + const exchange = source.exchanges.get(ordinal); + if ( + exchange !== undefined && + collectionMatch(collectionPathOf(exchange, false), segments) !== undefined + ) + lines.set(ordinal, exchange); + } + return selectReplayExchange(tool, lines, args)?.exchange; +}; + +/** + * A list call renders from the state on its line: every entity of the collection on a whole line, the query's matches + * on a line the query answers or for a call it computes, else the line's recorded members still held. + */ const answerList = ( serving: StateServing, { tool, args, exact }: ToolCall, @@ -422,17 +472,34 @@ const answerList = ( return fromRecording(source, tool, args, "body-unusable"); const collection = collectionPathOf(base, false); const type = store.type(view.type); - const all = listsAll(view, base.ordinal); + const values = callQuery(tool, args); + // A named query line, or a call with query arguments answered on a line it did not name, evaluates the query. + const queried = + view.query !== undefined && + (exact + ? view.query.record_ordinals.includes(base.ordinal) + : values.size > 0); + const all = !queried && listsAll(view, base.ordinal); + const computed = queried + ? evaluateQuery( + view.query!.parameters, + values, + store.inCollection(view.type, collection), + ) + : undefined; + if (queried && computed === undefined) + return fromRecording(source, tool, args, "unrecorded-request"); const members: readonly StateEntity[] = all ? store.inCollection(view.type, collection) - : recorded.flatMap((item) => { + : (computed?.page ?? + recorded.flatMap((item) => { const id = scalarText(item[type?.id_field ?? ""]); const entity = id === undefined ? undefined : store.entity(view.type, collection, id); return entity === undefined ? [] : [entity]; - }); + })); const volatile = new Set(type?.volatile_fields); const items: JsonObject[] = []; for (const entity of members) { @@ -440,19 +507,23 @@ const answerList = ( if (!shown.ok) return shown; items.push(shown.value); } - return stateAnswer( - serving, - { exchange: base, exact }, - renderStateList(view, envelope, items), - { - source: "state", - view: "list", - type: view.type, - collection, - membership: all ? "all" : "recorded", - listed: items.length, - }, - ); + const body = + view.query === undefined || (!all && computed === undefined) + ? renderStateList(view, envelope, items) + : renderQueryList( + { ...view, query: view.query }, + envelope, + items, + computed?.total ?? items.length, + ); + return stateAnswer(serving, { exchange: base, exact }, body, { + source: "state", + view: "list", + type: view.type, + collection, + membership: all ? "all" : computed === undefined ? "recorded" : "query", + listed: items.length, + }); }; /** A document call answers the state's value for the document on its checked line's status and provenance. */ @@ -487,7 +558,8 @@ const answerCheckedLine = ( const base = named ?? (view.shape === "list" - ? unfilteredLine(serving.source, tool, allLines(view), args) + ? (unfilteredLine(serving.source, tool, allLines(view), args) ?? + queryLine(serving.source, tool, view, args)) : undefined); if (base === undefined) return fromRecording(serving.source, tool, args, "unrecorded-request"); diff --git a/src/application/environment/environmentStateWriteAnswer.test.ts b/src/application/environment/environmentStateWriteAnswer.test.ts index 5814f65f68..ada4612374 100644 --- a/src/application/environment/environmentStateWriteAnswer.test.ts +++ b/src/application/environment/environmentStateWriteAnswer.test.ts @@ -363,7 +363,7 @@ describe("paged list lines", () => { }); describe("list lines", () => { - it("lists a created book on the line that lists every book, while a filtered line keeps its recorded members", () => { + it("lists a created book on the line that lists every book, and a filtered line by its filter", () => { const books = servedFrom(LIST_LINE_ROWS); const created = books.answer("post_books", CREATE_NEW_BOOK).body as Book; expect(books.answer("get_books")).toMatchObject({ @@ -372,7 +372,7 @@ describe("list lines", () => { }); expect(books.answer("get_books", { name: "First Book" })).toMatchObject({ body: bookPage([FIRST_BOOK]), - state: { source: "state", membership: "recorded", listed: 1 }, + state: { source: "state", membership: "query", listed: 1 }, }); }); }); diff --git a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts index 1115da8adc..7cd0c5bdc6 100644 --- a/src/domain/analysisView/EnvironmentStateWriteSimulation.ts +++ b/src/domain/analysisView/EnvironmentStateWriteSimulation.ts @@ -53,6 +53,7 @@ import { type StateWritePlan, } from "./environmentStateWriteRules.js"; import { WriteTaint } from "./environmentStateWriteTaint.js"; +import { queryValues } from "./environmentStateListQuery.js"; /** What the write gate reads: the manifest tools, the timeline, the read views and the initial state. */ export interface WriteSimulationInput { @@ -349,6 +350,9 @@ export class EnvironmentStateWriteSimulation { : checkListRead(this.#state, view, record, { ...check, all: listsAll(view, base), + query: view.query?.record_ordinals.includes(base) + ? queryValues(record.query) + : undefined, }); }); } diff --git a/src/domain/analysisView/environmentStateGate.ts b/src/domain/analysisView/environmentStateGate.ts index 530de7f165..67ed825eee 100644 --- a/src/domain/analysisView/environmentStateGate.ts +++ b/src/domain/analysisView/environmentStateGate.ts @@ -20,6 +20,15 @@ import { type StateToolFacts, type StateView, } from "./environmentStateRules.js"; +import { + evaluateQuery, + learnQuery, + lineChoices, + queryValues, + renderQueryList, + type ListQuery, + type QueryLine, +} from "./environmentStateListQuery.js"; import { ENTRY_BYTES, textBytes } from "./webNetworkRouteTemplates.js"; /** A read tool whose recordings locate entities of one type at one pointer. */ @@ -248,7 +257,69 @@ const continues = (name: string, value: JsonValue | undefined): boolean => value !== false && value !== ""; -/** Whether one latest list recording renders from its own envelope and the listed entities' state. */ +/** The query that lines which are not whole follow, with the item totals that count its matches before paging. */ +const listQuery = ( + context: StateGateContext, + plan: StatePlan, + whole: ReadonlySet, +): ListQuery | undefined => { + const field = context.idField(plan.type); + const lines: QueryLine[] = []; + const counts = new Map(); + for (const record of plan.latest) { + const values = whole.has(record.ordinal) + ? undefined + : queryValues(record.query); + if (values === undefined || values.size === 0) continue; + context.withBody(record.ordinal, (body) => { + const items = + body === undefined ? undefined : listItems(body, plan.pointer); + if (body === undefined || items === undefined) return; + const choices = lineChoices(values, items); + context.retain(ENTRY_BYTES * (items.length + choices.length * 4 + 2)); + lines.push({ + ordinal: record.ordinal, + values, + choices, + listed: items.map((item) => scalarText(item[field]) ?? ""), + entities: context.inCollection( + plan.type, + collectionPathOf(record, false), + ), + }); + const parent = plan.pointer === "" || !isObject(body) ? {} : body; + counts.set( + record.ordinal, + Object.keys(parent).flatMap((name): [string, number][] => { + const count = isItemTotal(name) ? countOf(parent[name]) : undefined; + return count === undefined ? [] : [[name, count]]; + }), + ); + }); + } + const learned = learnQuery(lines); + if (learned === undefined) return undefined; + // Totals count every match on each line the query answers; a `number`-of-items member counts the page. + let totals: string[] | undefined; + for (const line of lines) { + if (!learned.record_ordinals.includes(line.ordinal)) continue; + const { total } = evaluateQuery( + learned.parameters, + line.values, + line.entities, + )!; + const names = (counts.get(line.ordinal) ?? []) + .filter( + ([name, count]) => + count === total && !nameTokens(name).includes("number"), + ) + .map(([name]) => name); + totals = (totals ?? names).filter((name) => names.includes(name)); + } + return { ...learned, totals: (totals ?? []).sort(compare) }; +}; + +/** Whether one latest list recording renders from its own envelope and the state: the whole collection, the query's matches, or the listed entities. */ const listReproduces = ( context: StateGateContext, plan: StatePlan, @@ -258,10 +329,12 @@ const listReproduces = ( readonly pointer: string; readonly counters: string[]; readonly whole: ReadonlySet; + readonly query: ListQuery | undefined; }, record: StateRecordFacts, ): boolean => { const field = context.idField(plan.type); + const collection = collectionPathOf(record, false); return context.withBody(record.ordinal, (body) => { const items = body === undefined ? undefined : listItems(body, plan.pointer); @@ -269,16 +342,24 @@ const listReproduces = ( const observed = new Map( items.map((item) => [scalarText(item[field]) ?? "", item]), ); + const computed = view.query?.record_ordinals.includes(record.ordinal) + ? evaluateQuery( + view.query.parameters, + queryValues(record.query) ?? new Map(), + context.inCollection(plan.type, collection), + ) + : undefined; const members = view.whole.has(record.ordinal) - ? context.inCollection(plan.type, collectionPathOf(record, false)) - : items.flatMap((item) => { + ? context.inCollection(plan.type, collection) + : (computed?.page ?? + items.flatMap((item) => { const entity = context.entity( plan.type, - collectionPathOf(record, false), + collection, scalarText(item[field]) ?? "", ); return entity === undefined ? [] : [entity]; - }); + })); const rendered = members.map((entity) => renderStateEntity( entity, @@ -287,7 +368,17 @@ const listReproduces = ( observed.get(entity.id), ), ); - return same(renderStateList(view, body, rendered), body); + const answer = + view.query === undefined || + (computed === undefined && !view.whole.has(record.ordinal)) + ? renderStateList(view, body, rendered) + : renderQueryList( + { ...view, query: view.query }, + body, + rendered, + computed?.total ?? rendered.length, + ); + return same(answer, body); }); }; @@ -299,13 +390,27 @@ export const checkListPlan = ( const union = new FieldUnion(); const { whole, counters } = listFacts(context, plan, union); context.retain(union.bytes); - const view = { + const base = { tool: plan.tool.name, fields: union.fields, pointer: plan.pointer, counters, whole, }; + let query = listQuery(context, plan, whole); + // A line the query explains but whose envelope or fields the state does not reproduce keeps its recorded members. + if (query !== undefined) { + const failing = plan.latest.filter( + (record) => + query!.record_ordinals.includes(record.ordinal) && + !listReproduces(context, plan, { ...base, query }, record), + ); + const kept = query.record_ordinals.filter( + (ordinal) => !failing.some((record) => record.ordinal === ordinal), + ); + query = kept.length === 0 ? undefined : { ...query, record_ordinals: kept }; + } + const view = { ...base, query }; const all = plan.latest.every((record) => whole.has(record.ordinal)); if ( !plan.latest.every((record) => listReproduces(context, plan, view, record)) @@ -327,6 +432,7 @@ export const checkListPlan = ( .filter((ordinal) => whole.has(ordinal)), }), counters, + ...(query === undefined ? {} : { query }), checked_record_ordinals: plan.latest.map((record) => record.ordinal), }; }; diff --git a/src/domain/analysisView/environmentStateListQuery.ts b/src/domain/analysisView/environmentStateListQuery.ts new file mode 100644 index 0000000000..d9adcbdbf3 --- /dev/null +++ b/src/domain/analysisView/environmentStateListQuery.ts @@ -0,0 +1,421 @@ +import type { JsonValue } from "../jsonValue.js"; +import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; +import { + isObject, + 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 request's query values, decoded, or undefined when a parameter repeats. */ +export const queryValues = ( + query: string | undefined, +): Map | undefined => { + const values = new Map(); + for (const [name, value] of new URLSearchParams(query ?? "")) { + if (values.has(name)) return undefined; + values.set(name, value); + } + return values; +}; + +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 = ( + entity: StateEntity, + fields: readonly string[], + term: string, +): boolean => { + const needle = term.toLowerCase(); + return ( + needle === "" || + fields.some((field) => { + const value = entity.fields[field]; + return typeof value === "string" && value.toLowerCase().includes(needle); + }) + ); +}; + +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; + }; + const whole = field(value); + if (whole !== undefined) return { field: whole, descending: false }; + if (value.startsWith("-")) { + const named = field(value.slice(1)); + return named === undefined ? undefined : { field: named, descending: true }; + } + 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()) || compare(a, b); +}; + +const integer = (value: string): number | undefined => + /^-?\d{1,9}$/u.test(value) ? Number(value) : undefined; + +/** 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 } | undefined => { + let matched = [...entities]; + let sort: string | undefined; + let direction: string | undefined; + let limit: number | undefined; + let offset = 0; + let page: { readonly index: number; readonly first: number } | undefined; + for (const [name, value] of values) { + const parameter = parameters[name]; + const number = integer(value); + if (parameter === undefined) return undefined; + if (parameter.role === "filter") + matched = matched.filter((entity) => + holds(entity.fields[parameter.field], value), + ); + else if (parameter.role === "search") + matched = matched.filter((entity) => + contains(entity, parameter.fields, value), + ); + else if (parameter.role === "sort") sort = value; + else if (parameter.role === "direction") { + if (!DIRECTIONS.has(value.toLowerCase())) return undefined; + 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") + limit = number > 0 ? number : undefined; + else if (parameter.role === "offset" && number >= 0) offset = number; + else if (parameter.role === "page" && number >= parameter.first) + page = { index: number, first: parameter.first }; + else return undefined; + } + if (sort !== undefined) { + const names = [ + ...new Set(entities.flatMap((entity) => Object.keys(entity.fields))), + ]; + const key = sortKey(sort, names); + if (key === undefined) return undefined; + const descending = + direction === undefined ? key.descending : direction === "desc"; + matched = matched + .map((entity, index) => ({ entity, index })) + .sort( + (left, right) => + (descending ? -1 : 1) * + ascending( + left.entity.fields[key.field], + right.entity.fields[key.field], + ) || left.index - right.index, + ) + .map(({ entity }) => entity); + } + 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, + }; +}; + +/** 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); + +/** String fields that contain the term in at least one listed item. */ +const searchFields = (items: readonly JsonObject[], term: string): string[] => + term === "" + ? [] + : [ + ...new Set( + items.flatMap((item) => + Object.keys(item).filter((field) => { + const value = item[field]; + return ( + typeof value === "string" && + value.toLowerCase().includes(term.toLowerCase()) + ); + }), + ), + ), + ].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[] = []; + 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)) + choices.push({ role: "search", fields: searchFields(items, value) }); + 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].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]) + ); +}; + +/** 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) ? 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; + }; + return search(0, {}); +}; + +/** 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]); + const merged: Record = {}; + for (const [name, seen] of roles) { + const active = seen.filter((role) => role.role !== "inert"); + if (active.length === 0) { + merged[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[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[name] = first!; + } + return 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 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) => replaceAt(body, pointerTo(name), total), + renderStateList(view, base, items), + ); diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index 9cdd4df2cf..3dc1bf1675 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -1,3 +1,4 @@ +import type { ListQuery } from "./environmentStateListQuery.js"; import { canonicalJson } from "../comparisonSemantics.js"; import { parseUrlQueryParameterNames } from "../recordedTransportUrl.js"; import type { JsonValue } from "../jsonValue.js"; @@ -104,6 +105,8 @@ export type StateListView = EntityViewPlan & { /** With recorded membership, the checked lines that each list their whole collection. */ readonly all_record_ordinals?: number[] | undefined; readonly counters: string[]; + /** The query the view's other lines follow, when one explains any of them. */ + readonly query?: ListQuery | undefined; }; /** Whether a list view answers on a checked line with every entity of the collection. */ diff --git a/src/domain/analysisView/environmentStateView.ts b/src/domain/analysisView/environmentStateView.ts index ecc3ec53ad..40319fea9c 100644 --- a/src/domain/analysisView/environmentStateView.ts +++ b/src/domain/analysisView/environmentStateView.ts @@ -21,6 +21,61 @@ export const environmentStateViewRequestSchema = z.strictObject({ }); const jsonObjectFieldsSchema = z.record(z.string(), jsonValueSchema); +const queryParameterSchema = z.discriminatedUnion("role", [ + z.strictObject({ + role: z.literal("filter"), + field: z + .string() + .describe( + "Entities whose field equals the value as text, or whose array field holds it directly or as an object's id, slug or name.", + ), + }), + z.strictObject({ + role: z.literal("search"), + fields: z + .array(z.string()) + .describe( + "Entities with a string field containing the value, ignoring case; an empty value matches every entity.", + ), + }), + z + .strictObject({ role: z.literal("sort") }) + .describe( + "Orders by the field the value names (`name`, `-name`, `name:desc`, `name-ASC`): numbers by value, text ignoring case, null last ascending.", + ), + z + .strictObject({ role: z.literal("direction") }) + .describe("asc or desc for the sort parameter."), + z + .strictObject({ role: z.literal("limit") }) + .describe("Page size; zero or a negative value lists every match."), + z + .strictObject({ role: z.literal("offset") }) + .describe("Matches skipped before the page."), + z.strictObject({ + role: z.literal("page"), + first: z.number().int().describe("The number of the first page."), + }), + z.strictObject({ + role: z.literal("inert"), + values: z + .array(z.string()) + .describe("Recorded values that change nothing the view lists; other values are not computed."), + }), +]); +const listQuerySchema = z.strictObject({ + parameters: z + .record(z.string(), queryParameterSchema) + .describe( + "What each query parameter does. A call whose every parameter has a role is answered by evaluating them over the state.", + ), + record_ordinals: z + .array(z.number().int().nonnegative()) + .describe("Checked lines the query reproduces, answered by evaluating it."), + totals: z + .array(z.string()) + .describe("Envelope members that count every match before paging."), +}); const checkedSchema = z .array(z.number().int().nonnegative()) .describe( @@ -69,6 +124,11 @@ const viewSchema = z.discriminatedUnion("shape", [ counters: z .array(z.string()) .describe("Envelope members that count the listed entities."), + query: listQuerySchema + .optional() + .describe( + "How the view's other checked lines select and order entities, when filters, a search, a sort or paging explain them; absent when none does.", + ), }), z.strictObject({ tool: z.string(), diff --git a/src/domain/analysisView/environmentStateWriteChecks.ts b/src/domain/analysisView/environmentStateWriteChecks.ts index f9eb379726..d69a3fde69 100644 --- a/src/domain/analysisView/environmentStateWriteChecks.ts +++ b/src/domain/analysisView/environmentStateWriteChecks.ts @@ -28,6 +28,7 @@ import { type StateToolFacts, type StateView, } from "./environmentStateRules.js"; +import { evaluateQuery, renderQueryList } from "./environmentStateListQuery.js"; /** A query as replay compares a call's: decoded values by name, in any order. A query repeating a name, which no call's arguments can carry, keeps its spelling. */ const queryKey = (query: string | undefined): string => { @@ -392,6 +393,7 @@ const listCheck = ( view: StateListView, check: ListBodies, members: readonly StateEntity[], + total = members.length, ): boolean => { if ( members.some( @@ -406,7 +408,64 @@ const listCheck = ( const rendered = members.map((entity) => renderStateEntity(entity, view, check.volatile, observed.get(entity.id)), ); - return same(renderStateList(view, check.base, rendered), check.body); + const answer = + view.query === undefined + ? renderStateList(view, check.base, rendered) + : renderQueryList( + { ...view, query: view.query }, + check.base, + rendered, + total, + ); + return same(answer, check.body); +}; + +/** + * A list read on a line the query answers: the query's matches in the state, in its order and page. Only a line whose + * parameters all sort or page, and that shows every match, covers the collection; any other covers the entities it lists. + */ +const checkQueryList = ( + state: SimulatedState, + view: StateListView, + check: ListBodies, + values: ReadonlyMap, +): CheckOutcome | undefined => { + const { collection } = check; + const result = evaluateQuery( + view.query!.parameters, + values, + state.members(view.type, collection), + ); + if (result === undefined) return undefined; + const selects = [...values.keys()].some((name) => { + const role = view.query!.parameters[name]?.role; + return role === "filter" || role === "search" || role === "inert"; + }); + const whole = !selects && result.page.length === result.total; + const held = new Set(result.page.map((entity) => entity.id)); + return { + pass: listCheck(view, check, result.page, result.total), + targets: [ + ...(whole ? [collectionTarget(view.type, collection)] : []), + ...result.page.map((entity) => + entityTarget(view.type, collection, entity.id), + ), + ], + // What the base line or the read listed that the state no longer holds: the read shows whether it went. + unheld: [ + ...new Set( + [...check.baseItems, ...check.items].map( + (item) => scalarText(item[check.idField]) ?? "", + ), + ), + ] + .filter( + (id) => + !held.has(id) && + state.entity(view.type, collection, id) === undefined, + ) + .map((id) => entityTarget(view.type, collection, id)), + }; }; /** A list read of every entity of a collection: membership, order, fields and counters must render from the state. */ @@ -465,6 +524,8 @@ export const checkListRead = ( readonly collection: string; /** Whether the base line lists every entity of its collection. */ readonly all: boolean; + /** The read's query values, when the query answers its line. */ + readonly query?: ReadonlyMap | undefined; }, ): CheckOutcome | undefined => { if (!succeeded(record)) return undefined; @@ -478,7 +539,10 @@ export const checkListRead = ( const bodies = { ...check, body, base, items: items ?? [], baseItems }; const outcome = check.all ? checkAllList(state, view, bodies) - : checkRecordedList(state, view, bodies); + : check.query === undefined + ? checkRecordedList(state, view, bodies) + : checkQueryList(state, view, bodies, check.query); + if (outcome === undefined) return undefined; return items === undefined ? { ...outcome, pass: false } : outcome; };