fix(dataview): recover rows for implicit LIST GROUP BY; label whitespace group keys

Two findings from testing 0.11.37 live against Dataview 0.5.68 in the app —
neither reachable through the pre-existing mock fixtures, both real:

1. Implicit `LIST ... GROUP BY` returned bare group keys, no rows. Dataview's
   programmatic query() API drops grouped rows when nothing in the query
   references `rows` (unlike rendered markdown), so `LIST FROM "x" GROUP BY
   file.folder` came back as just the folder names. normalizeListGroupByQuery
   injects a default `rows.file.link` output expression for exactly that shape
   — a LIST with GROUP BY and no explicit output expression — so proper
   {key, rows} groups come back. Queries that already name an output
   expression, and non-LIST / non-grouped queries, pass through untouched; the
   user-facing echoed query stays the original input.

2. `TASK ... GROUP BY status` groups incomplete tasks under a space-character
   key, which rendered as an empty bold header (`**** (n)`). groupKeyLabel now
   treats whitespace-only keys the same as empty → `(no group)`.

The test mock is made faithful to the live behavior: a grouped LIST that
references `rows` returns list-pair wrappers, one that doesn't collapses to bare
keys — so the injection is actually observable rather than passing vacuously.
Adds normalization unit tests, an end-to-end implicit-grouped-LIST recovery
test, and a whitespace-key label test. build + lint + test green, 355/355.
This commit is contained in:
Aaron Bockelie 2026-07-03 21:47:21 -05:00
parent 88f0ce5efd
commit 1fdcbc3a8b
3 changed files with 116 additions and 3 deletions

View file

