From d5f55cee803c54b92f27dfe2b0ca50b3b5d02f89 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Fri, 28 Feb 2025 23:20:25 -0800 Subject: [PATCH 1/3] Fix react-hooks/exhaustive-deps error on useSheetValue.ts (#4278) * Fix react-hooks/exhaustive-deps error on useSheetValue.ts * Release notes * Add comments * Fix typecheck error --- eslint.config.mjs | 1 - .../src/components/spreadsheet/index.ts | 13 +++- .../components/spreadsheet/useSheetValue.ts | 69 +++++++++++++++---- .../src/client/SpreadsheetProvider.tsx | 7 +- upcoming-release-notes/4278.md | 6 ++ 5 files changed, 76 insertions(+), 20 deletions(-) create mode 100644 upcoming-release-notes/4278.md diff --git a/eslint.config.mjs b/eslint.config.mjs index 1396a4fce40..c38cd071fed 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -789,7 +789,6 @@ export default [ 'packages/desktop-client/src/components/select/DateSelect.tsx', 'packages/desktop-client/src/components/sidebar/Tools.tsx', 'packages/desktop-client/src/components/sort.tsx', - 'packages/desktop-client/src/components/spreadsheet/useSheetValue.ts', ], rules: { diff --git a/packages/desktop-client/src/components/spreadsheet/index.ts b/packages/desktop-client/src/components/spreadsheet/index.ts index 8c38770853f..3394790763b 100644 --- a/packages/desktop-client/src/components/spreadsheet/index.ts +++ b/packages/desktop-client/src/components/spreadsheet/index.ts @@ -89,6 +89,15 @@ export type SheetNames = keyof Spreadsheets & string; export type SheetFields = keyof Spreadsheets[SheetName] & string; +export type BindingObject< + SheetName extends SheetNames, + SheetFieldName extends SheetFields, +> = { + name: SheetFieldName; + value?: Spreadsheets[SheetName][SheetFieldName] | undefined; + query?: Query | undefined; +}; + export type Binding< SheetName extends SheetNames, SheetFieldName extends SheetFields, @@ -96,8 +105,8 @@ export type Binding< | SheetFieldName | { name: SheetFieldName; - value?: Spreadsheets[SheetName][SheetFieldName]; - query?: Query; + value?: Spreadsheets[SheetName][SheetFieldName] | undefined; + query?: Query | undefined; }; export const parametrizedField = () => diff --git a/packages/desktop-client/src/components/spreadsheet/useSheetValue.ts b/packages/desktop-client/src/components/spreadsheet/useSheetValue.ts index af7278199f3..4e1998b9460 100644 --- a/packages/desktop-client/src/components/spreadsheet/useSheetValue.ts +++ b/packages/desktop-client/src/components/spreadsheet/useSheetValue.ts @@ -1,4 +1,4 @@ -import { useState, useRef, useLayoutEffect, useMemo } from 'react'; +import { useState, useRef, useLayoutEffect } from 'react'; import { useSpreadsheet } from 'loot-core/client/SpreadsheetProvider'; @@ -9,6 +9,7 @@ import { type SheetFields, type SheetNames, type Binding, + type BindingObject, } from '.'; type SheetValueResult< @@ -28,18 +29,18 @@ export function useSheetValue< ): SheetValueResult['value'] { const { sheetName, fullSheetName } = useSheetName(binding); - const bindingObj = useMemo( + const memoizedBinding = useMemoizedBinding( () => typeof binding === 'string' - ? { name: binding, value: null, query: undefined } + ? { name: binding, value: undefined, query: undefined } : binding, - [binding], + binding, ); const spreadsheet = useSpreadsheet(); const [result, setResult] = useState>({ name: fullSheetName, - value: bindingObj.value ? bindingObj.value : null, + value: memoizedBinding.value ? memoizedBinding.value : null, }); const latestOnChange = useRef(onChange); latestOnChange.current = onChange; @@ -50,7 +51,7 @@ export function useSheetValue< useLayoutEffect(() => { let isMounted = true; - const unbind = spreadsheet.bind(sheetName, bindingObj, newResult => { + const unbind = spreadsheet.bind(sheetName, memoizedBinding, newResult => { if (!isMounted) { return; } @@ -74,12 +75,56 @@ export function useSheetValue< isMounted = false; unbind(); }; - }, [ - spreadsheet, - sheetName, - bindingObj.name, - bindingObj.query?.serializeAsString(), - ]); + }, [spreadsheet, sheetName, memoizedBinding]); return result.value; } + +type MemoKey< + SheetName extends SheetNames, + FieldName extends SheetFields, +> = { + name: string; + value?: Spreadsheets[SheetName][FieldName] | undefined; + // We check the serialized query to see if it has changed + serializedQuery?: string; +}; + +function useMemoizedBinding< + SheetName extends SheetNames, + FieldName extends SheetFields, +>( + memoBinding: () => BindingObject, + key: Binding, +): BindingObject { + const ref = useRef<{ + key: MemoKey; + value: BindingObject; + } | null>(null); + + const bindingName = typeof key === 'string' ? key : key.name; + const bindingValue = typeof key === 'string' ? undefined : key.value; + const serializedBindingQuery = + typeof key === 'string' ? undefined : key.query?.serializeAsString(); + + if ( + !ref.current || + bindingName !== ref.current.key.name || + bindingValue !== ref.current.key.value || + serializedBindingQuery !== ref.current.key.serializedQuery + ) { + // This should not update the binding reference if the binding name, value, and query values are the same. + // Since query objects are immutable, we compare the serialized query string to make sure that we don't cause + // a re-render whenever a new query object with the same parameter values (QueryState) is passed in. + ref.current = { + key: { + name: bindingName, + value: bindingValue, + serializedQuery: serializedBindingQuery, + }, + value: memoBinding(), + }; + } + + return ref.current.value; +} diff --git a/packages/loot-core/src/client/SpreadsheetProvider.tsx b/packages/loot-core/src/client/SpreadsheetProvider.tsx index 7913acae576..23d306776a9 100644 --- a/packages/loot-core/src/client/SpreadsheetProvider.tsx +++ b/packages/loot-core/src/client/SpreadsheetProvider.tsx @@ -26,9 +26,7 @@ export function useSpreadsheet() { } // TODO: Make this generic and replace the Binding type in the desktop-client package. -type Binding = - | string - | { name: string; value?: unknown | null; query?: Query | undefined }; +type Binding = string | { name: string; query?: Query | undefined }; type CellCacheValue = { name: string; value: Node['value'] | null }; type CellCache = { [name: string]: Promise | null }; @@ -92,8 +90,7 @@ function makeSpreadsheet() { binding: Binding, callback: CellObserverCallback, ): () => void { - binding = - typeof binding === 'string' ? { name: binding, value: null } : binding; + binding = typeof binding === 'string' ? { name: binding } : binding; if (binding.query) { this.createQuery(sheetName, binding.name, binding.query); diff --git a/upcoming-release-notes/4278.md b/upcoming-release-notes/4278.md new file mode 100644 index 00000000000..79e2e16a33a --- /dev/null +++ b/upcoming-release-notes/4278.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [joel-jeremy] +--- + +Fix react-hooks/exhaustive-deps error on useSheetValue.ts From b4f423eac0ceafe1cbff189ac7e315214ecd4ca1 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Fri, 28 Feb 2025 23:31:54 -0800 Subject: [PATCH 2/3] [Mobile] Add support for searching child transactions (#4471) * [Mobile] Add support for searching child transactions * Release notes --- .../src/components/mobile/accounts/AccountTransactions.tsx | 5 +++-- upcoming-release-notes/4471.md | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 upcoming-release-notes/4471.md diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx index 3ac2b75af2b..9dfd826afb1 100644 --- a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx +++ b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx @@ -234,7 +234,7 @@ function TransactionListWithPreviews({ const { t } = useTranslation(); const baseTransactionsQuery = useCallback( () => - queries.transactions(accountId).options({ splits: 'none' }).select('*'), + queries.transactions(accountId).options({ splits: 'all' }).select('*'), [accountId], ); @@ -330,7 +330,8 @@ function TransactionListWithPreviews({ ); const transactionsToDisplay = !isSearching - ? previewTransactions.concat(transactions) + ? // Do not render child transactions in the list, unless searching + previewTransactions.concat(transactions.filter(t => !t.is_child)) : transactions; return ( diff --git a/upcoming-release-notes/4471.md b/upcoming-release-notes/4471.md new file mode 100644 index 00000000000..12a9e66eeb7 --- /dev/null +++ b/upcoming-release-notes/4471.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [joel-jeremy] +--- + +[Mobile] Add support for searching child transactions \ No newline at end of file From 89006275a092d2309ab03162a047e07663789198 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Sat, 1 Mar 2025 09:33:45 -0800 Subject: [PATCH 3/3] [TypeScript] Make `db.first` generic to make it easy to type DB query results (#4248) * [TypeScript] Make db.first generic * Release notes * Fix typecheck error * Cleanup type * Update db.first calls * Fix strict type * Code review * Fix typo --- packages/loot-core/src/mocks/budget.ts | 8 +- packages/loot-core/src/server/accounts/app.ts | 80 +++++++++++++------ .../loot-core/src/server/accounts/link.ts | 4 +- .../loot-core/src/server/accounts/payees.ts | 9 +-- .../loot-core/src/server/accounts/sync.ts | 6 +- packages/loot-core/src/server/api.ts | 7 +- .../loot-core/src/server/budget/actions.ts | 4 +- packages/loot-core/src/server/budget/base.ts | 2 +- .../src/server/budget/cleanup-template.ts | 6 +- .../src/server/budget/goalsSchedule.ts | 6 +- .../loot-core/src/server/dashboard/app.ts | 4 +- packages/loot-core/src/server/db/index.ts | 40 +++++----- packages/loot-core/src/server/filters/app.ts | 2 +- packages/loot-core/src/server/main.test.ts | 4 +- packages/loot-core/src/server/main.ts | 16 ++-- .../src/server/migrate/migrations.test.ts | 4 +- packages/loot-core/src/server/reports/app.ts | 2 +- .../loot-core/src/server/schedules/app.ts | 6 +- .../src/server/schedules/find-schedules.ts | 4 +- packages/loot-core/src/server/sheet.ts | 12 ++- .../transactions/transaction-rules.test.ts | 26 +++--- .../server/transactions/transaction-rules.ts | 22 +++-- .../src/server/transactions/transfer.test.ts | 12 +-- .../src/server/transactions/transfer.ts | 22 ++--- packages/loot-core/src/server/update.ts | 7 +- .../loot-core/src/types/models/index.d.ts | 1 + upcoming-release-notes/4248.md | 6 ++ 27 files changed, 191 insertions(+), 131 deletions(-) create mode 100644 upcoming-release-notes/4248.md diff --git a/packages/loot-core/src/mocks/budget.ts b/packages/loot-core/src/mocks/budget.ts index 303a4584346..112cf41cf59 100644 --- a/packages/loot-core/src/mocks/budget.ts +++ b/packages/loot-core/src/mocks/budget.ts @@ -461,14 +461,14 @@ async function fillOther(handlers, account, payees, groups) { async function createBudget(accounts, payees, groups) { const primaryAccount = accounts.find(a => (a.name = 'Bank of America')); const earliestDate = ( - await db.first( - `SELECT * FROM v_transactions t LEFT JOIN accounts a ON t.account = a.id + await db.first>( + `SELECT t.date FROM v_transactions t LEFT JOIN accounts a ON t.account = a.id WHERE a.offbudget = 0 AND t.is_child = 0 ORDER BY date ASC LIMIT 1`, ) ).date; const earliestPrimaryDate = ( - await db.first( - `SELECT * FROM v_transactions t LEFT JOIN accounts a ON t.account = a.id + await db.first>( + `SELECT t.date FROM v_transactions t LEFT JOIN accounts a ON t.account = a.id WHERE a.id = ? AND a.offbudget = 0 AND t.is_child = 0 ORDER BY date ASC LIMIT 1`, [primaryAccount.id], ) diff --git a/packages/loot-core/src/server/accounts/app.ts b/packages/loot-core/src/server/accounts/app.ts index d8ee393e7e5..bcc690c65b6 100644 --- a/packages/loot-core/src/server/accounts/app.ts +++ b/packages/loot-core/src/server/accounts/app.ts @@ -11,11 +11,9 @@ import { AccountEntity, CategoryEntity, SyncServerGoCardlessAccount, - PayeeEntity, TransactionEntity, SyncServerSimpleFinAccount, } from '../../types/models'; -import { BankEntity } from '../../types/models/bank'; import { createApp } from '../app'; import * as db from '../db'; import { @@ -77,24 +75,27 @@ async function getAccountBalance({ id: string; cutoff: string | Date; }) { - const { balance }: { balance: number } = await db.first( + const result = await db.first<{ balance: number }>( 'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0 AND date <= ?', [id, db.toDateRepr(dayFromDate(cutoff))], ); - return balance ? balance : 0; + return result?.balance ? result.balance : 0; } async function getAccountProperties({ id }: { id: AccountEntity['id'] }) { - const { balance }: { balance: number } = await db.first( + const balanceResult = await db.first<{ balance: number }>( 'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0', [id], ); - const { count }: { count: number } = await db.first( + const countResult = await db.first<{ count: number }>( 'SELECT count(id) as count FROM transactions WHERE acct = ? AND tombstone = 0', [id], ); - return { balance: balance || 0, numTransactions: count }; + return { + balance: balanceResult?.balance || 0, + numTransactions: countResult?.count || 0, + }; } async function linkGoCardlessAccount({ @@ -112,10 +113,15 @@ async function linkGoCardlessAccount({ const bank = await link.findOrCreateBank(account.institution, requisitionId); if (upgradingId) { - const accRow: AccountEntity = await db.first( + const accRow = await db.first( 'SELECT * FROM accounts WHERE id = ?', [upgradingId], ); + + if (!accRow) { + throw new Error(`Account with ID ${upgradingId} not found.`); + } + id = accRow.id; await db.update('accounts', { id, @@ -178,10 +184,15 @@ async function linkSimpleFinAccount({ ); if (upgradingId) { - const accRow: AccountEntity = await db.first( + const accRow = await db.first( 'SELECT * FROM accounts WHERE id = ?', [upgradingId], ); + + if (!accRow) { + throw new Error(`Account with ID ${upgradingId} not found.`); + } + id = accRow.id; await db.update('accounts', { id, @@ -278,7 +289,7 @@ async function closeAccount({ await unlinkAccount({ id }); return withUndo(async () => { - const account: AccountEntity = await db.first( + const account = await db.first( 'SELECT * FROM accounts WHERE id = ? AND tombstone = 0', [id], ); @@ -303,11 +314,15 @@ async function closeAccount({ true, ); - const { id: payeeId }: Pick = await db.first( + const transferPayee = await db.first>( 'SELECT id FROM payees WHERE transfer_acct = ?', [id], ); + if (!transferPayee) { + throw new Error(`Transfer payee with account ID ${id} not found.`); + } + await batchMessages(async () => { // TODO: what this should really do is send a special message that // automatically marks the tombstone value for all transactions @@ -328,7 +343,7 @@ async function closeAccount({ }); db.deleteAccount({ id }); - db.deleteTransferPayee({ id: payeeId }); + db.deleteTransferPayee({ id: transferPayee.id }); }); } else { if (balance !== 0 && transferAccountId == null) { @@ -340,14 +355,20 @@ async function closeAccount({ // If there is a balance we need to transfer it to the specified // account (and possibly categorize it) if (balance !== 0 && transferAccountId) { - const { id: payeeId }: Pick = await db.first( + const transferPayee = await db.first>( 'SELECT id FROM payees WHERE transfer_acct = ?', [transferAccountId], ); + if (!transferPayee) { + throw new Error( + `Transfer payee with account ID ${transferAccountId} not found.`, + ); + } + await mainApp.handlers['transaction-add']({ id: uuidv4(), - payee: payeeId, + payee: transferPayee.id, amount: -balance, account: id, date: monthUtils.currentDay(), @@ -948,20 +969,21 @@ async function importTransactions({ } async function unlinkAccount({ id }: { id: AccountEntity['id'] }) { - const { bank: bankId }: Pick = await db.first( - 'SELECT bank FROM accounts WHERE id = ?', + const accRow = await db.first( + 'SELECT * FROM accounts WHERE id = ?', [id], ); + if (!accRow) { + throw new Error(`Account with ID ${id} not found.`); + } + + const bankId = accRow.bank; + if (!bankId) { return 'ok'; } - const accRow: AccountEntity = await db.first( - 'SELECT * FROM accounts WHERE id = ?', - [id], - ); - const isGoCardless = accRow.account_sync_source === 'goCardless'; await db.updateAccount({ @@ -978,7 +1000,7 @@ async function unlinkAccount({ id }: { id: AccountEntity['id'] }) { return; } - const { count }: { count: number } = await db.first( + const accountWithBankResult = await db.first<{ count: number }>( 'SELECT COUNT(*) as count FROM accounts WHERE bank = ?', [bankId], ); @@ -990,15 +1012,23 @@ async function unlinkAccount({ id }: { id: AccountEntity['id'] }) { return 'ok'; } - if (count === 0) { - const { bank_id: requisitionId }: Pick = - await db.first('SELECT bank_id FROM banks WHERE id = ?', [bankId]); + if (!accountWithBankResult || accountWithBankResult.count === 0) { + const bank = await db.first>( + 'SELECT bank_id FROM banks WHERE id = ?', + [bankId], + ); + + if (!bank) { + throw new Error(`Bank with ID ${bankId} not found.`); + } const serverConfig = getServer(); if (!serverConfig) { throw new Error('Failed to get server config.'); } + const requisitionId = bank.bank_id; + try { await post( serverConfig.GOCARDLESS_SERVER + '/remove-account', diff --git a/packages/loot-core/src/server/accounts/link.ts b/packages/loot-core/src/server/accounts/link.ts index db48e8c2530..3c5245f79a7 100644 --- a/packages/loot-core/src/server/accounts/link.ts +++ b/packages/loot-core/src/server/accounts/link.ts @@ -4,8 +4,8 @@ import { v4 as uuidv4 } from 'uuid'; import * as db from '../db'; export async function findOrCreateBank(institution, requisitionId) { - const bank = await db.first( - 'SELECT id, bank_id, name FROM banks WHERE bank_id = ?', + const bank = await db.first>( + 'SELECT id, bank_id FROM banks WHERE bank_id = ?', [requisitionId], ); diff --git a/packages/loot-core/src/server/accounts/payees.ts b/packages/loot-core/src/server/accounts/payees.ts index bb8183f4e96..695a7150c96 100644 --- a/packages/loot-core/src/server/accounts/payees.ts +++ b/packages/loot-core/src/server/accounts/payees.ts @@ -1,11 +1,10 @@ // @ts-strict-ignore -import { CategoryEntity, PayeeEntity } from '../../types/models'; import * as db from '../db'; export async function createPayee(description) { // Check to make sure no payee already exists with exactly the same // name - const row: Pick = await db.first( + const row = await db.first>( `SELECT id FROM payees WHERE UNICODE_LOWER(name) = ? AND tombstone = 0`, [description.toLowerCase()], ); @@ -13,19 +12,19 @@ export async function createPayee(description) { if (row) { return row.id; } else { - return (await db.insertPayee({ name: description })) as PayeeEntity['id']; + return (await db.insertPayee({ name: description })) as db.DbPayee['id']; } } export async function getStartingBalancePayee() { - let category: CategoryEntity = await db.first(` + let category = await db.first(` SELECT * FROM categories WHERE is_income = 1 AND LOWER(name) = 'starting balances' AND tombstone = 0 `); if (category === null) { - category = await db.first( + category = await db.first( 'SELECT * FROM categories WHERE is_income = 1 AND tombstone = 0', ); } diff --git a/packages/loot-core/src/server/accounts/sync.ts b/packages/loot-core/src/server/accounts/sync.ts index dfe4ab4f879..133292e0c80 100644 --- a/packages/loot-core/src/server/accounts/sync.ts +++ b/packages/loot-core/src/server/accounts/sync.ts @@ -584,7 +584,7 @@ export async function matchTransactions( ); // The first pass runs the rules, and preps data for fuzzy matching - const accounts: AccountEntity[] = await db.getAccounts(); + const accounts: db.DbAccount[] = await db.getAccounts(); const accountsMap = new Map(accounts.map(account => [account.id, account])); const transactionsStep1 = []; @@ -603,7 +603,7 @@ export async function matchTransactions( // is the highest fidelity match and should always be attempted // first. if (trans.imported_id) { - match = await db.first( + match = await db.first( 'SELECT * FROM v_transactions WHERE imported_id = ? AND account = ?', [trans.imported_id, acctId], ); @@ -737,7 +737,7 @@ export async function addTransactions( { rawPayeeName: true }, ); - const accounts: AccountEntity[] = await db.getAccounts(); + const accounts: db.DbAccount[] = await db.getAccounts(); const accountsMap = new Map(accounts.map(account => [account.id, account])); for (const { trans: originalTrans, subtransactions } of normalized) { diff --git a/packages/loot-core/src/server/api.ts b/packages/loot-core/src/server/api.ts index 63e1ec7c14e..32b78ee7197 100644 --- a/packages/loot-core/src/server/api.ts +++ b/packages/loot-core/src/server/api.ts @@ -94,9 +94,10 @@ async function validateExpenseCategory(debug, id) { throw APIError(`${debug}: category id is required`); } - const row = await db.first('SELECT is_income FROM categories WHERE id = ?', [ - id, - ]); + const row = await db.first>( + 'SELECT is_income FROM categories WHERE id = ?', + [id], + ); if (!row) { throw APIError(`${debug}: category “${id}” does not exist`); diff --git a/packages/loot-core/src/server/budget/actions.ts b/packages/loot-core/src/server/budget/actions.ts index e09511ca33b..f3f12f277c1 100644 --- a/packages/loot-core/src/server/budget/actions.ts +++ b/packages/loot-core/src/server/budget/actions.ts @@ -329,7 +329,7 @@ export async function setNMonthAvg({ N: number; category: string; }): Promise { - const categoryFromDb = await db.first( + const categoryFromDb = await db.first>( 'SELECT is_income FROM v_categories WHERE id = ?', [category], ); @@ -361,7 +361,7 @@ export async function holdForNextMonth({ month: string; amount: number; }): Promise { - const row = await db.first( + const row = await db.first>( 'SELECT buffered FROM zero_budget_months WHERE id = ?', [month], ); diff --git a/packages/loot-core/src/server/budget/base.ts b/packages/loot-core/src/server/budget/base.ts index a3fcb075e17..975b00ac741 100644 --- a/packages/loot-core/src/server/budget/base.ts +++ b/packages/loot-core/src/server/budget/base.ts @@ -442,7 +442,7 @@ export async function createBudget(months) { } export async function createAllBudgets() { - const earliestTransaction = await db.first( + const earliestTransaction = await db.first( 'SELECT * FROM transactions WHERE isChild=0 AND date IS NOT NULL ORDER BY date ASC LIMIT 1', ); const earliestDate = diff --git a/packages/loot-core/src/server/budget/cleanup-template.ts b/packages/loot-core/src/server/budget/cleanup-template.ts index 7b0dd8536e9..30174657b28 100644 --- a/packages/loot-core/src/server/budget/cleanup-template.ts +++ b/packages/loot-core/src/server/budget/cleanup-template.ts @@ -64,7 +64,7 @@ async function applyGroupCleanups( ); const to_budget = budgeted + Math.abs(balance); const categoryId = generalGroup[ii].category; - let carryover = await db.first( + let carryover = await db.first>( `SELECT carryover FROM zero_budgets WHERE month = ? and category = ?`, [db_month, categoryId], ); @@ -220,7 +220,7 @@ async function processCleanup(month: string): Promise { } else { warnings.push(category.name + ' does not have available funds.'); } - const carryover = await db.first( + const carryover = await db.first>( `SELECT carryover FROM zero_budgets WHERE month = ? and category = ?`, [db_month, category.id], ); @@ -249,7 +249,7 @@ async function processCleanup(month: string): Promise { const budgeted = await getSheetValue(sheetName, `budget-${category.id}`); const to_budget = budgeted + Math.abs(balance); const categoryId = category.id; - let carryover = await db.first( + let carryover = await db.first>( `SELECT carryover FROM zero_budgets WHERE month = ? and category = ?`, [db_month, categoryId], ); diff --git a/packages/loot-core/src/server/budget/goalsSchedule.ts b/packages/loot-core/src/server/budget/goalsSchedule.ts index 9705a81afc2..57b2027bfca 100644 --- a/packages/loot-core/src/server/budget/goalsSchedule.ts +++ b/packages/loot-core/src/server/budget/goalsSchedule.ts @@ -21,8 +21,10 @@ async function createScheduleList( const errors = []; for (let ll = 0; ll < template.length; ll++) { - const { id: sid, completed: complete } = await db.first( - 'SELECT * FROM schedules WHERE TRIM(name) = ? AND tombstone = 0', + const { id: sid, completed: complete } = await db.first< + Pick + >( + 'SELECT id, completed FROM schedules WHERE TRIM(name) = ? AND tombstone = 0', [template[ll].name.trim()], ); const rule = await getRuleForSchedule(sid); diff --git a/packages/loot-core/src/server/dashboard/app.ts b/packages/loot-core/src/server/dashboard/app.ts index 8b456f280c8..d1299069b51 100644 --- a/packages/loot-core/src/server/dashboard/app.ts +++ b/packages/loot-core/src/server/dashboard/app.ts @@ -144,7 +144,9 @@ async function addDashboardWidget( // If no x & y was provided - calculate it dynamically // The new widget should be the very last one in the list of all widgets if (!('x' in widget) && !('y' in widget)) { - const data = await db.first( + const data = await db.first< + Pick + >( 'SELECT x, y, width, height FROM dashboard WHERE tombstone = 0 ORDER BY y DESC, x DESC', ); diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts index 40927d042e7..ad5262cebcd 100644 --- a/packages/loot-core/src/server/db/index.ts +++ b/packages/loot-core/src/server/db/index.ts @@ -37,6 +37,7 @@ import { DbAccount, DbCategory, DbCategoryGroup, + DbClockMessage, DbPayee, DbTransaction, DbViewTransaction, @@ -83,7 +84,7 @@ export function getDatabase() { } export async function loadClock() { - const row = await first('SELECT * FROM messages_clock'); + const row = await first('SELECT * FROM messages_clock'); if (row) { const clock = deserializeClock(row.clock); setClock(clock); @@ -166,12 +167,9 @@ export async function all(sql, params?: (string | number)[]) { return runQuery(sql, params, true) as any[]; } -export async function first(sql, params?: (string | number)[]) { - const arr = await 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 arr.length === 0 ? null : (arr[0] as any); +export async function first(sql, params?: (string | number)[]) { + const arr = await runQuery(sql, params, true); + return arr.length === 0 ? null : arr[0]; } // The underlying sql system is now sync, but we can't update `first` yet @@ -353,7 +351,9 @@ export async function getCategoriesGrouped( export async function insertCategoryGroup(group) { // Don't allow duplicate group - const existingGroup = await first( + const existingGroup = await first< + Pick + >( `SELECT id, name, hidden FROM category_groups WHERE UPPER(name) = ? and tombstone = 0 LIMIT 1`, [group.name.toUpperCase()], ); @@ -363,7 +363,7 @@ export async function insertCategoryGroup(group) { ); } - const lastGroup = await first(` + const lastGroup = await first>(` SELECT sort_order FROM category_groups WHERE tombstone = 0 ORDER BY sort_order DESC, id DESC LIMIT 1 `); const sort_order = (lastGroup ? lastGroup.sort_order : 0) + SORT_INCREMENT; @@ -411,7 +411,7 @@ export async function insertCategory( let id_; await batchMessages(async () => { // Dont allow duplicated names in groups - const existingCatInGroup = await first( + const existingCatInGroup = await first>( `SELECT id FROM categories WHERE cat_group = ? and UPPER(name) = ? and tombstone = 0 LIMIT 1`, [category.cat_group, category.name.toUpperCase()], ); @@ -422,7 +422,7 @@ export async function insertCategory( } if (atEnd) { - const lastCat = await first(` + const lastCat = await first>(` SELECT sort_order FROM categories WHERE tombstone = 0 ORDER BY sort_order DESC, id DESC LIMIT 1 `); sort_order = (lastCat ? lastCat.sort_order : 0) + SORT_INCREMENT; @@ -507,11 +507,11 @@ export async function deleteCategory( } export async function getPayee(id: DbPayee['id']) { - return first(`SELECT * FROM payees WHERE id = ?`, [id]); + return first(`SELECT * FROM payees WHERE id = ?`, [id]); } export async function getAccount(id: DbAccount['id']) { - return first(`SELECT * FROM accounts WHERE id = ?`, [id]); + return first(`SELECT * FROM accounts WHERE id = ?`, [id]); } export async function insertPayee(payee) { @@ -525,9 +525,10 @@ export async function insertPayee(payee) { } export async function deletePayee(payee: Pick) { - const { transfer_acct } = await first('SELECT * FROM payees WHERE id = ?', [ - payee.id, - ]); + const { transfer_acct } = await first( + 'SELECT * FROM payees WHERE id = ?', + [payee.id], + ); if (transfer_acct) { // You should never be able to delete transfer payees return; @@ -654,7 +655,7 @@ export async function getOrphanedPayees() { } export async function getPayeeByName(name: DbPayee['name']) { - return first( + return first( `SELECT * FROM payees WHERE UNICODE_LOWER(name) = ? AND tombstone = 0`, [name.toLowerCase()], ); @@ -695,7 +696,10 @@ export async function moveAccount( id: DbAccount['id'], targetId: DbAccount['id'], ) { - const account = await first('SELECT * FROM accounts WHERE id = ?', [id]); + const account = await first( + 'SELECT * FROM accounts WHERE id = ?', + [id], + ); let accounts; if (account.closed) { accounts = await all( diff --git a/packages/loot-core/src/server/filters/app.ts b/packages/loot-core/src/server/filters/app.ts index 8fc22399b12..ac4059285b0 100644 --- a/packages/loot-core/src/server/filters/app.ts +++ b/packages/loot-core/src/server/filters/app.ts @@ -42,7 +42,7 @@ const filterModel = { }; async function filterNameExists(name, filterId, newItem) { - const idForName = await db.first( + const idForName = await db.first>( 'SELECT id from transaction_filters WHERE tombstone = 0 AND name = ?', [name], ); diff --git a/packages/loot-core/src/server/main.test.ts b/packages/loot-core/src/server/main.test.ts index 39a16890d5f..0100c8d92fb 100644 --- a/packages/loot-core/src/server/main.test.ts +++ b/packages/loot-core/src/server/main.test.ts @@ -68,7 +68,9 @@ describe('Budgets', () => { // Grab the clock to compare later await db.openDatabase('test-budget'); - const row = await db.first('SELECT * FROM messages_clock'); + const row = await db.first( + 'SELECT * FROM messages_clock', + ); const { error } = await runHandler(handlers['load-budget'], { id: 'test-budget', diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index e13cd0d35f3..38337e7670b 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -258,7 +258,7 @@ handlers['category-delete'] = mutator(async function ({ id, transferId }) { return withUndo(async () => { let result = {}; await batchMessages(async () => { - const row = await db.first( + const row = await db.first>( 'SELECT is_income FROM categories WHERE id = ?', [id], ); @@ -269,9 +269,10 @@ handlers['category-delete'] = mutator(async function ({ id, transferId }) { const transfer = transferId && - (await db.first('SELECT is_income FROM categories WHERE id = ?', [ - transferId, - ])); + (await db.first>( + 'SELECT is_income FROM categories WHERE id = ?', + [transferId], + )); if (!row || (transferId && !transfer)) { result = { error: 'no-categories' }; @@ -1406,9 +1407,10 @@ async function loadBudget(id: string) { // This is a bit leaky, but we need to set the initial budget type const { value: budgetType = 'rollover' } = - (await db.first('SELECT value from preferences WHERE id = ?', [ - 'budgetType', - ])) ?? {}; + (await db.first>( + 'SELECT value from preferences WHERE id = ?', + ['budgetType'], + )) ?? {}; sheet.get().meta().budgetType = budgetType; await budget.createAllBudgets(); diff --git a/packages/loot-core/src/server/migrate/migrations.test.ts b/packages/loot-core/src/server/migrate/migrations.test.ts index 23721fb8135..ba6f4fdd316 100644 --- a/packages/loot-core/src/server/migrate/migrations.test.ts +++ b/packages/loot-core/src/server/migrate/migrations.test.ts @@ -62,14 +62,14 @@ describe('Migrations', () => { return withMigrationsDir( __dirname + '/../../mocks/migrations', async () => { - let desc = await db.first( + let desc = await db.first<{ sql: string }>( "SELECT * FROM sqlite_master WHERE name = 'poop'", ); expect(desc).toBe(null); await migrate(db.getDatabase()); - desc = await db.first( + desc = await db.first<{ sql: string }>( "SELECT * FROM sqlite_master WHERE name = 'poop'", ); expect(desc).toBeDefined(); diff --git a/packages/loot-core/src/server/reports/app.ts b/packages/loot-core/src/server/reports/app.ts index e1356696cad..f5d3beeb71a 100644 --- a/packages/loot-core/src/server/reports/app.ts +++ b/packages/loot-core/src/server/reports/app.ts @@ -85,7 +85,7 @@ async function reportNameExists( reportId: string, newItem: boolean, ) { - const idForName: { id: string } = await db.first( + const idForName = await db.first>( 'SELECT id from custom_reports WHERE tombstone = 0 AND name = ?', [name], ); diff --git a/packages/loot-core/src/server/schedules/app.ts b/packages/loot-core/src/server/schedules/app.ts index 06c8e64999f..078b0e73767 100644 --- a/packages/loot-core/src/server/schedules/app.ts +++ b/packages/loot-core/src/server/schedules/app.ts @@ -140,7 +140,9 @@ export async function setNextDate({ if (newNextDate !== nextDate) { // Our `update` functon requires the id of the item and we don't // have it, so we need to query it - const nd = await db.first( + const nd = await db.first< + Pick + >( 'SELECT id, base_next_date_ts FROM schedules_next_date WHERE schedule_id = ?', [id], ); @@ -166,7 +168,7 @@ export async function setNextDate({ // Methods async function checkIfScheduleExists(name, scheduleId) { - const idForName = await db.first( + const idForName = await db.first>( 'SELECT id from schedules WHERE tombstone = 0 AND name = ?', [name], ); diff --git a/packages/loot-core/src/server/schedules/find-schedules.ts b/packages/loot-core/src/server/schedules/find-schedules.ts index 8d6df7354d0..adabe2b2a11 100644 --- a/packages/loot-core/src/server/schedules/find-schedules.ts +++ b/packages/loot-core/src/server/schedules/find-schedules.ts @@ -337,8 +337,8 @@ export async function findSchedules() { for (const account of accounts) { // Find latest transaction-ish to start with - const latestTrans = await db.first( - 'SELECT * FROM v_transactions WHERE account = ? AND parent_id IS NULL ORDER BY date DESC LIMIT 1', + const latestTrans = await db.first>( + 'SELECT date FROM v_transactions WHERE account = ? AND parent_id IS NULL ORDER BY date DESC LIMIT 1', [account.id], ); diff --git a/packages/loot-core/src/server/sheet.ts b/packages/loot-core/src/server/sheet.ts index 106e59812fd..3259c991625 100644 --- a/packages/loot-core/src/server/sheet.ts +++ b/packages/loot-core/src/server/sheet.ts @@ -5,6 +5,7 @@ import { captureBreadcrumb } from '../platform/exceptions'; import * as sqlite from '../platform/server/sqlite'; import { sheetForMonth } from '../shared/months'; +import { DbPreference } from './db'; import * as Platform from './platform'; import { Spreadsheet } from './spreadsheet/spreadsheet'; import { resolveName } from './spreadsheet/util'; @@ -189,16 +190,19 @@ export async function reloadSpreadsheet(db): Promise { } } -export async function loadUserBudgets(db): Promise { +export async function loadUserBudgets( + db: typeof import('./db'), +): Promise { const sheet = globalSheet; // TODO: Clear out the cache here so make sure future loads of the app // don't load any extra values that aren't set here const { value: budgetType = 'rollover' } = - (await db.first('SELECT value from preferences WHERE id = ?', [ - 'budgetType', - ])) ?? {}; + (await db.first>( + 'SELECT value from preferences WHERE id = ?', + ['budgetType'], + )) ?? {}; const table = budgetType === 'report' ? 'reflect_budgets' : 'zero_budgets'; const budgets = await db.all(` diff --git a/packages/loot-core/src/server/transactions/transaction-rules.test.ts b/packages/loot-core/src/server/transactions/transaction-rules.test.ts index 5004ebb5c31..13a0abf04af 100644 --- a/packages/loot-core/src/server/transactions/transaction-rules.test.ts +++ b/packages/loot-core/src/server/transactions/transaction-rules.test.ts @@ -943,11 +943,14 @@ describe('Learning categories', () => { // Internally, it should still be stored with the internal names // so that it's backwards compatible - const rawRule = await db.first('SELECT * FROM rules'); - rawRule.conditions = JSON.parse(rawRule.conditions); - rawRule.actions = JSON.parse(rawRule.actions); - expect(rawRule.conditions[0].field).toBe('imported_description'); - expect(rawRule.actions[0].field).toBe('description'); + const rawRule = await db.first('SELECT * FROM rules'); + const parsedRule = { + ...rawRule, + conditions: JSON.parse(rawRule.conditions), + actions: JSON.parse(rawRule.actions), + }; + expect(parsedRule.conditions[0].field).toBe('imported_description'); + expect(parsedRule.actions[0].field).toBe('description'); await loadRules(); @@ -973,11 +976,14 @@ describe('Learning categories', () => { // This rule internally has been stored with the public names. // Making this work now allows us to switch to it by default in // the future - const rawRule = await db.first('SELECT * FROM rules'); - rawRule.conditions = JSON.parse(rawRule.conditions); - rawRule.actions = JSON.parse(rawRule.actions); - expect(rawRule.conditions[0].field).toBe('imported_payee'); - expect(rawRule.actions[0].field).toBe('payee'); + const rawRule = await db.first('SELECT * FROM rules'); + const parsedRule = { + ...rawRule, + conditions: JSON.parse(rawRule.conditions), + actions: JSON.parse(rawRule.actions), + }; + expect(parsedRule.conditions[0].field).toBe('imported_payee'); + expect(parsedRule.actions[0].field).toBe('payee'); const [rule] = getRules(); expect(rule.conditions[0].field).toBe('imported_payee'); diff --git a/packages/loot-core/src/server/transactions/transaction-rules.ts b/packages/loot-core/src/server/transactions/transaction-rules.ts index 40448d3ac40..b9cefd8a0aa 100644 --- a/packages/loot-core/src/server/transactions/transaction-rules.ts +++ b/packages/loot-core/src/server/transactions/transaction-rules.ts @@ -14,7 +14,6 @@ import { type TransactionEntity, type RuleActionEntity, type RuleEntity, - AccountEntity, } from '../../types/models'; import { schemaConfig } from '../aql'; import * as db from '../db'; @@ -220,9 +219,10 @@ export async function updateRule(rule) { } export async function deleteRule(id: string) { - const schedule = await db.first('SELECT id FROM schedules WHERE rule = ?', [ - id, - ]); + const schedule = await db.first>( + 'SELECT id FROM schedules WHERE rule = ?', + [id], + ); if (schedule) { return false; @@ -278,7 +278,7 @@ function onApplySync(oldValues, newValues) { // Runner export async function runRules( trans, - accounts: Map | null = null, + accounts: Map | null = null, ) { let accountsMap = null; if (accounts === null) { @@ -631,13 +631,11 @@ export async function applyActions( return null; } - const accounts: AccountEntity[] = await db.getAccounts(); + const accounts: db.DbAccount[] = await db.getAccounts(); + const accountsMap = new Map(accounts.map(account => [account.id, account])); const transactionsForRules = await Promise.all( transactions.map(transactions => - prepareTransactionForRules( - transactions, - new Map(accounts.map(account => [account.id, account])), - ), + prepareTransactionForRules(transactions, accountsMap), ), ); @@ -870,12 +868,12 @@ export async function updateCategoryRules(transactions) { export type TransactionForRules = TransactionEntity & { payee_name?: string; - _account?: AccountEntity; + _account?: db.DbAccount; }; export async function prepareTransactionForRules( trans: TransactionEntity, - accounts: Map | null = null, + accounts: Map | null = null, ): Promise { const r: TransactionForRules = { ...trans }; if (trans.payee) { diff --git a/packages/loot-core/src/server/transactions/transfer.test.ts b/packages/loot-core/src/server/transactions/transfer.test.ts index faf6d634684..81701d66731 100644 --- a/packages/loot-core/src/server/transactions/transfer.test.ts +++ b/packages/loot-core/src/server/transactions/transfer.test.ts @@ -64,10 +64,10 @@ describe('Transfer', () => { const differ = expectSnapshotWithDiffer(await getAllTransactions()); - const transferTwo = await db.first( + const transferTwo = await db.first( "SELECT * FROM payees WHERE transfer_acct = 'two'", ); - const transferThree = await db.first( + const transferThree = await db.first( "SELECT * FROM payees WHERE transfer_acct = 'three'", ); @@ -134,10 +134,10 @@ describe('Transfer', () => { test('transfers are properly de-categorized', async () => { await prepareDatabase(); - const transferTwo = await db.first( + const transferTwo = await db.first( "SELECT * FROM payees WHERE transfer_acct = 'two'", ); - const transferThree = await db.first( + const transferThree = await db.first( "SELECT * FROM payees WHERE transfer_acct = 'three'", ); @@ -179,8 +179,8 @@ describe('Transfer', () => { await prepareDatabase(); const [transferOne, transferTwo] = await Promise.all([ - db.first("SELECT * FROM payees WHERE transfer_acct = 'one'"), - db.first("SELECT * FROM payees WHERE transfer_acct = 'two'"), + db.first("SELECT * FROM payees WHERE transfer_acct = 'one'"), + db.first("SELECT * FROM payees WHERE transfer_acct = 'two'"), ]); let parent: Transaction = { diff --git a/packages/loot-core/src/server/transactions/transfer.ts b/packages/loot-core/src/server/transactions/transfer.ts index 4538e4c26c8..0158a73bc7c 100644 --- a/packages/loot-core/src/server/transactions/transfer.ts +++ b/packages/loot-core/src/server/transactions/transfer.ts @@ -2,12 +2,14 @@ import * as db from '../db'; async function getPayee(acct) { - return db.first('SELECT * FROM payees WHERE transfer_acct = ?', [acct]); + return db.first('SELECT * FROM payees WHERE transfer_acct = ?', [ + acct, + ]); } async function getTransferredAccount(transaction) { if (transaction.payee) { - const result = await db.first( + const result = await db.first>( 'SELECT transfer_acct FROM v_payees WHERE id = ?', [transaction.payee], ); @@ -18,14 +20,12 @@ async function getTransferredAccount(transaction) { } async function clearCategory(transaction, transferAcct) { - const { offbudget: fromOffBudget } = await db.first( - 'SELECT offbudget FROM accounts WHERE id = ?', - [transaction.account], - ); - const { offbudget: toOffBudget } = await db.first( - 'SELECT offbudget FROM accounts WHERE id = ?', - [transferAcct], - ); + const { offbudget: fromOffBudget } = await db.first< + Pick + >('SELECT offbudget FROM accounts WHERE id = ?', [transaction.account]); + const { offbudget: toOffBudget } = await db.first< + Pick + >('SELECT offbudget FROM accounts WHERE id = ?', [transferAcct]); // If the transfer is between two on budget or two off budget accounts, // we should clear the category, because the category is not relevant @@ -51,7 +51,7 @@ export async function addTransfer(transaction, transferredAccount) { return null; } - const { id: fromPayee } = await db.first( + const { id: fromPayee } = await db.first>( 'SELECT id FROM payees WHERE transfer_acct = ?', [transaction.account], ); diff --git a/packages/loot-core/src/server/update.ts b/packages/loot-core/src/server/update.ts index 73f5a7fe62c..a8a1b1c89f5 100644 --- a/packages/loot-core/src/server/update.ts +++ b/packages/loot-core/src/server/update.ts @@ -13,9 +13,10 @@ async function runMigrations() { async function updateViews() { const hashKey = 'view-hash'; - const row = await db.first('SELECT value FROM __meta__ WHERE key = ?', [ - hashKey, - ]); + const row = await db.first<{ value: string }>( + 'SELECT value FROM __meta__ WHERE key = ?', + [hashKey], + ); const { value: hash } = row || {}; const views = makeViews(schema, schemaConfig); diff --git a/packages/loot-core/src/types/models/index.d.ts b/packages/loot-core/src/types/models/index.d.ts index 543ca5eca11..42b5287965e 100644 --- a/packages/loot-core/src/types/models/index.d.ts +++ b/packages/loot-core/src/types/models/index.d.ts @@ -13,3 +13,4 @@ export type * from './schedule'; export type * from './transaction'; export type * from './transaction-filter'; export type * from './user'; +export type * from './bank'; diff --git a/upcoming-release-notes/4248.md b/upcoming-release-notes/4248.md new file mode 100644 index 00000000000..07f329fd72e --- /dev/null +++ b/upcoming-release-notes/4248.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [joel-jeremy] +--- + +[TypeScript] Make `db.first` generic to make it easy to type DB query results. \ No newline at end of file