Система Orphus
Версия для печати

QueryMachine: Автономный процессор SQL для платформы .NET

Автор: Чертков Семен Александрович
Опубликовано: 04.06.2011
Исправлено: 10.12.2016
Версия текста: 1.1
Введение
Расширенный синтаксис SQL
Префиксы источников данных
Дополнительные функции
Динамические таблицы и детальное соединение
Обработка групп файлов в одном запросе
Поддержка XML
Простая генерация XML
XML агрегация
Извлечение данных из документов XML
Использование QueryMachine
Обзор реализации
Заключение
Ссылки

Введение

Язык SQL в настоящее время повсеместно реализован в современных реляционных базах данных. Тем не менее, возможности SQL в части выборки данных (оператора SELECT, проще говоря), не обязательно должны использоваться только внутри СУБД. С точки зрения семантики языка, оператор выборки данных манипулирует таблицами и полями, безотносительно того, как они физически организованы и где расположены. Это наводит на мысль о возможности конструирования на основе SQL всевозможных специализированных языков выборки данных, применяемых в необходимых случаях аналогично тому, как обычно используются DSL языки (предметно-ориентированные языки программирования [1]).

Одним из типичных примеров подобного подхода является WQL – язык запросов к инфраструктуре WMI (Windows Management Instrumentation), реализованный компанией Майкрософт для Windows [2]. В качестве таблиц в запросах WQL выступают классы модели данных CIM (Common Information Model), которые содержат информацию о различных частях инфраструктуры предприятия. В практическом плане, эти запросы позволяют, «в стиле SQL», выбирать информацию об устройствах, конфигурации систем, работающих процессах, событиях и многом другом.

Другой хорошо известный пример – это язык HQL (Hibernate Query Language), используемый в O/R-mapper Hibernate от компании JBoss [3]. В отличие от предыдущего случая, HQL в обычно не выполняет запросы самостоятельно, а транслирует их в диалект используемой базы данных, на основе заданного отображения объектной модели в структуру таблиц. Таким образом, Hibernate обеспечивает не только хранение объектов в СУБД, но и механизм запросов, не зависящий от физической организации схемы данных. Это позволяет писать приложения, не привязываясь к конкретной СУБД, что является очень важным качеством в больших проектах.

Любопытно, что аналогичным образом работает и «1С:Предприятие», начиная с версии 8.0. Язык запросов 1С, если использовать латинские названия операторов, представляет собой все тот же SQL с небольшим количеством адаптированных объектных расширений, который транслируется сервером приложения 1С в последовательность запросов к используемой для хранения информационной базы СУБД. При этом поддержка DML не реализована, и изменение данных происходит из внутреннего скриптового языка, посредством объектов данных конфигурации, что в целом повторяет подход, применяемый в любых обычных ORM.

Еще одной областью применения SQL без СУБД, на которой хотелось бы остановиться подробнее, являются всевозможные драйвера для чтения данных из плоских файлов, которые используются через интерфейсы JDBC или ADO.NET. Поскольку и JDBC и ADO.NET проектировались для доступа к реляционным базам, технически такие источники данных подразумевают SQL интерфейс, имитирующий СУБД. Более того, теоретически поддержка SQL на уровне файла данных может оказаться эффективным инструментом их анализа. Дело в том, что хотя последовательное чтение таблицы (table full scan) в любой реляционной СУБД выполняется несоизмеримо быстрее чтения плоского файла, соответствующую таблицу перед использованием еще нужно загрузить в базу данных. И если вести речь о файле размером в пару гигабайт и более, то предварительная загрузка соответствующей таблицы во многих случаях будет значительно медленнее выполнения простого запроса на уровне файла данных.

Для иллюстрации сказанного рассмотрим следующую задачу. Имеем каталог логов доступа пользователей к Microsoft ISA Server в виде набора текстовых файлов, размером 2‑3 гигабайта. Требуется узнать общее количество трафика, который использовал конкретный пользователь по дням. Очевидными способами решения этой задачи являются либо написание специального скрипта, либо создание таблицы в базе данных и загрузка в нее содержимого каждого файла (серьезные решения для совсем больших объемов данных, вроде Apache Hadoop/Hive, не рассматриваем). Процессор SQL, способный, где это возможно, в потоковом режиме выполнять запросы к плоским файлам и имеющий механизм экспорта результатов, в этом случае мог бы стать неплохим подспорьем.

Идея выполнять аналитические запросы к плоским файлам без их загрузки в базу данных очевидным образом приводит нас к необходимости поддержки распределенных запросов к различным источникам данных. Предположим, что в нашей задаче требуется сформировать статистику в виде отчета по потреблению интернет трафика в разрезе подразделений. Проблема в том, что в логах ISA Server нет подразделений. В них нет даже фамилий пользователей, а есть только их имена в системе. Таким образом, было бы неплохо прочитать необходимые данные из ActiveDirectory, представив их в виде какой-то виртуальной таблицы и соединить ее с данными из файлов. Распределенные запросы так же полезны в случае, когда мы хотим выбрать ограниченный, но достаточно большой набор записей из списка. Например, мы имеем в Oracle большую таблицу, из которой для анализа информации требуется выбрать какие-то записи перечислением составных ключевых значений. Конечно, возможно написать соответствующий оператор SQL, но если список большой - это не слишком удобно, и скорее всего, потребуется промежуточная таблица. Поэтому, почему бы не сделать распределенный запрос, соединяющий нужную таблицу и файл электронной таблицы Excel, где поместить необходимый перечень значений.

На самом деле, механизм, позволяющий более-менее эффективно выполнять запросы SQL к разнородным данным, представимым в виде таблиц, открывает много интересных возможностей.

Эта статья о библиотеке QueryMachine, которая является «универсальным» драйвером ADO .NET предназначенным для выполнения SQL запросов выборки из любых источников структурированных данных. Она обеспечивает следующие возможности:

Сайт проекта размещен на CodePlex http://qm.codeplex.com. Код написан на C# и использует общую платформу с разработанным автором процессором XQuery [4]. Там же размещен QmConsole – WPF-клиент для выполнения запросов XQuery и SQL с возможностью экспорта результатов в различные форматы файлов и SQL базы данных.

Расширенный синтаксис SQL

Описываемая реализация определяет небольшое количество дополнений к стандартному синтаксису SQL-92:

  1. Для именования источников данных в названии таблиц используется префикс. При этом остальное название таблицы может быть составным и включать в себя любое количество частей, которые указываются в зависимости от используемого источника данных.
  2. Реализован расширяемый механизм дополнительных функций, которые могут использоваться в выражениях SQL.
  3. Допускается использование любых объектов в качестве полей таблиц в запросах SQL, в том числе массивов, элементов и списков элементов XML DOM. Грамматика SQL расширена операцией получения элемента по индексу и операцией получения компонента структуры.
  4. В оператор FROM добавлена конструкция TABLE, позволяющая в качестве источника данных (т.е., вместо таблицы или подзапроса) указывать вычисляемое выражение.

Префиксы источников данных

Поскольку в SQL запросе можно использовать сразу несколько источников данных, для их именования в названии таблиц используется префикс.

Например,

        SELECT * FROM TXT:"1.txt"
SELECT * FROM XLS:"Test.xls"."sheet1$"

В первом рассматриваемом запросе с помощью встроенного парсера выбираются все поля из плоского файла 1.txt. Во втором запросе выбираются все поля электронной таблицы Test.xls из вкладки (Worksheet) sheet1. Для чтения данных из файла Excel в этом случае система использует драйвер OLEDB Microsoft JET 4.0. Следующая таблица описывает встроенные префиксы и соответствующие им источники данных:

Префикс Описание

XML

Префикс для XML файлов, используемых как источники данных;

DBF

Таблицы в формате DBF. Для чтения данных используется MS JET 4.0;

XLS

Таблицы Excel. Имя таблицы состоит из имени файла и названия вкладки с суффиксом «$». Для чтения данных используется MS JET 4.0;

TXT

Плоский файл в формате CSV или Fixed-Length. Используется внутренний потоковой парсер исключающий предварительную загрузку данных в память;

ADO

Формат XML файла используемый ADO .NET DataSet.

Перечисленные выше префиксы в качестве имени таблицы используют имя файла данных, либо только идентификатор без расширения и без кавычек. Если расширение не указанно, оно добавляется исходя из ожидаемого типа файла. Кроме того, если получившееся имя файла не является абсолютным, система ищет требуемый файл в заранее определенном списке каталогов.

Любые другие источники данных, доступные с помощью ADO .NET с помощью класса DatabaseDictionary могут быть связаны с выбранными префиксами и таким образом использованы в запросах. Более того, один из источников данных может быть отмечен как основной. Для основного источника данных указывать префикс не обязательно.

