الـ keywords · فصل ٥

التركيب والشروط — الـ allOf والـ oneOf والـ if/then

allOf · anyOf · oneOf · not · if · then · else · discriminator · unions · unevaluatedProperties
🎯 الهدف: تبني schemas من schemas أصغر، وتكتب unions وشروط بين الحقول، وتتجنب أشهر فخين في oneOf والـ additionalProperties.

الأربعة الأساسيين

allOf = و (AND)

لازم يطابق كل الـ schemas. بنستخدمها عشان نركّب: “user أساسي + حقول admin”.

anyOf = أو (OR)

لازم يطابق واحدة على الأقل. أسهل وأرخص من oneOf، وغالبًا هي اللي محتاجها.

oneOf = واحدة بالظبط (XOR)

لازم يطابق واحدة بس. لو طابق اتنين، بيفشل. مفيد للـ unions اللي فروعها مش بتتقاطع.

not = لأ

لازم ميطابقش الـ schema. مثلًا username مش root ولا admin. الرسالة must NOT be valid.

compose.json
{  "type": "object",  "properties": {    "username": {      "type": "string",      "not": { "enum": ["root", "admin"] }    },    "contact": {      "anyOf": [        { "type": "string", "format": "email" },        { "type": "string", "pattern": "^01[0125][0-9]{8}$" }      ]    }  }}
  • الـ username: أي string ما عدا root وadmin.
  • الـ contact: إيميل أو رقم موبايل مصري.
  • الـ errors مع anyOf: Ajv بيرجّع أخطاء كل الفروع ومعاها must match a schema in anyOf، فالرسالة ممكن تبقى طويلة ومحيّرة لليوزر.

فخ الـ oneOf: الفروع اللي بتتقاطع

oneof-trap.json
{ "oneOf": [{ "type": "number" }, { "type": "integer" }] }
  • جرّبنا 5: طلع invalid! لأن 5 number و integer في نفس الوقت، يعني طابق فرعين.
  • و5.5: طلع valid، لأنه طابق number بس.
  • نفس الكلام مع objects: oneOf بين {required: [card]} و{required: [iban]} يرفض object فيه الاتنين. ساعات ده اللي عايزه، وساعات لأ.
  • القاعدة: استخدم anyOf إلا لو محتاج فعلًا “واحدة بس”. والـ oneOf لازم يجرّب كل الفروع عشان يتأكد إن مفيش اتنين، فهو أبطأ.

الـ discriminated unions

أشهر استخدام: object شكله بيتغير حسب حقل اسمه type أو kind أو method:

union.js
import Ajv from "ajv/dist/2020.js";const ajv = new Ajv({ allErrors: true, discriminator: true });const validate = ajv.compile({  type: "object",  discriminator: { propertyName: "method" },  required: ["method"],  oneOf: [    {      properties: {        method: { const: "card" },        cardNumber: { type: "string", pattern: "^[0-9]{16}$" },      },      required: ["cardNumber"],    },    {      properties: {        method: { const: "wallet" },        phone: { type: "string", pattern: "^01[0125][0-9]{8}$" },      },      required: ["phone"],    },    { properties: { method: { const: "cod" } } },  ],});const show = (d) => console.log(JSON.stringify(d), validate(d), validate.errors?.map((e) => `${e.instancePath} ${e.message}`) ?? "");show({ method: "card", cardNumber: "4111111111111111" });show({ method: "card", cardNumber: "41" });show({ method: "bitcoin" });show({ method: "cod" });
output
{"method":"card","cardNumber":"4111111111111111"} true{"method":"card","cardNumber":"41"} false [ '/cardNumber must match pattern "^[0-9]{16}$"' ]{"method":"bitcoin"} false [ ' value of tag "method" must be in oneOf' ]{"method":"cod"} true
  • الـ const في كل فرع: هو اللي بيخلي الفروع مش بتتقاطع، فالـ oneOf آمن.
  • الـ discriminator: keyword جاي من OpenAPI، مش من JSON Schema نفسها. Ajv بيدعمه بـ discriminator: true.
  • الفايدة: Ajv بيبص على method الأول ويروح للفرع الصح على طول، فالـ error بقى سطر واحد مفهوم.
  • من غير discriminator: نفس الـ body بيرجّع ٥ أخطاء من كل الفروع:
