Skip to content

Commit

Permalink
make a clear distinction between optional field and field with option…
Browse files Browse the repository at this point in the history
…al value
  • Loading branch information
lostbean committed Dec 4, 2024
1 parent 5b90b76 commit cf0adea
Show file tree
Hide file tree
Showing 4 changed files with 98 additions and 32 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ type Coordinate =
#(Float, Float)
type Drawing {
Box(Float, Float, Coordinate, Option(Color))
Box(Float, Float, Option(Coordinate), Option(Color))
}
fn color_decoder() {
Expand Down Expand Up @@ -239,7 +239,9 @@ fn drawing_decoder() -> blueprint.Decoder(Drawing) {
Box,
blueprint.field("width", blueprint.float()),
blueprint.field("height", blueprint.float()),
blueprint.field("position", coordinate_decoder()),
// Make this field required by with a possible null value
blueprint.field("position", optional(coordinate_decoder())),
// Make this field optional
blueprint.optional_field("color", color_decoder()),
),
),
Expand All @@ -248,7 +250,7 @@ fn drawing_decoder() -> blueprint.Decoder(Drawing) {
pub fn drawing_test() {
// Test cases
let box = Box(15.0, 25.0, #(30.0, 40.0), None)
let box = Box(15.0, 25.0, Some(#(30.0, 40.0)), None)
// Test encoding
let encoded_box = encode_drawing(box)
Expand Down
67 changes: 56 additions & 11 deletions src/json/blueprint.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,6 @@ pub fn list(of decoder_type: Decoder(inner)) -> Decoder(List(inner)) {
)
}

pub fn optional(of decode: Decoder(inner)) -> Decoder(Option(inner)) {
Decoder(
dynamic.optional(decode.dyn_decoder),
jsch.Nullable(decode.schema),
decode.defs,
)
}

pub fn field(named name: String, of inner_type: Decoder(t)) -> FieldDecoder(t) {
FieldDecoder(
dynamic.field(name, inner_type.dyn_decoder),
Expand All @@ -164,11 +156,64 @@ pub fn field(named name: String, of inner_type: Decoder(t)) -> FieldDecoder(t) {
)
}

/// Creates a decoder that can handle `null` values by wrapping the result in an `Option` type.
/// When the value is `null`, it returns `None`. Otherwise, it uses the provided decoder
/// to decode the value and wraps the result in `Some`. If you need the decoder to handle a possible missing field
/// (i.e., the field is absent from the JSON), use the `optional_field` function instead.
///
/// ## Example
/// ```gleam
/// type User {
/// User(name: String, age: Option(Int))
/// }
///
/// let decoder = decode2(
/// User,
/// field("name", string()),
/// field("age", optional(int())) // Will handle "age": null
/// )
///
/// // These JSON strings will decode successfully:
/// // {"name": "Alice", "age": 25} -> User("Alice", Some(25))
/// // {"name": "Bob", "age": null} -> User("Bob", None)
/// ```
///
pub fn optional(of decode: Decoder(inner)) -> Decoder(Option(inner)) {
Decoder(
dynamic.optional(decode.dyn_decoder),
jsch.Nullable(decode.schema),
decode.defs,
)
}

@external(erlang, "json_blueprint_ffi", "null")
@external(javascript, "../json_blueprint_ffi.mjs", "do_null")
fn native_null() -> dynamic.Dynamic

/// Decode a `Option` value where the underlaying JSON field can be missing or have `null` value
/// Decode a field that can be missing or have a `null` value into an `Option` type.
/// This function is useful when you want to handle both cases where a field is absent from the JSON
/// or when it's explicitly set to `null`.
///
/// If you only need to handle fields that are present but might be `null`, use the `optional` function instead.
///
/// ## Example
/// ```gleam
/// type User {
/// User(name: String, age: Option(Int))
/// }
///
/// let decoder = decode2(
/// User,
/// field("name", string()),
/// optional_field("age", int()) // Will handle both missing "age" field and "age": null
/// )
///
/// // All these JSON strings will decode successfully:
/// // {"name": "Alice", "age": 25} -> User("Alice", Some(25))
/// // {"name": "Bob", "age": null} -> User("Bob", None)
/// // {"name": "Charlie"} -> User("Charlie", None)
/// ```
///
pub fn optional_field(
named name: String,
of inner_type: Decoder(t),
Expand All @@ -183,7 +228,7 @@ pub fn optional_field(
})(value)
|> result.map(option.flatten)
},
#(name, jsch.Nullable(inner_type.schema)),
#(name, jsch.Optional(inner_type.schema)),
inner_type.defs,
)
}
Expand Down Expand Up @@ -589,7 +634,7 @@ fn create_object_schema(
Some(
list.filter_map(fields, fn(field_dec) {
case field_dec {
#(_, jsch.Nullable(_)) -> Error(Nil)
#(_, jsch.Optional(_)) -> Error(Nil)
#(name, _) -> Ok(name)
}
}),
Expand Down
43 changes: 31 additions & 12 deletions src/json/blueprint/schema.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ pub type SchemaDefinition {
// Not required
Nullable(schema: SchemaDefinition)

// Not required
Optional(schema: SchemaDefinition)

// Numeric constraints
Number(
minimum: Option(Float),
Expand Down Expand Up @@ -204,7 +207,7 @@ pub fn to_json(schema: Schema) -> json.Json {
})

// Add the main schema definition
let schema_fields = schema_definition_to_json_fields(schema.schema)
let schema_fields = schema_definition_to_json_fields(schema.schema, False)
let fields = list.append(fields, schema_fields)

json.object(fields)
Expand All @@ -221,7 +224,7 @@ pub fn hash_schema_definition(def: SchemaDefinition) -> String {

/// Convert a SchemaDefinition to JSON value
fn schema_definition_to_json(def: SchemaDefinition) -> json.Json {
json.object(schema_definition_to_json_fields(def))
json.object(schema_definition_to_json_fields(def, False))
}

fn prepend_option(
Expand All @@ -241,20 +244,31 @@ fn prepend_option(
/// Convert a SchemaDefinition to a list of JSON fields
fn schema_definition_to_json_fields(
def: SchemaDefinition,
make_type_nullable: Bool,
) -> List(#(String, json.Json)) {
let with_nullable_type = fn(type_) {
case make_type_nullable {
False -> schema_type_to_json(type_)
True -> schema_type_to_json(Multiple([type_, Null]))
}
}

case def {
Type(type_) -> [#("type", schema_type_to_json(type_))]
Type(type_) -> [#("type", with_nullable_type(type_))]

Enum(values, schema) ->
[#("enum", json.preprocessed_array(values))]
|> prepend_option(schema, "type", schema_type_to_json)
|> prepend_option(schema, "type", with_nullable_type)

Const(value) -> [#("const", value)]

Nullable(schema) -> schema_definition_to_json_fields(schema)
Nullable(schema) -> schema_definition_to_json_fields(schema, True)

Optional(schema) ->
schema_definition_to_json_fields(schema, make_type_nullable)

Number(minimum, maximum, exclusive_minimum, exclusive_maximum, multiple_of) -> {
[]
[#("type", with_nullable_type(NumberType))]
|> prepend_option(minimum, "minimum", json.float)
|> prepend_option(maximum, "maximum", json.float)
|> prepend_option(exclusive_minimum, "exclusiveMinimum", json.float)
Expand All @@ -263,7 +277,7 @@ fn schema_definition_to_json_fields(
}

String(min_length, max_length, pattern, format) -> {
[]
[#("type", with_nullable_type(StringType))]
|> prepend_option(min_length, "minLength", json.int)
|> prepend_option(max_length, "maxLength", json.int)
|> prepend_option(pattern, "pattern", json.string)
Expand All @@ -273,15 +287,15 @@ fn schema_definition_to_json_fields(
}

Array(items) -> {
[#("type", schema_type_to_json(ArrayType))]
[#("type", with_nullable_type(ArrayType))]
|> prepend_option(items, "items", fn(schema) {
schema_definition_to_json(schema)
})
}

Object(properties, additional_properties, required) -> {
[
#("type", schema_type_to_json(ObjectType)),
#("type", with_nullable_type(ObjectType)),
#(
"properties",
json.object(
Expand Down Expand Up @@ -309,7 +323,7 @@ fn schema_definition_to_json_fields(
min_contains,
max_contains,
) -> {
[#("type", schema_type_to_json(ArrayType))]
[#("type", with_nullable_type(ArrayType))]
|> prepend_option(items, "items", fn(schema) {
schema_definition_to_json(schema)
})
Expand All @@ -335,7 +349,7 @@ fn schema_definition_to_json_fields(
min_properties,
max_properties,
) -> {
[#("type", schema_type_to_json(ObjectType))]
[#("type", with_nullable_type(ObjectType))]
|> prepend_option(properties, "properties", fn(props) {
json.object(
list.map(props, fn(prop) {
Expand Down Expand Up @@ -376,7 +390,12 @@ fn schema_definition_to_json_fields(
]
Not(schema) -> [#("not", schema_definition_to_json(schema))]

Ref(ref) -> [#("$ref", json.string(ref))]
Ref(ref) ->
case make_type_nullable {
False -> [#("$ref", json.string(ref))]
True ->
schema_definition_to_json_fields(AnyOf([Ref(ref), Type(Null)]), False)
}

TrueValue -> [#("type", json.bool(True))]
FalseValue -> [#("type", json.bool(False))]
Expand Down
12 changes: 6 additions & 6 deletions test/json_blueprint_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ pub fn json_schema_string_format_test() {
#("format", json.string("email")),
#("maxLength", json.int(100)),
#("minLength", json.int(5)),
#("type", json.string("string")),
])
|> json.to_string,
)
Expand All @@ -273,6 +274,7 @@ pub fn json_schema_number_constraint_test() {
#("multipleOf", json.float(0.5)),
#("maximum", json.float(100.0)),
#("minimum", json.float(0.0)),
#("type", json.string("number")),
])
|> json.to_string,
)
Expand Down Expand Up @@ -696,10 +698,8 @@ fn tree_decoder() {
blueprint.decode3(
Node,
blueprint.field("value", blueprint.int()),
blueprint.field(
"left",
blueprint.optional(blueprint.self_decoder(tree_decoder)),
),
// testing both an optional field a field with a possible null
blueprint.optional_field("left", blueprint.self_decoder(tree_decoder)),
blueprint.field(
"right",
blueprint.optional(blueprint.self_decoder(tree_decoder)),
Expand Down Expand Up @@ -743,7 +743,7 @@ pub fn tree_decoder_test() {
schema
|> json.to_string
|> should.equal(
"{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[\"type\",\"data\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"node\"]},\"data\":{\"required\":[\"value\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"integer\"},\"left\":{\"$ref\":\"#\"},\"right\":{\"$ref\":\"#\"}}}}}",
"{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[\"type\",\"data\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"node\"]},\"data\":{\"required\":[\"value\",\"right\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"integer\"},\"left\":{\"$ref\":\"#\"},\"right\":{\"anyOf\":[{\"$ref\":\"#\"},{\"type\":\"null\"}]}}}}}",
)
}

Expand Down Expand Up @@ -824,6 +824,6 @@ pub fn reuse_decoder_test() {
blueprint.generate_json_schema(person_with_address_decoder)
|> json.to_string
|> should.equal(
"{\"$defs\":{\"ref_3B07ED20E59E713A14D2B0C98C72D9ECB75C0D9C\":{\"required\":[\"name\",\"age\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"age\":{\"type\":\"integer\"},\"email\":{\"type\":\"string\"}}}},\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[\"person\",\"address\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"person\":{\"$ref\":\"#/$defs/ref_3B07ED20E59E713A14D2B0C98C72D9ECB75C0D9C\"},\"address\":{\"required\":[\"street\",\"city\",\"zip\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"street\":{\"type\":\"string\"},\"city\":{\"type\":\"string\"},\"zip\":{\"type\":\"string\"}}}}}",
"{\"$defs\":{\"ref_707AD0AE2AF80DF30FAB6C677D270B616C19AF94\":{\"required\":[\"name\",\"age\",\"email\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"age\":{\"type\":\"integer\"},\"email\":{\"type\":[\"string\",\"null\"]}}}},\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"required\":[\"person\",\"address\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"person\":{\"$ref\":\"#/$defs/ref_707AD0AE2AF80DF30FAB6C677D270B616C19AF94\"},\"address\":{\"required\":[\"street\",\"city\",\"zip\"],\"additionalProperties\":false,\"type\":\"object\",\"properties\":{\"street\":{\"type\":\"string\"},\"city\":{\"type\":\"string\"},\"zip\":{\"type\":\"string\"}}}}}",
)
}

0 comments on commit cf0adea

Please sign in to comment.