diff --git a/src/typegate/src/runtimes/substantial.ts b/src/typegate/src/runtimes/substantial.ts index 2c692baa0..8fada6ea2 100644 --- a/src/typegate/src/runtimes/substantial.ts +++ b/src/typegate/src/runtimes/substantial.ts @@ -20,12 +20,10 @@ import { } from "./substantial/agent.ts"; import { closestWord } from "../utils.ts"; import { InternalAuth } from "../services/auth/protocols/internal.ts"; -import { applyFilter, Expr } from "./substantial/filter_utils.ts"; +import { applyFilter, type Expr, type ExecutionStatus } from "./substantial/filter_utils.ts"; const logger = getLogger(import.meta); -export type ExecutionStatus = "COMPLETED" | "COMPLETED_WITH_ERROR" | "ONGOING" | "UNKNOWN"; - interface QueryCompletedWorkflowResult { run_id: string; started_at: string; diff --git a/src/typegate/src/runtimes/substantial/filter_utils.ts b/src/typegate/src/runtimes/substantial/filter_utils.ts index 0adc3f274..5a14efb82 100644 --- a/src/typegate/src/runtimes/substantial/filter_utils.ts +++ b/src/typegate/src/runtimes/substantial/filter_utils.ts @@ -1,9 +1,14 @@ // Copyright Metatype OÜ, licensed under the Mozilla Public License Version 2.0. // SPDX-License-Identifier: MPL-2.0 -import { ExecutionStatus } from "../substantial.ts"; import { Agent } from "./agent.ts"; +export type ExecutionStatus = + | "COMPLETED" + | "COMPLETED_WITH_ERROR" + | "ONGOING" + | "UNKNOWN"; + type KVHelper = { // should be a union type but it is not very helpful with autocomplete [K in T[number]]?: V; @@ -64,7 +69,7 @@ export async function buildSearchableItems( const isOk = "Ok" in result; const kind = isOk ? "Ok" : "Err"; - const stoppedStatus = isOk ? "COMPLETED_WITH_ERROR" : "COMPLETED"; + const stoppedStatus = isOk ? "COMPLETED" : "COMPLETED_WITH_ERROR"; searchList.push( new SearchItem( @@ -96,52 +101,64 @@ export async function applyFilter( return searchResults; } -function evalExpr(sResult: SearchItem, filter: Expr, path: Array) { - for (const k in filter) { - const op = k as unknown as keyof Expr; - const newPath = [...path, op]; - switch (op) { - // Expr - case "and": - case "or": { - const exprList = filter[op]; - if (!Array.isArray(exprList)) { - // should be unreachable since filter is validated at push - throw new Error(`Fatal: array expected at ${path.join(".")}`); - } - const fn = op == "or" ? "some" : "every"; - if ( - !exprList[fn]((subFilter) => evalExpr(sResult, subFilter, newPath)) - ) { - return false; - } - break; + +export function evalExpr( + sResult: SearchItem, + filter: Expr, + path: Array, +) { + const keys = Object.keys(filter) as Array; + if (keys.length != 1) { + throw new Error(`Invalid expression at ${path.join(".")}`); + } + const op = keys[0]; + const newPath = [...path, op]; + + switch (op) { + // Expr + case "and": + case "or": { + const exprList = filter[op]; + if (!Array.isArray(exprList)) { + // should be unreachable since filter is validated at push + throw new Error( + `Fatal: array expected at ${path.join(".")}`, + ); } - case "not": { - if (evalExpr(sResult, filter["not"]!, newPath)) { - return false; - } - break; + const fn = op == "or" ? "some" : "every"; + if ( + !exprList[fn]((subFilter, index) => + evalExpr(sResult, subFilter, [...newPath, `#${index}`]) + ) + ) { + return false; } - // special - case "status": - case "started_at": - case "ended_at": { - const discriminator = sResult[op]; - const repr = new SearchItem( - sResult.run_id, - sResult.started_at, - sResult.ended_at, - sResult.status, - discriminator, - ); - return evalTerm(repr, filter[op]!, newPath); + break; + } + case "not": { + if (evalExpr(sResult, filter["not"]!, newPath)) { + return false; } - // Term - default: { - if (!evalTerm(sResult, filter, newPath)) { - return false; - } + break; + } + // Special + case "status": + case "started_at": + case "ended_at": { + const discriminator = sResult[op]; + const repr = new SearchItem( + sResult.run_id, + null, + null, + sResult.status, + discriminator, + ); + return evalTerm(repr, filter[op]!, newPath); + } + // Term + default: { + if (!evalTerm(sResult, filter, path)) { + return false; } } } @@ -151,45 +168,69 @@ function evalExpr(sResult: SearchItem, filter: Expr, path: Array) { function evalTerm(sResult: SearchItem, terms: Terms, path: Array) { const value = sResult.value; + const keys = Object.keys(terms) as Array; + if (keys.length != 1) { + throw new Error(`Invalid expression at ${path.join(".")}`); + } - for (const k in terms) { - const op = k as unknown as keyof Terms; - const term = JSON.parse(terms[op] ?? "null"); // TODO: impl generic JSON on typegate - const newPath = [...path, op]; - switch (op) { - case "eq": { - if (value != term) { - return false; - } - break; + const op = keys[0]; + const newPath = [...path, op]; + switch (op) { + case "eq": { + // term can never compare (null at worst) + if (value === undefined) { + return false; } - case "lt": - case "lte": - case "gt": - case "gte": { - if (!ord(value, term, op, newPath)) { - return false; - } - break; + + if (!testCompare(value, toJS(terms[op]))) { + return false; } - case "contains": - case "in": { - if ( - !inclusion(value, term, op, newPath) - ) { - return false; - } - break; + + break; + } + case "lt": + case "lte": + case "gt": + case "gte": { + if (!ord(value, toJS(terms[op]), op, newPath)) { + return false; } - default: { - throw new Error(`Unknown operator at ${newPath.join(".")}`); + break; + } + case "contains": + case "in": { + if ( + !inclusion(value, toJS(terms[op]), op, newPath) + ) { + return false; } + break; + } + default: { + throw new Error( + `Unknown operator "${op}" at ${path.join(".")}`, + ); } } return true; } +function toJS(val: string | undefined) { + // TODO: impl generic JSON on typegate + // ideally this should be an identity fn + return JSON.parse(val ?? "null"); +} + +function testCompare(value: unknown, testValue: unknown) { + const easy = ["number", "boolean", "string"]; + if (easy.includes(typeof value)) { + return value === testValue; + } + + return JSON.stringify(value) == JSON.stringify(testValue); +} + function comparable(a: unknown, b: unknown) { return typeof a == typeof b; } @@ -229,13 +270,12 @@ function inclusion( cp: keyof INCL, _newPath: Array, ) { - if (!comparable(l, r)) { - return false; - } - const [left, right] = cp == "in" ? [l, r] : [r, l]; if (Array.isArray(right)) { - // FIXME: does not work with [ [[1]] ].includes([[1]]) + // Note: Array.prototype.includes compare inner references + const leftComp = JSON.stringify(left); + return right.some((inner) => JSON.stringify(inner) === leftComp); + } else if (typeof left == "string" && typeof right == "string") { return right.includes(left); } else if ( typeof left == typeof right && typeof left == "object" && left != null diff --git a/tests/runtimes/substantial/filter_utils_test.ts b/tests/runtimes/substantial/filter_utils_test.ts new file mode 100644 index 000000000..72612c12b --- /dev/null +++ b/tests/runtimes/substantial/filter_utils_test.ts @@ -0,0 +1,198 @@ +// Copyright Metatype OÜ, licensed under the Mozilla Public License Version 2.0. +// SPDX-License-Identifier: MPL-2.0 + +import { assertEquals } from "@std/assert"; +import { Meta } from "../../utils/mod.ts"; +import { + evalExpr, + ExecutionStatus, + Expr, + SearchItem, +} from "@metatype/typegate/runtimes/substantial/filter_utils.ts"; + +function addDays(date: Date, days: number) { + const ret = new Date(date); + ret.setDate(ret.getDate() + days); + return ret; +} + +function val(x: unknown) { + return JSON.stringify(x); +} + +export function testData() { + const samples = [ + { "COMPLETED_WITH_ERROR": "Fatal: error" }, + { "COMPLETED": true }, + { "ONGOING": undefined }, + { "COMPLETED": [1, 2, ["three"]] }, + { "ONGOING": undefined }, + { "COMPLETED_WITH_ERROR": { nested: { object: 1234 }, b: 4 } }, + { "COMPLETED": null }, + { "COMPLETED": 1 }, + { "COMPLETED_WITH_ERROR": 2 }, + { "COMPLETED": 3 }, + ] satisfies Array<{ [K in ExecutionStatus]?: unknown }>; + + const dataset = []; + + let start = new Date("2024-01-01"), end = null; + for (let i = 0; i < samples.length; i += 1) { + end = addDays(start, 1); + const [status, value] = Object.entries(samples[i])[0] as [ + ExecutionStatus, + unknown, + ]; + + dataset.push( + new SearchItem( + `fakeUUID#${i}`, + start.toJSON(), + status == "ONGOING" ? null : end.toJSON(), + status, + value, + ), + ); + + if (i % 2 == 0) { + start = end; + } + } + + return dataset; +} + +Meta.test("base filter logic", async (t) => { + const testShould = async ( + fact: string, + data: { filter: Expr; expected: Array }, + ) => { + await t.should(fact, () => { + const items = testData(); + const searchResults = []; + for (const item of items) { + if (evalExpr(item, data.filter, [""])) { + searchResults.push(item.toSearchResult()); + } + } + assertEquals(searchResults, data.expected); + }); + }; + + // ------------------ + await testShould("be able discriminate truthy values and 1)", { + filter: { eq: val(1) }, + expected: [ + { + ended_at: "2024-01-06T00:00:00.000Z", + run_id: "fakeUUID#7", + started_at: "2024-01-05T00:00:00.000Z", + status: "COMPLETED", + value: "1", + }, + ] + }); + + await testShould('work with null and special values (e.g. "status")', { + filter: { + or: [ + { status: { eq: val("ONGOING") } }, + { eq: val(null) }, + ], + }, + expected: [ + { + run_id: "fakeUUID#2", + started_at: "2024-01-02T00:00:00.000Z", + ended_at: null, + status: "ONGOING", + value: undefined, + }, + { + run_id: "fakeUUID#4", + started_at: "2024-01-03T00:00:00.000Z", + ended_at: null, + status: "ONGOING", + value: undefined, + }, + { + ended_at: "2024-01-05T00:00:00.000Z", + run_id: "fakeUUID#6", + started_at: "2024-01-04T00:00:00.000Z", + status: "COMPLETED", + value: "null", + } + ], + }); + + await testShould('work with "in" and "contains" operators', { + filter: { + or: [ + { + and: [ + { contains: val(1) }, + { contains: val(["three"]) }, + ], + }, + { contains: val({ nested: { object: 1234 } })}, + { in: val("Fatal: error+ some other string") }, + ], + }, + expected: [ + { + run_id: "fakeUUID#0", + started_at: "2024-01-01T00:00:00.000Z", + ended_at: "2024-01-02T00:00:00.000Z", + status: "COMPLETED_WITH_ERROR", + value: '"Fatal: error"', + }, + { + run_id: "fakeUUID#3", + started_at: "2024-01-03T00:00:00.000Z", + ended_at: "2024-01-04T00:00:00.000Z", + status: "COMPLETED", + value: '[1,2,["three"]]', + }, + ], + }); + + await testShould( + "be able to compare numbers and strings on all kinds of terms (special + simple) ", + { + filter: { + or: [ + { + and: [ + { started_at: { gte: val("2024-01-02") } }, + { not: { not: { ended_at: { eq: val(null) } } } }, + ], + }, + { lte: val(1) }, + ], + }, + expected: [ + { + run_id: "fakeUUID#2", + started_at: "2024-01-02T00:00:00.000Z", + ended_at: null, + status: "ONGOING", + value: undefined, + }, + { + run_id: "fakeUUID#4", + started_at: "2024-01-03T00:00:00.000Z", + ended_at: null, + status: "ONGOING", + value: undefined, + }, + { + run_id: "fakeUUID#7", + started_at: "2024-01-05T00:00:00.000Z", + ended_at: "2024-01-06T00:00:00.000Z", + status: "COMPLETED", + value: "1", + }, + ], + }, + ); +});