Например, предположим, что для СУБД Oracle и SQL Server сконфигурированы два источника данных с префиксами ORA и MSSQL соответственно. Также будем считать, что в Oracle необходимая таблица находится в схеме текущего пользователя и этот пользователь NORTHWND, а база данных SQLServer помечена как основная. Тогда будут допустимы следующие запросы:

        SELECT * FROM ORA:Orders NATURAL JOIN "Order Details"
SELECT * FROM ORA:NORTHWND.Orders NATURAL JOIN MSSQL:"Order Details"
ПРИМЕЧАНИЕ

Здесь, и далее в большинстве запросов используются данные из стандартного примера для MS SQL Server NORTHWND.MDB. Эти примеры можно попробовать в работе, настроив соединение к SQL Server из QmConsole. Для тестирования запросов к СУБД Оракул содержимое таблицы Orders можно переместить командой Move Data.

Дополнительные функции

Собственно SQL-92 не предусматривает возможности использовать какие-либо функции в выражениях, в том смысле, что правило value_expression_primary в BNF грамматике языка [5] декларирует только ссылку на колонки, результаты агрегации, скалярный подзапрос и операторы CASE и CAST. Данная реализация SQL добавляет в это правило функциональные выражения, которые вычисляются в коде и расширяют возможности системы без необходимости изменения грамматики. Имена функцией QueryMachine начинаются с префикса «$» (dollar sign).

Следующая таблица описывает имеющиеся в настоящий момент дополнительные функции:

Имя Описание

$extract(dataSource, xpath)

Извлекает фрагмент XML документа по заданному выражению XPath.

$base64decode(string)

Декодирует данные из кодировки base64 в byte[]

$base64encode(byte[])

Кодирует данные из byte[] в кодировку base64

$systab(DbAlias, tablename)

Возвращает таблицу tablename, вызывая DbConnection.GetSchema() для драйвера ADO .NET заданного префиксом DbAlias.

$ldap(DomanServicePath, LdapQuery, PropertiesToLoad)

Выполняет поиск в каталоге домена DomainServicePath с критерием, заданным LdapQuery. Пустая строка в DomainServicePath по умолчанию задает текущий домен. Возвращаемая таблица содержит колонки соответствующие набору свойств, который требуется получить для каждого найденного объекта. Набор свойств определяется строкой PropertiesToLoad, которая содержит имена свойств, разделяемые через запятую. Эта функция использует стандартный класс DirectorySearcher.

$ldap_props(DomanServicePath, LdapQuery)

Возвращает информационную таблицу, содержащую все доступные свойства объектов, выбираемых критерием LdapQuery. Эта функция использует стандартный класс DirectorySearcher.

$text()

Возвращает текстовое содержимое текущего XML элемента.

$rownum()

Возвращает текущий номер строки. Эта функция допускается только внутри оператора SELECT.

$format(FormatStr, Arg1,…)

Форматирует строку аналогично String.Format.

$getfilename(Path)

Возвращает имя файла из строки, представляющей полный путь файла.

$getfilepath(Path)

Возвращает каталог файла из строки, содержащей полный путь файла.

$machinename()

Возвращает имя системы.

$userdomain()

Возвращает имя домена текущего пользователя системы.

$username()

Возвращает текущее имя пользователя системы.

Динамические таблицы и детальное соединение

Функциональные выражения так же могут использоваться для обращения к источникам данных, которые требуют определения дополнительных параметров. Например, для чтения информации из ActiveDirectory необходимо указать домен, фильтр Ldap и набор свойств, который передается для каждого найденного объекта. Поэтому такой источник данных удобно обозначить в виде функции $ldap, принимающей необходимые аргументы. Реализация этой функции в коде выполняет необходимый запрос к ActiveDirectory с помощью стандартного класса DirectorySearcher и возвращает результат в виде виртуальной таблицы, состоящей из полей соответствующих указанному набору свойств. Также заметим, что если какое-либо свойство объекта каталога имеет несколько значений, то в соответствующее поле помещается массив значений.

Следующий пример иллюстрирует простой запрос к ActiveDirectory, который выводит список пользователей в виде таблицы: «учетная запись», «ФИО» и «подразделение»:

        SELECT * FROMTABLE $ldap('','(&(objectClass=person)(sn=*))','SAMAccountName,displayname,department') 

Применению этого подхода с формальной точки зрения препятствует невозможность использовать какие-либо выражения в операторе FROM, так как обычная грамматика SQL в правиле для table_reference допускает только название таблицы или подзапрос. Поэтому, в язык SQL для QueryMachine добавлена конструкция TABLE, определяющая динамическую таблицу по следующим правилам:

table_ref_spec
    : table_name
    | dynamic_table
    | subquery
    ;   
    
dynamic_table
    : TABLE funcall
    | TABLE column_ref
    | TABLE '(' value_exp ')'    
    | TABLE xml_query
    ;     

Как видно из приведенной выше грамматики, в качестве динамической таблицы может выступать вызов функции, содержимое поля из таблицы, запрос XQuery или произвольное вычисляемое выражение. Ключевое слово TABLE, а так же скобки при использовании произвольного выражения (не функции) необходимы для исключения Shift/Reduce конфликтов в грамматике.

Динамические таблицы могут использоваться и в соединениях, коррелируясь с другими таблицами, участвующими в запросе. Предположим, нам необходим запрос, который выводит список учетных записей и идентификаторов групп, к которым относятся данные учетные записи.

Очевидный запрос, дающий информацию, возвращаемую нашей функцией, не будет корректным, поскольку поле memberof многозначное иявляется массивом String[] в случаях, когда одна и та же учетная запись относится к нескольким группам:

        SELECT * FROMTABLE $ldap('', '(&(objectClass=person)(sn=*))','SAMAccountName,memberof')

Чтобы выделить отдельные строки из массива memberof, можно воспользоваться синтаксисом для получения элементов по индексу:

        SELECT SAMAccountName as username, memberof[1], memberof[2], memberof[3]
  FROMTABLE $ldap('', '(&(objectClass=person)(sn=*))','SAMAccountName,memberof') WHERE memberof ISNOTNULL

Этот запрос будет выполняться без ошибок, вне зависимости от количества элементов в любом из массивов memberof, так как операция получения элемента по индексу работает аналогично XQuery по следующим правилам:

  1. Нумерация элементов начинается с единицы;
  2. a[1] = a, если a не является индексируемым типом данных;
  3. a[x] = NULL, если x больше длинны a.

Однако рассматриваемый запрос будет возвращать только идентификаторы максимум первых трех групп, и выдавать, вообще говоря, не те данные, которые требовались. Для получения нужного результата, соединим таблицу возвращаемую функцией $ldap, с новой динамической таблицей, формируемой по полю memberof:

        SELECT SAMAccountName as username, m.node as memberof FROMTABLE $ldap('', '(&(objectClass=person)(sn=*))', 'SAMAccountName,memberof') dir, TABLE dir.memberof m

Приведенный выше запрос требует дополнительных пояснений.

Во-первых, QueryMachine умеет внутри конструкции TABLE преобразовывать в таблицы любые перечисляемые типы данных. Результатом такого преобразования является виртуальная таблица, содержащая единственное поле – node, в которое и помещается элемент перечисления.

Во-вторых, система обнаруживает, что динамическая таблица является подчиненной и вычисляется из полей первой таблицы. Поэтому транслятор SQL создает специальный вид соединения — детальное соединение (detail join). Детальное соединение порождает новую динамическую таблицу, вычисляя выражение (в нашем случае она определяется содержимым поля memberof) для каждой строки мастер-таблицы, и выполняет декартово произведение строк мастер-таблицы на множество записей каждой порожденной таблицы. Множество полей получившейся соединённой таблицы будет являться объединением полей мастер таблицы и полей каждой из таблиц, порожденных в соединении. В рассматриваемом примере, все порождаемые таблицы имеют одну и ту же структуру, поэтому результатом соединения будет являться множество из трех полей: SAMAccountName, memberof и node.


Рисунок 1

Детальное соединение является основным механизмом, с помощью которого в QueryMachine реализована поддержка XML.

Обработка групп файлов в одном запросе

При написании запросов к файлам данным зачастую требуется обрабатывать группы файлов. В рассмотренной ранее задаче анализа Microsoft ISA Server требуется обрабатывать группу файлов, так как события сервера разбиваются по нескольким лог-файлам с некоторым периодом времени, устанавливаемым системным администратором. Стандартным способом решения этой задачи будет разбиение запроса на части, соответствующие каждому анализируемому файлу и объединение результатов с помощью union all:

        SELECTFROM TXT:"file1.txt"
…
UNIONALLSELECTFROM TXT:"file2.txt"
…

Такие конструкции могут быть весьма громоздкими и требуют, чтобы набор файлов был заранее определен перед написанием запроса. Чтобы избежать необходимости дублировать фактически один и тот же запрос для разных файлов, в QueryMachine реализован механизм, позволяющий для плоских файлов и XML указывать вместо имени таблицы – маску, определяющую нужную группу файлов:

        SELECT * FROM TXT:"file*.txt" 

