module account.data_impl_sqlite; import std.datetime; import d2sqlite3; import handy_http_primitives : Optional; import account.data; import account.model; import history.model; 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 associated history. util.sqlite.update( db, "DELETE FROM history WHERE id IN ( SELECT history_id FROM account_history WHERE account_id = ? )", id ); // 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 ); // Finally delete the account itself (and all cascaded entities, like journal entries). util.sqlite.update(db, "DELETE FROM account WHERE id = ?", id); }); } 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 ); } } History getHistory(ulong id) { if (!exists(db, "SELECT id FROM account WHERE id = ?", id)) { throw new Exception("Account doesn't exist."); } Optional!History history = findOne( db, q"SQL SELECT * FROM history LEFT JOIN account_history ah ON ah.history_id = history.id WHERE ah.account_id = ? SQL", r => History(r.peek!ulong(0)), id ); if (!history.empty) { return history.value; } // No history exists yet, so add it. ulong historyId = doTransaction(db, () { util.sqlite.update(db, "INSERT INTO history DEFAULT VALUES"); ulong historyId = db.lastInsertRowid(); util.sqlite.update(db, "INSERT INTO account_history (account_id, history_id) VALUES (?, ?)", id, historyId); return historyId; }); return History(historyId); } 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 id = db.lastInsertRowid(); return findById(id).orElseThrow(); } void deleteById(ulong id) { util.sqlite.deleteById(db, "account_journal_entry", id); } void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId) { util.sqlite.update( db, "DELETE FROM account_journal_entry WHERE account_id = ? AND transaction_id = ?", accountId, transactionId ); } AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl) { const query = "SELECT * FROM account_journal_entry " ~ "WHERE timestamp >= ? AND timestamp <= ? " ~ "ORDER BY timestamp ASC"; return util.sqlite.findAll( db, query, &parseEntry, startIncl.toISOExtString(), endIncl.toISOExtString() ); } 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 id = db.lastInsertRowid(); return findById(accountId, id).orElseThrow(); } void deleteById(ulong accountId, ulong 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)) ); } }