Skip to content
Snippets Groups Projects
index.js 17.62 KiB
import LRU from 'lru-cache';
import * as sqlite from '../../platform/server/sqlite';
import fs from '../../platform/server/fs';
import { sendMessages, batchMessages } from '../sync';
import { schema, schemaConfig } from '../aql/schema';
import {
  accountModel,
  categoryModel,
  categoryGroupModel,
  payeeModel,
  payeeRuleModel
} from '../models';
import { groupById } from '../../shared/util';
import {
  makeClock,
  setClock,
  serializeClock,
  deserializeClock,
  makeClientId,
  Timestamp
} from '../crdt';
import {
  convertForInsert,
  convertForUpdate,
  convertFromSelect
} from '../aql/schema-helpers';
import { shoveSortOrders, SORT_INCREMENT } from './sort';

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

const uuid = require('../../platform/uuid');

let dbPath;
let db;

// Util

export function getDatabasePath() {
  return dbPath;
}

export async function openDatabase(id) {
  if (db) {
    await sqlite.closeDatabase(db);
  }

  dbPath = fs.join(fs.getBudgetDir(id), 'db.sqlite');
  setDatabase(await sqlite.openDatabase(dbPath));

  // await execQuery('PRAGMA journal_mode = WAL');
}

export async function reopenDatabase() {
  await sqlite.closeDatabase(db);
  setDatabase(await sqlite.openDatabase(dbPath));
}

export async function closeDatabase() {
  if (db) {
    await sqlite.closeDatabase(db);
    setDatabase(null);
  }
}

export function setDatabase(db_) {
  db = db_;
  resetQueryCache();
}

export function getDatabase() {
  return db;
}

export async function loadClock() {
  let row = await first('SELECT * FROM messages_clock');
  if (row) {
    let clock = deserializeClock(row.clock);
    setClock(clock);
  } else {
    // No clock exists yet (first run of the app), so create a default
    // one.
    let timestamp = new Timestamp(0, 0, makeClientId());
    let clock = makeClock(timestamp);
    setClock(clock);

    await runQuery('INSERT INTO messages_clock (id, clock) VALUES (?, ?)', [
      1,
      serializeClock(clock)
    ]);
  }
}

// Functions

export function runQuery(sql, params, fetchAll) {
  // const unrecord = perf.record('sqlite');
  const result = sqlite.runQuery(db, sql, params, fetchAll);
  // unrecord();
  return result;
}

export function execQuery(sql) {
  sqlite.execQuery(db, sql);
}

// This manages an LRU cache of prepared query statements. This is
// only needed in hot spots when you are running lots of queries.
let _queryCache = new LRU({ max: 100 });
export function cache(sql) {
  let cached = _queryCache.get(sql);
  if (cached) {
    return cached;
  }

  let prepared = sqlite.prepare(db, sql);
  _queryCache.set(sql, prepared);
  return prepared;
}

function resetQueryCache() {
  _queryCache = new LRU({ max: 100 });
}

export function transaction(fn) {
  return sqlite.transaction(db, fn);
}

export function asyncTransaction(fn) {
  return sqlite.asyncTransaction(db, fn);
}

// This function is marked as async because `runQuery` is no longer
// 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) {
  return runQuery(sql, params, true);
}

