使用原生 SQL 實作 Firebase SQL Connect 作業

本指南說明如何使用 SQL (而非 GraphQL) 編寫 Firebase SQL Connect 作業。 page_type: guide announcement: > 原生 SQL 目前為功能搶先體驗版,因此不受任何服務水準協議或廢止政策規範,且可能有不具回溯相容性的變更。如果搭配使用這項功能與執行動態 SQL 的預存程序或函式,請遵循本頁底部說明的安全最佳做法。

Firebase SQL Connect 提供多種與 Cloud SQL 資料庫互動的方式:

  • 原生 GraphQL:在 schema.gql 中定義型別,SQL Connect 會將 GraphQL 作業轉換為 SQL。這是標準做法,可提供嚴格型別和結構定義強制執行的結構。大部分的 SQL Connect 說明文件 (本頁除外) 都在討論這個選項。建議盡可能使用這個方法,充分發揮類型安全和工具支援的優勢。
  • @view 指令:在 schema.gql 中定義 GraphQL 型別,並以自訂 SELECT SQL 陳述式做為後端。這項功能有助於根據複雜的 SQL 邏輯,建立唯讀的強型別檢視區塊。這些型別可像一般型別一樣查詢。請參閱「@view」。
  • 原生 SQL:使用特殊根欄位,直接在 .gql 檔案的具名作業中嵌入 SQL 陳述式。這可提供最大的彈性和直接控制權,特別是對於標準 GraphQL 不支援的作業、運用資料庫專屬功能或使用 PostgreSQL 擴充功能。與 GraphQL 和 @view 指令不同,原生 SQL 不會提供強型別輸出內容。

本指南著重於「原生 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) 指令明確自訂 Postgres 識別碼。

如果您定義的型別沒有指令,GraphQL 資料表和欄位名稱會對應至預設的 snake_case Postgres ID:

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 ID 不區分大小寫。如果您使用 @table@col 等指令指定含有大寫或大小寫混合字母的名稱,則必須在 SQL 陳述式中以雙引號括住該 ID。

在下列範例中,您必須使用 "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 和 Auth Context 進行 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:使用 Postgres 擴充功能

使用原生 SQL 時,您可以運用 PostGIS 等 Postgres 擴充功能,不必將複雜的幾何類型對應至 GraphQL 結構定義,也不必變更基礎資料表。

在這個範例中,假設您的餐廳應用程式有一個資料表,可將位置資料儲存在中繼資料 JSON 欄中 (例如 {"latitude": 37.3688, "longitude": -122.0363})。如果您已啟用 PostGIS 擴充功能,可以使用標準 Postgres 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 呼叫自訂 Postgres 預存程序或函式來執行動態 SQL,請務必確保內部 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() 做為 ID:搭配 %I 旗標使用 format(),確保資料庫 ID 注入安全無虞 (例如資料表名稱)。
  • 嚴格限制 ID:請勿讓用戶端應用程式任意選擇資料庫 ID。如果程序需要動態 ID,請先根據 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;
$$;