module account.data_impl_sqlite; import std.datetime; import d2sqlite3; import handy_http_primitives : Optional; import slf4d; import account.data; import account.model; import account.dto; import util.sqlite; import util.money; import util.pagination; class SqliteAccountRepository : AccountRepository { private Database db; this(Database db) { this.db = db; } Optional!Account findById(ulong id) { return findOne(db, "SELECT * FROM account WHERE id = ?", &parseAccount, id); } bool existsById(ulong id) { return util.sqlite.exists(db, "SELECT id FROM account WHERE id = ?", id); } Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description) { util.sqlite.update( db, "INSERT INTO account (created_at, type, number_suffix, name, currency, description) VALUES (?, ?, ?, ?, ?, ?)", Clock.currTime(UTC()).toISOExtString(), type.id, numberSuffix, name, currency.code, description ); ulong accountId = db.lastInsertRowid(); return findById(accountId).orElseThrow("Couldn't find account!"); } void setArchived(ulong id, bool archived) { util.sqlite.update( db, "UPDATE account SET archived = ? WHERE id = ?", archived ? 1 : 0, id ); } Account update(ulong id, in Account newData) { return doTransaction(db, () { Account oldAccount = this.findById(id).orElseThrow("Account doesn't exist."); bool typeDiff = oldAccount.type != newData.type; bool numberSuffixDiff = oldAccount.numberSuffix != newData.numberSuffix; bool nameDiff = oldAccount.name != newData.name; bool currencyDiff = oldAccount.currency != newData.currency; bool descriptionDiff = oldAccount.description != newData.description; util.sqlite.update( db, q"SQL UPDATE account SET type = ?, number_suffix = ?, name = ?, currency = ?, description = ? WHERE id = ? SQL", newData.type.id, newData.numberSuffix, newData.name, newData.currency.code, newData.description, id ); return this.findById(id).orElseThrow("Account doesn't exist"); }); } void deleteById(ulong id) { doTransaction(db, () { // Delete all associated transactions. util.sqlite.update( db, "DELETE FROM \"transaction\" WHERE id IN " ~ "(SELECT transaction_id FROM account_journal_entry WHERE account_id = ?)", id ); // Delete the account itself (and all cascaded entities, like journal entries). util.sqlite.update(db, "DELETE FROM account WHERE id = ?", id); /* Delete all orphaned history entries for journal entries referencing deleted transactions. This is needed because even though the above `DELETE` cascades to delete all history items for this account, there are some history items linked to other accounts which aren't deleted automatically. */ util.sqlite.update( db, "DELETE FROM account_history_item WHERE type LIKE 'JOURNAL_ENTRY' AND id NOT IN ( SELECT item_id FROM history_item_linked_journal_entry )" ); }); } Account[] findAll() { return util.sqlite.findAll(db, "SELECT * FROM account", &parseAccount); } AccountCreditCardProperties getCreditCardProperties(ulong id) { Account account = findById(id).orElseThrow("Account doesn't exist."); if (account.type != AccountTypes.CREDIT_CARD) throw new Exception("Account is not credit card."); auto optionalProps = findOne( db, "SELECT * FROM account_credit_card_properties WHERE account_id = ?", &parseCreditCardProperties, id ); if (!optionalProps.isNull) return optionalProps.value; // No properties exist, so set them and return the new data. const props = AccountCreditCardProperties(account.id, -1); util.sqlite.update( db, "INSERT INTO account_credit_card_properties (account_id, credit_limit) VALUES (?, ?)", props.account_id, props.creditLimit ); return props; } void setCreditCardProperties(ulong id, in AccountCreditCardProperties props) { bool hasProps = exists(db, "SELECT * FROM account_credit_card_properties WHERE account_id = ?", id); if (hasProps) { util.sqlite.update( db, "UPDATE account_credit_card_properties SET credit_limit = ? WHERE account_id = ?", props.creditLimit, id ); } else { util.sqlite.update( db, "INSERT INTO account_credit_card_properties (account_id, credit_limit) VALUES (?, ?)", id, props.creditLimit ); } } private static struct BaseHistoryItem { ulong id; string timestamp; string type; } Page!AccountHistoryItemResponse getHistory(ulong accountId, in PageRequest pagination) { ulong count = util.sqlite.count( db, "SELECT COUNT(id) FROM account_history_item WHERE account_id = ?", accountId ); const baseQuery = "SELECT id, timestamp, type " ~ "FROM account_history_item " ~ "WHERE account_id = ?"; string query = baseQuery ~ " " ~ pagination.toSql(); // First fetch the basic information about each item. BaseHistoryItem[] items = util.sqlite.findAll( db, query, (row) { return BaseHistoryItem( row.peek!ulong(0), row.peek!string(1), row.peek!string(2) ); }, accountId ); // Then we'll do another query based on the type of history item. // This is fine for paginated responses. AccountHistoryItemResponse[] results; foreach (BaseHistoryItem item; items) { if (item.type == AccountHistoryItemType.ValueRecord) { results ~= fetchValueRecordHistoryItem(item); } else if (item.type == AccountHistoryItemType.JournalEntry) { results ~= fetchJournalEntryHistoryItem(item); } else { AccountHistoryItemResponse base = new AccountHistoryItemResponse(); base.timestamp = item.timestamp; base.type = item.type; results ~= base; } } return Page!AccountHistoryItemResponse.of(results, pagination, count); } private AccountHistoryValueRecordItemResponse fetchValueRecordHistoryItem(in BaseHistoryItem item) { return util.sqlite.findOne( db, "SELECT vr.id, vr.type, vr.value, vr.currency FROM history_item_linked_value_record h " ~ "LEFT JOIN account_value_record vr ON vr.id = h.value_record_id " ~ "WHERE h.item_id = ?", (row) { auto obj = new AccountHistoryValueRecordItemResponse(); obj.timestamp = item.timestamp; obj.type = item.type; obj.valueRecordId = row.peek!ulong(0); obj.valueRecordType = row.peek!string(1); obj.value = row.peek!long(2); obj.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(3)); return obj; }, item.id ).orElseThrow(); } private AccountHistoryJournalEntryItemResponse fetchJournalEntryHistoryItem(in BaseHistoryItem item) { return util.sqlite.findOne( db, "SELECT je.type, je.amount, je.currency, tx.id, tx.description FROM history_item_linked_journal_entry h " ~ "LEFT JOIN account_journal_entry je ON je.id = h.journal_entry_id " ~ "LEFT JOIN \"transaction\" tx ON tx.id = je.transaction_id " ~ "WHERE h.item_id = ?", (row) { auto obj = new AccountHistoryJournalEntryItemResponse(); obj.timestamp = item.timestamp; obj.type = item.type; obj.journalEntryType = row.peek!string(0); obj.amount = row.peek!ulong(1); obj.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(2)); obj.transactionId = row.peek!ulong(3); obj.transactionDescription = row.peek!string(4); return obj; }, item.id ).orElseThrow(); } static Account parseAccount(Row row) { return Account( row.peek!ulong(0), SysTime.fromISOExtString(row.peek!string(1)), row.peek!bool(2), AccountType.fromId(row.peek!string(3)), row.peek!string(4), row.peek!string(5), Currency.ofCode(row.peek!string(6)), row.peek!string(7) ); } static AccountCreditCardProperties parseCreditCardProperties(Row row) { import std.typecons : Nullable; ulong accountId = row.peek!ulong(0); Nullable!ulong creditLimit = row.peek!ulong(1); return AccountCreditCardProperties( accountId, creditLimit.isNull ? -1 : creditLimit.get() ); } } class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository { private Database db; this(Database db) { this.db = db; } Optional!AccountJournalEntry findById(ulong id) { return util.sqlite.findById(db, "account_journal_entry", &parseEntry, id); } AccountJournalEntry insert( SysTime timestamp, ulong accountId, ulong transactionId, ulong amount, AccountJournalEntryType type, Currency currency ) { util.sqlite.update( db, "INSERT INTO account_journal_entry (timestamp, account_id, transaction_id, amount, type, currency) VALUES (?, ?, ?, ?, ?, ?)", timestamp.toISOExtString(), accountId, transactionId, amount, type, currency.code ); ulong journalEntryId = db.lastInsertRowid(); // Insert a history item that links to the journal entry. ulong historyItemId = insertNewAccountHistoryItem( db, timestamp, accountId, AccountHistoryItemType.JournalEntry); util.sqlite.update( db, "INSERT INTO history_item_linked_journal_entry (item_id, journal_entry_id) VALUES (?, ?)", historyItemId, journalEntryId ); return findById(journalEntryId).orElseThrow(); } void deleteById(ulong id) { util.sqlite.update( db, "DELETE FROM account_history_item WHERE id IN " ~ "(SELECT item_id FROM history_item_linked_journal_entry WHERE journal_entry_id = ?)", id ); util.sqlite.deleteById(db, "account_journal_entry", id); } void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId) { util.sqlite.update( db, "DELETE FROM account_history_item WHERE id IN " ~ "(SELECT j.item_id FROM history_item_linked_journal_entry j " ~ " LEFT JOIN account_journal_entry je ON je.id = j.journal_entry_id " ~ "WHERE je.account_id = ? AND je.transaction_id = ?)", accountId, transactionId ); util.sqlite.update( db, "DELETE FROM account_journal_entry WHERE account_id = ? AND transaction_id = ?", accountId, transactionId ); } AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl, ulong accountId) { const query = "SELECT * FROM account_journal_entry " ~ "WHERE timestamp >= ? AND timestamp <= ? AND account_id = ? " ~ "ORDER BY timestamp ASC"; return util.sqlite.findAll( db, query, &parseEntry, startIncl.toISOExtString(), endIncl.toISOExtString(), accountId ); } static AccountJournalEntry parseEntry(Row row) { string typeStr = row.peek!(string, PeekMode.slice)(5); AccountJournalEntryType type; if (typeStr == "CREDIT") { type = AccountJournalEntryType.CREDIT; } else if (typeStr == "DEBIT") { type = AccountJournalEntryType.DEBIT; } else { throw new Exception("Invalid account journal entry type: " ~ typeStr); } return AccountJournalEntry( row.peek!ulong(0), SysTime.fromISOExtString(row.peek!string(1)), row.peek!ulong(2), row.peek!ulong(3), row.peek!ulong(4), type, Currency.ofCode(row.peek!(string, PeekMode.slice)(6)) ); } } class SqliteAccountValueRecordRepository : AccountValueRecordRepository { private Database db; this(Database db) { this.db = db; } Optional!AccountValueRecord findById(ulong accountId, ulong id) { return util.sqlite.findOne( db, "SELECT * FROM account_value_record WHERE account_id = ? AND id = ?", &parseValueRecord, accountId, id ); } Page!AccountValueRecord findAllByAccountId(ulong accountId, in PageRequest pr) { const baseQuery = "SELECT * FROM account_value_record WHERE account_id = ?"; string query = baseQuery ~ " " ~ pr.toSql(); AccountValueRecord[] records = util.sqlite.findAll( db, query, &parseValueRecord, accountId ); ulong count = util.sqlite.count( db, "SELECT COUNT(id) FROM account_value_record WHERE account_id = ?", accountId ); return Page!AccountValueRecord.of(records, pr, count); } AccountValueRecord insert( SysTime timestamp, ulong accountId, AccountValueRecordType type, long value, Currency currency ) { util.sqlite.update( db, "INSERT INTO account_value_record " ~ "(timestamp, account_id, type, value, currency) " ~ "VALUES (?, ?, ?, ?, ?)", timestamp.toISOExtString(), accountId, type, value, currency.code ); ulong valueRecordId = db.lastInsertRowid(); // Insert a history item that links to this value record. ulong historyItemId = insertNewAccountHistoryItem( db, timestamp, accountId, AccountHistoryItemType.ValueRecord); util.sqlite.update( db, "INSERT INTO history_item_linked_value_record (item_id, value_record_id) VALUES (?, ?)", historyItemId, valueRecordId ); return findById(accountId, valueRecordId).orElseThrow(); } void linkAttachment(ulong valueRecordId, ulong attachmentId) { util.sqlite.update( db, "INSERT INTO account_value_record_attachment (value_record_id, attachment_id) VALUES (?, ?)", valueRecordId, attachmentId ); } void deleteById(ulong accountId, ulong id) { // First delete any associated history items: util.sqlite.update( db, "DELETE FROM account_history_item WHERE id IN " ~ "(SELECT item_id FROM history_item_linked_value_record WHERE value_record_id = ?)", id ); util.sqlite.update( db, "DELETE FROM account_value_record WHERE account_id = ? AND id = ?", accountId, id ); } Optional!AccountValueRecord findNearestByAccountIdBefore(ulong accountId, SysTime timestamp) { const query = "SELECT * FROM account_value_record " ~ "WHERE account_id = ? AND timestamp < ? " ~ "ORDER BY timestamp DESC LIMIT 1"; return util.sqlite.findOne( db, query, &parseValueRecord, accountId, timestamp.toISOExtString() ); } Optional!AccountValueRecord findNearestByAccountIdAfter(ulong accountId, SysTime timestamp) { const query = "SELECT * FROM account_value_record " ~ "WHERE account_id = ? AND timestamp > ? " ~ "ORDER BY timestamp ASC LIMIT 1"; return util.sqlite.findOne( db, query, &parseValueRecord, accountId, timestamp.toISOExtString() ); } static AccountValueRecord parseValueRecord(Row row) { string typeStr = row.peek!(string, PeekMode.slice)(3); AccountValueRecordType type; if (typeStr == "BALANCE") { type = AccountValueRecordType.BALANCE; } else { throw new Exception("Invalid account value record type: " ~ typeStr); } return AccountValueRecord( row.peek!ulong(0), parseISOTimestamp(row, 1), row.peek!ulong(2), type, row.peek!long(4), Currency.ofCode(row.peek!(string, PeekMode.slice)(5)) ); } } private ulong insertNewAccountHistoryItem( ref Database db, SysTime timestamp, ulong accountId, AccountHistoryItemType type ) { util.sqlite.update( db, "INSERT INTO account_history_item (account_id, timestamp, type) VALUES (?, ?, ?)", accountId, timestamp.toISOExtString(), type ); return db.lastInsertRowid(); }