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

Aproveitando template strings do node.js para construir um gerador de sql

Motivação

A empresa que trabalho hoje utiliza bastante Node.js como ferramenta para backend e o principal banco de dados é o Oracle DB, dito isso ao longo dos quase 2 anos em que estou atuando como desenvolvedor fullstack tem algo que me incomoda frequentemente: a construção de queries para interação com o banco de dados acaba sendo bastante trabalhosa, já ouvi sobre iniciativas para utilizar ferramentas como Knex (um query builder), porém como não teve sequência, acredito que foi descartada ao longo do tempo, a solução que encontrei para resolver um dos problemas hoje da empresa foi a criação de uma função que faça a concatenação das queries necessárias bem como a criação dos binds dentro dessas queries e na sequência pretendo mostrar como fiz essa primeira versão que ainda está em testes.

Template strings

Um grande agradecimento que tenho para iniciar esse projeto foi o uso frequente do styled-components no React, para quem não conhece é uma biblioteca para construir componentes com estilização pronta, e o seu uso se dá usando essa impressionante feature chamada template strings do javascript, aqui um exemplo da biblioteca

const color = "red";
const Button = styled.button`
  background: ${color};
  font-size: ${(props) => (props.size === "small" ? "12px" : "32px")};
`;

function Page() {
  return <Button size="small">Iniciar</Button>;
}

O que me deixava mais impressionado com essa biblioteca era que eu não só poderia colocar valores diretos, como também poderia interagir com as propriedades passadas para o botão, e outras coisas além (como as variáveis de tema que defini para a aplicação) através de uma função passada dentro da string.

Para quem não conhece seu comportamento, aqui vai uma explicação:
Você define uma função que recebe inúmeros parâmetros, sendo o primeiro uma lista com a string enviada porém quebrada em pedaços, e o restante dos parâmetros são as informações que você colocou como concatenação

function button(stringList, arg1, arg2, ...args) {
  console.log(stringList[0]);
  // '\n  background: '
  console.log(arg1);
  // 'red'
  console.log(stringList[1]);
  // ';\n    font-size: '
  console.log(arg2);
  // [Function (anonymous)]
  console.log(stringList[2]);
  // ';\n '
  console.log(args);
  // []
}

Para utilizar essa função da forma correta, você ignora o uso de parênteses () e no lugar, usará apenas a crase ```

button`
  background: ${color};
  font-size: ${(props) => (props.size === "small" ? "12px" : "32px")};
