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

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

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

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

Наш список требований будет включать следующее:

  1. Напишите интерфейс для таблиц в базе данных — это даст нам абстрактное представление данных, к которому может адаптироваться любой плагин.
  2. Настройте интерфейс для представления данных о взаимосвязях между таблицами — более продвинутая концепция для нашей миграции.
  3. Напишите интерфейс для подключаемых модулей для преобразования данных о взаимосвязях в сценарии для инструмента миграции — нам нужны миграции вверх и вниз и максимальное улавливание абстракции этих операций.
  4. Написание прототипа приложения, которое может считывать исходные файлы для экспортируемых классов и создавать данные с использованием интерфейсов — чтобы сначала протестировать функциональные аспекты моделирования кода базы данных.

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

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

Это многословно, но ChatGPT слишком рад угодить.

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

Первоначальный текстовый вывод немного удивил меня, поскольку я работал с TypeScript почти десять лет, поскольку я никогда не пытался импортировать или экспортировать что-либо из API-интерфейса компилятора TypeScript. Код довольно интересный:

Это потрясающее начало. Одновременно я узнаю о действительно простом и элегантном способе извлечения информации из API компилятора, который является шлюзом к определениям всего, что мне нужно, чтобы продолжить создание функционального прототипа моей идеи для решения Code-First, и написан код прототипа. для меня в реальном времени. Это раскрывает основную концепцию работы с ChatGPT, которую я нашел действительно интересной; он может справиться со многими исследованиями решений, если вы подскажете ему правильный путь.

Он даже включал пояснения о том, как это работает, которые можно было скопировать и вставить в файл кода в виде многострочного комментария без каких-либо дополнительных изменений:

Однако то, что приходит после, меньше того, что я искал; ChatGPT продолжает переопределять исходное решение в следующем файле кода, предполагая, что локальная база данных Postgres настроена, и добавляет Sequelize и настраивает модели, определенные выводом classInfoList:

Это не совсем то, что мы ищем, и, оглядываясь назад на нашу исходную подсказку, мы не хотели, чтобы происходила фактическая миграция. Мы просто хотели, чтобы был сгенерирован интерфейс, который мы могли бы использовать для «подключения» различных стратегий для создания миграций.

Итак, в нашем списке вещей, которые необходимо сделать, ChatGPT избавил нас от следующего:

  • Исследуя решение, о существовании которого мы не знали и, возможно, не нашли
  • Написание исходного прототипа, который может извлекать классы и зависимости из исходных файлов.

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

Давайте адаптируем часть этого кода, чтобы он был полезен, начнем с создания интерфейса, который нам нужен для нашего инструмента миграции Code-First. Мы начнем с определения типа TableInfo и вспомогательных типов, которые нам понадобятся для создания таблицы в сценарии:

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

  • Отношения. Причина, по которой мы сохраняем перечисленные отношения, заключается в том, что мы можем определить порядок создания и удаления таблиц, поскольку в большинстве СУБД активные отношения не позволяют удалять таблицы без каскадного удаления.
  • Столбцы — это основная часть определения таблицы, содержащая определения столбцов и то, как они представлены. Столбец содержит fieldName, datatype и nullable, которые говорят сами за себя, в то время как реквизит «модификаторы» заменяет определения наших ключей, таких как первичные/внешние ключи.

Что-то действительно интересное происходит в нашем типе KeyMetadata. Здесь у нас есть раздвоение типа, основанное на том, что на самом деле представляет собой модификатор. Для Modifier.PrimaryKey нам нужно только имя свойства, но для Modifier.ForeignKey нам также нужно сохранить имя цели (таблицы) и имя свойства.

Если мы напишем наш код так, как компилятор TypeScript может обдумать значение свойства модификатора, то созданный статический тип автоматически узнает, доступны ли эти свойства, без необходимости делать свойство «цель» обнуляемым.

Мы определили общий интерфейс для нашего Automigrate API. Это сделает средство миграции достаточно гибким, чтобы поддерживать несколько языков баз данных, если кто-то внедрил плагин для этой конкретной базы данных. Теперь, когда мы знаем, для какого интерфейса мы собираемся программировать, мы можем начать думать о том, как мы собираемся построить TableInfo с точки зрения модели CodeFirst.

Давайте напишем несколько примеров моделей. Мы определим набор таблиц с некоторыми классами TypeScript в первую очередь в коде:

