Написание парсера DBML на PHP

    Иногда возникает задача парсинга произвольного 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 выглядел следующим образом:

    1. Брал структуру из DBML документа

    2. описывал её в EBNF

    3. генерировал 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 за помощь в подготовке материала и помощи в разработке парсера.

    Ссылки

    Комментарии 0

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

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