Разработка парсера, кодогенератора и редактора SQL с помощью EMFText

  • Tutorial


Это 6-я статья цикла по разработке, управляемой моделями. В прошлой статье вы получили общее представление о разработке предметно-ориентированных языков с помощью EMFText. Настало время перейти от игрушечного языка к более серьёзному. Будет очень много рисунков, кода и текста. Если вы планируете использовать EMFText или подобный инструмент, то эта статья должна сэкономить вам много времени. Возможно, вы узнаете что-то новое о EMF (делегаты преобразований).

Подобно отважному хоббиту мы начнём свой путь с BNF-грамматики SQL, дойдём до жуткого дракона (метамодели) и вернёмся обратно к грамматике, но уже другой…

Введение


Сегодня мы разработаем парсер, кодогенератор и редактор SQL. Кодогенератор потребуется в следующей статье.

Вообще, SQL очень сложный язык. Гораздо сложнее, чем, например, Java. Чтобы убедиться в этом сравните грамматики Java и SQL. Это просто жесть. Поэтому в статье мы реализуем маленький фрагментик SQL – выражения для создания таблиц (CREATE TABLE), причем, не полностью.

1 Настройка


Как обычно, понадобится Eclipse Modeling Tools. Установите последнюю версию EMFText отсюда http://emftext.org/update_trunk.

2 Создание проекта


Вы можете либо взять готовый проект, либо создать новый (File -> New -> Other… -> EMFText Project).



В новом проекте в папке model уже созданы заготовки для метамодели (sql.ecore) и грамматики (sql.cs) разрабатываемого языка, с которыми вы познакомились в прошлой статье. Два этих файла полностью описывают язык. Почти всё остальное генерируется из них.

Удалите все классы и типы из метамодели, они нам не понадобятся. Редактировать метамодель можно либо в древовидном редакторе, либо в редакторе диаграмм. Чтобы создать диаграмму для метамодели выберите File -> New -> Other… -> Sirius -> Representations File. Выберите инициализацию диаграммы из существующей модели (sql.ecore). Выберите точку зрения «Design». Переключитесь на перспективу Sirius (Window -> Perspective -> Open Perspective -> Other…). Откройте созданный aird-файл. Создайте для пакета sql диаграмму классов.



Примечание

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

Также вы можете открыть Ecore-модель с помощью редактора OCLinEcore. Он текстовый, как и Xcore, только вместо Java используется OCL. Кстати, в метамодели одно вычисляемое свойство и одно правило контроля как-раз написаны с помощью OCLinEcore, но это уже тема для отдельной статьи.

Словом, для работы с метамоделью у вас есть 4 редактора на выбор :) Древовидный, диаграммный, текстовый Java-ориентированный, текстовый OCL-ориентированный.

3 Подходы к разработке метамодели языка


Есть два пути создания метамодели предметно-ориентированного языка: 1) от предметной области или 2) от синтаксиса.

В первом случае вы пытаетесь представить, что должен описывать язык. Например, язык из прошлой статьи описывает сущности, типы данных, свойства. А язык из этой статьи описывает якоря, связи, узлы, атрибуты. Для каждого вида объектов вы создаёте в метамодели соответствующий класс. А затем описываете для этих классов текстовую нотацию. Иными словами, это путь от семантики к синтаксису.

Во втором случае вы анализируете грамматику языка, смотрите как на этом языке описывается предметная область. Для каждого нетерминального символа создаёте в метамодели класс. Метамодель получается очень большая, избыточная и сложная. Вы отбрасываете из неё всё лишнее, оптимизируете её и в итоге получаете более-менее адекватную метамодель. Иными словами, это путь от синтаксиса к семантике.

Первый путь выглядит более правильным, но он опасен тем, что метамодель может получиться слишком оторванной от языка. Например, что описывает SQL? Лично я был убеждён, что это язык о таблицах, столбцах, представлениях, ограничениях, ключах,… – об объектах, которые описаны в конце спецификации SQL в разделах Information Schema и Definition Schema. Логично было бы создать в метамодели языка соответствующие классы: таблица, столбец и т.п. Но это неправильно, потому что в итоге мы получим структуру метаданных реляционной СУБД, а не структуру операторов языка SQL.

Таблицы, столбцы, ограничения как таковые в SQL отсутствуют. Вместо них есть операторы создания, удаления, изменения этих объектов. Соответственно в метамодели вместо класса «Таблица», должны быть классы «Оператор создания таблицы», «Оператор удаления таблицы», «Оператор изменения таблицы», …

Этим SQL принципиально отличается от простого декларативного языка из предыдущей статьи. Принято считать SQL декларативным языком, в отличие от, например, Java. Но, блин, на Java я могу написать:

public class User {
    public int id;
    public String name;
}

А на SQL я не могу описать таблицу декларативно, а могу только вызвать оператор создания таблицы:

CREATE TABLE "user" (
    id INT CONSTRAINT user_pk PRIMARY KEY,
    name VARCHAR(50) NOT NULL
);

Или несколько операторов:

CREATE TABLE "user" (
    id INT CONSTRAINT user_pk PRIMARY KEY
);
ALTER TABLE "user" ADD COLUMN name VARCHAR(50) NOT NULL;

Из-за этого валидация SQL-скриптов усложняется. Мы сначала можем создать таблицу, потом удалить её, потом создать другую таблицу с таким же именем. Представьте, если бы в Java можно было разопределять переменные, изменять у них тип данных или добавлять/удалять свойства классов! Как вообще валидировать такой код?

Примечание

Наверное, это не делает SQL языком с динамической типизацией. Я уже чувствую, как в меня летят помидоры за «императивный и динамически типизируемый» SQL :) Но попробуйте реализовать парсер или редактор SQL-скриптов и вы придёте к тому, что это равнозначно реализации императивного и динамически типизируемого языка. Чтобы определить допустимые имена таблиц или столбцов, парсеру приходится фактически интерпретировать код, но без изменения реальной БД. В данной статье эта интерпретация реализована максимально просто (в классах, отвечающих за разрешение ссылок, описанных далее), в реальности всё сложнее. Некоторые идеи в части механизма разрешения ссылок можно почерпать из JaMoPP.

4 Разработка метамодели SQL


Итак, первый путь к метамодели хорош для декларативных языков типа EntityModel или Anchor. А придти к метамодели SQL нам проще вторым путём. Мы проанализируем небольшой фрагмент грамматики SQL и создадим для него необходимые классы.

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

  1. Если в правой части правила «сложная» последовательность символов, то для нетерминального символа из левой части правила создаём класс.
  2. Если в правой части правила выбор из нескольких «сложных» последовательностей символов, то для нетерминального символа из левой части правила создаём абстрактный класс, а для каждой альтернативы создаём конкретный подкласс, который наследуем от абстрактного.
  3. Если в правой части правила выбор из нескольких «простых» последовательностей символов, то для нетерминального символа из левой части правила создаём перечисление.
  4. Если правая часть правила относительно «простая», но нельзя перечислить все возможные варианты, то либо используем для нетерминального символа из левой части один из примитивных типов данных Ecore, либо (если нет подходящего) создаём новый тип данных.
  5. Для каждого «простого» нетерминального символа из правой части, который не является ссылкой по имени на некоторый объект предметной области, создаём атрибут в классе из левой части правила.
  6. Для каждого «простого» нетерминального символа из правой части, который не является ссылкой по имени на некоторый объект предметной области, создаём non-containment ссылку в классе из левой части правила на класс именуемого объекта.
  7. Для каждого «сложного» нетерминального символа из правой части создаём containment ссылку в классе из левой части правила на класс, соответствующий данному нетерминальному символу из правой части.

Все эти рекомендации звучат как какой-то детский сад :) Что значит «простые» и «сложные»? Также из этих правил есть некоторые исключения. Например, если символы повторно используются в разных правилах.

Я начал описывать всё это формально, с чёткими определениями. Но на второй странице понял, что это тема для отдельного научного труда, который просто не поместится в эту и так уже гигантскую статью. Поэтому предлагаю обойтись пока такими интуитивными рекомендациями.

4.1 Анализ правила для <table definition>

