【问题标题】:SQL join against date ranges?SQL 加入日期范围?
【发布时间】:2011-01-19 08:56:04
【问题描述】:

考虑两个表:

交易,金额为外币:

     Date  Amount
========= =======
 1/2/2009    1500
 2/4/2009    2300
3/15/2009     300
4/17/2009    2200
etc.

ExchangeRates,以外币表示的主要货币(比如美元)的价值:

     Date    Rate
========= =======
 2/1/2009    40.1
 3/1/2009    41.0
 4/1/2009    38.5
 5/1/2009    42.7
etc.

可以输入任意日期的汇率 - 用户可以每天、每周、每月或不定期输入汇率。

为了将外国金额转换为美元,我需要遵守以下规则:

A.如果可能,使用最近的先前汇率;因此,2009 年 2 月 4 日的交易使用 2009 年 2 月 1 日的汇率,2009 年 3 月 15 日的交易使用 2009 年 3 月 1 日的汇率。

B.如果没有为前一个日期定义费率,请使用可用的最早费率。因此,2009 年 1 月 2 日的交易使用 2009 年 2 月 1 日的汇率,因为没有定义更早的汇率。

这行得通...

Select 
    t.Date, 
    t.Amount,
    ConvertedAmount=(   
        Select Top 1 
            t.Amount/ex.Rate
        From ExchangeRates ex
        Where t.Date > ex.Date
        Order by ex.Date desc
    )
From Transactions t

...但是 (1) 看起来连接会更高效和优雅,并且 (2) 它不处理上面的规则 B。

除了使用子查询来找到合适的费率之外,还有其他方法吗?有没有一种优雅的方式来处理规则 B,而不会让自己陷入困境?

【问题讨论】:

  • 这些真的是您拥有的表格,还是您简化了?你能提供更多信息吗?你有其他标识符、PK 等吗?
  • 当然,现实中还有很多事情要做(多种货币等),但我想把它归结为基本要素。
  • 仅供参考...请注意,我的解决方案基本上也适用于不同的货币,但您必须将IndexedExchangeRate 中的行编号更改为每种货币一个(使用PARTITION)并将其添加到RangedExchangeRate 中的LEFT JOIN 条件中。
  • @Herb:当然。如果您向我提供有关您的表格的更多信息,我可以为您提供一个称为 tie-breaker 的解决方案,它在 SQL-Server 上的许多情况下运行得非常快。理想情况下,ExchangeRates 表上必须有一个快速 (INT) 标识符。
  • 什么版本的 SQL Server?

标签: sql sql-server tsql join date-range


【解决方案1】:

您可以首先对按日期排序的汇率进行自我加入,这样您就可以知道每个汇率的开始日期和结束日期,而日期中没有任何重叠或间隙(也许可以将其添加为视图您的数据库 - 在我的情况下,我只是使用一个公用表表达式)。

现在将这些“准备好的”费率与交易结合起来既简单又高效。

类似:

WITH IndexedExchangeRates AS (           
            SELECT  Row_Number() OVER (ORDER BY Date) ix,
                    Date,
                    Rate 
            FROM    ExchangeRates 
        ),
        RangedExchangeRates AS (             
            SELECT  CASE WHEN IER.ix=1 THEN CAST('1753-01-01' AS datetime) 
                    ELSE IER.Date 
                    END DateFrom,
                    COALESCE(IER2.Date, GETDATE()) DateTo,
                    IER.Rate 
            FROM    IndexedExchangeRates IER 
            LEFT JOIN IndexedExchangeRates IER2 
            ON IER.ix = IER2.ix-1 
        )
SELECT  T.Date,
        T.Amount,
        RER.Rate,
        T.Amount/RER.Rate ConvertedAmount 
FROM    Transactions T 
LEFT JOIN RangedExchangeRates RER 
ON (T.Date > RER.DateFrom) AND (T.Date <= RER.DateTo)

注意事项:

  • 您可以将GETDATE() 替换为遥远未来的日期,我在这里假设未来的费率未知。

  • 规则 (B) 是通过将第一个已知汇率的日期设置为 SQL Server datetime 支持的最小日期来实现的,这应该(根据定义,如果它是您使用的类型) Date 列)是可能的最小值。

