اتخطّى للمحتوى

المرحلة 03 · الـ APIs والتكاملدرس 13 من 2110 دقيقة قراءةآخر تحديث:

Lesson 13 / 21

GraphQLFull CRUD.

هنبني GraphQL API كامل لـ entity جديدة من الصفر — Create, Read, Update, Delete — خطوة بخطوة بالشرح. الـ entity هتكون "Ticket" (تذكرة دعم بسيطة) كمثال عملي.

● Step by step Schema Resolvers Mutations Queries

الـ GraphQL في Magento 2 بيتعرّف في ملف etc/schema.graphqls: بتكتب الـ types والـ queries والـ mutations، وكل field بيتربط بـ resolver class عن طريق @resolver. الـ resolver بينفّذ ResolverInterface وبينادي الـ repository ويرجّع array. وعلى عكس الـ REST، الـ schema والـ resolvers مابيتولّدوش تلقائيًا من الـ service contracts.

// قبل ما نبدأ الـ artifact ده بيفترض إنك عامل بالفعل الـ entity الأساسية: الـ Model, ResourceModel, Collection, والـ Repository (service contract). إحنا هنركّز على طبقة الـ GraphQL اللي بتتبني فوقهم. لو مش عاملهم، ابدأ بيهم الأول.
00

نظرة عامة والملفات

قبل الكود، افهم الصورة الكاملة: أنهي ملفات هنعملها وإيه دور كل واحد.

إزاي GraphQL بيشتغل في Magento

أي عملية GraphQL بتمر بخطوتين: 1) الـ schema بيعرّف "إيه المتاح" (types, queries, mutations). 2) الـ resolver بينفّذ العملية فعليًا لما الـ client يطلبها.

قاعدة ذهبية

الـ resolvers مبيكتبوش business logic جواهم. بيستخدموا الـ Repository (service contract). ده بيخلّي نفس المنطق مشترك بين الـ GraphQL والـ REST والكود الداخلي.

i
في GraphQL: الـ Query للقراءة (Read)، والـ Mutation لأي تغيير (Create / Update / Delete). ده تقسيم أساسي في GraphQL نفسه.
Vendor/Module/ ├── etc/ │ └── schema.graphqls # تعريف الـ API ├── Model/Resolver/ │ ├── Ticket.php # Read (single) │ ├── Tickets.php # Read (list) │ ├── CreateTicket.php # Create │ ├── UpdateTicket.php # Update │ └── DeleteTicket.php # Delete └── (Api/ + Model/ already exist)
i
ملف schema.graphqls واحد بيعرّف كل حاجة. كل resolver في class منفصل — ده بيخلّي الكود منظّم وكل عملية مسؤوليتها واضحة.
• • •
01

تعريف الـ Schema

أول خطوة دايمًا: نعرّف الـ types والـ queries والـ mutations. ده "العقد" اللي الـ client هيشتغل عليه.

الـ type Ticket بيوصّف شكل البيانات. الـ type Query بيعرّف عمليات القراءة، وكل query مربوط بـ @resolver.

etc/schema.graphqlsgraphql
# 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")
}
i
علامة ! بعد النوع (زي Int!) معناها الحقل ده إجباري. الأقواس المربعة [Ticket] معناها array من الـ Tickets.

الـ type Mutation بيعرّف عمليات التغيير. الـ input بيوصّف البيانات الداخلة للـ create والـ update.

etc/schema.graphqls (تكملة)graphql
# 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")
}
ASKالفرق بين type و input؟
الإجابة: الـ type بيوصّف البيانات الخارجة (اللي الـ API بيرجّعها). الـ input بيوصّف البيانات الداخلة (اللي الـ client بيبعتها في create/update). مينفعش تستخدم type كـ input والعكس.
• • •
02

Read — قراءة تذكرة واحدة

أبسط resolver. بياخد id ويرجّع ticket واحدة عن طريق الـ repository.

وظيفته

بينفّذ resolve()، بياخد الـ id من الـ $args، بينادي الـ repository، وبيرجّع البيانات كـ array (مش object).

Model/Resolver/Ticket.phpphp
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(),
        ];
    }
}
!
لاحظ استخدام GraphQlInputException وGraphQlNoSuchEntityException — دي exceptions مخصوصة للـ GraphQL بترجّع أخطاء بصيغة يفهمها الـ client. متستخدمش الـ exceptions العادية هنا.
client querygraphql
{
  ticket(id: 5) {
    title
    status
  }
}
• • •
03

