From 922c21c9f9bde0394680e04a6cc2da3dcfdf012f Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:05:55 -0800 Subject: [PATCH] Add aggregate and grouping selection support (#312) --- docs/query-builder.md | 9 +++++++ src/utils/predefinedSelections.ts | 42 +++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/docs/query-builder.md b/docs/query-builder.md index 9a9c91e..aa756a7 100644 --- a/docs/query-builder.md +++ b/docs/query-builder.md @@ -183,6 +183,15 @@ The `label`, which gets specified after **AS**, denotes the name of the column t | `node` | Use the label to edit the column header of the first column | [Link](examples.md#first-column-header) | | `add({label1}, {label2})` | Add the values of two columns. Supports adding values to dates. | [Link](examples.md#add-or-subtract) | | `subtract({label1}, {label2})` | Subtract the values betweenn two columns. Supports adding values to dates. | [Link](examples.md#add-or-subtract) | +| `count({node})` | Count all matches for an intermediary `{node}` variable (for example, references per block). | N/A | +| `count-distinct({node})` | Count unique matches for an intermediary `{node}` variable. | N/A | + +Aggregate selections are grouped by the returned `node` by default. A common pattern is: + +- Condition: `node` `references` `tag` +- Selection: `count(tag)` +- Sort by that count column descending +- Optional exclusion: add a `not` condition using the same intermediary variable (for example, `tag` `has title` `Some Page`) ## Manipulating Results diff --git a/src/utils/predefinedSelections.ts b/src/utils/predefinedSelections.ts index 719e9ff..0f218e3 100644 --- a/src/utils/predefinedSelections.ts +++ b/src/utils/predefinedSelections.ts @@ -28,6 +28,8 @@ const ADD_TEST = /^add\(([^,)]+),([^,)]+)\)$/i; const NODE_TEST = /^node:(\s*[^:]+\s*)(:.*)?$/i; const ACTION_TEST = /^action:\s*([^:]+)\s*(?::(.*))?$/i; const DATE_FORMAT_TEST = /^date-format\(([^,)]+),([^,)]+)\)$/i; +const COUNT_TEST = /^count\(\s*([^)]+?)\s*\)$/i; +const COUNT_DISTINCT_TEST = /^count-distinct\(\s*([^)]+?)\s*\)$/i; const MILLISECONDS_IN_DAY = 1000 * 60 * 60 * 24; const getArgValue = (key: string, result: QueryResult) => { @@ -42,6 +44,9 @@ const getArgValue = (key: string, result: QueryResult) => { return val; }; +const toDatalogVariable = (value = "") => + value.replace(/^\?/, "").replace(/[\s"()[\]{}/\\^@,~`]/g, ""); + const getUserDisplayNameById = (id?: number) => { if (!id) { return "Anonymous User"; @@ -168,6 +173,25 @@ const isVariableExposed = ( } }); +const getAggregateSelection = ({ + match, + where, + fn, +}: { + match: RegExpExecArray | null; + where: DatalogClause[]; + fn: "count" | "count-distinct"; +}) => { + const variable = (match?.[1] || "") + .trim() + .replace(/^\?/, "") + .replace(/^\{+/, "") + .replace(/\}+$/, ""); + if (!variable || !isVariableExposed(where, variable)) return ""; + const datalogVariable = toDatalogVariable(variable); + return datalogVariable ? `(${fn} ?${datalogVariable})` : ""; +}; + export type SelectionSuggestion = { text: string; children?: SelectionSuggestion[]; @@ -216,6 +240,10 @@ const EDIT_DATE_SUGGESTIONS: SelectionSuggestion[] = [ ]; const CREATE_BY_SUGGESTIONS: SelectionSuggestion[] = [{ text: "created by" }]; const EDIT_BY_SUGGESTIONS: SelectionSuggestion[] = [{ text: "edited by" }]; +const COUNT_SUGGESTIONS: SelectionSuggestion[] = [{ text: "count({{node}})" }]; +const COUNT_DISTINCT_SUGGESTIONS: SelectionSuggestion[] = [ + { text: "count-distinct({{node}})" }, +]; // TOO SLOW // const ATTR_SUGGESTIONS: SelectionSuggestion[] = ( // window.roamAlphaAPI.data.fast.q( @@ -479,6 +507,20 @@ const predefinedSelections: PredefinedSelection[] = [ }, suggestions: [{ text: "date-format({{node}},[MMM do, yyyy])" }], }, + { + test: COUNT_TEST, + pull: ({ match, where }) => + getAggregateSelection({ match, where, fn: "count" }), + mapper: () => "", + suggestions: COUNT_SUGGESTIONS, + }, + { + test: COUNT_DISTINCT_TEST, + pull: ({ match, where }) => + getAggregateSelection({ match, where, fn: "count-distinct" }), + mapper: () => "", + suggestions: COUNT_DISTINCT_SUGGESTIONS, + }, { test: ACTION_TEST, pull: ({ returnNode }) => `(pull ?${returnNode} [:block/uid])`,