如何在数据库中存储功能逻辑

How to store function logic in the database

我正在制作财务管理应用程序。我有一个数据库,其中包含用户拥有他的钱的所有地方,其中包括银行。这是 table 的结构..

CREATE TABLE IF NOT EXISTS reserves (
                            id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, 
                            name VARCHAR(31) NOT NULL, 
                            balance DECIMAL(10, 2) NOT NULL
                        )
CREATE TABLE IF NOT EXISTS banks (
                            reserve_id SMALLINT UNSIGNED UNIQUE NOT NULL, 
                            apy DECIMAL(4, 2) NOT NULL, 
                            accrued_interest DECIMAL(10, 4) NOT NULL, 
                            last_transaction DATE, 
                            FOREIGN KEY(reserve_id) REFERENCES reserves(id)
                        )

在这个模型中,我可以有一个固定的 APY,它将在插入时设置。但在现实世界中,银行有基于余额的可变利率。银行table.

中每个银行的具体情况都不同

在 JAVA class 中,我可以使用定义为 Function<BigDecimal, Big Decimal> APY 的 APY 非常轻松地捕获它,我可以在其中存储特定的 APY 逻辑并使用 APY.apply(balance) 来检索任何时候的利率。

但我不知道如何将此逻辑存储在 mySQL 数据库中。

我知道我可以创建一个单独的 table,例如 bank_balance_interest,我可以在其中将利率存储到特定银行的 ID 的最低余额,然后参考它。

但就是感觉不对。一方面,它非常繁琐和乏味。此外,如果没有明确的利益平衡边界,仍然不会有任何解决方案,而是一个连续的函数。

有没有更优雅的方法?

编辑 这是我的一些代码:

public class Reserve {
short id;
final String name;
BigDecimal balance;

ReservesData reservesData;
public Reserve(short id, String name, BigDecimal balance) {
    this.id = id;
    this.name = name;
    this.balance = balance;

    reservesData = ReservesData.instance;
}
public Reserve(String name) {
    this((short) -1, name, new BigDecimal("0.0"));
}

@Override
public String toString() {
    return name;
}

public short getId() {
    return id;
}

public String getName() {
    return name;
}

public BigDecimal getBalance() {
    return balance;
}

public boolean transact(BigDecimal amount) {
    if(balance.add(amount).compareTo(new BigDecimal("0.0")) < 0) return false;
    balance = balance.add(amount);
    return true;
}

public boolean save() {
    if(id == -1)
        return (id = reservesData.addReserve(this)) != -1;
    return reservesData.updateReserve(this);
}
}

.