output (من غير discriminator)
/cardNumber must match pattern "^[0-9]{16}$"(root) must have required property 'phone'/method must be equal to constant/method must be equal to constant(root) must match exactly one schema in oneOf

الـ if / then / else

لما شرط على قيمة حقل بيغيّر قواعد حقل تاني:

shipping.js
import Ajv from "ajv/dist/2020.js";const ajv = new Ajv({ allErrors: true });const validate = ajv.compile({  type: "object",  properties: {    country: { type: "string" },    postalCode: { type: "string" },  },  required: ["country"],  if: {    properties: { country: { const: "EG" } },    required: ["country"],  },  then: {    properties: { postalCode: { type: "string", pattern: "^[0-9]{5}$" } },    required: ["postalCode"],  },  else: {    properties: { postalCode: { type: "string", minLength: 3 } },  },});for (const data of [{ country: "EG" }, { country: "EG", postalCode: "12" }, { country: "US" }]) {  console.log(JSON.stringify(data), validate(data), validate.errors?.map((e) => `${e.instancePath} ${e.message}`).join(" | ") ?? "");}
output
{"country":"EG"} false  must have required property 'postalCode' |  must match "then" schema{"country":"EG","postalCode":"12"} false /postalCode must match pattern "^[0-9]{5}$" |  must match "then" schema{"country":"US"} true
  • الـ if: schema عادية، نتيجتها مش بتفشّل حاجة. بتحدد بس هنروح then ولا else.
  • الـ then / else: الاتنين اختياريين.
  • أكتر من شرط: حط كل if/then جوه allOf منفصل: "allOf": [ {if, then}, {if, then} ].

فخ additionalProperties مع allOf

عايز تعمل “schema أساسية” وتوسّعها:

extend-wrong.json
{  "type": "object",  "allOf": [    {      "type": "object",      "properties": { "id": { "type": "integer" } },      "required": ["id"]    }  ],  "properties": { "name": { "type": "string" } },  "additionalProperties": false}
  • جرّبنا {"id": 1, "name": "x"}: طلع invalid برسالة must NOT have additional properties!
  • ليه؟ الـ additionalProperties بيبص على الـ properties اللي جنبه في نفس الـ object بس. هو مش شايف id اللي جوه الـ allOf، فبيعتبره زيادة.
  • الحل في 2019-09 وما بعده: unevaluatedProperties: false بدل additionalProperties: false:
extend-right.json
{  "type": "object",  "allOf": [    {      "type": "object",      "properties": { "id": { "type": "integer" } },      "required": ["id"]    }  ],  "properties": { "name": { "type": "string" } },  "unevaluatedProperties": false}
  • الـ unevaluatedProperties: بيشوف كل حقل اتراجع في أي subschema (allOf، $ref، if/then، oneOf…)، والباقي بس هو اللي “زيادة”.
  • النتيجة: {"id": 1, "name": "x"} بقى valid، و{"id": 1, "name": "x", "hack": true} اترفض بـ must NOT have unevaluated properties.
  • ونفس الفكرة للـ arrays: unevaluatedItems.
  • في draft-07 مفيش الـ keyword ده: الحل هناك إنك تكرر الـ properties كلها في المكان اللي فيه additionalProperties.

خلّصت الفصل لما

🧠 اختبر نفسك
١) oneOf بين string بـ maxLength 10 وstring بـ pattern أرقام بس. القيمة 12345؟
invalid، لأنها طابقت الفرعين. استخدم anyOf.
٢) if فيها properties.kind.const = business من غير required. الـ {} هيروح then ولا else؟
then. لأن properties مش بتراجع حقل مش موجود، فالـ if بقت true.
٣) عملت base schema في allOf وحطيت additionalProperties false بره. حقول الـ base بقت مرفوضة. ليه وإزاي تحلها؟
لأن additionalProperties بيشوف الـ properties اللي جنبه بس. استخدم unevaluatedProperties: false (2019-09 وما بعده).