SQL соединяется с диапазонами дат?

Рассмотрим две таблицы:

Сделки, с суммой в иностранной валюте:

     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.

Курсы обмена могут быть введены для произвольных дат - пользователь может вводить их ежедневно, еженедельно, ежемесячно или нерегулярно.

Чтобы перевести иностранные суммы в доллары, мне необходимо соблюдать эти правила:

а. Если возможно, используйте последний предыдущий курс; поэтому транзакция 2/4/2009 использует ставку за 2/1/2009, а транзакция 3/15/2009 использует ставку за 3/1/2009.

В. Если для предыдущей даты нет ставки, используйте самую раннюю доступную ставку. Таким образом, транзакция на 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, не привязывая себя к узлам?

Ответ 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) - наименьшее возможное значение.

Ответ 2

Предположим, что у вас была расширенная таблица обменных курсов, содержащая:

 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", который включает в себя концы диапазонов. Часто лучший формат для диапазонов - "открытый-закрытый"; первая включенная дата включена, а вторая исключена. Обратите внимание, что есть ограничение на строки данных - есть (а) отсутствие пробелов в охвате диапазона дат и (б) отсутствие перекрытий в охвате. Выполнение этих ограничений не является полностью тривиальным (вежливое преуменьшение - мейоз).

Теперь базовый запрос тривиален, а Case 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 из значения даты, чтобы получить следующий или предыдущий день (и имеет таблицу с одной строкой, называемую "Двойной" ), тогда вариант на этом будет работать (без использования каких-либо функций 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;

В тестовой СУБД (IBM Informix Dynamic Server 11.50.FC6 на 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;

Ответ 3

Я не могу проверить это, но я думаю, что это сработает. Он использует 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

Ответ 4

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

Вычисляется разница между датой перехода и курсом, затем отрицательные значения (условие b) умножаются на -10000, так что они все равно могут быть ранжированы, но положительные значения (условие всегда имеет приоритет. Затем мы выбираем минимальную разницу дат для каждый тран с использованием позиции ранга.

Ответ 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)

Вам нужно, чтобы нужные индексы для этого запроса были быстрыми. В идеале вы не должны иметь JOIN на "Date", но на "ID" -поле (INTEGER). Дайте мне больше информации о схеме, я создам вам пример.

Ответ 6

Нет ничего о соединении, которое будет более элегантным, чем коррелированный подзапрос 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;

Я не знаю, какой из них может работать лучше, или если он будет работать лучше, чем другие ответы на странице. С правильным индексом в столбцах Date они должны быть достаточно хороши - определенно лучше, чем любое решение Row_Number().