Skip to content

Commit 804e580

Browse files
committed
MDH: dataset-driven type inference for aggregation variables
Whole-quoted "{var}" placeholders now default to the matched field's data type (detected via $type and cached) instead of the input-text shape — so a numeric-looking value typed against a string field no longer silently fails to match. The resolved type is folded into an "Auto (Type)" selector per variable and can be overridden (String/Number/Boolean/Null); Boolean/Null are offered only when the value makes them meaningful, and the "Auto (X)" label always reflects the true inferred type even under a manual override. Substitution stays textual-then-JSON5.parse (byte-identical when no type resolves); bulk/download and persistence (last pipeline, per-collection state, query history) all carry the chosen types. Field types reuse the Stats $type facet machinery. New modules: placeholderSyntax, placeholderFields, fieldTypes. Full unit coverage; suite green.
1 parent 6f42a90 commit 804e580

20 files changed

Lines changed: 838 additions & 50 deletions

src/console/console.css

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,15 +419,37 @@ body {
419419
font-size: 11px;
420420
color: var(--accent);
421421
flex-shrink: 0;
422-
min-width: 80px;
423422
}
424423

425424
.placeholder-input {
426425
flex: 1;
427-
padding: 4px 8px;
426+
min-width: 0;
427+
height: 24px;
428+
padding: 2px 8px;
428429
font-size: 12px;
429430
}
430431

432+
.placeholder-type-select {
433+
flex-shrink: 0;
434+
height: 24px;
435+
padding: 0 6px;
436+
font-family: var(--font-mono);
437+
font-size: 11px;
438+
border: 1px solid var(--border);
439+
border-radius: var(--radius);
440+
background: var(--bg-input);
441+
color: var(--text-primary);
442+
cursor: pointer;
443+
}
444+
445+
.placeholder-warn {
446+
flex-shrink: 0;
447+
font-size: 13px;
448+
line-height: 1;
449+
color: var(--warning-fg);
450+
cursor: help;
451+
}
452+
431453
/* ── Pipeline debug ───────────────────────────── */
432454

