Add SQL helper utilities and migrate queries

This commit is contained in:
Jordan Wages 2025-09-24 05:48:06 -05:00
commit fa68916d3e
2 changed files with 218 additions and 136 deletions

353
script.js
View file

@ -124,6 +124,89 @@
});
}
const SQL_FRAGMENT = Symbol('sqlFragment');
function createSqlFragment(text, raw) {
return {
text,
raw,
[SQL_FRAGMENT]: true,
};
}
function isSqlFragment(value) {
return Boolean(value && value[SQL_FRAGMENT]);
}
function quoteSqlString(value) {
return `'${String(value).replace(/'/g, "''")}'`;
}
function sqlValue(value) {
if (value === null || value === undefined) {
return createSqlFragment('NULL', value);
}
const type = typeof value;
if (type === 'number') {
if (!Number.isFinite(value)) throw new Error(`Invalid numeric SQL value: ${value}`);
return createSqlFragment(String(value), value);
}
if (type === 'boolean') {
return createSqlFragment(value ? '1' : '0', value);
}
if (type === 'string') {
return createSqlFragment(quoteSqlString(value), value);
}
throw new Error(`Unsupported SQL value type: ${type}`);
}
function sqlLike(value, { escapeChar = '\\' } = {}) {
if (escapeChar === null || escapeChar === undefined) throw new Error('escapeChar required');
if (typeof escapeChar !== 'string' || escapeChar.length !== 1) throw new Error('escapeChar must be a single character');
if (value === null || value === undefined) {
return createSqlFragment('NULL', { value, escapeChar });
}
const raw = String(value);
const regex = new RegExp(`[${escapeChar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}%_]`, 'g');
const escaped = raw.replace(regex, (match) => escapeChar + match);
return createSqlFragment(quoteSqlString(escaped), { value, escapeChar });
}
function sqlIdentifier(name, { allowlist } = {}) {
if (typeof name !== 'string' || !name) throw new Error('Identifier must be a non-empty string');
if (!Array.isArray(allowlist) || allowlist.length === 0) throw new Error('allowlist required for sqlIdentifier');
if (!allowlist.includes(name)) {
throw new Error(`Identifier not in allowlist: ${name}`);
}
return createSqlFragment(name, name);
}
function buildSql(strings, ...values) {
if (!Array.isArray(strings) || strings.length === 0) throw new Error('sql tag requires template literals');
let sqlText = '';
const params = [];
for (let i = 0; i < strings.length; i += 1) {
sqlText += strings[i];
if (i >= values.length) continue;
const part = values[i];
if (isSqlFragment(part)) {
sqlText += part.text;
params.push(part.raw);
continue;
}
if (part && typeof part === 'object' && typeof part.sql === 'string') {
sqlText += part.sql;
if (Array.isArray(part.params)) params.push(...part.params);
else if (part.params !== undefined) params.push(part.params);
continue;
}
throw new Error('sql template values must be created with sqlValue/sqlLike/sqlIdentifier or other sql fragments');
}
return { sql: sqlText, params };
}
const sql = buildSql;
function logPaginationState({ view, rawPage, rawPageSize, page, pageSize, offset, flags }) {
logDebug('[Pagination]', {
view: view || 'unknown',
@ -136,6 +219,20 @@
});
}
function normalizeSqlInput(sqlInput) {
if (typeof sqlInput === 'string') {
return { sql: sqlInput, params: undefined };
}
if (sqlInput && typeof sqlInput === 'object') {
const sqlText = sqlInput.sql;
if (typeof sqlText !== 'string' || !sqlText) {
throw new Error('Invalid SQL input: missing sql string');
}
return { sql: sqlText, params: cloneBoundParams(sqlInput.params) };
}
throw new Error('Invalid SQL input');
}
function normalizePagination({ page, pageSize, defaultPageSize, view }) {
const safeDefault = Number.isFinite(defaultPageSize) && defaultPageSize > 0 ? Math.max(1, Math.floor(defaultPageSize)) : 25;
const rawPage = page;
@ -492,16 +589,6 @@
return `${hz.toLocaleString()} Hz`;
}
function escapeSqlText(value) {
return `'${String(value).replace(/'/g, "''")}'`;
}
function applyFtsMatch(sql, matchExpr) {
const placeholder = 'MATCH ?';
if (!sql.includes(placeholder)) throw new Error('Expected FTS MATCH placeholder');
return sql.replace(placeholder, `MATCH ${escapeSqlText(matchExpr)}`);
}
async function unzipSqlite(zipBytes) {
loader.setStep('Unpacking database…');
loader.setDetail('Decompressing ZIP');
@ -538,7 +625,7 @@
view: meta.view || 'unknown',
label: meta.label || undefined,
};
this._boundParams = undefined;
this._paramsForLog = cloneBoundParams(meta.params);
this._rowCount = 0;
this._startedAt = 0;
this._errored = false;
@ -548,17 +635,10 @@
if (meta.sql) this._meta.sql = meta.sql;
if (meta.view) this._meta.view = meta.view;
if (Object.prototype.hasOwnProperty.call(meta, 'label')) this._meta.label = meta.label;
return this;
}
bind(values) {
try {
if (values !== undefined) this._stmt.bind(values);
this._boundParams = cloneBoundParams(values);
return true;
} catch (error) {
this._recordError(error, 'bind');
throw error;
if (Object.prototype.hasOwnProperty.call(meta, 'params')) {
this._paramsForLog = cloneBoundParams(meta.params);
}
return this;
}
step() {
try {
@ -602,7 +682,7 @@
logQueryBegin({
view: this._meta.view,
sql: this._meta.sql,
params: this._boundParams,
params: this._paramsForLog,
label: this._meta.label,
});
}
@ -612,7 +692,7 @@
logQueryEnd({
view: this._meta.view,
sql: this._meta.sql,
params: this._boundParams,
params: this._paramsForLog,
rowCount: this._rowCount,
durationMs: duration,
label: this._meta.label,
@ -627,7 +707,7 @@
logSqlError({
view: this._meta.view,
sql: this._meta.sql,
params: this._boundParams,
params: this._paramsForLog,
error,
phase,
});
@ -639,9 +719,10 @@
const source = bytes instanceof Uint8Array ? bytes : bytes ? new Uint8Array(bytes) : undefined;
this._db = source ? new SQL.Database(source) : new SQL.Database();
}
prepare(sql, context) {
const meta = context ? { ...context, sql } : { sql };
return new Statement(this._db.prepare(sql), meta);
prepare(sqlInput, context) {
const { sql: sqlText, params } = normalizeSqlInput(sqlInput);
const meta = context ? { ...context, sql: sqlText, params } : { sql: sqlText, params };
return new Statement(this._db.prepare(sqlText), meta);
}
exec(sql) {
return this._db.exec(sql);
@ -1179,34 +1260,38 @@
}
const searchSql = {
rank: `
SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre
FROM fts_tracks
JOIN tracks t ON t.id = fts_tracks.rowid
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE fts_tracks MATCH ?
ORDER BY rank
LIMIT ? OFFSET ?
`,
alpha: `
SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre
FROM fts_tracks
JOIN tracks t ON t.id = fts_tracks.rowid
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE fts_tracks MATCH ?
ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE, COALESCE(t.year, 0)
LIMIT ? OFFSET ?
`,
rank({ matchExpr, pageSize, offset }) {
return sql`
SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre
FROM fts_tracks
JOIN tracks t ON t.id = fts_tracks.rowid
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE fts_tracks MATCH ${sqlValue(matchExpr)}
ORDER BY rank
LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)}
`;
},
alpha({ matchExpr, pageSize, offset }) {
return sql`
SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre
FROM fts_tracks
JOIN tracks t ON t.id = fts_tracks.rowid
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE fts_tracks MATCH ${sqlValue(matchExpr)}
ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE, COALESCE(t.year, 0)
LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)}
`;
},
};
const countSql = `
const countSql = ({ matchExpr }) => sql`
SELECT COUNT(*) AS count
FROM fts_tracks
JOIN tracks t ON t.id = fts_tracks.rowid
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE fts_tracks MATCH ?
WHERE fts_tracks MATCH ${sqlValue(matchExpr)}
`;
function enableSearch() {
@ -1252,20 +1337,17 @@
defaultPageSize,
view: VIEW_NAMES.search,
});
const effectiveSearchSql = searchSql[state.sort] || searchSql.rank;
const effectiveSearchBuilder = searchSql[state.sort] || searchSql.rank;
let total = 0;
let rows = [];
try {
const countQuery = countSql({ matchExpr });
logDebug('[Search] count SQL', {
matchExpr,
sql: countSql.trim(),
sql: formatSql(countQuery.sql),
params: formatParamsForLog(countQuery.params),
});
const countStmt = prepareForView(
db,
VIEW_NAMES.search,
applyFtsMatch(countSql, matchExpr),
'count',
);
const countStmt = prepareForView(db, VIEW_NAMES.search, countQuery, 'count');
if (countStmt.step()) {
const row = countStmt.getAsObject();
total = Number(row.count) || 0;
@ -1290,20 +1372,13 @@
view: VIEW_NAMES.search,
}));
}
const searchParams = [pageSize, offset];
const rowsQuery = effectiveSearchBuilder({ matchExpr, pageSize, offset });
logDebug('[Search] row SQL', {
matchExpr,
sql: effectiveSearchSql.trim(),
params: searchParams,
sql: formatSql(rowsQuery.sql),
params: formatParamsForLog(rowsQuery.params),
});
const searchStmt = prepareForView(
db,
VIEW_NAMES.search,
applyFtsMatch(effectiveSearchSql, matchExpr),
'rows',
);
searchStmt.bind(searchParams);
const searchStmt = prepareForView(db, VIEW_NAMES.search, rowsQuery, 'rows');
const nextRows = [];
while (searchStmt.step()) {
nextRows.push(searchStmt.getAsObject());
@ -1445,31 +1520,34 @@
});
}
function escapeLike(str) {
return String(str).replace(/[\\%_]/g, (m) => `\\${m}`);
}
function buildLikeTerm() {
const typed = state.filter.trim();
if (typed) return `%${escapeLike(typed)}%`;
if (state.prefix) return `${escapeLike(state.prefix)}%`;
if (typed) return `%${typed}%`;
if (state.prefix) return `${state.prefix}%`;
return '%';
}
const baseCountSql = 'SELECT COUNT(*) AS count FROM artists';
const buildBaseRowsSql = (pageSize, offset) => `
SELECT id, name
const baseCountSql = sql`
SELECT COUNT(*) AS count
FROM artists
ORDER BY name COLLATE NOCASE
LIMIT ${pageSize} OFFSET ${offset}
`;
const countSql = 'SELECT COUNT(*) AS count FROM artists WHERE name LIKE ? ESCAPE "\\"';
const buildRowsSql = (likeValue, pageSize, offset) => `
const buildBaseRowsSql = (pageSize, offset) => sql`
SELECT id, name
FROM artists
WHERE name LIKE ${escapeSqlText(likeValue)} ESCAPE "\\"
ORDER BY name COLLATE NOCASE
LIMIT ${pageSize} OFFSET ${offset}
LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)}
`;
const buildFilteredCountSql = (likeValue) => sql`
SELECT COUNT(*) AS count
FROM artists
WHERE name LIKE ${sqlLike(likeValue)} ESCAPE '\\'
`;
const buildFilteredRowsSql = (likeValue, pageSize, offset) => sql`
SELECT id, name
FROM artists
WHERE name LIKE ${sqlLike(likeValue)} ESCAPE '\\'
ORDER BY name COLLATE NOCASE
LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)}
`;
function updateJumpButtons() {
jumpButtons.forEach((btn) => {
@ -1510,13 +1588,13 @@
const rows = [];
try {
const countQuery = useUnfilteredQuery ? baseCountSql : buildFilteredCountSql(likeTerm);
const countStmt = prepareForView(
db,
VIEW_NAMES.browseArtists,
useUnfilteredQuery ? baseCountSql : countSql,
countQuery,
useUnfilteredQuery ? 'count' : 'count-filtered',
);
if (!useUnfilteredQuery) countStmt.bind([likeTerm]);
if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0;
countStmt.free();
@ -1538,12 +1616,13 @@
({ pageSize, offset } = sanitized);
}
const rowsQuery = useUnfilteredQuery
? buildBaseRowsSql(pageSize, offset)
: buildFilteredRowsSql(likeTerm, pageSize, offset);
const rowsStmt = prepareForView(
db,
VIEW_NAMES.browseArtists,
useUnfilteredQuery
? buildBaseRowsSql(pageSize, offset)
: buildRowsSql(likeTerm, pageSize, offset),
rowsQuery,
useUnfilteredQuery ? 'rows' : 'rows-filtered',
);
while (rowsStmt.step()) rows.push(rowsStmt.getAsObject());
@ -1666,13 +1745,17 @@
title: 'al.title COLLATE NOCASE, a.name COLLATE NOCASE, COALESCE(al.year, 0)',
};
const countSql = 'SELECT COUNT(*) AS count FROM albums';
const baseSql = `
const countSql = sql`
SELECT COUNT(*) AS count
FROM albums
`;
const buildRowsSql = (orderExpr, pageSize, offset) => sql`
SELECT al.id, al.title, al.year, a.name AS artist
FROM albums al
JOIN artists a ON a.id = al.artist_id
ORDER BY %ORDER%
LIMIT ? OFFSET ?
ORDER BY ${sqlIdentifier(orderExpr, { allowlist: Object.values(orderMap) })}
LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)}
`;
function loadAlbumsImmediate() {
@ -1710,13 +1793,13 @@
}
const order = orderMap[state.sort] || orderMap.artist;
const rowsQuery = buildRowsSql(order, pageSize, offset);
const stmt = prepareForView(
db,
VIEW_NAMES.browseAlbums,
baseSql.replace('%ORDER%', order),
rowsQuery,
'rows',
);
stmt.bind([pageSize, offset]);
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
} catch (err) {
@ -1820,22 +1903,26 @@
let yearButtons = [];
const yearsSql = `
const yearsSql = sql`
SELECT year, COUNT(*) AS cnt
FROM tracks
WHERE year IS NOT NULL
GROUP BY year
ORDER BY year
`;
const tracksCountSql = 'SELECT COUNT(*) AS count FROM tracks WHERE year = ?';
const tracksRowsSql = `
const tracksCountSql = (year) => sql`
SELECT COUNT(*) AS count
FROM tracks
WHERE year = ${sqlValue(year)}
`;
const tracksRowsSql = (year, pageSize, offset) => sql`
SELECT t.id, t.title, a.name AS artist, IFNULL(al.title, '') AS album, t.genre
FROM tracks t
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE t.year = ?
WHERE t.year = ${sqlValue(year)}
ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE
LIMIT ? OFFSET ?
LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)}
`;
function updateYearButtons() {
@ -1919,10 +2006,9 @@
const countStmt = prepareForView(
db,
VIEW_NAMES.browseYears,
tracksCountSql,
tracksCountSql(state.selectedYear),
'tracks-count',
);
countStmt.bind([state.selectedYear]);
if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0;
countStmt.free();
@ -1948,10 +2034,9 @@
const stmt = prepareForView(
db,
VIEW_NAMES.browseYears,
tracksRowsSql,
tracksRowsSql(state.selectedYear, pageSize, offset),
'tracks-rows',
);
stmt.bind([state.selectedYear, pageSize, offset]);
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
} catch (err) {
@ -2051,22 +2136,26 @@
let genreButtons = [];
const genresSql = `
const genresSql = sql`
SELECT genre, COUNT(*) AS cnt
FROM tracks
WHERE genre IS NOT NULL AND genre != ''
GROUP BY genre
ORDER BY cnt DESC, genre COLLATE NOCASE
`;
const tracksCountSql = 'SELECT COUNT(*) AS count FROM tracks WHERE genre = ?';
const tracksRowsSql = `
const tracksCountSql = (genre) => sql`
SELECT COUNT(*) AS count
FROM tracks
WHERE genre = ${sqlValue(genre)}
`;
const tracksRowsSql = (genre, pageSize, offset) => sql`
SELECT t.id, t.title, a.name AS artist, IFNULL(al.title, '') AS album, t.year
FROM tracks t
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE t.genre = ?
WHERE t.genre = ${sqlValue(genre)}
ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE
LIMIT ? OFFSET ?
LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)}
`;
function renderGenres() {
@ -2145,10 +2234,9 @@
const countStmt = prepareForView(
db,
VIEW_NAMES.browseGenres,
tracksCountSql,
tracksCountSql(state.selectedGenre),
'tracks-count',
);
countStmt.bind([state.selectedGenre]);
if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0;
countStmt.free();
@ -2174,10 +2262,9 @@
const stmt = prepareForView(
db,
VIEW_NAMES.browseGenres,
tracksRowsSql,
tracksRowsSql(state.selectedGenre, pageSize, offset),
'tracks-rows',
);
stmt.bind([state.selectedGenre, pageSize, offset]);
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
} catch (err) {
@ -2507,23 +2594,23 @@
let artistInfo = null;
const headerSql = `
const headerSql = (id) => sql`
SELECT a.name,
(SELECT COUNT(*) FROM albums WHERE artist_id = a.id) AS album_count,
(SELECT COUNT(*) FROM tracks WHERE artist_id = a.id) AS track_count
FROM artists a
WHERE a.id = ?
WHERE a.id = ${sqlValue(id)}
`;
const albumsSql = `
const albumsSql = (id) => sql`
SELECT id, title, year
FROM albums
WHERE artist_id = ?
WHERE artist_id = ${sqlValue(id)}
ORDER BY COALESCE(year, 0), title COLLATE NOCASE
`;
const tracksSql = `
const tracksSql = (id) => sql`
SELECT id, title, year, genre
FROM tracks
WHERE artist_id = ?
WHERE artist_id = ${sqlValue(id)}
ORDER BY COALESCE(year, 0), title COLLATE NOCASE
LIMIT 100
`;
@ -2567,8 +2654,7 @@
function loadHeader() {
try {
const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, headerSql, 'artist-header');
stmt.bind([artistId]);
const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, headerSql(artistId), 'artist-header');
if (stmt.step()) {
artistInfo = stmt.getAsObject();
$name.textContent = artistInfo.name;
@ -2591,8 +2677,7 @@
loaded.add('albums');
try {
const rows = [];
const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, albumsSql, 'artist-albums');
stmt.bind([artistId]);
const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, albumsSql(artistId), 'artist-albums');
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
albumTable.setRows(rows);
@ -2609,8 +2694,7 @@
loaded.add('tracks');
try {
const rows = [];
const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, tracksSql, 'artist-tracks');
stmt.bind([artistId]);
const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, tracksSql(artistId), 'artist-tracks');
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
trackTable.setRows(rows);
@ -2684,16 +2768,16 @@
const $close = el.querySelector('[data-action="close"]');
const $tracks = el.querySelector('[data-ref="tracks"]');
const headerSql = `
const headerSql = (id) => sql`
SELECT al.title, al.year, a.name AS artist, a.id AS artist_id
FROM albums al
JOIN artists a ON a.id = al.artist_id
WHERE al.id = ?
WHERE al.id = ${sqlValue(id)}
`;
const tracksSql = `
const tracksSql = (id) => sql`
SELECT id, track_no, title, duration_sec, bitrate_kbps
FROM tracks
WHERE album_id = ?
WHERE album_id = ${sqlValue(id)}
ORDER BY COALESCE(track_no, 999), title COLLATE NOCASE
`;
@ -2720,8 +2804,7 @@
function loadHeader() {
try {
const stmt = prepareForView(db, VIEW_NAMES.albumOverlay, headerSql, 'album-header');
stmt.bind([albumId]);
const stmt = prepareForView(db, VIEW_NAMES.albumOverlay, headerSql(albumId), 'album-header');
if (stmt.step()) {
const info = stmt.getAsObject();
artistId = Number(info.artist_id);
@ -2744,8 +2827,7 @@
function loadTracks() {
try {
const rows = [];
const stmt = prepareForView(db, VIEW_NAMES.albumOverlay, tracksSql, 'album-tracks');
stmt.bind([albumId]);
const stmt = prepareForView(db, VIEW_NAMES.albumOverlay, tracksSql(albumId), 'album-tracks');
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
trackTable.setRows(rows);
@ -2816,7 +2898,7 @@
const $close = el.querySelector('[data-action="close"]');
const $copy = el.querySelector('[data-action="copy-path"]');
const sql = `
const trackSql = (id) => sql`
SELECT t.id, t.title, t.year, t.genre, t.duration_sec, t.bitrate_kbps, t.samplerate_hz,
t.channels, t.filesize_bytes, t.sha1, t.relpath,
a.id AS artist_id, a.name AS artist,
@ -2824,15 +2906,14 @@
FROM tracks t
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE t.id = ?
WHERE t.id = ${sqlValue(id)}
`;
let track = null;
function loadTrack() {
try {
const stmt = prepareForView(db, VIEW_NAMES.trackOverlay, sql, 'track-detail');
stmt.bind([trackId]);
const stmt = prepareForView(db, VIEW_NAMES.trackOverlay, trackSql(trackId), 'track-detail');
if (stmt.step()) {
track = stmt.getAsObject();
$title.textContent = track.title || 'Untitled track';