Skip to content

Commit 0ba3a4b

Browse files
authored
Merge pull request #109 from xhtmlboi/inherit-models
Add `Yocaml.Data.Validation.sub_record`
2 parents 83f0dda + ce30e60 commit 0ba3a4b

File tree

9 files changed

+198
-8
lines changed

9 files changed

+198
-8
lines changed

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#### Yocaml
44

55
- Add `Action.remove_residuals` for erasing residuals files (by [xvw](https://xvw.lol))
6+
- Add `Yocaml.Data.Validation.sub_record` for validating a complete structure as a record field (by [xvw](https://xvw.lol))
67

78
#### Yocaml_git
89

doc/data_validation.mld

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,39 @@ Build a validator for lists of users! Excellent! It is partly for this reason
407407
that errors are so complex; they must be nestable and handle increasingly
408408
complex cases to ensure the reusability of validators.
409409

410+
{3 Sub-records}
411+
412+
Sometimes, you may want to validate a record as a minimum record with
413+
additional data. For example, consider this type:
414+
415+
{eof@ocaml[
416+
type p = {
417+
page: Yocaml.Archetype.Page.t
418+
; authors: string list
419+
; category: string
420+
}
421+
]eof}
422+
423+
In this example, we would like our source to be “a page” with two
424+
additional fields.
425+
426+
{!val:Yocaml.Data.Validation.sub_record} allows you to validate an
427+
entire structure as a record field. For example:
428+
429+
{eof@ocaml[
430+
let validate_p =
431+
let open Yocaml.Data.Validation in
432+
record (fun fields ->
433+
let+ page = sub_record fields Yocaml.Archetype.Page.validate
434+
and+ authors = optional_or ~default:[] fields "authors" (list_of string)
435+
and+ category = required fields "category" string in
436+
{ page; authors; category }
437+
)
438+
]eof}
439+
440+
The overall idea is to enable the validation of a known model to be
441+
applied within the validation of a record.
442+
410443
{2 Additional Validators}
411444

412445
There are other additional validators that fit with common OCaml types. For

lib/core/data.ml

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ let list_of f l = list @@ List.map f l
4141
let record fields = Record fields
4242
let option some = Option.fold ~none:null ~some
4343
let path p = string (Path.to_string p)
44+
let mk_record = record
4445

4546
let sum f value =
4647
let k, v = f value in
@@ -141,6 +142,7 @@ module Validation = struct
141142
and record_error =
142143
| Missing_field of { field : string }
143144
| Invalid_field of { given : t; field : string; error : value_error }
145+
| Invalid_subrecord of value_error
144146

145147
type 'a validated_value = ('a, value_error) result
146148
type 'a validated_record = ('a, record_error Nel.t) result
@@ -367,6 +369,10 @@ module Validation = struct
367369
let opt = optional assoc field validator in
368370
Result.bind opt (function Some x -> Ok x | None -> Ok default)
369371

372+
let sub_record assoc validator =
373+
validator (mk_record assoc)
374+
|> Result.map_error (fun err -> Nel.singleton (Invalid_subrecord err))
375+
370376
module Infix = struct
371377
let ( & ) l r x = Result.bind (l x) r
372378
let ( / ) l r x = Result.fold ~ok:Result.ok ~error:(fun _ -> r x) (l x)
@@ -411,10 +417,6 @@ module Validation = struct
411417

412418
let path = string $ Path.from_string
413419

414-
(** {2 String validators}
415-
416-
Validators specifically for string values. *)
417-
418420
module String = struct
419421
let string_pp = Format.pp_print_string
420422
let string_equal = Stdlib.String.equal
@@ -425,7 +427,6 @@ module Validation = struct
425427
let not_equal not_expected actual =
426428
not_equal ~pp:string_pp ~equal:string_equal not_expected actual
427429

428-
(* Length-based validators - using Int.equal and Int.compare as suggested *)
429430
let has_length expected_length actual =
430431
let actual_length = Stdlib.String.length actual in
431432
if Int.equal actual_length expected_length then Ok actual
@@ -580,8 +581,6 @@ module Validation = struct
580581
let where = with_pp where
581582
end
582583

583-
(** {2 Validator combinators} *)
584-
585584
let negate validator x =
586585
match validator x with
587586
| Ok _ -> fail_with ~given:"<value>" "should not satisfy the validator"

lib/core/data.mli

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ module Validation : sig
136136
and record_error =
137137
| Missing_field of { field : string }
138138
| Invalid_field of { given : t; field : string; error : value_error }
139+
| Invalid_subrecord of value_error
139140

140141
type 'a validated_value = ('a, value_error) result
141142
(** Used to validate data described by type {!type:t} to build validation
@@ -188,6 +189,10 @@ module Validation : sig
188189
(** [record v x] ensure that [x] has the shape validated by [v] (all errors
189190
are collected). *)
190191

192+
val sub_record :
193+
(string * t) list -> (t -> 'a validated_value) -> 'a validated_record
194+
(** [sub_record] allows you to validate a record during record validation. *)
195+
191196
val option : (t -> 'a validated_value) -> t -> 'a option validated_value
192197
(** [option v x] validate a value using [v] that can be [null] wrapped into an
193198
option. *)

lib/core/diagnostic.ml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ and pp_record_error custom_error ppf = function
6565
field
6666
(pp_validation_error custom_error)
6767
error Data.pp given
68+
| Data.Validation.Invalid_subrecord error ->
69+
Format.fprintf ppf "Invalid subrecord @[<2>%a@]"
70+
(pp_validation_error custom_error)
71+
error
6872

6973
let pp_provider_error custom_error ppf = function
7074
| Required.Parsing_error { given; message } ->

test/archetype/page_test.ml

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
(* YOCaml a static blog generator.
2+
Copyright (C) 2025 The Funkyworkers and The YOCaml's developers
3+
4+
This program is free software: you can redistribute it and/or modify
5+
it under the terms of the GNU General Public License as published by
6+
the Free Software Foundation, either version 3 of the License, or
7+
(at your option) any later version.
8+
9+
This program is distributed in the hope that it will be useful,
10+
but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
GNU General Public License for more details.
13+
14+
You should have received a copy of the GNU General Public License
15+
along with this program. If not, see <https://www.gnu.org/licenses/>. *)
16+
17+
type t = {
18+
page : Yocaml.Archetype.Page.t
19+
; category : string option
20+
; authors : string list
21+
}
22+
23+
let normalize_t { page; category; authors } =
24+
let open Yocaml.Data in
25+
record
26+
(Yocaml.Archetype.Page.normalize page
27+
@ [
28+
("category", option string category)
29+
; ("authors", (list_of string) authors)
30+
])
31+
32+
let testable_t =
33+
let pp ppf x = Format.fprintf ppf "%a" Yocaml.Data.pp (normalize_t x)
34+
and equal a b = Yocaml.Data.equal (normalize_t a) (normalize_t b) in
35+
Alcotest.testable pp equal
36+
37+
let validate_t =
38+
let open Yocaml.Data.Validation in
39+
record (fun fields ->
40+
let+ page = sub_record fields Yocaml.Archetype.Page.validate
41+
and+ category = optional fields "category" string
42+
and+ authors =
43+
optional_or ~default:[] fields "authors" (list_of string)
44+
in
45+
{ page; category; authors })
46+
47+
let test_validate_with_subpage_1 =
48+
let open Alcotest in
49+
test_case "Validate using a page inside a different value - 1" `Quick
50+
(fun () ->
51+
let input =
52+
let open Yocaml.Data in
53+
record []
54+
and input_page =
55+
let open Yocaml.Data in
56+
record []
57+
and category = None
58+
and authors = [] in
59+
let expected =
60+
Result.map
61+
(fun page -> { page; category; authors })
62+
(Yocaml.Archetype.Page.validate input_page)
63+
and computed = validate_t input in
64+
check
65+
Test_lib.Testable.(validated_value testable_t)
66+
"shoudl be equal" expected computed)
67+
68+
let test_validate_with_subpage_2 =
69+
let open Alcotest in
70+
test_case "Validate using a page inside a different value - 2" `Quick
71+
(fun () ->
72+
let input =
73+
let open Yocaml.Data in
74+
record [ ("page_title", string "foo") ]
75+
and input_page =
76+
let open Yocaml.Data in
77+
record [ ("page_title", string "foo") ]
78+
and category = None
79+
and authors = [] in
80+
let expected =
81+
Result.map
82+
(fun page -> { page; category; authors })
83+
(Yocaml.Archetype.Page.validate input_page)
84+
and computed = validate_t input in
85+
check
86+
Test_lib.Testable.(validated_value testable_t)
87+
"shoudl be equal" expected computed)
88+
89+
let test_validate_with_subpage_3 =
90+
let open Alcotest in
91+
test_case "Validate using a page inside a different value - 3" `Quick
92+
(fun () ->
93+
let input =
94+
let open Yocaml.Data in
95+
record
96+
[
97+
("page_title", string "foo")
98+
; ("category", string "article")
99+
; ("authors", list_of string [ "xvw"; "xhtmlboi"; "msp"; "grm" ])
100+
]
101+
and input_page =
102+
let open Yocaml.Data in
103+
record [ ("page_title", string "foo") ]
104+
and category = Some "article"
105+
and authors = [ "xvw"; "xhtmlboi"; "msp"; "grm" ] in
106+
let expected =
107+
Result.map
108+
(fun page -> { page; category; authors })
109+
(Yocaml.Archetype.Page.validate input_page)
110+
and computed = validate_t input in
111+
check
112+
Test_lib.Testable.(validated_value testable_t)
113+
"shoudl be equal" expected computed)
114+
115+
let cases =
116+
( "Yocaml.Archetype.Path"
117+
, [
118+
test_validate_with_subpage_1
119+
; test_validate_with_subpage_2
120+
; test_validate_with_subpage_3
121+
] )

test/archetype/page_test.mli

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
(* YOCaml a static blog generator.
2+
Copyright (C) 2025 The Funkyworkers and The YOCaml's developers
3+
4+
This program is free software: you can redistribute it and/or modify
5+
it under the terms of the GNU General Public License as published by
6+
the Free Software Foundation, either version 3 of the License, or
7+
(at your option) any later version.
8+
9+
This program is distributed in the hope that it will be useful,
10+
but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
GNU General Public License for more details.
13+
14+
You should have received a copy of the GNU General Public License
15+
along with this program. If not, see <https://www.gnu.org/licenses/>. *)
16+
17+
val cases : string * unit Alcotest.test_case list
18+
(** Returns the list of test cases. *)

test/archetype/yocaml_archetype_test.ml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@
1414
You should have received a copy of the GNU General Public License
1515
along with this program. If not, see <https://www.gnu.org/licenses/>. *)
1616

17-
let () = Alcotest.run "Yocaml Archetype test" [ Datetime_test.cases ]
17+
let () =
18+
Alcotest.run "Yocaml Archetype test" [ Datetime_test.cases; Page_test.cases ]

test/lib/testable.ml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@ and pp_record_error cst ppf err =
148148
; field "given" (fun (_, _, g) -> g) (parens Yocaml.Data.pp)
149149
])
150150
ppf (f, error, given)
151+
| Invalid_subrecord error ->
152+
braces
153+
(record
154+
[
155+
field "kind" (Fun.const "invalid subrecord") string
156+
; field "error" Fun.id (parens (pp_value_error cst))
157+
])
158+
ppf error
151159

152160
let rec equal_value_error cst a b =
153161
let open Yocaml.Data.Validation in

0 commit comments

Comments
 (0)