Menu
flag Ar iconالعربيةdown icon

Prepared Statements في SQLite: شرح كامل

تعرّف على Prepared Statements في SQLite، ولماذا توجد، ودورة الحياة prepare/bind/step/finalize التي تعتمد عليها كل المكتبات.

تحتوي هذه الصفحة على محررات قابلة للتشغيل — حرّر، شغّل، وشاهد النتيجة فوراً.

ما هي الـ Prepared Statement فعلياً؟

عندما تُمرّر سلسلة SQL إلى SQLite، فإنها تقوم بعمل حقيقي قبل أن تتحرك أي صفوف من البيانات: تقطيع السلسلة (tokenize)، تحليلها (parse)، التحقق من وجود الجداول والأعمدة، التخطيط لكيفية التنفيذ، ثم تجميع الخطة إلى bytecode تفهمه الآلة الافتراضية الخاصة بـ SQLite. بعد كل ذلك فقط، يبدأ الاستعلام في العمل.

الـ prepared statement هي ما تحصل عليه إذا توقفت عند مرحلة "التجميع إلى bytecode" واحتفظت بالناتج. البرنامج المُجمَّع يحتوي على فتحات — أو placeholders — تُملأ بالقيم الفعلية لاحقاً. وبهذا تستطيع تشغيل نفس البرنامج المُجمَّع مرات عديدة بقيم مختلفة، وتستطيع تشغيله بأمان حتى مع قيم قادمة من مصادر غير موثوقة.

تخيّل الفرق هكذا: إما أن تعطي شخصاً وصفة طبخ ليقرأها بصوت عالٍ في كل مرة يطبخ فيها، أو أن تُعلّمه الوصفة مرة واحدة ثم تكتفي بذكر المكونات في يوم الطهي.

دورة الحياة: prepare ثم bind ثم step ثم finalize

كل مكتبة (driver) لـ SQLite في أي لغة برمجة هي مجرد غلاف حول نفس الأربع دوال من واجهة C. حتى لو لم تكتب سطراً واحداً بلغة C، فإن معرفة هذه الأسماء مفيدة، لأن رسائل الأخطاء والتوثيق تستخدم نفس المصطلحات:

  1. sqlite3_prepare_v2 — تجميع سلسلة SQL إلى مقبض statement.
  2. sqlite3_bind_* — تعبئة قيم الـ placeholders (دالة لكل نوع بيانات).
  3. sqlite3_step — تشغيل البرنامج. مع SELECT تستدعيها مراراً للمرور على الصفوف، أما مع INSERT وUPDATE وDELETE فاستدعاء واحد يكفي.
  4. sqlite3_finalize — تحرير البرنامج المُجمَّع عند الانتهاء منه.

وبين خطوة وأخرى، تأتي sqlite3_reset التي تُرجع الـ statement المنتهية إلى البداية، حتى تتمكن من إعادة الـ bind وإعادة التنفيذ دون الحاجة لإعادة الـ prepare من الصفر.

الـ Placeholders داخل استعلامات SQL

داخل سلسلة SQL، بدلاً من دمج القيمة مباشرة في النص، تضع علامة placeholder في موضع كل قيمة. ويدعم SQLite عدة صيغ لذلك:

-- مجهولة، موضعية:
INSERT INTO users (name, email) VALUES (?, ?);

-- مرقّمة:
INSERT INTO users (name, email) VALUES (?1, ?2);

-- مُسمّاة:
INSERT INTO users (name, email) VALUES (:name, :email);
INSERT INTO users (name, email) VALUES (@name, @email);
INSERT INTO users (name, email) VALUES ($name, $email);

? هي الأكثر شيوعاً في الكود الذي يتعامل مباشرة مع المُشغّل (driver). أما العلامات المُسمّاة مثل :name، فهي أوضح للقراءة عندما يكون لديك عدة معاملات أو عندما تتكرر نفس القيمة أكثر من مرة. اختر أسلوباً واحداً لكل مشروع والتزم به.

الشيء الذي يجب تجنّبه تماماً هو بناء الاستعلام عن طريق دمج النصوص (string concatenation):

-- لا تفعل هذا:
"INSERT INTO users (name) VALUES ('" + user_input + "')"

هذا تماماً هو الباب المفتوح أمام هجمات SQL injection، كما أنه يُفقدك ميزة إعادة استخدام البايت كود التي سنتحدث عنها بعد قليل.

مثال تطبيقي بلغة SQL

لكي نفهم آلية عمل prepare/bind/step بمعزل عن أي لغة برمجة مُضيفة، إليك ما يقابلها باستخدام إمكانيات SQL المتوفرة داخل SQLite نفسها. سنُنشئ جدولاً ونُدرج فيه صفاً عبر عنصر نائب (placeholder) على نمط المعاملات، مع تمرير قيمة حرفية:

