运行时参数注入的PostgreSQL视图

发表时间: 2023-12-16 19:27

有许多情况下,应用程序需要灵活多变,能够在运行时生成动态报告。

本文旨在通过利用 PostgreSQL 数据库支持的临时配置参数,提供一种实现这一目标的方法。

根据 PostgreSQL 文档,在 7.3 版本开始,可以使用 set_config(name, value, is_local) 函数设置配置参数。稍后,可以使用 current_setting(name) 函数读取先前设置的参数值,必要时进行转换并使用。如果前一个函数的第三个参数为 true,则更改的设置仅适用于当前事务。

这正是这里所需要的——提供一个可以作为原子操作的一部分使用的运行时参数值的方法。

设置

示例应用程序构建如下:

  • Java 21
  • Spring Boot 版本 3.1.15
  • PostgreSQL 驱动程序版本 42.6.0。
  • Liquibase 4.20.0
  • Maven 3.6.3

在应用程序级别,Maven 项目配置为使用 Spring Data JPA 和 Liquibase 依赖项。

领域由产品表示,其价格以各种货币表示。为了在不同货币之间进行转换,存在货币汇率。目标是能够以某种货币的汇率读取所有产品及其价格,以及某一天的汇率。

概念验证

为了开始建模,连接到数据库后首先应创建一个新模式。

create schema pgsetting;

有三个实体:Product、Currency 和 CurrencyExchange。

@Entity@Table(name = "product")public class Product {    @Id    @Column(name = "id")    private Long id;    @Column(name = "name", nullable = false)    private String name;    @Column(name = "price", nullable = false)    private Double price;    @ManyToOne    @JoinColumn(name = "currency_id")    private Currency currency;    ...}@Entity@Table(name = "currency")public class Currency {    @Id    @Column(name = "id", nullable = false)    private Long id;    @Column(name = "name", nullable = false)    private String name;    ...}@Entity@Table(name = "currency_exchange")public class CurrencyExchange {    @Id    @Column(name = "id", nullable = false)    private Long id;    @Column(name = "date", nullable = false)    private LocalDate date;    @ManyToOne    @JoinColumn(name = "from_currency_id", nullable = false)    private Currency from;    @ManyToOne    @JoinColumn(name = "to_currency_id", nullable = false)    private Currency to;    @Column(name = "value", nullable = false)    private Double value;    ...}

每个实体都有相应的 CrudRepository。

@Repositorypublic interface ProductRepository extends CrudRepository<Product, Long> { }@Repositorypublic interface CurrencyRepository extends CrudRepository<Currency, Long> { }@Repositorypublic interface CurrencyExchangeRepository extends CrudRepository<CurrencyExchange, Long> { }

