@fogpipe/forma-core 0.10.3 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -275,4 +275,349 @@ describe("validate", () => {
275
275
  expect(result.errors[0].message).toBe("Name must be at least 2 characters");
276
276
  });
277
277
  });
278
+
279
+ // ============================================================================
280
+ // Array validation
281
+ // ============================================================================
282
+
283
+ describe("array validation", () => {
284
+ describe("minItems/maxItems from schema", () => {
285
+ it("should validate minItems from schema when fieldDef does not specify it", () => {
286
+ const spec: Forma = {
287
+ version: "1.0",
288
+ meta: { id: "test", title: "Test" },
289
+ schema: {
290
+ type: "object",
291
+ properties: {
292
+ items: {
293
+ type: "array",
294
+ items: { type: "string" },
295
+ minItems: 2,
296
+ },
297
+ },
298
+ },
299
+ fields: {
300
+ items: { label: "Items", type: "array" },
301
+ },
302
+ fieldOrder: ["items"],
303
+ };
304
+
305
+ // Valid - has minimum 2 items
306
+ expect(validate({ items: ["a", "b"] }, spec).valid).toBe(true);
307
+ expect(validate({ items: ["a", "b", "c"] }, spec).valid).toBe(true);
308
+
309
+ // Invalid - less than minItems
310
+ const result = validate({ items: ["a"] }, spec);
311
+ expect(result.valid).toBe(false);
312
+ expect(result.errors[0].field).toBe("items");
313
+ expect(result.errors[0].message).toBe("Items must have at least 2 items");
314
+ });
315
+
316
+ it("should validate maxItems from schema when fieldDef does not specify it", () => {
317
+ const spec: Forma = {
318
+ version: "1.0",
319
+ meta: { id: "test", title: "Test" },
320
+ schema: {
321
+ type: "object",
322
+ properties: {
323
+ tags: {
324
+ type: "array",
325
+ items: { type: "string" },
326
+ maxItems: 3,
327
+ },
328
+ },
329
+ },
330
+ fields: {
331
+ tags: { label: "Tags", type: "array" },
332
+ },
333
+ fieldOrder: ["tags"],
334
+ };
335
+
336
+ // Valid - has maximum 3 items
337
+ expect(validate({ tags: ["a", "b", "c"] }, spec).valid).toBe(true);
338
+ expect(validate({ tags: ["a"] }, spec).valid).toBe(true);
339
+
340
+ // Invalid - more than maxItems
341
+ const result = validate({ tags: ["a", "b", "c", "d"] }, spec);
342
+ expect(result.valid).toBe(false);
343
+ expect(result.errors[0].field).toBe("tags");
344
+ expect(result.errors[0].message).toBe("Tags must have no more than 3 items");
345
+ });
346
+
347
+ it("should allow fieldDef.minItems to override schema.minItems", () => {
348
+ const spec: Forma = {
349
+ version: "1.0",
350
+ meta: { id: "test", title: "Test" },
351
+ schema: {
352
+ type: "object",
353
+ properties: {
354
+ items: {
355
+ type: "array",
356
+ items: { type: "string" },
357
+ minItems: 5, // schema says 5
358
+ },
359
+ },
360
+ },
361
+ fields: {
362
+ items: {
363
+ label: "Items",
364
+ type: "array",
365
+ minItems: 2, // fieldDef overrides to 2
366
+ },
367
+ },
368
+ fieldOrder: ["items"],
369
+ };
370
+
371
+ // fieldDef.minItems (2) should take precedence over schema.minItems (5)
372
+ expect(validate({ items: ["a", "b"] }, spec).valid).toBe(true);
373
+ expect(validate({ items: ["a"] }, spec).valid).toBe(false);
374
+ });
375
+ });
376
+
377
+ describe("array item validation from schema", () => {
378
+ it("should validate array item type constraints from schema", () => {
379
+ const spec: Forma = {
380
+ version: "1.0",
381
+ meta: { id: "test", title: "Test" },
382
+ schema: {
383
+ type: "object",
384
+ properties: {
385
+ scores: {
386
+ type: "array",
387
+ items: {
388
+ type: "object",
389
+ properties: {
390
+ name: { type: "string" },
391
+ score: { type: "number", minimum: 0, maximum: 100 },
392
+ },
393
+ required: ["name", "score"],
394
+ },
395
+ },
396
+ },
397
+ },
398
+ fields: {
399
+ scores: { label: "Scores", type: "array" },
400
+ },
401
+ fieldOrder: ["scores"],
402
+ };
403
+
404
+ // Valid data
405
+ const validResult = validate(
406
+ {
407
+ scores: [
408
+ { name: "Alice", score: 95 },
409
+ { name: "Bob", score: 87 },
410
+ ],
411
+ },
412
+ spec
413
+ );
414
+ expect(validResult.valid).toBe(true);
415
+
416
+ // Invalid - score above maximum
417
+ const maxResult = validate(
418
+ {
419
+ scores: [
420
+ { name: "Alice", score: 105 },
421
+ { name: "Bob", score: 87 },
422
+ ],
423
+ },
424
+ spec
425
+ );
426
+ expect(maxResult.valid).toBe(false);
427
+ expect(maxResult.errors[0].field).toBe("scores[0].score");
428
+ expect(maxResult.errors[0].message).toContain("no more than 100");
429
+
430
+ // Invalid - score below minimum
431
+ const minResult = validate(
432
+ {
433
+ scores: [
434
+ { name: "Alice", score: -5 },
435
+ { name: "Bob", score: 87 },
436
+ ],
437
+ },
438
+ spec
439
+ );
440
+ expect(minResult.valid).toBe(false);
441
+ expect(minResult.errors[0].field).toBe("scores[0].score");
442
+ expect(minResult.errors[0].message).toContain("at least 0");
443
+ });
444
+
445
+ it("should validate required fields in array items from schema", () => {
446
+ const spec: Forma = {
447
+ version: "1.0",
448
+ meta: { id: "test", title: "Test" },
449
+ schema: {
450
+ type: "object",
451
+ properties: {
452
+ people: {
453
+ type: "array",
454
+ items: {
455
+ type: "object",
456
+ properties: {
457
+ name: { type: "string" },
458
+ age: { type: "integer" },
459
+ },
460
+ required: ["name"],
461
+ },
462
+ },
463
+ },
464
+ },
465
+ fields: {
466
+ people: { label: "People", type: "array" },
467
+ },
468
+ fieldOrder: ["people"],
469
+ };
470
+
471
+ // Valid - name is present (age is optional)
472
+ expect(
473
+ validate({ people: [{ name: "Alice" }, { name: "Bob", age: 30 }] }, spec).valid
474
+ ).toBe(true);
475
+
476
+ // Invalid - missing required name
477
+ const result = validate({ people: [{ age: 25 }] }, spec);
478
+ expect(result.valid).toBe(false);
479
+ expect(result.errors[0].field).toBe("people[0].name");
480
+ expect(result.errors[0].message).toContain("required");
481
+ });
482
+
483
+ it("should validate nested string constraints in array items", () => {
484
+ const spec: Forma = {
485
+ version: "1.0",
486
+ meta: { id: "test", title: "Test" },
487
+ schema: {
488
+ type: "object",
489
+ properties: {
490
+ emails: {
491
+ type: "array",
492
+ items: {
493
+ type: "object",
494
+ properties: {
495
+ address: { type: "string", format: "email" },
496
+ label: { type: "string", minLength: 1, maxLength: 20 },
497
+ },
498
+ required: ["address"],
499
+ },
500
+ },
501
+ },
502
+ },
503
+ fields: {
504
+ emails: { label: "Emails", type: "array" },
505
+ },
506
+ fieldOrder: ["emails"],
507
+ };
508
+
509
+ // Valid
510
+ expect(
511
+ validate({ emails: [{ address: "test@example.com", label: "Work" }] }, spec).valid
512
+ ).toBe(true);
513
+
514
+ // Invalid email format
515
+ const emailResult = validate({ emails: [{ address: "not-an-email" }] }, spec);
516
+ expect(emailResult.valid).toBe(false);
517
+ expect(emailResult.errors[0].field).toBe("emails[0].address");
518
+ expect(emailResult.errors[0].message).toContain("valid email");
519
+
520
+ // Invalid label (too long)
521
+ const labelResult = validate(
522
+ { emails: [{ address: "test@example.com", label: "This label is way too long for the constraint" }] },
523
+ spec
524
+ );
525
+ expect(labelResult.valid).toBe(false);
526
+ expect(labelResult.errors[0].field).toBe("emails[0].label");
527
+ expect(labelResult.errors[0].message).toContain("no more than 20");
528
+ });
529
+
530
+ it("should validate multipleOf in array item fields", () => {
531
+ const spec: Forma = {
532
+ version: "1.0",
533
+ meta: { id: "test", title: "Test" },
534
+ schema: {
535
+ type: "object",
536
+ properties: {
537
+ prices: {
538
+ type: "array",
539
+ items: {
540
+ type: "object",
541
+ properties: {
542
+ amount: { type: "number", multipleOf: 0.01 },
543
+ },
544
+ },
545
+ },
546
+ },
547
+ },
548
+ fields: {
549
+ prices: { label: "Prices", type: "array" },
550
+ },
551
+ fieldOrder: ["prices"],
552
+ };
553
+
554
+ // Valid - correct precision
555
+ expect(validate({ prices: [{ amount: 10.99 }, { amount: 5.0 }] }, spec).valid).toBe(true);
556
+
557
+ // Invalid - wrong precision
558
+ const result = validate({ prices: [{ amount: 10.999 }] }, spec);
559
+ expect(result.valid).toBe(false);
560
+ expect(result.errors[0].field).toBe("prices[0].amount");
561
+ expect(result.errors[0].message).toContain("multiple of 0.01");
562
+ });
563
+ });
564
+
565
+ describe("combined fieldDef and schema validation", () => {
566
+ it("should use itemFields for custom validations while using schema for type constraints", () => {
567
+ const spec: Forma = {
568
+ version: "1.0",
569
+ meta: { id: "test", title: "Test" },
570
+ schema: {
571
+ type: "object",
572
+ properties: {
573
+ orders: {
574
+ type: "array",
575
+ items: {
576
+ type: "object",
577
+ properties: {
578
+ quantity: { type: "integer", minimum: 1 },
579
+ price: { type: "number", minimum: 0 },
580
+ },
581
+ },
582
+ minItems: 1,
583
+ },
584
+ },
585
+ },
586
+ fields: {
587
+ orders: {
588
+ label: "Orders",
589
+ type: "array",
590
+ itemFields: {
591
+ quantity: {
592
+ label: "Quantity",
593
+ validations: [
594
+ { rule: "value <= 100", message: "Cannot order more than 100 items" },
595
+ ],
596
+ },
597
+ },
598
+ },
599
+ },
600
+ fieldOrder: ["orders"],
601
+ };
602
+
603
+ // Valid
604
+ expect(validate({ orders: [{ quantity: 5, price: 10.0 }] }, spec).valid).toBe(true);
605
+
606
+ // Invalid - schema minimum violated
607
+ const minResult = validate({ orders: [{ quantity: 0, price: 10.0 }] }, spec);
608
+ expect(minResult.valid).toBe(false);
609
+ expect(minResult.errors[0].message).toContain("at least 1");
610
+
611
+ // Invalid - custom FEEL validation violated
612
+ const customResult = validate({ orders: [{ quantity: 150, price: 10.0 }] }, spec);
613
+ expect(customResult.valid).toBe(false);
614
+ expect(customResult.errors.some((e) => e.message.includes("more than 100"))).toBe(true);
615
+
616
+ // Invalid - empty array (minItems from schema)
617
+ const emptyResult = validate({ orders: [] }, spec);
618
+ expect(emptyResult.valid).toBe(false);
619
+ expect(emptyResult.errors[0].message).toContain("at least 1 items");
620
+ });
621
+ });
622
+ });
278
623
  });
