[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:
| id | name | homeworld |
|---|---|---|
| 1 | Luke Skywalker | Tatooine |
| 2 | Anakin Skywalker | Tatooine |
| 3 | Obi-Wan Kenobi | Stewjon |
Tabela JediRank:
| rankId | position | joinedCouncilDate |
|---|---|---|
| 1 | Jedi | 2021-01-01 |
| 2 | Sith | 2021-01-01 |
| 3 | Jedi | 2021-01-01 |
Para otimizar isso no SQL precisariamos normalizar, ou seja, criar mais tabelas:
Tabela Planetas: Armazena os nomes dos planetas uma única vez.
| id | name |
|---|---|
| 1 | Tatooine |
| 2 | Stewjon |
Tabela Ranks: Armazena as posições possíveis.
| id | title |
|---|---|
| 1 | Jedi |
| 2 | Sith |
Tabela Jedi (Atualizada): Agora ela não guarda o nome do planeta, apenas o planet_id.
| id | name | planet_id |
|---|---|---|
| 1 | Luke Skywalker | 1 |
| 2 | Anakin Skywalker | 1 |
| 3 | Obi-Wan Kenobi | 2 |
E por fim JediRank (Atualizada): Aqui fazemos a ponte entre o Jedi, o cargo dele e a data.
| jedi_id | rank_id | joined_council_date |
|---|---|---|
| 1 | 1 | 2021-01-01 |
| 2 | 2 | 2021-01-01 |
| 3 | 1 | 2021-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) | Name | Homeworld | Date |
|---|---|---|---|---|
| JEDI#1 | METADATA | Luke Skywalker | Tatooine | |
| JEDI#1 | RANK#2021-01-01 | Jedi | 2021-01-01 | |
| JEDI#2 | METADATA | Anakin Skywalker | Tatooine | |
| JEDI#2 | RANK#2021-01-01 | Sith | 2021-01-01 | |
| PLANET#TATOOINE | METADATA | Tatooine | ||
| PLANET#STEWJON | METADATA | Stewjon |
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 :)