`;

E você já conseguirá interagir com os parâmetros do jeito que quiser, um exemplo básico de uso seria para simplesmente juntar os valores passado e devolver uma string com tudo concatenado:

function templater(strings, ...keys) {
  const finalString = strings
    .map((item, index) => {
      const param = params[index];

      return item + param;
    })
    .join("");
  return finalString;
}

Binds

Como disse antes, o meu objetivo é aproveitar essa funcionalidade, para automatizar de certa forma a criação de binds, assim, o Oracle DB terá mais facilidade de re-executar certas queries, e também mantendo a segurança da execução, onde seu uso seria mais ou menos assim:

const userId = 1;
const { query, binds } = sql`SELECT * FROM USERS U WHERE U.id = ${userId}`;
console.log(query);
// SELECT * FROM USERS U WHERE U.id = :bind0
console.log(binds);
// { bind0: 1 }

Sei que parece simples demais para esse exemplo, porém o problema começa quando são usados vários parãmetros para listar diferentes tabelas, onde alguns parâmetros precisam estar combinados com outros, e dependendo de qual foi a busca do usuário, algumas tabelas não precisam ser consultadas

SELECT U.* FROM USERS U
WHERE U.ID = :userId
-- bloco a seguir só é necessário se o bind `orderId` não estiver vazio
  AND (
    :orderId IS NULL
    OR U.ID IN (SELECT O.USER_ID FROM ORDERS O WHERE O.USER_ID = U.ID AND O.ID = :orderId)
  )

Mão no código

Tudo começa com o básico, juntar as strings e substituir os valores puros por um bind:

export function sql(queries, ...params) {
  const binds = {};

  const query = queries.map((queryPart, index) => {
    const param = params[index];

    if (!param) return queryPart;

    const key = `bind${index}`;

    binds[key] = param;
    return `${queryPart}:${key}`;
  });

  return { binds, query };
}

Essa é a parte básica onde já é possível resolver bastante dos problemas, inúmeras queries comuns podem ser aproveitadas com esse modelo, porém possui algumas limitações:

  1. Em caso de ser usado alguma lista (SELECT * FROM USERS WHERE ID IN (1,2,3,4)), eu teria que criar uma concatenação para cada elemento, ao invés de poder passar a lista diretamente:
sql`SELECT * FROM USERS WHERE ID IN (${userIdList})`;
  1. Eu teria que me contentar em evitar ao máximo manipulação de queries e não poderia ter a adição de queries condicionais:
sql`
SELECT U.* FROM USERS U
WHERE U.ID = :userId
${
  orderId &&
  `AND (
    ${orderId} IS NULL
    OR U.ID IN (SELECT O.USER_ID FROM ORDERS O WHERE O.USER_ID = U.ID AND O.ID = :orderId)
  )`
}
`;

Problemas com listas

Pode ser comum o uso de listas para filtro de dados os hotéis com 1, 2, 3 camas e a solução atual não está suficiente, então adicionei uma forma de tratar isso antes de colocar os parâmetros:

const query = queries.map((queryPart, index) => {
  const param = params[index];

  if (!param) return queryPart;

  // Lidando com listas
  if (Array.isArray(param)) {
    const bindList = param
      .map((paramItem, paramIndex) => {
        const key = `bind${index}_${paramIndex}`;

        binds[key] = paramItem;

        return `:${key}`;
      })
      .join(",");

    return `${queryPart}:${bindList}`;
  }
  //

  const key = `bind${index}`;

  binds[key] = param;
  return `${queryPart}:${key}`;
});

Problemas com queries condicionais

Para resolver o problema de queries condicionais, eu vou obrigar o desenvolvedor a charmar novamente a função sql, entendendo que assim ela já está parseada e só preciso concatenar essa query à sua query mãe, e também concatenar o objeto de binds

const query = queries.map((queryPart, index) => {
  const param = params[index];

  if (!param) return queryPart;

  // Lidando com listas
  // ...
  // Lidando com queries condicionais
  if (
    typeof param === "object" &&
    Object.keys(param).includes("query") &&
    Object.keys(param).includes("binds")
  ) {
    Object.assign(binds, param.binds);

    return `${queryPart}${param.query}`;
  }
  //

  const key = `bind${index}`;

  binds[key] = param;
  return `${queryPart}:${key}`;
});

Com essa adição, tenho uma solução parcial de queries condicionais, e consigo adicioná-las da seguinte forma

sql`
SELECT U.* FROM USERS U
WHERE U.ID = ${userId}
${
  orderId &&
  sql`
  AND U.ID IN (
    SELECT O.USER_ID 
    FROM ORDERS O
    WHERE O.USER_ID = U.ID AND O.ID = ${orderId}
  )`
}
`;

Ou seja, se o parâmetro orderId for um valor válido, vou adicionar a query que faz o filtro pelo id das ordens, caso contrário, o valor naquele bloco será undefined e a primeira validação do parâmetro já vai considerar que não é necessário.

Mas adicionei um novo problema: do jeito atual, haverá uma concorrência com relação a quem será o 'bind0', pois um não sabe que o outro possui um objeto de binds com o primeiro bind já definido:
A minha solução foi gerar valores randômicos para os nomes dos binds e ao invés de eu relacionar o bind ao index dele, eu simplesmente colo agrupamentos de caracteres aleatórios para os binds.

function generateRandomString(length = 6) {
  let result = "";
  const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

  for (let i = 0; i < length; i++) {
    const randomIndex = Math.floor(Math.random() * characters.length);
    result += characters.charAt(randomIndex);
  }

  return result;
}

const query = queries.map((queryPart, index) => {
  const param = params[index];

  if (!param) return queryPart;

  // Lidando com listas
  if (Array.isArray(param)) {
    const bindList = param
      .map((paramItem) => {
        const key = `${generateRandomString()}`; // adicionando o gerador de caracters aleatórios aqui

        binds[key] = paramItem;

        return `:${key}`;
      })
      .join(",");

    return `${queryPart}:${bindList}`;
  }
  // Lidando com queries condicionais
  if (
    typeof param === "object" &&
    Object.keys(param).includes("query") &&
    Object.keys(param).includes("binds")
  ) {
    Object.assign(binds, param.binds);

    return `${queryPart}${param.query}`;
  }
  //

  const key = `${generateRandomString()}`; // adicionando o gerador de caracters aleatórios aqui

  binds[key] = param;
  return `${queryPart}:${key}`;
});

Observações:

  • Esse texto não possui a versão completa. Mesmo antes de produzir esse artigo, eu já resolvi alguns outros problemas como:
    • Validar binds (se for enviado uma função ou um objeto que não é aceito).
    • Adicionar bind com nome específico (se eu quiser identificar o bind no projeto, posso chamá-lo de userId)
  • Essa é apenas uma ideia, ficarei bastante contente se essa ferramenta for utilizada e elogiada por meus colegas, o que começou num dia sem ser produtivo e sem expectativas, porém ainda não está em uso.
  • Aqui está o link para o meu repositório no github caso tenham interesse: Matan18/sql_constructor.git

Conclusão

Há muito tempo eu estava procurando uma oportunidade de construir algo que utilize essa funcionalidade do javascript, e fiquei bastante empolgado durante o desenvolvimento dessa ferramenta, sei que muitos de vocês talvez não precisem exatamente dessa funcionalidade do jeito que criei, mas espero que inspirem vocês a testarem essa funcionalidade para resolver os seus próprios problemas.