433455
.pipeline-debug {

src/mdh/components/DataPanel.jsx

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export default function DataPanel() {
4848
// that drives the Variables inputs and the Pipeline Debug — see
4949
// useEditorSnapshot. recomputeEditorState() is called on every editor edit and
5050
// on placeholder changes.
51-
const [editorState, recomputeEditorState] = useEditorSnapshot(editorRef, pipeline.computeEditorState);
51+
const [editorState, recomputeEditorState] = useEditorSnapshot(editorRef, pipeline.computeEditorStateWithTypes);
5252

5353
const collection = selectedCollection.value;
5454

@@ -95,9 +95,10 @@ export default function DataPanel() {
9595
async function runQuery() {
9696
if (!collection || !editorRef.current) return;
9797
const rawText = editorRef.current.getValue();
98-
const result = await query.runQuery(collection, rawText, pipeline.substitutePlaceholders);
98+
await pipeline.ensureFieldTypes(collection, pipeline.referencedFields(rawText));
99+
const result = await query.runQuery(collection, rawText, pipeline.substituteWithTypes);
99100
if (result) {
100-
addToHistory(collection, rawText, { ...pipeline.placeholderValues.value });
101+
addToHistory(collection, rawText, { ...pipeline.placeholderValues.value }, { ...pipeline.placeholderTypes.value });
101102
}
102103
}
103104

@@ -119,6 +120,7 @@ export default function DataPanel() {
119120
if (external && external.collection === collection) {
120121
pendingPipelineLoad.value = null;
121122
if (external.variables) pipeline.placeholderValues.value = { ...external.variables };
123+
if (external.placeholderTypes) pipeline.placeholderTypes.value = { ...external.placeholderTypes };
122124
setTimeout(() => {
123125
if (!editorRef.current) return;
124126
pipeline.suppressSync.value = true;
@@ -133,6 +135,7 @@ export default function DataPanel() {
133135
if (pending) {
134136
pendingLoadRef.current = null;
135137
if (pending.variables) pipeline.placeholderValues.value = { ...pending.variables };
138+
if (pending.placeholderTypes) pipeline.placeholderTypes.value = { ...pending.placeholderTypes };
136139
setTimeout(() => {
137140
if (!editorRef.current) return;
138141
pipeline.suppressSync.value = true;
@@ -148,6 +151,7 @@ export default function DataPanel() {
148151
if (saved) {
149152
skip.value = saved.skip || 0;
150153
if (saved.variables) pipeline.placeholderValues.value = { ...saved.variables };
154+
if (saved.placeholderTypes) pipeline.placeholderTypes.value = { ...saved.placeholderTypes };
151155
setTimeout(() => {
152156
if (!editorRef.current) return;
153157
pipeline.suppressSync.value = true;
@@ -172,11 +176,18 @@ export default function DataPanel() {
172176
return () => { saveStateForCleanup(collection); };
173177
}, [collection]);
174178

179+
useEffect(() => {
180+
if (!collection || !editorRef.current) return;
181+
pipeline.ensureFieldTypes(collection, pipeline.referencedFields(editorState.text))
182+
.then((changed) => { if (changed) recomputeEditorState(); });
183+
}, [editorState.text, collection]);
184+
175185
function saveStateForCleanup(col) {
176186
if (!editorRef.current) return;
177187
savePipelineState(col, {
178188
pipelineText: editorRef.current.getValue(),
179189
variables: { ...pipeline.placeholderValues.value },
190+
placeholderTypes: { ...pipeline.placeholderTypes.value },
180191
skip: skip.value,
181192
});
182193
}
@@ -197,7 +208,7 @@ export default function DataPanel() {
197208
// Falls back to {} when the pipeline has no $match or is unparseable.
198209
if (!editorRef.current) return {};
199210
try {
200-
const text = pipeline.substitutePlaceholders(editorRef.current.getValue());
211+
const text = pipeline.substituteWithTypes(editorRef.current.getValue());
201212
const parsed = JSON5.parse(text);
202213
if (Array.isArray(parsed)) {
203214
const match = parsed.find((s) => s && typeof s === 'object' && s.$match);
@@ -318,7 +329,7 @@ export default function DataPanel() {
318329
clearTimeout(persistTimerRef.current);
319330
persistTimerRef.current = setTimeout(() => {
320331
if (!editorRef.current) return;
321-
saveLastPipeline(editorRef.current.getValue(), pipeline.placeholderValues.value);
332+
saveLastPipeline(editorRef.current.getValue(), pipeline.placeholderValues.value, pipeline.placeholderTypes.value);
322333
}, 400);
323334
}
324335

@@ -338,16 +349,17 @@ export default function DataPanel() {
338349
}
339350
}
340351

341-
function handleLoadPipeline(pipelineText, col, variables) {
352+
function handleLoadPipeline(pipelineText, col, variables, placeholderTypes) {
342353
if (col && col !== collection) {
343354
// Defer to the [collection] effect — it will apply the pipeline and variables
344355
// after reset() instead of racing the default path.
345-
pendingLoadRef.current = { pipelineText, variables };
356+
pendingLoadRef.current = { pipelineText, variables, placeholderTypes };
346357
selectedCollection.value = col;
347358
return;
348359
}
349360
if (selectionMode.value) selectionPipelineDirty.value = true;
350361
if (variables) pipeline.placeholderValues.value = { ...variables };
362+
if (placeholderTypes) pipeline.placeholderTypes.value = { ...placeholderTypes };
351363
if (editorRef.current) {
352364
pipeline.suppressSync.value = true;
353365
editorRef.current.setValue(pipelineText);
@@ -427,6 +439,14 @@ export default function DataPanel() {
427439
handleSetPlaceholder._timer = setTimeout(runQuery, 400);
428440
}
429441

442+
function handleSetPlaceholderType(name, type) {
443+
pipeline.setPlaceholderType(name, type);
444+
persistLastPipeline();
445+
recomputeEditorState();
446+
clearTimeout(handleSetPlaceholder._timer);
447+
handleSetPlaceholder._timer = setTimeout(runQuery, 400);
448+
}
449+
430450
async function downloadAll() {
431451
const tc = pagination.totalCount.value;
432452
if (tc !== null && tc > 10_000) {
@@ -455,7 +475,7 @@ export default function DataPanel() {
455475

456476
let pipelineStages;
457477
try {
458-
const text = pipeline.substitutePlaceholders(editorRef.current.getValue());
478+
const text = pipeline.substituteWithTypes(editorRef.current.getValue());
459479
const parsed = JSON5.parse(text);
460480
if (!Array.isArray(parsed)) throw new Error('pipeline must be a JSON array');
461481
pipelineStages = stripPaginationStages(parsed);
@@ -533,7 +553,7 @@ export default function DataPanel() {
533553
if (!editorRef.current) return;
534554
let pipelineStages;
535555
try {
536-
const text = pipeline.substitutePlaceholders(editorRef.current.getValue());
556+
const text = pipeline.substituteWithTypes(editorRef.current.getValue());
537557
const parsed = JSON5.parse(text);
538558
if (!Array.isArray(parsed)) throw new Error('pipeline must be a JSON array');
539559
pipelineStages = stripPaginationStages(parsed);
@@ -614,7 +634,7 @@ export default function DataPanel() {
614634
if (!editorRef.current) return;
615635
let pipelineStages;
616636
try {
617-
const text = pipeline.substitutePlaceholders(editorRef.current.getValue());
637+
const text = pipeline.substituteWithTypes(editorRef.current.getValue());
618638
const parsed = JSON5.parse(text);
619639
if (!Array.isArray(parsed)) throw new Error('pipeline must be a JSON array');
620640
pipelineStages = stripPaginationStages(parsed);
@@ -706,7 +726,7 @@ export default function DataPanel() {
706726
if (!editorRef.current) return;
707727
let pipelineStages;
708728
try {
709-
const text = pipeline.substitutePlaceholders(editorRef.current.getValue());
729+
const text = pipeline.substituteWithTypes(editorRef.current.getValue());
710730
const parsed = JSON5.parse(text);
711731
if (!Array.isArray(parsed)) throw new Error('pipeline must be a JSON array');
712732
pipelineStages = stripPaginationStages(parsed);
@@ -847,8 +867,11 @@ export default function DataPanel() {
847867
<PlaceholderInputs
848868
names={placeholderNames}
849869
values={pipeline.placeholderValues.value}
870+
types={pipeline.placeholderTypes.value}
850871
onSetValue={handleSetPlaceholder}
872+
onSetType={handleSetPlaceholderType}
851873
onRunQuery={runQuery}
874+
resolvedTypeFor={(name) => pipeline.resolvedTypeForName(name, editorState.fieldMap || {}, editorState.parsed != null)}
852875
/>
853876
<PipelineDebug pipeline={editorState.parsed} />
854877
</div>

src/mdh/components/PipelineEditor.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ export default function PipelineEditor({ editorRef, initialValue, onChange, onVa
8181
updateSaveBtn();
8282
}
8383

84-
function loadFromPanel(pipeline, collection, variables) {
84+
function loadFromPanel(pipeline, collection, variables, placeholderTypes) {
8585
setLibraryOpen(false);
86-
onLoadPipeline(pipeline, collection, variables);
86+
onLoadPipeline(pipeline, collection, variables, placeholderTypes);
8787
}
8888

8989
return (

src/mdh/components/PlaceholderInputs.jsx

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,42 @@
11
import { h } from 'preact';
22
import { useState } from 'preact/hooks';
33
import { domain, token } from '../store.js';
4+
import { isJson5NumberLiteral } from '../hooks/usePipeline.js';
5+
6+
// True when the typed value can be matched as the given type (drives the
7+
// "won't match" hint). string / null / auto / undefined accept anything.
8+
export function isCompatibleWithType(val, type) {
9+
if (type === 'number') return isJson5NumberLiteral(val);
10+
if (type === 'boolean') return val === 'true' || val === 'false';
11+
return true;
12+
}
13+
14+
const CAP = { string: 'String', number: 'Number', boolean: 'Boolean', null: 'Null' };
15+
16+
// Static tooltip describing what the control is — not a repeat of the resolved
17+
// type, which is already shown in the "Auto (X)" option text.
18+
const TYPE_SELECT_TITLE = 'Data type for this variable in the query — Auto infers it from the matched field';
19+
20+
// What the value-based (Auto, no dataset type) path coerces a value to — mirrors
21+
// renderWholeToken's default branch order. Used to label "Auto (X)".
22+
export function valueBasedType(val) {
23+
if (val === 'true' || val === 'false') return 'boolean';
24+
if (val === 'null') return 'null';
25+
if (isJson5NumberLiteral(val)) return 'number';
26+
return 'string';
27+
}
28+
29+
// Type options to offer for a variable. Auto/String/Number are always available;
30+
// Boolean only when the value is true/false; Null only when the value is empty or
31+
// 'null' (matching JSON null is pointless otherwise). The current override is
32+
// always included so a previously-saved Boolean/Null choice is never dropped.
33+
export function typeOptionsFor(value, override) {
34+
const v = value || '';
35+
const opts = ['auto', 'string', 'number'];
36+
if (v === 'true' || v === 'false' || override === 'boolean') opts.push('boolean');
37+
if (v === '' || v === 'null' || override === 'null') opts.push('null');
38+
return opts;
39+
}
440

541
export function parseAnnotationId(input) {
642
if (/^\d+$/.test(input)) return input;
@@ -31,7 +67,7 @@ function extractDatapoints(nodes, fields) {
3167
}
3268
}
3369

34-
export default function PlaceholderInputs({ names, values, onSetValue, onRunQuery }) {
70+
export default function PlaceholderInputs({ names, values, types, onSetValue, onSetType, onRunQuery, resolvedTypeFor }) {
3571
const [annotRow, setAnnotRow] = useState(false);
3672
const [annotStatus, setAnnotStatus] = useState('');
3773

@@ -72,19 +108,38 @@ export default function PlaceholderInputs({ names, values, onSetValue, onRunQuer
72108
<span class="placeholder-annotation-status">{annotStatus}</span>
73109
</div>
74110
)}
75-
{names.map((name) => (
76-
<div class="placeholder-row" key={name}>
77-
<span class="placeholder-name">{`{${name}}`}</span>
78-
<input
79-
class="input placeholder-input"
80-
value={values[name] || ''}
81-
onInput={(e) => {
82-
onSetValue(name, e.target.value);
83-
}}
84-
onKeyDown={(e) => { if (e.key === 'Enter') onRunQuery(); }}
85-
/>
86-
</div>
87-
))}
111+
{names.map((name) => {
112+
const rt = resolvedTypeFor ? resolvedTypeFor(name) : { type: undefined, autoType: undefined };
113+
const value = values[name] || '';
114+
const override = (types && types[name]) || '';
115+
const autoLabelType = rt.autoType || valueBasedType(value); // what Auto yields, ignoring override
116+
const effective = override || rt.type; // override-first effective type, for the compat check
117+
const incompatible = value !== '' && !isCompatibleWithType(value, effective);
118+
return (
119+
<div class="placeholder-row" key={name}>
120+
<span class="placeholder-name">{`{${name}}`}</span>
121+
<input
122+
class="input placeholder-input"
123+
value={value}
124+
onInput={(e) => { onSetValue(name, e.target.value); }}
125+
onKeyDown={(e) => { if (e.key === 'Enter') onRunQuery(); }}
126+
/>
127+
<select
128+
class="placeholder-type-select"
129+
value={override || 'auto'}
130+
title={TYPE_SELECT_TITLE}
131+
onChange={(e) => onSetType(name, e.target.value)}
132+
>
133+
{typeOptionsFor(value, override).map((opt) => (
134+
<option value={opt} key={opt}>{opt === 'auto' ? `Auto (${CAP[autoLabelType]})` : CAP[opt]}</option>
135+
))}
136+
</select>
137+
{incompatible && (
138+
<span class="placeholder-warn" title={`This value won't match as ${CAP[effective]}`}>{'⚠'}</span>
139+
)}
140+
</div>
141+
);
142+
})}
88143
</div>
89144
);
90145
}

src/mdh/components/QueryHistory.jsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,22 @@ function dedupKey(collection, pipeline) {
2626
return collection + '::' + normalized;
2727
}
2828

29-
export async function addToHistory(collection, pipeline, variables) {
29+
export async function addToHistory(collection, pipeline, variables, placeholderTypes) {
3030
const queryHistory = await readList('queryHistory');
3131
const key = dedupKey(collection, pipeline);
3232
const filtered = queryHistory.filter((e) => dedupKey(e.collection, e.pipeline) !== key);
3333
const entry = { collection, pipeline, ts: Date.now() };
3434
if (variables && Object.keys(variables).length > 0) entry.variables = variables;
35+
if (placeholderTypes && Object.keys(placeholderTypes).length > 0) entry.placeholderTypes = placeholderTypes;
3536
filtered.unshift(entry);
3637
await writeList('queryHistory', filtered.slice(0, MAX_HISTORY));
3738
}
3839

39-
export async function saveQuery(collection, pipeline, name, variables) {
40+
export async function saveQuery(collection, pipeline, name, variables, placeholderTypes) {
4041
const savedQueries = await readList('savedQueries');
4142
const entry = { collection, pipeline, name, ts: Date.now() };
4243
if (variables && Object.keys(variables).length > 0) entry.variables = variables;
44+
if (placeholderTypes && Object.keys(placeholderTypes).length > 0) entry.placeholderTypes = placeholderTypes;
4345
savedQueries.push(entry);
4446
await writeList('savedQueries', savedQueries);
4547
}
@@ -67,7 +69,7 @@ function formatTime(ts) {
6769
function QueryRow({ item, currentCollection, savedName, onLoad, onDismiss, showUnsave, onUnsave }) {
6870
return (
6971
<div class={'query-history-item' + (item.collection === currentCollection ? ' query-history-item-current' : '')}>
70-
<div class="query-history-item-info" onClick={() => { onLoad(item.pipeline, item.collection, item.variables); onDismiss(); }}>
72+
<div class="query-history-item-info" onClick={() => { onLoad(item.pipeline, item.collection, item.variables, item.placeholderTypes); onDismiss(); }}>
7173
<span class="query-history-collection">{item.collection}</span>
7274
{savedName && <span class="query-history-name">{savedName}</span>}
7375
<span class="query-history-time">{formatTime(item.ts)}</span>

0 commit comments

Comments
 (0)