【讨论】:

  • 是否有理由不在最后一行使用 BETWEEN?
  • 是的,因为 BETWEEN 会返回错误结果,它相当于 (T.Date &gt;= RER.DateFrom) AND (T.Date &lt;= RER.DateTo) 这不是您想要的,因为交易日期等于汇率日期将导致 2 行被连接(其中之一这将使用错误的汇率)。
  • 最后一行应该是:ON (T.Date &gt;= RER.DateFrom) AND (T.Date &lt; RER.DateTo)(用“或等于”部分反转条件)?
  • 视情况而定,我一开始是这样,但后来更改它以匹配您的样本,因为输出不同。如果一笔交易和一个汇率有相同的日期,是使用这个汇率还是以前的汇率?在 A 中,您声明“最近的 previous 日期”,听起来当前的情况是正确的。
【解决方案2】:

我无法对此进行测试,但我认为它会起作用。它使用两个子查询的合并来根据规则 A 或规则 B 选择费率。

Select t.Date, t.Amount, 
  ConvertedAmount = t.Amount/coalesce(    
    (Select Top 1 ex.Rate 
        From ExchangeRates ex 
        Where t.Date > ex.Date 
        Order by ex.Date desc )
     ,
     (select top 1 ex.Rate 
        From ExchangeRates  
        Order by ex.Date asc)
    ) 
From Transactions t

