使用原生 SQL 实现 Firebase SQL Connect 操作

有关如何使用 SQL 而不是 GraphQL 编写 Firebase SQL Connect 操作的指南。 page_type: guide announcement: > 原生 SQL 作为功能预览版提供,这意味着它不受任何服务等级协议 (SLA) 或弃用政策的约束,并且可能会以不向后兼容的方式更改。如果您将此功能与执行动态 SQL 的存储过程或函数搭配使用,请遵循本页底部介绍的安全最佳实践。

Firebase SQL Connect 提供了多种与 Cloud SQL 数据库交互的方式:

  • 原生 GraphQL:在 schema.gql 中定义类型,SQL Connect 将您的 GraphQL 操作转换为 SQL。这是标准方法,可提供强类型和架构强制执行的结构。本页之外的大部分 SQL Connect 文档都讨论了此选项。如果可以,您应使用此方法,以便充分利用类型安全性和工具支持。
  • @view 指令:在 schema.gql 中定义由自定义 SELECT SQL 语句支持的 GraphQL 类型。这对于基于复杂的 SQL 逻辑创建只读的强类型视图非常有用。这些类型可像常规类型一样进行查询。请参阅 @view
  • 原生 SQL:使用特殊的根字段将 SQL 语句直接嵌入到 .gql 文件中的命名操作中。这可提供最大的灵活性和直接控制,尤其适用于标准 GraphQL 不支持的操作、利用数据库特定功能或使用 PostgreSQL 扩展程序的情况。 与 GraphQL 和 @view 指令不同,原生 SQL 不提供强类型输出。

本指南重点介绍 Native SQL 选项。

原生 SQL 的常见使用场景

虽然原生 GraphQL 提供完整的类型安全性,并且 @view 指令可为只读 SQL 报告提供强类型结果,但原生 SQL 提供了所需的灵活性,可用于:

  • PostgreSQL 扩展程序:直接查询和使用任何已安装的 PostgreSQL 扩展程序(例如用于地理空间数据的 PostGIS),而无需在 GraphQL 架构中映射复杂类型。
  • 复杂查询:执行包含联接、子查询、聚合、窗口函数和存储过程的复杂 SQL。
  • 数据操纵 (DML):直接执行 INSERT, UPDATE, DELETE 操作。(不过,请勿将原生 SQL 用于数据定义语言 (DDL) 命令。您必须继续使用 GraphQL 进行架构级更改,以使后端和生成的 SDK 保持同步。)
  • 特定于数据库的功能:利用 PostgreSQL 特有的函数、运算符或数据类型。
  • 性能优化:针对关键路径手动调整 SQL 语句。

原生 SQL 根字段

如需使用 SQL 编写操作,请使用 querymutation 类型的以下某个根字段:

query 个字段

字段 说明
_select

执行 SQL 查询,返回零个或多个行。

实参

  • sql:SQL 语句字符串字面量。为防止 SQL 注入,请为参数值使用位置占位符($1$2 等)。
  • params:要绑定到占位符的值的有序列表。这可以包括字面量、GraphQL 变量和特殊服务器注入的上下文映射(例如 {_expr: "auth.uid"},即经过身份验证的用户的 ID)。

返回:JSON 数组 ([Any])。

_selectFirst

执行预期会返回零行或一行结果的 SQL 查询。

实参

  • sql:SQL 语句字符串字面量。为防止 SQL 注入,请为参数值使用位置占位符($1$2 等)。
  • params:要绑定到占位符的值的有序列表。这可以包括字面量、GraphQL 变量和特殊服务器注入的上下文映射(例如 {_expr: "auth.uid"},即经过身份验证的用户的 ID)。

返回:JSON 对象 (Any) 或 null

mutation 个字段

字段 说明
_execute

执行 DML 语句 (INSERT, UPDATE, DELETE)。

实参

  • sql:SQL 语句字符串字面量。为防止 SQL 注入,请为参数值使用位置占位符($1$2 等)。

    您可以在此处使用修改数据的通用表表达式(例如 WITH new_row AS (INSERT...)),因为此字段仅返回行数。只有 _execute 支持 CTE。

  • params:要绑定到占位符的值的有序列表。这可以包括字面量、GraphQL 变量和特殊服务器注入的上下文映射(例如 {_expr: "auth.uid"},即经过身份验证的用户的 ID)。

返回:一个 Int(受影响的行数)。

结果中会忽略 RETURNING 子句。

_executeReturning

执行包含 RETURNING 子句的 DML 语句,返回零行或多行。

实参

  • sql:SQL 语句字符串字面量。为防止 SQL 注入,请为参数值使用位置占位符($1$2 等)。不支持修改数据的通用表表达式。
  • params:要绑定到占位符的值的有序列表。这可以包括字面量、GraphQL 变量和特殊服务器注入的上下文映射(例如 {_expr: "auth.uid"},即经过身份验证的用户的 ID)。

返回:JSON 数组 ([Any])。

_executeReturningFirst

执行包含 RETURNING 子句的 DML 语句,预计返回零行或一行。

