email: OR support in search (OR-separated branches; mixed FTS/SQL via id-IN subquery)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 23:19:25 +00:00
co-authored by Claude Opus 4.8
parent 08f5e4561d
commit 03f7bb81cf
+26 -23
View File
@@ -171,9 +171,11 @@ const parseSearchDate = (v: string): string | null => {
return Number.isNaN(d.getTime()) ? null : d.toISOString();
};
type ParsedQuery = { fts: string; where: string[]; params: string[] };
// One AND-group of terms (a single OR branch). `fts` is the FTS5 MATCH expression for this branch;
// `where`/`params` are its structured SQL filters.
type Branch = { fts: string; where: string[]; params: string[] };
function parseEmailQuery(q: string): ParsedQuery {
function parseBranch(q: string): Branch {
const fts: string[] = [];
const where: string[] = [];
const params: string[] = [];
@@ -222,30 +224,31 @@ function parseEmailQuery(q: string): ParsedQuery {
}
export function searchEmails(db: Database, q: string, limit: number, offset: number): { rows: Record<string, unknown>[]; total: number } {
const { fts, where, params } = parseEmailQuery(q);
if (!fts && where.length === 0) return { rows: [], total: 0 };
// Split on top-level uppercase OR into branches (Gmail-style; lowercase "or" stays a search word).
const branches = q
.split(/\s+OR\s+/)
.map(parseBranch)
.filter((b) => b.fts || b.where.length > 0);
if (branches.length === 0) return { rows: [], total: 0 };
const filterSql = ['e.deleted = 0', ...where].join(' AND ');
// Full-text present → join the FTS index; structured-filters-only → query `emails` directly.
if (fts) {
const rows = db
.query(
`SELECT e.* FROM emails_fts JOIN emails e ON e.id = emails_fts.id
WHERE emails_fts MATCH ? AND ${filterSql}
ORDER BY e.date DESC LIMIT ? OFFSET ?`,
)
.all(fts, ...params, limit, offset) as Record<string, unknown>[];
const total = (
db.query(`SELECT count(*) AS c FROM emails_fts JOIN emails e ON e.id = emails_fts.id WHERE emails_fts MATCH ? AND ${filterSql}`).get(fts, ...params) as {
c: number;
}
).c;
return { rows, total };
// Each branch becomes one self-contained condition: its FTS terms via an `id IN (FTS subquery)` so
// full-text and structured filters share a WHERE and branches can be OR'd. All ANDed within a branch.
const conds: string[] = [];
const params: string[] = [];
for (const b of branches) {
const parts: string[] = [];
if (b.fts) {
parts.push('e.id IN (SELECT emails_fts.id FROM emails_fts WHERE emails_fts MATCH ?)');
params.push(b.fts);
}
parts.push(...b.where);
params.push(...b.params);
conds.push(`(${parts.join(' AND ')})`);
}
const rows = db.query(`SELECT e.* FROM emails e WHERE ${filterSql} ORDER BY e.date DESC LIMIT ? OFFSET ?`).all(...params, limit, offset) as Record<string, unknown>[];
const total = (db.query(`SELECT count(*) AS c FROM emails e WHERE ${filterSql}`).get(...params) as { c: number }).c;
const whereSql = `e.deleted = 0 AND (${conds.join(' OR ')})`;
const rows = db.query(`SELECT e.* FROM emails e WHERE ${whereSql} ORDER BY e.date DESC LIMIT ? OFFSET ?`).all(...params, limit, offset) as Record<string, unknown>[];
const total = (db.query(`SELECT count(*) AS c FROM emails e WHERE ${whereSql}`).get(...params) as { c: number }).c;
return { rows, total };
}