backend

Postgres MERGE RETURNING: القيم القديمة والجديدة (2026)

٢ يوليو ٢٠٢٦

Postgres MERGE RETURNING: Old and New Values (2026)

حصلت MERGE في Postgres على بند RETURNING في الإصدار 17، وأضاف الإصدار 18 أسماء مستعارة خاصة OLD و NEW تعرض قيم الصف السابقة والحالية في استعلام واحد — مما يستبدل الحيلة غير الموثقة xmax = 0 لتحديد ما إذا كان الصف قد أُدخل أو تم تحديثه.12

ملخص

MERGE ... RETURNING موجود منذ Postgres 17. الجديد في Postgres 18 (الصادر في 25 سبتمبر 2025، الإصدار الفرعي الحالي 18.4) هو القدرة على كتابة OLD.column و NEW.column — أو OLD.* / NEW.* — داخل بند RETURNING ذلك، لـ INSERT, UPDATE, DELETE, و MERGE.32 مع merge_action()، التي تُبلغ 'INSERT', 'UPDATE', أو 'DELETE' لكل صف متأثر، يمكنك بناء سجل مراجعة كامل للUpsert — القيمة القديمة، القيمة الجديدة، والإجراء الذي تم — في بيان واحد، بدون تفعيلات ولا استعلام SELECT لاحق.1 تم تشغيل كل استعلام ورسالة خطأ في هذه المقالة على نسخة حية من PostgreSQL 18.4 مثبتة محليًا بدلاً من كتابتها من الذاكرة.

ما ستتعلمه

  • الفرق الحقيقي بين MERGE و INSERT ... ON CONFLICT (upsert)
  • منذ أي إصدار من Postgres يدعم MERGE RETURNING على الإطلاق
  • كيف تعرف ما إذا أجرت MERGE إدخالًا أو تحديثًا باستخدام merge_action()
  • كيف تعيد القيم القديمة والجديدة من بيان MERGE أو upsert واحد
  • ما إذا كنت لا تزال بحاجة إلى حيلة xmax = 0 في Postgres 18
  • كيف تبني سجل مراجعة بدون تفعيلات من بيان MERGE واحد
  • ما الخطأ الذي ترفعه Postgres إذا كانت بيانات المصدر تحتوي على مفاتيح مكررة
  • كيف تستهلك أعمدة OLD/NEW RETURNING من Node.js باستخدام pg و TypeScript

ما الفرق بين MERGE في Postgres و INSERT ... ON CONFLICT (upsert)؟

INSERT ... ON CONFLICT DO UPDATE (upsert الكلاسيكي) يبدأ دائمًا من INSERT: إذا تم انتهاك قيد فريد، فإنه يعود إلى UPDATE. MERGE أكثر عمومية — فهو يدمج جدول الهدف مع المصدر باستخدام شرط ON عشوائي ويسمح لك بالفرع إلى WHEN MATCHED, WHEN NOT MATCHED, و (منذ Postgres 17) WHEN NOT MATCHED BY SOURCE, لذا يمكنه الإدخال والتحديث و الحذف في بيان واحد.1 تم إضافة MERGE في Postgres 15 (الصادر 13 أكتوبر 2022)؛ INSERT ... ON CONFLICT موجود منذ Postgres 9.5.4

-- Classic upsert: always an INSERT, falls back to UPDATE on conflict
INSERT INTO webhook (id, data)
VALUES (1, 'payload')
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data;

-- MERGE: an explicit join with independent branches per outcome
MERGE INTO products p
USING staging s ON p.product_code = s.product_code
WHEN MATCHED THEN
  UPDATE SET product_name = s.product_name, price = s.price, stock_quantity = s.stock_quantity
WHEN NOT MATCHED THEN
  INSERT (product_code, product_name, price, stock_quantity)
  VALUES (s.product_code, s.product_name, s.price, s.stock_quantity);