Получающаяся в результате таблица содержит записи соответствующие каждому файлу и всегда состоит из двух полей: name – полное имя файла и node – его содержимое в виде внутренней таблицы. Далее, чтобы использовать данные каждого файла, просто извлекаем его таблицу с помощью детального соединения. Например, следующий простой запрос подсчитывает общее количество записей во всех файлах:

        SELECT
        COUNT(*) FROM TXT:"file*.txt" T, TABLE T.node

Следует обратить внимание на то, что при детальном соединении происходит объединение полей всех порожденных таблиц, поэтому файлы, попадающие в группу, не обязательно должны быть одинаковой структуры. Более того, QueryMachine не буферизует целиком вложенные таблицы перед выполнением соединения, поэтому приведенный выше запрос будет работать на исходных данных абсолютно любого размера.


Рисунок 2

На приведенном выше рисунке видно, что в случае, когда в группе файлов мы имеем элементы file1.txt с полями {Field1, Field2, …} и file2.txt c полями {Field1, Field3,…}, то результатом детального соединения будет представление с полями {name,Field1,Field2,Field3,…}, при условии, что типы данных поля Field1 в обоих файлах совпадают. Таким образом, если name = “file1.txt” будут заполнены поля Field1 и Field2, а если name = “file2.txt” – поля Field1 и Field3.

Поддержка XML

Следует заметить, что расширения языка SQL для обработки XML [7] данных являются частью стандарта SQL:2003, а не SQL-92. Практически каждая известная СУБД реализует какие-то средства для поддержки SQL/XML. С точки зрения автора, одной из наиболее полных и близких к спецификации реализаций является Oracle XML DB [8].

Сутью спецификацию SQL/XML, с точки зрения идеологии, является добавление в язык SQL операторов, позволяющих использовать XML данные в SQL запросах и генерировать XML данные с помощью SQL запросов.

Простая генерация XML

Начнем с генерирования XML данных в запросах.

QueryMachine реализует поддержку следующих операторов SQL/XML:

Эти операторы могут находиться в части SELECTSQL-запросаи комбинироваться между собой как функциональные выражения, примерно так же, как в Linq-to-XML для создания моделей DOM комбинируются конструкторы классов XElement, XAttribute и другие классы.

Например, запрос

        SELECT XMLRoot(
        XMLElement(rss, 
           XMLAttributes('2.0' AS version),
           XMLElement(channel,
             XMLElement(title, 'RSS Channel Title'),
             XMLElement(description, 'RSS Channel Description.'),
             XMLElement(link, 'http://www.example.com'),
             XMLElement(item))))

будет возвращать таблицу, состоящую из единственного поля и одной строки, где разместиться экземпляр класса System.Xml.XmlDocument вот такого содержания:

<rss version="2.0">
  <channel>
    <title>RSS Channel Title</title>
    <description>RSS Channel Description.</description>
    <link>http://www.example.com</link>
    <item />
  </channel>
</rss>

Следующий запрос:

        SELECT XMLElement(elem, XMLAttributes(OrderID, ProductID, Quantity)), OrderID, ProductID, Quantity
   FROM "Order Details"

выбирает таблицу из четырех полей: первое поле – это экземпляр класса System.Xml.XmlElement, a остальные поля значения из таблицы “Order Details”. Естественно, количество выбираемых запросом записей совпадет с размером этой таблицы.


Рисунок 3

Таким образом, оператор XMLElement с двумя аргументами создает экземпляр класса System.Xml.XmlElement с именем элемента, определяемого первым аргументом и вычисляемым содержимым, задающимся во втором аргументе. Обратим внимание, что имя элемента – это не строковая константа, а идентификатор, аналогичный имени в операторе AS. Оператор XMLElement также может иметь более двух аргументов. В этом случае каждый последующий перечисленный аргумент вычисляется и добавляется к содержимому элемента.

Оператор XMLAttributes принимает любое число выражений – аргументов, которые представляет в качестве значений атрибутов текущего элемента. Каждое из этих выражений должно быть явно именовано с помощью оператора AS, за исключением случаев, когда выражение является ссылкой на колонку таблицы (так как колонка сама по себе имеет имя).

Оператор XMLForest работает аналогично XMLAttributes, но представляет значения выражений в виде дочерних элементов. Таким образом, запрос:

        SELECT XMLElement(elem, XMLForest(OrderID, ProductID, Quantity))
  FROM "Order Details"

сформирует элементы вида:

<elem>
  <OrderID>10248</OrderID>
  <ProductID>11</ProductID>
  <Quantity>12</Quantity>
</elem>

Остальные элементы XMLPI, XMLRoot и XMLComment предназначены для создания корневого XML документа, комментария и инструкций обработки (processing instruction).

Для упрощения операторов XMLAttributes и XMLForest в QueryMachine добавлены конструкции выбора полей, не предусмотренные стандартом. Они работают аналогично соглашению выбора полей в обычном SQL:

        SELECT XMLForest(*) FROM Orders
SELECT XMLElement(elem,XMLAttributes(t.*, Quantity * 2 as DoubleQuantity)) FROM "Order Details" t

Другими словами, синтаксис SQL для QueryMachine позволяет в операторах XMLElement и XMLAttributes использовать символ * точно так же, как и в операторе select.

XML агрегация

Предыдущие рассмотренные примеры дают представление о генерации фрагментов XML, однако выбирают нужные данные в разрозненном виде. Предположим, что нам требуется собрать записи таблицы Orders в один большой документ. Очевидный запрос:

        SELECT XMLElement("order", XMLAttributes(*)) FROM Orders

выберет нам таблицу фрагментов, по одному элементу на каждую запись из Orders. Чтобы собрать все фрагменты в один документ, спецификация SQL/XML предусматривает специальную операцию – агрегацию XML. Эта операция в каком-то смысле похожа на обычную агрегацию, только вместо суммирования или нахождения максимума для числового значения, оператор XMLAgg строит список XML элементов, «схлопывая» обрабатываемое множество строк в одну строку результата, содержащую весь список. Таким образом, следующий запрос возвратит одну запись, содержащую список всех фрагментов XML:

        SELECT XMLAgg(XMLElement("order", XMLAttributes(*))) FROM Orders


Рисунок 4

Полученный результат все еще не является правильным документом XML (well-formed document), поскольку XML документ должен иметь единственный корневой элемент (document element). Но это не очень большая проблема, так как оператор XMLAgg строит список фрагментов находящихся внутри оператора и может использоваться вместе с другими операторами SQL/XML. Таким образом, следующий запрос построит для нас необходимый XML документ:

        SELECT XMLRoot(XMLElement(doc, XMLAgg(XMLElement("order", XMLAttributes(*)))))
   FROM Orders

Однако с оператором XMLAgg не все так просто, как может показаться на первый взгляд. Дело в том, что никакие ссылки на поля таблиц, участвующие в запросе не могут использоваться вне оператора XMLAgg, и, естественно, операторы XMLAgg не могут вкладываться друг в друга. Для того, чтобы понять суть проблемы, предположим, что требуется сформировать документ, содержащий записи таблицы Orders вместе с детализацией из Order Details вот такой структуры:

<doc>
  <order OrderID="10248" CustomerID="VINET" EmployeeID="5" ...>
    <detail OrderID="10248" ProductID="42" UnitPrice="9.8000" Quantity="10" />
    <detail OrderID="10248" ProductID="11" UnitPrice="14.0000" Quantity="12" />
    <detail OrderID="10248" ProductID="72" UnitPrice="34.8000" Quantity="5" />  
  </order>
  ...
</doc>

То есть требуется вложить в каждый элемент order соответствующие ему элементы из таблицы Order Details, связав их по внешнему ключу OrderID. Это означает, что в нашем запросе необходимо для каждой записи подзапросом сформировать вложенную таблицу, содержащую детальные записи, отбираемые по полям мастер-таблицы. Язык SQL допускает только скалярные подзапросы в выражениях, однако, так как мы используем XML агрегацию, подзапрос, обрабатывающий данные из Order Details, будет возвращать единственное значение – список элементов XML и таким образом будет допустимым с точки зрения SQL:

        SELECT XMLRoot(XMLElement(doc, 
    XMLAgg(XMLElement("order", 
        XMLAttributes(*),
            (SELECT XMLAgg(XMLElement(detail, XMLAttributes(*))) 
               FROM "Order Details" d 
               WHERE o.OrderID = d.OrderID))))) 
    FROM Orders o

Здесь внутри XML элемента order находится директива XMLAttributes, устанавливающая атрибутами этого элемента поля строки таблицы Orders, и подзапрос, выбирающий данные из таблицы Order Details как одно значение - агрегированный список элементов detail. Основной запрос и вложенный запрос связаны между собой через алиасы таблиц, аналогично тому, как связываются обычные вложенные подзапросы в части WHERE.