Теперь у нас есть пара таблиц: Project, Feature, Person и Project_Persons. Здесь мы подразумеваем пару отношений, например, что функции принадлежат проектам, а проектам может быть назначено много людей, но эти люди должны управляться вне проектов.

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

Существует экспериментальная концепция TypeScript под названием декораторы, которая позволит нам помечать метаданные в этих полях. Пусть вас не пугает фраза экспериментальная, так как она уже давно совершенствуется. Чтобы включить их, мы войдем в файл tsconfig.json и пометим experimentalDecorators и emitDecoratorMetadata как true, чтобы получить поддержку в компиляторе. Как только это будет завершено, мы сможем определить некоторые декораторы для первичных и внешних ключей:

Это определяет два новых декоратора: PrimaryKey и ForeignKey, а также методы, которые могут извлекать метаданные через библиотеку отражения. Существование «цели» здесь подразумевает, что нам нужно будет вызывать эти методы для созданного экземпляра класса, чтобы извлечь из него метаданные. Это может немного усложнить нашу реализацию позже, но по большей части это должно позволить нам эффективно создавать наши сценарии развертывания.

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

Все эти тесты выполняются и проходят успешно, так что мы можем использовать эти новые декораторы в наших моделях, основанных на коде. Здесь мы будем использовать diff, чтобы показать наш скорректированный файл CodeFirst.ts:

Теперь, когда мы подтвердили наши примеры моделей, поддержали ссылочные метаданные и настроили интерфейсы плагинов, мы можем заняться сутью проблемы: извлечь метаданные определений классов через API-интерфейс компилятора TypeScript.

Давайте вернемся к исходному выводу кода ChatGPT. Он создал некоторый код, который получает файлы из каталога, запускает их через API TypeScript для получения ClassInfo, а затем возвращает это как имена и зависимости. В целом, это тот код, который нам нужно написать, чтобы он работал для нас. Давайте разложим этот код на некоторые связанные обязанности, чтобы мы могли рассуждать о них изолированно и разъяснять, что происходит.

Я скажу, что мы не хотим обрабатывать извлечение файлов из источника в основной части приложения. Это должно происходить вне его, поэтому мы собираемся создать новый класс Modeler, у которого будет метод статического извлечения для извлечения TableInfo из набора исходных файлов. Таким образом, когда кто-то еще захочет использовать нашу библиотеку, он сможет выбрать, где будут храниться исходные файлы.

Мы сохраним использование компилятором TypeScript типа SourceFiles и реализуем метод извлечения кода, чтобы мы могли протестировать его в каталоге примеров:

Здесь мы создаем класс SourceExtractor с двумя статическими методами — getSourceFile и getFilesFrom, которые принимают строковые аргументы для чтения файлов и получения для них TypeScript SourceFile. Теперь это может стать аргументом для разработчика моделей, который интерпретирует определения классов и создает типы API наших плагинов.

Во-первых, давайте начнем разбирать и создавать методы, которые позволят нам разбить тип исходного файла на полезные данные:

Здесь есть несколько методов, которые помогут нам проанализировать исходные файлы.

  • tryGetTargetInstance — возвращает экземпляр класса из наших моделей. Если класс не зарегистрирован во время выполнения, он выдаст ошибку, которая явно сообщает пользователю, почему выполнение не выполняется и что с этим делать.
  • getPropertiesOf — Синтаксический сахар при фильтрации свойств членов класса
  • getClassesFrom — Синтаксический сахар по сравнению с фильтрацией классов из исходного файла
  • processClassFile — это всего лишь метод-заглушка для того, что мы ожидаем от обработки свойства одного класса. Наш идеальный результат — это полное ColumnDefinition или false, если свойство по какой-то причине непредставимо.
  • getColumnDefinitionsFrom — это метод, который мы будем вызывать для создания наших ColumnDefinitions. Он также будет отфильтровывать любые ошибки синтаксического анализа из вывода.

Теперь мы можем настроить экстрактор на использование вспомогательных методов из парсера исходного файла.

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