إذا كنت تزامن جدولًا كاملاً من المؤقت مع جدول الهدف — حالة ETL/مزامنة البيانات الكلاسيكية — فإن MERGE عادةً ما يكون الخيار الأفضل، لأن شرط الانضمام يمكنه المطابقة على أي شيء، وليس فقط قيد فريد.

هل يدعم MERGE في Postgres بند RETURNING؟

نعم، منذ Postgres 17. RETURNING على MERGE يحسب مخرجاته من صف المصدر بالإضافة إلى صف الهدف الذي أُدخل أو تم تحديثه أو حُذف، ويقبل دالة merge_action() الخاصة لتقرير أي إجراء تم تشغيله.1 في Postgres 15 أو 16، يعمل MERGE بشكل صحيح لكن RETURNING غير مقبول — ستحصل على خطأ في التركيب وتحتاج إلى استعلام SELECT منفصل لرؤية النتيجة.

MERGE INTO products p
USING staging s ON p.product_code = s.product_code
WHEN MATCHED THEN
  UPDATE SET product_name = s.product_name, price = s.price, stock_quantity = s.stock_quantity
WHEN NOT MATCHED THEN
  INSERT (product_code, product_name, price, stock_quantity)
  VALUES (s.product_code, s.product_name, s.price, s.stock_quantity)
RETURNING merge_action() AS action, p.product_code, p.price;

تم تشغيله على نسخة حية من PostgreSQL 18.4 تحتوي على صف واحد موجود (LAPTOP-001) وسطوين في الجدول المؤقت (تحديث لـ LAPTOP-001 و MONITOR-001 جديد تمامًا)، يُعيد:

 action | product_code | price
--------+--------------+---------
 UPDATE | LAPTOP-001   | 1099.99
 INSERT | MONITOR-001  |  399.99

كيف أعرف ما إذا أجرت MERGE إدخالًا أو تحديثًا؟

أضف merge_action() إلى قائمة RETURNING. إنها دالة خاصة، صالحة فقط داخل بند MERGE ... RETURNING, وتُقيّم إلى السلسلة النصية 'INSERT', 'UPDATE', أو 'DELETE' بناءً على الفرع الذي تم تشغيله لهذا الصف.1 كانت متاحة منذ نفس إصدار Postgres 17 الذي أضاف RETURNING إلى MERGE — لا تحتاج إلى Postgres 18 لمعرفة أي إجراء تم تشغيله؛ تحتاج إلى 18 لرؤية القيم القديمة والجديدة بجانب ذلك (القسم التالي).

RETURNING merge_action() AS action, p.product_code;

النتيجة المُختبرة لنفس التشغيل أعلاه: LAPTOP-001 تُبلغ action = 'UPDATE', MONITOR-001 تُبلغ action = 'INSERT' — مطابقة تمامًا للفرع الذي تم تشغيله لكل صف في بند WHEN MATCHED / WHEN NOT MATCHED.

كيف أحصل على القيم القديمة والجديدة من استعلام upsert أو MERGE واحد؟

استخدم أسماء المستعارة OLD و NEW داخل RETURNING, المضافة في Postgres 18. OLD.column (أو OLD.*) تعيد قيمة الصف قبل تشغيل البيان؛ NEW.column (أو NEW.*) تعيدها بعد. يعمل هذا أيضًا لـ UPDATE/DELETE/INSERT العادية، ليس فقط MERGE — ويعمل لـ upserts الكلاسيكية INSERT ... ON CONFLICT أيضًا.2

MERGE INTO products p
USING staging s ON p.product_code = s.product_code
WHEN MATCHED THEN
  UPDATE SET product_name = s.product_name, price = s.price, stock_quantity = s.stock_quantity
WHEN NOT MATCHED THEN
  INSERT (product_code, product_name, price, stock_quantity)
  VALUES (s.product_code, s.product_name, s.price, s.stock_quantity)
RETURNING
  merge_action()                            AS action,
  COALESCE(new.product_code, old.product_code) AS product_code,
  old.price                                 AS old_price,
  new.price                                 AS new_price,
  (old.price IS DISTINCT FROM new.price)    AS price_changed;

