GraphQLFull CRUD.
هنبني GraphQL API كامل لـ entity جديدة من الصفر — Create, Read, Update, Delete — خطوة بخطوة بالشرح. الـ entity هتكون "Ticket" (تذكرة دعم بسيطة) كمثال عملي.
الـ GraphQL في Magento 2 بيتعرّف في ملف etc/schema.graphqls: بتكتب الـ types والـ queries والـ mutations، وكل field بيتربط بـ resolver class عن طريق @resolver. الـ resolver بينفّذ ResolverInterface وبينادي الـ repository ويرجّع array. وعلى عكس الـ REST، الـ schema والـ resolvers مابيتولّدوش تلقائيًا من الـ service contracts.
نظرة عامة والملفات
قبل الكود، افهم الصورة الكاملة: أنهي ملفات هنعملها وإيه دور كل واحد.
أي عملية GraphQL بتمر بخطوتين: 1) الـ schema بيعرّف "إيه المتاح" (types, queries, mutations). 2) الـ resolver بينفّذ العملية فعليًا لما الـ client يطلبها.
الـ resolvers مبيكتبوش business logic جواهم. بيستخدموا الـ Repository (service contract). ده بيخلّي نفس المنطق مشترك بين الـ GraphQL والـ REST والكود الداخلي.
تعريف الـ Schema
أول خطوة دايمًا: نعرّف الـ types والـ queries والـ mutations. ده "العقد" اللي الـ client هيشتغل عليه.
الـ type Ticket بيوصّف شكل البيانات. الـ type Query بيعرّف عمليات القراءة، وكل query مربوط بـ @resolver.
# The shape of a Ticket type Ticket { entity_id: Int title: String status: String created_at: String } # READ operations type Query { ticket(id: Int!): Ticket @resolver(class: "Vendor\\Module\\Model\\Resolver\\Ticket") @doc(description: "Get one ticket by id") tickets(status: String): [Ticket] @resolver(class: "Vendor\\Module\\Model\\Resolver\\Tickets") @doc(description: "Get a list of tickets") }
الـ type Mutation بيعرّف عمليات التغيير. الـ input بيوصّف البيانات الداخلة للـ create والـ update.
# Input shape for create / update input TicketInput { title: String! status: String } # WRITE operations type Mutation { createTicket(input: TicketInput!): Ticket @resolver(class: "Vendor\\Module\\Model\\Resolver\\CreateTicket") updateTicket(id: Int!, input: TicketInput!): Ticket @resolver(class: "Vendor\\Module\\Model\\Resolver\\UpdateTicket") deleteTicket(id: Int!): Boolean @resolver(class: "Vendor\\Module\\Model\\Resolver\\DeleteTicket") }
Read — قراءة تذكرة واحدة
أبسط resolver. بياخد id ويرجّع ticket واحدة عن طريق الـ repository.
بينفّذ resolve()، بياخد الـ id من الـ $args، بينادي الـ repository، وبيرجّع البيانات كـ array (مش object).
class Ticket implements ResolverInterface { public function __construct( private readonly TicketRepositoryInterface $repo ) {} public function resolve( Field $field, $context, ResolveInfo $info, array $value = null, array $args = null ) { // 1. validate input if (empty($args['id'])) { throw new GraphQlInputException( __('"id" is required') ); } try { // 2. use the repository (service contract) $ticket = $this->repo->getById( (int) $args['id'] ); } catch (NoSuchEntityException $e) { throw new GraphQlNoSuchEntityException( __($e->getMessage()) ); } // 3. return as array return [ 'entity_id' => $ticket->getId(), 'title' => $ticket->getTitle(), 'status' => $ticket->getStatus(), 'created_at' => $ticket->getCreatedAt(), ]; } }
{
ticket(id: 5) {
title
status
}
}
Read — قائمة مع فلتر
resolver بيرجّع array من الـ tickets، مع إمكانية الفلترة بالـ status.
بيبني SearchCriteria (مع فلتر اختياري على الـ status)، بينادي getList() في الـ repository، وبيعمل loop على النتايج ويرجّعها كـ array of arrays.
class Tickets implements ResolverInterface { public function __construct( private readonly TicketRepositoryInterface $repo, private readonly SearchCriteriaBuilder $criteria ) {} public function resolve( Field $field, $context, ResolveInfo $info, array $value = null, array $args = null ) { // optional filter by status if (!empty($args['status'])) { $this->criteria->addFilter( 'status', $args['status'] ); } $list = $this->repo->getList( $this->criteria->create() ); $output = []; foreach ($list->getItems() as $ticket) { $output[] = [ 'entity_id' => $ticket->getId(), 'title' => $ticket->getTitle(), 'status' => $ticket->getStatus(), ]; } return $output; } }
{
tickets(status: "open") {
entity_id
title
}
}
Create — إنشاء تذكرة
أول mutation. بياخد الـ input، بيعمل model جديد، وبيحفظه عن طريق الـ repository.
1) نتأكد الـ input موجود. 2) نعمل model جديد بالـ Factory. 3) نحط البيانات. 4) نحفظ بالـ repository. 5) نرجّع الـ ticket المحفوظة.
class CreateTicket implements ResolverInterface { public function __construct( private readonly TicketRepositoryInterface $repo, private readonly TicketInterfaceFactory $factory ) {} public function resolve( Field $field, $context, ResolveInfo $info, array $value = null, array $args = null ) { $input = $args['input'] ?? []; if (empty($input['title'])) { throw new GraphQlInputException( __('"title" is required') ); } // new instance via factory $ticket = $this->factory->create(); $ticket->setTitle($input['title']); $ticket->setStatus($input['status'] ?? 'open'); // save through repository $saved = $this->repo->save($ticket); return [ 'entity_id' => $saved->getId(), 'title' => $saved->getTitle(), 'status' => $saved->getStatus(), ]; } }
mutation { createTicket(input: { title: "Login issue", status: "open" }) { entity_id status } }
Update — تعديل تذكرة
بيجيب ticket موجودة بالـ id، بيعدّل حقولها، وبيحفظها. مزيج من الـ read والـ create.
1) نجيب الـ ticket الموجودة بالـ id (زي الـ read). 2) نعدّل بس الحقول اللي جت في الـ input. 3) نحفظ ونرجّع.
class UpdateTicket implements ResolverInterface { public function __construct( private readonly TicketRepositoryInterface $repo ) {} public function resolve( Field $field, $context, ResolveInfo $info, array $value = null, array $args = null ) { try { // 1. load existing $ticket = $this->repo->getById( (int) $args['id'] ); } catch (NoSuchEntityException $e) { throw new GraphQlNoSuchEntityException( __('Ticket not found') ); } $input = $args['input']; // 2. update only provided fields if (isset($input['title'])) { $ticket->setTitle($input['title']); } if (isset($input['status'])) { $ticket->setStatus($input['status']); } // 3. save $saved = $this->repo->save($ticket); return [ 'entity_id' => $saved->getId(), 'title' => $saved->getTitle(), 'status' => $saved->getStatus(), ]; } }
mutation { updateTicket(id: 5, input: { status: "closed" }) { entity_id status } }
Delete — حذف تذكرة
أبسط mutation. بيجيب الـ ticket ويحذفها، وبيرجّع true/false.
class DeleteTicket implements ResolverInterface { public function __construct( private readonly TicketRepositoryInterface $repo ) {} public function resolve( Field $field, $context, ResolveInfo $info, array $value = null, array $args = null ): bool { try { $ticket = $this->repo->getById( (int) $args['id'] ); // delete returns bool return $this->repo->delete($ticket); } catch (NoSuchEntityException $e) { throw new GraphQlNoSuchEntityException( __('Ticket not found') ); } } }
mutation { deleteTicket(id: 5) } # returns: true
الاختبار والأخطاء الشائعة
إزاي تجرّب الـ API، وإيه أكتر الأخطاء اللي بتقابلك.
بعد ما تعمل الملفات، شغّل الأوامر دي عشان Magento يشوف الـ schema الجديد:
php bin/magento setup:upgrade php bin/magento cache:clean # in production mode also: php bin/magento setup:di:compile
- جرّب الـ queries عن طريق Altair أو GraphiQL أو Postman.
- الـ endpoint دايمًا: /graphql (POST request).
- للعمليات اللي محتاجة صلاحية، ابعت Authorization: Bearer <token> في الـ header.
- "Cannot query field": نسيت setup:upgrade أو cache:clean بعد تعديل الـ schema.
- الـ resolver مبيتنفّذش: مسار الـ class في الـ @resolver غلط، أو الـ backslash مش double (\\).
- errors غريبة في الـ response: استخدمت exception عادية بدل الـ GraphQl exceptions.
- الـ mutation بترجّع null: نسيت ترجّع array بنفس أسماء الحقول اللي في الـ type.
GraphQL CRUD كامل ✓
دلوقتي عندك API كامل: Read (single + list)، Create، Update، Delete — كله مبني على service contracts وبأفضل الممارسات. نفس النمط ده بيتطبّق على أي entity في Magento.
افتكر النمط الثابت: schema يعرّف → resolver ينفّذ → repository بيشتغل → array بترجع. لو حفظت الإيقاع ده، تقدر تعمل GraphQL لأي حاجة.
خلّصت الدرس؟علّمه عشان تتابع تقدّمك في الكورس.
أسئلة شائعة
إزاي أعمل GraphQL API لـ custom entity في Magento 2؟
بتعرّف الـ types والـ queries والـ mutations في ملف etc/schema.graphqls، وكل عملية بتربطها بـ class عن طريق @resolver. كل resolver بينفّذ ResolverInterface، بينادي الـ repository، وبيرجّع البيانات كـ array. بعدها شغّل setup:upgrade وcache:clean عشان Magento يشوف الـ schema الجديد.
ما الفرق بين Query و Mutation في GraphQL؟
الـ Query للقراءة بس، زي إنك تجيب ticket واحدة أو list بفلتر على الـ status. الـ Mutation لأي تغيير في البيانات: create وupdate وdelete. وفي Magento كل واحدة بتتعرّف جوّه type Query أو type Mutation في schema.graphqls ومربوطة بـ resolver.
ما الفرق بين type و input في GraphQL schema؟
الـ type بيوصّف البيانات الخارجة اللي الـ API بيرجّعها، زي type Ticket. الـ input بيوصّف البيانات الداخلة اللي الـ client بيبعتها في الـ create أو الـ update، زي input TicketInput. ومينفعش تستخدم type مكان input ولا العكس.
ليه الـ GraphQL resolver في Magento 2 بيرجّع array مش object؟
لأن طبقة الـ GraphQL في Magento بتتعامل مع الـ data كـ arrays وبتطلّع منها الحقول اللي الـ client طلبها بنفسها. عشان كده مفاتيح الـ array لازم تطابق أسماء الحقول في الـ schema type بالظبط، ولو نسيت ترجّع الـ array بنفس الأسماء هتلاقي الـ mutation راجعة null.
إيه الـ exceptions الصح اللي أستخدمها في GraphQL resolver في Magento 2؟
استخدم GraphQlInputException لما الـ input ناقص أو غلط، وGraphQlNoSuchEntityException لما الـ entity مش موجودة، وغالبًا بتحوّل ليها الـ NoSuchEntityException اللي جاية من الـ repository. دي exceptions مخصوصة بترجّع الخطأ بصيغة الـ client يفهمها، أما الـ exceptions العادية بتطلّع errors غريبة في الـ response.
ليه بيظهر خطأ Cannot query field في Magento 2 GraphQL؟
غالبًا عدّلت schema.graphqls ونسيت تشغّل setup:upgrade أو cache:clean، فـ Magento لسه شايف الـ schema القديم. ولو الـ resolver نفسه مش بيتنفّذ، راجع مسار الـ class في @resolver واتأكد إن الـ backslash مكتوب double (\\).
هل أكتب business logic جوّه الـ GraphQL resolver؟
لأ، الـ resolver وظيفته ينفّذ العملية بس، والمنطق الحقيقي مكانه الـ Repository (service contract). كده نفس المنطق بيبقى مشترك بين الـ GraphQL والـ REST والكود الداخلي، ولو غيّرته بتغيّره في مكان واحد.