数据源通常在 [application.properties](
https://github.com/horatiucd/pg-setting/blob/master/src/main/resources/application.properties) 文件中配置,其中包括记录了用于初始化模式的三个表和它们之间关系的 Liquibase 变更日志文件的路径。

有关详细信息,可以查看应用程序属性和
db/changelog/schema-init.xml 文件。

根变更日志文件为:

<?xml version="1.1" encoding="UTF-8" standalone="no"?><databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"                   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"                   xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog                      https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">    <include file="/db/changelog/schema-init.xml"/></databaseChangeLog>

应用程序启动时,变更集按声明顺序执行。到目前为止,一切都很简单,没有什么特别之处——一个简单的 Spring Boot 应用程序,其数据库变更由 Liquibase 管理。

创建动态报告

假设当前应用程序定义了两种货币——RON 和 EUR,以及两种以不同货币记录价格的产品。

货币

+--+----+|id|name|+--+----+|1 |RON ||2 |EUR |+--+----+

产品

+--+-------------------+-----+-----------+|id|name               |price|currency_id|+--+-------------------+-----+-----------+|1 |Swatch Moonlight v1|100  |2          ||2 |Winter Sky         |1000 |1          |+--+-------------------+-----+-----------+

2023 年 11 月 15 日的货币汇率

+--+----------+----------------+--------------+-----+|id|date      |from_currency_id|to_currency_id|value|+--+----------+----------------+--------------+-----+|1 |2023-11-15|2               |1             |5    ||2 |2023-11-15|2               |2             |1    ||3 |2023-11-15|1               |2             |0.2  ||4 |2023-11-15|1               |1             |1    |+--+----------+----------------+--------------+-----+

目标结果是一个产品报告,其中所有价格以 EUR 表示,使用 2023 年 11 月 15 日的汇率。这意味着需要转换第二个产品的价格。

为了简化设计,先前设定的目标被分解为更小的部分,然后逐一实现。从概念上讲,应首先获取产品,然后转换它们的价格(如果需要)。

  1. 获取产品。
  2. 使用请求的货币汇率转换价格。

前者很简单。Spring Data Repository 方法很容易允许获取产品——List<Product> findAll()。

后者可以通过查询实现转换。

SELECT p.id,       p.name,       p.price * e.value price,              e.to_currency_id currency_id,       e.dateFROM product pLEFT JOIN currency_exchange e on p.currency_id = e.from_currency_id and        e.to_currency_id = 2 and        e.date = '2023-11-15'

为了将两者合并,完成了以下操作:

  • 定义一个视图,用于上述查询——product_view

它在 product-view.sql 文件中定义,并作为可重复的 Liquibase 变更集的幂等操作添加,每当更改时运行。

<?xml version="1.1" encoding="UTF-8" standalone="no"?><databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"                   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"                   xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog                      https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">    <include file="/db/changelog/schema-init.xml"/>    <changeSet id="repeatable" author="horatiucd" runOnChange="true">        <sqlFile dbms="postgresql" path="db/changelog/product-view.sql"/>    </changeSet></databaseChangeLog>
  • 定义一个新实体 — ProductView — 作为领域的一部分,并与相应的存储库一起使用。
@Entity@Immutablepublic class ProductView {    @Id    private Long id;    private String name;    private Double price;    private LocalDate date;    @ManyToOne    @JoinColumn(name = "currency_id")    private Currency currency;    ...}
@Repositorypublic interface ProductViewRepository extends org.springframework.data.repository.Repository<ProductView, Long> {    List<ProductView> findAll();}

现在应用程序能够构建所需的报告,但只能为硬编码的货币和汇率。

为了在运行时传递这两个值,同一事务中执行了以下操作:

  • 将两个参数值设置为配置参数 — SELECT set_config(:name, :value, true)
  • 使用存储库方法获取 ProductView 实体

此外,修改了 product_view,以读取作为当前事务的一部分设置的配置参数,并相应地选择数据。

SELECT p.id,       p.name,       p.price * e.value price,       e.date,       e.to_currency_id currency_idFROM product pLEFT JOIN currency_exchange e on p.currency_id = e.from_currency_id and        e.to_currency_id = current_setting('pgsetting.CurrencyId')::int and        e.date = current_setting('pgsetting.CurrencyDate')::date;

current_setting('pgsetting.CurrencyId') 和 current_setting('pgsetting.CurrencyDate') 调用读取先前设置的参数,然后进行转换并使用。

实现需要一些额外的调整。

ProductViewRepository 增加了一个允许设置配置参数的方法。

@Repositorypublic interface ProductViewRepository extends org.springframework.data.repository.Repository<ProductView, Long> {    List<ProductView> findAll();    @Query(value = "SELECT set_config(:name, :value, true)")    void setConfigParam(String name, String value);}

最后一个参数始终设置为 true,因此该值仅在当前事务期间保留。

此外,定义了一个 ProductService,用于清楚地标记事务中涉及的所有操作。

@Servicepublic class ProductService {    private final ProductViewRepository productViewRepository;    public ProductService(ProductViewRepository productViewRepository) {        this.productViewRepository = productViewRepository;    }    @Transactional    public List<ProductView> getProducts(Currency currency, LocalDate date) {        productViewRepository.setConfigParam("pgsetting.CurrencyId",                String.valueOf(currency.getId()));        productViewRepository.setConfigParam("pgsetting.CurrencyDate",                DateTimeFormatter.ofPattern("yyyy-MM-dd").format(date));        return productViewRepository.findAll();    }}

参数的名称与 product_view 定义中使用的名称相同。

为了验证实现,设置了两个测试。

@SpringBootTestclass Product1Test {    @Autowired    private CurrencyRepository currencyRepository;    @Autowired    private ProductRepository productRepository;    @Autowired    private CurrencyExchangeRepository rateRepository;    @Autowired    private ProductService productService;    private Currency ron, eur;    private Product watch, painting;    private CurrencyExchange eurToRon, ronToEur;    private LocalDate date;    @BeforeEach    public void setup() {        ron = new Currency(1L, "RON");        eur = new Currency(2L, "EUR");        currencyRepository.saveAll(List.of(ron, eur));        watch = new Product(1L, "Swatch Moonlight v1", 100.0d, eur);        painting = new Product(2L, "Winter Sky", 1000.0d, ron);        productRepository.saveAll(List.of(watch, painting));        date = LocalDate.now();        eurToRon = new CurrencyExchange(1L, date, eur, ron, 5.0d);        CurrencyExchange eurToEur = new CurrencyExchange(2L, date, eur, eur, 1.0d);        ronToEur = new CurrencyExchange(3L, date, ron, eur, .2d);        CurrencyExchange ronToRon = new CurrencyExchange(4L, date, ron, ron, 1.0d);        rateRepository.saveAll(List.of(eurToRon, eurToEur, ronToEur, ronToRon));    }}

前者获取以 EUR 记录价格,使用记录的汇率。

@Testvoid prices_in_eur() {    List<ProductView> products = productService.getProducts(eur, date);    Assertions.assertEquals(2, products.size());    Assertions.assertTrue(products.stream()            .allMatch(product -> product.getCurrency().getId().equals(eur.getId())));    Assertions.assertTrue(products.stream()            .allMatch(product -> product.getDate().equals(date)));    Assertions.assertEquals(watch.getPrice(),            products.get(0).getPrice());    Assertions.assertEquals(painting.getPrice() * ronToEur.getValue(),            products.get(1).getPrice());}

调用时,product_view 为:

+--+-------------------+-----+-----------+----------+|id|name               |price|currency_id|date      |+--+-------------------+-----+-----------+----------+|1 |Swatch Moonlight v1|100  |2          |2023-11-15||2 |Winter Sky         |200  |2          |2023-11-15|+--+-------------------+-----+-----------+----------+

后者使用相同的汇率,获取以 RON 价格的产品。

@Testvoid prices_in_ron() {    List<ProductView> products = productService.getProducts(ron, date);    Assertions.assertEquals(2, products.size());    Assertions.assertTrue(products.stream()            .allMatch(product -> product.getCurrency().getId().equals(ron.getId())));    Assertions.assertTrue(products.stream()            .allMatch(product -> product.getDate().equals(date)));    Assertions.assertEquals(watch.getPrice() * eurToRon.getValue(),            products.get(0).getPrice());    Assertions.assertEquals(painting.getPrice(),            products.get(1).getPrice());}

调用时,product_view 为:

+--+-------------------+-----+-----------+----------+|id|name               |price|currency_id|date      |+--+-------------------+-----+-----------+----------+|1 |Swatch Moonlight v1|500  |1          |2023-11-15||2 |Winter Sky         |1000 |1          |2023-11-15|+--+-------------------+-----+-----------+----------+

示例代码

可在 此处 找到。