Неявные реляционные базы данных, значения JSON, получение последней записи из таблицы, обработка сценариев без данных — все это выполнимо с Prisma.io.

Введение

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

Я работаю в компании Интернета вещей Blues, которая специализируется на передаче данных IoT с устройств в реальном мире в облако через сотовую связь, и мы демонстрировали, как наша технология может использоваться руководителями объектов для мониторинга перекачки жидкости и газа и удаленно. переключать клапаны по всей системе трубопроводов через веб-приложение.

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

Если вам интересно узнать больше о нашем проекте по мониторингу скорости потока, вы можете посмотреть полное руководство по созданию собственного здесь.

Панель управления веб-приложения выглядит следующим образом:

Для веб-приложения мы использовали Next.js для отображения панели данных IoT и Prisma (объектно-реляционный преобразователь или ORM) для взаимодействия с нашей базой данных PostgreSQL. Из-за требований проекта, о которых я вскоре расскажу, мы в итоге сделали с Prisma несколько довольно интересных вещей, включая транзакцию upsert, запрос самой последней записи в таблице, выборку связанных данных из нескольких таблиц в одном запросе, обработку необработанных данных. данные JSON и многое другое.

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

ПРИМЕЧАНИЕ. Эта статья не является введением в работу с Prisma. Если вы новичок в этом, я настоятельно рекомендую сначала ознакомиться с их документацией по началу работы, чтобы ознакомиться с ней.

Призма грунтовка

Сегодня веб-разработчики редко пишут необработанные SQL-запросы в своих приложениях. Вместо этого мы склонны полагаться на библиотеки объектно-реляционного отображения (ORM), такие как Mongoose, Sequelize, Knex или Prisma.

ORM обеспечивают множество преимуществ, таких как:

  • Сэкономленное время при написании необработанных запросов,
  • Совместимость с несколькими типами баз данных,
  • определения схемы,
  • Безопасность типов,
  • И более.

Prisma, в частности, стал последним фаворитом с открытым исходным кодом, когда дело доходит до ORM, потому что он обеспечивает все преимущества, о которых я упоминал выше, а также:

  • Интеграция с VS Code,
  • Типобезопасность TypeScript,
  • Пагинация и транзакции,
  • Бессерверная поддержка,
  • И визуальный браузер базы данных, и это лишь некоторые из них.

С ним действительно удобно работать, и взаимодействие с базой данных в приложении JavaScript становится более интуитивным и естественным, а не похоже на смешение двух разных языков.

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

Определение неявных отношений данных «многие ко многим»

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

Для нашего проекта у нас было две модели Prisma, которые были связаны друг с другом:

  • Устройство — каждое отдельное устройство управления расходом и клапаном,
  • Флот — определенная группа этих устройств.

ПРИМЕЧАНИЕ. Для любого из фрагментов кода, перечисленных ниже, щелкните заголовок фрагмента, чтобы просмотреть рабочий код в репозитории GitHub.

Модель Device в схеме Prisma выглядит так:

model Device {
  id            Int         @id @default(autoincrement())

  // unique device uid in notehub
  deviceID     String      @unique @map("device_id") @db.VarChar(64)

  // device serial number (cached)
  name          String?     @db.VarChar(80)
  lastSeenAt    DateTime?   @map("last_seen_at") // the last time an event was heard from this device

  // the project the device belongs to
  project       Project     @relation(fields: [projectID], references: [projectID])
  projectID    String

  @@map("device")
}

Модель Fleet в схеме Prisma выглядит так:

model Fleet {
  id          Int      @id @default(autoincrement())

  // unique fleet id
  fleeID    String   @unique

  // the project the fleet belongs to
  project     Project   @relation(fields: [projectID], references: [projectID])
  projectID  String

  @@map("fleet")
}

Каждое устройство может не принадлежать ни одной группе или нескольким группам, и каждая группа может не содержать устройств или содержать много устройств. Это классический пример отношения многие ко многим (m:n).