@@ -173,11 +173,12 @@ function validateField(
173
173
  }
174
174
 
175
175
  // 4. Array validation
176
- if (Array.isArray(value) && fieldDef.itemFields) {
176
+ if (Array.isArray(value)) {
177
177
  const arrayErrors = validateArray(
178
178
  path,
179
179
  value,
180
180
  fieldDef,
181
+ schemaProperty,
181
182
  spec,
182
183
  data,
183
184
  computed,
@@ -514,6 +515,7 @@ function validateArray(
514
515
  path: string,
515
516
  value: unknown[],
516
517
  fieldDef: FieldDefinition,
518
+ schemaProperty: JSONSchemaProperty | undefined,
517
519
  spec: Forma,
518
520
  data: Record<string, unknown>,
519
521
  computed: Record<string, unknown>,
@@ -523,25 +525,34 @@ function validateArray(
523
525
  const errors: FieldError[] = [];
524
526
  const label = fieldDef.label ?? path;
525
527
 
526
- // Check min/max items
527
- if (fieldDef.minItems !== undefined && value.length < fieldDef.minItems) {
528
+ // Get array schema for minItems/maxItems fallback
529
+ const arraySchema = schemaProperty?.type === "array" ? schemaProperty : undefined;
530
+
531
+ // Check min/max items - fieldDef overrides schema
532
+ const minItems = fieldDef.minItems ?? arraySchema?.minItems;
533
+ const maxItems = fieldDef.maxItems ?? arraySchema?.maxItems;
534
+
535
+ if (minItems !== undefined && value.length < minItems) {
528
536
  errors.push({
529
537
  field: path,
530
- message: `${label} must have at least ${fieldDef.minItems} items`,
538
+ message: `${label} must have at least ${minItems} items`,
531
539
  severity: "error",
532
540
  });
533
541
  }
534
542
 
535
- if (fieldDef.maxItems !== undefined && value.length > fieldDef.maxItems) {
543
+ if (maxItems !== undefined && value.length > maxItems) {
536
544
  errors.push({
537
545
  field: path,
538
- message: `${label} must have no more than ${fieldDef.maxItems} items`,
546
+ message: `${label} must have no more than ${maxItems} items`,
539
547
  severity: "error",
540
548
  });
541
549
  }
542
550
 
551
+ // Get item schema for nested validation
552
+ const itemSchema = arraySchema?.items;
553
+
543
554
  // Validate each item's fields
544
- if (fieldDef.itemFields) {
555
+ if (fieldDef.itemFields || itemSchema) {
545
556
  for (let i = 0; i < value.length; i++) {
546
557
  const item = value[i] as Record<string, unknown>;
547
558
  const itemErrors = validateArrayItem(
@@ -549,6 +560,7 @@ function validateArray(
549
560
  i,
550
561
  item,
551
562
  fieldDef.itemFields,
563
+ itemSchema,
552
564
  spec,
553
565
  data,
554
566
  computed,
@@ -569,7 +581,8 @@ function validateArrayItem(
569
581
  arrayPath: string,
570
582
  index: number,
571
583
  item: Record<string, unknown>,
572
- itemFields: Record<string, FieldDefinition>,
584
+ itemFields: Record<string, FieldDefinition> | undefined,
585
+ itemSchema: JSONSchemaProperty | undefined,
573
586
  spec: Forma,
574
587
  data: Record<string, unknown>,
575
588
  computed: Record<string, unknown>,
@@ -578,7 +591,20 @@ function validateArrayItem(
578
591
  ): FieldError[] {
579
592
  const errors: FieldError[] = [];
580
593
 
581
- for (const [fieldName, fieldDef] of Object.entries(itemFields)) {
594
+ // Get object schema for item if available
595
+ const objectSchema = itemSchema?.type === "object" ? itemSchema : undefined;
596
+ const schemaProperties = objectSchema?.properties ?? {};
597
+ const schemaRequired = new Set(objectSchema?.required ?? []);
598
+
599
+ // Determine which fields to validate - union of itemFields and schema properties
600
+ const allFieldNames = new Set([
601
+ ...Object.keys(itemFields ?? {}),
602
+ ...Object.keys(schemaProperties),
603
+ ]);
604
+
605
+ for (const fieldName of allFieldNames) {
606
+ const fieldDef = itemFields?.[fieldName];
607
+ const fieldSchema = schemaProperties[fieldName];
582
608
  const itemFieldPath = `${arrayPath}[${index}].${fieldName}`;
583
609
 
584
610
  // Skip hidden fields
@@ -596,23 +622,36 @@ function validateArrayItem(
596
622
  value,
597
623
  };
598
624
 
599
- // Required check
600
- const isRequired = fieldDef.requiredWhen
625
+ // Required check - fieldDef.requiredWhen overrides schema.required
626
+ const isRequired = fieldDef?.requiredWhen
601
627
  ? evaluateBoolean(fieldDef.requiredWhen, context)
602
- : false;
628
+ : schemaRequired.has(fieldName);
603
629
 
604
630
  if (isRequired && isEmpty(value)) {
605
631
  errors.push({
606
632
  field: itemFieldPath,
607
- message: fieldDef.label
633
+ message: fieldDef?.label
608
634
  ? `${fieldDef.label} is required`
609
635
  : "This field is required",
610
636
  severity: "error",
611
637
  });
612
638
  }
613
639
 
614
- // Custom validations
615
- if (fieldDef.validations && !isEmpty(value)) {
640
+ // Type validation from schema (only if value is present)
641
+ if (!isEmpty(value) && fieldSchema) {
642
+ const typeError = validateType(
643
+ itemFieldPath,
644
+ value,
645
+ fieldSchema,
646
+ fieldDef ?? { label: fieldName }
647
+ );
648
+ if (typeError) {
649
+ errors.push(typeError);
650
+ }
651
+ }
652
+
653
+ // Custom validations from fieldDef
654
+ if (fieldDef?.validations && !isEmpty(value)) {
616
655
  const customErrors = validateCustomRules(itemFieldPath, fieldDef.validations, context);
617
656
  errors.push(...customErrors);
618
657
  }