Read — قائمة مع فلتر

resolver بيرجّع array من الـ tickets، مع إمكانية الفلترة بالـ status.

وظيفته

بيبني SearchCriteria (مع فلتر اختياري على الـ status)، بينادي getList() في الـ repository، وبيعمل loop على النتايج ويرجّعها كـ array of arrays.

Model/Resolver/Tickets.phpphp
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;
    }
}
client querygraphql
{
  tickets(status: "open") {
    entity_id
    title
  }
}
• • •
04

Create — إنشاء تذكرة

أول mutation. بياخد الـ input، بيعمل model جديد، وبيحفظه عن طريق الـ repository.

الخطوات

1) نتأكد الـ input موجود. 2) نعمل model جديد بالـ Factory. 3) نحط البيانات. 4) نحفظ بالـ repository. 5) نرجّع الـ ticket المحفوظة.

Model/Resolver/CreateTicket.phpphp
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(),
        ];
    }
}
client mutationgraphql
mutation {
  createTicket(input: {
    title: "Login issue",
    status: "open"
  }) {
    entity_id
    status
  }
}
i
لاحظ TicketInterfaceFactory — دي factory بتتولّد أوتوماتيك للـ interface. بتديك instance جديدة تنفّذ الـ interface، جاهزة للحفظ.
• • •
05

Update — تعديل تذكرة

بيجيب ticket موجودة بالـ id، بيعدّل حقولها، وبيحفظها. مزيج من الـ read والـ create.

الخطوات

1) نجيب الـ ticket الموجودة بالـ id (زي الـ read). 2) نعدّل بس الحقول اللي جت في الـ input. 3) نحفظ ونرجّع.

Model/Resolver/UpdateTicket.phpphp
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(),
        ];
    }
}
client mutationgraphql
mutation {
  updateTicket(id: 5, input: {
    status: "closed"
  }) {
    entity_id
    status
  }
}
i
استخدام isset() لكل حقل بيخلّي الـ update جزئي (partial) — الـ client يقدر يبعت الحقل اللي عايز يغيّره بس، والباقي يفضل زي ما هو.
• • •
06

Delete — حذف تذكرة

أبسط mutation. بيجيب الـ ticket ويحذفها، وبيرجّع true/false.

Model/Resolver/DeleteTicket.phpphp
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')
            );
        }
    }
}
client mutationgraphql
mutation {
  deleteTicket(id: 5)
}
# returns: true
!
في الـ production الحقيقي، عمليات الـ create/update/delete لازم تتأكد من صلاحيات المستخدم (authorization) عن طريق الـ $context. متسبش أي حد يعدّل البيانات من غير تحقق.
• • •
07

الاختبار والأخطاء الشائعة

إزاي تجرّب الـ API، وإيه أكتر الأخطاء اللي بتقابلك.

بعد ما تعمل الملفات، شغّل الأوامر دي عشان Magento يشوف الـ schema الجديد:

terminalbash
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.
ASKليه الـ resolver بيرجّع array مش object؟
الإجابة: لأن طبقة الـ GraphQL في Magento بتتعامل مع الـ data كـ arrays وبتعمل الـ mapping للحقول المطلوبة بنفسها. لو رجّعت object، Magento مش هيعرف يطلّع منه الحقول اللي الـ client طلبها. الأسماء في الـ array لازم تطابق أسماء الحقول في الـ schema type.

GraphQL CRUD كامل ✓

دلوقتي عندك API كامل: Read (single + list)، Create، Update، Delete — كله مبني على service contracts وبأفضل الممارسات. نفس النمط ده بيتطبّق على أي entity في Magento.

افتكر النمط الثابت: schema يعرّف → resolver ينفّذ → repository بيشتغل → array بترجع. لو حفظت الإيقاع ده، تقدر تعمل GraphQL لأي حاجة.

// magento 2 backend · graphql full crud · hands-on

خلّصت الدرس؟علّمه عشان تتابع تقدّمك في الكورس.

كاتب الدرس

Abdulrahman Masoud

عندك سؤال على الدرس أو محتاج مساعدة في مشروع Magento؟ كلّمني.

أسئلة شائعة

إزاي أعمل 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 والكود الداخلي، ولو غيّرته بتغيّره في مكان واحد.