export async function first(sql, params) {
  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
// without auditing all uses of it
export function firstSync(sql, params) {
  const arr = runQuery(sql, params, true);
  return arr.length === 0 ? null : arr[0];
}

// This function is marked as async because `runQuery` is no longer
// async. We return a promise here until we've audited all the code to
// make sure nothing calls `.then` on this.
export async function run(sql, params) {
  return runQuery(sql, params);
}

export async function select(table, id) {
  const rows = await runQuery(
    'SELECT * FROM ' + table + ' WHERE id = ?',
    [id],
    true
  );
  return rows[0];
}

export async function update(table, params) {
  let fields = Object.keys(params).filter(k => k !== 'id');

  if (params.id == null) {
    throw new Error('update: id is required');
  }

  await sendMessages(
    fields.map(k => {
      return {
        dataset: table,
        row: params.id,
        column: k,
        value: params[k],
        timestamp: Timestamp.send()
      };
    })
  );
}

export async function insertWithUUID(table, row) {
  if (!row.id) {
    row = { ...row, id: uuid.v4Sync() };
  }

  await insert(table, row);

  // We can't rely on the return value of insert because if the
  // primary key is text, sqlite returns the internal row id which we
  // don't care about. We want to return the generated UUID.
  return row.id;
}

export async function insert(table, row) {
  let fields = Object.keys(row).filter(k => k !== 'id');

  if (row.id == null) {
    throw new Error('insert: id is required');
  }

  await sendMessages(
    fields.map(k => {
      return {
        dataset: table,
        row: row.id,
        column: k,
        value: row[k],
        timestamp: Timestamp.send()
      };
    })
  );
}

export async function delete_(table, id) {
  await sendMessages([
    {
      dataset: table,
      row: id,
      column: 'tombstone',
      value: 1,
      timestamp: Timestamp.send()
    }
  ]);
}

export async function selectWithSchema(table, sql, params) {
  let rows = await runQuery(sql, params, true);
  return rows
    .map(row => convertFromSelect(schema, schemaConfig, table, row))
    .filter(Boolean);
}

export async function selectFirstWithSchema(table, sql, params) {
  let rows = await selectWithSchema(table, sql, params);
  return rows.length > 0 ? rows[0] : null;
}

export function insertWithSchema(table, row) {
  // Even though `insertWithUUID` does this, we need to do it here so
  // the schema validation passes
  if (!row.id) {
    row = { ...row, id: uuid.v4Sync() };
  }

  return insertWithUUID(
    table,
    convertForInsert(schema, schemaConfig, table, row)
  );
}

export function updateWithSchema(table, fields) {
  return update(table, convertForUpdate(schema, schemaConfig, table, fields));
}

// Data-specific functions. Ideally this would be split up into
// different files

export async function getCategories() {
  return all(`
    SELECT c.* FROM categories c
      LEFT JOIN category_groups cg ON c.cat_group = cg.id
      WHERE c.tombstone = 0
      ORDER BY cg.sort_order, cg.id, c.sort_order, c.id
  `);
}

export async function getCategoriesGrouped() {
  const groups = await all(
    'SELECT * FROM category_groups WHERE tombstone = 0 ORDER BY is_income, sort_order, id'
  );
  const rows = await all(`
    SELECT * FROM categories WHERE tombstone = 0
      ORDER BY sort_order, id
  `);

  return groups.map(group => {
    return {
      ...group,
      categories: rows.filter(row => row.cat_group === group.id)
    };
  });
}

export async function insertCategoryGroup(group) {
  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;

  group = {
    ...categoryGroupModel.validate(group),
    sort_order: sort_order
  };
  return insertWithUUID('category_groups', group);
}

export function updateCategoryGroup(group) {
  group = categoryGroupModel.validate(group, { update: true });
  return update('category_groups', group);
}

export async function moveCategoryGroup(id, targetId) {
  const groups = await all(
    `SELECT id, sort_order FROM category_groups WHERE tombstone = 0 ORDER BY sort_order, id`
  );

  const { updates, sort_order } = shoveSortOrders(groups, targetId);
  for (let info of updates) {
    await update('category_groups', info);
  }
  await update('category_groups', { id, sort_order });
}

export async function deleteCategoryGroup(group, transferId) {
  const categories = await all('SELECT * FROM categories WHERE cat_group = ?', [
    group.id
  ]);

  // Delete all the categories within a group
  await Promise.all(categories.map(cat => deleteCategory(cat, transferId)));
  await delete_('category_groups', group.id);
}

export async function insertCategory(category, { atEnd } = {}) {
  let sort_order;

  let id_;
  await batchMessages(async () => {
    if (atEnd) {
      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;
    } else {
      // Unfortunately since we insert at the beginning, we need to shove
      // the sort orders to make sure there's room for it
      const categories = await all(
        `SELECT id, sort_order FROM categories WHERE cat_group = ? AND tombstone = 0 ORDER BY sort_order, id`,
        [category.cat_group]
      );

      const { updates, sort_order: order } = shoveSortOrders(
        categories,
        categories.length > 0 ? categories[0].id : null
      );
      for (let info of updates) {
        await update('categories', info);
      }
      sort_order = order;
    }

    category = {
      ...categoryModel.validate(category),
      sort_order: sort_order
    };

    const id = await insertWithUUID('categories', category);
    // Create an entry in the mapping table that points it to itself
    await insert('category_mapping', { id, transferId: id });
    id_ = id;
  });
  return id_;
}

export function updateCategory(category) {
  category = categoryModel.validate(category, { update: true });
  return update('categories', category);
}

export async function moveCategory(id, groupId, targetId) {
  if (!groupId) {
    throw new Error('moveCategory: groupId is required');
  }

  const categories = await all(
    `SELECT id, sort_order FROM categories WHERE cat_group = ? AND tombstone = 0 ORDER BY sort_order, id`,
    [groupId]
  );

  const { updates, sort_order } = shoveSortOrders(categories, targetId);
  for (let info of updates) {
    await update('categories', info);
  }
  await update('categories', { id, sort_order, cat_group: groupId });
}

export async function deleteCategory(category, transferId) {
  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
    // "forwarded" to the new transferred category.
    const existingTransfers = await all(
      'SELECT * FROM category_mapping WHERE transferId = ?',
      [category.id]
    );
    for (let mapping of existingTransfers) {
      await update('category_mapping', { id: mapping.id, transferId });
    }

    // Finally, map the category we're about to delete to the new one
    await update('category_mapping', { id: category.id, transferId });
  }

  return delete_('categories', category.id);
}

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

