Implementa operaciones de Firebase SQL Connect con SQL nativo

Una guía para escribir operaciones de Firebase SQL Connect con SQL en lugar de GraphQL.page_type: guideannouncement: >Native SQL is available as a feature Preview, which means that it isn'tsubject to any SLA or deprecation policy and could change inbackwards-incompatible ways. Si usas esta función con procedimientos almacenados o funciones que ejecutan SQL dinámico, sigue las prácticas recomendadas de seguridad que se explican en la parte inferior de esta página.

Firebase SQL Connect ofrece varias formas de interactuar con tu base de datos de Cloud SQL:

  • GraphQL nativo: Define tipos en tu schema.gql y SQL Connect traduce tus operaciones de GraphQL a SQL. Este es el enfoque estándar, que ofrece tipos seguros y estructuras forzadas por el esquema. La mayor parte de la documentación de SQL Connect fuera de esta página analiza esta opción. Cuando sea posible, debes usar este método para aprovechar la seguridad de tipo completa y la compatibilidad con herramientas.
  • La directiva @view: Define un tipo de GraphQL en schema.gql respaldado por una instrucción SQL SELECT personalizada. Esto es útil para crear vistas de solo lectura y de tipo seguro basadas en una lógica SQL compleja. Se pueden consultar estos tipos como tipos normales. Consulta @view.
  • SQL nativo: Incorpora instrucciones SQL directamente en operaciones con nombre en .gql archivos con campos raíz especiales. Esto proporciona máxima flexibilidad y control directo, en especial para las operaciones que no son compatibles con GraphQL estándar, que aprovechan las funciones específicas de la base de datos o que utilizan extensiones de PostgreSQL. A diferencia de GraphQL y la directiva @view, el SQL nativo no proporciona un resultado de tipo seguro.

Esta guía se centra en la opción SQL nativo.

Casos de uso comunes para SQL nativo

Si bien GraphQL nativo proporciona seguridad de tipo completa y la directiva @view ofrece resultados de tipo seguro para informes SQL de solo lectura, el SQL nativo proporciona la flexibilidad necesaria para lo siguiente:

  • Extensiones de PostgreSQL: Consulta y usa directamente cualquier extensión de PostgreSQL instalada (como PostGIS para datos geoespaciales) sin necesidad de asignar tipos complejos en tu esquema de GraphQL.
  • Consultas complejas: Ejecuta SQL complejo con uniones, subconsultas, agregaciones, funciones de ventana y procedimientos almacenados.
  • Manipulación de datos (DML): Realiza operaciones INSERT, UPDATE, DELETEdirectamente. (Sin embargo, no uses SQL nativo para los comandos del lenguaje de definición de datos [DDL]. Debes seguir realizando alteraciones a nivel del esquema con GraphQL para mantener sincronizados tu backend y los SDK generados).
  • Funciones específicas de la base de datos: Utiliza funciones, operadores o tipos de datos exclusivos de PostgreSQL.
  • Optimización del rendimiento: Ajusta manualmente las instrucciones SQL para las rutas críticas.

Campos raíz de SQL nativo

Para escribir operaciones con SQL, usa uno de los siguientes campos raíz de los tipos query o mutation:

Campos query

Campo Descripción
_select

Ejecuta una consulta SQL que muestra cero o más filas.

Argumentos:

  • sql: Es el literal de cadena de la instrucción SQL. Para evitar la inyección de SQL, usa marcadores de posición posicionales ($1, $2, etcétera) para los valores de los parámetros.
  • params: Es una lista ordenada de valores para vincular a los marcadores de posición. Puede incluir literales, variables de GraphQL y mapas de contexto especiales insertados por el servidor, como {_expr: "auth.uid"} (el ID del usuario autenticado ).

Devuelve: Un array JSON ([Any]).

_selectFirst

Ejecuta una consulta SQL que se espera que muestre cero o una fila.

Argumentos:

  • sql: Es el literal de cadena de la instrucción SQL. Para evitar la inyección de SQL, usa marcadores de posición posicionales ($1, $2, etcétera) para los valores de los parámetros.
  • params: Es una lista ordenada de valores para vincular a los marcadores de posición. Puede incluir literales, variables de GraphQL y mapas de contexto especiales insertados por el servidor, como {_expr: "auth.uid"} (el ID del usuario autenticado ).

Devuelve: Un objeto JSON (Any) o null.

Campos mutation

Campo Descripción
_execute

