Создание пользовательских статистических функций Суммировать данные по-новому путем написания своих собственных статистических функций. Одним из аспектов Oracle Database, который давно считается очень мощным, является возможность писать пользовательский процедурный код и сочетать его реляционной обработкой. Такой код очень часто принимает вид хранимых функций, которые можно вызывать из предложений SELECT или других предложений. Создание и использование функций, обрабатывающих одну строку в Oracle Database, очень хорошо понятно, но знаете ли вы, что точно также можно создавать и ваши собственные статистические агрегатные функции? Да, вы можете это делать, и гораздо проще, чем вы себе думаете. Эта статья показывает вам, как это делается. Сценарий Предположим, что вы работаете в компании по прокату автомобилей. У вас есть таблица, показанная в Листинге 1, в которой каждая строка представляет выдачу в прокат одного автомобиля заказчику. Для каждого проката вы имеете время выдачи автомобиля и время его возврата (столбцы rental_out и rental_in). Ваша таблица хранит эти два значения в виде типа TIMESTAMP. Ваша задача состоит в том, чтобы создавать различные отчеты основываясь на среднем времени срока проката автомобиля. Например, вы хотите иметь возможность определять средний период проката по штатам и сравнить каждый период проката со скользящим средним. Листинг 1: Таблица Car_rental CAR_ID -------1 1 1 1 2 2 2 2 CUST_ID -------101 102 103 104 113 114 115 116 RENTAL_STATE --------MI MI MI MI WI WI WI WI RENTAL_OUT -----------------------05-MAR-06 08.00.00.00 AM 12-MAR-06 08.34.24.00 AM 17-MAR-06 11.19.00.00 PM 28-MAR-06 09.45.15.00 AM 06-MAR-06 08.00.00.00 AM 13-MAR-06 08.34.24.00 AM 18-MAR-06 11.19.00.00 PM 29-MAR-06 09.45.15.00 AM RENTAL_IN -----------------------11-MAR-06 02.00.00.00 PM 17-MAR-06 07.23.19.00 PM 26-MAR-06 06.00.00.00 AM 30-MAR-06 03.27.13.00 PM 11-MAR-06 02.00.00.00 PM 17-MAR-06 07.23.19.00 PM 26-MAR-06 06.00.00.00 AM 30-MAR-06 03.27.13.00 PM . . . Использование типа TIMESTAMP дает возможность вычислить продолжительность каждого индивидуального проката достаточно просто: вычитаете одно значение типа TIMESTAMP из другого и получаете результат типа INTERVAL DAY TO SECOND: SELECT rental_in - rental_out FROM car_rental; RENTAL_IN-RENTAL_OUT 6 6:0:0.0 5 10:48:55.0 8 6:41:0.0 . . . Однако, вычисление средней продолжительности периода проката быстро превращает эту задачу в менее тривиальную: SELECT AVG(rental_in - rental_out) FROM car_rental; SQL Error: ORA-00932: inconsistent datatypes: expected NUMBER got INTERVAL DAY TO SECOND Встроенная статистическая функция AVG не поддерживает интервальные типы (то же самое относится и к функции SUM). Для многих эта проблема может привести к написанию процедурного кода, который выполняется на клиентской машине и извлекает и суммирует большие объемы данных через сеть с помощью циклов и курсоров, со всеми вытекающими отсюда проблемами с производительностью и расширяемостью, которые могут возникнуть в результате использования такого подхода. Однако, знание того, как писать статистические функции делает эту проблему не более сложной, чем небольшое ускорение. Написание статистических функций Рисунок 1 концептуально показывает, как статистическая функция — любого типа статистической функции — должна быть вычислена. Прежде всего, вы должны выполнить некую инициализацию. Затем вы передаете внутрь несколько значений в качестве входных данных для функции. И наконец, вы получаете в ответ единственное результирующее значение. Рисунок 1. Как производится вычисление статистической функции Рисунок 2. Методы статистической функции Пользовательская статистическая функция имеет в точности один параметр. Тогда каждое входное значение представляет отдельную строку таблицы или результата запроса. Нет возможности написать пользовательскую статистическую функцию, которая принимает множество параметров. Написание статистической функции в Oracle Database это дело создания объектного типа с методами, соответствующими каждой фазе Рисунка 1, плюс один дополнительный метод для поддержки параллельного вычисления. Рисунок 2 иллюстрирует эти методы. При вычислении статистических функций на основании сгруппированных значений Oracle Database делает следующее: 1. Вызывает ODCIAggregateInitialize для создания экземпляра типа, который вы построили для реализации агрегирования. Этот экземпляр известен как агрегирующий контекст. 2. Вызывает ODCIAggregateIterate в цикле для передачи внутрь контекста всех значений в группе, которая должна быть агрегирована. 3. Вызывает ODCIAggregateTerminate для генерации единственного результирующего значений. Это процесс представляет собой простейшую форму агрегирования. Oracle Database также может распараллелить эту операцию путем разделения данных на блоки и выполнения перечисленных выше шагов над каждым блоком и затем выполняя один или более вызовов ODCIAggregateMerge для объединения результатов от каждого блока в единственный результат. Создание типа Creating the Type Возвращаясь к сценарию проката автомобилей, вашей задачей будет написание статистической функции для вычисления средней продолжительности проката группы автомобилей, выраженного в виде значения типа INTERVAL DAY TO SECOND. Начать необходимо с создания объектного типа для реализации методов статистической функции. Листинг 2 показывает спецификацию объектного типа с именем AvgInterval. Листинг 3 показывает тело объектного типа. Вы должны реализовать, по крайней мере, методы, приведенные в Листинге 2. Это включает три метода для начала, снабжения (feeding) и завершения агрегации, а также один метод для слияния результатов после параллельного вычисления. Все методы должны возвращать значение типа NUMBER, которое представляет статус удачного или неудачного завершения. Вы можете изменить имена параметров, но число параметров и их порядок фиксированы. Листинг 2: Спецификация типа AvgInterval CREATE OR REPLACE TYPE AvgInterval AS OBJECT ( runningSum INTERVAL DAY(9) TO SECOND(9), runningCount NUMBER, STATIC FUNCTION ODCIAggregateInitialize ( actx IN OUT AvgInterval ) RETURN NUMBER, MEMBER FUNCTION ODCIAggregateIterate ( self IN OUT AvgInterval, val IN DSINTERVAL_UNCONSTRAINED ) RETURN NUMBER, MEMBER FUNCTION ODCIAggregateTerminate ( self IN AvgInterval, returnValue OUT DSINTERVAL_UNCONSTRAINED, flags IN NUMBER ) RETURN NUMBER, MEMBER FUNCTION ODCIAggregateMerge (self IN OUT AvgInterval, ctx2 IN AvgInterval ) RETURN NUMBER ); / Листинг 3: Тело типа AvgInterval CREATE OR REPLACE TYPE BODY AvgInterval AS STATIC FUNCTION ODCIAggregateInitialize ( actx IN OUT AvgInterval ) RETURN NUMBER IS BEGIN IF actx IS NULL THEN dbms_output.put_line('NULL INIT'); actx := AvgInterval (INTERVAL '0 0:0:0.0' DAY TO SECOND, 0); ELSE dbms_output.put_line('NON-NULL INIT'); actx.runningSum := INTERVAL '0 0:0:0.0' DAY TO SECOND; actx.runningCount := 0; END IF; RETURN ODCIConst.Success; END; MEMBER FUNCTION ODCIAggregateIterate ( self IN OUT AvgInterval, val IN DSINTERVAL_UNCONSTRAINED ) RETURN NUMBER IS BEGIN DBMS_OUTPUT.PUT_LINE('Iterate ' || TO_CHAR(val)); IF val IS NULL THEN /* Это никогда не должно случиться */ DBMS_OUTPUT.PUT_LINE('Null on iterate'); END IF; self.runningSum := self.runningSum + val; self.runningCount := self.runningCount + 1; RETURN ODCIConst.Success; END; MEMBER FUNCTION ODCIAggregateTerminate ( self IN AvgInterval, ReturnValue OUT DSINTERVAL_UNCONSTRAINED, flags IN NUMBER ) RETURN NUMBER IS BEGIN dbms_output.put_line('Terminate ' || to_char(flags) || to_char(self.runningsum)); IF self.runningCount <> 0 THEN returnValue := self.runningSum / self.runningCount; ELSE /* Возможен случай пустой группы, поэтому нужно быть осторожным при делении на ноль. */ returnValue := self.runningSum; END IF; RETURN ODCIConst.Success; END; MEMBER FUNCTION ODCIAggregateMerge (self IN OUT AvgInterval, ctx2 IN AvgInterval ) RETURN NUMBER IS BEGIN self.runningSum := self.runningSum + ctx2.runningSum; self.runningCount := self.runningCount + ctx2.runningCount; RETURN ODCIConst.Success; END; END; / В объектном типе необходимо определить любые переменные, которые нужны для поддержки состояния в процессе агрегации. Как вы поддерживаете состояние, целиком зависит от вас и от того, как вы решите реализовать логику агрегации. Имеет смысл использовать накапливающееся значение суммы и счетчик количества значений для последующего вычисления среднего значения (хотя возможны и другие подходы), поэтому в верху спецификации AvgInterval определены две переменные (смотрите Листинг 2): runningSum INTERVAL DAY(9) TO SECOND(9), runningCount NUMBER, runningSum определена с максимальной точностью (9) для учета и дней, и десятичных долей секунд. По умолчанию можно использовать только два знака для учета дней и только шесть знаков после десятичной точки для учета десятичных долей секунды. Использование же максимальной точности в обоих случаях делает AvgInterval полезной в общем случае с любыми значениями INTERVAL DAY TO SECOND, не зависимо от точности. Далее в спецификации AvgInterval идут объявления функций для интерфейса ODCIAggregate. Они могут следовать в любом порядке, но давайте начнем с начала, то есть с функции инициализации: . . . STATIC FUNCTION ODCIAggregateInitialize ( actx IN OUT AvgInterval ) RETURN NUMBER, . . . Oracle Database вызывает эту функцию, чтобы инициализировать процесс статистических вычислений. Единственный аргумент имеет тип, который вы создаете для реализации статистической функции (в данном случае AvgInterval). Вообще говоря назначение функции ODCIAggregateInitialize состоит в создании нового экземпляра базового объектного типа и последующей инициализации переменных экземпляра, используемых в вычислении статистического результата. Ключевое слово STATIC позволяет Oracle Database вызывать эту функцию независимо от того, существует ли в действительности экземпляр данного типа.ODCIAggregateInitialize единственная из функций интерфейса ODCIAggregate определяемая как STATIC. Экземпляр объекта, который вернул метод ODCIAggregateInitialize становится агрегирующим (или статистическим) контекстом. Если вы вызываете одну и ту же статистическую функцию несколько раз в одном предложении, то Oracle Database создаст, по крайней мере, по одному контексту для каждого вызова. Если же Oracle Database распараллеливает выполнение, то она создаст по одному контексту для каждого из потоков. В Листинге 3 вы можете посмотреть код ODCIAggregateInitialize. Теперь же сфокусируемся на случае, в котором входной параметр имеет пустое значение (null). Для создания экземпляра объекта используется неявно определенный конструктор. Значения, передаваемые в конструктор соответствуют runningSum и runningCount, и оба инициализированы в 0. Далее посмотрим на ODCIAggregateIterate в Листинге 3. Это «рабочая лошадка» всего агрегирующего типа. Oracle Database будет вызывать эту функцию для каждой строки—по одному разу для каждого значения для накопления статистических значений. Логика здесь достаточно проста: self.runningSum := self.runningSum + val; self.runningCount := self.runningCount + 1; Как только поступает новое значение для вычисления статистики, функция добавляет это значение к накопленной сумме и увеличивает значение счетчика. После того как обработаны все значения, Oracle Databaseвызывает ODCIAggregateTerminate, как показано в Листинге 3, которая, в свою очередь, использует два значения для вычисления среднего: returnValue := self.runningSum / self.runningCount; Знайте также о возможности того, что контекст будет создан и завершен даже для пустой группы. Учитывая вычисления, показанные здесь для returnValue, пустая группа приведет в результате к ошибке деления на ноль. Код в Листинге 3 старается избежать такого деления и сообщает об ошибке в случае если счетчик равен 0. (Деление на ноль в сценарии для данной статьи объявляет себя как ошибка "ORA01873: the leadingprecision of the interval is too small (точность интервала слишком мала)".) Отметьте, что в Листинге 3 тип данных, используемый для аргумента val в ODCIAggregateIterate, а именно DSINTERVAL_UNCONSTRAINED, эквивалентен типу INTERVAL DAY(9) TO SECOND(9). Необходимо использовать неограниченный тип потому, что вы не можете задать точность в списке параметров метода. Однако, вы также не можете использовать неограниченный тип для значения текущей суммы. Последний метод класса, который нужно рассмотреть, это ODCIAggregateMerge, и он также приведен в Листинге 3. Вспомните из Рисунка 2, что процедура агрегирования может быть распараллелена. Oracle Databaseсоздаст отдельные агрегирующие (или статистические) контексты для каждого параллельного потока выполнения. Предполагая, что мы имеем три параллельных потока, последовательность событий будет следующей 1. ODCIAggregateInitialize вызывается для потока 1 2. ODCIAggregateInitialize вызывается для потока 2 3. ODCIAggregateInitialize вызывается для потока 3 4. ODCIAggregateIterate вызывается параллельно во всех потоках: 1, 2 и 3 5. ODCIAggregateMerge вызывается для слияния, например, потоков 2 и 3 6. ODCIAggregateMerge вызывается для слияния потока 1 с результатом шага 5 7. ODCIAggregateTerminate вызывается для генерации окончательного результата Oracle Database вызывает ODCIAggregateMerge с двумя аргументами. Каждый аргумент представляет собой контекст агрегации: MEMBER FUNCTION ODCIAggregateMerge (self IN OUT AvgInterval, ctx2 IN AvgInterval ) RETURN NUMBER IS Задача функции слияния состоит в изменении первого контекста агрегации—self—таким образом, чтобы он отражал объединение первого и второго—ctx2—контекстов. Вы вычисляете среднее значение, поэтому вы можете сложить текущие значения сумм и счетчиков: . . . self.runningSum := self.runningSum + ctx2.runningSum; self.runningCount := self.runningCount + ctx2.runningCount; RETURN ODCIConst.Success; . . . Эффект этой операции слияния состоит в том, что значения текущей суммы и счетчика в self (первый контекст в списке аргументов) теперь хранят значения такие, которые были бы получены в случае «прогонки» всех значений через этот один контекст. Контекст ctx2 теперь больше не нужен. (Если вы когда-либо писали пользовательскую статистическую функцию на таких языках как C или Java и вы распределяли память или какие-то другие ресурсы для контекста ctx2, то вам необходимо освободить память или ресурсы в этой точке процесса вычислений.) Определение функции После того, как вы создадите объектный тип и методы для реализации новой статистической функции, вы должны построить между именем функции, которое вы хотите использовать в SQL, и базовым типом. Делается это с помощью предложения приведенного ниже: CREATE OR REPLACE FUNCTION avg_interval ( x DSINTERVAL_UNCONSTRAINED ) RETURN DSINTERVAL_UNCONSTRAINED PARALLEL_ENABLE AGGREGATE USING AvgInterval; / Это предложение создает функцию avg_interval, которая имеет один аргумент, совместимый с любым значением типа INTERVAL DAY TO SECOND. PARALLEL_ENABLE разрешает использовать распараллеливание.Предложение AGGREGATE USING связывает функцию avg_interval с базовым типом AvgInterval. Вызовите avg_interval и - Oracle Database вызовет различные методы типа AvgInterval для генерации результата. Использование новой функции Использование созданной новой статистической функции avg_interval не может быть проще: вызывается она так же как и любая другая встроенная статистическая функция. Теперь можно легко вычислить среднюю продолжительность периода проката даже для всех данных: SELECT avg_interval (rental_in - rental_out) FROM car_rental; AVG_INTERVAL(RENTAL_IN-RENTAL_OUT) +000000004 15:25:17.000000000 И вы также легко и просто можете вычислить среднюю продолжительность проката для каждого штата в отдельности: SELECT rental_state, avg_interval (rental_in - rental_out) FROM car_rental GROUP BY rental_state; RE ---MI MN WI AVG_INTERVAL(RENTAL_IN-RENTAL_O... --------------------------------------+000000005 12:31:08.250000000 +000000003 12:07:43.250000000 +000000004 12:31:08.250000000 Код в этой статье содержит вызовы DBMS_OUTPUT. Если вы выполняете приведенные выше предложения SELECT из SQL Developer, то можно разрешить видимость сообщений из DBMS_OUTPUT, щелкнув на пиктограмму панели инструментов Enable DBMS Output в закладке DBMS Output. (В SQL*Plus нужно выполнить команду SET SERVEROUTPUT ON.) Теперь можете выполнить запрос, и вы увидите, как Oracle Databaseвызывает различные методы ODCI для типа AvgInterval: NULL INIT Iterate +000000006 Iterate +000000005 Iterate +000000008 . . . NULL INIT Iterate +000000005 Iterate +000000004 Iterate +000000007 . . . 06:00:00.000000000 10:48:55.000000000 06:41:00.000000000 06:00:00.000000000 10:48:55.000000000 06:41:00.000000000 NULL INIT Iterate +000000003 14:37:00.000000000 Iterate +000000003 00:48:55.000000000 Iterate +000000007 12:41:00.000000000 . . . Terminate 0+000000066 06:13:39.000000000 Terminate 0+000000028 01:01:46.000000000 Terminate 0+000000054 06:13:39.000000000 Три строки с NULL INIT показывают, как Oracle Database создает новый контекст агрегирования для каждой группы. Строки итерации показывают значения, поступающие в каждую группу. И наконец, Oracle Databaseвызывает метод завершения по одному разу для каждой группы для формирования окончательных результатов. (Если вы выполняете это код на многопроцессорной системе, то из-за использования параллелизма, результаты вывода DBMS_OUTPUT не будут так красиво упорядочены, как приведенные выше.) Конечно, вы вероятно можете не захотеть использовать вызовы DBMS_OUTPUT в рабочем коде. За кулисами Oracle Database выполняет для вас достаточно много вспомогательной работы: Она группирует данные. Она отфильтровывает пустые значения (nulls) (так как это требует стандарт ISO SQL). Она осуществляет распараллеливание. Все, о чем вам нужно беспокоиться при написании методов ODCI, это две вещи: как сложить одну группу значений и как слить две группы. Вы даже бесплатно получаете поддержку аналитического синтаксиса. Аналитический синтаксис Любая статистическая функция, которую вы создаете, может также быть использована как аналитическая функция. Oracle Database сама обрабатывает все детали, связанные с разбивкой и упорядочением.Например, чтобы сравнить каждый отдельный срок проката со средним сроком проката в том же самом штате, вы можете написать запрос, похожий на этот: SELECT rental_in - rental_out time, rental_state st, avg_interval (rental_in - rental_out) OVER (PARTITION BY rental_state) state_avg FROM car_rental; Результат работы будет выглядеть следующим образом: RENTAL_TIME -----------6 6:0:0.0 5 10:48:55.0 8 6:41:0.0 6 6:41:0.0 4 10:48:55.0 ... ST -MI MI MI MN WI STATE_AVG ----------5 12:31:8.250 5 12:31:8.250 5 12:31:8.250 3 12:7:43.250 4 12:31:8.250 Если вы посмотрите на результаты вывода из DBMS Output , то вы заметите несколько интересных деталей. На однопроцессорной машине вы увидите одно вхождение NULL INIT за которым следует несколькоNON-NULL INIT. Это говорит о том, что Oracle Database создает один контекст агрегации и затем повторно использует этот контекст для каждого разбиения определяемого опцией OVER аналитической функции. МетодODCIAggregateInitialize в Листинге 3 использует это. Когда первый параметр не является пустым (non-null), то метод не создает новый объект, а вместо этого повторно инициализирует существующий объект. Вы также можете заметить достаточно много завершающих вызовов, больше, чем вы ожидаете, это происходит потому что Oracle может вызывать функцию завершения, чтобы снова получить среднее значение для каждой выводимой строки. В большинстве из этих завершающих вызовов аргумент флагов (смотрите Листинг 3) будет установлен в 1 (ODCIConst.AGGREGATE_REUSE_CTX), указывая, что вы ожидаете, что OracleDatabase будет использовать контекст повторно. Поддержка аналитического кадра Синтаксис аналитических функций включает поддержку агрегации над скользящим окном данных. Следующий запрос использует такое условие кадра (framing clause) для сравнения каждого значения срока проката с текущим средним для окна, состоящего из трех периодов с центром в текущем значении срока проката. Условие кадра выглядит так ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING Скользящее окно, таким образом, состоит из текущей рассматриваемой записи, одной записи непосредственно предшествующей текущей и одной записи непосредственно следующей за текущей. Вот полный запрос: SELECT rental_in - rental_out time, rental_state st, avg_interval (rental_in - rental_out) OVER (PARTITION BY rental_state ORDER BY rental_out ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) state_avg FROM car_rental; Oracle Database может выполнить этот запрос с использованием методов определенных в Листингах 2 и 3. Однако, при движении такого окна каждое перемещение приводит к новой группировке, которая должна быть вычислена «с нуля». Вы можете повысить эффективность написав метод для удаления значения из агрегирования позволяя, тем самым, Oracle Database сдвигать окно удаляя значения с заднего конца диапазона и добавляя значения в передний конец диапазона. Метод, который вам нужен, называется ODCIAggregateDelete. Вот его объявление, которое необходимо добавить к спецификации типа в Листинге 2 (не забудьте отделить его от других объявлений запятой): MEMBER FUNCTION ODCIAggregateDelete ( self IN OUT AvgInterval, val IN DSINTERVAL_UNCONSTRAINED ) RETURN NUMBER А вот его реализация, которую нужно разместить в теле типа (Листинг 3): MEMBER FUNCTION ODCIAggregateDelete ( self IN OUT AvgInterval, val IN DSINTERVAL_UNCONSTRAINED ) RETURN NUMBER IS BEGIN DBMS_OUTPUT.PUT_LINE ('Delete ' || TO_CHAR(val)); IF val IS NULL THEN /* Will never happen */ DBMS_OUTPUT.PUT_LINE ('Null on delete'); END IF; self.runningSum := self.runningSum - val; self.runningCount := self.runningCount - 1; RETURN ODCIConst.Success; END; Теперь, как только окно ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING изменяется для каждой обработанной запросом строки, Oracle Database будет делать вызов метода ODCIAggregateDelete для удаления интервала для строки, уходящей из окна, за которым следует вызов метода ODCIAggregateIterate для добавления интервала для следующей строки, поступающей в окно. Это более эффективный подход, чем агрегирование с нуля для каждого перемещения окна. Будь креативным Пользовательские статистические функции открывают дверь в интересный мир новых возможностей. Вы можете написать функцию SUM, которая возвращает 0 вместо пустого значения (null), когда в группе не ни одной строки для обработки. Вы можете написать функцию MUL для генерации произведения группы чисел путем их перемножения. Вы даже можете очень легко и просто решить проблему всех строковых значений в группе. Пользовательские статистические функции могут быть мощным инструментом для решения проблем в SQL. Подбор материала, перевод и подготовка статьи Игорь Изварин