DevGang
Авторизоваться

Ввод mixins ассоциации Sequelize с использованием TypeScript

При использовании пакета Sequelize ORM весьма вероятно, что в какой-то момент вам понадобится создать ассоциации между моделями. Используя примеры моделей:

import {
  Model,
  type InferAttributes,
  type InferCreationAttributes,
} from "sequelize";

class Foo extends Model<InferAttributes<Foo>, InferCreationAttributes<Foo>> {
  declare id: number;
  // ...
}

class Bar extends Model<InferAttributes<Bar>, InferCreationAttributes<Bar>>{
  declare id: number;
  // ...
}

Затем вы можете связать их, используя один из методов ассоциации в модели:

Foo.belongsTo(Bar); // creates one-to-one relation, FK is part of Foo

Теперь Foo связан с Bar.

Mixins

Согласно документации Sequelize:

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

Итак, при вызове Model.belongsTo (или любых других методов ассоциации) некоторые методы добавляются к экземплярам указанной модели. Вы знаете, что эти методы существуют, а TypeScript — нет. Итак, как бы нам ввести эти методы?

Ввод Mixins: «простой» способ

Пакет @types/sequelize, используемый для получения типов для Sequelize, любезно относится к нам, предоставляя предварительно объявленные универсальные типы для каждого из методов примеси. Используя их, вы можете ввести методы следующим образом:

class Foo extends Model<InferAttributes<Foo>, InferCreationAttributes<Foo>> {
  // ...

  // declare methods (implementations are defined by Sequelize)
  declare getBar: BelongsToGetAssociationMixin<Bar>;
  declare setBar: BelongsToSetAssociationMixin<Bar, number>;
  declare createBar: BelongsToCreateAssociationMixin<Bar>;
}

Это легко и понятно, но у этого метода есть несколько проблем:

  • Для полноты картины каждый mixin должен быть объявлен для каждой ассоциации. Это означает, что тело модели может оказаться раздутым из-за десятков объявлений примесей по мере добавления ассоциаций.
  • Вводить каждый тип примеси, даже для одной ассоциации, утомительно (даже с автозаполнением).
  • Ассоциации «многие-ко-многим» создают в общей сложности 10 методов примеси каждая!
  • Все типы методов mixins необходимо импортировать, что увеличивает размер импортируемых файлов.
  • Что происходит, когда ассоциация изменяется? Вам необходимо изменить или удалить каждое объявление.

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

Ввод Mxixns: практический способ

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

Ну, во-первых, нам нужно подумать о Mixin ассоциации, поэтому давайте извлечем их к типу:

type FooBarMixins = {
  getBar: BelongsToGetAssociationMixin<Bar>;
  setBar: BelongsToSetAssociationMixin<Bar, number>;
  createBar: BelongsToCreateAssociationMixin<Bar>;
}

Хорошо, теперь у нас есть все Mixins, можем ли мы провести дальнейший рефакторинг? Может быть, создать общий тип для ассоциаций ownTo? Есть несколько типов, которые мы могли бы использовать для конфигурации, например, модель и тип первичного ключа модели!...

Но каждое из имен свойств является производным от имени ассоциации, которое мы присваиваем Sequelize (полученного из имени другой модели, если оно не указано). К счастью, это автоматическое именование следует нескольким правилам, которые мы тоже могли бы использовать!

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

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

Эксперимент: создание типа с настраиваемым именем свойства.

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

type SetMixin<Name extends string> = {
  [`set${Capitalize<Name>}`]: any; // we'll get back to the property type later
}
error TS1170: имя вычисляемого свойства в литерале типа должно относиться к выражению, тип которого является литеральным типом или типом «уникальный символ».

Ах... этот синтаксис предполагает значение в скобках, которое может быть только литералом или символом. Нам нужен синтаксис, который бы принимал тип и создавал из него свойство. А что произойдет, если Name будет объединением типов строковых литералов? Возможно, следует создать несколько свойств, `set${Capitalize<Name>}`, которые уже будут объединением строк в соответствии с правилами TypeScript для шаблона. Буквальные типы...

Я понимаю! нам нужно использовать сопоставленный тип!

type SetMixin<Name extends string> = {
  [_ in `set${Capitalize<Name>}`]: any;
}

Нет ошибки, если мы проверим это с помощью:

type SetFoo = SetMixin<"foo">
//    ^? type SetFoo = { setFoo: any; }

Это работает! Теперь давайте, как объединить несколько настраиваемых свойств.

Эксперимент: объединение настраиваемых свойств

Теперь давайте попробуем создать несколько настраиваемых свойств в одном типе, поскольку мы знаем, что для достижения нашей цели нам нужно как минимум 3. Давайте попробуем поместить синтаксис сопоставления несколько раз в один и тот же тип объекта!

type BelongsToMixin<Name extends string> = {
  [_ in `set${Capitalize<Name>}`]: any;
  [_ in `get${Capitalize<Name>}`]: any;
}
error TS7061: сопоставленный тип не может объявлять свойства или методы.  

