ใช้การดำเนินการ Firebase SQL Connect โดยใช้ SQL ดั้งเดิม

คำแนะนำในการเขียนการดำเนินการ Firebase SQL Connect ด้วย SQL แทน GraphQL page_type: guide announcement: > SQL ดั้งเดิมพร้อมใช้งานเป็นฟีเจอร์ตัวอย่าง ซึ่งหมายความว่าฟีเจอร์นี้ ไม่อยู่ภายใต้นโยบาย SLA หรือนโยบายการเลิกใช้งานใดๆ และอาจมีการเปลี่ยนแปลง ในแบบที่เข้ากันไม่ได้กับเวอร์ชันก่อน หากใช้ฟีเจอร์นี้กับกระบวนการที่เก็บไว้ หรือฟังก์ชันที่เรียกใช้ SQL แบบไดนามิก ให้ทําตามแนวทางปฏิบัติแนะนําด้านความปลอดภัย ที่อธิบายไว้ที่ด้านล่างของหน้านี้

Firebase SQL Connect มีวิธีโต้ตอบกับฐานข้อมูล Cloud SQL หลายวิธี ดังนี้

  • GraphQL ดั้งเดิม: กำหนดประเภทใน schema.gql และ SQL Connect จะแปลการดำเนินการ GraphQL เป็น SQL ซึ่งเป็นแนวทางมาตรฐานที่ให้การพิมพ์ที่รัดกุมและโครงสร้างที่บังคับใช้สคีมา เอกสารประกอบส่วนใหญ่ของ SQL Connect นอกหน้านี้ จะอธิบายตัวเลือกนี้ หากเป็นไปได้ คุณควรใช้วิธีนี้เพื่อใช้ประโยชน์จากความปลอดภัยในการกำหนดประเภทและการรองรับเครื่องมืออย่างเต็มที่
  • @viewDirective: กำหนดประเภท GraphQL ใน schema.gql ที่มีSELECTคำสั่ง SQL ที่กำหนดเองเป็นข้อมูลสำรอง ซึ่งมีประโยชน์ต่อการสร้างมุมมองแบบอ่านอย่างเดียว ที่มีการพิมพ์อย่างเข้มงวดโดยอิงตามตรรกะ SQL ที่ซับซ้อน ประเภทเหล่านี้สามารถค้นหาได้ เหมือนกับประเภทปกติ ดู @view
  • SQL เนทีฟ: ฝังคำสั่ง SQL โดยตรงในการดำเนินการที่มีชื่อใน.gql ไฟล์โดยใช้ฟิลด์รูทพิเศษ ซึ่งจะช่วยให้มีความยืดหยุ่นสูงสุดและควบคุมได้โดยตรง โดยเฉพาะอย่างยิ่งสำหรับการดำเนินการที่ GraphQL มาตรฐานไม่รองรับ การใช้ประโยชน์จากฟีเจอร์เฉพาะของฐานข้อมูล หรือการใช้ส่วนขยาย PostgreSQL SQL ดั้งเดิมไม่มีเอาต์พุตที่มีการพิมพ์อย่างเข้มงวด ซึ่งต่างจาก GraphQL และคำสั่ง @view

คู่มือนี้มุ่งเน้นที่ตัวเลือก SQL ดั้งเดิม

กรณีการใช้งานทั่วไปสำหรับ SQL ดั้งเดิม