النتيجة المُتحقق منها:

 action | product_code | old_price | new_price | price_changed
--------+--------------+-----------+-----------+---------------
 UPDATE | LAPTOP-001   |    999.99 |   1099.99 | t
 INSERT | MONITOR-001  |           |    399.99 | t

لاحظ أن MONITOR-001 — صف جديد تمامًا — لديه old_price فارغ (NULL)، لأنه لم تكن هناك حالة "قبل"، لكن price_changed يعود t (صحيح)، وليس NULL. ذلك لأن IS DISTINCT FROM آمن للقيم NULL: على عكس مشغل = العادي، الذي يعيد NULL (لا صحيح ولا خطأ) عندما يكون أحد الجانبين NULL, IS DISTINCT FROM يعيد دائمًا قيمة بوليان حقيقية ويعامل NULL كمختلف عن أي قيمة غير NULL. لذا NULL IS DISTINCT FROM 399.99 يُقيّم بشكل صحيح إلى true — تحصل على إشارة "تم تغيير هذا الصف" واضحة للإدخالات أيضًا، دون الحاجة إلى COALESCE أو فروع إضافية.

إذا تصادمت معرفاتك مع old/new (أو كنت داخل دالة تفعيل حيث تم أخذ هذه الأسماء بالفعل)، أعد تسمية الأسماء المستعارة:

RETURNING WITH (OLD AS o, NEW AS n) o.price AS old_price, n.price AS new_price;

تم اختبارها بنفس الطريقة مثل الأسماء المستعارة الافتراضية — هذا مجرد راحة في التسمية.

هل ما زلت بحاجة إلى حيلة xmax للكشف عن صف جديد في Postgres 18؟

لا، لكنها لا تزال تعمل إذا كنت عالقاً في إصدار قديم. قبل Postgres 18، كانت الطريقة الشائعة لمعرفة ما إذا كان الصف الذي تم عمل upsert له جديداً هي التحقق من عمود النظام الداخلي xmax: (xmax = 0) AS is_new، بما أن الصف المدرج حديثاً لا يحتوي على قفل صف وبالتالي يكون xmax = 0.1 هذا يعمل، ولكنه يعتمد على عمود نظام داخلي تقول وثائق PostgreSQL نفسها أن التطبيقات "لا تحتاج حقاً إلى الاهتمام به" — فهو متاح لعمليات تدوين MVCC الداخلية، وليس موثقاً كـ API مستقر لاكتشاف الصفوف الجديدة.5

تم تشغيل كليهما بالتتابع مقابل نفس قاعدة البيانات الحية لهذا المقال، على نفس الجدول، للتأكد من تطابقهما:

-- Old way (works on Postgres 9.5+, still works on 18)
INSERT INTO webhook (id, data) VALUES (3, 'xmax-test')
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data
RETURNING id, (xmax = 0) AS is_new_xmax;
-- -> is_new_xmax: true (fresh row)

-- New way (Postgres 18+, no internal-column dependency)
INSERT INTO webhook (id, data) VALUES (2, 'brand-new')
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data
RETURNING id, old.data AS old_data, (old IS NULL) AS is_new;
-- -> old_data: null, is_new: true

(old IS NULL) AS is_new هو بديل Postgres 18: مقروء، موثق، ولا يعتمد على كيفية تنفيذ قفل الصفوف داخلياً.

كيف يمكنني بناء سجل تدقيق من upsert بدون triggers؟

قم بتغليف MERGE ... RETURNING في CTE وقم بتغذية مخرجاته مباشرة في INSERT على جدول التدقيق الخاص بك — لا حاجة لـ trigger، ويظل داخل نفس المعاملة (transaction) مثل الكتابة الأصلية.

CREATE TABLE product_audit (
  audit_id    SERIAL PRIMARY KEY,
  product_code VARCHAR(50),
  action      VARCHAR(10),
  old_values  JSONB,
  new_values  JSONB,
  changed_at  TIMESTAMPTZ DEFAULT now()
);

