Skip to content

Commit

Permalink
[TypeScript] Make db.runQuery generic to make it easy to type DB qu…
Browse files Browse the repository at this point in the history
…ery results (#4247)

* Make runQuery generic to make it easy to type DB query results.

* Release notes

* typo
  • Loading branch information
joel-jeremy authored Feb 18, 2025
1 parent c92e445 commit cab24df
Show file tree
Hide file tree
Showing 12 changed files with 428 additions and 66 deletions.
4 changes: 2 additions & 2 deletions packages/loot-core/src/server/budget/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function createCategory(cat, sheetName, prevSheetName, start, end) {
initialValue: 0,
run: () => {
// Making this sync is faster!
const rows = db.runQuery(
const rows = db.runQuery<{ amount: number }>(
`SELECT SUM(amount) as amount FROM v_transactions_internal_alive t
LEFT JOIN accounts a ON a.id = t.account
WHERE t.date >= ${start} AND t.date <= ${end}
Expand Down Expand Up @@ -86,7 +86,7 @@ function createCategoryGroup(group, sheetName) {

function handleAccountChange(months, oldValue, newValue) {
if (!oldValue || oldValue.offbudget !== newValue.offbudget) {
const rows = db.runQuery(
const rows = db.runQuery<Pick<db.DbTransaction, 'category'>>(
`
SELECT DISTINCT(category) as category FROM transactions
WHERE acct = ?
Expand Down
4 changes: 2 additions & 2 deletions packages/loot-core/src/server/budget/statements.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as db from '../db';
import { Schedule } from '../db/types';
import { DbSchedule } from '../db';

import { GOAL_PREFIX, TEMPLATE_PREFIX } from './template-notes';

Expand Down Expand Up @@ -41,7 +41,7 @@ export async function getCategoriesWithTemplateNotes(): Promise<
);
}

export async function getActiveSchedules(): Promise<Schedule[]> {
export async function getActiveSchedules(): Promise<DbSchedule[]> {
return await db.all(
'SELECT id, rule, active, completed, posts_transaction, tombstone, name from schedules WHERE name NOT NULL AND tombstone = 0',
);
Expand Down
5 changes: 2 additions & 3 deletions packages/loot-core/src/server/budget/template-notes.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as db from '../db';
import { Schedule } from '../db/types';

import {
CategoryWithTemplateNote,
Expand All @@ -20,7 +19,7 @@ function mockGetTemplateNotesForCategories(
);
}

function mockGetActiveSchedules(schedules: Schedule[]) {
function mockGetActiveSchedules(schedules: db.DbSchedule[]) {
(getActiveSchedules as jest.Mock).mockResolvedValue(schedules);
}

Expand Down Expand Up @@ -277,7 +276,7 @@ describe('checkTemplates', () => {
);
});

function mockSchedules(): Schedule[] {
function mockSchedules(): db.DbSchedule[] {
return [
{
id: 'mock-schedule-1',
Expand Down
111 changes: 77 additions & 34 deletions packages/loot-core/src/server/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,7 @@ import * as fs from '../../platform/server/fs';
import * as sqlite from '../../platform/server/sqlite';
import * as monthUtils from '../../shared/months';
import { groupById } from '../../shared/util';
import {
CategoryEntity,
CategoryGroupEntity,
PayeeEntity,
} from '../../types/models';
import { CategoryEntity, CategoryGroupEntity } from '../../types/models';
import {
schema,
schemaConfig,
Expand All @@ -37,6 +33,16 @@ import {
import { sendMessages, batchMessages } from '../sync';

import { shoveSortOrders, SORT_INCREMENT } from './sort';
import {
DbAccount,
DbCategory,
DbCategoryGroup,
DbPayee,
DbTransaction,
DbViewTransaction,
} from './types';

export * from './types';

export { toDateRepr, fromDateRepr } from '../models';

Expand Down Expand Up @@ -100,17 +106,24 @@ export function runQuery(
sql: string,
params?: Array<string | number>,
fetchAll?: false,
);
export function runQuery(
): { changes: unknown };

export function runQuery<T>(
sql: string,
params: Array<string | number> | undefined,
fetchAll: true,
);
export function runQuery(sql, params, fetchAll) {
// const unrecord = perf.record('sqlite');
const result = sqlite.runQuery(db, sql, params, fetchAll);
// unrecord();
return result;
): T[];

export function runQuery<T>(
sql: string,
params: (string | number)[],
fetchAll: boolean,
) {
if (fetchAll) {
return sqlite.runQuery<T>(db, sql, params, true);
} else {
return sqlite.runQuery(db, sql, params, false);
}
}

export function execQuery(sql: string) {
Expand Down Expand Up @@ -147,19 +160,28 @@ export function asyncTransaction(fn: () => Promise<void>) {
// async. We return a promise here until we've audited all the code to
// make sure nothing calls `.then` on this.
export async function all(sql, params?: (string | number)[]) {
return runQuery(sql, params, true);
// TODO: In the next phase, we will make this function generic
// and pass the type of the return type to `runQuery`.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return runQuery(sql, params, true) as any[];
}

export async function first(sql, params?: (string | number)[]) {
const arr = await runQuery(sql, params, true);
return arr.length === 0 ? null : arr[0];
// TODO: In the next phase, we will make this function generic
// and pass the type of the return type to `runQuery`.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return arr.length === 0 ? null : (arr[0] as any);
}

// The underlying sql system is now sync, but we can't update `first` yet
// without auditing all uses of it
export function firstSync(sql, params?: (string | number)[]) {
const arr = runQuery(sql, params, true);
return arr.length === 0 ? null : arr[0];
// TODO: In the next phase, we will make this function generic
// and pass the type of the return type to `runQuery`.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return arr.length === 0 ? null : (arr[0] as any);
}

// This function is marked as async because `runQuery` is no longer
Expand All @@ -175,7 +197,10 @@ export async function select(table, id) {
[id],
true,
);
return rows[0];
// TODO: In the next phase, we will make this function generic
// and pass the type of the return type to `runQuery`.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return rows[0] as any;
}

export async function update(table, params) {
Expand Down Expand Up @@ -252,9 +277,12 @@ export async function deleteAll(table: string) {

export async function selectWithSchema(table, sql, params) {
const rows = await runQuery(sql, params, true);
return rows
const convertedRows = rows
.map(row => convertFromSelect(schema, schemaConfig, table, row))
.filter(Boolean);
// TODO: Make convertFromSelect generic so we don't need this cast
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return convertedRows as any[];
}

export async function selectFirstWithSchema(table, sql, params) {
Expand Down Expand Up @@ -282,16 +310,18 @@ export function updateWithSchema(table, fields) {
// Data-specific functions. Ideally this would be split up into
// different files

// TODO: Fix return type. This should returns a DbCategory[].
export async function getCategories(
ids?: Array<CategoryEntity['id']>,
ids?: Array<DbCategory['id']>,
): Promise<CategoryEntity[]> {
const whereIn = ids ? `c.id IN (${toSqlQueryParameters(ids)}) AND` : '';
const query = `SELECT c.* FROM categories c WHERE ${whereIn} c.tombstone = 0 ORDER BY c.sort_order, c.id`;
return ids ? await all(query, [...ids]) : await all(query);
}

// TODO: Fix return type. This should returns a [DbCategoryGroup, ...DbCategory].
export async function getCategoriesGrouped(
ids?: Array<CategoryGroupEntity['id']>,
ids?: Array<DbCategoryGroup['id']>,
): Promise<Array<CategoryGroupEntity>> {
const categoryGroupWhereIn = ids
? `cg.id IN (${toSqlQueryParameters(ids)}) AND`
Expand Down Expand Up @@ -432,7 +462,11 @@ export function updateCategory(category) {
return update('categories', category);
}

export async function moveCategory(id, groupId, targetId?: string) {
export async function moveCategory(
id: DbCategory['id'],
groupId: DbCategoryGroup['id'],
targetId?: DbCategory['id'],
) {
if (!groupId) {
throw new Error('moveCategory: groupId is required');
}
Expand All @@ -449,7 +483,10 @@ export async function moveCategory(id, groupId, targetId?: string) {
await update('categories', { id, sort_order, cat_group: groupId });
}

export async function deleteCategory(category, transferId?: string) {
export async function deleteCategory(
category: Pick<DbCategory, 'id'>,
transferId?: DbCategory['id'],
) {
if (transferId) {
// We need to update all the deleted categories that currently
// point to the one we're about to delete so they all are
Expand All @@ -469,11 +506,11 @@ export async function deleteCategory(category, transferId?: string) {
return delete_('categories', category.id);
}

export async function getPayee(id) {
export async function getPayee(id: DbPayee['id']) {
return first(`SELECT * FROM payees WHERE id = ?`, [id]);
}

export async function getAccount(id) {
export async function getAccount(id: DbAccount['id']) {
return first(`SELECT * FROM accounts WHERE id = ?`, [id]);
}

Expand All @@ -487,7 +524,7 @@ export async function insertPayee(payee) {
return id;
}

export async function deletePayee(payee) {
export async function deletePayee(payee: Pick<DbPayee, 'id'>) {
const { transfer_acct } = await first('SELECT * FROM payees WHERE id = ?', [
payee.id,
]);
Expand All @@ -506,7 +543,7 @@ export async function deletePayee(payee) {
return delete_('payees', payee.id);
}

export async function deleteTransferPayee(payee) {
export async function deleteTransferPayee(payee: Pick<DbPayee, 'id'>) {
// This allows deleting transfer payees
return delete_('payees', payee.id);
}
Expand All @@ -516,9 +553,12 @@ export function updatePayee(payee) {
return update('payees', payee);
}

export async function mergePayees(target: string, ids: string[]) {
export async function mergePayees(
target: DbPayee['id'],
ids: Array<DbPayee['id']>,
) {
// Load in payees so we can check some stuff
const dbPayees: PayeeEntity[] = await all('SELECT * FROM payees');
const dbPayees: DbPayee[] = await all('SELECT * FROM payees');
const payees = groupById(dbPayees);

// Filter out any transfer payees
Expand Down Expand Up @@ -613,7 +653,7 @@ export async function getOrphanedPayees() {
return rows.map(row => row.id);
}

export async function getPayeeByName(name) {
export async function getPayeeByName(name: DbPayee['name']) {
return first(
`SELECT * FROM payees WHERE UNICODE_LOWER(name) = ? AND tombstone = 0`,
[name.toLowerCase()],
Expand All @@ -632,7 +672,7 @@ export function getAccounts() {
export async function insertAccount(account) {
const accounts = await all(
'SELECT * FROM accounts WHERE offbudget = ? ORDER BY sort_order, name',
[account.offbudget != null ? account.offbudget : 0],
[account.offbudget ? 1 : 0],
);

// Don't pass a target in, it will default to appending at the end
Expand All @@ -651,7 +691,10 @@ export function deleteAccount(account) {
return delete_('accounts', account.id);
}

export async function moveAccount(id, targetId) {
export async function moveAccount(
id: DbAccount['id'],
targetId: DbAccount['id'],
) {
const account = await first('SELECT * FROM accounts WHERE id = ?', [id]);
let accounts;
if (account.closed) {
Expand All @@ -661,7 +704,7 @@ export async function moveAccount(id, targetId) {
} else {
accounts = await all(
`SELECT id, sort_order FROM accounts WHERE tombstone = 0 AND offbudget = ? ORDER BY sort_order, name`,
[account.offbudget],
[account.offbudget ? 1 : 0],
);
}

Expand All @@ -674,7 +717,7 @@ export async function moveAccount(id, targetId) {
});
}

export async function getTransaction(id) {
export async function getTransaction(id: DbViewTransaction['id']) {
const rows = await selectWithSchema(
'transactions',
'SELECT * FROM v_transactions WHERE id = ?',
Expand All @@ -683,7 +726,7 @@ export async function getTransaction(id) {
return rows[0];
}

export async function getTransactions(accountId) {
export async function getTransactions(accountId: DbTransaction['acct']) {
if (arguments.length > 1) {
throw new Error(
'`getTransactions` was given a second argument, it now only takes a single argument `accountId`',
Expand Down
14 changes: 6 additions & 8 deletions packages/loot-core/src/server/db/mappings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,12 @@ let unlistenSync;
export async function loadMappings() {
// The mappings are separated into tables specific to the type of
// data. But you know, we really could keep a global mapping table.
const categories = (await db.all('SELECT * FROM category_mapping')).map(r => [
r.id,
r.transferId,
]);
const payees = (await db.all('SELECT * FROM payee_mapping')).map(r => [
r.id,
r.targetId,
]);
const categories = (await db.all('SELECT * FROM category_mapping')).map(
r => [r.id, r.transferId] as const,
);
const payees = (await db.all('SELECT * FROM payee_mapping')).map(
r => [r.id, r.targetId] as const,
);

// All ids are unique, so we can just keep a global table of mappings
allMappings = new Map(categories.concat(payees));
Expand Down
9 changes: 0 additions & 9 deletions packages/loot-core/src/server/db/types.d.ts

This file was deleted.

Loading

0 comments on commit cab24df

Please sign in to comment.