export async function insertPayee(payee) {
  payee = payeeModel.validate(payee);
  let id;
  await batchMessages(async () => {
    id = await insertWithUUID('payees', payee);
    await insert('payee_mapping', { id, targetId: id });
  });
  return id;
}

export async function deletePayee(payee) {
  let { transfer_acct } = await first('SELECT * FROM payees WHERE id = ?', [
    payee.id
  ]);
  if (transfer_acct) {
    // You should never be able to delete transfer payees
    return;
  }

  // let mappings = await all('SELECT id FROM payee_mapping WHERE targetId = ?', [
  //   payee.id
  // ]);
  // await Promise.all(
  //   mappings.map(m => update('payee_mapping', { id: m.id, targetId: null }))
  // );

  let rules = await all('SELECT * FROM payee_rules WHERE payee_id = ?', [
    payee.id
  ]);
  await Promise.all(rules.map(rule => deletePayeeRule({ id: rule.id })));
  return delete_('payees', payee.id);
}

export async function deleteTransferPayee(payee) {
  // This allows deleting transfer payees
  return delete_('payees', payee.id);
}

export function updatePayee(payee) {
  payee = payeeModel.validate(payee, { update: true });
  return update('payees', payee);
}

export async function mergePayees(target, ids) {
  // Load in payees so we can check some stuff
  let payees = groupById(await all('SELECT * FROM payees'));

  // Filter out any transfer payees
  if (payees[target].transfer_acct != null) {
    return;
  }
  ids = ids.filter(id => payees[id].transfer_acct == null);

  await batchMessages(async () => {
    await Promise.all(
      ids.map(async id => {
        let mappings = await all(
          'SELECT id FROM payee_mapping WHERE targetId = ?',
          [id]
        );
        await Promise.all(
          mappings.map(m =>
            update('payee_mapping', { id: m.id, targetId: target })
          )
        );
      })
    );

    return Promise.all(
      ids.map(id =>
        Promise.all([
          update('payee_mapping', { id, targetId: target }),
          delete_('payees', id)
        ])
      )
    );
  });
}

export function getPayees() {
  return all(`
    SELECT p.*, COALESCE(a.name, p.name) AS name FROM payees p
    LEFT JOIN accounts a ON (p.transfer_acct = a.id AND a.tombstone = 0)
    WHERE p.tombstone = 0 AND (p.transfer_acct IS NULL OR a.id IS NOT NULL)
    ORDER BY p.transfer_acct IS NULL DESC, p.name COLLATE NOCASE
  `);
}

export async function getOrphanedPayees() {
  let rows = await all(`
    SELECT p.id FROM payees p
    LEFT JOIN payee_mapping pm ON pm.id = p.id
    LEFT JOIN v_transactions_internal_alive t ON t.payee = pm.targetId
    WHERE p.tombstone = 0 AND p.transfer_acct IS NULL AND t.id IS NULL
  `);
  return rows.map(row => row.id);
}

export async function getPayeeByName(name) {
  return first(`SELECT * FROM payees WHERE LOWER(name) = ? AND tombstone = 0`, [
    name.toLowerCase()
  ]);
}