แม้ว่า GraphQL ดั้งเดิมจะให้ความปลอดภัยในการกำหนดประเภทอย่างเต็มรูปแบบ และ Directive @view จะให้ผลลัพธ์ที่มีการพิมพ์อย่างเข้มงวดสำหรับรายงาน SQL แบบอ่านอย่างเดียว แต่ SQL ดั้งเดิมก็ให้ความยืดหยุ่นที่จำเป็นสำหรับสิ่งต่อไปนี้

  • ส่วนขยาย PostgreSQL: ค้นหาและใช้ส่วนขยาย PostgreSQL ที่ติดตั้ง (เช่น PostGIS สำหรับข้อมูลเชิงพื้นที่) ได้โดยตรงโดยไม่ต้องแมปประเภทที่ซับซ้อนในสคีมา GraphQL
  • การค้นหาที่ซับซ้อน: เรียกใช้ SQL ที่ซับซ้อนด้วย JOIN, การค้นหาย่อย การรวม ฟังก์ชันหน้าต่าง และกระบวนการที่จัดเก็บไว้
  • การจัดการข้อมูล (DML): ดำเนินการINSERT, UPDATE, DELETEโดยตรง (อย่างไรก็ตาม อย่าใช้ SQL ดั้งเดิมสำหรับคำสั่งภาษานิยามข้อมูล (DDL) คุณต้องทำการเปลี่ยนแปลงระดับสคีมาโดยใช้ GraphQL ต่อไป เพื่อให้แบ็กเอนด์และ SDK ที่สร้างขึ้นซิงค์กัน)
  • ฟีเจอร์เฉพาะฐานข้อมูล: ใช้ฟังก์ชัน ตัวดำเนินการ หรือประเภทข้อมูล เฉพาะสำหรับ PostgreSQL
  • การเพิ่มประสิทธิภาพ: ปรับแต่งคำสั่ง SQL ด้วยตนเองสำหรับเส้นทางที่สำคัญ

ฟิลด์รูท SQL ดั้งเดิม

หากต้องการเขียนการดำเนินการด้วย SQL ให้ใช้ฟิลด์รูทรายการใดรายการหนึ่งต่อไปนี้ของประเภท query หรือ mutation

query ฟิลด์

ช่อง คำอธิบาย
_select

เรียกใช้การค้นหา SQL ที่ส่งกลับแถว 0 แถวขึ้นไป

อาร์กิวเมนต์

  • sql: สัญพจน์สตริงคำสั่ง SQL หากต้องการป้องกัน การแทรก SQL ให้ใช้ตัวยึดตำแหน่งตามตำแหน่ง ($1, $2 และอื่นๆ) สำหรับค่าพารามิเตอร์
  • params: รายการค่าที่เรียงลำดับเพื่อเชื่อมโยงกับ ตัวยึดตำแหน่ง ซึ่งอาจรวมถึงค่าคงที่ ตัวแปร GraphQL และ แผนที่บริบทพิเศษที่เซิร์ฟเวอร์แทรก เช่น {_expr: "auth.uid"} (รหัสของผู้ใช้ที่ได้รับการตรวจสอบสิทธิ์)

ส่งคืน: อาร์เรย์ JSON ([Any])

_selectFirst

เรียกใช้คำค้นหา SQL ที่คาดว่าจะแสดงผล 0 หรือ 1 แถว

อาร์กิวเมนต์

  • sql: สัญพจน์สตริงคำสั่ง SQL หากต้องการป้องกัน การแทรก SQL ให้ใช้ตัวยึดตำแหน่งตามตำแหน่ง ($1, $2 และอื่นๆ) สำหรับค่าพารามิเตอร์
  • params: รายการค่าที่เรียงลำดับเพื่อเชื่อมโยงกับ ตัวยึดตำแหน่ง ซึ่งอาจรวมถึงค่าคงที่ ตัวแปร GraphQL และ แผนที่บริบทพิเศษที่เซิร์ฟเวอร์แทรก เช่น {_expr: "auth.uid"} (รหัสของผู้ใช้ที่ได้รับการตรวจสอบสิทธิ์)

ส่งคืน: ออบเจ็กต์ JSON (Any) หรือ null

mutation ฟิลด์

ช่อง คำอธิบาย
_execute

เรียกใช้คำสั่ง DML (INSERT, UPDATE, DELETE)