Prisma предлагает два способа обработки таких отношений:

  • Явные отношения «многие ко многим» — в явных отношениях m:n таблица отношений представлена ​​как собственная модель в схеме Prisma и может использоваться в запросах. Явные отношения многие ко многим определяют три модели: две модели, имеющие отношение многие ко многим: Device и Fleet, и одна модель, представляющая таблицу отношений: DevicesInFleets (иногда называемую JOIN, ссылкой или сводной таблицей) в базовая база данных.
  • Неявные отношения «многие ко многим» — при неявных отношениях m:n таблица существует в базовой базе данных, но управляется Prisma и не проявляется в схеме Prisma (т. е. вы не вам не нужно самостоятельно определять модель и таблицу DevicesInFleets, Prisma просто знает, что это важно).

Вот типичное событие данных JSON Device, которое может быть перенаправлено в веб-приложение.

Пример события, перенаправленного в приложение

{
  "projectID": "app:a8beb5bd-622e-46a6-866a-ae4528c7c201",
  "deviceID": "dev:864622040363787",
  "eventID": "490006f7-80f8-4314-8b5e-5ff587f07fba",
  "lastSeenAt": "2023-01-23T18:29:16.000Z",
  "eventBody": { "flow_rate": 810, "valve_state": "open" },
  "fleetIDs": [ "fleet:f660d491-8830-420f-be7a-c8f91c460813" ],
  "name": "Pipe Section A"
}

Итак, к модели Device добавлено Fleet:

model Device {
  id            Int         @id @default(autoincrement())

  // unique device uid in notehub
  deviceID     String      @unique @map("device_id") @db.VarChar(64)

  // extra device-specific data here

  // fleets a device belongs to (implicit many-to-many relationship)
  fleets        Fleet[]

  @@map("device")
}

А к модели Fleet добавлено Device:

model Fleet {
  id          Int      @id @default(autoincrement())

  // unique fleet id
  fleeID    String   @unique

  // more fleet-specific data

  // devices assigned to fleet (implicit many-to-many relationship)
  devices     Device[]

  @@map("fleet")
}

И это все, что вам нужно сделать, чтобы установить неявную связь «многие ко многим» с Prisma. Никаких явных таблиц, никаких SQL-запросов, никакой дополнительной работы по обработке относительно сложных, но очень обычных отношений. Я могу сказать вам, что неявное определение такого рода отношений между несколькими связанными таблицами в этом проекте действительно сэкономило нам массу времени во время разработки.

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

Точно обновляйте связанные записи в нескольких таблицах с помощью Prisma

Основываясь на предыдущем примере, мы расскажем, как правильно обновлять связанные данные сразу в нескольких таблицах.

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

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

Пример события, перенаправленного в приложение

{
  "projectID": "app:a8beb5bd-622e-46a6-866a-ae4528c7c201",
  "deviceID": "dev:864622040363787",
  "eventID": "490006f7-80f8-4314-8b5e-5ff587f07fba",
  "lastSeenAt": "2023-01-23T18:29:16.000Z",
  "eventBody": { "flow_rate": 810, "valve_state": "open" },
  "fleetIDs": [ "fleet:f660d491-8830-420f-be7a-c8f91c460813" ],
  "name": "Pipe Section A"
}

Согласно документам Prisma, функция connectOrCreate API — это способ создания связанной записи, которая может существовать или не существовать, что идеально подходит для добавления новых устройств в таблицу Device и новых связанных парков в таблицу. Fleet одновременно через create() с вложенной функцией записи. Однако при попытке обновить парк устройств при появлении нового события я столкнулся с небольшой проблемой с функцией update().

Когда событие поступает для существующего устройства, а парки совершенно разные, только при наличии функции connectOrCreate() в update() не удалось удалить старые парки и заменить их новыми парками в таблице Fleet. Вместо этого новые парки были связаны с устройством вместе со старыми парками, и устройство выглядело так, как будто оно было связано с большим количеством флотов, чем было на самом деле.

Чтобы точно обновить как таблицу Device, так и связанные записи в таблице Fleet в одном запросе, мы должны сделать две вещи:

  1. Отключите все связанные записи автопарка с помощью метода set().
  2. Вызовите функцию connectOrCreate(), чтобы вставить новые данные о парке в таблицу Fleet.

ПРИМЕЧАНИЕ. Удаление определенных связанных записей с помощью функции deleteMany() не работает