实参

  • sql:SQL 语句字符串字面量。为防止 SQL 注入,请为参数值使用位置占位符($1$2 等)。不支持修改数据的通用表表达式。
  • params:要绑定到占位符的值的有序列表。这可以包括字面量、GraphQL 变量和特殊服务器注入的上下文映射(例如 {_expr: "auth.uid"},即经过身份验证的用户的 ID)。

返回:JSON 对象 (Any) 或 null

备注:

  • 操作是使用授予 SQL Connect 服务账号的权限执行的。

语法规则和限制

原生 SQL 会强制执行严格的解析规则,以确保安全性并防止 SQL 注入。请注意以下限制:

  • 注释:使用块注释 (/* ... */)。禁止使用行注释 (--),因为它们可能会在查询串联期间截断后续子句(例如安全过滤条件)。
  • 参数:使用与 params 数组顺序匹配的位置参数($1$2)。不支持命名参数($id:name)。
  • 字符串:支持扩展字符串字面量 (E'...') 和美元符号引用字符串 ($$...$$)。不支持 PostgreSQL Unicode 转义序列 (U&'...')。

注释中的形参

解析器会忽略块注释中的所有内容。如果您注释掉包含参数(例如 /* WHERE id = $1 */)的行,还必须从 params 列表中移除该参数,否则操作将失败并显示错误 unused parameter: $1

命名规则

编写原生 SQL 时,您将直接与 PostgreSQL 数据库进行交互,因此必须使用表和列的实际数据库名称。默认情况下,SQL Connect 会自动将 GraphQL 架构中的名称映射到数据库中的蛇形命名法,除非您使用 @table(name)@col(name) 指令明确自定义 PostgreSQL 标识符。

如果您定义了不含指令的类型,GraphQL 表格和字段名称会映射到默认的 snake_case PostgreSQL 标识符:

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

默认情况下,PostgreSQL 标识符不区分大小写。如果您使用 @table@col 等指令来指定包含大写字母或大小写混合字母的名称,则必须在 SQL 语句中使用双引号将该标识符括起来。

在以下示例中,您必须将 "UserProfiles" 用作表名称,将 "profileId" 用作 userId 列。displayName 字段遵循默认转换规则,转换为 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]
  )
}

用法示例

示例 1:使用字段别名的基本 SELECT 语句

您可以为根字段(例如 movies: _select)设置别名,使客户端响应更简洁(data.movies 而不是 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]
  )
}

使用客户端 SDK 运行查询后,结果将位于 data.movies 中。

示例 2:基本 UPDATE

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

使用客户端 SDK 运行更改后,受影响的行数将位于 data._execute 中。

示例 3:基本聚合

queries.gql

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

使用客户端 SDK 运行查询后,结果将位于 data.stats.total_reviews 中。

示例 4:使用 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: []
  )
}

使用客户端 SDK 运行查询后,结果将位于 data._select 中。

示例 5:使用 RETURNING 和身份验证上下文的 UPDATE 语句

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

使用客户端 SDK 运行相应变更后,更新后的帖子数据将位于 data.updatedReview 中。

示例 6:使用 upsert(原子 get-or-create)的高级 CTE

此模式有助于确保在单个数据库事务中插入子记录(例如评价)之前,存在相关记录(例如用户或电影)。

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_executeReturningFirst 将查询封装在父 CTE 中,以将输出格式设置为 JSON。PostgreSQL 不允许将修改数据的 CTE 嵌套在另一个修改数据的语句中,这会导致查询失败。

示例 7:使用 PostgreSQL 扩展程序

借助原生 SQL,您可以使用 PostgreSQL 扩展程序(例如 PostGIS),而无需将复杂的几何图形类型映射到 GraphQL 架构或更改底层表。

在此示例中,假设您的餐厅应用有一个表,用于在元数据 JSON 列(例如 {"latitude": 37.3688, "longitude": -122.0363})中存储位置数据。如果您已启用 PostGIS 扩展程序,则可以使用标准 PostgreSQL JSON 运算符 (->>) 动态提取这些值,并将它们传递到 PostGIS ST_MakePoint 函数中。

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

使用客户端 SDK 运行查询后,结果将位于 data.nearby 中。

安全最佳实践:动态 SQL 和存储过程

SQL Connect 在 GraphQL 到数据库的边界安全地对所有输入进行形参化,从而全面保护您的标准 SQL 查询免受一阶 SQL 注入的攻击。不过,如果您使用 SQL 调用执行动态 SQL 的自定义 PostgreSQL 存储过程或函数,则必须确保内部 PL/pgSQL 代码能够安全地处理这些参数。

如果您的存储过程直接将用户输入连接到 EXECUTE 字符串,则会绕过参数化并创建二阶 SQL 注入漏洞:

-- 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;
$$;

为避免这种情况,请遵循以下最佳实践:

  • 使用 USING 子句:在存储过程中编写动态 SQL 时,请务必使用 USING 子句安全地绑定数据参数。
  • 使用 format() 作为标识符:将 format()%I 标志搭配使用,以安全地注入数据库标识符(例如表名称)。
  • 严格允许标识符:不允许客户端应用随意选择数据库标识符。如果您的过程需要动态标识符,请在执行之前根据 PL/pgSQL 逻辑中的硬编码许可名单验证输入。
-- 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;
$$;