อาร์กิวเมนต์

  • sql: สัญพจน์สตริงคำสั่ง SQL หากต้องการป้องกัน การแทรก SQL ให้ใช้ตัวยึดตำแหน่งตามตำแหน่ง ($1, $2 และอื่นๆ) สำหรับค่าพารามิเตอร์

    คุณใช้ Common Table Expressions ที่แก้ไขข้อมูล (เช่น WITH new_row AS (INSERT...)) ในที่นี้ได้เนื่องจาก ฟิลด์นี้จะแสดงเฉพาะจำนวนแถว เฉพาะ _execute ที่รองรับ CTE

  • params: รายการค่าที่เรียงลำดับเพื่อเชื่อมโยงกับ ตัวยึดตำแหน่ง ซึ่งอาจรวมถึงค่าคงที่ ตัวแปร GraphQL และ แผนที่บริบทพิเศษที่เซิร์ฟเวอร์แทรก เช่น {_expr: "auth.uid"} (รหัสของผู้ใช้ที่ได้รับการตรวจสอบสิทธิ์)

Returns: an Int (number of rows affected).

ระบบจะไม่สนใจข้อความ RETURNING ในผลลัพธ์

_executeReturning

เรียกใช้คำสั่ง DML ที่มีคําสั่ง RETURNING โดยจะส่งกลับแถว 0 แถวขึ้นไป

อาร์กิวเมนต์

  • sql: สัญพจน์สตริงคำสั่ง SQL หากต้องการป้องกัน การแทรก SQL ให้ใช้ตัวยึดตำแหน่งตามตำแหน่ง ($1, $2 และอื่นๆ) สำหรับค่าพารามิเตอร์ การแก้ไขข้อมูล ไม่รองรับ Common Table Expression
  • params: รายการค่าที่เรียงลำดับเพื่อเชื่อมโยงกับ ตัวยึดตำแหน่ง ซึ่งอาจรวมถึงค่าคงที่ ตัวแปร GraphQL และ แผนที่บริบทพิเศษที่เซิร์ฟเวอร์แทรก เช่น {_expr: "auth.uid"} (รหัสของผู้ใช้ที่ได้รับการตรวจสอบสิทธิ์)

ส่งคืน: อาร์เรย์ JSON ([Any])

_executeReturningFirst

เรียกใช้คำสั่ง DML ที่มีคําสั่ง RETURNING ซึ่งคาดว่าจะแสดงผล 0 หรือ 1 แถว

อาร์กิวเมนต์

  • sql: สัญพจน์สตริงคำสั่ง SQL หากต้องการป้องกัน การแทรก SQL ให้ใช้ตัวยึดตำแหน่งตามตำแหน่ง ($1, $2 และอื่นๆ) สำหรับค่าพารามิเตอร์ การแก้ไขข้อมูล ไม่รองรับ Common Table Expression
  • params: รายการค่าที่เรียงลำดับเพื่อเชื่อมโยงกับ ตัวยึดตำแหน่ง ซึ่งอาจรวมถึงค่าคงที่ ตัวแปร GraphQL และ แผนที่บริบทพิเศษที่เซิร์ฟเวอร์แทรก เช่น {_expr: "auth.uid"} (รหัสของผู้ใช้ที่ได้รับการตรวจสอบสิทธิ์)

ส่งคืน: ออบเจ็กต์ JSON (Any) หรือ null

หมายเหตุ:

  • ระบบจะดำเนินการโดยใช้สิทธิ์ที่มอบให้กับSQL Connectบัญชีบริการ

กฎไวยากรณ์และข้อจำกัด

SQL ดั้งเดิมจะบังคับใช้กฎการแยกวิเคราะห์ที่เข้มงวดเพื่อให้มั่นใจในความปลอดภัยและป้องกันการแทรก SQL โปรดรับทราบข้อจำกัดต่อไปนี้

  • ความคิดเห็น: ใช้ความคิดเห็นแบบบล็อก (/* ... */) ห้ามใช้ความคิดเห็นแบบบรรทัด (--) เนื่องจากอาจตัดข้อความที่ตามมา (เช่น ตัวกรองความปลอดภัย) ในระหว่างการต่อคำค้นหา
  • พารามิเตอร์: ใช้พารามิเตอร์ตำแหน่ง ($1, $2) ที่ตรงกับลำดับอาร์เรย์ params ระบบไม่รองรับพารามิเตอร์ที่มีชื่อ ($id, :name)
  • สตริง: ระบบรองรับสตริงลิเทอรัลแบบขยาย (E'...') และสตริงที่อ้างอิงด้วยเครื่องหมายดอลลาร์ ($$...$$) ไม่รองรับการหลีกเลี่ยงอักขระ Unicode ใน PostgreSQL (U&'...')