في تطبيق حقيقي، ما راح تكتب القيم مباشرة داخل الاستعلام — بدل ذلك، تستخدم prepare مع عبارة INSERT تحتوي على علامات ?, ? كـ placeholders مرّة وحدة، وبعدين تربط (bind) الاسم والإيميل لكل مستخدم وتنفّذ (step). الـ bytecode المُجمَّع يكون نفسه في كل استدعاء؛ التي يتغيّر هو القيم المربوطة فقط.

إعادة استخدام العبارة المُجهَّزة (مكسب الأداء الحقيقي)

هذا هو النمط التي يتيحه لك أي driver تتعامل معه. الكود التالي توضيحي (pseudocode) — كل لغة تكتبه بأسلوبها الخاص — لكن الفكرة موحّدة في جميع اللغات:

-- يتم التحضير مرة واحدة:
INSERT INTO users (name, email) VALUES (?, ?);

-- ثم، داخل حلقة:
--   bind(1, name)
--   bind(2, email)
--   step()
--   reset()

التحضير (prepare) يقوم بتحليل وترجمة جملة SQL مرة واحدة فقط. بعد ذلك، كل دورة تكرار تنفّذ البايت كود وتنسخ القيم إلى الخانات المخصّصة لها. عند الإدخال بالجملة (تخيّل استيراد 100,000 صف مثلاً)، هذا الأسلوب أسرع بفارق هائل مقارنةً بتنفيذ 100,000 جملة يُعاد تحليل كلٍّ منها على حدة — غالباً بفارق رتبة كاملة، خصوصاً إذا غلّفت العملية كلها داخل معاملة (transaction) واحدة.

من الأخطاء الشائعة: أن يضع المبرمج استدعاء prepare داخل الحلقة. هذا يُلغي الفائدة كلها. القاعدة: حضّر الجملة خارج الحلقة، واربط القيم ونفّذ step داخلها.

لماذا يُعدّ هذا الأسلوب آمناً؟

المعاملات المربوطة (bound parameters) ليست نصوصاً تُحقن داخل جملة SQL. هي قيم تُمرَّر إلى برنامج البايت كود عبر خانات ذات أنواع محدّدة — خانات للأعداد الصحيحة، وخانات للنصوص، وخانات للـ blob. لا يقوم SQLite أبداً بإعادة تحليلها كأنها SQL، ولذلك لا يمكن لأي قيمة مهما كانت أن تُغيّر بنية الاستعلام.

قارن بين الحالتين:

-- ضعيف. إذا كان user_input هو:  '); DROP TABLE users;--
-- يصبح الاستعلام مدمراً.
"SELECT * FROM users WHERE name = '" + user_input + "'"

-- آمن. يتم ربط user_input كقيمة نصية TEXT ولا تتم مقارنته
-- إلا كسلسلة نصية، مهما كان محتواه.
SELECT * FROM users WHERE name = ?;

الصيغة الثانية آمنة حتى لو كانت قيمة user_input هي '); DROP TABLE users;--، إذ سيبحث SQLite بكل اجتهاد عن مستخدم اسمه يطابق هذه السلسلة الغريبة حرفياً، ولن يجد شيئاً، وسيُرجع صفر صفوف. ببساطة، لا شيء في بنية الاستعلام يمكن أن يتغير بناءً على القيمة المُمرَّرة.

سنتعمق في موضوع حقن SQL لاحقاً في درس مستقل، لكن الخلاصة الآن: العبارات المُحضَّرة (prepared statements) ليست مجرد وسيلة من وسائل الحماية من حقن SQL — بل هي الوسيلة الأساسية للحماية منه.

الاستعلامات التي تُرجع صفوفاً

في حالة استعلامات SELECT، تُرجع دالة step صفّاً واحداً في كل استدعاء. وعادةً ما يقوم المُشغِّل (driver) بتكرار الاستدعاء داخل حلقة حتى تعود القيمة "done" (أي انتهى):

في الكود الفعلي للتطبيق، يقوم المُشغِّل (driver) بتحضير استعلام SELECT مع وضع ? بدلًا من 2.00، ثم يربط قيمة الحد، ويستدعي step داخل حلقة بحيث يقرأ صفًّا واحدًا في كل استدعاء. بعد آخر صف، يُبلِّغ step بانتهاء التنفيذ، وعندها يقوم المُشغِّل إمّا بإعادة تعيين العبارة عبر reset (لتشغيلها مجدّدًا بقيمة جديدة) أو بإنهائها عبر finalize.

لا تنسَ استدعاء finalize