Итак, первое правило, которое нас интересует, описывает выражения определения таблиц.

<table definition>    ::= 
         CREATE [ <table scope> ] TABLE <table name> <table contents source>
         [ ON COMMIT <table commit action> ROWS ]

Оно выглядит достаточно «сложным»: в правой части есть область видимости создаваемой таблицы, название таблицы, источник содержимого и действие при коммите. Правило подпадает под рекомендацию 1, значит, создаём класс TableDefinition.



Теперь разберём нетерминальные символы из правой части. Для каждого из них мы в соответствии с рекомендациями 5-7 должны создать атрибут или ссылку в классе TableDefinition.

4.2 Анализ правила для <table scope>

<table scope>    ::=   <global or local> TEMPORARY
<global or local>    ::=   GLOBAL | LOCAL

Видно, что правые части правил достаточно «простые». Для большей простоты их можно объединить в одно правило и в соответствии с рекомендацией 3 создать в метамодели перечисление TableScope с тремя значениями:

  • PERSISTENT
  • GLOBAL_TEMPORARY
  • LOCAL_TEMPORARY

Первый вариант перечисления не указан в грамматике явно. Но из правила для <table definition> видно, что область видимости таблицы опциональна, а по умолчанию создаются как-раз постоянные таблицы.

Примечание

У вас может сложиться впечатление, что все мои рассуждения какие-то совершенно нетривиальные – всё это выглядит безумным и сложным. Но всё проще, чем кажется, просто попробуйте сами реализовать какой-нибудь язык.

Нетерминальный символ <table scope> используется в правиле для <table definition>. Поэтому в соответствии с рекомендацией 5 у класса TableDefinition создаём атрибут tableScope с типом TableScope.



4.3 Анализ правила для <table name>

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

<table name>    ::=   <local or schema qualified name>
<local or schema qualified name>    ::=   [ <local or schema qualifier> <period> ] <qualified identifier>
<local or schema qualifier>    ::=   <schema name> | MODULE
<qualified identifier>    ::=   <identifier>
<schema name>    ::=   [ <catalog name> <period> ] <unqualified schema name>
<unqualified schema name> ::= <identifier>
<catalog name>    ::=   <identifier>
<identifier>    ::=   <actual identifier>
<actual identifier>    ::=   <regular identifier> | <delimited identifier>
<regular identifier>    ::=   <identifier body>
<identifier body>    ::=   <identifier start> [ <identifier part> ... ]
<identifier part>    ::=   <identifier start> | <identifier extend>
<identifier start>    ::=   !! See the Syntax Rules.
<identifier extend>    ::=   !! See the Syntax Rules.
<delimited identifier>    ::=   <double quote> <delimited identifier body> <double quote>
<delimited identifier body>    ::=   <delimited identifier part> ...
<delimited identifier part>    ::=   <nondoublequote character> | <doublequote symbol>
<nondoublequote character>    ::=   !! See the Syntax Rules.
<doublequote symbol>    ::=   <double quote> <double quote>
<double quote>    ::=   "

Большая часть правил описывает формат идентификаторов и имён и должна реализовываться на уровне типов данных, а не в метамодели. Вопрос только в том где именно провести границу между деталями реализации типов данных и матемоделью. Возможны такие варианты представления имён таблиц:

  1. Использовать в метамодели существующий тип данных EString, которому в Java соответствует java.lang.String.
  2. Создать новый тип данных с тремя атрибутами (имя каталога, имя схемы и имя объекта), которые будут не видны на уровне метамодели.
    1. Использовать для атрибутов существующий тип данных java.lang.String
    2. Использовать для атрибутов новый тип данных «Идентификатор»
  3. Создать класс с такими же тремя атрибутами, которые описываются уже на уровне метамодели.
    1. Использовать для атрибутов существующий тип данных EString
    2. Использовать для атрибутов новый тип данных «Идентификатор»

После нескольких бессонных ночей и курения исходников JaMoPP я пришёл к тому, что лучше всего вариант 3.1. В вариантах 1 и 2 придётся создавать два пересекающихся вида токенов: идентификатор и квалифицированное имя. Проще определить один вид токена для идентификаторов. Всё что сложнее идентификаторов (включая квалифицированные имена) реализуется на уровне метамодели, а всё что проще – на уровне типов данных.



4.4 Анализ правила для <table contents source>

<table contents source>    ::= 
         <table element list>
     |     OF <path-resolved user-defined type name> [ <subtable clause> ] [ <table element list> ]
     |     <as subquery clause>
<table element list>    ::=   <left paren> <table element> [ { <comma> <table element> }... ] <right paren>
<left paren>    ::=   (
<right paren>    ::=   )
<comma>    ::=   ,

Правило для источника содержимого таблицы подпадает под рекомендацию 2, поэтому создаём абстрактный класс TableContentsSource, от которого наследуем конкретный класс TableElementList. А два других варианта реализовывать пока не будем.

В соответствии с рекомендацией 7 создаем ссылку от класса TableDefinition к классу TableContentsSource. Для ссылки необходимо установить свойство containment в значение true.



Для скобок и запятых классы, очевидно, не нужны, они реализуются на уровне лексера.

4.5 Анализ правила для <table element>

<table element>    ::= 
         <column definition>
     |     <table constraint definition>
     |     <like clause>
     |     <self-referencing column specification>
     |     <column options>

В соответствии с рекомендацией 2 создаём абстрактный класс TableElement и унаследованный от него класс ColumnDefinition. Другие варианты пока реализовывать не будем.

В соответствии с рекомендацией 7 создаем ссылку от класса TableElementList к классу TableElement. Для ссылки необходимо установить свойство containment в значение true.



4.6 Анализ правила для <column definition>

<column definition>    ::= 
         <column name> [ <data type> | <domain name> ] [ <reference scope check> ]
         [ <default clause> | <identity column specification> | <generation clause> ]
         [ <column constraint definition> ... ] [ <collate clause> ]
<column name>    ::=   <identifier>

В соответствии с рекомендацией 5 создаём у класса ColumnDefinition атрибут columnName с типом данных EString. Остальные свойства столбцов пока не будем реализовывать.



5 Более полная метамодель SQL


Если вы докурите ещё несколько правил из грамматики SQL, то получите такую метамодель.

Корневой объект модели – это SQLScript, который может содержать несколько выражений (Statement) двух видов: осмысленные выражения и разделители. Разделители также могут быть двух видов: пробельные символы и комментарии. Первые нам в метамодели не нужны. Комментарии также могут быть двух видов: однострочные и многострочные.

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



Обратите внимание на типы данных справа. UnsignedInteger отображается на Java-класс, который мы реализуем позже. А типы для представления даты и времени отображаются на уже существующие Java-классы.

На следующем рисунке представлена основная часть метамодели. Обратите внимание на то, что в ограничениях (снизу справа) указываются не просто имена столбцов и таблиц, вовлечённых в ограничение, а ссылки на них. Парсер будет формировать из исходного кода граф, а не дерево.



При определении столбцов нужно указывать их тип данных:



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



Также в качестве значения по умолчанию можно указать текущую дату или время:



Или NULL:



6 Описание синтаксиса SQL


Итак, мы наконец дошли до Эребора, увидели жуткого дракона Смауга (метамодель). Настало время возвращаться обратно.



Теперь мы снова опишем синтаксис SQL, но уже не на BNF, а на BNF-подобном языке в файле sql.cs.

Указываем а) расширение файлов, б) пространство имён метамодели и в) класс корневого объекта синтаксического дерева (начальный символ грамматики):

SYNTAXDEF sql
FOR <http://www.emftext.org/language/sql>
START Common.SQLScript

Немного настроек, которые описаны в руководстве EMFText:

OPTIONS {
    reloadGeneratorModel = "true";
    usePredefinedTokens = "false";
    caseInsensitiveKeywords = "true";
    disableBuilder = "true";
    disableDebugSupport = "true";
    disableLaunchSupport = "true";
    disableTokenSorting = "true";
    overrideProposalPostProcessor = "false";
    overrideManifest = "false";
    overrideUIManifest = "false";
}