พารามิเตอร์ในความคิดเห็น

ตัวแยกวิเคราะห์จะละเว้นทุกอย่างภายในความคิดเห็นแบบบล็อก หากคุณแสดงความคิดเห็นในบรรทัด ที่มีพารามิเตอร์ (เช่น /* WHERE id = $1 */) คุณต้อง นำพารามิเตอร์นั้นออกจากรายการ params ด้วย ไม่เช่นนั้นการดำเนินการจะล้มเหลว พร้อมข้อผิดพลาด unused parameter: $1

รูปแบบการตั้งชื่อ

เมื่อเขียน SQL ดั้งเดิม คุณจะโต้ตอบกับฐานข้อมูล PostgreSQL โดยตรง ดังนั้นคุณต้องใช้ชื่อฐานข้อมูลจริงสำหรับตารางและคอลัมน์ โดยค่าเริ่มต้น SQL Connect จะแมปชื่อในสคีมา GraphQL กับรูปแบบ snake case ในฐานข้อมูลโดยอัตโนมัติ เว้นแต่คุณจะปรับแต่งตัวระบุ Postgres อย่างชัดเจนโดยใช้คำสั่ง @table(name) และ @col(name)

หากคุณกำหนดประเภทโดยไม่มีคำสั่ง ชื่อตารางและฟิลด์ GraphQL จะแมป กับตัวระบุ snake_casePostgres เริ่มต้น

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 จะเป็นไปตาม Conversion เริ่มต้นเป็น 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: UPDATE ด้วย RETURNING และบริบทการตรวจสอบสิทธิ์

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: 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 ดั้งเดิมช่วยให้คุณใช้ส่วนขยาย Postgres เช่น PostGIS ได้โดยไม่ต้อง แมปประเภทเรขาคณิตที่ซับซ้อนลงในสคีมา GraphQL หรือแก้ไขตาราง พื้นฐาน

ในตัวอย่างนี้ สมมติว่าแอปภัตตาคารมีตารางที่จัดเก็บข้อมูลสถานที่ตั้ง ในคอลัมน์ JSON ของข้อมูลเมตา (เช่น {"latitude": 37.3688, "longitude": -122.0363}) หากเปิดใช้ส่วนขยาย PostGIS คุณจะใช้ตัวดำเนินการ JSON มาตรฐานของ Postgres (->>) เพื่อดึงค่าเหล่านี้ได้ทันที และส่งไปยังฟังก์ชัน ST_MakePoint ของ 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]
  )
}

หลังจากเรียกใช้การค้นหาโดยใช้ SDK ของไคลเอ็นต์แล้ว ผลลัพธ์จะอยู่ใน data.nearby

แนวทางปฏิบัติแนะนำด้านการรักษาความปลอดภัย: SQL แบบไดนามิกและกระบวนการที่จัดเก็บไว้

SQL Connect กำหนดพารามิเตอร์อินพุตทั้งหมดอย่างปลอดภัยที่ขอบเขต GraphQL ไปยังฐานข้อมูล ซึ่งจะปกป้องคำค้นหา SQL มาตรฐานอย่างเต็มที่ จากการแทรก SQL ระดับแรก อย่างไรก็ตาม หากคุณใช้ SQL เพื่อเรียกใช้กระบวนการหรือฟังก์ชันที่จัดเก็บไว้ของ Postgres ที่กำหนดเองซึ่งเรียกใช้ SQL แบบไดนามิก คุณต้องตรวจสอบว่าโค้ด PL/pgSQL ภายในจัดการพารามิเตอร์เหล่านี้อย่างปลอดภัย

หากกระบวนการที่จัดเก็บต่ออินพุตของผู้ใช้เข้ากับEXECUTE สตริงโดยตรง กระบวนการดังกล่าวจะข้ามการกำหนดพารามิเตอร์และสร้างช่องโหว่การแทรก SQL ระดับที่ 2

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