Кроме того, метод извлечения прост; он использует файлы, которые мы будем извлекать с помощью экстрактора исходного кода, и зарегистрированные типы для создания нашего TableInfo[], все это обеспечивается статической проверкой типов. Он выполняет итерацию по каждому файлу, извлекает из него классы, затем отправляет эти классы через метод getColumnDefinitions, чтобы получить определения нашего интерфейса. Он также использует нашу вспомогательную функцию, поэтому у нас будет вся информация, необходимая для создания миграций.

Сейчас самое время посмотреть, как использование библиотеки может выглядеть для конечного пользователя. Ранее мы рассмотрели файл примера code-first, но как его интегрировать? Напишем краткую разбивку:

Довольно просто! Сначала мы получаем целевые файлы (в данном случае только файл CodeFirst.ts), выполняем извлечение модели в исходных файлах из файла и можем вывести интерфейсы TableInfo в файл состояния. В конечном итоге мы, вероятно, будем реализовывать процесс сравнения между предыдущими миграциями, чтобы генерировать сценарии только для изменений, представляющих различия между локальной средой и вновь внесенными изменениями.

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

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

Одна концепция, с которой мы будем часто сталкиваться, заключается в том, что API-интерфейс компилятора TypeScript имеет свои собственные внутренние представления синтаксиса, которые присутствуют в перечислении с именем SyntaxKind. Перечисление SyntaxKind имеет представления для каждого фрагмента кода, распознаваемого компилятором. Доступ к этому можно получить с помощью fn+F12 в редакторе, чтобы перейти к определению SyntaxKind в typescript.d.ts:

Итак, похоже, что нам понадобится набор функций, предназначенных для интерпретации вывода компилятора TypeScript и возврата нашего представления этих типов (DatabaseType) для интерфейса, над которым мы работаем.

Сначала напишем это:

typescriptSyntaxKindToDatabaseTypeMap существует как антикоррупционный слой для нашей библиотеки, позволяя нам быть гибкими, если мы хотим что-то изменить. Основываясь на том факте, что большинство из них являются довольно прямым переводом, может возникнуть вопрос, почему мы не используем здесь просто SyntaxKind и просто не возвращаем undefined, когда сталкиваемся с SyntaxKind.UndefinedKeyword и SyntaxKind.TypeReference. Ответ заключается в том, что мы не знаем, всегда ли эта библиотека будет использовать компилятор TypeScript.

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

После этого метод extractKeyData создает декоратор (ключевые данные AKA) из экземпляров объектов нашего класса при интерпретации файлов классов. Это необходимая часть создания ColumnDefinitions для будущих миграций.

Ранее мы добавили заглушку метода под названием processClassProperty в качестве замены для кода, который мы собираемся написать. Теперь мы удалим эту заглушку и воссоздадим ее в новом файле, где мы будем обрабатывать большую часть информации о свойствах через API компилятора TypeScript.

«Мясо» оригинальной реализации ChatGPT — это место, где мы немного узнали об API-интерфейсе компилятора TypeScript. В рамках этого мы узнали, что каждое объявление свойства имеет много связанных значений, а API компилятора предоставляет несколько упрощенных помощников для оценки узлов. Мы также узнаем немного о том, как статическая проверка типов будет представлять типы после того, как мы проведем некоторые проверки.

Это довольно большой фрагмент кода, поэтому давайте сначала попробуем отделить его от нашего метода processClassProperty и просмотреть различные пути кода. Для этого мы составим диаграмму русалки, которая интегрируется на GitHub, чтобы мы могли добавить ее в README вместе с остальной нашей документацией.

flowchart TD
 A[processClassProperty]
 A --> B{Is Union Type?} 
 B --> |No| singleNode[processSingularNode]
 B --> |Yes| C[Definition is Valid]
 C --> |No| E[Error]
 C --> |Defined Type| singleNode
 singleNode --> typeRefCheck{Node Type?}
 typeRefCheck --> |TypeReference or date| pTypeRef[processFromTypeReferenceNode]
pTypeRef --> return
 typeRefCheck --> |Array| arrayCheck{Is array type?}
 arrayCheck --> retArr[Retrieve array type]
 retArr --> typeRefCheck
 typeRefCheck --> |Basic Type| basicType[typescriptSyntaxKindToDatabaseTypeMap]
 return((return ColumnDefinition))
 basicType --> return
 typeRefCheck --> |Else| error[Error]

