Firebase SQL Connect-Vorgänge mit nativem SQL implementieren

Eine Anleitung zum Schreiben von Firebase SQL Connect-Vorgängen mit SQL anstelle von GraphQL. page_type: guide announcement: > Native SQL ist als Vorschaufunktion verfügbar. Das bedeutet, dass sie keinen SLA oder Richtlinien zur Einstellung von Produkten und Diensten unterliegt und sich in nicht abwärtskompatibler Weise ändern kann. Wenn Sie dieses Feature mit gespeicherten Prozeduren oder Funktionen verwenden, die dynamisches SQL ausführen, sollten Sie die Sicherheits-Best Practices befolgen, die unten auf dieser Seite beschrieben werden.

Firebase SQL Connect bietet mehrere Möglichkeiten zur Interaktion mit Ihrer Cloud SQL-Datenbank:

  • Natives GraphQL: Definieren Sie Typen in Ihrem schema.gql. SQL Connect übersetzt Ihre GraphQL-Vorgänge in SQL. Dies ist der Standardansatz, der eine starke Typisierung und schemabezogene Strukturen bietet. In den meisten SQL Connect-Dokumenten außerhalb dieser Seite wird diese Option behandelt. Wenn möglich, sollten Sie diese Methode verwenden, um die Vorteile der vollständigen Typsicherheit und der Tooling-Unterstützung zu nutzen.
  • Die @view-Anweisung: Definieren Sie einen GraphQL-Typ in schema.gql, der auf einer benutzerdefinierten SELECT-SQL-Anweisung basiert. Dies ist nützlich, um schreibgeschützte, stark typisierte Ansichten auf Grundlage komplexer SQL-Logik zu erstellen. Diese Typen können wie reguläre Typen abgefragt werden. Weitere Informationen finden Sie unter @view.
  • Natives SQL: Sie können SQL-Anweisungen mithilfe spezieller Stammfelder direkt in benannte Vorgänge in .gql-Dateien einbetten. Dies bietet maximale Flexibilität und direkte Kontrolle, insbesondere für Vorgänge, die nicht von Standard-GraphQL unterstützt werden, datenbankspezifische Funktionen nutzen oder PostgreSQL-Erweiterungen verwenden. Im Gegensatz zu GraphQL und der @view-Anweisung wird bei nativem SQL keine stark typisierte Ausgabe bereitgestellt.

In diesem Leitfaden geht es um die Option Native SQL.

Gängige Anwendungsfälle für nativen SQL-Code

Während natives GraphQL vollständige Typsicherheit bietet und die @view-Anweisung stark typisierte Ergebnisse für schreibgeschützte SQL-Berichte bietet, bietet natives SQL die Flexibilität, die für Folgendes erforderlich ist:

  • PostgreSQL-Erweiterungen: Sie können direkt installierte PostgreSQL-Erweiterungen (z. B. PostGIS für geografische Daten) abfragen und verwenden, ohne komplexe Typen in Ihrem GraphQL-Schema zuordnen zu müssen.
  • Komplexe Abfragen: Führen Sie komplexe SQL-Anweisungen mit Joins, Unterabfragen, Aggregationen, Fensterfunktionen und gespeicherten Prozeduren aus.
  • Datenbearbeitung (DML): Führen Sie INSERT, UPDATE, DELETE-Vorgänge direkt aus. Verwenden Sie jedoch kein natives SQL für DDL-Befehle (Data Definition Language). Sie müssen weiterhin Änderungen auf Schemaebene mit GraphQL vornehmen, um Ihr Backend und die generierten SDKs zu synchronisieren.)
  • Datenbankspezifische Funktionen: Verwenden Sie Funktionen, Operatoren oder Datentypen, die nur in PostgreSQL verfügbar sind.
  • Leistungsoptimierung: SQL-Anweisungen für kritische Pfade manuell optimieren.

Native SQL-Stammfelder

Verwenden Sie eines der folgenden Stammfelder der Typen query oder mutation, um Vorgänge mit SQL zu schreiben:

query Felder

Feld Beschreibung
_select

Führt eine SQL-Abfrage aus, die null oder mehr Zeilen zurückgibt.

Argumente:

  • sql: Das Stringliteral der SQL-Anweisung. Um SQL-Injection zu verhindern, verwenden Sie Platzhalter für Parameterwerte ($1, $2 usw.).
  • params: Eine geordnete Liste von Werten, die an die Platzhalter gebunden werden sollen. Dazu können Literale, GraphQL-Variablen und spezielle serverseitig eingefügte Kontextzuordnungen wie {_expr: "auth.uid"} (die ID des authentifizierten Nutzers) gehören.

Gibt zurück: ein JSON-Array ([Any]).

_selectFirst

Führt eine SQL-Abfrage aus, die voraussichtlich keine oder eine Zeile zurückgibt.

Argumente:

  • sql: Das Stringliteral der SQL-Anweisung. Um SQL-Injection zu verhindern, verwenden Sie Platzhalter für Parameterwerte ($1, $2 usw.).
  • params: Eine geordnete Liste von Werten, die an die Platzhalter gebunden werden sollen. Dazu können Literale, GraphQL-Variablen und spezielle serverseitig eingefügte Kontextzuordnungen wie {_expr: "auth.uid"} (die ID des authentifizierten Nutzers) gehören.

