diff --git a/README.markdown b/README.markdown index b348b79..b901291 100644 --- a/README.markdown +++ b/README.markdown @@ -32,7 +32,7 @@ to use a JSON Schema validator at runtime to enforce remaining constraints. | Applicator (2020-12) | `items` | Yes | | Applicator (2020-12) | `prefixItems` | Yes | | Applicator (2020-12) | `anyOf` | Yes | -| Applicator (2020-12) | `patternProperties` | **CANNOT SUPPORT** | +| Applicator (2020-12) | `patternProperties` | **PARTIAL** (Prefix patterns only) | | Applicator (2020-12) | `propertyNames` | Ignored | | Applicator (2020-12) | `dependentSchemas` | Pending | | Applicator (2020-12) | `contains` | Ignored | diff --git a/src/generator/typescript.cc b/src/generator/typescript.cc index 640dce9..2508f85 100644 --- a/src/generator/typescript.cc +++ b/src/generator/typescript.cc @@ -101,7 +101,7 @@ auto TypeScript::operator()(const IRObject &entry) -> void { std::holds_alternative(entry.additional) && std::get(entry.additional)}; - if (has_typed_additional && entry.members.empty()) { + if (has_typed_additional && entry.members.empty() && entry.pattern.empty()) { const auto &additional_type{std::get(entry.additional)}; this->output << "export type " << type_name << " = Recordprefix, additional_type.pointer, @@ -110,7 +110,7 @@ auto TypeScript::operator()(const IRObject &entry) -> void { return; } - if (allows_any_additional && entry.members.empty()) { + if (allows_any_additional && entry.members.empty() && entry.pattern.empty()) { this->output << "export type " << type_name << " = Record;\n"; return; @@ -136,6 +136,30 @@ auto TypeScript::operator()(const IRObject &entry) -> void { << ";\n"; } + for (const auto &pattern_property : entry.pattern) { + this->output << " [key: `" << pattern_property.prefix << "${string}`]: " + << mangle(this->prefix, pattern_property.pointer, + pattern_property.symbol, this->cache); + + // TypeScript requires that a more specific index signature type is + // assignable to any less specific one that overlaps it. When a prefix + // is a sub-prefix of another (i.e. "x-data-" starts with "x-"), + // intersect the types so the constraint is satisfied + for (const auto &other : entry.pattern) { + if (&other == &pattern_property) { + continue; + } + + if (pattern_property.prefix.starts_with(other.prefix)) { + this->output << " & " + << mangle(this->prefix, other.pointer, other.symbol, + this->cache); + } + } + + this->output << ";\n"; + } + if (allows_any_additional) { this->output << " [key: string]: unknown | undefined;\n"; } else if (has_typed_additional) { @@ -155,6 +179,13 @@ auto TypeScript::operator()(const IRObject &entry) -> void { << " |\n"; } + for (const auto &pattern_property : entry.pattern) { + this->output << " " + << mangle(this->prefix, pattern_property.pointer, + pattern_property.symbol, this->cache) + << " |\n"; + } + const auto &additional_type{std::get(entry.additional)}; this->output << " " << mangle(this->prefix, additional_type.pointer, diff --git a/src/ir/include/sourcemeta/codegen/ir.h b/src/ir/include/sourcemeta/codegen/ir.h index 70eedd8..f7902f2 100644 --- a/src/ir/include/sourcemeta/codegen/ir.h +++ b/src/ir/include/sourcemeta/codegen/ir.h @@ -62,11 +62,17 @@ struct IRObjectValue : IRType { bool immutable; }; +/// @ingroup ir +struct IRObjectPatternProperty : IRType { + std::string prefix; +}; + /// @ingroup ir struct IRObject : IRType { // To preserve the user's ordering std::vector> members; std::variant additional; + std::vector pattern; }; /// @ingroup ir diff --git a/src/ir/ir_default_compiler.h b/src/ir/ir_default_compiler.h index 83a39e6..cb836bb 100644 --- a/src/ir/ir_default_compiler.h +++ b/src/ir/ir_default_compiler.h @@ -4,6 +4,7 @@ #include #include +#include #include // assert #include // std::string_view @@ -77,7 +78,7 @@ auto handle_object(const sourcemeta::core::JSON &schema, // other properties. Therefore, we whitelist this, but we consider it to // be the responsability of the validator "additionalProperties", "minProperties", "maxProperties", - "propertyNames"}); + "propertyNames", "patternProperties"}); std::vector> members; @@ -133,10 +134,39 @@ auto handle_object(const sourcemeta::core::JSON &schema, } } + std::vector pattern; + if (subschema.defines("patternProperties")) { + const auto &pattern_props{subschema.at("patternProperties")}; + for (const auto &entry : pattern_props.as_object()) { + auto pattern_pointer{sourcemeta::core::to_pointer(location.pointer)}; + pattern_pointer.push_back("patternProperties"); + pattern_pointer.push_back(entry.first); + + const auto pattern_location{ + frame.traverse(sourcemeta::core::to_weak_pointer(pattern_pointer))}; + assert(pattern_location.has_value()); + + const auto regex{sourcemeta::core::to_regex(entry.first)}; + if (!regex.has_value() || + !std::holds_alternative( + regex.value())) { + throw UnsupportedKeywordValueError( + schema, location.pointer, "patternProperties", + "Only prefix patterns are supported"); + } + + pattern.push_back(IRObjectPatternProperty{ + {.pointer = std::move(pattern_pointer), + .symbol = symbol(frame, pattern_location.value().get())}, + std::get(regex.value())}); + } + } + return IRObject{{.pointer = sourcemeta::core::to_pointer(location.pointer), .symbol = symbol(frame, location)}, std::move(members), - std::move(additional)}; + std::move(additional), + std::move(pattern)}; } auto handle_integer(const sourcemeta::core::JSON &schema, diff --git a/test/e2e/typescript/2020-12/pattern_properties_additional_false/expected.d.ts b/test/e2e/typescript/2020-12/pattern_properties_additional_false/expected.d.ts new file mode 100644 index 0000000..ca2c39e --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_additional_false/expected.d.ts @@ -0,0 +1,10 @@ +export type StrictExtName = string; + +export type StrictExtX = string; + +export type StrictExtAdditionalProperties = never; + +export interface StrictExt { + "name"?: StrictExtName; + [key: `x-${string}`]: StrictExtX; +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_additional_false/options.json b/test/e2e/typescript/2020-12/pattern_properties_additional_false/options.json new file mode 100644 index 0000000..07e5347 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_additional_false/options.json @@ -0,0 +1,3 @@ +{ + "defaultPrefix": "StrictExt" +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_additional_false/schema.json b/test/e2e/typescript/2020-12/pattern_properties_additional_false/schema.json new file mode 100644 index 0000000..7ac5ec5 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_additional_false/schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "patternProperties": { + "^x-": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_additional_false/test.ts b/test/e2e/typescript/2020-12/pattern_properties_additional_false/test.ts new file mode 100644 index 0000000..a021119 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_additional_false/test.ts @@ -0,0 +1,24 @@ +import { StrictExt } from "./expected"; + +const test1: StrictExt = { + name: "hello" +}; + +const test2: StrictExt = { + name: "hello", + "x-custom": "value" +}; + +// Wrong type for pattern property +const test3: StrictExt = { + name: "hello", + // @ts-expect-error + "x-custom": 42 +}; + +// Non-matching key should be rejected (additionalProperties: false) +const test4: StrictExt = { + name: "hello", + // @ts-expect-error + other: "value" +}; diff --git a/test/e2e/typescript/2020-12/pattern_properties_additional_true/expected.d.ts b/test/e2e/typescript/2020-12/pattern_properties_additional_true/expected.d.ts new file mode 100644 index 0000000..f421c62 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_additional_true/expected.d.ts @@ -0,0 +1,11 @@ +export type OpenExtName = string; + +export type OpenExtX = string; + +export type OpenExtAdditionalProperties = unknown; + +export interface OpenExt { + "name"?: OpenExtName; + [key: `x-${string}`]: OpenExtX; + [key: string]: unknown | undefined; +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_additional_true/options.json b/test/e2e/typescript/2020-12/pattern_properties_additional_true/options.json new file mode 100644 index 0000000..3735e21 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_additional_true/options.json @@ -0,0 +1,3 @@ +{ + "defaultPrefix": "OpenExt" +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_additional_true/schema.json b/test/e2e/typescript/2020-12/pattern_properties_additional_true/schema.json new file mode 100644 index 0000000..c6dc108 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_additional_true/schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "patternProperties": { + "^x-": { "type": "string" } + }, + "additionalProperties": true +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_additional_true/test.ts b/test/e2e/typescript/2020-12/pattern_properties_additional_true/test.ts new file mode 100644 index 0000000..2474fb4 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_additional_true/test.ts @@ -0,0 +1,13 @@ +import { OpenExt } from "./expected"; + +const test1: OpenExt = { + name: "hello", + "x-custom": "value", + other: 42 +}; + +const test2: OpenExt = { + name: "hello", + // @ts-expect-error + "x-custom": 123 +}; diff --git a/test/e2e/typescript/2020-12/pattern_properties_multiple_prefixes/expected.d.ts b/test/e2e/typescript/2020-12/pattern_properties_multiple_prefixes/expected.d.ts new file mode 100644 index 0000000..bc82de5 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_multiple_prefixes/expected.d.ts @@ -0,0 +1,9 @@ +export type MultiPatternX = string; + +export type MultiPatternData = number; + +export interface MultiPattern { + [key: `x-${string}`]: MultiPatternX; + [key: `data-${string}`]: MultiPatternData; + [key: string]: unknown | undefined; +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_multiple_prefixes/options.json b/test/e2e/typescript/2020-12/pattern_properties_multiple_prefixes/options.json new file mode 100644 index 0000000..d607006 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_multiple_prefixes/options.json @@ -0,0 +1,3 @@ +{ + "defaultPrefix": "MultiPattern" +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_multiple_prefixes/schema.json b/test/e2e/typescript/2020-12/pattern_properties_multiple_prefixes/schema.json new file mode 100644 index 0000000..3adde06 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_multiple_prefixes/schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "patternProperties": { + "^x-": { "type": "string" }, + "^data-": { "type": "integer" } + } +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_multiple_prefixes/test.ts b/test/e2e/typescript/2020-12/pattern_properties_multiple_prefixes/test.ts new file mode 100644 index 0000000..1b52fbc --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_multiple_prefixes/test.ts @@ -0,0 +1,24 @@ +import { MultiPattern } from "./expected"; + +const test1: MultiPattern = { + "x-foo": "hello" +}; + +const test2: MultiPattern = { + "data-id": 42 +}; + +const test3: MultiPattern = { + "x-foo": "hello", + "data-id": 42 +}; + +const test4: MultiPattern = { + // @ts-expect-error + "x-foo": 123 +}; + +const test5: MultiPattern = { + // @ts-expect-error + "data-id": "not a number" +}; diff --git a/test/e2e/typescript/2020-12/pattern_properties_overlapping_prefixes/expected.d.ts b/test/e2e/typescript/2020-12/pattern_properties_overlapping_prefixes/expected.d.ts new file mode 100644 index 0000000..358de60 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_overlapping_prefixes/expected.d.ts @@ -0,0 +1,10 @@ +export type OverlapXdata = number; + +export type OverlapX = string; + +export type OverlapAdditionalProperties = never; + +export interface Overlap { + [key: `x-${string}`]: OverlapX; + [key: `x-data-${string}`]: OverlapXdata & OverlapX; +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_overlapping_prefixes/options.json b/test/e2e/typescript/2020-12/pattern_properties_overlapping_prefixes/options.json new file mode 100644 index 0000000..4dc5275 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_overlapping_prefixes/options.json @@ -0,0 +1,3 @@ +{ + "defaultPrefix": "Overlap" +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_overlapping_prefixes/schema.json b/test/e2e/typescript/2020-12/pattern_properties_overlapping_prefixes/schema.json new file mode 100644 index 0000000..5bd6b67 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_overlapping_prefixes/schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "patternProperties": { + "^x-": { "type": "string" }, + "^x-data-": { "type": "integer" } + }, + "additionalProperties": false +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_overlapping_prefixes/test.ts b/test/e2e/typescript/2020-12/pattern_properties_overlapping_prefixes/test.ts new file mode 100644 index 0000000..e8c7899 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_overlapping_prefixes/test.ts @@ -0,0 +1,18 @@ +import { Overlap } from "./expected"; + +// Non-overlapping: only matches ^x- +const test1: Overlap = { + "x-foo": "hello" +}; + +// Overlapping: matches both ^x- (string) and ^x-data- (number), +// so no value can satisfy both, which is correct per JSON Schema +const test2: Overlap = { + // @ts-expect-error + "x-data-id": 42 +}; + +const test3: Overlap = { + // @ts-expect-error + "x-data-id": "hello" +}; diff --git a/test/e2e/typescript/2020-12/pattern_properties_overlapping_same_type/expected.d.ts b/test/e2e/typescript/2020-12/pattern_properties_overlapping_same_type/expected.d.ts new file mode 100644 index 0000000..a6985fc --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_overlapping_same_type/expected.d.ts @@ -0,0 +1,10 @@ +export type SameTypeXdata = string; + +export type SameTypeX = string; + +export type SameTypeAdditionalProperties = never; + +export interface SameType { + [key: `x-${string}`]: SameTypeX; + [key: `x-data-${string}`]: SameTypeXdata & SameTypeX; +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_overlapping_same_type/options.json b/test/e2e/typescript/2020-12/pattern_properties_overlapping_same_type/options.json new file mode 100644 index 0000000..d5d5e46 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_overlapping_same_type/options.json @@ -0,0 +1,3 @@ +{ + "defaultPrefix": "SameType" +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_overlapping_same_type/schema.json b/test/e2e/typescript/2020-12/pattern_properties_overlapping_same_type/schema.json new file mode 100644 index 0000000..200f5ca --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_overlapping_same_type/schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "patternProperties": { + "^x-": { "type": "string" }, + "^x-data-": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_overlapping_same_type/test.ts b/test/e2e/typescript/2020-12/pattern_properties_overlapping_same_type/test.ts new file mode 100644 index 0000000..87b70e9 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_overlapping_same_type/test.ts @@ -0,0 +1,29 @@ +import { SameType } from "./expected"; + +// x-data-* satisfies both ^x- and ^x-data-, both string, so string works +const test1: SameType = { + "x-data-id": "hello" +}; + +// Non-overlapping x- key +const test2: SameType = { + "x-foo": "world" +}; + +// Both together +const test3: SameType = { + "x-foo": "hello", + "x-data-id": "world" +}; + +// Wrong type on overlapping key +const test4: SameType = { + // @ts-expect-error + "x-data-id": 42 +}; + +// Wrong type on non-overlapping key +const test5: SameType = { + // @ts-expect-error + "x-foo": 42 +}; diff --git a/test/e2e/typescript/2020-12/pattern_properties_partial_overlap/expected.d.ts b/test/e2e/typescript/2020-12/pattern_properties_partial_overlap/expected.d.ts new file mode 100644 index 0000000..ab9ed50 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_partial_overlap/expected.d.ts @@ -0,0 +1,16 @@ +export type PartialId = number; + +export type PartialY = number; + +export type PartialXinternal = boolean; + +export type PartialX = string; + +export type PartialAdditionalProperties = never; + +export interface Partial { + "id": PartialId; + [key: `x-${string}`]: PartialX; + [key: `x-internal-${string}`]: PartialXinternal & PartialX; + [key: `y-${string}`]: PartialY; +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_partial_overlap/options.json b/test/e2e/typescript/2020-12/pattern_properties_partial_overlap/options.json new file mode 100644 index 0000000..bd94f09 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_partial_overlap/options.json @@ -0,0 +1,3 @@ +{ + "defaultPrefix": "Partial" +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_partial_overlap/schema.json b/test/e2e/typescript/2020-12/pattern_properties_partial_overlap/schema.json new file mode 100644 index 0000000..f228d38 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_partial_overlap/schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { "type": "integer" } + }, + "required": [ "id" ], + "patternProperties": { + "^x-": { "type": "string" }, + "^x-internal-": { "type": "boolean" }, + "^y-": { "type": "integer" } + }, + "additionalProperties": false +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_partial_overlap/test.ts b/test/e2e/typescript/2020-12/pattern_properties_partial_overlap/test.ts new file mode 100644 index 0000000..e6d4189 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_partial_overlap/test.ts @@ -0,0 +1,53 @@ +import { Partial } from "./expected"; + +// Named property only +const test1: Partial = { + id: 1 +}; + +// Non-overlapping x- prefix (string) +const test2: Partial = { + id: 1, + "x-foo": "hello" +}; + +// Non-overlapping y- prefix (integer) +const test3: Partial = { + id: 1, + "y-count": 42 +}; + +// Overlapping x-internal- matches both ^x- and ^x-internal-, +// boolean & string = never, so no value satisfies both +const test4: Partial = { + id: 1, + // @ts-expect-error + "x-internal-debug": true +}; + +const test5: Partial = { + id: 1, + // @ts-expect-error + "x-internal-debug": "hello" +}; + +// Wrong type for x- prefix +const test6: Partial = { + id: 1, + // @ts-expect-error + "x-foo": 42 +}; + +// Wrong type for y- prefix +const test7: Partial = { + id: 1, + // @ts-expect-error + "y-count": "not a number" +}; + +// Non-matching key rejected (additionalProperties: false) +const test8: Partial = { + id: 1, + // @ts-expect-error + other: "value" +}; diff --git a/test/e2e/typescript/2020-12/pattern_properties_prefix_only/expected.d.ts b/test/e2e/typescript/2020-12/pattern_properties_prefix_only/expected.d.ts new file mode 100644 index 0000000..6006b38 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_prefix_only/expected.d.ts @@ -0,0 +1,6 @@ +export type SchemaX = string; + +export interface Schema { + [key: `x-${string}`]: SchemaX; + [key: string]: unknown | undefined; +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_prefix_only/options.json b/test/e2e/typescript/2020-12/pattern_properties_prefix_only/options.json new file mode 100644 index 0000000..ece7910 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_prefix_only/options.json @@ -0,0 +1,3 @@ +{ + "defaultPrefix": "Schema" +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_prefix_only/schema.json b/test/e2e/typescript/2020-12/pattern_properties_prefix_only/schema.json new file mode 100644 index 0000000..e92d03d --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_prefix_only/schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "patternProperties": { + "^x-": { "type": "string" } + } +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_prefix_only/test.ts b/test/e2e/typescript/2020-12/pattern_properties_prefix_only/test.ts new file mode 100644 index 0000000..1d6787a --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_prefix_only/test.ts @@ -0,0 +1,20 @@ +import { Schema } from "./expected"; + +const test1: Schema = { + "x-foo": "hello" +}; + +const test2: Schema = { + "x-foo": "hello", + "x-bar": "world" +}; + +const test3: Schema = { + "x-foo": "hello", + other: "value" +}; + +const test4: Schema = { + // @ts-expect-error + "x-foo": 123 +}; diff --git a/test/e2e/typescript/2020-12/pattern_properties_typed_additional/expected.d.ts b/test/e2e/typescript/2020-12/pattern_properties_typed_additional/expected.d.ts new file mode 100644 index 0000000..4396f80 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_typed_additional/expected.d.ts @@ -0,0 +1,18 @@ +export type MixedName = string; + +export type MixedX = number; + +export type MixedAdditionalProperties = boolean; + +export interface Mixed { + "name"?: MixedName; + [key: `x-${string}`]: MixedX; + [key: string]: + // As a notable limitation, TypeScript requires index signatures + // to also include the types of all of its properties, so we must + // match a superset of what JSON Schema allows + MixedName | + MixedX | + MixedAdditionalProperties | + undefined; +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_typed_additional/options.json b/test/e2e/typescript/2020-12/pattern_properties_typed_additional/options.json new file mode 100644 index 0000000..e221531 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_typed_additional/options.json @@ -0,0 +1,3 @@ +{ + "defaultPrefix": "Mixed" +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_typed_additional/schema.json b/test/e2e/typescript/2020-12/pattern_properties_typed_additional/schema.json new file mode 100644 index 0000000..9a35d6e --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_typed_additional/schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "patternProperties": { + "^x-": { "type": "integer" } + }, + "additionalProperties": { "type": "boolean" } +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_typed_additional/test.ts b/test/e2e/typescript/2020-12/pattern_properties_typed_additional/test.ts new file mode 100644 index 0000000..dfa9bfb --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_typed_additional/test.ts @@ -0,0 +1,30 @@ +import { Mixed } from "./expected"; + +const test1: Mixed = { + name: "hello", + "x-count": 42, + extra: true +}; + +// Wrong type on pattern property (string instead of number) +const test2: Mixed = { + name: "hello", + // @ts-expect-error + "x-count": "not a number" +}; + +// Template literal signature takes priority over the permissive +// [key: string] union, so boolean is rejected even though the +// union includes boolean +const test3: Mixed = { + name: "hello", + // @ts-expect-error + "x-count": true +}; + +// Additional property with wrong type (array is not in the union) +const test4: Mixed = { + name: "hello", + // @ts-expect-error + extra: [ 1, 2, 3 ] +}; diff --git a/test/e2e/typescript/2020-12/pattern_properties_with_properties/expected.d.ts b/test/e2e/typescript/2020-12/pattern_properties_with_properties/expected.d.ts new file mode 100644 index 0000000..9f3fad1 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_with_properties/expected.d.ts @@ -0,0 +1,9 @@ +export type ExtensibleName = string; + +export type ExtensibleX = string; + +export interface Extensible { + "name": ExtensibleName; + [key: `x-${string}`]: ExtensibleX; + [key: string]: unknown | undefined; +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_with_properties/options.json b/test/e2e/typescript/2020-12/pattern_properties_with_properties/options.json new file mode 100644 index 0000000..1ee0865 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_with_properties/options.json @@ -0,0 +1,3 @@ +{ + "defaultPrefix": "Extensible" +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_with_properties/schema.json b/test/e2e/typescript/2020-12/pattern_properties_with_properties/schema.json new file mode 100644 index 0000000..542ebd4 --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_with_properties/schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "required": [ "name" ], + "patternProperties": { + "^x-": { "type": "string" } + } +} diff --git a/test/e2e/typescript/2020-12/pattern_properties_with_properties/test.ts b/test/e2e/typescript/2020-12/pattern_properties_with_properties/test.ts new file mode 100644 index 0000000..bcaaf4f --- /dev/null +++ b/test/e2e/typescript/2020-12/pattern_properties_with_properties/test.ts @@ -0,0 +1,27 @@ +import { Extensible } from "./expected"; + +const test1: Extensible = { + name: "hello" +}; + +const test2: Extensible = { + name: "hello", + "x-custom": "value" +}; + +const test3: Extensible = { + // @ts-expect-error + name: 123 +}; + +const test4: Extensible = { + name: "hello", + // @ts-expect-error + "x-custom": 42 +}; + +// Missing required property +// @ts-expect-error +const test5: Extensible = { + "x-custom": "value" +}; diff --git a/test/ir/ir_2020_12_test.cc b/test/ir/ir_2020_12_test.cc index f478e3d..50227f2 100644 --- a/test/ir/ir_2020_12_test.cc +++ b/test/ir/ir_2020_12_test.cc @@ -1163,3 +1163,114 @@ TEST(IR_2020_12, boolean_false_schema) { std::get(result.at(1)).additional)); EXPECT_TRUE(std::get(std::get(result.at(1)).additional)); } + +TEST(IR_2020_12, object_with_pattern_properties_prefix) { + const sourcemeta::core::JSON schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "patternProperties": { + "^x-": { "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; + + EXPECT_EQ(result.size(), 3); + + EXPECT_IR_SCALAR(result, 0, String, "/properties/name"); + EXPECT_SYMBOL(std::get(result.at(0)).symbol, "name"); + + EXPECT_IR_SCALAR(result, 1, String, "/patternProperties/^x-"); + + EXPECT_TRUE(std::holds_alternative(result.at(2))); + const auto &object{std::get(result.at(2))}; + EXPECT_AS_STRING(object.pointer, ""); + EXPECT_EQ(object.members.size(), 1); + EXPECT_EQ(object.members.at(0).first, "name"); + + EXPECT_EQ(object.pattern.size(), 1); + EXPECT_AS_STRING(object.pattern.at(0).pointer, "/patternProperties/^x-"); + EXPECT_EQ(object.pattern.at(0).prefix, "x-"); + + EXPECT_TRUE(std::holds_alternative(object.additional)); + EXPECT_TRUE(std::get(object.additional)); +} + +TEST(IR_2020_12, object_with_multiple_pattern_properties) { + const sourcemeta::core::JSON schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "patternProperties": { + "^x-": { "type": "string" }, + "^data-": { "type": "integer" } + } + })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_FALSE(result.empty()); + EXPECT_TRUE(std::holds_alternative(result.back())); + const auto &object{std::get(result.back())}; + EXPECT_EQ(object.pattern.size(), 2); + EXPECT_EQ(object.pattern.at(0).prefix, "x-"); + EXPECT_EQ(object.pattern.at(1).prefix, "data-"); +} + +TEST(IR_2020_12, object_with_pattern_properties_and_additional_false) { + const sourcemeta::core::JSON schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "patternProperties": { + "^x-": { "type": "string" } + }, + "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_FALSE(result.empty()); + EXPECT_TRUE(std::holds_alternative(result.back())); + const auto &object{std::get(result.back())}; + EXPECT_EQ(object.members.size(), 1); + EXPECT_EQ(object.pattern.size(), 1); + EXPECT_EQ(object.pattern.at(0).prefix, "x-"); + EXPECT_TRUE(std::holds_alternative(object.additional)); + EXPECT_FALSE(std::get(object.additional)); +} + +TEST(IR_2020_12, object_with_non_prefix_pattern_properties_throws) { + const sourcemeta::core::JSON schema{sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "patternProperties": { + "[a-z]+_id": { "type": "integer" } + } + })JSON")}; + + EXPECT_THROW( + sourcemeta::codegen::compile(schema, sourcemeta::core::schema_walker, + sourcemeta::core::schema_resolver, + sourcemeta::codegen::default_compiler), + sourcemeta::codegen::UnsupportedKeywordValueError); +}