WITH merge_results AS (
  MERGE INTO products p
  USING staging s ON p.product_code = s.product_code
  WHEN MATCHED THEN
    UPDATE SET product_name = s.product_name, price = s.price, stock_quantity = s.stock_quantity
  WHEN NOT MATCHED THEN
    INSERT (product_code, product_name, price, stock_quantity)
    VALUES (s.product_code, s.product_name, s.price, s.stock_quantity)
  RETURNING
    merge_action() AS action,
    COALESCE(new.product_code, old.product_code) AS product_code,
    CASE WHEN old.product_code IS NOT NULL
      THEN jsonb_build_object('name', old.product_name, 'price', old.price, 'stock', old.stock_quantity)
    END AS old_values,
    CASE WHEN new.product_code IS NOT NULL
      THEN jsonb_build_object('name', new.product_name, 'price', new.price, 'stock', new.stock_quantity)
    END AS new_values
)
INSERT INTO product_audit (product_code, action, old_values, new_values)
SELECT product_code, action, old_values, new_values FROM merge_results
RETURNING product_code, action, old_values, new_values;

النتيجة المؤكدة لنفس مزامنة الصفين المستخدمة في هذا المقال:

[
  {
    "product_code": "LAPTOP-001",
    "action": "UPDATE",
    "old_values": { "name": "Premium Laptop", "price": 999.99, "stock": 50 },
    "new_values": { "name": "Premium Laptop Pro", "price": 1099.99, "stock": 45 }
  },
  {
    : "MONITOR-001",
    "action": "INSERT",
    "old_values": null,
    "new_values": { "name": "4K Monitor", "price": 399.99, "stock": 75 }
  }
]

يُظهر MONITOR-001 بشكل صحيح action: "INSERT" مع old_values: null — ومن الجدير بالذكر صراحةً، أن مثالاً مشابهاً منشوراً على مدونة أحد مزودي Postgres يخطئ في هذه الحالة تحديداً، حيث يُظهر "UPDATE" لصف تثبت بيانات الإعداد الخاصة به أنه تم إدراجه حديثاً.6 قم بحماية استدعاءات jsonb_build_object باستخدام فحص CASE WHEN ... IS NOT NULL كما هو موضح أعلاه؛ لأن استدعاء jsonb_build_object دون قيد أو شرط على أعمدة مصدر NULL لا يزال ينتج كائن JSON بقيم null بداخله بدلاً من SQL NULL نظيف.

أي إصدار من Postgres أحتاجه فعلياً لهذا؟

هناك ثلاث بوابات ميزات منفصلة، والخلط بينها هو المصدر الأكثر شيوعاً لارتباك "لماذا لا يعمل هذا":

الميزةالحد الأدنى للإصدارتاريخ الإصدار
جملة MERGE نفسهاPostgres 1513 أكتوبر 20224
بند RETURNING في MERGEPostgres 1726 سبتمبر 20243
الأسماء المستعارة OLD/NEW في RETURNING (بما في ذلك في MERGE)Postgres 1825 سبتمبر 20253

إذا كنت تستخدم Postgres 15 أو 16، فإن MERGE تعمل ولكن RETURNING لا تعمل — قم بتشغيل SELECT منفصل بعد ذلك إذا كنت بحاجة لرؤية النتائج. إذا كنت تستخدم Postgres 17، فإن MERGE ... RETURNING و merge_action() تعملان، لكن الأسماء المستعارة OLD/NEW غير موجودة بعد — ستعود إلى خدعة xmax أو SELECT يدوي قبل/بعد. كما أصلح Postgres 18.4 (الإصدار الفرعي الحالي وقت كتابة هذا المقال) خطأً خاصاً بـ MERGE حيث لم يتم الإبلاغ عن التحديثات المتزامنة تحت مستويات العزل REPEATABLE READ أو SERIALIZABLE بشكل صحيح كفشل تسلسل (serialization failures) — وهو أمر يستحق المعرفة إذا كنت تقوم بتشغيل MERGE تحت مستويات عزل صارمة.2

