Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
35 changes: 33 additions & 2 deletions src/generator/typescript.cc
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ auto TypeScript::operator()(const IRObject &entry) -> void {
std::holds_alternative<bool>(entry.additional) &&
std::get<bool>(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<IRType>(entry.additional)};
this->output << "export type " << type_name << " = Record<string, "
<< mangle(this->prefix, additional_type.pointer,
Expand All @@ -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<string, unknown>;\n";
return;
Expand All @@ -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) {
Expand All @@ -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<IRType>(entry.additional)};
this->output << " "
<< mangle(this->prefix, additional_type.pointer,
Expand Down
6 changes: 6 additions & 0 deletions src/ir/include/sourcemeta/codegen/ir.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::pair<sourcemeta::core::JSON::String, IRObjectValue>> members;
std::variant<bool, IRType> additional;
std::vector<IRObjectPatternProperty> pattern;
};

/// @ingroup ir
Expand Down
34 changes: 32 additions & 2 deletions src/ir/ir_default_compiler.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <sourcemeta/codegen/ir.h>

#include <sourcemeta/core/jsonschema.h>
#include <sourcemeta/core/regex.h>

#include <cassert> // assert
#include <string_view> // std::string_view
Expand Down Expand Up @@ -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<std::pair<sourcemeta::core::JSON::String, IRObjectValue>> members;

Expand Down Expand Up @@ -133,10 +134,39 @@ auto handle_object(const sourcemeta::core::JSON &schema,
}
}

std::vector<IRObjectPatternProperty> 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<sourcemeta::core::RegexTypePrefix>(
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<sourcemeta::core::RegexTypePrefix>(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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"defaultPrefix": "StrictExt"
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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"
};
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"defaultPrefix": "OpenExt"
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
};
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"defaultPrefix": "MultiPattern"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"patternProperties": {
"^x-": { "type": "string" },
"^data-": { "type": "integer" }
}
}
Original file line number Diff line number Diff line change
@@ -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"
};
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"defaultPrefix": "Overlap"
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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"
};
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"defaultPrefix": "SameType"
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
};
Loading
Loading