
Иногда возникает задача парсинга произвольного DSL для дальнейшей работы с ним на уровне PHP кода. И я хочу поделиться опытом решения этой проблемы с примерами.
Достаточно долгое время я пользуюсь сервисом dbdiagram для проектирования структуры БД для будущих или существующих проектов. Данный сервис я выбрал потому, что он достаточно прост в использовании. Описываем структуру таблиц на языке DBML и сразу видим результат.

Как я говорил ранее, данный сервис использует DBML для описания структуры БД, который они же и придумали и написали спецификацию по нему.
Зачем парсить
После долгого пользования сервисом и создания массивных схем БД, в голове периодически возникала идея: как бы эту схему превратить в PHP код, чтобы потом из этого кода сгенерировать модели и миграции, например для Laravel фреймворка.

Первая попытка
Я решил не изобретать велосипед, а изучить парсер написанный на GO, который работает на конечных автоматах, и повторить все то, что она делает на PHP.
Парсер работает следующим образом: разбивка посимвольно всего документа, токенизация (разбор входной последовательности символов на распознаваемые группы (лексемы) с целью получения на выходе идентифицированных последовательностей, называемых «токенами»). Ну собственно итогом токенизации являетя последовательнось (массив) токенов со значениями.
Пример
// Описание колонки таблицы // full_name varchar [not null, unique, default: 1] // Поток токенов в JSON формате [ {"name": "IDENT", "string": "full_name", "pos": [0, 9]}, {"name": "IDENT", "string": "varchar", "pos": [0, 17]}, {"name": "LBRACK", "string": "[", "pos": [0, 18]}, {"name": "NOT", "string": "not", "pos": [0, 22]}, {"name": "NULL", "string": "null", "pos": [0, 27]}, {"name": "COMMA", "string": ",", "pos": [0, 27]}, {"name": "UNIQUE", "string": "unique", "pos": [0, 35]}, {"name": "COMMA", "string": ",", "pos": [0, 35]}, {"name": "DEFAULT", "string": "default", "pos": [0, 44]}, {"name": "COLON", "string": ":", "pos": [0, 44]}, {"name": "INT", "string": "1", "pos": [0, 46]}, {"name": "RBRACK", "string": "]", "pos": [0, 47]} ]
После того как токены определены, необходимо пройтись по каждому из них и создать абстрактное синтаксическое дерево.
Рассмотрим следующий пример
// Описание проекта в формате DBML Project test { database_type: 'PostgreSQL' Note: 'Description of the project' }
И последовательность токенов для него
[ {"name":"PROJECT","string":"Project","pos":[0,7]}, {"name":"IDENT","string":"test","pos":[0,12]}, {"name":"LBRACE","string":"{","pos":[0,13]}, {"name":"IDENT","string":"database_type","pos":[1,15]}, {"name":"COLON","string":":","pos":[1,15]}, {"name":"DSTRING","string":"PostgreSQL","pos":[1,18]}, {"name":"NOTE","string":"Note","pos":[2,6]}, {"name":"COLON","string":":","pos":[2,6]}, {"name":"DSTRING","string":"Description of the project","pos":[2,9]}, {"name":"RBRACE","string":"}","pos":[3,0]} ]
По токену PROJECT мы понимаем, что далее нас ждет описание проекта и последовательно пытаемся провалидировать структуру проекта.
Пример реализации
<?php // последовательность токенов $tokens = TokenCollection(...); // Токен который должен содержать название проекта $token = $tokens->nextToken(); // Если токен не является строкой и строкой в ковычках, то это не название if (!$token->is(Token::IDENT) && !$token->is(Token::DSTRING)) { throw new ParserException('Project does not have a name'); } $name = $token->getString(); // Переход к следующему токену LBRACE $token = $tokens->nextToken(); if (!$token->is('LBRACE')) { throw new ParserException('Expects {'); } $project = new Project($name); // Пробегаемся по токенам до тех пор пока не дойдем до закрывающей скобки do { $token = $tokens->nextToken(); switch ($token->getName()) { case Token::IDENT: switch ($token->getString()) { case 'database_type': $project->setDbType(...); break; default: throw new ParserException('Expects database_type'); } break; // Если токен с комментарием, то добавляем в проект коментарий case Token::NOTE: $project->setNote(...); break; // Если закрвающая скобка, то выходим из цикла case 'RBRACE': return $project; default: throw new ParserException(sprintf('Invalid token %s', $token->getString())); } } while ($tokens->valid());
После реализации на 50% парсинга всех структур DBML, нервы не выдержали и хотя структура проекта достаточно простая, и кажется что все легко, но дальше появляются гораздо более сложные конструкции с другими вложенными конструкциями, валидировать становится все очень сложно и я решил отказаться от этого подхода и посмотреть в сторону других решений.
Библиотека phplrt
Буквально на днях на конференции PHP Russia 2021 @SerafimArtsвыступал с докладом о своем инстуременте phplrt, который занимается синтаксическим разбором языка и построением AST. Т.е. данный инструмент решает мою задачу и совершенно другим способом.
Если совсем грубо говоря, то это парсер, который позволяет описать структуру, в моем случае DBML, используя синтаксис EBNF. Согласно EBNF phplrt разбирает структуру и достает из нее значения, токенизирует по своим алгоритмам и строит AST. Далее мы можем работать с построенным деревом.
phplrt использует EBNF для генерирации регулярки или компиляции php файла, который содержит все необходимые инструкции для разбора синтаксиса языка.
Регулярка не для слабонервных для DBML :)))
\G(?|(?:(?:\s+)(*MARK:T_WHITESPACE))|(?:(?:\/\/[^\n]*\n)(*MARK:T_COMMENT))|(?:(?:(?<=\b)true\b)(*MARK:T_BOOL_TRUE))|(?:(?:(?<=\b)false\b)(*MARK:T_BOOL_FALSE))|(?:(?:(?<=\b)null\b)(*MARK:T_NULL))|(?:(?:(?<=\b)Project\b)(*MARK:T_PROJECT))|(?:(?:(?<=\b)Table\b)(*MARK:T_TABLE))|(?:(?:(?<=\b)as\b)(*MARK:T_TABLE_ALIAS))|(?:(?:(?<=\b)(Indexes|indexes)\b)(*MARK:T_TABLE_INDEXES))|(?:(?:(Ref|ref))(*MARK:T_TABLE_REF))|(?:(?:(?<=\b)TableGroup\b)(*MARK:T_TABLE_GROUP))|(?:(?:(?<=\b)(Enum|enum)\b)(*MARK:T_ENUM))|(?:(?:(?<=\b)(primary\ske|pk)\b)(*MARK:T_TABLE_SETTING_PK))|(?:(?:(?<=\b)unique\b)(*MARK:T_TABLE_SETTING_UNIQUE))|(?:(?:(?<=\b)increment\b)(*MARK:T_TABLE_SETTING_INCREMENT))|(?:(?:(?<=\b)default\b)(*MARK:T_TABLE_SETTING_DEFAULT))|(?:(?:(?<=\b)null\b)(*MARK:T_TABLE_SETTING_NULL))|(?:(?:(?<=\b)not\snull\b)(*MARK:T_TABLE_SETTING_NOT_NULL))|(?:(?:(?<=\b)cascade\b)(*MARK:T_REF_ACTION_CASCADE))|(?:(?:(?<=\b)restrict\b)(*MARK:T_REF_ACTION_RESTRICT))|(?:(?:(?<=\b)set\snull\b)(*MARK:T_REF_ACTION_SET_NULL))|(?:(?:(?<=\b)set\default\b)(*MARK:T_REF_ACTION_SET_DEFAULT))|(?:(?:(?<=\b)no\saction\b)(*MARK:T_REF_ACTION_NO_ACTION))|(?:(?:(?<=\b)delete\b)(*MARK:T_REF_ACTION_DELETE))|(?:(?:(?<=\b)update\b)(*MARK:T_REF_ACTION_UPDATE))|(?:(?:note:)(*MARK:T_SETTING_NOTE))|(?:(?:(?<=\b)Note\b)(*MARK:T_NOTE))|(?:(?:[0-9]+\.[0-9]+)(*MARK:T_FLOAT))|(?:(?:[0-9]+)(*MARK:T_INT))|(?:(?:('{3}|["']{1})([^'"][\s\S]*?)\1)(*MARK:T_QUOTED_STRING))|(?:(?:(`{1})([\s\S]+?)\1)(*MARK:T_EXPRESSION))|(?:(?:[a-zA-Z0-9_]+)(*MARK:T_WORD))|(?:(?:\\n)(*MARK:T_EOL))|(?:(?:\()(*MARK:T_LPAREN))|(?:(?:\))(*MARK:T_RPAREN))|(?:(?:{)(*MARK:T_LBRACE))|(?:(?:})(*MARK:T_RBRACE))|(?:(?:\[)(*MARK:T_LBRACK))|(?:(?:\])(*MARK:T_RBRACK))|(?:(?:\>)(*MARK:T_GT))|(?:(?:\<)(*MARK:T_LT))|(?:(?:,)(*MARK:T_COMMA))|(?:(?::)(*MARK:T_COLON))|(?:(?:\-)(*MARK:T_MINUS))|(?:(?:\.)(*MARK:T_DOT))|(?:(?:.+?)(*MARK:T_UNKNOWN)))
Ну а теперь разберем пару примеров
Вернемся к нашему примеру со структурой проекта
// Описание проекта Project test { database_type: 'PostgreSQL' Note: 'Description of the project' }
И опишем основные токены для этой структуры, т.е. опишем элементы, которые встречаются в ней.
// Ключевое слово для структуры проекта %token T_PROJECT (?<=\b)Project\b // Ключевое слово для заголовка комментария %token T_NOTE (?<=\b)Note\b // Строка, в кавычках %token T_QUOTED_STRING ('{3}|["']{1})([^'"][\s\S]*?)\1 // Любые слова %token T_WORD [a-zA-Z_]+ // Символы которые встречаются %token T_LBRACE { %token T_RBRACE } %token T_COLON : %token T_EOL \\n // Символы, которые хотим игнорировать в процессе разбора %skip T_WHITESPACE \s+
Ну а теперь самое интересное, давайте расскажем парсеру о струтктуре проекта с помощью токенов
#Project : ::T_PROJECT:: <T_WORD> ::T_LBRACE:: ::T_EOL:: // Здесь должны быть строки с внутренними параметрами ::T_RBRACE:: ::EOL:: ; // #Project - это правило (типа как функция), которое можно включать в // другие правила // Токены ::TOKEN_NAME:: исключаются из AST // Токены <TOKEN_NAME> показываются в результате и с ними можно работать
Я пока что не стал описывать внутреннюю структуру проекта, а только внешнюю часть, чтобы не запутать.
А теперь опишем внутренню часть
// Строка database_type: 'PostgreSQL' #ProjectSetting : <T_WORD> ::T_COLON:: (<T_WORD> | <T_QUOTED_STRING>) ; // Строка Note: 'Description of the project' #Note : ::T_NOTE:: ::T_COLON:: (<T_WORD> | <T_QUOTED_STRING>) ; // ( <T_WORD> | <T_QUOTED_STRING> ) говорит о том, что в этом месте может бы // быть или слово или слово в кавычках
Ну а теперь соберем все воедино
#DBML : // Наша DBML схема может содержать один или несколько из правил // О чем говорит симовл (...)* ( Project() // Project() | // Table() | // TableGroup() | // Enum() | // Ref() )* ; #Project : ::T_PROJECT:: <T_WORD> ::T_LBRACE:: ::T_EOL:: // Каждое из правил может повторяться 0 или несколько раз (ProjectSetting() | Note() ::T_EOL::)* ::T_RBRACE:: ::T_EOL:: ;
Общая идея надеюсь понятна. Я намеренно упростил схему, чтобы ее легче было понять. Полноценную рабочую схему можно посмотреть здесь.
По итогу в тестах мы можем получать xml представление нашего дерева.
Пример
<DBML offset="0"> <Project offset="0"> <T_WORD offset="8">project_name</T_WORD> <ProjectSetting offset="27"> <T_WORD offset="27">database_type</T_WORD> <T_QUOTED_STRING offset="42">'PostgreSQL'</T_QUOTED_STRING> </ProjectSetting> <Note offset="59"> <T_QUOTED_STRING offset="65">'Description of the project'</T_QUOTED_STRING> </Note> </Project> </DBML>
Окей, схему определили, XML получили, что дальше?
А дальше нам нужно превратить все это в объекты. В phplrt есть такое понятие как PHP инъекции
!!!Важно!!! Работают толко после компиляции. При генерации XML на лету, они игнорируются.
Инъекции позволяют для каждого правила в нашей схеме сопоставить объект, в который будут переданы все значения правила.
#Project -> { return new ProjectNode( // $children - список внутренних настроек // в виде объектов \Butschster\Dbml\Ast\Project\SettingNode // или \Butschster\Dbml\Ast\NoteNode $token->getOffset(), $children ); } #ProjectSetting -> { return new SettingNode( // \current($children) - ключ // \end($children) - значение $token->getOffset(), \current($children), \end($children) ); } #Note -> { return new NoteNode( // \end($children) текст комментария $token->getOffset(), \end($children) ); }
Пример PHP классов
<?php class ProjectNode { private ?string $note = null; /** @var SettingNode[] */ private array $settings = []; private string $name; public function __construct( private int $offset, array $children ) { foreach ($children as $child) { if ($child instanceof NoteNode) { $this->note = $child->getDescription(); } else if ($child instanceof SettingNode) { $this->settings[$child->getKey()] = $child; } else if ($child instanceof NameNode) { $this->name = $child->getValue(); } } } public function getName(): string { return $this->name; } public function getNote(): ?string { return $this->note; } public function getSettings(): array { return $this->settings; } } class NoteNode { private string $description; public function __construct(private int $offset, StringNode $string) { $this->description = $string->getValue(); } public function getDescription(): string { return $this->description; } } class SettingNode { private string $key; private string $value; public function __construct( private int $offset, SettingKeyNode $key, StringNode $value ) { $this->key = $key->getValue(); $this->value = $value->getValue(); } public function getKey(): string { return $this->key; } public function getValue(): string { return $this->value; } }
Ну я думаю идея понятна. После того, как парсер получает DBML документ, он его раскладывает по объектам согласно описанным правилам.
Процесс написания парсера с использованием phplrt занял несколько дней и сам процесс создания EBNF выглядел следующим образом:
-
Брал структуру из DBML документа
-
описывал её в EBNF
-
генерировал XML структуру, которая покрывалась тестами
Пример теста
<?php class ProjectParserTest extends TestCase { function test_project_with_single_line_note_should_be_parsed() { $this->assertAst(<<<DBML Project project_name { Note: 'Description of the project' database_type: 'PostgreSQL' } DBML , <<<AST <Schema offset="0"> <Project offset="0"> <ProjectName offset="8"> <String offset="8"> <T_WORD offset="8">project_name</T_WORD> </String> </ProjectName> <Note offset="27"> <String offset="33"> <T_QUOTED_STRING offset="33">'Description of the project'</T_QUOTED_STRING> </String> </Note> <ProjectSetting offset="66"> <ProjectSettingKey offset="66"> <T_WORD offset="66">database_type</T_WORD> </ProjectSettingKey> <String offset="81"> <T_QUOTED_STRING offset="81">'PostgreSQL'</T_QUOTED_STRING> </String> </ProjectSetting> </Project> </Schema> AST ); } function test_project_with_multi_line_note_should_be_parsed() { $this->assertAst(<<<DBML Project project_name { database_type: 'PostgreSQL' Note: ''' # DBML - Database Markup Language (database markup language) is a simple, readable DSL language designed to define database structures. ## Benefits * It is simple, flexible and highly human-readable * It is database agnostic, focusing on the essential database structure definition without worrying about the detailed syntaxes of each database * Comes with a free, simple database visualiser at [dbdiagram.io](http://dbdiagram.io) ''' } DBML , <<<AST <Schema offset="0"> <Project offset="0"> <ProjectName offset="8"> <String offset="8"> <T_WORD offset="8">project_name</T_WORD> </String> </ProjectName> <ProjectSetting offset="27"> <ProjectSettingKey offset="27"> <T_WORD offset="27">database_type</T_WORD> </ProjectSettingKey> <String offset="42"> <T_QUOTED_STRING offset="42">'PostgreSQL'</T_QUOTED_STRING> </String> </ProjectSetting> <Note offset="59"> <String offset="65"> <T_QUOTED_STRING offset="65">''' # DBML - Database Markup Language (database markup language) is a simple, readable DSL language designed to define database structures. ## Benefits * It is simple, flexible and highly human-readable * It is database agnostic, focusing on the essential database structure definition without worrying about the detailed syntaxes of each database * Comes with a free, simple database visualiser at [dbdiagram.io](http://dbdiagram.io) '''</T_QUOTED_STRING> </String> </Note> </Project> </Schema> AST ); } function test_project_with_block_note_should_be_parsed() { $this->assertAst(<<<DBML Project project_name { database_type: 'PostgreSQL' Note { 'This is a note of this table' } } DBML , <<<AST <Schema offset="0"> <Project offset="0"> <ProjectName offset="8"> <String offset="8"> <T_WORD offset="8">project_name</T_WORD> </String> </ProjectName> <ProjectSetting offset="27"> <ProjectSettingKey offset="27"> <T_WORD offset="27">database_type</T_WORD> </ProjectSettingKey> <String offset="42"> <T_QUOTED_STRING offset="42">'PostgreSQL'</T_QUOTED_STRING> </String> </ProjectSetting> <Note offset="59"> <String offset="74"> <T_QUOTED_STRING offset="74">'This is a note of this table'</T_QUOTED_STRING> </String> </Note> </Project> </Schema> AST ); } }
Как только я покрыл все вариации структуры DBML документа тестами и получен итоговой EBNF, то phplrt компилирует её в php код, который далее и будет использоваться для парсинга (Компилятор компилятора).
И результатом всего этого стал yet another DBML parser written on PHP8 с покрытием тестами всех структур (но это не точно) — https://github.com/butschster/dbml-parser
Первый этап в моем плане реализован. Теперь осталось сделать генератор моделей и миграций.
По итогам работы с инстурментом phplrt хочу выразить респект и уважение @SerafimArts за него, который помог в корне изменить подход к парсингу языка и решить мою задачу.
Отдельное спасибо @greabock и @SerafimArts за помощь в подготовке материала и помощи в разработке парсера.
Ссылки
-
DBML парсер — https://github.com/butschster/dbml-parser
-
Инструмент phplrt — https://github.com/phplrt/phplrt
-
Документация по phlrt — https://phplrt.org/
ссылка на оригинал статьи https://habr.com/ru/post/565694/
Добавить комментарий