Завершая обсуждение XML-агрегации, следует упомянуть, что поскольку SQL применяет ORDER BY после вычисления SELECT, получается, что сортировка фрагментов XML возможна только самим оператором XMLAgg, ведь обычная сортировка будет применяться к результату агрегации, всегда состоящему из одной строки. Поэтому оператор XMLAgg предусматривает возможность указания полей сортировки:

        SELECT XMLAgg(XMLElement("order", XMLAttributes(*)) ORDERBY 1) 
    FROM Orders

Синтаксис такой сортировки совпадает с обычным, но производится она оператором XMLAgg.

Извлечение данных из документов XML

Описанная выше реализация SQL/XML в QueryMachine позволяет использовать ее как универсальный способ генерации XML данных, однако, с практической точки зрения, имеет небольшую ценность, так как основные СУБД уже включают подобные механизмы. Более интересной, с точки зрения автора, является обратная задача – извлечение табличных данных из существующих XML файлов.

Для иллюстрации сказанного, предположим, что в таблицу Orders требуется загрузить из XML документа, сформированного в предыдущем разделе, какие-то данные. Либо, к примеру, имеется несколько файлов, представляющих собой ордеры, переданные системой B2B через сервер интеграции, и требуется их проанализировать с целью отладки бизнес-процесса (скажем, проверить сумму и количество).

Эта задача встречается довольно часто и во всех известных автору системах решается с помощью проекции документа на необходимую структуру таблицы. Для использования проекции требуется определить множество XML-узлов, по которым будут создаваться строки таблицы, а затем для каждого узла, соответствующего строке – критерии отбора значений каждого поля. Таким образом, для построения таблицы мы разбиваем содержимое документа на строки, а затем каждую строку – на поля.


Рисунок 5

Например, для загрузки данных в таблицу Orders сопоставляем строкам – XML элементы order, для чего используем выражение XPath /doc/orders. Далее, предполагая использование каждого найденного элемента в качестве контекста, указываем простейшие XPath-выражения для отбора атрибутов {./@OrderID,./@CustomerID,...,./@ShipCountry}, значения которых присваиваем соответствующим полям каждой строки. Кроме критериев отбора, в карте преобразования, схематично показанной на рисунке 5, так же необходимы объявления типов каждого поля заполняемой таблицы.

Описанный выше подход реализуется в SQL/XML с помощью оператора XMLTable. Следующий пример 17-2 для СУБД Оракул приведен в [8] и дает представление об использовании этого оператора на практике:

        SELECT lines.lineitem, lines.description, lines.partid,
       lines.unitprice, lines.quantity
  FROM purchaseorder,
       XMLTable('for $i in /PurchaseOrder/LineItems/LineItem
                 where $i/@ItemNumber >= 8
                   and $i/Part/@UnitPrice > 50
                   and $i/Part/@Quantity > 2
                 return $i'
                PASSING OBJECT_VALUE
                COLUMNS lineitem    NUMBER       PATH '@ItemNumber',
                        description VARCHAR2(30) PATH 'Description',
                        partid      NUMBER       PATH 'Part/@Id',
                        unitprice   NUMBER       PATH 'Part/@UnitPrice',
                        quantity    NUMBER       PATH 'Part/@Quantity') lines

Данный пример иллюстрирует основной недостаток оператора XMLTable – необходимость перед написанием запроса анализировать исходный XML документ и детально расписывать поля создаваемой таблицы. Поэтому, несмотря на то, что технически с помощью проекций можно извлекать любые необходимые данные, на практике этот подход не получил широкого распространения.

Между тем, пользователям XML редакторов, таких как Altova XMLSpy или XmlPad, хорошо известна табличная форма документов, в которой удобно просматривать и изменять XML файлы без всяких предварительных определений. Идея проста – представлять последовательность одинаковых XML элементов в виде таблицы, в колонках которой по названию группируются значения атрибутов и содержимое совпадающих дочерних элементов. В тех случаях, когда фрагмент документа не позволяет использовать подобную группировку, последовательность элементов представляется как таблица из одной колонки и несколько строчек, соответствующих каждому такому элементу.

Скриншот табличной формы рассматриваемого файла OrderDetails.xml можно увидеть рис.6 и рис.7. Табличная форма этого документа является таблицей размерностью 2 x 1, первая строка которой – декларация <?xml?> , а вторая – корневой элемент doc. Ячейка, соответствующая корневому элементу, в свою очередь является таблицей размерностью 800 x 15. Эта таблица содержит сгруппированные значения атрибутов элементов order и колонку для дочерних элементов detail. Поскольку каждый элемент order содержит несколько элементов detail, эти ячейки так же могут содержать еще одну вложенную таблицу сгруппированных атрибутов (см. рис 7).


Рисунок 6


Рисунок 7

Приведённый выше пример отражает тот факт, что произвольный XML документ можно представить в виде однозначного набора вложенных таблиц. Следовательно, вместо определения проекций для извлечения табличных данных, мы можем автоматически построить соответствующий набор для любого XML документа и использовать его в запросе SQL, применяя детальное соединение для преобразования данных к требуемому виду. Эта идея появилась у автора при написании табличного представления для XmlPad [9] и через какое-то время была реализована в QueryMachine. Ниже будет показано, что этот способ извлечения таблиц является более простым, чем использование оператора XMLTable и позволяет писать компактные и простые запросы.

Чтобы извлечь данные из XML документа в QueryMachine достаточно использовать имя файла с префиксом XML. Полученная таблица будет сгруппирована, как описано выше, или содержать всего одну строку и набор колонок, соответствующий каждому узлу преобразуемой последовательности, если группировку выполнить невозможно. При этом корневой элемент документа (но не атрибуты корневого элемента) отбрасывается. Кроме того, QueryMachine автоматически определяет тип данных каждого поля, предполагая, что исходный XML документ придерживается форматов данных определенных в [10].

Например, следующий запрос сформирует таблицу Orders по файлу OrderDetail.xml:

        SELECT * FROM XML:OrderDetail

Результатом запроса будет представление, состоящее из полей для атрибутов элемента order, и поля detail в котором будет находиться экземпляр класса XmlNodeList – список дочерних элементов detail каждого элемента order. Чтобы выбрать все строки таблицы Order Details, записанные в файл, используем детальное соединение:

        SELECT t.* FROM XML:OrderDetail AS o, TABLE o.detail AS t

При выполнении этого запроса, QueryMachine сначала трансформирует в таблицу записи orders аналогично предыдущему запросу, затем трансформирует каждый получившийся список элементов из поля detail в отдельную детальную таблицу, после чего строит декартово произведение записей мастер таблицы на объединение множества строк каждой детальной таблицы. Таким образом, в QueryMachine трансформация XML документа происходит по мере необходимости, что обеспечивает приемлемую производительность на больших объемах данных.

Чуть более сложный запрос позволяет выбрать из файла OrderDetail.xml агрегированное количество продукции и общую стоимость по заказам в разрезе покупателя и номенклатуры:

        SELECT @CustomerID, @ProductID, SUM(@Quantity) AS Quantity, SUM(@Quantity * @UnitPrice) AS Total
  FROM XML:OrderDetail AS o, TABLE o.detail AS t GROUPBY @CustomerID, @ProductID

Обратим внимание, что наш запрос не требует объявления типов данных для полей, поскольку QueryMachine определяет их тип автоматически. Названия полей соответствующих атрибутам элементов имеют префикс «@» (коммерческое at) для того, чтобы в случае обработки элемента содержащего совпадающие по названию атрибуты и дочерние элементы их можно было бы различать.

Аналогичный запрос, выполняющийся в СУБД Оракул с помощью оператора XMLTable, требует использования XQuery и выглядит примерно так:

        SELECT CustomerID, ProductID, SUM(Quantity) AS Quantity, 
    SUM(Quantity * UnitPrice) AS Total 
  FROM XMLTable(
    'for $o in doc("orderDetail.xml")/doc/orderfor $d in $o/detail return
         <row CustomerID="{$o/@CustomerID}" 
              ProductID="{$d/@ProductID}" 
              Quantity="{$d/@Quantity}" 
              UnitPrice="{$d/@UnitPrice}"/>'
    PASSING OBJECT_VALUE
        COLUMNS CustomerID VARCHAR2(5) PATH '@CustomerID',
        COLUMNS ProductID  NUMBER      PATH '@ProductID',
        COLUMNS Quantity   NUMBER      PATH '@Quantity',
        COLUMNS UnitPrice  NUMBER      PATH '@UnitPrice')
  GROUPBY CustomerID, ProductID

Здесь, чтобы не использовать XMLTable два раза, отдельно для таблицы Orders и отдельно для Order Detail, мы выполняем декартово произведение с помощью XQuery и формируем набор промежуточных элементов row, которые оператором XMLTable преобразуем в таблицу и далее средствами SQL выполняем агрегирование и группировку. С точки зрения автора, второй запрос более сложен и менее понятен.