Другой вариант изменения связанных записей, который я пробовал, заключался в удалении всех связанных записей с помощью функции deleteMany(). Однако это не сработало правильно, потому что были удалены идентификаторы паркавсех устройствв таблице Device, а не только deviceID указанного события.

Вот как выглядит окончательный запрос upsertDevice(): он корректно обновляет данные об устройствах в таблице Device и связанные идентификаторы групп в таблице Fleet без изменения групп, связанных с каким-либо другим устройством в процессе.

upsertDevice() запрос

/**
   * Insert or update the device based on the unique device ID.
   *
   * @param project
   * @param deviceID
   * @param name
   * @param lastSeenAt
   * @param fleetIDs
   * @param location
   * @returns
   */
  private upsertDevice(
    project: Project,
    deviceID: string,
    name: string | undefined,
    lastSeenAt: Date,
    fleetIDs: string[],
    location?: NotehubLocation
  ) {
    const args = arguments;

    const formatConnectedFleetData = fleetIDs.map((fleet) => ({
      create: {
        fleetID: fleet,
        projectID: project.projectID,
      },
      where: {
        fleetID: fleet,
      },
    }));

    return this.prisma.device
      .upsert({
        where: {
          deviceID,
        },
        create: {
          name,
          deviceID,
          locationName,
          fleets: {
            connectOrCreate: formatConnectedFleetData,
          },
          project: {
            connect: {
              id: project.id,
            },
          },
          lastSeenAt,
        },
        update: {
          name,
          locationName,
          fleets: {
            set: [],
            connectOrCreate: formatConnectedFleetData,
          },
          project: {
            connect: {
              id: project.id,
            },
          },
          lastSeenAt,
        },
      })
      .catch((cause) => {
        throw new ErrorWithCause(
          `error upserting device ${deviceID} ${JSON.stringify(args)}`,
          { cause }
        );
      });
  }

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

К сожалению, в настоящее время нет единой функции связанной записи upsert(), которая одновременно удаляет ранее связанные записи и создает новые связанные записи, но, возможно, однажды Prisma включит этот вариант использования. До тех пор я надеюсь, что это поможет вам правильно обновлять связанные записи.

Хорошо, давайте перейдем к следующему сложному совету Prisma: включение реляционных данных в запрос READ.

Возврат реляционных данных из другой таблицы в запросе Prisma READ

Точно так же, как обновление связанных таблиц может быть выполнено в одной транзакции с Prisma, запрос связанных данных в нескольких таблицах также может быть выполнен в одном действии.

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

При сохранении данных об устройствах и связанных с ними парках в соответствующих таблицах мы можем использовать функцию API connectOrCreate.

Чтобы выполнить вложенное чтение и получить сведения об устройстве, и сведения о связанном парке, мы используем функцию Prisma include API.

Чтобы получить все данные о парке, связанные с конкретным устройством, запрос fetchDevice() в нашей кодовой базе выглядит следующим образом:

fetchDevice()функция

async fetchDevice(deviceID: DeviceID) {
    const device = await this.prisma.device.findUnique({
      where: {
        deviceID: deviceID,
      },
      include: {
        fleets: true,
      },
    });
    return device;
  }

Мы используем стандартный запрос Prisma findUnique API в таблице Device, чтобы найти конкретное устройство на основе его идентификатора устройства, а затем внутри этого запроса findUnique() мы добавляем include: { fleets: true }, чтобы получить все сведения о парке, которые связанных с этим устройством.

Вот как выглядят данные, возвращаемые запросом:

Пример данных устройства с информацией о парке, возвращенной из запроса

{
  [
   {
      "id":1,
      "deviceID":"dev:864622040363787",
      "name":"Pipe Section A",
      "lastSeenAt":"2023-01-23T18:29:16.000Z",
      "projectUID":"app:a8beb5bd-622e-46a6-866a-ae4528c7c201",
      "fleets":[
         {
            "id":1,
            "fleetID":"fleet:f660d491-8830-420f-be7a-c8f91c460813",
            "projectID":"app:a8beb5bd-622e-46a6-866a-ae4528c7c201"
         }
      ]
   }
 ]
}

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

ПРИМЕЧАНИЕ.