Gibt zurück: ein JSON-Objekt (Any) oder null.

mutation Felder

Feld Beschreibung
_execute

Führt eine DML-Anweisung (INSERT, UPDATE, DELETE) aus.

Argumente:

  • sql: Das Stringliteral der SQL-Anweisung. Um SQL-Injection zu verhindern, verwenden Sie Platzhalter für Parameterwerte ($1, $2 usw.).

    Sie können hier datenändernde CTEs (Common Table Expressions, z. B. WITH new_row AS (INSERT...)) verwenden, da in diesem Feld nur die Anzahl der Zeilen zurückgegeben wird. Nur _execute unterstützt CTEs.

  • params: Eine geordnete Liste von Werten, die an die Platzhalter gebunden werden sollen. Dazu können Literale, GraphQL-Variablen und spezielle serverseitig eingefügte Kontextzuordnungen wie {_expr: "auth.uid"} (die ID des authentifizierten Nutzers) gehören.

Rückgaben: eine Int (Anzahl der betroffenen Zeilen).

RETURNING-Klauseln werden im Ergebnis ignoriert.

_executeReturning

Führt eine DML-Anweisung mit einer RETURNING-Klausel aus und gibt null oder mehr Zeilen zurück.

Argumente:

  • sql: Das Stringliteral der SQL-Anweisung. Um SQL-Injection zu verhindern, verwenden Sie Platzhalter für Parameterwerte ($1, $2 usw.). Datenändernde Common Table Expressions werden nicht unterstützt.
  • params: Eine geordnete Liste von Werten, die an die Platzhalter gebunden werden sollen. Dazu können Literale, GraphQL-Variablen und spezielle serverseitig eingefügte Kontextzuordnungen wie {_expr: "auth.uid"} (die ID des authentifizierten Nutzers) gehören.

Gibt zurück: ein JSON-Array ([Any]).

_executeReturningFirst

Führt eine DML-Anweisung mit einer RETURNING-Klausel aus, die voraussichtlich null oder eine Zeile zurückgibt.

Argumente:

  • sql: Das Stringliteral der SQL-Anweisung. Um SQL-Injection zu verhindern, verwenden Sie Platzhalter für Parameterwerte ($1, $2 usw.). Datenändernde Common Table Expressions werden nicht unterstützt.
  • params: Eine geordnete Liste von Werten, die an die Platzhalter gebunden werden sollen. Dazu können Literale, GraphQL-Variablen und spezielle serverseitig eingefügte Kontextzuordnungen wie {_expr: "auth.uid"} (die ID des authentifizierten Nutzers) gehören.

Gibt zurück: ein JSON-Objekt (Any) oder null.

Hinweise:

  • Vorgänge werden mit den Berechtigungen ausgeführt, die dem SQL Connect-Dienstkonto gewährt wurden.

Syntaxregeln und Einschränkungen

Bei nativem SQL werden strenge Parsing-Regeln angewendet, um die Sicherheit zu gewährleisten und SQL-Injection zu verhindern. Beachten Sie die folgenden Einschränkungen:

  • Kommentare: Verwenden Sie Blockkommentare (/* ... */). Zeilenkommentare (--) sind verboten, da sie nachfolgende Anweisungen (z. B. Sicherheitsfilter) während der Abfrageverkettung abschneiden können.
  • Parameter: Verwenden Sie Positionsparameter ($1, $2), die der Reihenfolge des params-Arrays entsprechen. Benannte Parameter ($id, :name) werden nicht unterstützt.
  • Strings: Erweiterte Stringliterale (E'...') und dollar-quoted strings ($$...$$) werden unterstützt. PostgreSQL-Unicode-Escapes (U&'...') werden nicht unterstützt.

Parameter in Kommentaren

Der Parser ignoriert alles innerhalb eines Blockkommentars. Wenn Sie eine Zeile mit einem Parameter (z. B. /* WHERE id = $1 */) auskommentieren, müssen Sie diesen Parameter auch aus der Liste params entfernen. Andernfalls schlägt der Vorgang mit dem Fehler unused parameter: $1 fehl.

Namenskonventionen

Wenn Sie nativen SQL-Code schreiben, interagieren Sie direkt mit Ihrer PostgreSQL-Datenbank. Daher müssen Sie die tatsächlichen Datenbanknamen für Tabellen und Spalten verwenden. Standardmäßig ordnet SQL Connect die Namen in Ihrem GraphQL-Schema automatisch Snake Case in der Datenbank zu, sofern Sie die PostgreSQL-Bezeichner nicht explizit mit den Direktiven @table(name) und @col(name) anpassen.

Wenn Sie einen Typ ohne Direktiven definieren, werden die GraphQL-Tabellen- und Feldnamen den Standard-PostgreSQL-Kennungen snake_case zugeordnet:

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

