Implementar operações do Firebase Data Connect usando SQL nativo

O Firebase Data Connect oferece várias maneiras de interagir com seu banco de dados do Cloud SQL:

  • GraphQL nativo: define tipos em schema.gql e Data Connect e traduz suas operações GraphQL para SQL. Essa é a abordagem padrão, que oferece tipagem forte e estruturas com esquema aplicado. A maior parte da documentação do Data Connect fora desta página discute essa opção. Quando possível, use esse método para aproveitar a segurança de tipo completa e o suporte a ferramentas.
  • A diretiva @view: define um tipo do GraphQL em schema.gql com suporte de uma instrução SQL SELECT personalizada. Isso é útil para criar visualizações somente leitura e fortemente tipadas com base em uma lógica SQL complexa. Esses tipos podem ser consultados como os tipos regulares. Consulte @view.
  • SQL nativo: incorpore instruções SQL diretamente em operações nomeadas em .gql usando campos raiz especiais. Isso oferece máxima flexibilidade e controle direto, especialmente para operações que não são facilmente expressas em GraphQL padrão, aproveitando recursos específicos do banco de dados ou usando extensões do PostgreSQL.

Este guia se concentra na opção SQL nativo.

Casos de uso comuns do SQL nativo

Embora o GraphQL nativo ofereça segurança de tipos total, e a diretiva @view forneça resultados fortemente tipados para relatórios SQL somente leitura, o SQL nativo oferece a flexibilidade necessária para:

  • Extensões do PostgreSQL: consulte e use diretamente qualquer extensão instalada do PostgreSQL (como PostGIS para dados geoespaciais) sem precisar mapear tipos complexos no seu esquema GraphQL.
  • Consultas complexas: execute SQL complexo com junções, subconsultas, agregações, funções de janela e procedimentos armazenados.
  • Manipulação de dados (DML): execute operações INSERT, UPDATE, DELETE diretamente. No entanto, não use SQL nativo para comandos da linguagem de definição de dados (DDL). Você precisa continuar fazendo alterações no nível do esquema usando GraphQL para manter o back-end e os SDKs gerados sincronizados.
  • Recursos específicos do banco de dados: use funções, operadores ou tipos de dados exclusivos do PostgreSQL.
  • Otimização de performance: ajuste manual de instruções SQL para caminhos críticos.

Campos raiz do SQL nativo

Para gravar operações com SQL, use um dos seguintes campos raiz dos tipos query ou mutation:

query campos

Campo Descrição
_select

Executa uma consulta SQL que retorna zero ou mais linhas.

Argumentos:

  • sql: o literal de string da instrução SQL. Para evitar injeção de SQL, use marcadores de posição posicionais ($1, $2 etc.) para valores de parâmetros.
  • params: uma lista ordenada de valores a serem vinculados aos marcadores de posição. Isso pode incluir literais, variáveis do GraphQL e mapas de contexto especiais injetados pelo servidor, como {_expr: "auth.uid"} (o ID do usuário autenticado).

Retorna: uma matriz JSON ([Any]).

_selectFirst

Executa uma consulta SQL que deve retornar zero ou uma linha.

Argumentos:

  • sql: o literal de string da instrução SQL. Para evitar injeção de SQL, use marcadores de posição posicionais ($1, $2 etc.) para valores de parâmetros.
  • params: uma lista ordenada de valores a serem vinculados aos marcadores de posição. Isso pode incluir literais, variáveis do GraphQL e mapas de contexto especiais injetados pelo servidor, como {_expr: "auth.uid"} (o ID do usuário autenticado).

Retorna: um objeto JSON (Any) ou null.

mutation campos

Campo Descrição
_execute

Executa uma instrução DML (INSERT, UPDATE, DELETE).

Argumentos:

  • sql: o literal de string da instrução SQL. Para evitar injeção de SQL, use marcadores de posição posicionais ($1, $2 etc.) para valores de parâmetros.

    É possível usar expressões de tabela comuns que modificam dados (por exemplo, WITH new_row AS (INSERT...)) porque esse campo retorna apenas a contagem de linhas. Somente _execute é compatível com CTEs.

  • params: uma lista ordenada de valores a serem vinculados aos marcadores de posição. Isso pode incluir literais, variáveis do GraphQL e mapas de contexto especiais injetados pelo servidor, como {_expr: "auth.uid"} (o ID do usuário autenticado).

Retorna: um Int (número de linhas afetadas).

As cláusulas RETURNING são ignoradas no resultado.

_executeReturning

Executa uma instrução DML com uma cláusula RETURNING, retornando zero ou mais linhas.

Argumentos:

  • sql: o literal de string da instrução SQL. Para evitar injeção de SQL, use marcadores de posição posicionais ($1, $2 etc.) para valores de parâmetros. As expressões de tabela comuns que modificam dados não são compatíveis.
  • params: uma lista ordenada de valores a serem vinculados aos marcadores de posição. Isso pode incluir literais, variáveis do GraphQL e mapas de contexto especiais injetados pelo servidor, como {_expr: "auth.uid"} (o ID do usuário autenticado).