【讨论】:

    【解决方案3】:
    SELECT 
        a.tranDate, 
        a.Amount,
        a.Amount/a.Rate as convertedRate
    FROM
        (
    
        SELECT 
            t.date tranDate,
            e.date as rateDate,
            t.Amount,
            e.rate,
            RANK() OVER (Partition BY t.date ORDER BY
                             CASE WHEN DATEDIFF(day,e.date,t.date) < 0 THEN
                                       DATEDIFF(day,e.date,t.date) * -100000
                                  ELSE DATEDIFF(day,e.date,t.date)
                             END ) AS diff
        FROM 
            ExchangeRates e
        CROSS JOIN 
            Transactions t
             ) a
    WHERE a.diff = 1
    

    计算 tran 和 rate 日期之间的差异,然后将负值(条件 b)乘以 -10000,以便它们仍然可以排名,但正值(条件 a 始终优先。然后我们选择最小日期差异为每个 tran date 使用 rank over 子句。

    【讨论】:

    • 对,现在A好像实现了。但是 - 我同意它不是很优雅,并且请记住,使用这种计算的交叉连接将导致性能下降,因为交易数量和汇率不断增加,因为要计算的行数等于它们的数量相乘,例如10 年,每天一种汇率和每天 3 次交易,您需要计算大约 4000 万行。
    【解决方案4】:

    假设您有一个扩展的汇率表,其中包含:

     Start Date   End Date    Rate
     ========== ========== =======
     0001-01-01 2009-01-31    40.1
     2009-02-01 2009-02-28    40.1
     2009-03-01 2009-03-31    41.0
     2009-04-01 2009-04-30    38.5
     2009-05-01 9999-12-31    42.7
    

    我们可以讨论前两行是否应该合并的细节,但一般的想法是找到给定日期的汇率是微不足道的。此结构与包含范围末端的 SQL 'BETWEEN' 运算符一起使用。通常,更好的范围格式是“开闭”;列出的第一个日期包括在内,第二个日期不包括在内。请注意,数据行有一个限制 - (a) 日期范围的覆盖范围没有间隙,(b) 覆盖范围没有重叠。执行这些约束并非完全无关紧要(礼貌的轻描淡写 - 减数分裂)。

    现在基本查询是微不足道的,案例 B 不再是特例:

    SELECT T.Date, T.Amount, X.Rate
      FROM Transactions AS T JOIN ExtendedExchangeRates AS X
           ON T.Date BETWEEN X.StartDate AND X.EndDate;
    

    棘手的部分是从给定的 ExchangeRate 表动态创建 ExtendedExchangeRate 表。 如果这是一个选项,那么修改基本 ExchangeRate 表的结构以匹配 ExtendedExchangeRate 表将是一个好主意;您可以在输入数据时(每月一次)而不是每次需要确定汇率时(一天多次)来解决混乱的问题。

    如何创建扩展汇率表?如果您的系统支持从日期值中加或减 1 以获得下一天或前一天(并且有一个名为“Dual”的单行表),那么一个变体 这将起作用(不使用任何 OLAP 函数):

    CREATE TABLE ExchangeRate
    (
        Date    DATE NOT NULL,
        Rate    DECIMAL(10,5) NOT NULL
    );
    INSERT INTO ExchangeRate VALUES('2009-02-01', 40.1);
    INSERT INTO ExchangeRate VALUES('2009-03-01', 41.0);
    INSERT INTO ExchangeRate VALUES('2009-04-01', 38.5);
    INSERT INTO ExchangeRate VALUES('2009-05-01', 42.7);
    

    第一行:

    SELECT '0001-01-01' AS StartDate,
           (SELECT MIN(Date) - 1 FROM ExchangeRate) AS EndDate,
           (SELECT Rate FROM ExchangeRate
             WHERE Date = (SELECT MIN(Date) FROM ExchangeRate)) AS Rate
    FROM Dual;
    

    结果:

    0001-01-01  2009-01-31      40.10000
    

    最后一行:

    SELECT (SELECT MAX(Date) FROM ExchangeRate) AS StartDate,
           '9999-12-31' AS EndDate,
           (SELECT Rate FROM ExchangeRate
             WHERE Date = (SELECT MAX(Date) FROM ExchangeRate)) AS Rate
    FROM Dual;
    

    结果:

    2009-05-01  9999-12-31      42.70000
    

    中间行:

    SELECT X1.Date     AS StartDate,
           X2.Date - 1 AS EndDate,
           X1.Rate     AS Rate
      FROM ExchangeRate AS X1 JOIN ExchangeRate AS X2
           ON X1.Date < X2.Date
     WHERE NOT EXISTS
           (SELECT *
              FROM ExchangeRate AS X3
             WHERE X3.Date > X1.Date AND X3.Date < X2.Date
            );
    

    结果:

    2009-02-01  2009-02-28      40.10000
    2009-03-01  2009-03-31      41.00000
    2009-04-01  2009-04-30      38.50000
    

    请注意,NOT EXISTS 子查询相当重要。没有它,“中间行”的结果是:

    2009-02-01  2009-02-28      40.10000
    2009-02-01  2009-03-31      40.10000    # Unwanted
    2009-02-01  2009-04-30      40.10000    # Unwanted
    2009-03-01  2009-03-31      41.00000
    2009-03-01  2009-04-30      41.00000    # Unwanted
    2009-04-01  2009-04-30      38.50000
    

    随着表格大小的增加,不需要的行数急剧增加(对于 N > 2 行,我相信有 (N-2) * (N - 3) / 2 个不需要的行)。

    ExtendedExchangeRate 的结果是三个查询的(不相交的)UNION:

    SELECT DATE '0001-01-01' AS StartDate,
           (SELECT MIN(Date) - 1 FROM ExchangeRate) AS EndDate,
           (SELECT Rate FROM ExchangeRate
             WHERE Date = (SELECT MIN(Date) FROM ExchangeRate)) AS Rate
    FROM Dual
    UNION
    SELECT X1.Date     AS StartDate,
           X2.Date - 1 AS EndDate,
           X1.Rate     AS Rate
      FROM ExchangeRate AS X1 JOIN ExchangeRate AS X2
           ON X1.Date < X2.Date
     WHERE NOT EXISTS
           (SELECT *
              FROM ExchangeRate AS X3
             WHERE X3.Date > X1.Date AND X3.Date < X2.Date
            )
    UNION
    SELECT (SELECT MAX(Date) FROM ExchangeRate) AS StartDate,
           DATE '9999-12-31' AS EndDate,
           (SELECT Rate FROM ExchangeRate
             WHERE Date = (SELECT MAX(Date) FROM ExchangeRate)) AS Rate
    FROM Dual;
    

    在测试 DBMS(MacOS X 10.6.2 上的 IBM Informix Dynamic Server 11.50.FC6)上,我能够将查询转换为视图,但我不得不停止使用数据类型作弊 - 通过将字符串强制转换为日期:

    CREATE VIEW ExtendedExchangeRate(StartDate, EndDate, Rate) AS
        SELECT DATE('0001-01-01')  AS StartDate,
               (SELECT MIN(Date) - 1 FROM ExchangeRate) AS EndDate,
               (SELECT Rate FROM ExchangeRate WHERE Date = (SELECT MIN(Date) FROM ExchangeRate)) AS Rate
        FROM Dual
        UNION
        SELECT X1.Date     AS StartDate,
               X2.Date - 1 AS EndDate,
               X1.Rate     AS Rate
          FROM ExchangeRate AS X1 JOIN ExchangeRate AS X2
               ON X1.Date < X2.Date
         WHERE NOT EXISTS
               (SELECT *
                  FROM ExchangeRate AS X3
                 WHERE X3.Date > X1.Date AND X3.Date < X2.Date
                )
        UNION 
        SELECT (SELECT MAX(Date) FROM ExchangeRate) AS StartDate,
               DATE('9999-12-31') AS EndDate,
               (SELECT Rate FROM ExchangeRate WHERE Date = (SELECT MAX(Date) FROM ExchangeRate)) AS Rate
        FROM Dual;
    

    【讨论】:

    • @Jonathan,与我的基本想法相同(我猜我发布答案时您正在研究它),但是我想出了一种不同(可能更有效)的方法来计算ExtendedExchangeRate(在我的代码中称为 RangedExchangeRate,并实现了开闭日期范围)实际上只使用一个自联接。在表中只保留一个日期是有意义的,因为 - 假设计算正确 - 您永远不会得到不一致的数据结构,这可能会破坏与事务的以下连接。
    • @Lucero:正如我所指出的,我没有在 SQL 中使用任何 OLAP 操作(例如 OVER)。尽管我的示例使用了包含范围,但我怀疑使用开闭范围编写会更好。我支持“最好将 ExtendedExchangeRate (XXR) 表变成真实表”(现有的 ExchangeRate 表是 XXR 表的简单投影视图); XXR 上的几个修改操作和它上的许多选择操作之间可能存在的不平衡对我来说是决定性的。插入新值的实际操作可以由触发器处理。
    • @Lucero:我们同意当您在扩展汇率表中获得明确的日期范围信息时,查询会更简单。如果 DBMS 支持诸如 OVER 之类的符号,那么当然可以按照自己的方式进行操作。
    • 谢谢 - 这很有帮助。我正在使用 SQL Server。
    【解决方案5】:

    许多解决方案都会奏效。你真的应该找到最适合你的工作量(最快)的一个:你通常搜索一个事务,它们的列表,所有的吗?

    给定架构的决胜局解决方案是:

    SELECT      t.Date,
                t.Amount,
                r.Rate
                --//add your multiplication/division here
    
    FROM        "Transactions" t
    
    INNER JOIN  "ExchangeRates" r
            ON  r."ExchangeRateID" = (
                            SELECT TOP 1 x."ExchangeRateID"
                            FROM        "ExchangeRates" x
                            WHERE       x."SourceCurrencyISO" = t."SourceCurrencyISO" --//these are currency-related filters for your tables
                                    AND x."TargetCurrencyISO" = t."TargetCurrencyISO" --//,which you should also JOIN on
                                    AND x."Date" <= t."Date"
                            ORDER BY    x."Date" DESC)
    

    您需要有正确的索引才能使此查询快速进行。理想情况下,您不应该在"Date" 上使用JOIN,而是在"ID"-like 字段 (INTEGER) 上。给我更多架构信息,我将为您创建一个示例。

    【讨论】:

    • @Van - 我在每个表上都有一个数字主键字段,例如TransactionID 和 ExchangeRateID。
    • 上述解决方案仅适用于 A) 规则。最初没有注意到 B)。现在添加没有意义,因为您有适合您的答案。
    【解决方案6】:

    没有什么比原始帖子中的 TOP 1 相关子查询更优雅的连接了。但是,正如您所说,它不满足要求 B。

    这些查询确实有效(需要 SQL Server 2005 或更高版本)。见the SqlFiddle for these

    SELECT
       T.*,
       ExchangeRate = E.Rate
    FROM
      dbo.Transactions T
      CROSS APPLY (
        SELECT TOP 1 Rate
        FROM dbo.ExchangeRate E
        WHERE E.RateDate <= T.TranDate
        ORDER BY
          CASE WHEN E.RateDate <= T.TranDate THEN 0 ELSE 1 END,
          E.RateDate DESC
      ) E;
    

    请注意,具有单个列值的 CROSS APPLY 在功能上等同于您所展示的 SELECT 子句中的相关子查询。我现在更喜欢 CROSS APPLY,因为它更加灵活,可以让您在多个位置重用该值,其中包含多行(用于自定义取消透视)并允许您拥有多个列。

    SELECT
       T.*,
       ExchangeRate = Coalesce(E.Rate, E2.Rate)
    FROM
      dbo.Transactions T
      OUTER APPLY (
        SELECT TOP 1 Rate
        FROM dbo.ExchangeRate E
        WHERE E.RateDate <= T.TranDate
        ORDER BY E.RateDate DESC
      ) E
      OUTER APPLY (
        SELECT TOP 1 Rate
        FROM dbo.ExchangeRate E2
        WHERE E.Rate IS NULL
        ORDER BY E2.RateDate
      ) E2;
    

    我不知道哪一个可能会表现得更好,或者是否会比页面上的其他答案更好。如果在 Date 列上有适当的索引,它们应该会非常好——绝对比任何 Row_Number() 解决方案都要好。

    【讨论】:

      猜你喜欢
      • 2017-04-27
      • 2020-05-23
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2019-01-24
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多