Bei PostgreSQL-Kennungen wird standardmäßig nicht zwischen Groß- und Kleinschreibung unterschieden. Wenn Sie mit Direktiven wie @table oder @col einen Namen angeben, der Groß- oder gemischte Buchstaben enthält, müssen Sie diesen Bezeichner in Ihren SQL-Anweisungen in doppelte Anführungszeichen setzen.

Im folgenden Beispiel müssen Sie "UserProfiles" für den Tabellennamen und "profileId" für die Spalte userId verwenden. Das Feld displayName folgt der Standardkonvertierung in 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]
  )
}

Anwendungsbeispiele

Beispiel 1: Einfache SELECT-Anweisung mit Feldaliasen

Sie können das Stammfeld (z. B. movies: _select) aliasieren, um die Clientantwort übersichtlicher zu gestalten (data.movies statt 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]
  )
}

Nachdem Sie die Abfrage mit einem Client SDK ausgeführt haben, befindet sich das Ergebnis in data.movies.

Beispiel 2: Einfacher UPDATE-Vorgang

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

Nachdem Sie die Mutation mit einem Client SDK ausgeführt haben, finden Sie die Anzahl der betroffenen Zeilen in data._execute.

Beispiel 3: Einfache Aggregation

queries.gql:

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

Nachdem Sie die Abfrage mit einem Client-SDK ausgeführt haben, befindet sich das Ergebnis in data.stats.total_reviews.

Beispiel 4: Erweiterte Aggregation mit 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: []
  )
}

Nachdem Sie die Abfrage mit einem Client-SDK ausgeführt haben, befindet sich das Ergebnis in data._select.

Beispiel 5: UPDATE mit RETURNING und Auth-Kontext

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

Nachdem Sie die Mutation mit einem Client-SDK ausgeführt haben, befinden sich die aktualisierten Post-Daten in data.updatedReview.

Beispiel 6: Erweiterter CTE mit Upserts (atomares „get-or-create“)

Dieses Muster ist nützlich, um sicherzustellen, dass abhängige Datensätze (z. B. Nutzer oder Filme) vorhanden sind, bevor ein untergeordneter Datensatz (z. B. eine Rezension) in einer einzelnen Datenbanktransaktion eingefügt wird.

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 und _executeReturningFirst umschließen Ihre Abfrage in einem übergeordneten CTE, um die Ausgabe als JSON zu formatieren. PostgreSQL lässt es nicht zu, eine CTE, die Daten ändert, in einer anderen Anweisung zu verschachteln, die Daten ändert. Daher schlägt die Abfrage fehl.

Beispiel 7: PostgreSQL-Erweiterungen verwenden

Mit nativem SQL können Sie PostgreSQL-Erweiterungen wie PostGIS verwenden, ohne komplexe Geometrietypen in Ihr GraphQL-Schema einbinden oder die zugrunde liegenden Tabellen ändern zu müssen.

Angenommen, Ihre Restaurant-App hat eine Tabelle, in der Standortdaten in einer JSON-Metadatenspalte gespeichert sind (z. B. {"latitude": 37.3688, "longitude": -122.0363}). Wenn Sie die PostGIS-Erweiterung aktiviert haben, können Sie Standard-PostgreSQL-JSON-Operatoren (->>) verwenden, um diese Werte im Handumdrehen zu extrahieren und an die PostGIS-Funktion ST_MakePoint zu übergeben.

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

Nachdem Sie die Abfrage mit einem Client SDK ausgeführt haben, befindet sich das Ergebnis in data.nearby.

Best Practices für die Sicherheit: Dynamisches SQL und gespeicherte Prozeduren

SQL Connect parametrisiert alle Eingaben an der GraphQL-zu-Datenbank-Grenze sicher und schützt Ihre Standard-SQL-Abfragen vollständig vor SQL-Injection erster Ordnung. Wenn Sie jedoch SQL verwenden, um benutzerdefinierte gespeicherte PostgreSQL-Prozeduren oder -Funktionen aufzurufen, die dynamisches SQL ausführen, müssen Sie dafür sorgen, dass Ihr interner PL/pgSQL-Code diese Parameter sicher verarbeitet.

Wenn in Ihrer gespeicherten Prozedur Nutzereingaben direkt in einen EXECUTE-String verkettet werden, wird die Parametrisierung umgangen und es entsteht eine SQL-Injection-Sicherheitslücke zweiter Ordnung:

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

Das können Sie vermeiden, indem Sie die folgenden Best Practices beachten:

  • USING-Klausel verwenden: Wenn Sie Dynamic SQL in Ihren gespeicherten Prozeduren schreiben, verwenden Sie immer die USING-Klausel, um Datenparameter sicher zu binden.
  • format() für Kennungen verwenden: Verwenden Sie format() mit dem Flag %I für die sichere Einfügung von Datenbankkennungen (z. B. Tabellennamen).
  • IDs nur eingeschränkt zulassen: Clientanwendungen dürfen Datenbank-IDs nicht beliebig auswählen. Wenn für Ihre Prozedur dynamische Kennungen erforderlich sind, validieren Sie die Eingabe vor der Ausführung anhand einer fest codierten Zulassungsliste in Ihrer PL/pgSQL-Logik.
-- 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;
$$;