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:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user