Retorna: uma matriz JSON ([Any]).

_executeReturningFirst

Executa uma instrução DML com uma cláusula RETURNING, que deve retornar zero ou uma linha.

Argumentos:

  • sql: o literal de string da instrução SQL. Para evitar injeção de SQL, use marcadores de posição posicionais ($1, $2 etc.) para valores de parâmetros. As expressões de tabela comuns que modificam dados não são compatíveis.
  • params: uma lista ordenada de valores a serem vinculados aos marcadores de posição. Isso pode incluir literais, variáveis do GraphQL e mapas de contexto especiais injetados pelo servidor, como {_expr: "auth.uid"} (o ID do usuário autenticado).

Retorna: um objeto JSON (Any) ou null.

Observações:

  • As operações são executadas usando as permissões concedidas à conta de serviço do Data Connect.

  • Se você definir explicitamente o nome da tabela usando a diretiva @table (@table(name: "ExampleTable")), também será necessário colocar o nome da tabela entre aspas nas instruções SQL (SELECT field FROM "ExampleTable" ...).

    Sem as aspas, Data Connect vai converter o nome da tabela em snake case (example_table).

Regras e limitações de sintaxe

O SQL nativo aplica regras de análise estritas para garantir a segurança e evitar a injeção de SQL. Atente-se às seguintes restrições:

  • Comentários: use comentários de bloco (/* ... */). Os comentários de linha (--) são proibidos porque podem truncar cláusulas subsequentes (como filtros de segurança) durante a concatenação de consultas.
  • Parâmetros: use parâmetros posicionais ($1, $2) que correspondam à ordem da matriz params. Parâmetros nomeados ($id, :name) não são compatíveis.
  • Strings: literais de string estendidos (E'...') e strings entre cifrões ($$...$$) são compatíveis. Os escapes Unicode do PostgreSQL (U&'...') não são compatíveis.

Parâmetros em comentários

O analisador ignora tudo o que está dentro de um comentário em bloco. Se você comentar uma linha que contém um parâmetro (por exemplo, /* WHERE id = $1 */), também será necessário remover esse parâmetro da lista params. Caso contrário, a operação vai falhar com o erro unused parameter: $1.

Exemplos

Exemplo 1: SELECT básico com alias de campo

É possível criar um alias para o campo raiz (por exemplo, movies: _select) para deixar a resposta do cliente mais limpa (data.movies em vez de data._select).

queries.gql:

query GetMoviesByGenre($genre: String!, $limit:Int!) @auth(level: PUBLIC) {
  movies: _select(
    sql: """
      SELECT id, title, release_year, rating
      FROM movie
      WHERE genre = $1
      ORDER BY release_year DESC
      LIMIT $2
    """,
    params: [$genre, $limit]
  )
}

Depois de executar a consulta usando um SDK do cliente, o resultado estará em data.movies.

Exemplo 2: UPDATE básico

mutations.gql:

mutation UpdateMovieRating($movieId: UUID!, $newRating: Float!) @auth(level: NO_ACCESS) {
  _execute(
    sql: """
      UPDATE movie
      SET rating = $2
      WHERE id = $1
    """,
    params: [$movieId, $newRating]
  )
}

Depois de executar a mutação usando um SDK do cliente, o número de linhas afetadas vai estar em data._execute.

Exemplo 3: agregação básica

queries.gql:

query GetTotalReviewCount @auth(level: PUBLIC) {
  stats: _selectFirst(
    sql: "SELECT COUNT(*) as total_reviews FROM \"Reviews\""
  )
}

Depois de executar a consulta usando um SDK do cliente, o resultado estará em data.stats.total_reviews.

Exemplo 4: agregação avançada com RANK

queries.gql:

query GetMoviesRankedByRating @auth(level: PUBLIC) {
  _select(
    sql: """
      SELECT
        id,
        title,
        rating,
        RANK() OVER (ORDER BY rating DESC) as rank
      FROM movie
      WHERE rating IS NOT NULL
      LIMIT 20
    """,
    params: []
  )
}

Depois de executar a consulta usando um SDK do cliente, o resultado estará em data._select.

Exemplo 5: UPDATE com RETURNING e contexto de autenticação

mutations.gql:

mutation UpdateMyReviewText($movieId: UUID!, $newText: String!) @auth(level: USER) {
  updatedReview: _executeReturningFirst(
    sql: """
      UPDATE "Reviews"
      SET review_text = $2
      WHERE movie_id = $1 AND user_id = $3
      RETURNING movie_id, user_id, rating, review_text
    """,
    params: [$movieId,$newText,{_expr: "auth.uid" }]
  )
}

Depois de executar a mutação usando um SDK do cliente, os dados atualizados da postagem estarão em data.updatedReview.

Exemplo 6: CTE avançado com upserts (get-or-create atômico)