Ejecuta una instrucción DML (INSERT, UPDATE, DELETE).

Argumentos:

  • sql: Es el literal de cadena de la instrucción SQL. Para evitar la inyección de SQL, usa marcadores de posición posicionales ($1, $2, etcétera) para los valores de los parámetros.

    Puedes usar expresiones de tabla comunes que modifican datos (por ejemplo, WITH new_row AS (INSERT...)) aquí porque este campo solo muestra el recuento de filas. Solo _execute admite CTE.

  • params: Es una lista ordenada de valores para vincular a los marcadores de posición. Puede incluir literales, variables de GraphQL y mapas de contexto especiales insertados por el servidor, como {_expr: "auth.uid"} (el ID del usuario autenticado ).

Devuelve: Un Int (cantidad de filas afectadas).

Se ignoran las cláusulas RETURNING en el resultado.

_executeReturning

Ejecuta una instrucción DML con una cláusula RETURNING y muestra cero o más filas.

Argumentos:

  • sql: Es el literal de cadena de la instrucción SQL. Para evitar la inyección de SQL, usa marcadores de posición posicionales ($1, $2, etcétera) para los valores de los parámetros. No se admiten expresiones de tabla comunes que modifican datos.
  • params: Es una lista ordenada de valores para vincular a los marcadores de posición. Puede incluir literales, variables de GraphQL y mapas de contexto especiales insertados por el servidor, como {_expr: "auth.uid"} (el ID del usuario autenticado ).

Devuelve: Un array JSON ([Any]).

_executeReturningFirst

Ejecuta una instrucción DML con una cláusula RETURNING que se espera que muestre cero o una fila.

Argumentos:

  • sql: Es el literal de cadena de la instrucción SQL. Para evitar la inyección de SQL, usa marcadores de posición posicionales ($1, $2, etcétera) para los valores de los parámetros. No se admiten expresiones de tabla comunes que modifican datos.
  • params: Es una lista ordenada de valores para vincular a los marcadores de posición. Puede incluir literales, variables de GraphQL y mapas de contexto especiales insertados por el servidor, como {_expr: "auth.uid"} (el ID del usuario autenticado ).

Devuelve: Un objeto JSON (Any) o null.

Notas:

  • Las operaciones se ejecutan con los permisos otorgados a la SQL Connect cuenta de servicio.

Reglas y limitaciones de sintaxis

El SQL nativo aplica reglas de análisis estrictas para garantizar la seguridad y evitar la inyección de SQL. Ten en cuenta las siguientes restricciones:

  • Comentarios: Usa comentarios de bloque (/* ... */). Los comentarios de línea (--) están prohibidos porque pueden truncar las cláusulas posteriores (como los filtros de seguridad) durante la concatenación de consultas.
  • Parámetros: Usa parámetros posicionales ($1, $2) que coincidan con el params orden del array. No se admiten parámetros con nombre ($id, :name).
  • Cadenas: Se admiten literales de cadena extendidos (E'...') y cadenas entre comillas de dólar ($$...$$). No se admiten escapes Unicode de PostgreSQL (U&'...') .

Parámetros en comentarios

El analizador ignora todo lo que se encuentra dentro de un comentario de bloque. Si comentas una línea que contiene un parámetro (por ejemplo, /* WHERE id = $1 */), también debes quitar ese parámetro de la lista params, o la operación fallará con el error unused parameter: $1.

Convenciones de nombres

Cuando escribes SQL nativo, interactúas directamente con tu base de datos de PostgreSQL, por lo que debes usar los nombres reales de la base de datos para las tablas y las columnas. De forma predeterminada, SQL Connect asigna automáticamente los nombres de tu esquema de GraphQL a snake case en la base de datos, a menos que personalices explícitamente los identificadores de Postgres con las @table(name) y @col(name) directivas.

Si defines un tipo sin directivas, los nombres de la tabla y del campo de GraphQL se asignan a los identificadores snake_case predeterminados de Postgres:

schema.gql queries.gql
type UserProfile {
  userId: ID!
  displayName: String
}
query GetUserProfileDefault($id: ID!) {
  profile: _selectFirst(
    sql: """
      SELECT user_id, display_name
      FROM user_profile
      WHERE user_id = $1
    """,
    params: [$id]
  )
}

Los identificadores de PostgreSQL no distinguen entre mayúsculas y minúsculas de forma predeterminada. Si usas directivas como @table o @col para especificar un nombre que contiene letras mayúsculas o mixtas, debes incluir ese identificador entre comillas dobles en tus instrucciones SQL.