Если вы хотите вернуть только определенные поля для связанных данных, вы можете использовать select API, чтобы выбрать подмножество связанных полей для возврата.

Например, если вамнужны толькоидентификаторы связанных автопарков, вы должны обновить запрос include следующим образом: include: { fleets: { select: { fleetID: true } } } }.

Получить самую последнюю запись данных из таблицы

Хорошо, давайте перейдем к чтению самой последней записи данных из таблицы.

В пользовательском интерфейсе монитора расхода клапана нам нужно получить последние добавленные данные состояния flow-rate и valve для устройства, чтобы правильно показать пользователю, что происходит.

Prisma предоставляет запрос findFirstAPI, который возвращает первую созданную запись данных, но не предоставляет аналогичный запрос findLastAPI.

Вместо этого есть два способа получить самую последнюю запись из таблицы:

  1. Используйте комбинацию API findFirst и фильтра orderBy, чтобы отсортировать записи по чему-то вроде даты записи.
  2. Используйте findMany API и используйте функцию take, чтобы получить только последнее значение из запроса.

Мы рассмотрим оба варианта:

findFirst и orderBy пример

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

Вот как выглядит окончательный запрос.

получитьLatestDeviceAlarm()

async getLatestDeviceAlarm(deviceID: DeviceID): Promise<Notification> {
    const latestAlarmFromDevice = await this.prisma.notification.findFirst({
      where: {
        AND: {
          type: "alarm",
        },
        content: {
          path: ["deviceID"],
          equals: deviceID,
        },
      },
      orderBy: {
        lastSeenAt: "desc",
      },
    });

    return latestAlarmFromDevice || undefined;
  }

Обратите внимание на то, что в конце запроса функция getLatestDeviceAlarm() либо возвращает самый последний аварийный сигнал для устройства, либо возвращает undefined, потому что существует вероятность того, что на устройстве не было срабатывания аварийного сигнала. Что нужно иметь в виду, о чем я расскажу более подробно позже в этой статье.

Теперь давайте рассмотрим другой вариант получения самой последней записи из таблицы Event с помощью findMany.

найти много и взять пример

получитьLatestDeviceEvent()

async getLatestDeviceEvent(deviceID: DeviceID, file: string): Promise<Event> {
    const latestDeviceEvent = await this.prisma.event.findMany({
      where: {
        AND: {
          deviceUID: deviceID,
        },
        eventName: file,
      },
      take: -1,
    });
    return latestDeviceEvent[0];
  }

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

Чтобы получить самое последнее событие с помощью findMany API Prisma, мы используем deviceID для сужения списка всех событий и используем take: -1 для захвата последней записи в таблице. take: -1 был введен в Prisma еще в v2 для упрощения разбиения на страницы большого количества данных, но его также можно использовать только для возврата одной записи, а также списка записей, просто не забудьте извлечь одну запись из массива при доступе к данным.

Чтение и запись данных JSON в таблицу

Хорошо, теперь мы рассмотрим очень полезную и гибкую вещь, которую вы, возможно, захотите сделать: записать данные JSON в столбец таблицы или прочитать их снова.

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

Для нашего проекта мы решили использовать поля JSON для объектов событий, потому что не всегда были уверены, какие данные может содержать событие. Вот как может выглядеть событие, направляемое в приложение. Свойство eventBody — это то, что мы хотим сохранить в виде необработанных данных JSON, потому что это поле наиболее подвержено изменениям.

Пример события, перенаправленного в приложение

{
  "projectID": "app:a8beb5bd-622e-46a6-866a-ae4528c7c201",
  "deviceID": "dev:864622040363787",
  "eventID": "490006f7-80f8-4314-8b5e-5ff587f07fba",
  "lastSeenAt": "2023-01-23T18:29:16.000Z",
  "eventBody": { "flow_rate": 810, "valve_state": "open" },
  "fleetIDs": [ "fleet:f660d491-8830-420f-be7a-c8f91c460813" ],
  "name": "Pipe Section A"
}

Чтобы наша таблица данных была готова принимать любые старые данные JSON в качестве столбца, нам просто нужно сообщить модели, что это так. Итак, в нашем файле schema.prisma, где мы определяем все наши модели Prisma, мы устанавливаем свойства для Event следующим образом:

Event модель