2 последние опции отключают перегенерацию файлов MANIFEST.MF в плагинах org.emftext.language.sql.resource.sql и org.emftext.language.sql.resource.sql.ui соответственно. В этих файлах необходимо установить минимально требуемую версию Java в JavaSE-1.8, т.к. мы будем использовать Stream API. А версию плагина изменить с «1.0.0» на «1.0.0.qualifier» иначе будут необъяснимые проблемы.

Обратите внимание на то, что регулярные выражения для токенов UNSIGNED_INTEGER и EXACT_NUMERIC_LITERAL пересекаются. Из-за этого далее нам придётся немного усложнить грамматику.

TOKENS {
    // Default
    DEFINE WHITESPACE $('\u0009'|'\u000A'|'\u000B'|'\u000C'|'\u000D'|'\u0020'|'\u00A0'|'\u2000'|'\u2001'$ +
                      $|'\u2002'|'\u2003'|'\u2004'|'\u2005'|'\u2006'|'\u2007'|'\u2008'|'\u2009'|'\u200A'$ +
                      $|'\u200B'|'\u200C'|'\u200D'|'\u200E'|'\u200F'|'\u2028'|'\u2029'|'\u3000'|'\uFEFF')$;

    // Single characters
    DEFINE FRAGMENT SIMPLE_LATIN_LETTER $($ + SIMPLE_LATIN_UPPER_CASE_LETTER + $|$ + SIMPLE_LATIN_LOWER_CASE_LETTER + $)$;
    DEFINE FRAGMENT SIMPLE_LATIN_UPPER_CASE_LETTER $'A'..'Z'$;
    DEFINE FRAGMENT SIMPLE_LATIN_LOWER_CASE_LETTER $'a'..'z'$;
    DEFINE FRAGMENT DIGIT $('0'..'9')$;

    DEFINE FRAGMENT PLUS_SIGN $'+'$;
    DEFINE FRAGMENT MINUS_SIGN $'-'$;
    DEFINE FRAGMENT SIGN $($ + PLUS_SIGN + $|$ + MINUS_SIGN + $)$;
    DEFINE FRAGMENT COLON $':'$;
    DEFINE FRAGMENT PERIOD $'.'$;
    DEFINE FRAGMENT SPACE $' '$;
    DEFINE FRAGMENT UNDERSCORE $'_'$;
    DEFINE FRAGMENT SLASH $'/'$;
    DEFINE FRAGMENT ASTERISK $'*'$;
    DEFINE FRAGMENT QUOTE $'\''$;
    DEFINE FRAGMENT QUOTE_SYMBOL $($ + QUOTE + QUOTE + $)$;
    DEFINE FRAGMENT NONQUOTE_CHARACTER $~($ + QUOTE + $|$ + NEWLINE + $)$; 
    DEFINE FRAGMENT DOUBLE_QUOTE $'"'$;
    DEFINE FRAGMENT DOUBLEQUOTE_SYMBOL $($ + DOUBLE_QUOTE + DOUBLE_QUOTE + $)$;
    DEFINE FRAGMENT NONDOUBLEQUOTE_CHARACTER $~($ + DOUBLE_QUOTE + $|$ + NEWLINE + $)$;
    DEFINE FRAGMENT NEWLINE $('\r\n'|'\r'|'\n')$;

    // Comments
    DEFINE SIMPLE_COMMENT SIMPLE_COMMENT_INTRODUCER + $($ + COMMENT_CHARACTER + $)*$;
    DEFINE FRAGMENT SIMPLE_COMMENT_INTRODUCER MINUS_SIGN + MINUS_SIGN;
    DEFINE FRAGMENT COMMENT_CHARACTER $~('\n'|'\r'|'\uffff')$;

    DEFINE BRACKETED_COMMENT BRACKETED_COMMENT_INTRODUCER + BRACKETED_COMMENT_CONTENTS + BRACKETED_COMMENT_TERMINATOR;
    DEFINE FRAGMENT BRACKETED_COMMENT_INTRODUCER SLASH + ASTERISK;
    DEFINE FRAGMENT BRACKETED_COMMENT_TERMINATOR ASTERISK + SLASH;
    DEFINE FRAGMENT BRACKETED_COMMENT_CONTENTS $.*$; // TODO: Nested comments

    // Literals
    DEFINE UNSIGNED_INTEGER $($ + DIGIT + $)+$;

    DEFINE EXACT_NUMERIC_LITERAL $($ + UNSIGNED_INTEGER + $($ + PERIOD + $($ + UNSIGNED_INTEGER + $)?)?|$ + PERIOD + UNSIGNED_INTEGER + $)$;
    DEFINE APPROXIMATE_NUMERIC_LITERAL MANTISSA + $'E'$ + EXPONENT;
    DEFINE FRAGMENT MANTISSA EXACT_NUMERIC_LITERAL;
    DEFINE FRAGMENT EXPONENT SIGNED_INTEGER;
    DEFINE FRAGMENT SIGNED_INTEGER SIGN + $?$ + UNSIGNED_INTEGER;

    DEFINE QUOTED_STRING QUOTE + CHARACTER_REPRESENTATION + $*$ + QUOTE;
    DEFINE FRAGMENT CHARACTER_REPRESENTATION $($ + NONQUOTE_CHARACTER + $|$ + QUOTE_SYMBOL + $)$;

    // Names and identifiers
    DEFINE IDENTIFIER ACTUAL_IDENTIFIER;
    DEFINE FRAGMENT ACTUAL_IDENTIFIER $($ + REGULAR_IDENTIFIER + $|$ + DELIMITED_IDENTIFIER + $)$;
    DEFINE FRAGMENT REGULAR_IDENTIFIER IDENTIFIER_BODY;
    DEFINE FRAGMENT IDENTIFIER_BODY IDENTIFIER_START + IDENTIFIER_PART + $*$;
    DEFINE FRAGMENT IDENTIFIER_PART $($ + IDENTIFIER_START + $|$ + IDENTIFIER_EXTEND + $)$;
    DEFINE FRAGMENT IDENTIFIER_START $('A'..'Z'|'a'..'z')$; // TODO: \p{L} - \p{M}
    DEFINE FRAGMENT IDENTIFIER_EXTEND $($ + DIGIT + $|$ + UNDERSCORE + $)$; // TODO: Support more characters
    DEFINE FRAGMENT DELIMITED_IDENTIFIER DOUBLE_QUOTE + DELIMITED_IDENTIFIER_BODY + DOUBLE_QUOTE;
    DEFINE FRAGMENT DELIMITED_IDENTIFIER_BODY DELIMITED_IDENTIFIER_PART + $+$;
    DEFINE FRAGMENT DELIMITED_IDENTIFIER_PART $($ + NONDOUBLEQUOTE_CHARACTER + $|$ + DOUBLEQUOTE_SYMBOL + $)$;
}

Раскрасим токены:

TOKENSTYLES {
    "SIMPLE_COMMENT", "BRACKETED_COMMENT"
    COLOR #999999, ITALIC;
    
    "QUOTED_STRING"
    COLOR #000099, ITALIC;
    
    "EXACT_NUMERIC_LITERAL", "APPROXIMATE_NUMERIC_LITERAL", "UNSIGNED_INTEGER"
    COLOR #009900;
}

И, наконец, в секции RULES { } опишем синтаксис для классов из метамодели.

6.1 Описание синтаксиса для скрипта в целом, комментариев и имён

Если вы читали предыдущую статью, то смысл этих правил для вас должен быть очевиден:

    Common.SQLScript ::= (statements !0)*;
    Common.SimpleComment ::= value[SIMPLE_COMMENT];
    Common.BracketedComment ::= value[BRACKETED_COMMENT];
    Common.SchemaQualifiedName ::= ((catalogName[IDENTIFIER] ".")? schemaName[IDENTIFIER] ".")? name[IDENTIFIER];