Esse padrão é útil para garantir que registros dependentes (como usuários ou filmes) existam antes de inserir um registro filho (como uma avaliação), tudo em uma única transação de banco de dados.

mutations.gql:

mutation CreateMovieCTE($movieId: UUID!, $userId: UUID!, $reviewId: UUID!) {
  _execute(
    sql: """
      WITH
      new_user AS (
        INSERT INTO "user" (id, username)
        VALUES ($2, 'Auto-Generated User')
        ON CONFLICT (id) DO NOTHING
        RETURNING id
      ),
      movie AS (
        INSERT INTO movie (id, title, image_url, release_year, genre)
        VALUES ($1, 'Auto-Generated Movie', 'https://placeholder.com', 2025, 'Sci-Fi')
        ON CONFLICT (id) DO NOTHING
        RETURNING id
      )
      INSERT INTO "Reviews" (id, movie_id, user_id, rating, review_text, review_date)
      VALUES (
        $3,
        $1,
        $2,
        5,
        'Good!',
        NOW()
      )
    """,
    params: [$movieId, $userId, $reviewId]
  )
}

Exemplo 7: como usar extensões do Postgres

O SQL nativo permite usar extensões do Postgres, como o PostGIS, sem precisar mapear tipos de geometria complexos no esquema do GraphQL ou alterar as tabelas subjacentes.

Neste exemplo, suponha que o app de restaurante tenha uma tabela que armazena dados de localização em uma coluna JSON de metadados (por exemplo, {"latitude": 37.3688, "longitude": -122.0363}). Se você ativou a extensão PostGIS, use operadores JSON padrão do Postgres (->>) para extrair esses valores na hora e transmiti-los para a função ST_MakePoint do PostGIS.

query GetNearbyActiveRestaurants($userLong: Float!, $userLat: Float!, $maxDistanceMeters: Float!) @auth(level: USER) {
  nearby: _select(
    sql: """
      SELECT
        id,
        name,
        tags,
        ST_Distance(
          ST_MakePoint((metadata->>'longitude')::float, (metadata->>'latitude')::float)::geography,
          ST_MakePoint($1, $2)::geography
        ) as distance_meters
      FROM restaurant
      WHERE active = true
        AND metadata ? 'longitude' AND metadata ? 'latitude'
        AND ST_DWithin(
          ST_MakePoint((metadata->>'longitude')::float, (metadata->>'latitude')::float)::geography,
          ST_MakePoint($1, $2)::geography,
          $3
        )
      ORDER BY distance_meters ASC
      LIMIT 10
    """,
    params: [$userLong, $userLat, $maxDistanceMeters]
  )
}

Depois de executar a consulta usando um SDK do cliente, o resultado estará em data.nearby.

Práticas recomendadas de segurança: SQL dinâmico e procedimentos armazenados

O Data Connect parametriza com segurança todas as entradas no limite de GraphQL para banco de dados, protegendo totalmente suas consultas SQL padrão contra injeção de SQL de primeira ordem. No entanto, se você usar SQL para chamar procedimentos ou funções armazenados personalizados do Postgres que executam SQL dinâmico, verifique se o código PL/pgSQL interno processa esses parâmetros com segurança.

Se o procedimento armazenado concatenar diretamente as entradas do usuário em uma string EXECUTE, ele vai ignorar a parametrização e criar uma vulnerabilidade de injeção de SQL de segunda ordem:

-- INSECURE: Do not concatenate parameters into dynamic strings!
CREATE OR REPLACE PROCEDURE unsafe_update(user_input TEXT)
LANGUAGE plpgsql AS $$
BEGIN
    -- A malicious user_input (e.g., "val'; DROP TABLE users; --") will execute as code.
    EXECUTE 'UPDATE target_table SET status = ''' || user_input || '''';
END;
$$;

Para evitar isso, siga estas práticas recomendadas:

  • Use a cláusula USING: ao escrever SQL dinâmico em procedimentos armazenados, use sempre a cláusula USING para vincular parâmetros de dados com segurança.
  • Use format() para identificadores: use format() com a flag %I para injeção segura de identificadores de banco de dados (como nomes de tabelas).
  • Permita apenas identificadores: não deixe que os aplicativos cliente escolham identificadores de banco de dados de forma arbitrária. Se o procedimento exigir identificadores dinâmicos, valide a entrada em relação a uma lista de permissão fixa na lógica PL/pgSQL antes da execução.
-- SECURE: Use format() for identifiers and USING for data values
CREATE OR REPLACE PROCEDURE secure_update(target_table TEXT, new_value TEXT, row_id INT)
LANGUAGE plpgsql AS $$
BEGIN
    -- Validate the dynamic table name against an allowlist
    IF target_table NOT IN ('orders', 'users', 'inventory') THEN
        RAISE EXCEPTION 'Invalid table name';
    END IF;

    -- Execute securely
    EXECUTE format('UPDATE %I SET status = $1 WHERE id = $2', target_table)
    USING new_value, row_id;
END;
$$;