diff --git a/apps/roam/src/utils/__tests__/fireQuery.test.ts b/apps/roam/src/utils/__tests__/fireQuery.test.ts index dcc1efb54..6b5686605 100644 --- a/apps/roam/src/utils/__tests__/fireQuery.test.ts +++ b/apps/roam/src/utils/__tests__/fireQuery.test.ts @@ -66,6 +66,34 @@ describe("getDatalogQuery", () => { ]); expect(formatted).toMatchObject({ text: "A", uid: "u1", Created: "123" }); }); + + it("adds internal find variables to the query output", async () => { + const built = getDatalogQuery({ + conditions: [], + selections: [], + findVariables: [ + { label: "relationUid", variable: "condition-relSchema" }, + { label: "effectiveSource", variable: "?condition-relSource" }, + ], + }); + + expect(built.query).toContain("?condition-relSchema"); + expect(built.query).toContain("?condition-relSource"); + + const formatted = await built.formatResult([ + { ":node/title": "A", ":block/uid": "u1" }, + { ":block/uid": "u1" }, + "rel-1", + "source-1", + ]); + + expect(formatted).toMatchObject({ + text: "A", + uid: "u1", + relationUid: "rel-1", + effectiveSource: "source-1", + }); + }); }); describe("fireQuery", () => { diff --git a/apps/roam/src/utils/__tests__/getDiscourseContextResults.test.ts b/apps/roam/src/utils/__tests__/getDiscourseContextResults.test.ts new file mode 100644 index 000000000..f95e761ce --- /dev/null +++ b/apps/roam/src/utils/__tests__/getDiscourseContextResults.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DiscourseNode } from "~/utils/getDiscourseNodes"; +import type { DiscourseRelation } from "~/utils/getDiscourseRelations"; + +const mocks = vi.hoisted(() => ({ + findDiscourseNode: vi.fn(), + fireQuery: vi.fn(), + generateUID: vi.fn(), + getSetting: vi.fn(), +})); + +vi.mock("~/utils/deriveDiscourseNodeAttribute", () => ({ + ANY_RELATION_NAME: "Has Any Relation To", + ANY_RELATION_REGEX: /Has Any Relation To/i, +})); + +vi.mock("~/utils/extensionSettings", () => ({ + getSetting: mocks.getSetting, +})); + +vi.mock("~/utils/findDiscourseNode", () => ({ + default: mocks.findDiscourseNode, +})); + +vi.mock("~/utils/fireQuery", () => ({ + default: mocks.fireQuery, +})); + +vi.mock("~/utils/getDiscourseNodes", () => ({ + default: () => [], +})); + +vi.mock("~/utils/getDiscourseRelations", () => ({ + default: () => [], +})); + +import getDiscourseContextResults from "~/utils/getDiscourseContextResults"; + +const makeNode = ({ + type, + text, +}: { + type: string; + text: string; +}): DiscourseNode => ({ + type, + text, + shortcut: "", + specification: [], + backedBy: "user", + canvasSettings: {}, + format: "{content}", +}); + +describe("getDiscourseContextResults", () => { + beforeEach(() => { + vi.clearAllMocks(); + (globalThis as { window: unknown }).window = { + roamAlphaAPI: { + util: { + generateUID: mocks.generateUID, + }, + }, + }; + + mocks.generateUID.mockReturnValue("condition"); + mocks.getSetting.mockReturnValue(true); + mocks.findDiscourseNode.mockReturnValue({ type: "CLM" }); + }); + + it("regroups all-relation reified query results by schema order", async () => { + const onResult = vi.fn(); + const nodes: DiscourseNode[] = [ + makeNode({ type: "CLM", text: "Claim" }), + makeNode({ type: "QUE", text: "Question" }), + makeNode({ type: "EVD", text: "Evidence" }), + ]; + const relations: DiscourseRelation[] = [ + { + id: "supports", + label: "Supports", + complement: "Supported By", + source: "CLM", + destination: "QUE", + triples: [], + }, + { + id: "informs", + label: "Informs", + complement: "Informed By", + source: "EVD", + destination: "CLM", + triples: [], + }, + ]; + + mocks.fireQuery.mockResolvedValue([ + { + text: "Evidence A", + uid: "evidence-a", + relationUid: "informs", + effectiveSource: "evidence-a", + }, + { + text: "Question A", + uid: "question-a", + relationUid: "supports", + effectiveSource: "claim-a", + }, + ]); + + const results = await getDiscourseContextResults({ + uid: "claim-a", + nodes, + relations, + onResult, + }); + + expect(mocks.fireQuery).toHaveBeenCalledWith( + expect.objectContaining({ + returnNode: "Any", + selections: [], + findVariables: [ + { label: "relationUid", variable: "condition-relSchema" }, + { label: "effectiveSource", variable: "condition-relSource" }, + ], + }), + ); + + expect(results[0]).toEqual({ + label: "Supports", + results: { + "question-a": { + text: "Question A", + uid: "question-a", + relationUid: "supports", + effectiveSource: "claim-a", + target: "Question", + complement: 0, + id: "supports", + ctxTargetUid: "claim-a", + }, + }, + }); + expect(results[1]).toEqual({ + label: "Informed By", + results: { + "evidence-a": { + text: "Evidence A", + uid: "evidence-a", + relationUid: "informs", + effectiveSource: "evidence-a", + target: "Evidence", + complement: 1, + id: "informs", + ctxTargetUid: "claim-a", + }, + }, + }); + expect(onResult).toHaveBeenNthCalledWith(1, results[0]); + expect(onResult).toHaveBeenNthCalledWith(2, results[1]); + }); +}); diff --git a/apps/roam/src/utils/deriveDiscourseNodeAttribute.ts b/apps/roam/src/utils/deriveDiscourseNodeAttribute.ts index 81c78acce..3e6cf9cc9 100644 --- a/apps/roam/src/utils/deriveDiscourseNodeAttribute.ts +++ b/apps/roam/src/utils/deriveDiscourseNodeAttribute.ts @@ -6,7 +6,8 @@ import findDiscourseNode from "./findDiscourseNode"; import getDiscourseNodes from "./getDiscourseNodes"; import getDiscourseRelations from "./getDiscourseRelations"; -export const ANY_RELATION_REGEX = /Has Any Relation To/i; +export const ANY_RELATION_NAME = "Has Any Relation To"; +export const ANY_RELATION_REGEX = RegExp(ANY_RELATION_NAME, "i"); const evaluate = async (s: string) => window.RoamLazy diff --git a/apps/roam/src/utils/fireQuery.ts b/apps/roam/src/utils/fireQuery.ts index 1d68589d6..05b5bb27f 100644 --- a/apps/roam/src/utils/fireQuery.ts +++ b/apps/roam/src/utils/fireQuery.ts @@ -21,6 +21,10 @@ export type QueryArgs = { conditions: Condition[]; selections: Selection[]; inputs?: Record; + findVariables?: { + label: string; + variable: string; + }[]; }; type RelationInQuery = { id: string; @@ -39,6 +43,12 @@ export type FireQueryArgs = QueryArgs & { }; type FireQuery = (query: FireQueryArgs) => Promise; +type DefinedSelection = { + mapper?: PredefinedSelection["mapper"]; + pull: string; + label: string; + key: string; +}; const firstVariable = ( clause: DatalogClause | DatalogAndClause, @@ -200,6 +210,7 @@ export const getDatalogQuery = ({ selections, returnNode = DEFAULT_RETURN_NODE, inputs = {}, + findVariables = [], }: FireQueryArgs) => { const expectedInputs = getConditionTargets(conditions) .filter((c) => /^:in /.test(c)) @@ -211,12 +222,7 @@ export const getDatalogQuery = ({ new Set([]), ); - const defaultSelections: { - mapper: PredefinedSelection["mapper"]; - pull: string; - label: string; - key: string; - }[] = [ + const defaultSelections: DefinedSelection[] = [ { mapper: (r) => { return { @@ -237,26 +243,35 @@ export const getDatalogQuery = ({ key: "", }, ]; + const userSelections: DefinedSelection[] = selections + .map((s) => ({ + defined: predefinedSelections.find((p) => p.test.test(s.text)), + s, + })) + .filter( + (p): p is { defined: PredefinedSelection; s: Selection } => !!p.defined, + ) + .map((p) => ({ + mapper: p.defined.mapper, + pull: p.defined.pull({ + where: whereClauses, + returnNode, + match: p.defined.test.exec(p.s.text), + }), + label: p.s.label || p.s.text, + key: p.s.text, + })) + .filter((p) => !!p.pull); + const internalFindVariables: DefinedSelection[] = findVariables.map( + ({ label, variable }) => ({ + pull: variable.startsWith("?") ? variable : `?${variable}`, + label, + key: variable, + }), + ); const definedSelections = defaultSelections.concat( - selections - .map((s) => ({ - defined: predefinedSelections.find((p) => p.test.test(s.text)), - s, - })) - .filter( - (p): p is { defined: PredefinedSelection; s: Selection } => !!p.defined, - ) - .map((p) => ({ - mapper: p.defined.mapper, - pull: p.defined.pull({ - where: whereClauses, - returnNode, - match: p.defined.test.exec(p.s.text), - }), - label: p.s.label || p.s.text, - key: p.s.text, - })) - .filter((p) => !!p.pull), + userSelections, + internalFindVariables, ); const find = definedSelections.map((p) => p.pull).join("\n "); const where = whereClauses.map((c) => compileDatalog(c, 1)).join("\n"); @@ -274,7 +289,9 @@ export const getDatalogQuery = ({ definedSelections .map((c, i) => (prev: QueryResult) => { const pullResult = result[i]; - return typeof pullResult === "object" && pullResult !== null + return c.mapper && + typeof pullResult === "object" && + pullResult !== null ? Promise.resolve( c.mapper(pullResult as PullBlock, c.key, prev), ).then((output) => ({ diff --git a/apps/roam/src/utils/getDiscourseContextResults.ts b/apps/roam/src/utils/getDiscourseContextResults.ts index 3794f758f..8962dd5cf 100644 --- a/apps/roam/src/utils/getDiscourseContextResults.ts +++ b/apps/roam/src/utils/getDiscourseContextResults.ts @@ -7,9 +7,15 @@ import getDiscourseRelations, { DiscourseRelation, } from "./getDiscourseRelations"; import { Selection } from "./types"; +import { getSetting } from "./extensionSettings"; +import { + ANY_RELATION_NAME, + ANY_RELATION_REGEX, +} from "./deriveDiscourseNodeAttribute"; const resultCache: Record>> = {}; const CACHE_TIMEOUT = 1000 * 60 * 5; +const ANY_RELATION_ID = "any_relation"; type BuildQueryConfig = { args: { @@ -51,7 +57,6 @@ const buildSelections = ({ text: `node:${conditionUid}-Context`, }); } - return selections; }; @@ -130,7 +135,20 @@ const buildQueryConfig = ({ const returnNode = nodeTextByType[target]; const conditionUid = window.roamAlphaAPI.util.generateUID(); + const isAllRelationsQuery = ANY_RELATION_REGEX.test(r.label); const selections = buildSelections({ r, conditionUid }); + const findVariables = isAllRelationsQuery + ? [ + { + label: "relationUid", + variable: `${conditionUid}-relSchema`, + }, + { + label: "effectiveSource", + variable: `${conditionUid}-relSource`, + }, + ] + : []; const { nodes, relations } = fireQueryContext; return { relation, @@ -148,6 +166,7 @@ const buildQueryConfig = ({ }, ], selections, + findVariables, context: { relationsInQuery: [relation], customNodes: nodes, @@ -180,6 +199,10 @@ const getDiscourseContextResults = async ({ const discourseNode = findDiscourseNode({ uid: targetUid }); if (!discourseNode) return []; + const useReifiedRelations = getSetting( + "use-reified-relations", + false, + ); const nodeType = discourseNode?.type; const nodeTextByType = Object.fromEntries( nodes.map(({ type, text }) => [type, text]), @@ -211,9 +234,25 @@ const getDiscourseContextResults = async ({ }); const relationsWithComplement = Array.from(uniqueRelations.values()); + const queryRelations = useReifiedRelations + ? [ + { + id: ANY_RELATION_ID, + r: { + id: ANY_RELATION_ID, + complement: ANY_RELATION_NAME, + label: ANY_RELATION_NAME, + triples: [], + source: "*", + destination: "*", + }, + complement: false, + }, + ] + : relationsWithComplement; const context = { nodes, relations }; - const queryConfigs = relationsWithComplement.map((relation) => + const queryConfigs = queryRelations.map((relation) => buildQueryConfig({ args, targetUid, @@ -225,13 +264,58 @@ const getDiscourseContextResults = async ({ complement: relation.complement, }), ); + const postQueryOnResult = useReifiedRelations ? onResult : undefined; + onResult = postQueryOnResult ? undefined : onResult; - const resultsWithRelation = await executeQueries( + let resultsWithRelation = await executeQueries( queryConfigs, targetUid, nodeTextByType, onResult, ); + if ( + useReifiedRelations && + resultsWithRelation.length > 0 && + resultsWithRelation[0].results.length > 0 + ) { + const byRel: Record = {}; + const results = resultsWithRelation[0].results; + resultsWithRelation = []; + for (const r of results) { + const relKey = `${r.relationUid as string}-${r.effectiveSource !== targetUid}`; + byRel[relKey] = byRel[relKey] || []; + byRel[relKey].push(r); + } + resultsWithRelation = Array.from(uniqueRelations.entries()).flatMap( + ([ruid, relation]) => { + const results = byRel[ruid]; + if (!results?.length) return []; + delete byRel[ruid]; + const isComplement = relation.complement; + return [ + { + relation: { + id: relation.id, + label: isComplement ? relation.r.complement : relation.r.label, + isComplement, + text: isComplement ? relation.r.complement : relation.r.label, + target: isComplement ? relation.r.source : relation.r.destination, + }, + results, + }, + ]; + }, + ); + Object.keys(byRel).forEach((ruid) => { + /* + * A stored reified relation can outlive its relation schema, or the + * schema can stop applying to this node type after source/destination + * settings change. Drop that stale result from discourse context + * instead of treating it as a runtime failure. + */ + console.warn("Relation with obsolete relation type:" + ruid); + }); + } const groupedResults = Object.fromEntries( resultsWithRelation.map((r) => [ r.relation.text, @@ -256,10 +340,14 @@ const getDiscourseContextResults = async ({ }), ), ); - return Object.entries(groupedResults).map(([label, results]) => ({ - label, - results, - })); + const asResultList = Object.entries(groupedResults) + .filter(([, results]) => Object.keys(results).length > 0) + .map(([label, results]) => ({ + label, + results, + })); + if (postQueryOnResult) asResultList.map((r) => postQueryOnResult(r)); + return asResultList; }; export default getDiscourseContextResults; diff --git a/apps/roam/src/utils/predefinedSelections.ts b/apps/roam/src/utils/predefinedSelections.ts index 89a2209ff..060a33750 100644 --- a/apps/roam/src/utils/predefinedSelections.ts +++ b/apps/roam/src/utils/predefinedSelections.ts @@ -367,9 +367,7 @@ const predefinedSelections: PredefinedSelection[] = [ (c): c is QBClause => c.type === "clause" && c.target === selectedVar, ); if (introducedCondition?.relation === "references") { - const sourceUid = result[ - `${introducedCondition.source}-uid` - ] as string; + const sourceUid = result[`${introducedCondition.source}-uid`]; if (sourceUid) { const blockText = getTextByBlockUid(sourceUid); await updateBlock({ diff --git a/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts b/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts index 0deb4f176..f89108983 100644 --- a/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts +++ b/apps/roam/src/utils/registerDiscourseDatalogTranslators.ts @@ -8,7 +8,10 @@ import conditionToDatalog, { getTitleDatalog, registerDatalogTranslator, } from "./conditionToDatalog"; -import { ANY_RELATION_REGEX } from "./deriveDiscourseNodeAttribute"; +import { + ANY_RELATION_NAME, + ANY_RELATION_REGEX, +} from "./deriveDiscourseNodeAttribute"; import getDiscourseNodes, { excludeDefaultNodes, type DiscourseNode, @@ -404,7 +407,7 @@ const registerDiscourseDatalogTranslators = (snapshot?: SettingsSnapshot) => { const relationLabels = new Set( discourseRelations.flatMap((d) => [d.label, d.complement].filter(Boolean)), ); - relationLabels.add(ANY_RELATION_REGEX.source); + relationLabels.add(ANY_RELATION_NAME); relationLabels.forEach((label) => { unregisters.add( registerDatalogTranslator({