SQL如何对日期范围进行连接?

35
考虑两个表: 交易,使用外币计算的金额:
     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. 如果可能,使用最近的汇率;因此,2/4/2009的交易使用2/1/2009的汇率,3/15/2009的交易使用3/1/2009的汇率。
B. 如果没有先前日期定义的汇率,则使用最早可用的汇率。因此,1/2/2009的交易使用2/1/2009的汇率,因为没有更早的汇率定义。
这有效...
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,而不让自己陷入困境?


你手头的是真正的表格,还是简化版的呢?能否提供更多信息?你有其他标识符、主键等吗? - van
当然,实际情况要复杂得多(涉及多种货币等),但我想把它简化到基本要点。 - Herb Caudill
1
仅供参考...请注意,我的解决方案基本上也适用于不同的货币,但您必须更改IndexedExchangeRate中的行编号,使每个货币为一行(使用PARTITION),并将其添加到RangedExchangeRate中的LEFT JOIN条件中。 - Lucero
1
@Herb:当然可以。如果您能给我更多关于您的表的信息,我可以为您提供一种名为“抢占式”的解决方案,在SQL-Server上很快地运行。理想情况下,ExchangeRates表必须有一个快速(INT)标识符。 - van
SQL Server 的版本是什么? - ErikE
6个回答

38
你可以先对按日期排序的汇率进行自连接,这样你就可以得到每个汇率的开始和结束日期,而且日期之间没有重叠或空缺(也许可以将其作为数据库视图添加 - 在我的情况下,我只是使用了一个通用表达式)。
现在将这些“预处理”汇率与交易连接起来非常简单和高效。
类似于:
```sql SELECT * FROM prepared_rates pr JOIN transactions t ON t.transaction_date BETWEEN pr.start_date AND pr.end_date ```
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列的类型,则应该是可能的最小值)。


2
在最后一行中不使用BETWEEN有什么理由吗? - Herb Caudill
2
是的,因为BETWEEN会返回错误的结果,它相当于(T.Date >= RER.DateFrom) AND (T.Date <= RER.DateTo),这不是你想要的,因为交易日期等于汇率日期将导致加入2行(其中一行将使用错误的汇率)。 - Lucero
最后一行是否应该是:ON (T.Date >= RER.DateFrom) AND (T.Date < RER.DateTo)(将条件与“或等于”部分颠倒)? - Jonathan Leffler
这取决于情况,起初我也是这么做的,但后来为了匹配你提供的样本而进行了更改,因为输出结果不同。如果一笔交易和一个汇率具有相同的日期,应该使用这个汇率还是前一个汇率?在A中,你指出“最近的上一个日期”,听起来当前的条件是正确的。 - Lucero

7
假设您有一个包含以下内容的扩展汇率表:
 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”运算符,它包括范围的两端。通常,更好的范围格式是“开放-关闭”,即列出的第一个日期包括在内,第二个日期则排除在外。请注意,数据行有一个限制——日期范围覆盖不得有间隙和重叠。强制执行这些约束条件并不完全简单(委婉的说法是“减数法”)。
现在基本查询是微不足道的,情况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;

创建ExtendedExchangeRate表格是比较棘手的,需要从给定的ExchangeRate表格中即时生成。如果可以的话,将基本的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的结果是三个查询的(不相交的)并集:
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;

在测试数据库管理系统(IBM Informix Dynamic Server 11.50.FC6 on MacOS X 10.6.2)上,我成功将查询转换为视图,但是我不得不停止对数据类型的欺骗 - 通过强制将字符串转换为日期:

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
@Lucero:如我所说,我在SQL中没有使用任何OLAP操作(如OVER)。虽然我的示例使用包含范围,但我怀疑最好使用开放-关闭范围来编写它。我坚持认为“将ExtendedExchangeRate(XXR)表制成实际表格”(然后现有的ExchangeRate表格就是XXR表格的简单投影视图)更好;在XXR上的几个修改操作和许多选择操作之间可能会出现不平衡,这对我来说是决定性的。插入新值的实际操作可以由触发器处理。 - Jonathan Leffler
1
@Lucero:我们都认同,如果在扩展的汇率表中有明确的日期范围信息可用,查询会更简单。如果DBMS支持OVER等符号,那么按照你的方式做肯定是可行的。 - Jonathan Leffler
谢谢 - 这非常有帮助。我正在使用SQL Server。 - Herb Caudill

1

我无法测试这个,但我认为它会起作用。它使用两个子查询的 coalesce 函数来选择按规则 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

0
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)始终具有优先权。然后使用 rank over 子句选择每个 tran 日期的最小日期差异。


现在,A似乎已经实现了。然而,我同意它并不是非常优雅,并且请记住,使用这种计算进行交叉连接将导致性能变慢,因为随着交易和汇率数量的增加,要计算的行数等于它们的数量相乘,例如,每天一个汇率的10年和每天3次交易将给您大约4000万行需要计算。 - Lucero

0

有很多解决方案可行。你应该找到最适合(最快)你的工作负载的解决方案:你通常是搜索一个事务、一系列事务还是所有事务?

根据你的模式,决定胜负的解决方案是:

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,而是在类似于INTEGER"ID"字段上进行。给我更多的模式信息,我会为你创建一个示例。


@Van - 我的每个表上都有一个数字主键字段,例如TransactionID和ExchangeRateID。 - Herb Caudill
以上解决方案仅适用于A)规则。最初没有注意到B),现在添加也没有意义,因为您已经有了适合您的答案。 - van

0

关于连接(join)的问题,没有比你原始帖子中的TOP 1相关子查询更优雅的解决方案。但是,正如你所说,它不能满足B要求。

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

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;

我不知道哪个可能表现更好,或者是否比页面上的其他答案表现更好。如果在日期列上有适当的索引,它们应该表现得很好 - 肯定比任何Row_Number()解决方案都要好。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接