6.2 Описание синтаксиса для литералов

    @SuppressWarnings(explicitSyntaxChoice)
    Literal.ExactNumericLiteral ::= value[EXACT_NUMERIC_LITERAL] | value[UNSIGNED_INTEGER]; 
    Literal.ApproximateNumericLiteral ::= value[APPROXIMATE_NUMERIC_LITERAL];
    Literal.CharacterStringLiteral ::= ("_" characterSetName)? values[QUOTED_STRING] (separators values[QUOTED_STRING])*;
    Literal.NationalCharacterStringLiteral ::= "N" values[QUOTED_STRING] (separators values[QUOTED_STRING])*;
    Literal.DateLiteral ::= "DATE" value[QUOTED_STRING];
    Literal.TimeLiteral ::= "TIME" value[QUOTED_STRING];
    Literal.TimestampLiteral ::= "TIMESTAMP" value[QUOTED_STRING];
    Literal.BooleanLiteral ::= value[ "TRUE" : "FALSE" ]?;

Стоит подробней остановиться на первом правиле. Регулярные выражения для токенов EXACT_NUMERIC_LITERAL и UNSIGNED_INTEGER пересекаются. Например, если вы напишите в SQL-скрипте число в десятичной системе исчисления без символа «.», то оно будет интерпретировано лексером как UNSIGNED_INTEGER. Затем парсер, увидев в последовательности токенов UNSIGNED_INTEGER вместо EXACT_NUMERIC_LITERAL, выдаст ошибку, что в этом месте ожидался другой токен. Поэтому при пересечении токенов приходится усложнять грамматику подобным образом.

А, вот, APPROXIMATE_NUMERIC_LITERAL уже никак не спутаешь с другими токенами, потому что в нём всегда содержится символ «E».

Если вы внимательно смотрели BNF-грамматику SQL, то, наверняка заметили, что в ней достаточно подробно описан формат даты и времени, а мы ограничились простым QUOTED_STRING. Это связано с тем, что если бы мы описали токены для даты и времени, то они пересекались бы с токеном QUOTED_STRING и нам пришлось бы очень сильно усложнять грамматику (везде, где используется токен QUOTED_STRING указывать ещё и токены для даты и времени как допустимые). Либо пришлось бы составные части литералов (вплоть до отдельных символов) описывать в метамодели, что безумно её усложнило бы.

Проще реализовать разбор даты и времени в коде, а не на уровне лексера. Далее я опишу, как это сделать.

6.3 Описание синтаксиса для типов данных

Тут всё относительно просто. Стоит только обратить внимание на то, что в SQL есть многозначные токены. Например, «DATE» используется как для обозначения типа данных, так и для обозначения литералов. Если вы захотите, например, раскрасить токены, относящиеся к типам данных, и токены, относящиеся к литералам, в разные цвета, то в EMFText это сделать не так просто, потому что лексер не знает в каком именно контексте используется «DATE».

Так же немного усложняют жизнь «составные» токены типа «DOUBLE» «PRECISION». Почему нельзя было вместо пробела сделать символ "_" или вообще убрать второе слово?.. Текущая реализация не очень корректная, потому что допускает только одиночные пробелы в «составных» токенах, хотя реально может быть сколько угодно пробельных символов, включая переводы строк, табуляцию и т.п. Это можно было бы реализовать в EMFText, изменив соответствующим образом правила, но тогда начинаются проблемы с кодогенератором. Можно было бы переписать кодогенератор, но это слишком долго. В будущих статьях нам понадобится именно кодогенератор, а не парсер, поэтому остановимся пока на таком решении.

Альтернативная реализация «составных» токенов описана в разделе 6.5. Парсер работает нормально, но кодогенератор, скорее всего, не сможет сформировать в SQL-скрипте «GLOBAL» «TEMPORARY» или «LOCAL» «TEMPORARY».

    Datatype.ExactNumericType ::=
        kind[ NUMERIC : "NUMERIC", DECIMAL : "DECIMAL", DEC : "DEC", SMALLINT : "SMALLINT",
              INTEGER : "INTEGER", INT : "INT", BIGINT : "BIGINT" ]
        ("(" precision[UNSIGNED_INTEGER] ("," scale[UNSIGNED_INTEGER])? ")")?;

    Datatype.ApproximateNumericType ::=
        kind[ FLOAT : "FLOAT", REAL : "REAL", DOUBLE_PRECISION : "DOUBLE PRECISION" ]
        ("(" precision[UNSIGNED_INTEGER] ")")?;

    Datatype.CharacterStringType ::=
        kind[ CHARACTER : "CHARACTER", CHAR : "CHAR", VARCHAR : "VARCHAR",
              CHARACTER_VARYING : "CHARACTER VARYING", CHAR_VARYING : "CHAR VARYING" ]
        ("(" length[UNSIGNED_INTEGER] ")")?
        ("CHARACTER" "SET" characterSetName)?
        ("COLLATE" collationName)?;

    Datatype.NationalCharacterStringType ::= 
        kind[ NATIONAL_CHARACTER : "NATIONAL CHARACTER", NATIONAL_CHAR : "NATIONAL CHAR",
              NATIONAL_CHARACTER_VARYING : "NATIONAL CHARACTER VARYING",
              NATIONAL_CHAR_VARYING : "NATIONAL CHAR VARYING",
              NCHAR : "NCHAR", NCHAR_VARYING : "NCHAR VARYING" ]
        ("(" length[UNSIGNED_INTEGER] ")")?
        ("COLLATE" collationName)?;

    Datatype.BinaryLargeObjectStringType ::=
        kind[ BINARY_LARGE_OBJECT : "BINARY LARGE OBJECT", BLOB : "BLOB" ]
        ("(" length ")")?;

    Datatype.LargeObjectLength ::= value[UNSIGNED_INTEGER]
        multiplier[ K : "K", M : "M", G : "G" ]?
        units[ CHARACTERS : "CHARACTERS", CODE_UNITS : "CODE_UNITS", OCTETS : "OCTETS" ]?;

    Datatype.DateType ::= "DATE";

    Datatype.TimeType ::= "TIME"
        ("(" precision[UNSIGNED_INTEGER] ")")?
        (withTimeZone["WITH" : "WITHOUT"] "TIME" "ZONE")?;

    Datatype.TimestampType ::= "TIMESTAMP"
        ("(" precision[UNSIGNED_INTEGER] ")")?
        (withTimeZone["WITH" : "WITHOUT"] "TIME" "ZONE")?;

    Datatype.BooleanType ::= "BOOLEAN";

6.4 Описание синтаксиса для функций и выражений

Тут всё тривиально:

    Function.DatetimeValueFunction ::=
        kind[ CURRENT_DATE : "CURRENT_DATE", CURRENT_TIME : "CURRENT_TIME",
              LOCALTIME : "LOCALTIME", CURRENT_TIMESTAMP : "CURRENT_TIMESTAMP",
              LOCALTIMESTAMP : "LOCALTIMESTAMP" ]
        ("(" precision[UNSIGNED_INTEGER] ")")?;

    Expression.NullSpecification ::= "NULL";

6.5 Описание синтаксиса для определений таблиц

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

    Schema.TableReference ::= ((catalogName[IDENTIFIER] ".")? schemaName[IDENTIFIER] ".")? target[IDENTIFIER];

    @SuppressWarnings(explicitSyntaxChoice)
    Schema.TableDefinition ::= "CREATE"
        ( scope[ PERSISTENT : "" ]
        | scope[ GLOBAL_TEMPORARY : "GLOBAL", LOCAL_TEMPORARY : "LOCAL" ] "TEMPORARY" )
        "TABLE" schemaQualifiedName !0
        contentsSource ";" !0;

    Schema.TableElementList ::= "(" !1 elements ("," !1 elements)* !0 ")";

    Schema.Column ::= name[IDENTIFIER] dataType
        ("DEFAULT" defaultOption)?
        constraintDefinition?
        ("COLLATE" collationName)?;

    Schema.LiteralDefaultOption ::= literal;
    
    Schema.DatetimeValueFunctionDefaultOption ::= function;
    
    Schema.ImplicitlyTypedValueSpecificationDefaultOption ::= specification;
    
    Schema.NotNullColumnConstraint ::=
        ("CONSTRAINT" schemaQualifiedName)?
        "NOT" "NULL";

    Schema.UniqueColumnConstraint ::=
        ("CONSTRAINT" schemaQualifiedName)?
        kind[ UNIQUE : "UNIQUE" , PRIMARY_KEY : "PRIMARY KEY" ];

    Schema.ReferentialColumnConstraint ::=
        ("CONSTRAINT" schemaQualifiedName)?
        "REFERENCES" referencedTable
        ("(" referencedColumns[IDENTIFIER] ("," referencedColumns[IDENTIFIER])* ")")?;

    Schema.UniqueTableConstraint ::=
        ("CONSTRAINT" schemaQualifiedName)?
        kind[ UNIQUE : "UNIQUE" , PRIMARY_KEY : "PRIMARY KEY" ]
        "(" columns[IDENTIFIER] ("," columns[IDENTIFIER])* ")";

    Schema.ReferentialTableConstraint ::=
        ("CONSTRAINT" schemaQualifiedName)?
        "FOREIGN" "KEY" "(" columns[IDENTIFIER] ("," columns[IDENTIFIER])* ")"
        "REFERENCES" referencedTable
        ("(" referencedColumns[IDENTIFIER] ("," referencedColumns[IDENTIFIER])* ")")?;