model Event {
  id           Int         @id @default(autoincrement())

  eventName    String      @map("file")
  eventID     String      @map("event") @unique

// the device that produced the event
  device       Device      @relation(fields: [deviceID], references: [deviceID], onDelete: Cascade)
  deviceID    String 

// when the event occurred
  when         DateTime
  value        Json

  @@map("event")
}

Свойство, на которое следует обратить особое внимание, — это свойство value — это поле, которому мы присваиваем тип Json, и это все, что нам нужно сделать, чтобы модель была счастливой.

Запишите JSON в поле Prisma

После того, как модель определена, когда приходит новое событие (как в примере выше), можно вызвать функцию Prisma upsert() для таблицы Event.

upsertEvent()функция

/**
   * Insert or update the event
   *
   * @param deviceID
   * @param when
   * @param eventName
   * @param eventID
   * @param value
   * @returns
   */
  private upsertEvent(
    deviceID: string,
    when: Date,
    eventName: string,
    eventUID: string,
    value: object
  ) {
    const args = arguments;

    return this.prisma.event
      .upsert({
        where: {
          eventID,
        },
        create: {
          deviceID,
          when,
          eventName,
          eventID,
          value,
        },
        update: {
          // reading already exists
          // no-op
        },
      })
      .catch((cause) => {
        throw new ErrorWithCause(
          `error upserting event ${deviceUID} ${JSON.stringify(args)}`,
          { cause }
        );
      });
  }

Если вы используете TypeScript, как и мы в нашем проекте, просто определите поле JSON value как object, а затем просто передайте его как одно из значений в функции Prisma upsert(). Довольно просто.

Чтение JSON из таблицы Prisma

Чтение поля JSON с помощью Prisma также довольно просто. Если вы помните, в предыдущем совете мы рассмотрели запрос getLatestDeviceEvent(), этот запрос возвращает данные из таблицы Event, включая поле JSON value. Когда данные возвращаются, это выглядит примерно так:

Пример данных о событии, возвращенных из getLatestDeviceEvent()

[
  {
    "id": 1,
    "eventName": "data.qo",
    "eventID": "490006f7-80f8-4314-8b5e-5ff587f07fbc",
    "deviceID": "dev:864622040363787",
    "when": "2023-01-23T18:29:16.000Z",
    "value": { "flow_rate": 810, "valve_state": "open" }
  }
]

Отсюда, как и с любым другим полем данных, возвращаемым запросом Prisma, мы можем получить доступ к полю value (и любым содержащимся в нем данным) и делать с ним все, что захотим. Итак, чтобы получить flow_rate, нужно просто сделать что-то вроде const flowRate = data[0].value.flow_rate;.

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

Обработка неопределенных данных

Последний совет Prisma в этой статье: будьте готовы обрабатывать undefined значений, возвращаемых Prisma.

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

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

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

Заключение

Prisma.io — очень мощная и популярная ORM с поддержкой JavaScript для взаимодействия со всеми типами баз данных. Хотя кривая обучения для него может быть немного крутой, как только вы начнете ее осваивать, вы поймете, насколько детальным является элемент управления и насколько приятнее использовать его при взаимодействии с базами данных вместо необработанных SQL-запросов.

Когда моя команда и я использовали его, чтобы помочь нам создать приложение для мониторинга клапана и расхода, мы многое узнали об использовании некоторых более продвинутых функций Prisma, таких как реляционные таблицы данных, обработка необработанных полей JSON и даже фильтрация и сортировка результатов. С Prisma вы очень быстро можете начать делать удивительные вещи, которые раньше были довольно сложными и требовали хорошего знания SQL.

Загляните через несколько недель — я напишу больше о JavaScript, React, IoT или чем-то еще, связанном с веб-разработкой.

Если вы хотите быть уверены, что никогда не пропустите статью, которую я пишу, подпишитесь на мою рассылку здесь: https://paigeniedringhaus.substack.com

Спасибо за прочтение. Надеюсь, вы узнали несколько новых способов использования Prisma поверх ваших собственных веб-приложений, управляемых базами данных. Наслаждаться!

Ссылки и дополнительные ресурсы

Первоначально опубликовано на https://www.paigeniedringhaus.com.