@ -166,7 +166,10 @@ function asGroup(item: DataviewValue): DataviewGroup | null {
}
function groupKeyLabel(key: unknown): string {
if (key === null || key === undefined || key === '') {
// Empty or whitespace-only keys (e.g. `TASK ... GROUP BY status` puts
// incomplete tasks under the space-character status) render as an empty bold
// header otherwise. Collapse them to a stable label.
if (key === null || key === undefined || (typeof key === 'string' && key.trim() === '')) {
return '(no group)';
}
if (typeof key === 'string') {

View file

@ -76,6 +76,29 @@ function unwrapGroup(el: unknown): { key: unknown; rows: unknown[] } | null {
return { key: obj.key, rows: toPlainArray(inner) };
}
/**
* Dataview's programmatic `query()` API collapses a grouped LIST to bare group
* keys when nothing in the query references `rows` the grouped items are
* dropped from the payload entirely (unlike rendered markdown, which shows
* them). So `LIST FROM "x" GROUP BY file.folder` comes back as just the folder
* names, with no files. Inject a default `rows.file.link` output expression for
* exactly that shape a LIST with GROUP BY and no explicit output expression
* so the API returns proper `{ key, rows }` groups instead. Queries that already
* name an output expression, and non-LIST / non-grouped queries, pass through
* unchanged.
*/
export function normalizeListGroupByQuery(query: string): string {
const trimmed = query.trimStart();
if (!/^LIST\b/i.test(trimmed)) return query;
if (!/\bGROUP\s+BY\b/i.test(trimmed)) return query;
// There's no explicit output expression when a clause keyword (or the end of
// the string) immediately follows the LIST keyword.
const noOutputExpr = /^LIST\s+(FROM|WHERE|SORT|GROUP|FLATTEN|LIMIT)\b/i.test(trimmed)
|| /^LIST\s*$/i.test(trimmed);
if (!noOutputExpr) return query;
return trimmed.replace(/^LIST\b/i, 'LIST rows.file.link');
}
/** Dataview link value */
interface DataviewLink {
@ -224,7 +247,11 @@ export class DataviewTool {
// Execute DQL query. Dataview returns {successful: false, error}
// for syntax/runtime errors rather than throwing — propagate that
// status to the outer envelope so the formatter can render it.
const result: DataviewQueryResult = await dataviewAPI.query(query);
// Recover grouped rows for an implicit `LIST ... GROUP BY` — see
// normalizeListGroupByQuery. The user-facing `query` echoed below stays
// the original input; only the executed query is augmented.
const effectiveQuery = normalizeListGroupByQuery(query);
const result: DataviewQueryResult = await dataviewAPI.query(effectiveQuery);
const innerSuccess = result.successful !== false;
return {
success: innerSuccess,

View file

@ -1,4 +1,4 @@
import { DataviewTool, isDataviewToolAvailable } from '../src/tools/dataview-tool';
import { DataviewTool, isDataviewToolAvailable, normalizeListGroupByQuery } from '../src/tools/dataview-tool';
import { PluginDetector } from '../src/utils/plugin-detector';
import { formatResponse } from '../src/formatters';
@ -66,6 +66,19 @@ class MockDataviewAPI {
}
if (query.includes('GROUP BY')) {
// Faithful to real Dataview: a grouped LIST that references `rows` returns
// list-pair group wrappers; one that does NOT collapses to bare group keys
// (the rows are dropped from the query() payload). This is what makes the
// normalizeListGroupByQuery injection observable (#220 live follow-up).
if (!query.includes('rows')) {
return {
successful: true,
value: {
type: 'list',
values: ['groupA', 'groupB']
}
};
}
return {
successful: true,
value: {
@ -611,6 +624,76 @@ describe('Dataview Integration', () => {
});
});
// Live follow-up to #220 (found running 0.11.37 against Dataview 0.5.68):
// Dataview's query() API drops grouped rows for an implicit `LIST ... GROUP BY`
// (no `rows` reference), returning bare group keys. normalizeListGroupByQuery
// injects a default `rows.file.link` output so proper groups come back.
describe('implicit LIST ... GROUP BY row recovery', () => {
test('injects rows.file.link only for a grouped LIST with no output expression', () => {
// Implicit grouped LIST → augmented
expect(normalizeListGroupByQuery('LIST FROM "x" GROUP BY file.folder'))
.toBe('LIST rows.file.link FROM "x" GROUP BY file.folder');
expect(normalizeListGroupByQuery('LIST GROUP BY status'))
.toBe('LIST rows.file.link GROUP BY status');
expect(normalizeListGroupByQuery('list from "x" group by y'))
.toBe('LIST rows.file.link from "x" group by y');
// Already has an output expression → unchanged
expect(normalizeListGroupByQuery('LIST rows.file.name FROM "x" GROUP BY file.folder'))
.toBe('LIST rows.file.name FROM "x" GROUP BY file.folder');
// Not grouped → unchanged
expect(normalizeListGroupByQuery('LIST FROM "x"'))
.toBe('LIST FROM "x"');
// Not a LIST → unchanged
expect(normalizeListGroupByQuery('TABLE file.mtime FROM "x" GROUP BY file.folder'))
.toBe('TABLE file.mtime FROM "x" GROUP BY file.folder');
expect(normalizeListGroupByQuery('TASK FROM "x" GROUP BY status'))
.toBe('TASK FROM "x" GROUP BY status');
});
test('an implicit grouped LIST renders grouped rows end-to-end, not bare keys', async () => {
const app = new MockApp(true, true);
const api = new MockObsidianAPI(app);
const tool = new DataviewTool(api as any);
// No `rows` in the user query; the faithful mock would return bare keys
// without the injection. With it, proper {key, rows} groups come back.
const result = await tool.executeQuery('LIST FROM #tag GROUP BY group');
const output = formatResponse('dataview', 'query', result, false);
expect(output).toContain('Dataview: LIST');
expect(output).toContain('groupA');
expect(output).toContain('Note 1'); // grouped row recovered
expect(output).toContain('Note 2');
expect(output).not.toContain('$widget');
});
});
// Live follow-up: `TASK ... GROUP BY status` groups incomplete tasks under a
// space-character key, which rendered as an empty bold header (`**** (n)`).
describe('whitespace group key label', () => {
test('a whitespace-only group key renders as (no group), not empty bold', () => {
const result = {
success: true,
query: 'TASK FROM "x" GROUP BY status',
format: 'dql',
result: {
type: 'task',
values: [
{ key: ' ', rows: [{ text: 'incomplete task', completed: false, path: 'Note1.md' }] }
]
},
type: 'task'
};
const output = formatResponse('dataview', 'query', result, false);
expect(output).toContain('(no group)');
expect(output).toContain('incomplete task');
expect(output).not.toContain('**** '); // no empty bold header
});
});
describe('Tool Availability Detection', () => {
test('should detect when Dataview tool is not available', () => {
const app = new MockApp(false);