export function insertPayeeRule(rule) {
  rule = payeeRuleModel.validate(rule);
  return insertWithUUID('payee_rules', rule);
}

export function deletePayeeRule(rule) {
  return delete_('payee_rules', rule.id);
}

export function updatePayeeRule(rule) {
  rule = payeeModel.validate(rule, { update: true });
  return update('payee_rules', rule);
}

export function getPayeeRules(id) {
  return all(
    `SELECT pr.* FROM payee_rules pr
     LEFT JOIN payee_mapping pm ON pm.id = pr.payee_id
     WHERE pm.targetId = ? AND pr.tombstone = 0`,
    [id]
  );
}

export function getAccounts() {
  return all(
    `SELECT a.*, b.name as bankName, b.id as bankId FROM accounts a
       LEFT JOIN banks b ON a.bank = b.id
       WHERE a.tombstone = 0
       ORDER BY sort_order, name`
  );
}

export async function insertAccount(account) {
  // Default to checking. Makes it a lot easier for tests and is
  // generally harmless.
  if (account.type === undefined) {
    account = { ...account, type: 'checking' };
  }

  const accounts = await all(
    'SELECT * FROM accounts WHERE offbudget = ? ORDER BY sort_order, name',
    [account.offbudget != null ? account.offbudget : 0]
  );

  // Don't pass a target in, it will default to appending at the end
  let { sort_order } = shoveSortOrders(accounts);

  account = accountModel.validate({ ...account, sort_order });
  return insertWithUUID('accounts', account);
}

export function updateAccount(account) {
  account = accountModel.validate(account, { update: true });
  return update('accounts', account);
}

export function deleteAccount(account) {
  return delete_('accounts', account.id);
}

export async function moveAccount(id, targetId) {
  let account = await first('SELECT * FROM accounts WHERE id = ?', [id]);
  let accounts;
  if (account.closed) {
    accounts = await all(
      `SELECT id, sort_order FROM accounts WHERE closed = 1 ORDER BY sort_order, name`
    );
  } else {
    accounts = await all(
      `SELECT id, sort_order FROM accounts WHERE tombstone = 0 AND offbudget = ? ORDER BY sort_order, name`,
      [account.offbudget]
    );
  }

  const { updates, sort_order } = shoveSortOrders(accounts, targetId);
  await batchMessages(() => {
    for (let info of updates) {
      update('accounts', info);
    }
    update('accounts', { id, sort_order });
  });
}

export async function getTransaction(id) {
  let rows = await selectWithSchema(
    'transactions',
    'SELECT * FROM v_transactions WHERE id = ?',
    [id]
  );
  return rows[0];
}

function _addFragmentForAccount(accountId, addWhere, options = {}) {
  let { showClosed = false, showOffbudget = true } = options;

  let fragment = addWhere ? ' WHERE (' : ' AND ';
  let params = [];

  if (accountId) {
    if (accountId === 'offbudget') {
      fragment += 'a.closed = 0 AND a.offbudget = 1 ';
    } else if (accountId === 'budgeted') {
      fragment += 'a.closed = 0 AND a.offbudget = 0 ';
    } else if (accountId === 'uncategorized') {
      fragment += `
          t.category IS NULL AND a.offbudget = 0 AND isParent = 0 AND (
            ta.offbudget IS NULL OR ta.offbudget = 1
          )
        `;
    } else {
      fragment += 'a.id = ? ';
      params.push(accountId);
    }
  } else {
    fragment += showClosed ? '1' : 'a.closed = 0';

    if (!showOffbudget) {
      fragment += ' AND a.offbudget = 0';
    }
  }

  return { fragment, params };
}

export async function getTransactionsByDate(
  accountId,
  startDate,
  endDate,
  options = {}
) {
  throw new Error('`getTransactionsByDate` is deprecated');
}

export async function getTransactions(accountId, arg2) {
  if (arg2 !== undefined) {
    throw new Error(
      '`getTransactions` was given a second argument, it now only takes a single argument `accountId`'
    );
  }

  return selectWithSchema(
    'transactions',
    'SELECT * FROM v_transactions WHERE account = ?',
    [accountId]
  );
}

export function insertTransaction(transaction) {
  return insertWithSchema('transactions', transaction);
}

export function updateTransaction(transaction) {
  return updateWithSchema('transactions', transaction);
}

export async function deleteTransaction(transaction) {
  return delete_('transactions', transaction.id);
}