Skip to content

Commit 994e673

Browse files
committed
ppx: allow dropping arbitrary values with [@drop_default]
1 parent d6ca192 commit 994e673

13 files changed

+757
-14
lines changed

CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
- Support `Ptype_open`, e.g. `type u = X.(x) [@@deriving json]`
44
([#60](https://github.com/melange-community/melange-json/pull/60))
5+
- Add support for `[@drop_default]` to drop arbitrary values provided
6+
with `[@default]` from JSON representation, with option to provide
7+
function used for equality check
8+
([#77](https://github.com/melange-community/melange-json/pull/77))
59

610
## 2.0.0 (2025-03-11)
711

README.md

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -281,20 +281,79 @@ let t = of_json (Melange_json.of_string {|{"a": 42}|})
281281

282282
#### `[@json.drop_default]`: drop default values from JSON
283283

284-
When a field has `[@option]` attribute one can use `[@json.drop_default]`
285-
attribute to make the generated `to_json` function to drop the field if it's
286-
value is `None`:
284+
When a field has either `[@json.option]` or `[@json.default]` attributes, you can use the `[@json.drop_default]`
285+
attribute to make the generated `to_json` function drop the field
286+
from the JSON output when its value matches the default.
287+
288+
In its flag form (no argument), `[@json.drop_default]` checks for `None` when used with
289+
`[@json.option]`, and requires an `equal_<type>` function in scope when used with
290+
`[@json.default]`:
287291

288292
```ocaml
293+
let equal_string = String.equal
294+
289295
type t = {
290296
a: int;
291297
b: string option [@json.option] [@json.drop_default];
298+
c: string [@json.default "-"] [@json.drop_default];
292299
} [@@deriving to_json]
293300
294-
let t = to_json { a = 1; b = None; }
301+
let t = to_json { a = 1; b = None; c = "-"; }
295302
(* {"a": 1} *)
296303
```
297304

305+
For parameterized types, the equal function takes the inner type's `equal_<type>`
306+
function as an argument, so a field of type `int list` generates a call to `equal_list equal_int`,
307+
`int list list` generates `equal_list (equal_list equal_int)`, and so on.
308+
309+
```ocaml
310+
let equal_int = Int.equal
311+
let rec equal_list equal_a a b =
312+
match a, b with
313+
| [], [] -> true
314+
| x :: xs, y :: ys -> equal_a x y && equal_list equal_a xs ys
315+
| _ -> false
316+
317+
type t = {
318+
items: int list [@json.default []] [@json.drop_default];
319+
} [@@deriving to_json]
320+
321+
let json = to_json { items = [] }
322+
(* {} *)
323+
```
324+
325+
You can also provide a custom comparison function of type `'a -> 'a -> bool` directly:
326+
327+
```ocaml
328+
type t = {
329+
f: float [@json.default 0.0] [@json.drop_default Float.equal];
330+
} [@@deriving to_json]
331+
332+
let json = to_json { f = 0.0 }
333+
(* {} *)
334+
```
335+
336+
#### `[@json.drop_default_if_json_equal]`: drop defaults by comparing JSON output
337+
338+
A (mutually exclusive) alternative to `[@json.drop_default]` that compares values at the JSON level
339+
rather than requiring an `equal_<type>` function. This is useful for complex or
340+
nested types where you already have `to_json` but don't want to derive or write
341+
equality functions:
342+
343+
```ocaml
344+
type color = { r: int; g: int; b: int } [@@deriving json]
345+
346+
type style = {
347+
font_size: int;
348+
background: color
349+
[@json.default { r = 255; g = 255; b = 255 }]
350+
[@json.drop_default_if_json_equal];
351+
} [@@deriving json]
352+
353+
let json = to_json { font_size = 12; background = { r = 255; g = 255; b = 255 } }
354+
(* {"font_size": 12} *)
355+
```
356+
298357
#### `[@json.key "S"]`: customizing keys for record fields
299358

300359
You can specify custom keys for record fields using the `[@json.key E]`

ppx/browser/ppx_deriving_json_js.ml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,16 @@ module To_json = struct
214214
match [%e x] with
215215
| Stdlib.Option.None -> Js.Undefined.empty
216216
| Stdlib.Option.Some _ -> Js.Undefined.return [%e v]]
217+
| `Drop_default (cmp, def) ->
218+
[%expr
219+
if [%e cmp] [%e x] [%e def] then Js.Undefined.empty
220+
else Js.Undefined.return [%e v]]
221+
| `Drop_default_if_json_equal def ->
222+
[%expr
223+
let json = [%e v] in
224+
if Melange_json.equal json [%e derive ld.pld_type def]
225+
then Js.Undefined.empty
226+
else Js.Undefined.return json]
217227
in
218228
map_loc lident k, v)
219229
in

ppx/native/common/ppx_deriving_json_common.ml

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,16 @@ let ld_attr_json_drop_default =
7171
Attribute.get
7272
(Attribute.declare "json.drop_default"
7373
Attribute.Context.label_declaration
74-
Ast_pattern.(pstr nil)
75-
())
74+
Ast_pattern.(
75+
pstr (map0 ~f:None nil) (* flag form *)
76+
||| single_expr_payload (map1 ~f:Option.some __)
77+
(* comparison function *))
78+
(fun x -> x))
79+
80+
let ld_attr_json_drop_default_if_json_equal =
81+
Attribute.get
82+
(Attribute.declare_flag "json.drop_default_if_json_equal"
83+
Attribute.Context.label_declaration)
7684

7785
let ld_attr_default ld =
7886
match ld_attr_json_default ld with
@@ -84,14 +92,75 @@ let ld_attr_default ld =
8492
Some [%expr Stdlib.Option.None]
8593
| None -> None)
8694