Хм? Оказывается, TypeScript считает, что второе сопоставление пытается объявить свойство с помощью скобок, как мы пытались ранее, и мы не можем объявить свойства в отображаемом типе. Итак, мы могли бы сделать два, но как их объединить?

Учитывая сопоставленные типы A и B, наш новый тип должен быть одновременно A и B, что является пересечением типов!

type BelongsToMixin<Name extends string> = {
  [_ in `set${Capitalize<Name>}`]: any;
} & {
  [_ in `get${Capitalize<Name>}`]: any;
}

Опять же, мы не получаем никаких ошибок, и все работает так, как ожидалось!

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

Тип постфикса имени свойства

Давайте напишем простой тип, который позволит нам писать:

type BelongsToMixin<Name extends string> = PostfixProperties<
  {
    get: any;
    set: any;
    create: any;
  },
  Capitalize<Name>
>;

type FooBelongsToBar = BelongsToMixin<"bar">;
//     ^? type FooBelongsToBar = { getBar: any; setBar: any; createBar: any; }

Магия заключается в общем типе PostfixProperties, так как же нам его написать? Мы снова используем отображаемые типы и их возможность переименовывать свойства типа выходного объекта:

type PostfixProperties<PropTypes, Postfix extends string>= {
  [P in keyof PropTypes as `${Exclude<P, symbol>}${Postfix}`]: PropTypes[P];
};

Давайте уделим минуту анализу этого типа. Во-первых, это сопоставленный тип с ключами параметра типа PropTypes, однако мы переименовываем свойства, включив в них исходное имя, и добавляем предоставленный постфикс. При объединении нам необходимо исключить свойства символа нашего исходного объекта, поскольку они не могут

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

Определение типа интерфейса Mixin

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

type BelongsToMixin<
  AssociatedModel extends Model,
  PrimaryKeyType,
  Name extends string,
> = PostfixProperties<
  {
    get: BelongsToGetAssociationMixin<AssociatedModel>;
    set: BelongsToSetAssociationMixin<AssociatedModel, PrimaryKeyType>;
    create: BelongsToCreateAssociationMixin<AssociatedModel>;
  },
  Capitalize<Name>
>;

type FooBelongsToBar = BelongsToMixin<Bar, number, "foo">;
//    ^? type FooBelongsToBar = { getFoo: ...; setFoo: ...; createFoo: ...; }

И это работает: у нас есть общий тип для миксинов ассоциации ownTo! Ура!

Определение других типов ассоциации hasOne оставлено читателю в качестве упражнения.

Для типов отношений «многие ко многим» особых изменений не произошло, но сначала давайте определим тип Prettify:

type Prettify<T> = { [P in keyof T]: T[P]; };

Эти типы не меняют тип T, пока это тип объекта, но помогают инструментам машинописи отображать пересечения типов объектов как один тип объекта. Говоря о пересечении объектов, мы воспользуемся им, чтобы создать тип примеси ассоциации hasMany:

type HasManyMixin<
  AssociatedModel extends Model,
  PrimaryKeyType,
  SingularName extends string,
  PluralName extends string,
> = Prettify<
  PostfixProperties<
    {
      get: HasManyGetAssociationsMixin<AssociatedModel>;
      count: HasManyCountAssociationsMixin;
      has: HasManyHasAssociationsMixin<AssociatedModel, PrimaryKeyType>;
      set: HasManySetAssociationsMixin<AssociatedModel, PrimaryKeyType>;
      add: HasManyAddAssociationsMixin<AssociatedModel, PrimaryKeyType>;
      remove: HasManyRemoveAssociationsMixin<AssociatedModel, PrimaryKeyType>;
    },
    Capitalize<PluralName>
  > &
    PostfixProperties<
      {
        has: HasManyHasAssociationMixin<AssociatedModel, PrimaryKeyType>;
        add: HasManyAddAssociationMixin<AssociatedModel, PrimaryKeyType>;
        remove: HasManyRemoveAssociationMixin<AssociatedModel, PrimaryKeyType>;
        create: HasManyCreateAssociationMixin<AssociatedModel>;
      },
      Capitalize<SingularName>
    >
>;

Тип Mixin для ассоциаций ownToMany оставлен в качестве упражнения, поскольку по сути он тот же.

Использование типов интерфейса Mixin

Теперь мы можем генерировать типы интерфейсов миксинов, но как нам использовать их с нашими моделями?

Наши модели определяются как классы JS, наследуемые от базового класса модели из Sequelize. В TypeScript мы также можем добавить предложение реализации для дополнительных интерфейсов. «Но наши типы не определены как интерфейсы, мы не можем их реализовать?» вы можете спросить, однако в TypeScript вы на самом деле можете реализовать любой тип, «подобный интерфейсу», то есть типы, которые представляют вещи, описываемые интерфейсами, поэтому наш псевдоним типа объекта (или пересечение типов объекта) можно использовать в качестве интерфейса.

