diff --git a/README.markdown b/README.markdown index f1c3f53..102317c 100644 --- a/README.markdown +++ b/README.markdown @@ -36,7 +36,7 @@ to use a JSON Schema validator at runtime to enforce remaining constraints. | Applicator (2020-12) | `propertyNames` | Ignored | | Applicator (2020-12) | `dependentSchemas` | Pending | | Applicator (2020-12) | `contains` | Ignored | -| Applicator (2020-12) | `allOf` | Pending | +| Applicator (2020-12) | `allOf` | Yes | | Applicator (2020-12) | `oneOf` | **PARTIAL GIVEN LANGUAGE LIMITATIONS** | | Applicator (2020-12) | `not` | **CANNOT SUPPORT** | | Applicator (2020-12) | `if` | Pending | diff --git a/test/e2e/typescript/2020-12/allof_intersection/expected.d.ts b/test/e2e/typescript/2020-12/allof_intersection/expected.d.ts new file mode 100644 index 0000000..6efc9ac --- /dev/null +++ b/test/e2e/typescript/2020-12/allof_intersection/expected.d.ts @@ -0,0 +1,19 @@ +export type AllOfIntersection_1Age = number; + +export type AllOfIntersection_1AdditionalProperties = never; + +export interface AllOfIntersection_1 { + "age": AllOfIntersection_1Age; +} + +export type AllOfIntersection_0Name = string; + +export type AllOfIntersection_0AdditionalProperties = never; + +export interface AllOfIntersection_0 { + "name": AllOfIntersection_0Name; +} + +export type AllOfIntersection = + AllOfIntersection_0 & + AllOfIntersection_1; diff --git a/test/e2e/typescript/2020-12/allof_intersection/options.json b/test/e2e/typescript/2020-12/allof_intersection/options.json new file mode 100644 index 0000000..dcaa629 --- /dev/null +++ b/test/e2e/typescript/2020-12/allof_intersection/options.json @@ -0,0 +1,3 @@ +{ + "defaultPrefix": "AllOfIntersection" +} diff --git a/test/e2e/typescript/2020-12/allof_intersection/schema.json b/test/e2e/typescript/2020-12/allof_intersection/schema.json new file mode 100644 index 0000000..60be500 --- /dev/null +++ b/test/e2e/typescript/2020-12/allof_intersection/schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "required": [ "name" ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "age": { "type": "integer" } + }, + "required": [ "age" ], + "additionalProperties": false + } + ] +} diff --git a/test/e2e/typescript/2020-12/allof_intersection/test.ts b/test/e2e/typescript/2020-12/allof_intersection/test.ts new file mode 100644 index 0000000..af2d8f4 --- /dev/null +++ b/test/e2e/typescript/2020-12/allof_intersection/test.ts @@ -0,0 +1,19 @@ +import { AllOfIntersection } from "./expected"; + +// Valid: satisfies both branches +const valid: AllOfIntersection = { + name: "Alice", + age: 30 +}; + +// Invalid: missing age from second branch +// @ts-expect-error +const missingAge: AllOfIntersection = { + name: "Bob" +}; + +// Invalid: missing name from first branch +// @ts-expect-error +const missingName: AllOfIntersection = { + age: 25 +}; diff --git a/test/e2e/typescript/2020-12/allof_refs/expected.d.ts b/test/e2e/typescript/2020-12/allof_refs/expected.d.ts new file mode 100644 index 0000000..182fb3a --- /dev/null +++ b/test/e2e/typescript/2020-12/allof_refs/expected.d.ts @@ -0,0 +1,23 @@ +export type Person_1 = PersonAged; + +export type Person_0 = PersonNamed; + +export type PersonNamedName = string; + +export type PersonNamedAdditionalProperties = never; + +export interface PersonNamed { + "name": PersonNamedName; +} + +export type PersonAgedAge = number; + +export type PersonAgedAdditionalProperties = never; + +export interface PersonAged { + "age": PersonAgedAge; +} + +export type Person = + Person_0 & + Person_1; diff --git a/test/e2e/typescript/2020-12/allof_refs/options.json b/test/e2e/typescript/2020-12/allof_refs/options.json new file mode 100644 index 0000000..8d69d11 --- /dev/null +++ b/test/e2e/typescript/2020-12/allof_refs/options.json @@ -0,0 +1,3 @@ +{ + "defaultPrefix": "Person" +} diff --git a/test/e2e/typescript/2020-12/allof_refs/schema.json b/test/e2e/typescript/2020-12/allof_refs/schema.json new file mode 100644 index 0000000..d87b63e --- /dev/null +++ b/test/e2e/typescript/2020-12/allof_refs/schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Named": { + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "required": [ "name" ], + "additionalProperties": false + }, + "Aged": { + "type": "object", + "properties": { + "age": { "type": "integer" } + }, + "required": [ "age" ], + "additionalProperties": false + } + }, + "allOf": [ + { "$ref": "#/$defs/Named" }, + { "$ref": "#/$defs/Aged" } + ] +} diff --git a/test/e2e/typescript/2020-12/allof_refs/test.ts b/test/e2e/typescript/2020-12/allof_refs/test.ts new file mode 100644 index 0000000..bdb7934 --- /dev/null +++ b/test/e2e/typescript/2020-12/allof_refs/test.ts @@ -0,0 +1,19 @@ +import { Person } from "./expected"; + +// Valid: satisfies both $ref branches +const valid: Person = { + name: "Alice", + age: 30 +}; + +// Invalid: missing age +// @ts-expect-error +const missingAge: Person = { + name: "Bob" +}; + +// Invalid: missing name +// @ts-expect-error +const missingName: Person = { + age: 25 +}; diff --git a/test/e2e/typescript/2020-12/allof_single_element/expected.d.ts b/test/e2e/typescript/2020-12/allof_single_element/expected.d.ts new file mode 100644 index 0000000..d732e0a --- /dev/null +++ b/test/e2e/typescript/2020-12/allof_single_element/expected.d.ts @@ -0,0 +1,9 @@ +export type Wrapper_0Value = string; + +export type Wrapper_0AdditionalProperties = never; + +export interface Wrapper_0 { + "value": Wrapper_0Value; +} + +export type Wrapper = Wrapper_0; diff --git a/test/e2e/typescript/2020-12/allof_single_element/options.json b/test/e2e/typescript/2020-12/allof_single_element/options.json new file mode 100644 index 0000000..b339e67 --- /dev/null +++ b/test/e2e/typescript/2020-12/allof_single_element/options.json @@ -0,0 +1,3 @@ +{ + "defaultPrefix": "Wrapper" +} diff --git a/test/e2e/typescript/2020-12/allof_single_element/schema.json b/test/e2e/typescript/2020-12/allof_single_element/schema.json new file mode 100644 index 0000000..118d1ff --- /dev/null +++ b/test/e2e/typescript/2020-12/allof_single_element/schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "type": "object", + "properties": { + "value": { "type": "string" } + }, + "required": [ "value" ], + "additionalProperties": false + } + ] +} diff --git a/test/e2e/typescript/2020-12/allof_single_element/test.ts b/test/e2e/typescript/2020-12/allof_single_element/test.ts new file mode 100644 index 0000000..fbe5a60 --- /dev/null +++ b/test/e2e/typescript/2020-12/allof_single_element/test.ts @@ -0,0 +1,10 @@ +import { Wrapper } from "./expected"; + +// Valid: single-element allOf acts as the element itself +const valid: Wrapper = { + value: "hello" +}; + +// Invalid: missing required value +// @ts-expect-error +const missingValue: Wrapper = {}; diff --git a/test/ir/ir_2020_12_test.cc b/test/ir/ir_2020_12_test.cc index 2d7101b..ff28137 100644 --- a/test/ir/ir_2020_12_test.cc +++ b/test/ir/ir_2020_12_test.cc @@ -1393,3 +1393,170 @@ TEST(IR_2020_12, dynamic_anchor_on_typed_schema) { EXPECT_EQ(result.size(), 1); EXPECT_IR_SCALAR(result, 0, String, ""); } + +TEST(IR_2020_12, allof_two_objects) { + const sourcemeta::core::JSON schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": [ "name" ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { "age": { "type": "integer" } }, + "required": [ "age" ], + "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(), 7); + EXPECT_IR_INTERSECTION(result, 6, "", 2); +} + +TEST(IR_2020_12, allof_ref_and_object) { + const sourcemeta::core::JSON schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { "$ref": "#/$defs/Base" }, + { + "type": "object", + "properties": { "extra": { "type": "string" } }, + "additionalProperties": false + } + ], + "$defs": { + "Base": { + "type": "object", + "properties": { "id": { "type": "integer" } }, + "required": [ "id" ], + "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; + + EXPECT_IR_INTERSECTION(result, result.size() - 1, "", 2); +} + +TEST(IR_2020_12, allof_single_element) { + const sourcemeta::core::JSON schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { "type": "string" } + ] + })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(), 2); + EXPECT_IR_SCALAR(result, 0, String, "/allOf/0"); + EXPECT_IR_REFERENCE(result, 1, "", "/allOf/0"); +} + +TEST(IR_2020_12, allof_three_branches) { + const sourcemeta::core::JSON schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "type": "object", + "properties": { "a": { "type": "string" } }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { "b": { "type": "integer" } }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { "c": { "type": "number" } }, + "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; + + EXPECT_IR_INTERSECTION(result, result.size() - 1, "", 3); +} + +TEST(IR_2020_12, allof_with_defs) { + const sourcemeta::core::JSON schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Named": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": [ "name" ], + "additionalProperties": false + }, + "Aged": { + "type": "object", + "properties": { "age": { "type": "integer" } }, + "required": [ "age" ], + "additionalProperties": false + } + }, + "allOf": [ + { "$ref": "#/$defs/Named" }, + { "$ref": "#/$defs/Aged" } + ] + })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_INTERSECTION(result, result.size() - 1, "", 2); + + // 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; + } + } + } + + EXPECT_TRUE(found_named); + EXPECT_TRUE(found_aged); +} diff --git a/test/ir/ir_test_utils.h b/test/ir/ir_test_utils.h index df813f0..f3c6b58 100644 --- a/test/ir/ir_test_utils.h +++ b/test/ir/ir_test_utils.h @@ -50,6 +50,18 @@ std::get(result.at(index)).items->pointer, \ expected_items_pointer) +#define EXPECT_IR_INTERSECTION(result, index, expected_pointer, \ + expected_count) \ + EXPECT_TRUE(std::holds_alternative( \ + result.at(index))) \ + << "Expected IRIntersection at index " << index; \ + EXPECT_AS_STRING( \ + std::get(result.at(index)).pointer, \ + expected_pointer); \ + EXPECT_EQ(std::get(result.at(index)) \ + .values.size(), \ + expected_count) + #define EXPECT_IR_REFERENCE(result, index, expected_pointer, \ expected_target_pointer) \ EXPECT_TRUE(std::holds_alternative( \