95+
let rec equal_of_core_type ~loc ct =
96+
match ct.ptyp_desc with
97+
| Ptyp_constr ({ txt = Lident name; _ }, args) ->
98+
let fn = pexp_ident ~loc { loc; txt = lident ("equal_" ^ name) } in
99+
List.fold_left (List.rev args) ~init:fn ~f:(fun acc arg ->
100+
pexp_apply ~loc acc [ Nolabel, equal_of_core_type ~loc arg ])
101+
| Ptyp_constr ({ txt = Ldot (prefix, name); _ }, args) ->
102+
let name = if name = "t" then "equal" else "equal_" ^ name in
103+
let fn = pexp_ident ~loc { loc; txt = Ldot (prefix, name) } in
104+
List.fold_left (List.rev args) ~init:fn ~f:(fun acc arg ->
105+
pexp_apply ~loc acc [ Nolabel, equal_of_core_type ~loc arg ])
106+
| Ptyp_var name ->
107+
pexp_ident ~loc { loc; txt = lident ("equal_" ^ name) }
108+
| _ ->
109+
Location.raise_errorf ~loc
110+
"[@drop_default]: cannot derive equal for this type, provide a \
111+
comparison function via [@json.drop_default f]"
112+
87113
let ld_drop_default ld =
88114
let loc = ld.pld_loc in
89-
match ld_attr_json_drop_default ld, ld_attr_json_option ld with
90-
| Some (), None ->
115+
let drop_default = ld_attr_json_drop_default ld in
116+
let drop_json_equal = ld_attr_json_drop_default_if_json_equal ld in
117+
match drop_default, drop_json_equal with
118+
| Some _, Some () ->
91119
Location.raise_errorf ~loc
92-
"found [@drop_default] attribute without [@option]"
93-
| Some (), Some () -> `Drop_option
94-
| None, _ -> `No
120+
"[@drop_default] and [@drop_default_if_json_equal] are mutually \
121+
exclusive"
122+
| None, None -> `No
123+
| None, Some () -> begin
124+
match ld_attr_json_option ld, ld_attr_json_default ld with
125+
| None, Some def -> `Drop_default_if_json_equal def
126+
| Some (), None ->
127+
Location.raise_errorf ~loc
128+
"[@drop_default_if_json_equal] cannot be used with \
129+
[@option]. Use [@drop_default] instead."
130+
| Some (), Some _ ->
131+
Location.raise_errorf ~loc
132+
"[@drop_default_if_json_equal] cannot be used with both \
133+
[@option] and [@default]. Use [@json.default] only."
134+
| None, None ->
135+
Location.raise_errorf ~loc
136+
"[@drop_default_if_json_equal] requires [@json.default]"
137+
end
138+
| Some None, None -> begin
139+
(* flag form: [@json.drop_default] *)
140+
match ld_attr_json_option ld, ld_attr_json_default ld with
141+
| Some (), None -> `Drop_option
142+
| None, Some def ->
143+
let cmp = equal_of_core_type ~loc ld.pld_type in
144+
`Drop_default (cmp, def)
145+
| Some (), Some _ ->
146+
Location.raise_errorf ~loc
147+
"[@drop_default] cannot be used with both [@option] and \
148+
[@default]"
149+
| None, None ->
150+
Location.raise_errorf ~loc
151+
"[@drop_default] requires either [@option] or [@default]"
152+
end
153+
| Some (Some cmp), None -> begin
154+
(* expression form: [@json.drop_default expr] *)
155+
match ld_attr_json_option ld, ld_attr_json_default ld with
156+
| None, Some def -> `Drop_default (cmp, def)
157+
| Some (), _ ->
158+
Location.raise_errorf ~loc
159+
"[@drop_default expr] cannot be used with [@option]"
160+
| None, None ->
161+
Location.raise_errorf ~loc
162+
"[@drop_default expr] requires [@default]"
163+
end
95164

96165
let expand_via ~what ~through make ~ctxt (rec_flag, tds) =
97166
let loc = Expansion_context.Deriver.derived_item_loc ctxt in

ppx/native/ppx_deriving_json_native.ml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,16 @@ module To_json = struct
208208
match [%e x] with
209209
| Stdlib.Option.None -> [%e ebnds]
210210
| Stdlib.Option.Some _ -> ([%e k], [%e v]) :: [%e ebnds]]
211+
| `Drop_default (cmp, def) ->
212+
[%expr
213+
if [%e cmp] [%e x] [%e def] then [%e ebnds]
214+
else ([%e k], [%e v]) :: [%e ebnds]]
215+
| `Drop_default_if_json_equal def ->
216+
[%expr
217+
let json = [%e v] in
218+
if Melange_json.equal json [%e derive ld.pld_type def]
219+
then [%e ebnds]
220+
else ([%e k], json) :: [%e ebnds]]
211221
in
212222
[%expr
213223
let [%p pbnds] = [%e ebnds] in

ppx/test/dune

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,3 @@
1313
(applies_to ptype_open)
1414
(enabled_if
1515
(>= %{ocaml_version} 5.2.0)))
16-

0 commit comments

Comments
 (0)