mirror of
https://github.com/h-sphere/sql-seal.git
synced 2026-07-22 10:10:28 +00:00
* 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
223 lines
6 KiB
TypeScript
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,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
}
|