h-sphere_sql-seal/src/modules/editor/codeblockHandler/CodeblockProcessor.ts
Kacper c038366783
feat: add TAGS() macro and auto-detection for multi-tag AND queries (#202)
* feat: add TAGS() macro and auto-detection for multi-tag AND queries

Introduces two complementary solutions for the common mistake of filtering
by multiple tags with AND (which always returns zero results):

1. TAGS() macro: explicit opt-in syntax
   WHERE TAGS('#project', '#active')
   → path IN (SELECT path FROM tags WHERE tag = '#project'
              INTERSECT SELECT path FROM tags WHERE tag = '#active')

2. Auto-detection: transparently rewrites the broken pattern
   WHERE tag = '#a' AND tag = '#b'
   → path IN (...INTERSECT...)
   Mixed conditions (AND name = 'test') are preserved correctly.

Both use INTERSECT subqueries which are more flexible than an EXISTS
approach (no hardcoded table reference required).

Adds disableTagAutoDetection setting so advanced users can opt out of
the transparent rewrite and handle the SQL themselves.

Docs updated in query-vault-content.md and faq/understanding-tags.md.

* chore: adding changeset
2026-04-06 12:50:00 +01:00

223 lines
6 KiB
TypeScript

import { OmnibusRegistrator } from "@hypersphere/omnibus";
import {
App,
MarkdownPostProcessorContext,
MarkdownRenderChild,
} from "obsidian";
import { Sync } from "../../sync/sync/sync";
import { RendererRegistry, RenderReturn } from "../renderer/rendererRegistry";
import { ParserResult, parseWithDefaults, TableDefinition } from "../parser";
import { SqlocalDatabaseProxy } from "../../database/sqlocal/sqlocalDatabaseProxy";
import { displayError, displayNotice } from "../../../utils/ui";
import { transformQuery } from "../sql/sqlTransformer";
import { registerObservers } from "../../../utils/registerObservers";
import { Settings } from "../../settings/Settings";
import { ModernCellParser } from "../../syntaxHighlight/cellParser/ModernCellParser";
export class CodeblockProcessor extends MarkdownRenderChild {
registrator: OmnibusRegistrator;
renderer: RenderReturn;
private flags: ParserResult["flags"];
private extrasEl: HTMLElement;
private explainEl: HTMLElement;
constructor(
private el: HTMLElement,
private source: string,
private ctx: MarkdownPostProcessorContext,
private rendererRegistry: RendererRegistry,
private db: Pick<SqlocalDatabaseProxy, 'select' | 'explain'>,
private cellParser: ModernCellParser,
private settings: Settings,
private app: App,
private sync: Sync,
private tq: typeof transformQuery = transformQuery
) {
super(el);
this.registrator = this.sync.getRegistrator();
}
query: string;
async onload() {
try {
const defaults: ParserResult = {
flags: {
refresh: this.settings.get("enableDynamicUpdates"),
explain: false,
},
query: "",
renderer: {
options: "",
type: this.settings.get("defaultView").toUpperCase(),
},
tables: [],
};
const results = parseWithDefaults(
this.source,
this.rendererRegistry.getViewDefinitions(),
defaults,
this.rendererRegistry.flags,
);
if (results.tables) {
await this.registerTables(results.tables);
if (!results.query) {
displayNotice(
this.el,
`Creating tables: ${results.tables.map((t) => t.tableAlias).join(", ")}`,
);
return;
}
}
this.flags = results.flags;
let rendererEl = this.el;
if (this.flags.explain) {
this.extrasEl = this.el.createDiv({ cls: "sqlseal-extras-container" });
if (this.flags.explain) {
this.explainEl = this.extrasEl.createEl("pre", {
cls: "sqlseal-extras-explain-container",
});
}
rendererEl = this.el.createDiv({ cls: "sqlseal-renderer-container" });
}
// IF WE'RE ON CANVAS, LETS ADD BACKGRUND
if (this.isOnCanvas) {
rendererEl.classList.add('sqlseal-renderer-on-canvas')
}
this.renderer = this.rendererRegistry.prepareRender(
results.renderer.type.toLowerCase(),
results.renderer.options,
)(rendererEl, {
cellParser: this.cellParser,
sourcePath: this.sourceKey,
});
// FIXME: probably should save the one before transform and perform transform every time we execute it.
this.query = results.query;
await this.render();
} catch (e) {
displayError(this.el, e.toString());
}
}
onunload() {
this.registrator.offAll();
if (this.renderer?.cleanup) {
this.renderer.cleanup();
}
}
async render() {
try {
const registeredTablesForContext =
await this.sync.getTablesMappingForContext(this.sourceKey);
// Transforming Query
const res = this.tq(this.query, registeredTablesForContext, {
disableTagAutoDetection: this.settings.get('disableTagAutoDetection')
});
const transformedQuery = res.sql;
if (this.flags.refresh) {
registerObservers({
bus: this.registrator,
callback: () => this.render(),
fileName: this.sourceKey,
tables: res.mappedTables,
});
}
let variables = {};
const file = this.app.vault.getFileByPath(this.sourceKey);
if (file) {
const fileCache = this.app.metadataCache.getFileCache(file);
variables = {
...(fileCache?.frontmatter ?? {}),
path: file.path,
fileName: file.name,
basename: file.basename,
parent: file.parent?.path,
extension: file.extension,
};
}
// Merge with context frontmatter (used for Explorer variables)
variables = {
...variables,
...this.ctx.frontmatter,
};
if (this.flags.explain) {
// Rendering explain
const result = await this.db.explain(transformedQuery, variables);
this.explainEl.textContent = result;
}
const { data, columns } = (await this.db.select(
transformedQuery,
variables,
))!; // FIXME: check this
this.renderer.render({
data,
columns,
flags: this.flags,
frontmatter: variables,
});
} catch (e) {
this.renderer.error(e.toString());
}
}
private cachedName: string | null = null
get isOnCanvas() {
return !this.ctx.sourcePath
}
get canvasName() {
// This is hack to detect what name has current canvas.
// It's not fool proof but should work for majority of use-cases for now.
// We need to find proper way of getting it or ask Obsidian devs to expose some info.
if (this.cachedName !== null) {
return this.cachedName
}
const canvasViews = this.app.workspace.getLeavesOfType("canvas");
for (const leaf of canvasViews) {
const canvasView = leaf.view as any; // Canvas view has data and file properties not exposed in base View type
const nodes = JSON.parse(canvasView.data).nodes
const node = nodes.filter((n: any) => n.text).find((n: any) => n.text.contains(this.source))
if (node) {
this.cachedName = canvasView.file.path
return this.cachedName as string
}
}
this.cachedName = ''
return ''
}
get sourceKey() {
return this.ctx.sourcePath.trim() ? this.ctx.sourcePath.trim() : this.canvasName;
}
async registerTables(tables: TableDefinition[]) {
await Promise.all(
tables.map((table) =>
this.sync.registerTable({
...table,
sourceFile: this.sourceKey,
}),
),
);
}
}