Механизм извлечения табличных данных QueryMachine может использоваться совместно с фильтрацией исходного XML документа (или произвольного фрагмента документа) с помощью выражений XPath. Для этого используется встроенная функция $extract, аргументом которой может быть имя XML документа, ссылка на колонку таблицы или произвольное вычисляемое выражение. Например, чтобы выбрать из рассматриваемого нами файла данных содержимое таблицы Order Details, достаточно выполнить следующий запрос:

        SELECT * FROMTABLE $extract(XML:orderDetail, '//detail')

Рассмотрим более сложный пример. Предположим, что мы имеем набор XML файлов содержащих заказы следующего вида:

<po:purchaseOrder orderDate="2001-01-01" xsi:schemaLocation="http://www.ibm.com PurchaseOrder.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:po="http://www.ibm.com">
  <shipTo country="US">
    <name>Alice Smith</name>
    <street>125 Maple Street</street>
    <city>Mill Valley</city>
    <state>CA</state>
    <zip>90952</zip>
  </shipTo>
  <billTo country="US">
    <name>Robert Smith</name>
    <street>8 Oak Avenue</street>
    <city>Old Town</city>
    <state>PA</state>
    <zip>95819</zip>
  </billTo>
  <po:comment>Hurry, my lawn is going wild!</po:comment>
  <items>
    <item partNum="872-AA">
      <productName>Lawnmower</productName>
      <quantity>1</quantity>
      <USPrice>148.95</USPrice>
      <po:comment>Confirm this is electric</po:comment>
    </item>
...
    <item partNum="ZXC-AB">
      <productName>Chip-n-Dale dancer</productName>
      <quantity>1</quantity>
      <USPrice>149.99</USPrice>
      <po:comment>For one hour only!</po:comment>
    </item>
  </items>
</po:purchaseOrder>

Приведенный выше файл purchaseOrder.xml – это стандартный пример, используемый компанией IBM для WebSphere.

Далее, предположим, что нам требуется выбрать общую сумму заказов, имеющих конкретный partNum и адресованных получателю, имя которого начинается с Alice. Используя функцию $extract и возможность использовать группы файлов в качестве источника данных, формулируем следующий запрос:

        --! <useSampleData/>
        SELECT SUM(i.USPrice) 
   FROM XML:"purchase*.xml" p, TABLE $extract(p.node, '//shipTo/*') AS t, 
        TABLE $extract(p.node, '//item') AS i 
   WHERE t.name LIKE 'Alice%' AND i.@partNum='872-AA'

Группа файлов XML:"purchase*.xml" в запросе отображается на таблицу p, содержащую два поля: name – имя файла и node – XML документ в виде экземпляра класса XmlDocument. Эта таблица с помощью детального соединения обрабатывается XPath выражениями, выделяющими получателя заказа из заголовка документа в таблицу t и позиции заказов как таблицу i. Результирующее множество строк является декартовым произведением строк {p} x {t} x {i} и является объединением данных всех файлов входящих в группу. После этого стандартными средствами SQL осуществляется фильтрация и агрегирование.

Кроме выражений XPath, QueryMachine так же можно использовать запросы XQuery. Выполнение запросов XQuery производиться с помощью отдельного процессора QueryMachine.XQuery детально описанного в [4]. Поддержка XQuery реализована стандартным оператором XMLQuery из спецификации XML/SQL. Этот оператор может использоваться как в части SELECT запроса SQL, так и во FROM внутри выражения TABLE. В последнем случае результаты выполнения запроса XQuery преобразуются в таблицу, так же как и при вычислении выражений XPath.

