Skip to content

Commit 88e9272

Browse files
mjambonclaude
andauthored
atdj: add <json repr="object"> for sum types (externally-tagged encoding) (#491)
Sum types annotated with <json repr="object"> now encode tagged variants as single-key JSON objects {"Constructor": payload} instead of the default two-element array ["Constructor", payload]. Unit variants (no payload) are always encoded as plain strings regardless of the repr annotation, consistent with all other backends. This matches the Rust/Serde default externally-tagged encoding and also maps naturally to YAML as a single-key mapping: # array encoding (default): - - Circle - 3.14 # object encoding (<json repr="object">): - Circle: 3.14 Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7b392c1 commit 88e9272

9 files changed

Lines changed: 241 additions & 9 deletions

File tree

atdj/src/atdj_helper.ml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,25 @@ let output_util env =
3030
let out = Atdj_trans.open_class env "Util" in
3131
fprintf out "\
3232
class Util {
33-
// Extract the tag of sum-typed value
33+
// Extract the tag of a sum-typed value.
34+
// Handles three encodings:
35+
// - String: unit variant e.g. \"Foo\"
36+
// - JSONArray: two-element array e.g. [\"Foo\", payload]
37+
// (the default ATD encoding for tagged variants)
38+
// - JSONObject: single-key object e.g. {\"Foo\": payload}
39+
// (<json repr=\"object\">, the Rust/Serde default externally-tagged
40+
// encoding; also maps naturally to YAML as a single-key mapping)
3441
static String extractTag(Object value) throws JSONException {
3542
if (value instanceof String)
3643
return (String)value;
3744
else if (value instanceof JSONArray)
3845
return ((JSONArray)value).getString(0);
46+
else if (value instanceof JSONObject) {
47+
JSONObject obj = (JSONObject)value;
48+
if (obj.length() != 1)
49+
throw new JSONException(\"Expected single-key object for sum type\");
50+
return obj.keys().next();
51+
}
3952
else throw new JSONException(\"Cannot extract type\");
4053
}
4154

atdj/src/atdj_trans.ml

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -327,9 +327,24 @@ and trans_outer env (def : A.type_def) =
327327
* we generate a class Ty implemented in Ty.java and an enum TyEnum defined
328328
* in a separate file TyTag.java.
329329
*)
330-
and trans_sum my_name env (_, vars, _) =
330+
and trans_sum my_name env (_, vars, sum_an) =
331331
let class_name = Atdj_names.to_class_name my_name in
332332

333+
(* Determine whether tagged variants are encoded as two-element JSON arrays
334+
["Constructor", payload] (the default ATD encoding) or as single-key JSON
335+
objects {"Constructor": payload} (<json repr="object">).
336+
337+
The object encoding is the default for Rust/Serde (externally-tagged)
338+
and also maps naturally to YAML as a single-key mapping:
339+
340+
# array encoding (default):
341+
- - Circle
342+
- 3.14
343+
# object encoding (<json repr="object">):
344+
- Circle: 3.14
345+
*)
346+
let json_sum_repr = (Atd.Json.get_json_sum sum_an).json_sum_repr in
347+
333348
let cases =
334349
List.map (fun (x : A.variant) ->
335350
match x with
@@ -401,7 +416,16 @@ public class %s {
401416
enum_name
402417

403418
| Some (atd_ty, java_ty) ->
404-
let src = sprintf "((JSONArray)o).%s(1)" (get env atd_ty false) in
419+
(* Extract the payload from the tagged variant.
420+
Array encoding: ["Constructor", payload] → index 1 in JSONArray
421+
Object encoding: {"Constructor": payload} → key "Constructor" in JSONObject *)
422+
let src =
423+
match json_sum_repr with
424+
| Atd.Json.Array ->
425+
sprintf "((JSONArray)o).%s(1)" (get env atd_ty false)
426+
| Atd.Json.Object ->
427+
sprintf "((JSONObject)o).%s(\"%s\")" (get env atd_ty false) json_name
428+
in
405429
let set_value =
406430
assign env
407431
(Some ("field_" ^ field_name)) src
@@ -485,14 +509,31 @@ public class %s {
485509
json_name (* TODO: java-string-escape *)
486510

487511
| Some (atd_ty, _) ->
488-
fprintf out "
512+
(* Encode the tagged variant.
513+
Array encoding (default): ["Constructor", payload]
514+
Object encoding (<json repr="object">): {"Constructor": payload}
515+
516+
The object encoding is the Rust/Serde default (externally-tagged)
517+
and also produces idiomatic YAML: Constructor: payload *)
518+
(match json_sum_repr with
519+
| Atd.Json.Array ->
520+
fprintf out "
489521
case %s:
490522
_out.append(\"[\\\"%s\\\",\");
491523
%s _out.append(\"]\");
492524
break;"
493-
enum_name
494-
json_name
495-
(to_string env ("field_" ^ field_name) atd_ty " ")
525+
enum_name
526+
json_name
527+
(to_string env ("field_" ^ field_name) atd_ty " ")
528+
| Atd.Json.Object ->
529+
fprintf out "
530+
case %s:
531+
_out.append(\"{\\\"%s\\\":\");
532+
%s _out.append(\"}\");
533+
break;"
534+
enum_name
535+
json_name
536+
(to_string env ("field_" ^ field_name) atd_ty " "))
496537
) l
497538
) cases;
498539

atdj/test/AtdjTest.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,43 @@ public void testComplexRecord() throws JSONException {
8383
assertEquals(false, v.l.get(1));
8484
}
8585

86+
/**
87+
* Test the object encoding for sum types: {"Constructor": payload}
88+
* instead of the default array encoding: ["Constructor", payload].
89+
*
90+
* This is the Rust/Serde default (externally-tagged) and also maps
91+
* naturally to YAML as a single-key mapping.
92+
*/
93+
@Test
94+
public void testSumReprObject() throws JSONException {
95+
// Encode a Circle: should produce {"Circle": 3.14}
96+
Shape circle = new Shape();
97+
circle.setCircle(3.14);
98+
assertEquals("{\"Circle\":3.14}", circle.toJson());
99+
100+
// Encode a Square: should produce {"Square": 2.0}
101+
Shape square = new Shape();
102+
square.setSquare(2.0);
103+
assertEquals("{\"Square\":2.0}", square.toJson());
104+
105+
// Unit variant is still encoded as a plain string regardless of repr
106+
Shape point = new Shape();
107+
point.setPoint();
108+
assertEquals("\"Point\"", point.toJson());
109+
110+
// Decode: {"Circle": 1.0} -> Circle(1.0)
111+
Shape decoded = new Shape(new org.json.JSONObject("{\"Circle\": 1.0}"));
112+
assertEquals(Shape.Tag.CIRCLE, decoded.tag());
113+
assertEquals(1.0, decoded.getCircle(), 1e-9);
114+
115+
// Round-trip
116+
Shape orig = new Shape();
117+
orig.setSquare(5.5);
118+
Shape rt = new Shape(new org.json.JSONObject(orig.toJson()));
119+
assertEquals(Shape.Tag.SQUARE, rt.tag());
120+
assertEquals(5.5, rt.getSquare(), 1e-9);
121+
}
122+
86123
public static void main(String[] args) {
87124
org.junit.runner.JUnitCore.main("AtdjTest");
88125
}

atdj/test/com/mylife/test/dune

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
E.java
99
RecordWithDefaults.java
1010
SampleSum.java
11+
Shape.java
1112
SimpleRecord.java
1213
Util.java
1314
package.html)

atdj/test/dune

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@
3535
(package atdj)
3636
(action (diff expected/SampleSum.java com/mylife/test/SampleSum.java)))
3737

38+
(rule
39+
(alias runtest)
40+
(package atdj)
41+
(action (diff expected/Shape.java com/mylife/test/Shape.java)))
42+
3843
(rule
3944
(alias runtest)
4045
(package atdj)

atdj/test/expected/Shape.java

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Automatically generated; do not edit
2+
package com.mylife.test;
3+
import org.json.*;
4+
5+
/**
6+
* Construct objects of type shape.
7+
*/
8+
9+
public class Shape {
10+
Tag t = null;
11+
12+
public Shape() {
13+
}
14+
15+
public Tag tag() {
16+
return t;
17+
}
18+
19+
/**
20+
* Define tags for sum type shape.
21+
*/
22+
public enum Tag {
23+
CIRCLE, SQUARE, POINT
24+
}
25+
26+
public Shape(Object o) throws JSONException {
27+
String tag = Util.extractTag(o);
28+
if (tag.equals("Circle")) {
29+
field_circle = ((JSONObject)o).getDouble("Circle");
30+
31+
t = Tag.CIRCLE;
32+
}
33+
else if (tag.equals("Square")) {
34+
field_square = ((JSONObject)o).getDouble("Square");
35+
36+
t = Tag.SQUARE;
37+
}
38+
else if (tag.equals("Point"))
39+
t = Tag.POINT;
40+
else
41+
throw new JSONException("Invalid tag: " + tag);
42+
}
43+
44+
Double field_circle = null;
45+
public void setCircle(Double x) {
46+
/* TODO: clear previously-set field in order to avoid memory leak */
47+
t = Tag.CIRCLE;
48+
field_circle = x;
49+
}
50+
public Double getCircle() {
51+
if (t == Tag.CIRCLE)
52+
return field_circle;
53+
else
54+
return null;
55+
}
56+
57+
Double field_square = null;
58+
public void setSquare(Double x) {
59+
/* TODO: clear previously-set field in order to avoid memory leak */
60+
t = Tag.SQUARE;
61+
field_square = x;
62+
}
63+
public Double getSquare() {
64+
if (t == Tag.SQUARE)
65+
return field_square;
66+
else
67+
return null;
68+
}
69+
70+
public void setPoint() {
71+
/* TODO: clear previously-set field and avoid memory leak */
72+
t = Tag.POINT;
73+
}
74+
75+
public void toJsonBuffer(StringBuilder _out) throws JSONException {
76+
if (t == null)
77+
throw new JSONException("Uninitialized Shape");
78+
else {
79+
switch(t) {
80+
case CIRCLE:
81+
_out.append("{\"Circle\":");
82+
_out.append(String.valueOf(field_circle));
83+
_out.append("}");
84+
break;
85+
case SQUARE:
86+
_out.append("{\"Square\":");
87+
_out.append(String.valueOf(field_square));
88+
_out.append("}");
89+
break;
90+
case POINT:
91+
_out.append("\"Point\"");
92+
break;
93+
default:
94+
break; /* unused; keeps compiler happy */
95+
}
96+
}
97+
}
98+
99+
public String toJson() throws JSONException {
100+
StringBuilder out = new StringBuilder(128);
101+
toJsonBuffer(out);
102+
return out.toString();
103+
}
104+
}

atdj/test/expected/Util.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,25 @@
33
import org.json.*;
44

55
class Util {
6-
// Extract the tag of sum-typed value
6+
// Extract the tag of a sum-typed value.
7+
// Handles three encodings:
8+
// - String: unit variant e.g. "Foo"
9+
// - JSONArray: two-element array e.g. ["Foo", payload]
10+
// (the default ATD encoding for tagged variants)
11+
// - JSONObject: single-key object e.g. {"Foo": payload}
12+
// (<json repr="object">, the Rust/Serde default externally-tagged
13+
// encoding; also maps naturally to YAML as a single-key mapping)
714
static String extractTag(Object value) throws JSONException {
815
if (value instanceof String)
916
return (String)value;
1017
else if (value instanceof JSONArray)
1118
return ((JSONArray)value).getString(0);
19+
else if (value instanceof JSONObject) {
20+
JSONObject obj = (JSONObject)value;
21+
if (obj.length() != 1)
22+
throw new JSONException("Expected single-key object for sum type");
23+
return obj.keys().next();
24+
}
1225
else throw new JSONException("Cannot extract type");
1326
}
1427

atdj/test/test.atd

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,22 @@ type simple_record = { ?o : bool option }
4747
(* https://github.com/esperco/atdj/issues/2 *)
4848
type a = [ A of b list ]
4949
type b = [ B ]
50+
51+
(*
52+
Sum type using the object encoding: {"Constructor": payload}
53+
instead of the default array encoding: ["Constructor", payload].
54+
55+
This matches the Rust/Serde default (externally-tagged) and also
56+
looks natural in YAML:
57+
58+
# array encoding (default):
59+
- - Circle
60+
- 3.14
61+
# object encoding (<json repr="object">):
62+
- Circle: 3.14
63+
*)
64+
type shape = [
65+
| Circle of float
66+
| Square of float
67+
| Point (* unit variant -- always encoded as a plain string *)
68+
] <json repr="object">

internal/support_matrix.ml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,6 @@ let languages : (string * lang_support) list = [
122122
"atdj (Java)", { all_yes with
123123
wrap = Planned;
124124
json_repr_object = Planned;
125-
sum_repr_object = Planned;
126125
json_adapter = Planned;
127126
imports = Planned;
128127
open_enums = Planned;

0 commit comments

Comments
 (0)