7 Разбор токенов


Лексер разбивает исходный код на последовательность строк. Большинство этих строк нужно либо преобразовывать в значения определённых типов данных (число, дата, время), либо оставлять в строковом виде, но вносить некоторые изменения (например, в строковых литералах нужно заменять две кавычки на одну).

В простейших случаях EMFText сделает это автоматически. Но SQL не очень простой язык, поэтому придётся написать немного кода.

7.1 Разбор комментариев

Начнём с самого простого токена – однострочные комментарии.

Для них при парсинге достаточно удалить два начальных символа "-". А при кодогенерации – добавить эти символы:

package org.emftext.language.sql.resource.sql.analysis;

import java.util.Map;
import org.eclipse.emf.ecore.EObject;
import org.eclipse.emf.ecore.EStructuralFeature;
import org.emftext.language.sql.resource.sql.ISqlTokenResolveResult;
import org.emftext.language.sql.resource.sql.ISqlTokenResolver;

public class SqlSIMPLE_COMMENTTokenResolver implements ISqlTokenResolver {

    public String deResolve(Object value, EStructuralFeature feature, EObject container) {
        return "--" + ((String) value);
    }

    public void resolve(String lexem, EStructuralFeature feature, ISqlTokenResolveResult result) {
        result.setResolvedToken(lexem.substring(2));
    }

    public void setOptions(Map<?, ?> options) {
    }

}

7.2 Разбор идентификаторов

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

package org.emftext.language.sql.resource.sql.analysis;

import java.util.Map;

import org.eclipse.emf.ecore.EObject;
import org.eclipse.emf.ecore.EStructuralFeature;
import org.emftext.language.sql.resource.sql.ISqlTokenResolveResult;
import org.emftext.language.sql.resource.sql.ISqlTokenResolver;

public class SqlIDENTIFIERTokenResolver implements ISqlTokenResolver {
	
    public String deResolve(Object value, EStructuralFeature feature, EObject container) {
        return Helper.formatIdentifier((String) value);
    }

    public void resolve(String lexem, EStructuralFeature feature, ISqlTokenResolveResult result) {
        try {
            result.setResolvedToken(Helper.parseIdentifier(lexem));
        }
        catch (Exception e) {
            result.setErrorMessage(e.getMessage());
        }
    }

    public void setOptions(Map<?, ?> options) {
    }

}

Мы реализовали очень маленькую часть SQL, поэтому приходится явно перечислять все зарезервированные слова (недопустимые в идентификаторах):

public class Helper
package org.emftext.language.sql.resource.sql.analysis;

import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;

public class Helper {
    
    private static final String DOUBLE_QUOTE = "\"";
    private static final String DOUBLE_QUOTE_SYMBOL = "\"\"";

    private static final Set<String> RESERVED_WORDS = new HashSet<String>(Arrays.asList(new String[] { "ADD", "ALL",
            "ALLOCATE", "ALTER", "AND", "ANY", "ARE", "ARRAY", "AS", "ASENSITIVE", "ASYMMETRIC", "AT", "ATOMIC",
            "AUTHORIZATION", "BEGIN", "BETWEEN", "BIGINT", "BINARY", "BLOB", "BOOLEAN", "BOTH", "BY", "CALL", "CALLED",
            "CASCADED", "CASE", "CAST", "CHAR", "CHARACTER", "CHECK", "CLOB", "CLOSE", "COLLATE", "COLUMN", "COMMIT",
            "CONNECT", "CONSTRAINT", "CONTINUE", "CORRESPONDING", "CREATE", "CROSS", "CUBE", "CURRENT", "CURRENT_DATE",
            "CURRENT_DEFAULT_TRANSFORM_GROUP", "CURRENT_PATH", "CURRENT_ROLE", "CURRENT_TIME", "CURRENT_TIMESTAMP",
            "CURRENT_TRANSFORM_GROUP_FOR_TYPE", "CURRENT_USER", "CURSOR", "CYCLE", "DATE", "DAY", "DEALLOCATE", "DEC",
            "DECIMAL", "DECLARE", "DEFAULT", "DELETE", "DEREF", "DESCRIBE", "DETERMINISTIC", "DISCONNECT", "DISTINCT",
            "DOUBLE", "DROP", "DYNAMIC", "EACH", "ELEMENT", "ELSE", "END", "END-EXEC", "ESCAPE", "EXCEPT", "EXEC",
            "EXECUTE", "EXISTS", "EXTERNAL", "FALSE", "FETCH", "FILTER", "FLOAT", "FOR", "FOREIGN", "FREE", "FROM",
            "FULL", "FUNCTION", "GET", "GLOBAL", "GRANT", "GROUP", "GROUPING", "HAVING", "HOLD", "HOUR", "IDENTITY",
            "IMMEDIATE", "IN", "INDICATOR", "INNER", "INOUT", "INPUT", "INSENSITIVE", "INSERT", "INT", "INTEGER",
            "INTERSECT", "INTERVAL", "INTO", "IS", "ISOLATION", "JOIN", "LANGUAGE", "LARGE", "LATERAL", "LEADING",
            "LEFT", "LIKE", "LOCAL", "LOCALTIME", "LOCALTIMESTAMP", "MATCH", "MEMBER", "MERGE", "METHOD", "MINUTE",
            "MODIFIES", "MODULE", "MONTH", "MULTISET", "NATIONAL", "NATURAL", "NCHAR", "NCLOB", "NEW", "NO", "NONE",
            "NOT", "NULL", "NUMERIC", "OF", "OLD", "ON", "ONLY", "OPEN", "OR", "ORDER", "OUT", "OUTER", "OUTPUT",
            "OVER", "OVERLAPS", "PARAMETER", "PARTITION", "PRECISION", "PREPARE", "PRIMARY", "PROCEDURE", "RANGE",
            "READS", "REAL", "RECURSIVE", "REF", "REFERENCES", "REFERENCING", "REGR_AVGX", "REGR_AVGY", "REGR_COUNT",
            "REGR_INTERCEPT", "REGR_R2", "REGR_SLOPE", "REGR_SXX", "REGR_SXY", "REGR_SYY", "RELEASE", "RESULT",
            "RETURN", "RETURNS", "REVOKE", "RIGHT", "ROLLBACK", "ROLLUP", "ROW", "ROWS", "SAVEPOINT", "SCROLL",
            "SEARCH", "SECOND", "SELECT", "SENSITIVE", "SESSION_USER", "SET", "SIMILAR", "SMALLINT", "SOME", "SPECIFIC",
            "SPECIFICTYPE", "SQL", "SQLEXCEPTION", "SQLSTATE", "SQLWARNING", "START", "STATIC", "SUBMULTISET",
            "SYMMETRIC", "SYSTEM", "SYSTEM_USER", "TABLE", "THEN", "TIME", "TIMESTAMP", "TIMEZONE_HOUR",
            "TIMEZONE_MINUTE", "TO", "TRAILING", "TRANSLATION", "TREAT", "TRIGGER", "TRUE", "UESCAPE", "UNION",
            "UNIQUE", "UNKNOWN", "UNNEST", "UPDATE", "UPPER", "USER", "USING", "VALUE", "VALUES", "VAR_POP", "VAR_SAMP",
            "VARCHAR", "VARYING", "WHEN", "WHENEVER", "WHERE", "WIDTH_BUCKET", "WINDOW", "WITH", "WITHIN", "WITHOUT",
            "YEAR" }));