В качестве примера, рассмотрим запрос, который соберет содержимое заказов группы файлов purchaseOrder в один XML документ, добавив в каждый заказ имя файла, откуда он был прочитан.

        --! <useSampleData/>
        SELECT XMLRoot(
         XMLElement(root, 
      XMLAgg(XmlQuery(
        'declare variable $title as xs:string external;
       for $i in //item
         return 
           <item> 
           {$i/@partNum}
           {$i/productName}
           {$i/shipDate}
           {$i/quantity}
           {$i/USPrice}
           <comment>
              {$i/*:comment/text()}
           </comment>  
           <filename>{$title}</filename>
         </item>
      ' PASSING po.node, $getfilename(name) AS title))))
   FROM XML:"purchaseOrder_?.xml" po

Здесь к каждому выбранному XML документу группы, применяется запрос XQuery, трансформирующий документ к требуемому виду. Далее, полученные результаты аккумулируются в один список с помощью оператора XMLAgg обернутого в XMLRoot и XMLElement, которые порождают сам результирующий документ и его корневой элемент.

Обратим внимание, что документ и соответствующее имя файла передаются в запрос XQuery с помощью конструкции PASSING как контекст и внешняя переменная. Такая возможность является частью спецификации SQL/XML.

В заключение, приведем запрос, агрегирующий содержимое файла OrderDetail.xml при помощи оператора XmlQuery без использования детального соединения:

        SELECT @CustomerID, @ProductID, SUM(@Quantity) AS Quantity, 
    SUM(@Quantity * @UnitPrice) AS Total
  FROMTABLE XmlQuery(
    'for $o in doc("orderDetail.xml")/doc/orderfor $d in $o/detail return
         <row CustomerID="{$o/@CustomerID}" 
              ProductID="{$d/@ProductID}" 
              Quantity="{$d/@Quantity}" 
              UnitPrice="{$d/@UnitPrice}"/>')
  GROUPBY @CustomerID, @ProductID

Использование QueryMachine

Библиотека QueryMachine состоит из трех основных сборок: CoreServices.dll – компилятора Лисп, применяемого для динамических вычислений [4]; основной сборки QueryMachine.dll, реализующей процессор SQL и модули обработки данных; сборки Data.Remote.dll – модуль поддержки 32-битовых драйверов ADO.NET в 64-битовых приложениях. Для использования XQuery в запросах, также необходима сборка QueryMachine.XQuery.dll.

Выполнение запросов осуществляется с помощью класса DataEngine.ADO.Command, являющегося наследником стандартного класса System.Data.Common.DbCommand. Так как запросы QueryMachine выполняются в контексте вызывающего потока, класс System.Data.Common.DbConnection не используется. Регистрация и управление соединениями с ADO.NET источниками данных осуществляется классом DataEngine.DatabaseDictionary, экземпляр которого должен присваиваться свойству DatabaseDictionary класса Command при инициализации, по-аналогии со свойством Connection. SQL запросы QueryMachine допускают параметры, обозначаемые с помощью префикса & (amp):

      using System.Data.Common;
using DataEngine;
using DataEngine.ADO;
...

DatabaseDictionary dictionary = new DatabaseDictionary();
dictionary.RegisterDataProvider("ORA", false, "System.Data.OracleClient", "Data Source=TNP;User ID=NORTHWND;Password=****;Unicode=True");
dictionary.RegisterDataProvider("MSSQL", false, "System.Data.SqlClient", "Data Source=.\\SQLEXPRESS;AttachDbFilename=NORTHWND.MDF;"+
    "Integrated Security=True;Connect Timeout=30;User Instance=True");
Command command = new Command();
command.DatabaseDictionary = dictionary;
command.CommandText = "SELECT * FROM ORA:ORDERS NATURAL JOIN MSSQL:\"Order Details\""+
   "  WHERE customerid = &customerID";
command.Parameters.AddValue("&customerID", "VINET");
DbDataReader reader = command.ExecuteReader();
while (reader.Read())
  {
  ...
  }
reader.Close();

Здесь с помощью метода RegisterDataProvider класса DatabaseDictionary описаны соединения с сервером Oracle и SQL Server. Эти соединения назначены префиксам ORA и MSSQL соответственно. Подготовленный таким образом DatabaseDictionary передан классу Command через одноименное свойство. Далее установлено свойство CommandText, связан параметр customerID и вызван стандартный метод ExecuteReader().

Класс DatabaseDictionary так же позволяет настроить путь, в котором система будет искать файлы данных, если в запросе не указано полное имя файла. Свойство DatabaseDictionary.SearchPath содержит список каталогов, разделяемый точкой с запятой, в которых будет производиться поиск файлов.

В QueryMachine встроена функциональность, позволяющая использовать в 64-битовых приложениях 32-битовые драйвера ADO.NET. Дело в том, что многие источники данных, например Microsoft Jet 4.0, необходимый для чтения файлов Excel и формата DBF, являются драйверами OLE/DB, поддерживающими только 32-битовые приложения. Для того чтобы обеспечить возможность использования таких источников данных, QueryMachine создает отдельный 32-битовый процесс ADOHost.exe, который выполняет обращения к драйверам OLE/DB. Обмен данными между приложением, использующим QueryMachine и процессом ADOHost.exe происходит средствами System.Data.Remote по протоколу IPC. В случае, если QueryMachine используется в 32-битовом приложении, указанные драйвера вызываются напрямую.

Встроенный парсер плоских файлов для определения форматов данных, названий и типов полей использует отдельный файл настроек schema.ini, который должен располагаться в том же каталоге, что и файлы данных. По формату он совместим с schema.ini используемым Майкрософт для Text File Driver, который описан в [11]. Этот файл состоит из секций соответствующих каждому файлу и набору ключей, описывающих настройки. Например:

[1.txt]
Format=TabDelimited
ColNameHeader=True
Col1=@partNum Text
Col2=productName Text
Col3=quantity Double
Col4=USPrice Double
Col5="po:comment" Text
Col6=shipDate DateTime

Утилита QmConsole при выполнении экспорта данных автоматически формирует настройки соответствующие выгружаемым данным. Кроме ключей, описанных в [11], файл schema.ini так же может содержать дополнительные ключи, описанные в следующей таблице.

Название Описание Значение по умолчанию

NullValue

Выделенное обозначение для пустого поля.

нет

Encapsulator

Определяет разделитель полей, используемый в файлах формата CSV, в случаях, когда поле содержит символы разделителя и переводы строк.

“ (quote)

Escape

Включает обработку Escape-последовательностей по соглашениям оператора LOAD DATA mySQL. Более подробное описание [12]. Значением ключа является символ, используемый в качестве escape.

Нет

SequentialProcessing

Отменяет использование распараллеливания при чтении файла. При использовании распараллеливания не допускаются записи, состоящие из нескольких строк. По умолчанию распараллеливание используется во всех случаях, когда не включена обработка Escape-последовательностей.

false

Секции файла schema.ini также могут назначаться группе файлов. В этом случае в названии секции указывается шаблон, содержащий символы *(star) и ?(question).

Например, следующие настройки могут быть использованы для обработки группы лог-файлов ISA Server:

[*FWS_000.w3c]
Format=TabDelimited
ColNameHeader=True
MaxScanRows=0
CharacterSet=ANSI
NullValue=-
Col1=computer Text
Col2=date Text
Col3=time Text
Col4="IP protocol" Text
Col5=source Text
Col6=destination
Col7="original client IP" Text
Col8="source network" Text
Col9="destination network" Text
Col10=action Text
Col11=status Text
Col12=rule Text
Col13="application protocol" Text
Col14="bytes sent" Text
Col15="bytes sent intermediate" Text
Col16="bytes received" Text
Col17="bytes received intermediate" Text
Col18="connection time" Text
Col19="connection time intermediate" Text
Col20=username Text
Col21=agent Text
Col22="session ID" Text
Col23="connection ID" Text

Обзор реализации

Хотя библиотека QueryMachine и не является полноценной СУБД, ее архитектура в целом использует подходы, применяемые в обычных базах данных. Соответствующая теория подробно изложена в классической монографии К. Дж. Дейта [13]. Кроме того, хотелось бы отметить блог К. Фридмана [14], который содержит весьма детальное описание основных алгоритмов, использованных командой разработчиков SQL Server, и блог компании Оракул, посвященный оптимизатору СУБД Оракул [15].

Основной идеей, положенной в реализацию QueryMachine (впрочем, как и любой другой СУБД), является трансляция запроса на языке SQL в последовательность шагов, выполняющих основные реляционные операции с табличными данными, такими как проекция, отбор, соединение, агрегация, сортировка, объединение и пересечение. Поскольку соединение, объединение и пересечение являются бинарными операциями – получающееся выражение является деревом.


Рисунок 8

На рис.8 представлен пример запроса и соответствующее ему дерево операций. Реляционные операции на схеме представлены прямоугольниками, а порядок вычислений – стрелками. Для упрощения понимания, вычисления можно представлять как конвейер, на каждом шаге которого обрабатывается промежуточная таблица. То есть, возьмем таблицу Orders, применим к ней отбор, получившуюся таблицу соединим с Order Details, и так далее.

В действительности, передавать данные между операциями в виде таблиц неправильно, так как многие операции подразумевают потоковую обработку. Например, при выполнении отбора мы последовательно читаем исходную таблицу и фактически копируем в таблицу результата каждую запись, удовлетворяющую заданному критерию. Другой пример – оператор COUNT(*), который вообще отбрасывает все записи исходной таблицы, возвращая в качестве результата таблицу, содержащую единственную запись. Таким образом, получается, что буферизовать какие либо данные во многих случаях является напрасной тратой ресурсов. Более того, буферизация автоматически означает ограничение на максимальный размер обрабатываемых таблиц, поскольку количество оперативной памяти не является безграничным.

Поэтому в большинстве реализаций между узлами дерева реляционных операций передаются не таблицы целиком, а потоки их записей. При этом каждая операция, по-возможности, должна использовать стратегию отложенных вычислений. Применительно к рассмотренной выше операции отбора это означает, что мы останавливаемся и возвращаем результат всякий раз, когда находим запись, удовлетворяющую заданному условию, давая возможность вышестоящему узлу дерева операций принять решение о необходимости дальнейшей обработки.

С другой стороны, во многих случаях нам все-таки приходиться материализовывать таблицы. В основном это происходит для выполнения сортировки, заданной оператором ORDER BY, или при необходимости использования алгоритма соединения таблиц merge-join. Настоящие «взрослые» СУБД имеют возможность выполнять внешнюю сортировку, не накладывая ограничений на размер обрабатываемых таблиц, связанный с объемом доступной оперативной памяти. Например, СУБД Оракул для этой цели использует сегменты временных данных. QueryMachine не реализует внешнюю сортировку и буферизует все данные целиком, однако при выполнение merge-join, где это возможно, использует сортировку на уровне внешнего источника данных. Например, в рассматриваемом выше примере, таблицы Orders и Order Detail выбираются отсортированными по полю orderid.

Следует заметить, что на самом деле QueryMachine использует чуть более сложную схему обмена данными, которая позволяет избежать излишнего копирования при выполнении фильтрации и соединения. Обратимся к рис.8. После выполнения операции соединения и до выполнения проекции получающаяся виртуальная таблица должна содержать поля обеих соединяемых таблиц. Рассуждая прямолинейно, мы должны создать новый тип записи и скопировать туда все значения полей, поскольку на этом шаге не известно, какие из них используются в дальнейшем. Вместо этого, мы создаем особый тип записи – контейнер, содержащий всего два поля – ссылки на первоначальные записи каждой из таблиц. Таким образом, при выполнении композиции соединений мы копируем содержимое левого и правого контейнера, а не полей исходных таблиц, что позволяет нам на порядок снизить объем копируемых данных. Фильтрация, проекция и агрегирование могут обрабатывать результаты соединения таблиц, поэтому также используют контейнеры.

Механизм обмена данными между операциями в QueryMachine реализован классом Resultset, а сами операции – наследниками класса QueryNode.

      public
      abstract
      class QueryNode
{
    public QueryNode();

    publicvirtualvoid Prepare();
    
    publicabstract Resultset Get(QueryContext queryContext, object[] parameters); 
    
    public QueryNode Parent { get; }
    public QueryNodeCollection ChildNodes { get; }

    public QueryNode GetNodeByID(object ID);
    publicobject NodeID { get; set; }
    
    ...
}

Класс QueryNode содержит свойства Parent и ChildNodes, позволяющие строить иерархию операций, свойство NodeID, используемое для идентификации подзапросов из Lisp выражений, и основной метод Get(), возвращающий результат обработки в виде экземпляра Resultset. Класс Resultset по-существу является очередью записей, способной при необходимости буферизовать данные. Причем, заполнение этой очереди обычно производится по мере чтения данных с помощью наследника класса QueryNode.DemandProcessingContext. То есть большинство операций реализовано таким образом, что вызов метода Get() возвращает незаполненный Resultset, который на этом этапе содержит только метаданные. Далее, при попытке получить очередную запись связанный с операцией экземпляр Resultset выполняет необходимые вычисления, помещает их результат в очередь и возвращает управление. Таким образом, выполнение реляционных операций организовано в виде конвейера записей, неявно включающегося каждый раз при попытке чтения самого верхнего Resultset.

Условимся называть структуру взаимосвязанных классов QueryNodeпланом выполнения SQL запроса. Элементами плана выполнения запроса, кроме реляционных операторов, также выступают узлы – источники данных, являющиеся листьями дерева операций. Такие узлы фактически представляют в виде Resultset содержимое плоских файлов или результат выполнения запроса SQL к внешней базе данных.

Следующая таблица содержит описание элементов плана выполнения запросов, реализованных в QueryMachine:

Название класса Арность Описание

AdoTableAccessor

0

Представляет в виде Resultset таблицу System.Data.DataTable.

DataAggregator

1

Выполняет агрегацию данных для реализации SQL операторов SUM, MIN, MAX, COUNT, AVG, XmlAgg.

DataCollector

1

Оборачивает запись исходного Resultset в контейнер для дальнейшего использования в DataFilter, DataJoin, DataSelector, DetailJoin, UnionJoin и DataAggregator.

DataConnector

2

Выполняет теоретико-множественные операции соответсвующие SQL операторам UNION, UNION ALL, EXCEPT, INTERSECT.

DataFilter

1..n

Выполняет фильтрацию данных в операторах WHERE и HAVING

DataJoin

2

Выполняет операцию соединения таблиц. Поддерживаются следующие алгоритмы: cartesian-join, merge-join, hash-join и distributed-join.

DataProviderQueryAccessor

0

Представляет в виде Resultset результат выполнения SQL запроса к внешней базе данных. SQL запрос задается в виде текстовой строки, формируемой оптимизатором.

DataProviderTableAccessor

0

Представляет в виде Resultset результат выполнения SQL запроса к внешней базе данных. SQL запрос строиться автоматически по заданному имени таблицы, дополнительному условию фильтрации и полям сортировки.

DataSelector

1..n

Выполняет проекцию данных, т.е. вычисляет выражение SQL оператора SELECT.

DataSorter

1

Выполняет операцию сортировки, заданной SQL оператором ORDER BY или транслятором для алгоритма merge-join.

DetailJoin

2..n

Выполняет операцию детального соединения.

DualNode

0

Создает «dual» таблицу (aka Oracle), содержащую одну строку и одно поле.

FlatFileAccessor

0

Представляет в виде Resultset файл данных или группу файлов данных по имени или маске. Возвращаемые записи содержат экземпляры System.IO.Stream

TextDataAccessor

1

Выполняет парсинг плоских файлов. Исходные файлы представляются в виде потоков, создаваемых FlatFileAccessor.

UnionJoin

2

Реализует оператор SQL-92 UNION JOIN.

XmlDataAccessor

1

Выполняет парсинг и преобразование в каноническую табличную форму XML файлов. Исходные файлы представляются в виде потоков, создаваемых FlatFileAccessor.

Динамические вычисления, необходимые для выполнения отбора и проекции осуществляется встроенной Лисп-машиной аналогично реализации XQuery. На этапе трансляции части запроса SQL, соответствующие подвыражениям SELECT, WHERE (HAVING) и FROM TABLE, преобразуются в лисп-формы, которые обрабатываются узлами DataSelector, DataFilter и DetailJoin. При наличии подзапросов в операторе SQL в эти узлы также включаются соответствующие дополнительные ветви плана выполнения.

Следует заметить, что в отличие от XQuery, для SQL не нужно реализовывать механизм локальных переменных. Поэтому план выполнения запроса не требуется представлять единым функциональным выражением и каждый узел, осуществляющий вычисления, может использовать собственный экземпляр лисп-машины (класс DataEngine.CoreServices.Executive). Подробное описание используемой реализации Лиспа как платформы для динамических вычислений опубликовано в [4].

Приведенный ниже фрагмент трассировки получен с помощью метода QueryNode.Dump() и позволяет увидеть фактический план выполнения запроса показанного на рис.8.

DataSorter "Total"
  DataSelector
   >o.OrderID
   >[Total]:(- (* UnitPrice Quantity) Discount)
    DataFilter (eqx o.customerid "VINET")
      DataJoin Inner,o.OrderID == d.OrderID
        DataCollector
          DataProviderTableAccessor System.Data.OracleClient, ORDERS (eqx o.customerid "VINET")
        DataCollector
          DataProviderTableAccessor System.Data.SqlClient, [ORDER DETAILS]

Корневым узлом рассматриваемого плана выполнения запроса является экземпляр класса DataSorter, осуществляющий сортировку результатов по возрастанию поля Total. Дочерним элементом этого узла является экземпляр класса DataSelector, формирующий таблицу результата и вычисляющей лисп-форму для Total. Подчиненным узлом для DataSelector является узел DataFilter, осуществляющий отбор результатов соединения таблиц, выполняемых шагом ниже узлом DataJoin. Этот узел имеет две дочерние ветви, в каждой из которых находится экземпляр DataCollector подготавливающий контейнерные записи. Терминальными узлами плана выполнения являются экземпляры DataProviderTableAccessor осуществляющие чтение данных из СУБД Oracle и SQL Server.

Обратим внимание, что отбор записей осуществляется после выполнения соединения, что на первый взгляд является крайне неэффективным решением. Однако на самом деле, такой же критерий отбора присутствует и в качестве параметра соответствующего узла DataProviderTableAccessor. Этот отбор реализуется генерацией условия в запросе SQL к источнику данных и таким образом уже перед выполнением соединения данные таблицы Orders удовлетворяют заданному критерию. Описанная ситуация происходит из-за особенностей оптимизатора, который с целью упрощения реализации вместо преобразования исходных логических выражений производит их анализ, и, где это возможно, накладывает дополнительные ограничения на сами источники данных. Причем, эти ограничения могут быть более слабыми, чем первоначальное условие, поэтому в общем случае и требуется двойная проверка условий: на источнике данных и в самой QueryMachine. Некоторая избыточность этого подхода с лихвой компенсируется уменьшением объема обрабатываемых данных и возможностью косвенно использовать индексацию на стороне СУБД.

В целом подготовку и выполнение запроса SQL QueryMachine можно разделить на следующие основные фазы:

  1. Парсинг запроса и создание внутреннего семантического графа;
  2. Оптимизация запроса;
  3. Связывание подзапросов;
  4. Трансляция семантического графа в план выполнения;
  5. Вызов метода Get() корневого узла плана выполнения запроса.

Детальное описание выбранного внутреннего представления семантики, механизмов разбора и трансляции выходит за рамки настоящей статьи. QueryMachine использует таблично-управляемый восходящий алгоритм разбора на основе грамматики LR(1), опубликованной [5]. Применяемый алгоритм разбора подробно описан в [16]. Результатом разбора оператора SQL является экземпляр класса Notation, который последовательно обрабатывается на этапе оптимизации и связывания, а затем транслируется в план выполнения запроса. Особенностью реализации является то, что внутреннее представление запроса в любой момент может быть преобразовано обратно в SQL с помощью класса SqlWriter. При этом указания оптимизатора и связи отображаются с помощью специального синтаксиса. QueryMachine не имеет доступа к внутренней статистике и индексам таблиц, поэтому применяемый оптимизатор является ориентированным на правила (rule-based optimizer) и выполняет следующие функции:

  1. Определение запросов, которые целиком могут быть выполнены источником данных. Запросы, использующие таблицы одного источника данных и не содержащие специфических конструкций, транслируются в диалект SQL, поддерживаемый источником данных, и выполняются на стороне СУБД.
  2. Устранение неоднозначности оператора «.» (точка). В QueryMachine оператор «.»(точка) может использоваться как для выделения полей по именам таблиц, так и для получения компонентов структур. Поэтому, чтобы определить, какая из операций подразумевается, необходимо проанализировать составной идентификатор в контексте каждого подзапроса.
  3. Приведение агрегирующих запросов к каноническому виду, при котором внутри агрегирующей функции отсутствуют вычисляемые выражения. То есть, запрос вида SELECT SUM(A+B) … преобразуется к виду SELECT SUM(X) … FROM (SELECT A+B …
  4. Приведение к каноническому виду соединений используемых в запросе, с тем, чтобы на стадии трансляции использовать специализированный алгоритм. Например, запрос вида SELECT … FROM T1, T2 WHERE T1.K = T2.K будет преобразован к виду SELECT … FROM T1 INNER JOIN T2 ON T1.K = T2.K.
  5. Анализ условий с целью задания ограничений на источник данных. Как уже обсуждалось выше, данный механизм, где это возможно обеспечивает фильтрацию на уровне СУБД.
  6. Вычисление константных выражений. Арифметические выражения с константами обрабатываются на уровне оптимизатора.
  7. Обработка дополнительных опций запроса (optimizer-hints). Дополнительные опции запроса указываются в виде специально сформатированного комментария и управляют настройками кэширования, трассировки, а также позволяют объявлять префиксы пространств имён, используемых в XPath выражениях.

Таким образом, результатом работы оптимизатора является трансформированный SQL запрос, содержащий дополнительные реквизиты, управляющие его трансляцией. Следует заметить, что в настоящий момент QueryMachine не делегирует подзапросы на источники данных. То есть, либо весь запрос будет выполнен источником данных, либо будет сгенерирован план выполнения, обрабатывающий все подзапросы, даже если какие-либо из них могли быть выполнены на уровне СУБД.

Для последующей обработки запроса QueryMachine перед выполнением трансляции осуществляет связывание подзапросов, которое сводится к анализу и замене в подзапросах полей внешних (относительно подзапроса) таблиц на параметры. Следующий рисунок показывает результат оптимизации и связывания запроса, содержащего коррелирующий подзапрос:


Рисунок 9

Здесь мы видим, что ссылка на колонку o.OrderID из внешнего запроса после связывания заменена на позиционный параметр $1, определяемый реквизитом bind. Подготовленный таким образом запрос будет транслирован в приведенный ниже план выполнения. Как видно из трассировки, узел DataFilter, соответствующий основному запросу, также содержит дополнительную ветвь для подзапроса. В лисп-форме он идентифицируется атомом _SQuery2. Функция squery, вызываемая для каждой строки основного запроса, находит нужную ветвь плана выполнения по свойству NodeID и вызывает подзапрос с заданными значениями параметров, возвращая результат в виде экземпляра класса Resultset в функцию exists,которая и проверяет условие отбора. Реквизит оптимизатора filter при трансляции накладывает на источник данных дополнительное условие фильтрации.

Также отметим, что классом ResultsetCache в QueryMachine реализовано внутреннее кэширование результатов выполнения подзапросов. Иногда этот механизм существенно ускоряет обработку, однако все равно уступает методу, применяемому в «больших» СУБД – преобразованию исходного запроса с использованием соединения semi-join [14].

DataSelector 
 *
  DataFilter (exist (squery _SQuery2 (list o.OrderID)))
    DataCollector
      DataProviderTableAccessor System.Data.SqlClient,[ORDERS]
    DataSelector
     >OrderID
      DataFilter (eqx d.OrderID (pref 1))
        DataCollector
             DataProviderTableAccessor System.Data.SqlClient,[ORDER DETAILS] (eqx d.OrderID (pref 1))

Поскольку на стадии оптимизации мы не извлекаем из используемых СУБД метаданные, выбор алгоритма соединения происходит непосредственно узлом DataJoin при вызове метода Get(), который использует один из следующих реализованных алгоритмов: cartesian-join, merge-join, hash-join и distributed-join.

Метод distributed-join придуман автором для тех случаев, когда требуется соединить очень большую и сравнительно малую таблицы, причем большая таблица находится в СУБД, поддерживающей SQL. Например, требуется соединить большую таблицу Оракул, содержащую пару миллионов записей, с файлом данных, представляющим собой список ключей в несколько сотен записей. Использование любого из традиционных алгоритмов требует как минимум одного полного прохода по большой таблице, что является совершенно неприемлемым. Вместо этого QueryMachine разбивает записи малой таблицы на блоки по 64 записи, с помощью SQL выбирает для каждого блока соответствующие критерию соединения записи большой таблицы, а затем соединяет полученные данные картезианским (декартовым) произведением.

Выбор алгоритма соединения происходит по следующим правилам:

  1. Если критерии соединения не заданы – используется cartesian-join;
  2. Если хотя бы одна таблица поддерживает SQL, удалось произвести эвристическую оценку размеров таблиц, и оценка указывает на возможность использования распределенного соединения – используется алгоритм distributed-join;
  3. Если хотя бы одна таблица поддерживает SQL и есть возможность указать источнику данных поля сортировки, соответствующие критериям соединения – используется алгоритм merge-join;
  4. Во всех остальных случаях используется алгоритм hash-join.

Описанная выше стратегия нуждается в некоторых пояснениях. Во-первых, для выполнения соединений в наихудшем случае всегда требуется буферизация, так как декартово произведение с последующей фильтрацией на существенных объемах данных работает неприемлемо медленно (можно принять, что не работает вообще). Во-вторых, учитывая, что в QueryMachine не реализована внешняя сортировка, метод merge-join целесообразно использовать только если одна из таблиц уже отсортирована источником данных; во всех остальных случаях более предпочтителен hash-join.

Оценка размера таблицы, хранящейся в СУБД, требует учета специфики каждой конкретной базы данных. Действительно, самый очевидный способ – выполнить запрос SELECT COUNT(*) FROM… является самым неправильным, так как во многих СУБД будет требовать сканирования всей таблицы, и таким образом сильно замедлит обработку (в частности так работает Оракул). Оценка размера таблиц по метаданным или статистике оптимизатора требует относительно сложных настроек и специальных полномочий доступа к СУБД, что так же не всегда возможно. Поэтому QueryMachine не пытается вычислить точное количество записей в каждой из таблиц, а лишь оценивает, что одна таблица существенно больше другой.

Идея проста. Практически для каждой СУБД существует синтаксис SQL, позволяющий ограничить количество записей, обрабатываемых запросом. Поэтому, мы можем попытаться подсчитать записи в таблице, ограничив их максимально возможное количество. Например:

      -- Oracle
      SELECT
      COUNT(*) FROMTableWHERE ROWNUM < 1000

Далее, мы последовательно пробуем пороги в 10000, 50000 и 150000 записей для каждой из таблиц, используемых в соединении, и либо устанавливаем, что одна из них существенно больше другой, либо считаем, что обе таблицы большие и наш метод не приемлем. При этом, в случае, когда только одна из таблиц поддерживает SQL, для оценки размера внутренней таблицы мы просто буферизуем ее содержимое и определяем точное количество записей.

Специфические настройки QueryMachine для каждого источника данных хранятся в конфигурационном файле SQLX.Config.xml. Этот файл содержит небольшое количество настроек, которые невозможно извлечь из провайдера ADO.NET. Следующая таблица документирует используемые элементы настроек:

Имя Описание Пример (MSSQL)

qualifer

Символ-разделитель компонентов имени в SQL. Используется для генерации составных имен ([каталог[.схема].]имя_таблицы)

. (точка)

stringSeparator

Символ-разделитель строковых констант

' (апостроф)

leftQuote

Символ, обозначающий начало идентификатора (имени). Используется при необходимости в зависимости от метаданных, предоставляемых провайдером ADO.NET

[ (левая квадратная скобка)

rightQuote

Символ, обозначающий конец идентификатора (имени)

] (правая квадратная скобка)

parameterMarkerFormat

Формат именованных параметров в запросе SQL, если именованные параметры поддерживаются. Значением параметра является строка форматирования для функции String.Format

@{0}

dateFormat

Формат констант для типа System.DateTime, используемых в запросе SQL. QueryMachine преобразует все даты методом ToString("u") после чего они форматируются вызовом функции String.Format со строкой форматирования, указанной в параметре

{{ts '{0}'}}

rowCountQuery

Запрос для оценки размера таблиц. Если параметр не указан – источник данных не используется для distributed-join. Значением параметра является строка форматирования для функции String.Format. Первый параметр – количество записей, второй – имя таблицы, сформатированное в соответствии с используемым диалектом SQL

SELECT COUNT(*) FROM
(SELECT TOP({0}) 1 AS Expr1
FROM {1}) AS SQuery

updateBatchSize

Размер пакета при блочной записи

100

Файл настроек используется классом DataProviderHelper, предназначенным для форматирования литералов и идентификаторов в соответствии с диалектом SQL, поддерживаемым заданным провайдером ADO.NET. Если система не находит указанный файл в каталоге сборки QueryMachine.dll, загружается файл из ресурса, содержащий настройки для Oracle, MSSQL, MS JET 4.0 и MySQL. Указанная информация используется совместно с метаданными, предоставляемыми провайдером через вызовы GetSchema("DataSourceInformation") и GetSchema("ReservedWords").

Заключение

QueryMachine изначально задумывался как инструмент для извлечения табличных данных из XML файлов для XMLPad. Однако в процессе разработки, на взгляд автора, получилась целая платформа, способная в комбинации с XQuery решать разнообразные задачи. Не претендуя на какие-то инновации, хотелось бы отметить, что представленная реализация является весьма компактным и функциональным решением и практически не имеет аналогов.

Ссылки

  1. Предметно-ориентированный язык программирования – материал из Википедии — свободной энциклопедии.
  2. WQL (SQL for WMI) – сайт компании Майкрософт.
  3. HQL: The Hibernate Query Language – сайт компании JBoss.
  4. Чертков С.А: QueryMachine.XQuery: Реализация XQuery 1.0 для платформы .NET, RSDN Magazine #4-2009.
  5. BNF Grammar for ISO/IEC 9075:1992 - Database Language SQL (SQL-92) — home page of Ron Savage.
  6. Database Language SQL — (Second Informal Review Draft) ISO/IEC 9075:1992, Database Language SQL- July 30, 1992.
  7. July, 2004, SQL/XML Final Committee Draft, FCD ISO/IEC 9075-14:2005 (E) – сайт SQLX.org.
  8. Oracle® XML DB Developer's Guide, Part Number B14259-02 – сайт компании Oracle.
  9. WMHelp.com – сайт проекта XmlPad.
  10. XML Schema Part 2: Datatypes Second Edition, сайт World Wide Web Consortium (W3C).
  11. Schema.ini File (Text File Driver), сайт компании Майкрософт.
  12. 12.2.6. LOAD DATA INFILE Syntax, документация сервера mySQL.
  13. К. Дж. Дейт: Введение в системы баз данных. Восьмое издание. Виллиамс, 2008. ISBN 978-5-8459-0788-2.
  14. Craig Freedman's SQL Server Blog – сайт MSDN компании Майкрософт.
  15. Oracle Optimizer, Insight into the workings of the Optimizer – сайт компании Оракул.
  16. Альфред Ахо, Рави Сети, Джеффри Ульман: "Компиляторы: Принципы, Технологии, Инструменты".


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.