ماذا يحدث إذا كانت بيانات المصدر تحتوي على مفاتيح مكررة تطابق نفس صف الهدف؟

يقوم Postgres بإصدار خطأ بدلاً من اختيار واحد بصمت. يضمن MERGE تأثر كل صف هدف مرة واحدة على الأكثر لكل جملة؛ إذا تطابق صفان أو أكثر من المصدر مع نفس صف الهدف، تفشل الجملة.

CREATE TABLE src (id INT, val TEXT);
INSERT INTO src VALUES (1, 'x'), (1, 'y'); -- two source rows, same id

MERGE INTO tgt t USING src s ON t.id = s.id
WHEN MATCHED THEN UPDATE SET val = s.val
WHEN NOT MATCHED THEN INSERT (id, val) VALUES (s.id, s.val);

الخطأ المختبر، حرفياً من نسخة Postgres 18.4 حية:

ERROR:  MERGE command cannot affect row a second time

قم بإزالة التكرار من المصدر (أو أضف شرطاً لفك الارتباط إلى شرط الانضمام) قبل تشغيل MERGE. لدى INSERT ... ON CONFLICT DO UPDATE نفس القيد وتصدر خطأً متطابقاً تقريباً — ON CONFLICT DO UPDATE command cannot affect row a second time — إذا تعارض صفان في نفس قائمة VALUES على نفس صف الهدف؛ تم تأكيد ذلك من خلال تشغيل الجملتين بالتتابع مقابل نسخة Postgres 18.4 حية. في المقابل، لا يصدر ON CONFLICT DO NOTHING خطأً عند التكرار — بل يطبق بصمت أول صف مطابق ويتخطى الباقي.

كيف يمكنني قراءة هذا من Node.js و TypeScript باستخدام تعريف pg؟

قم بالاستعلام عنه مثل أي جملة أخرى باستخدام pg (يُبلغ npm view pg حالياً عن 8.22.0 كأحدث إصدار)، ولكن انتبه لمشكلتين لا تظهران في أمثلة SQL البحتة: أعمدة NUMERIC التي يتم إرجاعها مباشرة (غير مغلفة في JSON) تعود كـ سلاسل نصية (strings)، و merge_action() يحتاج إلى نوع سلسلة نصية عادي، وليس enum من Postgres.

npm install pg
npm install -D @types/pg TypeScript
import { Client, type QueryResult } from 'pg';

interface MergeAuditRow {
  action: 'INSERT' | 'UPDATE' | 'DELETE';
  product_code: string;
  old_price: string | null; // NUMERIC comes back as a string from node-postgres
  new_price: string | null;
  price_changed: boolean; // IS DISTINCT FROM is null-safe — never SQL NULL
}

const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect();

const result: QueryResult<MergeAuditRow> = await client.query(`
  MERGE INTO products p
  USING staging s ON p.product_code = s.product_code
  WHEN MATCHED THEN
    UPDATE SET product_name = s.product_name, price = s.price, stock_quantity = s.stock_quantity
  WHEN NOT MATCHED THEN
    INSERT (product_code, product_name, price, stock_quantity)
    VALUES (s.product_code, s.product_name, s.price, s.stock_quantity)
  RETURNING
    merge_action() AS action,
    COALESCE(new.product_code, old.product_code) AS product_code,
    old.price AS old_price,
    new.price AS new_price,
    (old.price IS DISTINCT FROM new.price) AS price_changed;
`);

for (const row of result.rows) {
  const oldPrice = row.old_price ? Number(row.old_price) : null;
  const newPrice = row.new_price ? Number(row.new_price) : null;
  console.log(row.action, row.product_code, oldPrice, '->', newPrice);
}