العبارة المُحضَّرة (prepared statement) هي تخصيص صغير داخل SQLite. تسريبها يستهلك الذاكرة، والأهم من ذلك أنه يُبقي قفلًا داخليًا على قاعدة البيانات قد يُعطِّل عمليات الكتابة الأخرى. كل مُشغِّل يوفّر لك طريقة للتنظيف التلقائي — مثل context managers في بايثون، وكتل using في C#، وتقنية RAII في ++C — ومن الأفضل أن تستخدمها دائمًا:

  • مكتبة sqlite3 في بايثون تُنهي العبارة تلقائيًا عند جمع المهملات للمؤشِّر (cursor)، لكن استدعاء cursor.close() صراحةً أنظف وأوضح.
  • مكتبة better-sqlite3 في Node تُنهي العبارة عند جمع المهملات لكائن Statement، ولا مشكلة في الاحتفاظ بعبارات مُحضَّرة طويلة العمر.
  • في لغة C الخام، عليك استدعاء sqlite3_finalize بنفسك. نسيان ذلك خطأ حقيقي يُكلّفك كثيرًا.

القاعدة الذهبية: إذا حضّرتَ عبارة، فلا بد لشيء ما أن يُنهيها.

متى لا تحتاج لاستدعائها بنفسك

نادرًا ما تستدعي sqlite3_prepare_v2 مباشرةً. فالمُشغِّلات عالية المستوى تُحوِّل سطرًا مثل connection.execute("SELECT ... WHERE id = ?", (42,)) إلى تسلسل prepare/bind/step/finalize نيابةً عنك. وسبب أهمية فهم دورة الحياة هذه:

  • ستفهم ما الذي يحدث عند ظهور أخطاء مثل "statement is busy" أو "cannot operate on a finalized statement".
  • ستعرف متى يجب تخزين العبارات المُحضَّرة طويلة العمر في الذاكرة المؤقتة، خاصةً عند الإدراج داخل حلقة كثيفة.
  • ستكتب استعلامات SQL ذات معاملات مربوطة (parameterized queries) بشكل تلقائي، حتى عندما يبدو دمج النصوص خيارًا مغريًا.

تذهب أُطر ORM وبُناة الاستعلامات إلى أبعد من ذلك. فهي تُنشئ استعلام SQL، وتُدير العبارات المُحضَّرة، وتُسلّمك نتائج بأنواع جاهزة. لكن تحت الغطاء، الأمر كلّه يعود إلى نفس الاستدعاءات الأربعة.

التالي: ربط المعاملات في sqlite

تحدّثنا حتى الآن عن العناصر النائبة (placeholders) بشكل مجرّد. في الجزء التالي سنتعمّق في عملية الربط (binding) بالتفصيل — المعاملات حسب الموضع مقابل المعاملات المُسمّاة، والتعامل مع الأنواع، وقيمة NULL، إضافةً إلى المزالق الصغيرة التي تظهر حين تبدأ بتمرير بيانات تطبيق حقيقية إلى الاستعلامات.

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

ما هو Prepared Statement في SQLite؟

هو استعلام SQL تم تحليله وترجمته إلى برنامج bytecode قابل لإعادة الاستخدام، لكن مع علامات نائبة (? أو :name) في الأماكن التي ستوضع فيها القيم لاحقًا. القيم نفسها تُربط بشكل منفصل وقت التنفيذ. ويتيح SQLite ذلك عبر الدوال sqlite3_prepare_v2 وsqlite3_bind_* وsqlite3_step وsqlite3_finalize.

لماذا أستخدم Prepared Statements في SQLite؟

لسببين أساسيين: الأمان والسرعة. القيم المربوطة (bound parameters) لا يمكن الخلط بينها وبين تركيب SQL، وبالتالي يصبح SQL Injection غير ممكن أصلًا. وإذا كنت تنفّذ نفس الاستعلام مرات كثيرة — مثلًا إدخال 10,000 صف — فإن التحضير مرة واحدة ثم إعادة الربط يوفّر عليك مرحلة التحليل (parsing) في كل تكرار، وهذا فرق ملحوظ في الأداء.

ما الفرق بين Prepared Statement والاستعلام العادي؟

الاستدعاء العادي عبر sqlite3_exec يقوم بتحليل وتنفيذ SQL في خطوة واحدة، مع إدراج القيم كنصوص مباشرة. أما Prepared Statement فيفصل بين الترجمة والتنفيذ: تستخدم prepare مرة واحدة على الاستعلام، ثم bind لربط قيم بأنواعها في العلامات النائبة، ثم step للمرور على النتائج، وreset لإعادة تشغيله مجددًا. وكل المكتبات عالية المستوى (مثل sqlite3 في Python و better-sqlite3 وغيرها) تعتمد على Prepared Statements داخليًا.

Coddy programming languages illustration

تعلّم البرمجة مع Coddy

ابدأ الآن