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` | **PARTIAL** (Prefix patterns only) |
| Applicator (2020-12) | `patternProperties` | **PARTIAL GIVEN LANGUAGE LIMITATIONS** |
| Applicator (2020-12) | `propertyNames` | Ignored |
| Applicator (2020-12) | `dependentSchemas` | Pending |
| Applicator (2020-12) | `contains` | Ignored |
Expand Down
36 changes: 25 additions & 11 deletions src/generator/typescript.cc
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#include <sourcemeta/codegen/generator.h>

#include <iomanip> // std::hex, std::setfill, std::setw
#include <sstream> // std::ostringstream
#include <algorithm> // std::ranges::any_of
#include <iomanip> // std::hex, std::setfill, std::setw
#include <sstream> // std::ostringstream

namespace {

Expand Down Expand Up @@ -137,7 +138,12 @@ auto TypeScript::operator()(const IRObject &entry) -> void {
}

for (const auto &pattern_property : entry.pattern) {
this->output << " [key: `" << pattern_property.prefix << "${string}`]: "
if (!pattern_property.prefix.has_value()) {
continue;
}

this->output << " [key: `" << pattern_property.prefix.value()
<< "${string}`]: "
<< mangle(this->prefix, pattern_property.pointer,
pattern_property.symbol, this->cache);

