Executando verificação de segurança...
1

[PITCH]? ORM para DynamoDB

Ola pessoal! :)

Senta que vem um pouco de história.

Eu fiz esse ORM com o pois estava com dificuldades de escrever requisições para o Dynamo, usava muito o Firestore, mas depois de ver a quantia de requisições no modo grátis eu fiquei com receio de ter que usar a versão Spark e bem... não tava afim de pagar para eles, comecei a procurar alternativas e achei o Dynamo que é bem generoso e muito rápido.

Comecei a pesquisar sobre como usar ele e na época em que conheci Dynamo a documentação era uma bagunça, hoje em dia está um pouco mais organizado.

Porém é... ruim usar o SDK deles. Poderia ser melhor, mais fluído, foi aí que conheci os ORMs vi sobre o Detenhamos e o ElectroDB, mas não era bom o suficiente ainda. Foi então que conheci o Drizzle, gostei muito de como ele simplifica as coisas, mas infelizmente não tem suporte a Bancos NoSQL.

Pensei então "por que não faço um ORM para o Dynamo" com uma API inspirada no Drizzle? Foi dessa ideia que surgiu o mizzle.

mizzle

Do inglês drizzle e drizzle significam chuvisco ou garoa, tem o mesmo significado, são parecidos, soam igual. Devem ser parecidos também.

Para iniciar nele você pode instalar usando o seu "empacotador" favorito:

bun add @aurios/mizzle @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb

Depois disso você define sua tabela:

// schema.ts
import { dynamoTable, string } from "@aurios/mizzle";

export const myTable = dynamoTable("JediOrder", {
  pk: string("pk"),
  sk: string("sk"),
});

Nesse caso você define o nome da tabela igual ao nome da tabela na qual você define no console da AWS e após isso define os indexes da tabela (pk, sk, lsi e gsi).

Depois de definir a tabela, você vai usar ela para definir a entidade:

// schema.ts
import { dynamoEntity, string, uuid, number, enum, date, prefixKey, staticKey } from "@aurios/mizzle";

export const jedi = dynamoEntity(
  myTable,
  "Jedi",
  {
    id: uuid(), // Automaticamente gera o UUID v7
    name: string(),
    homeworld: string()
  },
  (cols) => ({
    // PK vai ficar assim "JEDI#<uuid>"
    pk: prefixKey("JEDI#", cols.id),
    // SK vai ser uma string estática "PROFILE"
    sk: staticKey("PROFILE"),
  }),
);

export const jediRank = dynamoEntity(
  myTable,
  'JediRank',
  {
    jediId: uuid(),
    position: string().default('initiate'),
    joinedCouncilDate: string(),
  },
  (cols) => ({
    // Mesmo que Jedi já que são relacionados
    pk: prefixKey('JEDI#', cols.jediId),
    // Vai ficar assim "RANK#initiate"
    sk: prefixKey('RANK#', cols.position),
  })
)

Mas porque deve ser feito assim? Porque essa função que retorna um objeto no final de cada entidade? Por que entidades? Calma que eu explico tudo isso.

Single-Table Design

É feito dessa forma por culpa do single-table design, DynamoDB funciona assim, mas por quê? Sem esse padrão "Jedi" e "JediRank" seriam duas tabelas separadas que precisariam de joins, mas com esse padrão você pode fazer queries mais complexas e com menos requisições, bem menos requisições. Por exemplo, se você quiser pegar um Jedi e seu rank, você pode fazer uma query só, sem precisar fazer duas queries e depois fazer um join. Pode parecer confuso para quem não conhece esse padrão, mas vamos imaginar como as seguintes tabelas ficariam no single-table design:

Tabela Jedi:

idnamehomeworld
1Luke SkywalkerTatooine
2Anakin SkywalkerTatooine
3Obi-Wan KenobiStewjon

Tabela JediRank:

rankIdpositionjoinedCouncilDate
1Jedi2021-01-01
2Sith2021-01-01
3Jedi2021-01-01

Para otimizar isso no SQL precisariamos normalizar, ou seja, criar mais tabelas:

Tabela Planetas: Armazena os nomes dos planetas uma única vez.

idname
1Tatooine
2Stewjon

Tabela Ranks: Armazena as posições possíveis.

idtitle
1Jedi
2Sith

Tabela Jedi (Atualizada): Agora ela não guarda o nome do planeta, apenas o planet_id.

idnameplanet_id
1Luke Skywalker1
2Anakin Skywalker1
3Obi-Wan Kenobi2

E por fim JediRank (Atualizada): Aqui fazemos a ponte entre o Jedi, o cargo dele e a data.

jedi_idrank_idjoined_council_date
112021-01-01
222021-01-01
312021-01-01

Essa seria a forma mais otimizada das duas tabelas anteriores e agora como isso ficaria no single-table design:

PK (Partition Key)SK (Sort Key)NameHomeworldDate
JEDI#1METADATALuke SkywalkerTatooine
JEDI#1RANK#2021-01-01Jedi2021-01-01
JEDI#2METADATAAnakin SkywalkerTatooine
JEDI#2RANK#2021-01-01Sith2021-01-01
PLANET#TATOOINEMETADATATatooine
PLANET#STEWJONMETADATAStewjon

Viu como tudo foi resolvido com uma tabela só? Em um banco NoSQL que utiliza essa técnica, não fazemos JOINs. Em vez disso, pré-modelamos os dados para que fiquem "juntos" fisicamente na mesma partição. Separamos o que seriam tabelas em entidades conectadas entre si pelos indexes.

No exemplo acima fizemos a sobrecarga de atributos e agrupamento, ou seja, colocamos o nome do planeta no Jedi e também criamos uma entidade para o planeta. Isso é útil para que, quando consultarmos um Jedi, já tenhamos o nome do planeta sem precisar fazer um JOIN.

E qual é a vantagem disso? Com isso você tem uma melhor performance, pois como os dados relacionados estão na mesma partição, a leitura é extremamente rápida (latência de milisegundos), independentemente do volume de dados, não está satisfeito? Que tal redução do custo? Você paga menos por ter uma tabela apenas e menos requisições.

Mas não é só isso, você também tem uma melhor consistência, pois não precisa se preocupar com transações distribuídas. Tudo é uma única transação. E como você faz para consultar os dados? Você faz consultas por indexes e não por JOINs. Por exemplo, para consultar todos os dados do Luke você faz uma consulta por JEDI#1 . Para consultar todos os Jedi, você faz uma consulta por JEDI#. Para consultar todos os planetas, você faz uma consulta por PLANET#.

É complexo? Um pouco, mas fica mais fácil com o mizzle. Vamos continuar com o restante do guia então.

Relations

Agora definimos as relações entre as entidades:

// relations.ts
import { defineRelations } from "@aurios/mizzle";
import * as schema from "./schema";

export const relations = defineRelations(schema, (r) => ({
  jedi: {
    // Um Jedi pode ter muitos ranks ao longo dos anos
    ranks: r.many.jediRank({
      fields: [r.jedi.id],
      references: [r.jediRank.jediId],
    }),
  },
  jediRank: {
    // Cada registro aponta para apenas um Jedi
    member: r.one.jedi({
      fields: [r.jediRank.jediId],
      references: [r.jedi.id],
    }),
  },
}));

Tem varias formas de definir a mesma relação dentro da api do defineRelations, ela é bem flexível. Esse passo cria um mapa lógico de como suas entidades interagem, permitindo que você execute consultas relacionais poderosas, como buscar um Jedi junto com todo o histórico de ranks, em uma única operação.

Clientela

Agora o passo mais simples:

// db.ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { mizzle } from "@aurios/mizzle";
import { relations } from "./relations";

const client = new DynamoDBClient({ region: "us-east-1" });
export const db = mizzle({ client, relations });

O cliente é iniciado e podemos usar o mizzle normalmente.

A Procura do Jedi

Com tudo pronto você pode fazer a busca das informações usando a API fluida do mizzle:

import { jedi } from "$lib/schema.ts";

const newJedi = await db
  .insert(jedi)
  .values({
    name: "Luke Skywalker",
    homeworld: "Tatooine",
  })
  .returning();

console.log(newJedi.id); // UUID é gerada automáticamente

E para selecionar os dados:

import { jedi } from "$lib/schema.ts";
import { eq } from "@aurios/mizzle";

const user = await db.select().from(jedi).where(eq(jedi.id, "uuid-generica")).execute();

Mas espera aí, tudo isso funciona bem se eu tiver uma tabela já pronta na AWS. E se eu não tiver? Vai dar erro porque não vai achar a tabela com o nome definido em dynamoTable. E se tiver uma CLI pra fazer isso assim como o drizzle? Foi aí que surgiu a ideia do mizzling. Instale ele da mesma forma que o mizzle: bun add mizzling.

Com o bun por exemplo rodaria os comandos assim:

Para iniciar e criar o mizzle.config.ts

bunx mizzling init

Para analisar as entidades definidas e gerar uma migração para o DynamoDB:

bunx mizzling generate --name <migration_name>

Para fazer o push direto para a AWS, você pode usar o push, ele aplica as mudanças diretamente para o ambiente do DynamoDB. Ele compara as definições locais do seu esquema com o estado real das tabelas do DynamoDB e aplica as operações necessárias de CreateTable ou UpdateTable.

bunx mizzling push

Você pode listar as tabelas:

bunx mizzling list

E também pode excluir as tabelas:

bunx mizzling drop

Use o drop com muito cuidado, ele inicia um comando interativo, mas mesmo assim, muito cuidado.

Finito

Agora você já pode usar o mizzle para gerenciar suas tabelas do DynamoDB. De forma mais simples, mais rápida e... divertida talvez. A documentação ainda não está completa, mas já podem ver pelo link mizzle-docs.

Ah! Me desculpem, esqueci de avisar para porem a capa de chuva, está garoando lá fora :)

Carregando publicação patrocinada...