diff --git a/README.markdown b/README.markdown index 102317c..e974e30 100644 --- a/README.markdown +++ b/README.markdown @@ -39,9 +39,9 @@ to use a JSON Schema validator at runtime to enforce remaining constraints. | Applicator (2020-12) | `allOf` | Yes | | Applicator (2020-12) | `oneOf` | **PARTIAL GIVEN LANGUAGE LIMITATIONS** | | Applicator (2020-12) | `not` | **CANNOT SUPPORT** | -| Applicator (2020-12) | `if` | Pending | -| Applicator (2020-12) | `then` | Pending | -| Applicator (2020-12) | `else` | Pending | +| Applicator (2020-12) | `if` | **PARTIAL GIVEN LANGUAGE LIMITATIONS** | +| Applicator (2020-12) | `then` | **PARTIAL GIVEN LANGUAGE LIMITATIONS** | +| Applicator (2020-12) | `else` | **PARTIAL GIVEN LANGUAGE LIMITATIONS** | | Validation (2020-12) | `type` | Yes | | Validation (2020-12) | `enum` | Yes | | Validation (2020-12) | `required` | Yes | diff --git a/src/generator/include/sourcemeta/codegen/generator_typescript.h b/src/generator/include/sourcemeta/codegen/generator_typescript.h index 970b7f5..f7626ca 100644 --- a/src/generator/include/sourcemeta/codegen/generator_typescript.h +++ b/src/generator/include/sourcemeta/codegen/generator_typescript.h @@ -30,6 +30,7 @@ class SOURCEMETA_CODEGEN_GENERATOR_EXPORT TypeScript { auto operator()(const IRTuple &entry) -> void; auto operator()(const IRUnion &entry) -> void; auto operator()(const IRIntersection &entry) -> void; + auto operator()(const IRConditional &entry) -> void; private: // Exporting symbols that depends on the standard C++ library is considered diff --git a/src/generator/typescript.cc b/src/generator/typescript.cc index b221009..01f0d88 100644 --- a/src/generator/typescript.cc +++ b/src/generator/typescript.cc @@ -303,4 +303,26 @@ auto TypeScript::operator()(const IRIntersection &entry) -> void { this->output << ";\n"; } +auto TypeScript::operator()(const IRConditional &entry) -> void { + // As a notable limitation, TypeScript cannot express the negation of an + // if/then/else condition, so the else branch is wider than what JSON + // Schema allows + this->output << "// (if & then) | else approximation: the else branch is " + "wider than what\n"; + this->output << "// JSON Schema allows, as TypeScript cannot express type " + "negation\n"; + this->output << "export type " + << mangle(this->prefix, entry.pointer, entry.symbol, this->cache) + << " =\n (" + << mangle(this->prefix, entry.condition.pointer, + entry.condition.symbol, this->cache) + << " & " + << mangle(this->prefix, entry.consequent.pointer, + entry.consequent.symbol, this->cache) + << ") | " + << mangle(this->prefix, entry.alternative.pointer, + entry.alternative.symbol, this->cache) + << ";\n"; +} + } // namespace sourcemeta::codegen diff --git a/src/ir/include/sourcemeta/codegen/ir.h b/src/ir/include/sourcemeta/codegen/ir.h index 31a1c2d..c5ae85d 100644 --- a/src/ir/include/sourcemeta/codegen/ir.h +++ b/src/ir/include/sourcemeta/codegen/ir.h @@ -97,15 +97,22 @@ struct IRImpossible : IRType {}; /// @ingroup ir struct IRAny : IRType {}; +/// @ingroup ir +struct IRConditional : IRType { + IRType condition; + IRType consequent; + IRType alternative; +}; + /// @ingroup ir struct IRReference : IRType { IRType target; }; /// @ingroup ir -using IREntity = - std::variant; +using IREntity = std::variant; /// @ingroup ir using IRResult = std::vector; diff --git a/src/ir/ir_default_compiler.h b/src/ir/ir_default_compiler.h index 6d186ac..99f30a9 100644 --- a/src/ir/ir_default_compiler.h +++ b/src/ir/ir_default_compiler.h @@ -612,6 +612,53 @@ auto handle_allof(const sourcemeta::core::JSON &schema, std::move(branches)}; } +auto handle_if_then_else( + const sourcemeta::core::JSON &schema, + const sourcemeta::core::SchemaFrame &frame, + const sourcemeta::core::SchemaFrame::Location &location, + const sourcemeta::core::Vocabularies &, + const sourcemeta::core::SchemaResolver &, + const sourcemeta::core::JSON &subschema) -> IREntity { + ONLY_WHITELIST_KEYWORDS(schema, subschema, location.pointer, + {"$schema", "$id", "$anchor", "$dynamicAnchor", + "$defs", "$vocabulary", "if", "then", "else", + "title", "description", "default", "deprecated", + "readOnly", "writeOnly", "examples", + "unevaluatedProperties", "unevaluatedItems"}); + + assert(subschema.defines("if")); + assert(subschema.defines("then")); + assert(subschema.defines("else")); + + auto if_pointer{sourcemeta::core::to_pointer(location.pointer)}; + if_pointer.push_back("if"); + const auto if_location{ + frame.traverse(sourcemeta::core::to_weak_pointer(if_pointer))}; + assert(if_location.has_value()); + + auto then_pointer{sourcemeta::core::to_pointer(location.pointer)}; + then_pointer.push_back("then"); + const auto then_location{ + frame.traverse(sourcemeta::core::to_weak_pointer(then_pointer))}; + assert(then_location.has_value()); + + auto else_pointer{sourcemeta::core::to_pointer(location.pointer)}; + else_pointer.push_back("else"); + const auto else_location{ + frame.traverse(sourcemeta::core::to_weak_pointer(else_pointer))}; + assert(else_location.has_value()); + + return IRConditional{ + {.pointer = sourcemeta::core::to_pointer(location.pointer), + .symbol = symbol(frame, location)}, + {.pointer = std::move(if_pointer), + .symbol = symbol(frame, if_location.value().get())}, + {.pointer = std::move(then_pointer), + .symbol = symbol(frame, then_location.value().get())}, + {.pointer = std::move(else_pointer), + .symbol = symbol(frame, else_location.value().get())}}; +} + auto default_compiler(const sourcemeta::core::JSON &schema, const sourcemeta::core::SchemaFrame &frame, const sourcemeta::core::SchemaFrame::Location &location, @@ -704,8 +751,8 @@ auto default_compiler(const sourcemeta::core::JSON &schema, return handle_ref(schema, frame, location, vocabularies, resolver, subschema); } else if (subschema.defines("if")) { - throw UnsupportedKeywordError(schema, location.pointer, "if", - "Unsupported keyword in subschema"); + return handle_if_then_else(schema, frame, location, vocabularies, resolver, + subschema); } else if (subschema.defines("not")) { throw UnsupportedKeywordError(schema, location.pointer, "not", "Unsupported keyword in subschema"); diff --git a/test/e2e/typescript/2020-12/if_then_else_objects/expected.d.ts b/test/e2e/typescript/2020-12/if_then_else_objects/expected.d.ts new file mode 100644 index 0000000..7ed8d4d --- /dev/null +++ b/test/e2e/typescript/2020-12/if_then_else_objects/expected.d.ts @@ -0,0 +1,25 @@ +export type ShapeThenRadius = number; + +export interface ShapeThen { + "radius": ShapeThenRadius; + [key: string]: unknown | undefined; +} + +export type ShapeIfKind = "circle"; + +export interface ShapeIf { + "kind": ShapeIfKind; + [key: string]: unknown | undefined; +} + +export type ShapeElseSides = number; + +export interface ShapeElse { + "sides": ShapeElseSides; + [key: string]: unknown | undefined; +} + +// (if & then) | else approximation: the else branch is wider than what +// JSON Schema allows, as TypeScript cannot express type negation +export type Shape = + (ShapeIf & ShapeThen) | ShapeElse; diff --git a/test/e2e/typescript/2020-12/if_then_else_objects/options.json b/test/e2e/typescript/2020-12/if_then_else_objects/options.json new file mode 100644 index 0000000..fb9189e --- /dev/null +++ b/test/e2e/typescript/2020-12/if_then_else_objects/options.json @@ -0,0 +1,3 @@ +{ + "defaultPrefix": "Shape" +} diff --git a/test/e2e/typescript/2020-12/if_then_else_objects/schema.json b/test/e2e/typescript/2020-12/if_then_else_objects/schema.json new file mode 100644 index 0000000..2cdec51 --- /dev/null +++ b/test/e2e/typescript/2020-12/if_then_else_objects/schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "if": { + "type": "object", + "properties": { "kind": { "const": "circle" } }, + "required": [ "kind" ] + }, + "then": { + "type": "object", + "properties": { "radius": { "type": "number" } }, + "required": [ "radius" ] + }, + "else": { + "type": "object", + "properties": { "sides": { "type": "integer" } }, + "required": [ "sides" ] + } +} diff --git a/test/e2e/typescript/2020-12/if_then_else_objects/test.ts b/test/e2e/typescript/2020-12/if_then_else_objects/test.ts new file mode 100644 index 0000000..cd4163e --- /dev/null +++ b/test/e2e/typescript/2020-12/if_then_else_objects/test.ts @@ -0,0 +1,31 @@ +import { Shape } from "./expected"; + +// Valid: satisfies the if condition (kind="circle") and then branch (radius) +const circle: Shape = { + kind: "circle", + radius: 5 +}; + +// Valid: does not satisfy the if condition, satisfies else branch (sides) +const polygon: Shape = { + sides: 6 +}; + +// Invalid: satisfies if (kind="circle") but missing then's required radius +// @ts-expect-error +const circleWithoutRadius: Shape = { + kind: "circle" +}; + +// Invalid: does not satisfy if, and missing else's required sides +// @ts-expect-error +const emptyObject: Shape = {}; + +// NOTE: This passes TypeScript but would fail JSON Schema validation. +// The if condition matches (kind is "circle"), so the then branch should +// apply (requiring radius). But our (If & Then) | Else approximation +// allows the else branch to also match when if holds. +const circleMatchingElse: Shape = { + kind: "circle", + sides: 4 +}; diff --git a/test/e2e/typescript/2020-12/if_then_else_validation_only/expected.d.ts b/test/e2e/typescript/2020-12/if_then_else_validation_only/expected.d.ts new file mode 100644 index 0000000..e14cb6b --- /dev/null +++ b/test/e2e/typescript/2020-12/if_then_else_validation_only/expected.d.ts @@ -0,0 +1,16 @@ +export type PatternString_1 = string; + +export type PatternString_0Then = string; + +export type PatternString_0If = string; + +export type PatternString_0Else = string; + +// (if & then) | else approximation: the else branch is wider than what +// JSON Schema allows, as TypeScript cannot express type negation +export type PatternString_0 = + (PatternString_0If & PatternString_0Then) | PatternString_0Else; + +export type PatternString = + PatternString_0 & + PatternString_1; diff --git a/test/e2e/typescript/2020-12/if_then_else_validation_only/options.json b/test/e2e/typescript/2020-12/if_then_else_validation_only/options.json new file mode 100644 index 0000000..7684a31 --- /dev/null +++ b/test/e2e/typescript/2020-12/if_then_else_validation_only/options.json @@ -0,0 +1,3 @@ +{ + "defaultPrefix": "PatternString" +} diff --git a/test/e2e/typescript/2020-12/if_then_else_validation_only/schema.json b/test/e2e/typescript/2020-12/if_then_else_validation_only/schema.json new file mode 100644 index 0000000..cec189d --- /dev/null +++ b/test/e2e/typescript/2020-12/if_then_else_validation_only/schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "if": { "type": "string", "maxLength": 10 }, + "then": { "type": "string", "pattern": "^short" }, + "else": { "type": "string", "pattern": "^long" } +} diff --git a/test/e2e/typescript/2020-12/if_then_else_validation_only/test.ts b/test/e2e/typescript/2020-12/if_then_else_validation_only/test.ts new file mode 100644 index 0000000..8ba5cf2 --- /dev/null +++ b/test/e2e/typescript/2020-12/if_then_else_validation_only/test.ts @@ -0,0 +1,14 @@ +import { PatternString } from "./expected"; + +// Valid: any string satisfies the type since all branches resolve to string +const short_value: PatternString = "short123"; +const long_value: PatternString = "longstringvalue"; +const empty_value: PatternString = ""; + +// Invalid: not a string +// @ts-expect-error +const number_value: PatternString = 42; + +// Invalid: not a string +// @ts-expect-error +const boolean_value: PatternString = true; diff --git a/test/ir/ir_2020_12_test.cc b/test/ir/ir_2020_12_test.cc index ff28137..000bcc5 100644 --- a/test/ir/ir_2020_12_test.cc +++ b/test/ir/ir_2020_12_test.cc @@ -1538,25 +1538,142 @@ TEST(IR_2020_12, allof_with_defs) { using namespace sourcemeta::codegen; EXPECT_IR_INTERSECTION(result, result.size() - 1, "", 2); + EXPECT_IR_REFERENCE(result, 0, "/allOf/1", "/$defs/Aged"); + EXPECT_IR_REFERENCE(result, 1, "/allOf/0", "/$defs/Named"); +} - // Both allOf branches should be references to their respective $defs - bool found_named{false}; - bool found_aged{false}; - for (const auto &entry : result) { - if (std::holds_alternative(entry)) { - const auto &reference{std::get(entry)}; - const auto pointer_string{sourcemeta::core::to_string(reference.pointer)}; - const auto target_string{ - sourcemeta::core::to_string(reference.target.pointer)}; - if (pointer_string == "/allOf/0" && target_string == "/$defs/Named") { - found_named = true; - } else if (pointer_string == "/allOf/1" && - target_string == "/$defs/Aged") { - found_aged = true; - } +TEST(IR_2020_12, if_then_else_distinct_object_branches) { + const sourcemeta::core::JSON schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "if": { + "type": "object", + "properties": { "kind": { "const": "circle" } }, + "required": [ "kind" ] + }, + "then": { + "type": "object", + "properties": { "radius": { "type": "number" } }, + "required": [ "radius" ] + }, + "else": { + "type": "object", + "properties": { "sides": { "type": "integer" } }, + "required": [ "sides" ] } - } + })JSON")}; + + const auto result{ + sourcemeta::codegen::compile(schema, sourcemeta::core::schema_walker, + sourcemeta::core::schema_resolver, + sourcemeta::codegen::default_compiler)}; + + using namespace sourcemeta::codegen; + + EXPECT_IR_CONDITIONAL(result, result.size() - 1, "", "/if", "/then", "/else"); +} + +TEST(IR_2020_12, if_then_else_implicit_else) { + const sourcemeta::core::JSON schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "if": { "type": "string" }, + "then": { "type": "string", "minLength": 1 } + })JSON")}; + + const auto result{ + sourcemeta::codegen::compile(schema, sourcemeta::core::schema_walker, + sourcemeta::core::schema_resolver, + sourcemeta::codegen::default_compiler)}; + + using namespace sourcemeta::codegen; + + EXPECT_IR_CONDITIONAL(result, result.size() - 1, "", "/if", "/then", "/else"); +} + +TEST(IR_2020_12, if_then_else_with_type_sibling) { + const sourcemeta::core::JSON schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "if": { "type": "string", "maxLength": 10 }, + "then": { "type": "string", "pattern": "^short" }, + "else": { "type": "string", "pattern": "^long" } + })JSON")}; + + const auto result{ + sourcemeta::codegen::compile(schema, sourcemeta::core::schema_walker, + sourcemeta::core::schema_resolver, + sourcemeta::codegen::default_compiler)}; + + using namespace sourcemeta::codegen; + + EXPECT_IR_CONDITIONAL(result, result.size() - 2, "/allOf/0", "/allOf/0/if", + "/allOf/0/then", "/allOf/0/else"); + EXPECT_IR_INTERSECTION(result, result.size() - 1, "", 2); +} - EXPECT_TRUE(found_named); - EXPECT_TRUE(found_aged); +TEST(IR_2020_12, if_then_else_with_ref_branches) { + const sourcemeta::core::JSON schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Circle": { + "type": "object", + "properties": { "radius": { "type": "number" } }, + "required": [ "radius" ], + "additionalProperties": false + }, + "Square": { + "type": "object", + "properties": { "side": { "type": "number" } }, + "required": [ "side" ], + "additionalProperties": false + } + }, + "if": { + "type": "object", + "properties": { "kind": { "const": "circle" } }, + "required": [ "kind" ] + }, + "then": { "$ref": "#/$defs/Circle" }, + "else": { "$ref": "#/$defs/Square" } + })JSON")}; + + const auto result{ + sourcemeta::codegen::compile(schema, sourcemeta::core::schema_walker, + sourcemeta::core::schema_resolver, + sourcemeta::codegen::default_compiler)}; + + using namespace sourcemeta::codegen; + + ASSERT_EQ(result.size(), 11); + EXPECT_IR_REFERENCE(result, 0, "/then", "/$defs/Circle"); + EXPECT_IR_REFERENCE(result, 3, "/else", "/$defs/Square"); + EXPECT_IR_CONDITIONAL(result, 10, "", "/if", "/then", "/else"); +} + +TEST(IR_2020_12, if_then_else_nested_in_object_property) { + const sourcemeta::core::JSON schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "value": { + "if": { "type": "string" }, + "then": { "type": "string", "minLength": 1 }, + "else": { "type": "integer" } + } + }, + "additionalProperties": false + })JSON")}; + + const auto result{ + sourcemeta::codegen::compile(schema, sourcemeta::core::schema_walker, + sourcemeta::core::schema_resolver, + sourcemeta::codegen::default_compiler)}; + + using namespace sourcemeta::codegen; + + ASSERT_EQ(result.size(), 6); + EXPECT_IR_SCALAR(result, 0, String, "/properties/value/then"); + EXPECT_IR_SCALAR(result, 1, String, "/properties/value/if"); + EXPECT_IR_SCALAR(result, 2, Integer, "/properties/value/else"); + EXPECT_IR_CONDITIONAL(result, 3, "/properties/value", "/properties/value/if", + "/properties/value/then", "/properties/value/else"); } diff --git a/test/ir/ir_test.cc b/test/ir/ir_test.cc index 302b78c..97b7852 100644 --- a/test/ir/ir_test.cc +++ b/test/ir/ir_test.cc @@ -16,12 +16,10 @@ TEST(IR, unsupported_dialect_draft3) { sourcemeta::core::SchemaVocabularyError); } -TEST(IR, unsupported_keyword_error) { +TEST(IR, unsupported_keyword_error_not) { const sourcemeta::core::JSON schema{sourcemeta::core::parse_json(R"JSON({ "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "string", - "if": { "minLength": 5 }, - "then": { "maxLength": 10 } + "not": { "type": "string" } })JSON")}; EXPECT_THROW( @@ -56,16 +54,3 @@ TEST(IR, unsupported_keyword_value_error_unknown_type) { sourcemeta::codegen::default_compiler), sourcemeta::codegen::UnsupportedKeywordValueError); } - -TEST(IR, unexpected_schema_error_unsupported_shape) { - const sourcemeta::core::JSON schema{sourcemeta::core::parse_json(R"JSON({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "not": { "type": "string" } - })JSON")}; - - EXPECT_THROW( - sourcemeta::codegen::compile(schema, sourcemeta::core::schema_walker, - sourcemeta::core::schema_resolver, - sourcemeta::codegen::default_compiler), - sourcemeta::codegen::UnsupportedKeywordError); -} diff --git a/test/ir/ir_test_utils.h b/test/ir/ir_test_utils.h index f3c6b58..fd54ee8 100644 --- a/test/ir/ir_test_utils.h +++ b/test/ir/ir_test_utils.h @@ -62,6 +62,27 @@ .values.size(), \ expected_count) +#define EXPECT_IR_CONDITIONAL(result, index, expected_pointer, expected_if, \ + expected_then, expected_else) \ + EXPECT_TRUE(std::holds_alternative( \ + result.at(index))) \ + << "Expected IRConditional at index " << index; \ + EXPECT_AS_STRING( \ + std::get(result.at(index)).pointer, \ + expected_pointer); \ + EXPECT_AS_STRING( \ + std::get(result.at(index)) \ + .condition.pointer, \ + expected_if); \ + EXPECT_AS_STRING( \ + std::get(result.at(index)) \ + .consequent.pointer, \ + expected_then); \ + EXPECT_AS_STRING( \ + std::get(result.at(index)) \ + .alternative.pointer, \ + expected_else) + #define EXPECT_IR_REFERENCE(result, index, expected_pointer, \ expected_target_pointer) \ EXPECT_TRUE(std::holds_alternative( \