تم التأكيد مباشرة مقابل استعلام حي: أعمدة old_price/new_price المختارة كأعمدة NUMERIC مجردة تصل كسلاسل JSON مثل "999.99"، وهو سلوك node-postgres القياسي لنوع NUMERIC (يتجنب فقدان دقة الفاصلة العائمة الصامت). إذا كنت تفضل تلقي أرقام JS حقيقية بدون تحويل Number() يدوي، فقم بتغليف القيم في jsonb_build_object(...) في SQL كما هو موضح في قسم سجل التدقيق أعلاه — أكد نفس الاختبار الحي أن القيم الرقمية المتداخلة داخل نتيجة JSONB يتم فك تشفيرها كأرقام JS أصلية (على سبيل المثال، 999.99، وليس "999.99")، لأن pg يحلل أعمدة JSONB باستخدام JSON.parse داخلياً بدلاً من تطبيق محلل السلسلة النصية الخام لـ NUMERIC.

ملخص

إن MERGE ... RETURNING (Postgres 17+) بالإضافة إلى الأسماء المستعارة OLD/NEW (Postgres 18+) تحل محل حلين مؤقتين استخدما طويلاً في آن واحد: حيلة xmax = 0 لاكتشاف الصفوف الجديدة، واستعلام SELECT منفصل لالتقاط القيم قبل وبعد التعديل. عند وضعها داخل CTE، يمكن لنفس العبارة أيضاً كتابة سجل مراجعة (audit trail) كامل بدون أي triggers. اعرف إصدارك — 15 لـ MERGE نفسها، و17 لـ RETURNING، و18 لـ OLD/NEW — وتذكر، عند استخدام Node.js، أن أعمدة NUMERIC المجردة تصل كسلاسل نصية بينما لا يحدث ذلك للقيم المتداخلة في JSONB.

بالنسبة لجانب مستوى الصف في عمليات الكتابة متعددة المستأجرين التي غالباً ما تأتي بجانب هذا النوع من مهام المزامنة، راجع دليلنا حول أمان مستوى الصف في Postgres لتطبيقات Node.js متعددة المستأجرين. إذا كنت تقوم بنمذجة العلاقة بين منطقة التخزين المؤقت (staging) والهدف باستخدام ORM بدلاً من SQL الخام، فإن استعلامات Drizzle 관계형 API تغطي استعلامات with المتداخلة على نفس النوع من الجداول. وإذا كنت بحاجة إلى تتبع عمليات كتابة سجل المراجعة لمهمة مزامنة وصولاً إلى الطلب الذي تسبب فيها، فإن معرفات الارتباط (correlation IDs) مع AsyncLocalStorage في Node.js توضح كيفية تمرير هذا السياق.

Footnotes

  1. PostgreSQL, "MERGE" command reference (v18) — RETURNING, merge_action(), and xmax discussion. https://www.postgresql.org/docs/current/sql-merge.html 2 3 4 5 6 7 8

  2. PostgreSQL, "E.1. Release 18.4" release notes (2026-05-14) — OLD/NEW RETURNING aliases and MERGE serialization-failure fix. https://www.postgresql.org/docs/18/release-18-4.html 2 3 4 5

  3. PostgreSQL, "Versioning Policy" — release dates and current minor versions. https://www.postgresql.org/support/versioning/ 2 3 4

  4. PostgreSQL, "PostgreSQL 15 Released!" (October 13, 2022) — introduction of the MERGE command. https://www.postgresql.org/about/news/postgresql-15-released-2526/ 2 3

  5. PostgreSQL, "5.6. System Columns" (v18) — xmax definition and "you do not really need to be concerned about these columns" guidance. https://www.postgresql.org/docs/current/ddl-system-columns.html

  6. pgEdge, "PostgreSQL 18 RETURNING & MERGE RETURNING Explained" (January 6, 2026) — published audit-trail example. https://www.pgedge.com/blog/postgresql-18-returning-enhancements-a-game-changer-for-modern-applications

الأسئلة الشائعة

نعم، منذ Postgres 17. في الإصدارين 15 أو 16، يتم تشغيل MERGE ولكن يتم رفض RETURNING . 1