public class Bank extends Reserve{
private final Function<BigDecimal, BigDecimal> APY;
private BigDecimal accruedInterest;
private Date lastTransactionDate;

private final BanksData banksData;
public Bank(short id, String name, BigDecimal balance, Function<BigDecimal, BigDecimal> APY) {
    super(id, name, balance);

    this.APY = APY;
    accruedInterest = new BigDecimal("0.0");

    banksData = BanksData.instance;
}
public Bank(String name, Function<BigDecimal, BigDecimal> APY) {
    this((short) -1, name, new BigDecimal("0.0"), APY);
}

@Override
public BigDecimal getBalance() {
    return balance.add(accruedInterest);
}

public Function<BigDecimal, BigDecimal> getAPY() {
    return APY;
}

public BigDecimal getAccruedInterest() {
    return accruedInterest;
}

public void setAccruedInterest(BigDecimal accruedInterest) {
    this.accruedInterest = accruedInterest;
}

.

public class ReservesDAO implements ReservesData {
public ReservesDAO() {
    try(Statement stmt = MyConnection.getMySQLconnection().createStatement()) {
        stmt.executeUpdate("""
                        CREATE TABLE IF NOT EXISTS reserves (
                            id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, 
                            name VARCHAR(31) NOT NULL, 
                            balance DECIMAL(10, 2) NOT NULL
                        )"""
        );
    } catch (SQLException sqlException) {
        System.out.println("Failed to create reserves table on the database!");
        sqlException.printStackTrace();
    }
}

@Override
public short addReserve(Reserve reserve) {
    try (
            PreparedStatement pstmt = MyConnection.getMySQLconnection().prepareStatement("""
                    INSERT INTO reserves (name, balance) VALUES (?, ?)""", Statement.RETURN_GENERATED_KEYS
            )
    ) {
        pstmt.setString(1, reserve.getName());
        pstmt.setBigDecimal(2, reserve.getBalance());

        pstmt.executeUpdate();
        ResultSet rs = pstmt.getGeneratedKeys();
        if (rs.next())
            return rs.getShort(1);
        else throw new RuntimeException("Auto-Generated ID was not returned from reserves!");
    } catch (SQLException sqlException) {
        System.out.println("Failed to insert " + reserve.getName() + " info in the database!");
        sqlException.printStackTrace();
        return -1;
    }
}

public Reserve getReserve(short id) {
    try(
            PreparedStatement pstmt = MyConnection.getMySQLconnection().prepareStatement("""
                    SELECT * FROM reserves WHERE id = ?""")
    ) {
        pstmt.setShort(1, id);
        ResultSet rs = pstmt.executeQuery();

        if(rs.next())
            return new Reserve(rs.getShort(1), rs.getString(2), rs.getBigDecimal(3));
        else throw new RuntimeException("No reserve found on the database with the id " + id);

    } catch (SQLException sqlException) {
        System.out.println("Failed to fetch reserve from the database!");
        sqlException.printStackTrace();
        return null;
    }
}

public List<Reserve> getAllReserves() {
    List<Reserve> reserves = new ArrayList<>();
    try(Statement stmt = MyConnection.getMySQLconnection().createStatement()) {
        ResultSet rs = stmt.executeQuery("SELECT * FROM reserves");
        while(rs.next())
            reserves.add(new Reserve(rs.getShort(1), rs.getString(2), rs.getBigDecimal(3)));
    } catch (SQLException sqlException) {
        System.out.println("Failed to fetch reserves from the database!");
        sqlException.printStackTrace();
    }

    return reserves;
}

@Override
public BigDecimal getTotalReserveBalance() {
    try(Statement stmt = MyConnection.getMySQLconnection().createStatement()) {
        ResultSet rs = stmt.executeQuery("""
                SELECT SUM(balance) FROM reserves""");
        if(rs.next()) return rs.getBigDecimal(1);
        return new BigDecimal("0.0");
    } catch (SQLException sqlException) {
        System.out.println("Could not get total reserve balance from database!");
        sqlException.printStackTrace();
        return null;
    }
}

@Override
public List<Reserve> getAllWallets() {
    List<Reserve> reserves = new ArrayList<>();
    try(Statement stmt = MyConnection.getMySQLconnection().createStatement()) {
        ResultSet rs = stmt.executeQuery("""
                SELECT reserves.* FROM reserves 
                LEFT JOIN banks ON reserves.id = banks.id
                WHERE banks.id IS NULL
                """);
        while(rs.next())
            reserves.add(new Reserve(rs.getShort(1), rs.getString(2), rs.getBigDecimal(3)));
    } catch (SQLException sqlException) {
        System.out.println("Failed to fetch reserves from the database!");
        sqlException.printStackTrace();
    }

    return reserves;
}

@Override
public BigDecimal getTotalWalletBalance() {
    try(Statement stmt = MyConnection.getMySQLconnection().createStatement()) {
        ResultSet rs = stmt.executeQuery("""
                SELECT SUM(balance) FROM reserves 
                LEFT JOIN banks ON reserves.id = banks.id 
                WHERE banks.id IS NULL
                """);
        if(rs.next())
            return rs.getBigDecimal(1) == null ? new BigDecimal("0.0") : rs.getBigDecimal(1);
        return new BigDecimal("0.0");
    } catch (SQLException sqlException) {
        System.out.println("Could not get total wallet balance from database!");
        sqlException.printStackTrace();
        return null;
    }
}

@Override
public boolean updateReserve(Reserve reserve) {
    try(PreparedStatement pstmt = MyConnection.getMySQLconnection().prepareStatement("""
            UPDATE reserves SET name = ?, balance = ? WHERE id = ?""")
    ) {
        pstmt.setString(1, reserve.getName());
        pstmt.setBigDecimal(2, reserve.getBalance());
        pstmt.setShort(3, reserve.getId());
        pstmt.executeUpdate();
        return true;
    } catch(SQLException sqlException) {
        System.out.println("Failed to update reserves with new data!");
        sqlException.printStackTrace();
        return false;
    }
}
}

...

public class BanksDAO extends ReservesDAO implements BanksData {
public BanksDAO() {
    try(
            Statement stmt = MyConnection.getMySQLconnection().createStatement()
    ) {
        stmt.executeUpdate("""
                        CREATE TABLE IF NOT EXISTS banks (
                            id SMALLINT UNSIGNED UNIQUE NOT NULL, 
                            apy DECIMAL(4, 2) NOT NULL, // I have no way to store a logic here, so currently it only stores fixed value.
                            accrued_interest DECIMAL(10, 4) NOT NULL, 
                            last_transaction_date DATE, 
                            FOREIGN KEY(id) REFERENCES reserves(id)
                        )"""
        );
    } catch (SQLException sqlException) {
        System.out.println("Failed to create banks table on the database!");
        sqlException.printStackTrace();
    }
}

@Override
public short addBank(Bank bank) {
    try (
            PreparedStatement pstmt = MyConnection.getMySQLconnection().prepareStatement("""
                    INSERT INTO banks(id, apy, accrued_interest, last_transaction_date) VALUES (?, ?, ?, ?)"""
            )
    ) {
        short id = addReserve(bank);
        pstmt.setShort(1, id);
        pstmt.setBigDecimal(2, bank.getAPY());
        pstmt.setBigDecimal(3, bank.getAccruedInterest());
        pstmt.setDate(4, bank.getLastTransactionDate());

        pstmt.executeUpdate();
        return id;
    } catch (SQLException sqlException) {
        System.out.println("Failed to insert " + bank.getName() + " info in the database!");
        sqlException.printStackTrace();
        return -1;
    }
}

@Override
public Bank getBank(short reserve_id) {
    try(
            PreparedStatement pstmt = MyConnection.getMySQLconnection().prepareStatement("""
                    SELECT * FROM reserves NATURAL JOIN banks WHERE id = ?""")
    ) {
        pstmt.setShort(1, reserve_id);
        ResultSet rs = pstmt.executeQuery();
        if(!rs.next()) return null;
        Bank requestedBank = new Bank(rs.getShort(1), rs.getString(2),
                rs.getBigDecimal(3), rs.getBigDecimal(4));
        requestedBank.setAccruedInterest(rs.getBigDecimal(5));
        requestedBank.setLastTransactionDate(rs.getDate(6));
        return requestedBank;
    } catch (SQLException sqlException) {
        System.out.println("Failed to fetch bank data from the database!");
        sqlException.printStackTrace();
        return null;
    }
}

@Override
public List<Bank> getAllBanks() {
    List<Bank> allBanks = new ArrayList<>();
    try(
            Statement stmt = MyConnection.getMySQLconnection().createStatement()
    ) {
        ResultSet rs = stmt.executeQuery("SELECT * FROM reserves NATURAL JOIN banks");
        while(rs.next()) {
            Bank bank = new Bank(rs.getShort(1), rs.getString(2),
                    rs.getBigDecimal(3), rs.getBigDecimal(4));
            bank.setAccruedInterest(rs.getBigDecimal(5));
            bank.setLastTransactionDate(rs.getDate(6));
            allBanks.add(bank);
        }

        return allBanks;
    } catch (SQLException sqlException) {
        System.out.println("Failed to fetch bank data from the database!");
        sqlException.printStackTrace();
        return null;
    }
}

@Override
public BigDecimal getTotalBankBalance() {
    try(Statement stmt = MyConnection.getMySQLconnection().createStatement()) {
        ResultSet rs = stmt.executeQuery("""
                SELECT SUM(balance) FROM reserves NATURAL JOIN banks""");
        if(rs.next())
            return rs.getBigDecimal(1) == null ? new BigDecimal("0.0") : rs.getBigDecimal(1);
        return new BigDecimal("0.0");
    } catch (SQLException sqlException) {
        System.out.println("Could not get total bank balance from database!");
        sqlException.printStackTrace();
        return null;
    }
}
}

现在我可以将银行初始化为:

Bank bank1 = new Bank("TestBank1", balance -> balance.compareTo(new BigDecimal("10000")) == -1 ? new BigDecimal("4") : new BigDecimal("5"));

虽然我可以创建另一个银行:

Bank bank2 = new Bank("TestBank2", balance -> balance.compareTo(new BigDecimal("8000")) == -1 ? new BigDecimal("3.5") : new BigDecimal("5.3"));

现在这两个库都是在内存中创建的,只要应用程序是 运行 就可以完美运行。但是当我需要长期使用它时,我不能直接将 Funtion 类型的变量存储到 mysql 数据库中。

许多人建议使用存储过程,如果对银行 table 中的每个银行只有 1 个类似 balance -> balance.compareTo(new BigDecimal("10000")) == -1 ? new BigDecimal("4") : new BigDecimal("5") 的逻辑,那将会奏效,但此信息每次都会更改。这意味着如果我的银行中有 50 个条目 table 我需要为我的银行中的每个条目创建 50 个具有 50 种逻辑的不同存储过程 table,以便随着余额的变化不断更新 APY 字段.我认为可能有更好的方法不?

Advice/opinion

数据库用于永久存储数字和文本。

应用程序是用来计算和决定事情的。

也就是说,您描述的复杂业务逻辑可能用Java(或其他应用程序语言)编码更合适。同时,利率的断点等应该存储在数据库中。

换句话说:“业务逻辑”属于应用程序,而不属于数据库。 (当然,它们之间有一条灰线。例如,SQL 非常擅长汇总 table 中的所有数据;因此,我会在 SQL 中这样做,不是 Java.)

十进制

银行有挑剔的规则,这些规则可能与 DECIMALDOUBLE 提供的规则不同——在 Java 或 MySQL 或_任何其他计算机语言中。注意可能需要的规则。特别是,DECIMAL(10, 4) 在您的应用程序中不太可能足够。另一方面,如果呈现给客户的是 apy DECIMAL(4, 2) 可能就足够了。但是,再次提醒,生成该数字时要注意舍入规则。您可能被迫手动输入该号码。

注意 DECIMALDOUBLE 之间舍入特性的差异。

回答

如果您选择在数据库中实现算法,请参阅 CREATE STORED PROCEDURECREATE FUNCTION

存储过程可以用来封装一组SQL语句。它可以接收和发送字符串和数字,但不能接收和发送数组。它能够读取 tables(因此是某种“数组”。)

可以在表达式出现的任何地方调用函数。它可以接收一些 numbers/strings 并产生一个数字或字符串。

数组

我不清楚需要什么,但我设想 table 几行或几十行,每一行都说“对于高达 $xxx 的值,使用 y.yy%利率"。

存储过程有“游标”和“循环”,但它们很笨拙;应用程序语言可能有更好的代码。

我认为你被误解了。您可能不是在问如何将逻辑移动到数据库中(存储过程就是答案),而是在问如何将您在代码中实现的逻辑存储到数据库中,以便稍后恢复该状态。所以这是我回答的前提。

你的这些计算相关的设计不是很好。您绝对应该考虑将这些 APY 计算方法表达为 类 实现某种 IAPYCalculationMethod 接口,并使用一种方法来实际计算它。匿名 lambda 就像你拥有的那样不适合这个目的。

那么假设您确实有一个带有 CalculateAPY 方法的 IAPYCalculatioMethod 接口,然后是一个如下所示的 BalanceBasedCalculationMethod

// code is not complete, just to give you an idea
class BalanceBasedCalculationMethod implements IAPYCalculationMethod {
  public BalanceBasedCalculationMethod(BigDecimal balanceThreshold, BigDecimal whenLower, BigDecimal whenGreater) { ... }
  