En el siguiente ejemplo, debes usar "UserProfiles" para el nombre de la tabla y "profileId" para la columna userId. El campo displayName sigue la conversión predeterminada a display_name:

schema.gql queries.gql
type UserProfileCustom @table(name: "UserProfiles") {
  userId: ID! @col(name: "profileId")
  displayName: String
}
query GetUserProfileCustom($id: ID!) {
  profile: _selectFirst(
    sql: """
      SELECT "profileId", display_name
      FROM "UserProfiles"
      WHERE "profileId" = $1
    """,
    params: [$id]
  )
}

Ejemplos de uso

Ejemplo 1: SELECT básico con alias de campo

Puedes crear un alias del campo raíz (por ejemplo, movies: _select) para que la respuesta del cliente sea más clara (data.movies en lugar 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]
  )
}

Después de ejecutar la consulta con un SDK de cliente, el resultado estará en data.movies.

Ejemplo 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]
  )
}

Después de ejecutar la mutación con un SDK de cliente, la cantidad de filas afectadas estará en data._execute.

Ejemplo 3: Agregación básica

queries.gql:

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

Después de ejecutar la consulta con un SDK de cliente, el resultado estará en data.stats.total_reviews.

Ejemplo 4: Agregación avanzada con 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: []
  )
}

Después de ejecutar la consulta con un SDK de cliente, el resultado estará en data._select.

Ejemplo 5: UPDATE con RETURNING y contexto de autenticación

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"}]
  )
}

Después de ejecutar la mutación con un SDK de cliente, los datos de la publicación actualizados estarán en data.updatedReview.

Ejemplo 6: CTE avanzado con upserts (obtener o crear atómico)

Este patrón es útil para garantizar que existan registros dependientes (como usuarios o películas) antes de insertar un registro secundario (como una reseña), todo en una sola transacción de base de datos.

mutations.gql:

mutation CreateMovieCTE($movieId: UUID!, $userId: UUID!, $reviewId: UUID!) @auth(level: USER) {
  _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]
  )
}

_executeReturning y _executeReturningFirst unen tu consulta en una CTE superior para dar formato al resultado como JSON. PostgreSQL no permite anidar una CTE que modifica datos dentro de otra instrucción que modifica datos, lo que hace que la consulta falle.

Ejemplo 7: Uso de extensiones de Postgres

El SQL nativo te permite usar extensiones de Postgres, como PostGIS, sin necesidad de asignar tipos de geometría complejos a tu esquema de GraphQL ni alterar tus tablas subyacentes.

En este ejemplo, supongamos que tu app de restaurante tiene una tabla que almacena datos de ubicación en una columna JSON de metadatos (por ejemplo, {"latitude": 37.3688, "longitude": -122.0363}). Si habilitaste la extensión PostGIS, puedes usar operadores JSON estándar de Postgres (->>) para extraer estos valores sobre la marcha y pasarlos a la función ST_MakePoint de 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]
  )
}

Después de ejecutar la consulta con un SDK de cliente, el resultado estará en data.nearby.

Prácticas recomendadas de seguridad: SQL dinámico y procedimientos almacenados

SQL Connect parametriza de forma segura todas las entradas en el límite de GraphQL a la base de datos, lo que protege por completo tus consultas SQL estándar de la inyección de SQL de primer orden. Sin embargo, si usas SQL para llamar a procedimientos o funciones almacenados de Postgres personalizados que ejecutan SQL dinámico, debes asegurarte de que tu código PL/pgSQL interno controle estos parámetros de forma segura.

Si tu procedimiento almacenado concatena directamente las entradas del usuario en una cadena EXECUTE, omite la parametrización y crea una vulnerabilidad de inyección de SQL de segundo orden:

-- 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 esto, sigue estas prácticas recomendadas:

  • Usa la cláusula USING: Cuando escribas SQL dinámico en tus procedimientos almacenados, siempre usa la cláusula USING para vincular los parámetros de datos de forma segura.
  • Usa format() para los identificadores: Usa format() con la marca %I para la inyección segura de identificadores de bases de datos (como nombres de tablas).
  • Permite estrictamente los identificadores: No permitas que las aplicaciones cliente elijan identificadores de bases de datos de forma arbitraria. Si tu procedimiento requiere identificadores dinámicos, valida la entrada con una lista de entidades permitidas codificada dentro de tu lógica PL/pgSQL antes de la ejecución.
-- 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;
$$;