    private static boolean isReservedWord(String str) {
        return RESERVED_WORDS.contains(str.toUpperCase());
    }

    public static boolean isEmpty(String str) {
        return str == null || str.length() == 0;
    }
    
    public static String formatIdentifier(String str) {
        if (!str.matches("[A-Z][A-Z0-9_]*") || isReservedWord(str)) {
            return DOUBLE_QUOTE + str.replace(DOUBLE_QUOTE, DOUBLE_QUOTE_SYMBOL) + DOUBLE_QUOTE;
        }
        else {
            return str;
        }
    }
    
    public static String parseIdentifier(String str) {
        if (str.startsWith(DOUBLE_QUOTE) && str.endsWith(DOUBLE_QUOTE) && str.length() >= 2) {
            return str.substring(1, str.length() - 1)
                    .replace(DOUBLE_QUOTE_SYMBOL, DOUBLE_QUOTE);
        }
        else if (isReservedWord(str)) {
            throw new IllegalArgumentException(
                    String.format("Reserved word %s must be quoted when used as identifier", str.toUpperCase()));
        }
        else {
            return str.toUpperCase();
        }
    }

}


7.3 Разбор натуральных чисел

Для натуральных чисел (UNSIGNED_INTEGER) мы могли бы в метамодели использовать тип данных EInt, который отображается в примитивный Java-тип данных int. Но мы не ищем лёгких путей, поэтому создали собственный тип:

package org.emftext.language.sql;

public class UnsignedInteger {

    private int value;

    private UnsignedInteger(int value) {
        this.value = value;
    }

    public static UnsignedInteger valueOf(String str) {
        return new UnsignedInteger(Integer.parseUnsignedInt(str));
    }

    @Override
    public String toString() {
        return String.format("%d", value);
    }

}

Дополнительно настраивать лексер не требуется, он сам догадается, что необходимо использовать методы valueOf и toString. Причём, это фича даже не EMFText, а EMF вообще. Например, при (де)сериализации в XMI-формате абстрактного синтаксического дерева для некоторого SQL-скрипта будут использоваться эти же самые методы.

Возможно, у вас возник вопрос. Почему для натуральных чисел мы сделали отдельный тип, а для идентификаторов захардкодили всё на уровне лексера? Ведь первый вариант явно универсальней, эта логика будет использоваться не только при (де)сериализации скриптов в простом текстовом виде, но и, например, в XMI.

А ответ очень простой. В других (де)сериализаторах нам как-раз и не нужно заключать идентификаторы в двойные кавычки, если в них содержатся зарезервированные слова. Это нужно только при парсинге/кодогенерации SQL-скриптов в простом текстовом формате.

7.4 Разбор даты и времени

Наконец, самое сложное в нашем примере – это разбор даты и времени. Проблема заключается в том, что в Ecore есть только один тип для представления даты и времени – EDate, который отображается в java.util.Date. Но в SQL может указываться временная зона, которая этим типом не поддерживается. Также в SQL может указываться время суток без даты, для чего EDate тоже не очень хорош.

В Java есть более подходящие нам типы: java.time.LocalDate, java.time.LocalTime и java.time.ZonedDateTime. 2-ой, к сожалению, без временной зоны, но сейчас не критично.

Но есть проблема, у этих типов нет метода valueOf, а toString работает не так как хотелось бы. Реализовывать разбор даты и времени на уровне лексера не хочется, потому что эта же логика могла бы повторно использоваться и при (де)сериализации в XMI-формате или других. Поэтому воспользуемся не очень документированной фичей EMF – делегатами преобразований (conversion delegates).

Для этого откройте файл plugin.xml и на вкладке «Расширения» (Extensions) добавьте расширение org.eclipse.emf.ecore.conversion_delegate. Добавьте в него фабрику со следующими свойствами:

  • URI – org.emftext.language.sql.conversionDelegateFactory
  • Class – org.emftext.language.sql.ConversionDelegateFactory



Примечание

Разрабатываемый парсер и кодогенератор не обязательно должны запускаться как плагин Eclipse, они могут использоваться и в отдельном приложении. В этом случае файл plugin.xml не используется, а фабрика регистрируется подобным образом:
EDataType.Internal.ConversionDelegate.Factory.Registry.INSTANCE.put(
  "org.emftext.language.sql.conversionDelegateFactory",
  new ConversionDelegateFactory());

Затем откройте метамодель и в пакете (в данном случае common) создайте аннотацию (EAnnotation) с источником (Source) http://www.eclipse.org/emf/2002/Ecore. В ней создайте запись с ключом conversionDelegates и значением org.emftext.language.sql.conversionDelegateFactory.

Примечание

На момент написания статьи в редакторе OCLinEcore был баг, из-за которого эта аннотация удаляется. В этом случае нужно её просто создать заново. Если вы не понимаете о чём речь, то всё нормально.

У каждого типа данных, для которого требуется реализовать альтернативную (де)сериализацию, создайте аннотацию (EAnnotation) с источником (Source) org.emftext.language.sql.conversionDelegateFactory:



Так выглядит фабрика делегатов:

package org.emftext.language.sql;

import org.eclipse.emf.ecore.EDataType;
import org.eclipse.emf.ecore.EDataType.Internal.ConversionDelegate;
import org.eclipse.emf.ecore.EDataType.Internal.ConversionDelegate.Factory;
import org.emftext.language.sql.common.CommonPackage;

public class ConversionDelegateFactory implements Factory {

    public ConversionDelegateFactory() {
    }

    @Override
    public ConversionDelegate createConversionDelegate(EDataType eDataType) {
        if (eDataType.equals(CommonPackage.eINSTANCE.getDateType())) {
            return new DateConversionDelegate();
        }
        else if (eDataType.equals(CommonPackage.eINSTANCE.getTimeType())) {
            return new TimeConversionDelegate();
        }
        else if (eDataType.equals(CommonPackage.eINSTANCE.getTimestampType())) {
            return new TimestampConversionDelegate();
        }
        return null;
    }

}

И для примера один из делегатов:

package org.emftext.language.sql;

import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;

import org.eclipse.emf.ecore.EDataType.Internal.ConversionDelegate;

public class TimestampConversionDelegate implements ConversionDelegate {

    private static final DateTimeFormatter FORMATTER = new DateTimeFormatterBuilder()
            .append(DateTimeFormatter.ISO_LOCAL_DATE)
            .appendLiteral(" ")
            .append(DateTimeFormatter.ISO_TIME)
            .toFormatter();
    
    @Override
    public String convertToString(Object value) {
        ZonedDateTime timestamp = (ZonedDateTime) value;
        return timestamp.format(FORMATTER);
    }

    @Override
    public Object createFromString(String literal) {
        return ZonedDateTime.parse(literal, FORMATTER);
    }

}

Подытожу, что мы сделали:

  1. В метамодели определили тип данных TimestampType
  2. Указали, что в Java этот тип должен реализовываться как java.time.ZonedDateTime
  3. Создали делегат для (де)сериализации этого типа в строковое представление
  4. Создали и зарегистрировали фабрику создания этого делегата

Примечание

В EMF есть и другие виды делегатов: invocationDelegates, settingDelegates, validationDelegates.

Остаётся только немного усовершенствовать разбор QUOTED_STRING, потому что реализация по умолчанию делает не то, что хотелось бы:

package org.emftext.language.sql.resource.sql.analysis;

import java.util.Map;

import org.eclipse.emf.ecore.EDataType;
import org.eclipse.emf.ecore.EObject;
import org.eclipse.emf.ecore.EStructuralFeature;
import org.eclipse.emf.ecore.util.EcoreUtil;
import org.emftext.language.sql.resource.sql.ISqlTokenResolveResult;
import org.emftext.language.sql.resource.sql.ISqlTokenResolver;