Мы видим, что мы разделяем первый вызов на то, является ли свойство типом объединения. Это связано с тем, что типы объединения — это один из способов представления значения, допускающего значение NULL, в модели базы данных, например «archived: boolean | неопределенный." С другой стороны, если кто-то попытается написать свою модель, позволяющую «string | число», это было бы нерепрезентативно в большинстве баз данных, с которыми мы собираемся работать, поэтому мы выдадим явную ошибку всем, кто попытается это сделать. Для условия else мы будем обрабатывать узел «not undefined type» в обычном режиме.

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

  • Array — Поскольку array — это собственный тип с дополнительными узлами под ним для определения значения в массиве, мы должны извлечь основной тип массива, а затем отправить его обратно в processSingularNode call. Он может обрабатывать возврат оттуда.
  • TypeReference TypeReference в данном случае означает объявленное свойство, которое представляет собой класс, определенный пользователем, или тип даты из самого JavaScript. Мы обработаем оба случая, но сейчас мы ничего не делаем с другими небазовыми типами.
  • Базовый тип — здесь мы сопоставляем наш тип TypeScript с типом базы данных для вызова API и возвращаем значение.

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

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

И давайте напишем несколько тестов, чтобы мы знали, что наши таблицы будут эффективно отсортированы:

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

Теперь давайте создадим быстрый плагин Postgres:

Этот плагин немного тяжеловат, но давайте быстро его разберем:

  • postgresMap просто обеспечивает связь наших абстракций DatabaseType с типами данных в Postgres, когда мы создаем определения столбцов.
  • columnMap — это то, как создаются столбцы. Мы создаем на карте массив возможных вариантов, фильтруем его по логическому значению, чтобы удалить мусор, а затем соединяем их одним пробелом. Внутри этого также находится обозначение первичного/внешнего ключа для столбцов.
  • relationshipMap — небольшой помощник для сопоставления отношений внешнего ключа.
  • tableCreateMapper довольно прост, создавая один полный сценарий создания таблицы.
  • tableDropMapper аналогично, но для удаления таблиц
  • PostgresPlugin — это класс, который фактически реализует AutomigrateAPI. Он объединяет другие функции в одном месте для создания AutoMigrateOutput, который представляет собой просто миграцию вверх и вниз для применения к базе данных.

Хорошо, теперь, когда у нас есть весь наш код и написаны модели, давайте проведем тестовый запуск! Во-первых, мы собираемся изменить наш пример индексного файла, чтобы использовать подключаемый модуль Postgres, который мы только что написали:

Приведенная выше разница показывает, что мы удаляем и заменяем пример плагина экземпляром PostgresPlugin. Преимущество такого использования интерфейсов в том, что мы можем оперативно заменять этот фрагмент кода, и ничего больше в этом тесте менять не нужно; все работает так же. Когда мы выполним, мы создадим два ожидаемых вывода: файл state/state.json, который включает снимок текущего выполнения, и файл scripts/scripts.json, который будет включать миграции, определенные нашим интерфейсом API (вверх и вниз в виде строковых массивов).

Наше выходное состояние содержит массив информации о нашей таблице, который включает в себя имя таблицы, столбцы и массив строк связей с другими таблицами. Определения столбца включают имя поля, тип столбца, независимо от того, допускает ли он значение NULL, и модификаторы (ключи AKA). Все это нам нужно было отправить в наши плагины для создания действительных миграций. Это обрабатывает наши базовые случаи, но нам нужно будет хранить разные итерации файла состояния и различать их, чтобы правильно управлять плагинами в производственном варианте использования.

Давайте посмотрим, какие миграции производятся:

Здесь мы видим, что наш вывод соответствует нашему определению API. Объект JSON с массивами строк вверх и вниз представляет этапы процесса миграции. Мы видим, что операторы создания таблицы работают и что операторы отбрасываемой таблицы упорядочены для правильной очистки данных, чтобы избежать головной боли при миграции вниз. Это прекрасно выполняет наш базовый случай!

Следующие шаги

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

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

Это позволит нам применять различия миграции только между исполнениями кода библиотеки.

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

  • Удаление стола
  • Добавление/удаление/изменение столбца
  • Изменения отношений
  • Ручные скрипты (для миграции данных и др.)

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

Если вы хотите посмотреть на окончательный рабочий код, который включает конфиги для запуска с отладкой и сборкой локальной библиотеки для установки npm, вот ссылка на GitHub:



Спасибо за прочтение!