  public BigDecimal CalculateAPY(Bank bank) {
     if (bank.getBalance() < this.balanceThreshold)
        return this.whenLower;
     else
        return this.whenGreater;
  }
}

然后当您创建一个新银行时:

bank1 = new Bank("TestBank1", new BalanceBasedCalculationMethod(10000, 4, 5));
bank2 = new Bank("TestBank2", new BalanceBasedCalculationMethod(8000, 3.5, 5.3));

这已经好多了。这还允许您以某种方式序列化所有这些。你可以有一个 table 包含所有计算方法,银行和所用方法之间的关系,以及一个 JSON 参数(因为不同的方法可能有不同的参数;你可能还想将它们存储在一个单独的 table 而不是 JSON).

CREATE TABLE IF NOT EXISTS apy_calculation_method
(
   id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY
   name VARCHAR(100) NOT NULL
);


CREATE TABLE IF NOT EXISTS bank_calculation_method
(
  id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, 
  bank_id SMALLINT UNSIGNED NOT NULL REFERENCES reserves(id),
  method_id SMALLINT UNSIGNED NOT NULL REFERENCES apy_calculation_method(id),
  arguments JSON
);

为了表示我们基于余额的方法和使用它的银行,我们将有:

INSERT INTO apy_calculation_method (name) VALUES ('Balance Based');

那么每个银行的方法:

INSERT INTO bank_calculation_method (bank_id, method_id, arguments)
VALUES (1, 1, '{"balance": 10000, "whenLower": 4, "whenGreater": 5}')
     , (2, 1, '{"balance":  8000, "whenLower": 3.5, "whenGreater": 5.3}');

还有银行:

INSERT INTO banks (id, bank_method_id, accrued_interest) 
VALUES (1, 1, 0) -- first bank using the first method (balance based with 10000 balance)
     , (2, 2, 0) -- second bank using the second method (balance based with 8000 balance)
 

此方法可以更严格或更严格(规范化/非规范化),例如,您可以将计算方法作为 JSON 存储在银行 table 中(具有 {"method": "balance", "balance": 10000, .... } 结构排序)。您的计算方法工厂的设计以及它如何 serialized/deserializes 数据 to/from 数据库取决于它,但我相信您可以弄清楚。

这一切为您提供的是将计算方法序列化到数据库中的选项,而不是某种无法以任何方式引用的随机 lambda 函数。因此,无需将实际逻辑写入数据库,只需编写“使用的逻辑类型”和参数即可。

作为一个额外的好处,这创建了一个 testable 设计,您可以为 BalanceBasedCalculationMethod 编写一个测试以确保它实际执行它应该执行的操作。您无法自动测试这些 lambda 表达式,因为无法判断它们可以做什么或不能做什么,对吗?