public class SqlQUOTED_STRINGTokenResolver implements ISqlTokenResolver {

    private static final String QUOTE = "'";
    private static final String QUOTE_SYMBOL = "''";

    public String deResolve(Object value, EStructuralFeature feature, EObject container) {
        String result = EcoreUtil.convertToString((EDataType) feature.getEType(), value);
        return QUOTE + result.replace(QUOTE, QUOTE_SYMBOL) + QUOTE;
    }

    public void resolve(String lexem, EStructuralFeature feature, ISqlTokenResolveResult result) {
        lexem = lexem.substring(1, lexem.length() - 1);
        lexem = lexem.replace(QUOTE_SYMBOL, QUOTE);
        try {
            result.setResolvedToken(EcoreUtil.createFromString((EDataType) feature.getEType(), lexem));
        }
        catch (Exception e) {
            result.setErrorMessage(e.getMessage());
        }
    }

    public void setOptions(Map<?, ?> options) {
    }

}

8 Разрешение ссылок


В нашей реализации SQL можно описывать ограничения на уровне столбцов или таблиц. Хотелось бы в этих ограничениях не просто указывать произвольные имена внешних таблиц и столбцов, а ссылаться на реально существующие таблицы и столбцы. Поэтому в метамодели эти ссылки мы реализовали не просто как строковые поля, а как физические non-containment ссылки (columns, referencedColumns, referencedTable).

EMFText умеет разрешать такие ссылки из коробки, но реализация по умолчанию нас не очень устраивает, сделаем свою.

8.1 Разрешение ссылок на столбцы из определяемой таблицы

В предыдущей статье мы подробно разбирали как работает разрешение ссылок в EMFText.

Если метод resolve вызывается с истинным значением параметра resolveFuzzy (например, при автодополнении имени столбца в описании ограничения), то ищем в текущей таблице все столбцы, которые начинаются на введенную последовательность символов, за исключением тех столбцов, которые уже указаны в ограничении.

Если метод resolve вызывается с ложным значением параметра resolveFuzzy, то ищем в текущей таблице столбец в точности с указанным именем.

public class TableColumnsConstraintColumnsReferenceResolver
package org.emftext.language.sql.resource.sql.analysis;

import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Stream;

import org.eclipse.emf.ecore.EReference;
import org.emftext.language.sql.resource.sql.ISqlReferenceResolveResult;
import org.emftext.language.sql.resource.sql.ISqlReferenceResolver;
import org.emftext.language.sql.schema.Column;
import org.emftext.language.sql.schema.TableColumnsConstraint;

public class TableColumnsConstraintColumnsReferenceResolver implements
        ISqlReferenceResolver<TableColumnsConstraint, Column> {

    public void resolve(String identifier, TableColumnsConstraint container, EReference reference, int position,
            boolean resolveFuzzy, final ISqlReferenceResolveResult<Column> result) {

        Stream<Column> columns = container.getOwner().getElements().stream()
            .filter(element -> element instanceof Column)
            .map(col -> (Column) col);

        Consumer<Column> addMapping = col -> result.addMapping(col.getName(), col);

        if (resolveFuzzy) {
            columns
                .filter(col -> !container.getColumns().contains(col))
                .filter(col -> col.getName().startsWith(identifier))
                .forEach(addMapping);
        } else {
            columns
                .filter(col -> col.getName().equals(identifier))
                .findFirst()
                .ifPresent(addMapping);
        }
    }

    public String deResolve(Column element, TableColumnsConstraint container, EReference reference) {
        return element.getName();
    }

    public void setOptions(Map<?, ?> options) {
    }

}


8.2 Разрешение ссылок на внешние таблицы

Тут аналогичная логика, только ищем (вместо столбцов) все таблицы в SQL-скрипте. Также есть некоторые сложности, связанные с тем, что имена таблиц могут включать наименование каталога и схемы.

public class TableReferenceTargetReferenceResolver
package org.emftext.language.sql.resource.sql.analysis;

import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Stream;

import org.eclipse.emf.ecore.EReference;
import org.eclipse.emf.ecore.util.EcoreUtil;
import org.emftext.language.sql.common.SQLScript;
import org.emftext.language.sql.resource.sql.ISqlReferenceResolveResult;
import org.emftext.language.sql.resource.sql.ISqlReferenceResolver;
import org.emftext.language.sql.schema.TableDefinition;
import org.emftext.language.sql.schema.TableReference;

public class TableReferenceTargetReferenceResolver implements ISqlReferenceResolver<TableReference, TableDefinition> {

    public void resolve(String identifier, TableReference container, EReference reference, int position,
            boolean resolveFuzzy, final ISqlReferenceResolveResult<TableDefinition> result) {

        SQLScript sqlScript = (SQLScript) EcoreUtil.getRootContainer(container);

        String catalogName = container.getCatalogName();
        Predicate<TableDefinition> filter = !Helper.isEmpty(catalogName)
                ? table -> catalogName.equals(table.getSchemaQualifiedName().getCatalogName())
                : table -> true;

        String schemaName = container.getSchemaName();
        Predicate<TableDefinition> filter2 = !Helper.isEmpty(schemaName)
                ? table -> schemaName.equals(table.getSchemaQualifiedName().getSchemaName())
                : table -> true;

        Stream<TableDefinition> tables = sqlScript.getStatements().stream()
                .filter(stmt -> stmt instanceof TableDefinition)
                .map(table -> (TableDefinition) table)
                .filter(filter.and(filter2));

        Consumer<TableDefinition> addMapping =
                table -> result.addMapping(table.getSchemaQualifiedName().getName(), table);

        if (resolveFuzzy) {
            tables.filter(table -> table.getSchemaQualifiedName() != null &&
                                   table.getSchemaQualifiedName().getName() != null &&
                                   table.getSchemaQualifiedName().getName().toUpperCase().startsWith(identifier.toUpperCase()))
                  .forEach(addMapping);
        } else {
            tables.filter(table -> table.getSchemaQualifiedName() != null &&
                                   table.getSchemaQualifiedName().getName() != null &&
                                   table.getSchemaQualifiedName().getName().equals(identifier))
                  .findFirst()
                  .ifPresent(addMapping);
        }
    }
    
    public String deResolve(TableDefinition element, TableReference container, EReference reference) {
        return element.getSchemaQualifiedName().getName();
    }

    public void setOptions(Map<?, ?> options) {
    }

}


8.3 Разрешение ссылок на столбцы из внешних таблиц

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

public class ReferentialConstraintReferencedColumnsReferenceResolver
package org.emftext.language.sql.resource.sql.analysis;

import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Stream;

import org.eclipse.emf.ecore.EReference;
import org.emftext.language.sql.resource.sql.ISqlReferenceResolveResult;
import org.emftext.language.sql.resource.sql.ISqlReferenceResolver;
import org.emftext.language.sql.schema.Column;
import org.emftext.language.sql.schema.ReferentialConstraint;
import org.emftext.language.sql.schema.TableElementList;

public class ReferentialConstraintReferencedColumnsReferenceResolver
        implements ISqlReferenceResolver<ReferentialConstraint, Column> {

    public void resolve(String identifier, ReferentialConstraint container, EReference reference, int position,
            boolean resolveFuzzy, final ISqlReferenceResolveResult<Column> result) {
        
        Stream<Column> columns = Stream.of(container.getReferencedTable().getTarget())
            .filter(table -> table != null)
            .map(table -> table.getContentsSource())
            .filter(src -> src instanceof TableElementList)
            .flatMap(list -> ((TableElementList) list).getElements().stream())
            .filter(element -> element instanceof Column)
            .map(col -> (Column) col);

        Consumer<Column> addMapping = col -> result.addMapping(col.getName(), col);
        
        if (resolveFuzzy) {
            columns
                .filter(col -> !container.getReferencedColumns().contains(col))
                .filter(col -> col.getName().startsWith(identifier))
                .forEach(addMapping);
        } else {
            columns
                .filter(col -> col.getName().equals(identifier))
                .findFirst()
                .ifPresent(addMapping);
        }
    }
    
    public String deResolve(Column element, ReferentialConstraint container, EReference reference) {
        return element.getName();
    }

    public void setOptions(Map<?, ?> options) {
    }

}