Expand All @@ -146,11 +152,11 @@ auto TypeScript::operator()(const IRObject &entry) -> void {
// 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) {
if (&other == &pattern_property || !other.prefix.has_value()) {
continue;
}

if (pattern_property.prefix.starts_with(other.prefix)) {
if (pattern_property.prefix.value().starts_with(other.prefix.value())) {
this->output << " & "
<< mangle(this->prefix, other.pointer, other.symbol,
this->cache);
Expand All @@ -160,9 +166,14 @@ auto TypeScript::operator()(const IRObject &entry) -> void {
this->output << ";\n";
}

const auto has_non_prefix_pattern{
std::ranges::any_of(entry.pattern, [](const auto &pattern_property) {
return !pattern_property.prefix.has_value();
})};

if (allows_any_additional) {
this->output << " [key: string]: unknown | undefined;\n";
} else if (has_typed_additional) {
} else if (has_typed_additional || has_non_prefix_pattern) {
// TypeScript index signatures must be a supertype of all property value
// types. We use a union of all member types plus the additional properties
// type plus undefined (for optional properties).
Expand All @@ -186,11 +197,14 @@ auto TypeScript::operator()(const IRObject &entry) -> void {
<< " |\n";
}

const auto &additional_type{std::get<IRType>(entry.additional)};
this->output << " "
<< mangle(this->prefix, additional_type.pointer,
additional_type.symbol, this->cache)
<< " |\n";
if (has_typed_additional) {
const auto &additional_type{std::get<IRType>(entry.additional)};
this->output << " "
<< mangle(this->prefix, additional_type.pointer,
additional_type.symbol, this->cache)
<< " |\n";
}

this->output << " undefined;\n";
}

Expand Down
2 changes: 1 addition & 1 deletion src/ir/include/sourcemeta/codegen/ir.h
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ struct IRObjectValue : IRType {

/// @ingroup ir
struct IRObjectPatternProperty : IRType {
std::string prefix;
std::optional<std::string> prefix;
};

/// @ingroup ir
Expand Down
11 changes: 5 additions & 6 deletions src/ir/ir_default_compiler.h
Original file line number Diff line number Diff line change
Expand Up @@ -146,19 +146,18 @@ auto handle_object(const sourcemeta::core::JSON &schema,
frame.traverse(sourcemeta::core::to_weak_pointer(pattern_pointer))};
assert(pattern_location.has_value());

std::optional<std::string> prefix{std::nullopt};
const auto regex{sourcemeta::core::to_regex(entry.first)};
if (!regex.has_value() ||
!std::holds_alternative<sourcemeta::core::RegexTypePrefix>(
if (regex.has_value() &&
std::holds_alternative<sourcemeta::core::RegexTypePrefix>(
regex.value())) {
throw UnsupportedKeywordValueError(
schema, location.pointer, "patternProperties",
"Only prefix patterns are supported");
prefix = std::get<sourcemeta::core::RegexTypePrefix>(regex.value());
}

pattern.push_back(IRObjectPatternProperty{
{.pointer = std::move(pattern_pointer),
.symbol = symbol(frame, pattern_location.value().get())},
std::get<sourcemeta::core::RegexTypePrefix>(regex.value())});
std::move(prefix)});
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ const test4: StrictExt = {
// @ts-expect-error
other: "value"
};

// Edge case: key is exactly the prefix with empty suffix
const test5: StrictExt = {
name: "hello",
"x-": "value"
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export type EmptyPatternName = string;

export type EmptyPattern = number;

export type EmptyPatternAdditionalProperties = never;

export interface _EmptyPattern {
"name"?: EmptyPatternName;
[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
EmptyPatternName |
EmptyPattern |
undefined;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"defaultPrefix": "EmptyPattern"
}
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": {
"": { "type": "integer" }
},
"additionalProperties": false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { _EmptyPattern } from "./expected";

// Empty regex matches everything, so all keys go through [key: string]
// union (string | number | undefined)
const test1: _EmptyPattern = {
name: "hello",
anything: 42
};

const test2: _EmptyPattern = {
name: "hello",
anything: "also valid"
};

// Boolean is not in the union
const test3: _EmptyPattern = {
name: "hello",
// @ts-expect-error
flag: true
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export type HybridName = string;

export type HybridAge = number;

export type HybridX = string;

export type Hybrid_09_id = number;

export type HybridAdditionalProperties = boolean;

export interface Hybrid {
"name": HybridName;
"age"?: HybridAge;
[key: `x-${string}`]: HybridX;
[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
HybridName |
HybridAge |
HybridX |
Hybrid_09_id |
HybridAdditionalProperties |
undefined;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"defaultPrefix": "Hybrid"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer" }
},
"required": [ "name" ],
"patternProperties": {
"^x-": { "type": "string" },
"[0-9]+_id": { "type": "integer" }
},
"additionalProperties": { "type": "boolean" }
}
59 changes: 59 additions & 0 deletions test/e2e/typescript/2020-12/pattern_properties_full_hybrid/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Hybrid } from "./expected";

// All features together
const test1: Hybrid = {
name: "hello",
age: 30,
"x-custom": "extension",
"123_id": 42,
flag: true
};

// Required name only
const test2: Hybrid = {
name: "hello"
};

// Missing required name
// @ts-expect-error
const test3: Hybrid = {
age: 30
};

// Prefix pattern enforced: x- must be string, not number
const test4: Hybrid = {
name: "hello",
// @ts-expect-error
"x-custom": 42
};

// Array is not in the union
const test5: Hybrid = {
name: "hello",
// @ts-expect-error
extra: [ 1, 2, 3 ]
};

// Additional property with boolean (additionalProperties type)
const test6: Hybrid = {
name: "hello",
flag: true
};

// JSON Schema would reject this (additionalProperties is boolean, not
// number), but TypeScript allows it because the [key: string] union must
// include all member and pattern types (number from "age" and the non-prefix
// pattern). The generated types are always a superset of what JSON Schema
// allows, never a subset.
const test7: Hybrid = {
name: "hello",
extra: 42
};

// Template literal takes priority over permissive [key: string] union:
// boolean is in the union but x- keys must be string
const test8: Hybrid = {
name: "hello",
// @ts-expect-error
"x-custom": true
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type MixedFallbackX = string;

export type MixedFallback_09 = number;

export interface MixedFallback {
[key: `x-${string}`]: MixedFallbackX;
[key: string]: unknown | undefined;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "defaultPrefix": "MixedFallback" }
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" },
"[0-9]+": { "type": "integer" }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { MixedFallback } from "./expected";

// Prefix pattern enforced: x- key must be string
const test1: MixedFallback = {
"x-foo": "hello"
};

// Prefix pattern enforced even with [key: string]: unknown fallback
const test2: MixedFallback = {
// @ts-expect-error
"x-foo": 123
};

// Non-prefix pattern falls back to unknown, so any value works
const test3: MixedFallback = {
"123": 42
};

const test4: MixedFallback = {
"123": "also fine"
};

// Mixed together
const test5: MixedFallback = {
"x-custom": "hello",
"456": 99,
other: true
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export type StrictFallbackName = string;

export type StrictFallbackAz_id = number;

export type StrictFallbackAdditionalProperties = never;

export interface StrictFallback {
"name"?: StrictFallbackName;
[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
StrictFallbackName |
StrictFallbackAz_id |
undefined;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "defaultPrefix": "StrictFallback" }
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": {
"[a-z]+_id": { "type": "integer" }
},
"additionalProperties": false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { StrictFallback } from "./expected";

const test1: StrictFallback = {
name: "hello",
"user_id": 42
};

// JSON Schema would reject this (additionalProperties: false and "other"
// does not match the pattern), but TypeScript allows it because the
// [key: string] union must include all member types (string from "name"),
// so any string value passes. The generated types are always a superset
// of what JSON Schema allows, never a subset.
const test2: StrictFallback = {
name: "hello",
other: "also string"
};

// Boolean is not in the union (string | number | undefined)
const test3: StrictFallback = {
name: "hello",
// @ts-expect-error
flag: true
};

// Array is not in the union
const test4: StrictFallback = {
name: "hello",
// @ts-expect-error
items: [ 1, 2, 3 ]
};
Loading
Loading