ネイティブ SQL を使用して Firebase SQL Connect オペレーションを実装する

GraphQL ではなく SQL を使用して 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 ディレクティブ: カスタム SELECT SQL ステートメントでサポートされる schema.gql で GraphQL 型を定義します。これは、複雑な SQL ロジックに基づいて読み取り専用の厳密な型指定ビューを作成する場合に便利です。これらの型は、通常の型と同様にクエリ可能です。@view をご覧ください。
  • ネイティブ SQL: 特別なルート フィールドを使用して、.gql ファイルの名前付きオペレーションに SQL ステートメントを直接埋め込みます。これにより、特に標準の GraphQL でサポートされていないオペレーション、データベース固有の機能の活用、PostgreSQL 拡張機能の利用において、最大限の柔軟性と直接的な制御が可能になります。GraphQL や @view ディレクティブとは異なり、ネイティブ SQL は厳密に型指定された出力を提供しません。

このガイドでは、ネイティブ SQL オプションに焦点を当てます。

ネイティブ SQL の一般的なユースケース

ネイティブ GraphQL は完全な型安全性を実現し、@view ディレクティブは読み取り専用の SQL レポートに厳密に型指定された結果を提供しますが、ネイティブ SQL は次のために必要な柔軟性を提供します。

  • PostgreSQL 拡張機能: GraphQL スキーマで複雑な型をマッピングする必要なく、インストールされている PostgreSQL 拡張機能(地理空間データ用の PostGIS など)を直接クエリして使用します。
  • 複雑なクエリ: 結合、サブクエリ、集計、ウィンドウ関数、ストアド プロシージャを含む複雑な SQL を実行します。
  • データ操作(DML): INSERT, UPDATE, DELETE オペレーションを直接実行します。(ただし、データ定義言語(DDL)コマンドにはネイティブ SQL を使用しないでください。バックエンドと生成された SDK の同期を維持するには、GraphQL を使用してスキーマレベルの変更を継続的に行う必要があります)。
  • データベース固有の機能: PostgreSQL 固有の関数、演算子、データ型を利用します。
  • パフォーマンスの最適化: クリティカル パス用に SQL ステートメントを手動で調整します。

ネイティブ SQL のルート フィールド

SQL で書き込みオペレーションを行うには、query タイプまたは mutation タイプの次のいずれかのルートフィールドを使用します。

query フィールド

フィールド 説明
_select

0 個以上の行を返す SQL クエリを実行します。

引数:

  • sql: SQL ステートメントの文字列リテラル。SQL インジェクションを防ぐには、パラメータ値に位置プレースホルダ($1$2 など)を使用します。
  • params: プレースホルダにバインドする値の順序付きリスト。これには、リテラル、GraphQL 変数、{_expr: "auth.uid"}(認証済みユーザーの ID)などの特別なサーバー挿入コンテキスト マップが含まれます。

戻り値: JSON 配列([Any])。

_selectFirst

0 行または 1 行を返すことが想定される 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...) など)を使用できます。CTEs をサポートしているのは _execute のみです。

  • params: プレースホルダにバインドする値の順序付きリスト。これには、リテラル、GraphQL 変数、{_expr: "auth.uid"}(認証済みユーザーの ID)などの特別なサーバー挿入コンテキスト マップが含まれます。

戻り値: Int(影響を受けた行数)。

RETURNING 句は結果で無視されます。

_executeReturning

RETURNING 句を含む DML ステートメントを実行し、0 個以上の行を返します。

引数:

  • sql: SQL ステートメントの文字列リテラル。SQL インジェクションを防ぐには、パラメータ値に位置プレースホルダ($1$2 など)を使用します。データを変更する共通テーブル式はサポートされていません。
  • params: プレースホルダにバインドする値の順序付きリスト。これには、リテラル、GraphQL 変数、{_expr: "auth.uid"}(認証済みユーザーの ID)などの特別なサーバー挿入コンテキスト マップが含まれます。

戻り値: JSON 配列([Any])。

_executeReturningFirst

RETURNING 句を含む DML ステートメントを実行します。ゼロ行または 1 行が返されることが想定されます。

引数:

  • 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" を使用し、userId 列に "profileId" を使用する必要があります。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._select ではなく data.movies)。

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 コンテキストを使用した 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: アップサート(アトミック 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 を使用すると、複雑なジオメトリ型を GraphQL スキーマにマッピングしたり、基盤となるテーブルを変更したりすることなく、PostGIS などの PostgreSQL 拡張機能を使用できます。

この例では、レストラン アプリにメタデータ 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 クエリを 1 次 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() を使用する: %I フラグとともに format() を使用して、安全なデータベース識別子(テーブル名など)の挿入を行います。
  • 識別子を厳密に許可する: クライアント アプリケーションがデータベース識別子を任意に選択できないようにします。プロシージャで動的識別子が必要な場合は、実行前に 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;
$$;