9 Автодополнение не ссылочных атрибутов


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

public class SqlProposalPostProcessor
package org.emftext.language.sql.resource.sql.ui;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;

import org.eclipse.emf.ecore.EAttribute;
import org.emftext.language.sql.common.SQLScript;
import org.emftext.language.sql.resource.sql.analysis.Helper;
import org.emftext.language.sql.schema.SchemaPackage;
import org.emftext.language.sql.schema.TableDefinition;

public class SqlProposalPostProcessor {

    public List<SqlCompletionProposal> process(List<SqlCompletionProposal> proposals) {
        List<SqlCompletionProposal> newProposals = new ArrayList<SqlCompletionProposal>();

        EAttribute catalogNameFeature = SchemaPackage.eINSTANCE.getTableReference_CatalogName();
        EAttribute schemaNameFeature = SchemaPackage.eINSTANCE.getTableReference_SchemaName();
        
        for (SqlCompletionProposal proposal : proposals) {
            if (catalogNameFeature.equals(proposal.getStructuralFeature())) {
                addTableReferenceProposal(newProposals, proposal, table -> table.getSchemaQualifiedName().getCatalogName());
            }
            else if (schemaNameFeature.equals(proposal.getStructuralFeature())) {
                addTableReferenceProposal(newProposals, proposal, table -> table.getSchemaQualifiedName().getSchemaName());
            }
            else {
                newProposals.add(proposal);
            }
        }
        
        return newProposals;
    }

    private static void addTableReferenceProposal(List<SqlCompletionProposal> proposals, SqlCompletionProposal oldProposal,
            Function<TableDefinition, String> nameGetter) {
        SQLScript sqlScript = (SQLScript) oldProposal.getRoot();
        String prefix = oldProposal.getPrefix().toUpperCase();
        sqlScript.getStatements().stream()
                .filter(stmt -> stmt instanceof TableDefinition)
                .map(table -> (TableDefinition) table)
                .map(nameGetter)
                .filter(name -> name != null && name.length() > 0)
                .filter(name -> name.toUpperCase().startsWith(prefix))
                .forEach(name -> proposals.add(new SqlCompletionProposal(
                        oldProposal.getExpectedTerminal(), Helper.formatIdentifier(name), oldProposal.getPrefix(), true,
                        oldProposal.getStructuralFeature(), oldProposal.getContainer())));
    }

}


Реализация, наверное, далека от идеала, но идея должна быть понятна. Для атрибутов catalogName или schemaName некоторой ссылки на таблицу (TableReference) значения, которые EMFText предлагает по умолчанию, заменяем на имена каталогов или схем, которые уже упоминались в скрипте.

Также в EMFText есть опция overrideCompletionProposal, которая, судя по названию, позволяет использовать собственную реализацию автодополнения, а не просто постобработчик, но лично я этого не делал.

10 Тестирование редактора и парсера SQL


После перегенерации кода, запустите второй экземпляр Eclipse и создайте тестовый SQL-скрипт.

Как видите, скрипт успешно парсится: слева его абстрактное синтаксическое дерево, снизу свойства некоторого узла.



Также работает автодополнение, как для ссылок, так и для обычных атрибутов:



11 Тестирование кодогенератора SQL


Сохраните SQL-скрипт в XMI-формате. И вы получите абстрактное синтаксическое дерево, которое можно редактировать:



Я переименовал таблицу «user» в «person» и столбец «NAME» в «FullName».

Сохраните абстрактное синтаксическое дерево в SQL-формате и убедитесь, что изменения учтены:



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

Заключение


После прочтения данной статьи вы сможете реализовать парсер, кодогенератор и редактор некоторого языка с помощью EMFText.

Важно отметить, что только редактор SQL привязан к Eclipse, а парсер и кодогенератор могут совершенно спокойно работать и без Eclipse. Многие люди воспринимают Eclipse сугубо как IDE, а проекты типа EMFText, как плагины, которые могут существовать только внутри Eclipse. Это совсем не так.

Как обычно, проект, рассматриваемый в статье, доступен на GitHub.

Зачем всё это нужно вы узнаете в следующей статье :)
  • +18
  • 8,2k
  • 6
ООО «ЦИТ» 45,95
ИКТ-решения и сервисы для органов власти и бизнеса
Поделиться публикацией
Комментарии 6
  • +1
    Я не понял, почему у вас используется ANTLR 3 (судя по репозиторию), когда есть гораздо лучший четвертый?
    Или его внутренне использует EMFText?

    Ну и для какого диалекта вы разрабатывали парсер: для чистого SQL или все же для PL/SQL (разница между TSQL и PL/SQL достаточно большая). Для PL/SQL существует грамматика как для 3, так и для 4 версии ANTLR.
    • 0
      Да, вы правы, действительно используется ANTLR 3, на который завязан EMFText :)

      Грамматика описана на немного другом языке (хотя и очень похожем), поэтому существующие грамматики не подходят.

      Зачем это нужно, если уже есть парсеры? Цели две. Во-первых, образовательная — показать как реализовывать языки с помощью EMFText, Xtext или чего-то подобного. Во-вторых, в следующей статье мы будем генерить SQL-код, а реализации на которые вы ссылаетесь умеют только парсить, а генерить SQL из синтаксического дерева не могут.

      EMFText — это фактически ANTLR + EMF (Eclipse Modeling Framework). Разработчикам задавали вопрос, почему они не переходят на ANTLR 4. На что они ответили, что вообще такие планы есть, но особых преимуществ это не даст. Потому что EMFText не позволяет использовать напрямую значительную часть фич ANTLR. А в плане производительности они ANTLR 3 уже пропатчили.

      Я реализовывал чистый SQL:2003. Хотя от скриптов на чистом SQL (которые будем генерить в следующей статье) наверное не очень много пользы.​ Но для демонстрации идеи нормально, допилить поддержку других диалектов уже не очень сложно.
      • 0
        Cудя по сайту EMFText уже года два как не поддерживается: не выходят новые версии. Проблема в том, что ANTLR 3 уже официально не поддерживается.
        • 0
          Это не совсем так. Последний релиз что ли был действительно 2 года назад. Сейчас они в основном исправляют ошибки без каких-то существенных изменений, отвечают на вопросы (хотя и с задержкой и не всегда), т.е. какая-то поддержка всё-таки есть. Но я не считаю, что это очень большая проблема.

          Во-первых, я рассказываю не столько о конкретном инструменте, сколько в принципе о подходах и инструментах, которые используются в модельно-ориентированной разработке. Есть аналогичный проект — Xtext, с гораздо более мощным комьюнити, чем у EMFText, активно развивающийся. Он основан примерно на тех же идеях, что и EMFText. Можно использовать его.

          Во-вторых, EMFText работает :) Ну, и что, что существенно не обновляется, свои задачи он решает :)

          В-третьих, он с открытым исходным кодом, и какие-то вещи в нём можно допиливать самостоятельно. Фактически, это надстройка над ANTLR и EMF, которая сильно упрощает жизнь. Если бы его не было, то пришлось бы что-то подобное писать самостоятельно. А он уже написан.

          Возвращаясь к ANTLR 3. Есть несколько вариантов. Можно забить на то, что он не поддерживается, если всё работает. Можно использовать Xtext или что-то подобное. Можно допилить поддержку ANTLR 4 самостоятельно.

          Но в следующей статье нам парсер SQL вообще не понадобится. Всё это затевалось ради кодогенератора SQL, который на ANTLR никак не завязан.

          Основная фича EMFText в том, что описав грамматику и метамодель языка мы получаем не только парсер, но и кодогенератор, и редактор, и заготовки для интерпретатора, отладчика.
        • 0
          Я правильно понял, что у вас сейчас только предложение CREATE TABLE поддерживается и всё?
          • 0
            Да, причём, частично. Но я и не ставил цель реализовать SQL полностью. Я хотел описать как в принципе можно реализовывать предметно-ориентированные языки. И ещё выражения для создания таблиц потребуются в следующей статье, где мы будем генерить SQL-скрипты.

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

        Самое читаемое