mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 08:17:02 +00:00
feat(environment): answer list filters, search, sorting and paging from the state
- A list view learns, from the lines its whole collection does not explain, what each query parameter does: a filter on a field, a case-insensitive search over fields, a sort, a direction, a page size, an offset, a page number, or nothing for the values recorded. A line is answered by evaluating them only when that reproduces what it recorded, and the view's `query` names those lines and the envelope members that count matches before paging. - Serving evaluates the query over the live state for those lines and for calls whose every query argument has a role, so filtered and sorted lists show created, changed and deleted entities and new argument values; other calls replay as before. - The write gate checks a read on such a line against the query over its copy of the state. Only a line that sorts or pages its whole collection covers the collection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
847b0931a3
commit
ae8efbb439
8 files changed
+760
-30
No files matched your search
@@ -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<string, string> =>
|
||||
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<number, RecordedExchange>();
|
||||
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");
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<number>,
|
||||
): ListQuery | undefined => {
|
||||
const field = context.idField(plan.type);
|
||||
const lines: QueryLine[] = [];
|
||||
const counts = new Map<number, [string, number][]>();
|
||||
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<number>;
|
||||
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),
|
||||
};
|
||||
};
|
||||
@@ -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<string, QueryParameter>;
|
||||
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<string, string>;
|
||||
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<string, string> | undefined => {
|
||||
const values = new Map<string, string>();
|
||||
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<Record<string, QueryParameter>>,
|
||||
values: ReadonlyMap<string, string>,
|
||||
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<string, string>,
|
||||
items: readonly JsonObject[],
|
||||
): [string, QueryParameter[]][] =>
|
||||
[...values].map(([name, value]) => [name, choicesFor(name, value, items)]);
|
||||
|
||||
const listsAsRecorded = (
|
||||
line: QueryLine,
|
||||
parameters: Readonly<Record<string, QueryParameter>>,
|
||||
): 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<string, QueryParameter> | undefined => {
|
||||
const options = line.choices;
|
||||
let tried = 0;
|
||||
const search = (
|
||||
index: number,
|
||||
chosen: Record<string, QueryParameter>,
|
||||
): Record<string, QueryParameter> | 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<string, QueryParameter>[],
|
||||
): Record<string, QueryParameter> => {
|
||||
const roles = new Map<string, QueryParameter[]>();
|
||||
for (const parameters of explained)
|
||||
for (const [name, role] of Object.entries(parameters))
|
||||
roles.set(name, [...(roles.get(name) ?? []), role]);
|
||||
const merged: Record<string, QueryParameter> = {};
|
||||
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<ListQuery, "parameters" | "record_ordinals"> | 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<StateListView, "pointer" | "counters"> & {
|
||||
readonly query: Pick<ListQuery, "totals">;
|
||||
},
|
||||
base: JsonValue,
|
||||
items: readonly JsonObject[],
|
||||
total: number,
|
||||
): JsonValue =>
|
||||
view.query.totals.reduce(
|
||||
(body, name) => replaceAt(body, pointerTo(name), total),
|
||||
renderStateList(view, base, items),
|
||||
);
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<string, string>,
|
||||
): 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<string, string> | 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;
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user