class Foo
  extends Model<InferAttributes<Foo>, InferCreationAttributes<Foo>>
  implements BelongsToMixin<Bar, number, "Bar"> {
  // ...
}

Вот и все, да? Неправильный. Потому что реализация проверяет тип экземпляра класса (в соответствии с его телом и родительской иерархией) и обнаруживает, что свойства, которые, как мы говорили, существуют, не существуют. Так что же нам делать? Сдадимся ли мы и на самом деле напишем все, что нам не нужно, с единственной выгодой от проверки типов? У нас есть инструменты для этого, да, и мы должны убедиться, что каждое из них является объявленным свойством, потому что мы не контролируем реализацию...

Что ж, хотя это все равно было бы лучше, нам не нужно этого делать благодаря свойствам синтаксиса класса и интерфейсам TypeScript.

Определяя класс в TypeScript, вы фактически определяете две вещи одновременно: значение класса времени выполнения и тип экземпляра класса. Таким образом, когда определен класс Foo {}, у нас есть как локальное значение Foo, которое является конструктором класса, так и тип Foo для экземпляров класса. Тип экземпляра класса становится для нас интересным, поскольку он определяется не как псевдоним типа, а как интерфейс.

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

Объединив их, мы можем эмулировать своего рода функциональность «объявления инструментов», например:

interface Foo extends BelongsToMixin<Bar, number, "Bar"> {}
class Foo extends Model<InferAttributes<Foo>, InferCreationAttributes<Foo>> {
  // ...
}

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

Полный окончательный пример кода

Код также доступен в виде игровой площадки TypeScript.

import {
  Model,
  type InferAttributes,
  type InferCreationAttributes,
  type BelongsToGetAssociationMixin,
  type BelongsToSetAssociationMixin,
  type BelongsToCreateAssociationMixin,
  type HasManyGetAssociationsMixin,
  type HasManyCountAssociationsMixin,
  type HasManyHasAssociationsMixin,
  type HasManySetAssociationsMixin,
  type HasManyAddAssociationsMixin,
  type HasManyRemoveAssociationsMixin,
  type HasManyHasAssociationMixin,
  type HasManyAddAssociationMixin,
  type HasManyRemoveAssociationMixin,
  type HasManyCreateAssociationMixin,
} from "sequelize";

// define helper types
type PostfixProperties<PropTypes, Postfix extends string> = {
  [P in keyof PropTypes as `${Exclude<P, symbol>}${Postfix}`]: PropTypes[P];
};

type Prettify<T> = { [P in keyof T]: T[P] };

// association mixin interfaces
type BelongsToMixin<
  AssociatedModel extends Model,
  PrimaryKeyType,
  Name extends string,
> = PostfixProperties<
  {
    get: BelongsToGetAssociationMixin<AssociatedModel>;
    set: BelongsToSetAssociationMixin<AssociatedModel, PrimaryKeyType>;
    create: BelongsToCreateAssociationMixin<AssociatedModel>;
  },
  Capitalize<Name>
>;

type HasManyMixin<
  AssociatedModel extends Model,
  PrimaryKeyType,
  SingularName extends string,
  PluralName extends string,
> = Prettify<
  PostfixProperties<
    {
      get: HasManyGetAssociationsMixin<AssociatedModel>;
      count: HasManyCountAssociationsMixin;
      has: HasManyHasAssociationsMixin<AssociatedModel, PrimaryKeyType>;
      set: HasManySetAssociationsMixin<AssociatedModel, PrimaryKeyType>;
      add: HasManyAddAssociationsMixin<AssociatedModel, PrimaryKeyType>;
      remove: HasManyRemoveAssociationsMixin<AssociatedModel, PrimaryKeyType>;
    },
    Capitalize<PluralName>
  > &
    PostfixProperties<
      {
        has: HasManyHasAssociationMixin<AssociatedModel, PrimaryKeyType>;
        add: HasManyAddAssociationMixin<AssociatedModel, PrimaryKeyType>;
        remove: HasManyRemoveAssociationMixin<AssociatedModel, PrimaryKeyType>;
        create: HasManyCreateAssociationMixin<AssociatedModel>;
      },
      Capitalize<SingularName>
    >
>;

// define models
interface Foo extends BelongsToMixin<Bar, number, "Bar"> {}
class Foo extends Model<InferAttributes<Foo>, InferCreationAttributes<Foo>> {
  declare id: number;
  // ...
}

class Bar extends Model<InferAttributes<Bar>, InferCreationAttributes<Bar>> {
  declare id: number;
  // ...
}

// define association(s)
Foo.belongsTo(Bar);

// create instance
const foo = Foo.build({ id: 42 });

// use typed mixin!
foo.createBar({ id: 88 });

Источник:

#TypeScript
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу