Skip to content

Commit 8fa6864

Browse files
authored
Merge pull request #78 from slackhq/ih_shape_unions_1
Generate unions of shapes
2 parents 5075855 + 21f3219 commit 8fa6864

30 files changed

+3295
-83
lines changed

src/Codegen/Constraints/ObjectBuilder.php

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,6 @@ public function build(): this {
110110

111111
// Generate a type based on the specified properties
112112
$type = $this->codegenType($property_classes, $pattern_properties_classes);
113-
// TODO: Register objects as shapes or dicts
114-
Typing\TypeSystem::registerAlias($this->getType(), Typing\TypeSystem::nonnull());
115113
$this->ctx->getFile()->addBeforeType($type);
116114
return $this;
117115
}
@@ -531,15 +529,21 @@ private function codegenType(
531529
$additional_properties is nonnull && $additional_properties is bool ? $additional_properties : true;
532530

533531
$members = vec[];
532+
$shape_fields = dict[];
534533
foreach ($property_classes as $property => $builder) {
535534
$member = new CodegenShapeMember($property, $builder->getType());
536-
if (!C\contains($required, $property) && !C\contains_key($defaults, $property)) {
537-
$member->setIsOptional();
538-
}
535+
$field = shape('type' => $builder->getTypeInfo());
536+
537+
$is_optional = !C\contains($required, $property) && !C\contains_key($defaults, $property);
538+
$member->setIsOptional($is_optional);
539+
$field['required'] = !$is_optional;
539540

540541
$members[] = $member;
542+
$shape_fields[$property] = $field;
541543
}
542544

545+
Typing\TypeSystem::registerAlias($this->getType(), Typing\TypeSystem::shape($shape_fields, !$allow_subtyping));
546+
543547
$shape = $this->ctx
544548
->getHackCodegenFactory()
545549
->codegenShape(...$members)
@@ -549,12 +553,16 @@ private function codegenType(
549553
->codegenType($this->getType())
550554
->setShape($shape);
551555
} else if ($pattern_property_classes is nonnull && C\count($pattern_property_classes) === 1) {
556+
// TODO: Register pattern properties as dicts.
557+
Typing\TypeSystem::registerAlias($this->getType(), Typing\TypeSystem::nonnull());
552558
$builder = vec($pattern_property_classes)[0];
553559
return $this->ctx
554560
->getHackCodegenFactory()
555561
->codegenType($this->getType())
556562
->setType(Str\format('dict<string, %s>', $builder->getType()));
557563
} else {
564+
// TODO: Register as dict.
565+
Typing\TypeSystem::registerAlias($this->getType(), Typing\TypeSystem::nonnull());
558566
return $this->ctx
559567
->getHackCodegenFactory()
560568
->codegenType($this->getType())
@@ -566,4 +574,4 @@ private function codegenType(
566574
public function getTypeInfo(): Typing\Type {
567575
return Typing\TypeSystem::alias($this->getType());
568576
}
569-
}
577+
}

src/Codegen/Constraints/UntypedBuilder.php

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
use type Facebook\HackCodegen\{
77
CodegenClass,
88
CodegenMethod,
9-
CodegenType,
109
HackBuilder,
1110
HackBuilderKeys,
1211
HackBuilderValues,
@@ -18,6 +17,13 @@
1817
?'allOf' => vec<TSchema>,
1918
?'not' => vec<TSchema>,
2019
?'oneOf' => vec<TSchema>,
20+
21+
// Disable generating best-effort unions from shapes,
22+
// preferring nonnull instead.
23+
// This makes it easy to safely upgrade to versions of
24+
// Hack-JSON-Schema which enable shape unification.
25+
?'disableShapeUnification' => bool,
26+
2127
...
2228
);
2329

@@ -42,9 +48,8 @@ public function build(): this {
4248

4349
$this->addBuilderClass($class);
4450

45-
$type = $this->codegenType();
46-
$this->ctx->getFile()->addBeforeType($type);
47-
Typing\TypeSystem::registerAlias($this->getType(), $this->type_info);
51+
$renderer = new TypeRenderer($this->ctx);
52+
$renderer->render($this->type_info, $this->getType());
4853

4954
return $this;
5055
}
@@ -153,7 +158,10 @@ private function generateOneOfChecks(vec<TSchema> $schemas, HackBuilder $hb): vo
153158
$types[] = $schema_builder->getTypeInfo();
154159
}
155160

156-
$this->type_info = Typing\TypeSystem::union($types);
161+
$this->type_info = Typing\TypeSystem::union(
162+
$types,
163+
shape('disable_shape_unification' => $this->typed_schema['disableShapeUnification'] ?? false)
164+
);
157165

158166
$hb
159167
->addAssignment('$constraints', $constraints, HackBuilderValues::vec(HackBuilderValues::literal()))
@@ -503,7 +511,10 @@ private function generateGenericAnyOfChecks(vec<SchemaBuilder> $schema_builders,
503511
}
504512
}
505513

506-
$this->type_info = Typing\TypeSystem::union($present_types);
514+
$this->type_info = Typing\TypeSystem::union(
515+
$present_types,
516+
shape('disable_shape_unification' => $this->typed_schema['disableShapeUnification'] ?? false)
517+
);
507518
if ($this->type_info->isOptional()) {
508519
$hb
509520
->startIfBlock('$input === null')
@@ -629,15 +640,8 @@ public function getType(): string {
629640
return $this->generateTypeName($this->getClassName());
630641
}
631642

632-
private function codegenType(): CodegenType {
633-
return $this->ctx
634-
->getHackCodegenFactory()
635-
->codegenType($this->getType())
636-
->setType($this->type_info->render());
637-
}
638-
639643
<<__Override>>
640644
public function getTypeInfo(): Typing\Type {
641645
return Typing\TypeSystem::alias($this->getType());
642646
}
643-
}
647+
}

src/Codegen/TypeRenderer.hack

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
namespace Slack\Hack\JsonSchema\Codegen;
2+
3+
use namespace HH\Lib\{Str, Vec};
4+
use type Facebook\HackCodegen\{CodegenShapeMember};
5+
6+
/**
7+
* Visitor which renders types generated by the Typing namespace to a file.
8+
*
9+
* This could live in Typing, but I decided to put it here because Typing
10+
* currently doesn't know anything about the HackCodegen namespace.
11+
*/
12+
final class TypeRenderer {
13+
use Factory;
14+
15+
public function __construct(protected Context $ctx) {}
16+
17+
protected function generateTypeNameFromParts(string ...$parts): string {
18+
$config = $this->ctx->getJsonSchemaCodegenConfig();
19+
if ($parts) {
20+
$parts[0] = $parts[0]
21+
|> Str\strip_prefix($$, $config->getTypeNamePrefix())
22+
|> Str\strip_suffix($$, $config->getTypeNameSuffix());
23+
}
24+
return $this->generateTypeName(Str\join($parts, '_'));
25+
}
26+
27+
/**
28+
* Render the given type as the given name.
29+
*/
30+
public function render(Typing\Type $type, string $type_name): void {
31+
// Visiting a shape causes it to be rendered to a file, so we only need to codegen
32+
// a type in the case of non-shapes.
33+
if ($type is Typing\ConcreteType && $type->getConcreteTypeName() === Typing\ConcreteTypeName::SHAPE) {
34+
$this->visitShape($type, $type_name);
35+
} else {
36+
$type_value = $this->visitType($type, $type_name);
37+
$this->ctx
38+
->getFile()
39+
->addBeforeType($this->ctx->getHackCodegenFactory()->codegenType($type_name)->setType($type_value));
40+
Typing\TypeSystem::registerAlias($type_name, $type);
41+
}
42+
}
43+
44+
private function visitType(Typing\Type $type, string $type_name): string {
45+
if ($type is Typing\OptionalType) {
46+
return $this->visitOptionalType($type, $type_name);
47+
} else if ($type is Typing\TypeAlias) {
48+
return $this->visitTypeAlias($type);
49+
} else {
50+
$type as Typing\ConcreteType;
51+
return $this->visitConcreteType($type, $type_name);
52+
}
53+
}
54+
55+
private function visitConcreteType(Typing\ConcreteType $type, string $type_name): string {
56+
if ($type->getConcreteTypeName() === Typing\ConcreteTypeName::SHAPE) {
57+
return $this->visitShape($type, $type_name);
58+
} else {
59+
$codegen_value = (string)$type->getConcreteTypeName();
60+
$generics = $type->getGenerics();
61+
if ($generics) {
62+
$codegen_value = Str\format(
63+
'%s<%s>',
64+
$codegen_value,
65+
Str\join(
66+
Vec\map_with_key($generics, ($i, $generic) ==> {
67+
$generic_type_name = $this->generateTypeNameFromParts($type_name, 'generic', (string)$i);
68+
return $this->visitType($generic, $generic_type_name);
69+
}),
70+
', ',
71+
),
72+
);
73+
}
74+
return $codegen_value;
75+
}
76+
}
77+
78+
private function visitOptionalType(Typing\OptionalType $type, string $type_name): string {
79+
// Visit the inner type and then generate a type like '?<inner type>'
80+
$codegen_type = $this->visitType($type->getType(), $this->generateTypeNameFromParts($type_name, 'nonnull'));
81+
switch ($codegen_type) {
82+
case Typing\ConcreteTypeName::NONNULL:
83+
return 'mixed';
84+
case Typing\ConcreteTypeName::NOTHING:
85+
return 'null';
86+
default:
87+
return '?'.$codegen_type;
88+
}
89+
}
90+
91+
private function visitShape(Typing\ConcreteType $type, string $type_name): string {
92+
$shape_members = vec[];
93+
foreach ($type->getShapeFields() as $field_name => $field) {
94+
$field_type_name = $this->generateTypeNameFromParts($type_name, $field_name, 'field');
95+
$field_type_name = $this->visitType($field['type'], $field_type_name);
96+
$shape_member = new CodegenShapeMember($field_name, $field_type_name);
97+
$shape_member->setIsOptional(!Shapes::idx($field, 'required', false));
98+
$shape_members[] = $shape_member;
99+
}
100+
$this->ctx->getFile()->addBeforeType(
101+
$this->ctx
102+
->getHackCodegenFactory()
103+
->codegenType($type_name)
104+
->setShape(
105+
$this->ctx
106+
->getHackCodegenFactory()
107+
->codegenShape(...$shape_members)
108+
->setAllowsSubtyping(!$type->isClosedShape()),
109+
),
110+
);
111+
Typing\TypeSystem::registerAlias($type_name, $type);
112+
return $type_name;
113+
}
114+
115+
private function visitTypeAlias(Typing\TypeAlias $type): string {
116+
return $type->getName();
117+
}
118+
}
Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
namespace Slack\Hack\JsonSchema\Codegen\Typing;
22

3-
use namespace HH\Lib\{Str, Vec};
3+
use Facebook\HackCodegen\CodegenType;
44

55
/**
66
* Represents a concrete type in the Hack type system,
@@ -13,7 +13,12 @@ use namespace HH\Lib\{Str, Vec};
1313
* approach for now.
1414
*/
1515
final class ConcreteType extends Type {
16-
public function __construct(private ConcreteTypeName $name, private vec<Type> $generics = vec[]) {}
16+
public function __construct(
17+
private ConcreteTypeName $name,
18+
private vec<Type> $generics = vec[],
19+
private this::TShapeFields $shape_fields = dict[],
20+
private bool $is_closed_shape = false,
21+
) {}
1722

1823
<<__Override>>
1924
public function getConcreteTypeName(): ConcreteTypeName {
@@ -25,24 +30,28 @@ final class ConcreteType extends Type {
2530
return $this->generics;
2631
}
2732

33+
<<__Override>>
34+
public function getName(): string {
35+
return (string)$this->getConcreteTypeName();
36+
}
37+
38+
<<__Override>>
39+
public function getShapeFields(): this::TShapeFields {
40+
return $this->shape_fields;
41+
}
42+
2843
<<__Override>>
2944
public function hasAlias(): bool {
3045
return false;
3146
}
3247

3348
<<__Override>>
34-
public function isOptional(): bool {
35-
return false;
49+
public function isClosedShape(): bool {
50+
return $this->is_closed_shape;
3651
}
3752

3853
<<__Override>>
39-
public function render(): string {
40-
$out = (string)$this->name;
41-
$generics = $this->getGenerics();
42-
if ($generics) {
43-
$out = $out.'<'.Str\join(Vec\map($generics, $generic ==> $generic->render()), ', ').'>';
44-
}
45-
// TODO: Handle shape fields
46-
return $out;
54+
public function isOptional(): bool {
55+
return false;
4756
}
4857
}

src/Codegen/Typing/OptionalType.hack

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ namespace Slack\Hack\JsonSchema\Codegen\Typing;
1111
final class OptionalType extends Type {
1212
public function __construct(private Type $type) {}
1313

14+
public function getType(): Type {
15+
return $this->type;
16+
}
17+
1418
<<__Override>>
1519
public function getConcreteTypeName(): ConcreteTypeName {
1620
return $this->type->getConcreteTypeName();
@@ -21,26 +25,28 @@ final class OptionalType extends Type {
2125
return $this->type->getGenerics();
2226
}
2327

28+
<<__Override>>
29+
public function getName(): string {
30+
return '?'.$this->type->getName();
31+
}
32+
33+
<<__Override>>
34+
public function getShapeFields(): this::TShapeFields {
35+
return $this->type->getShapeFields();
36+
}
37+
2438
<<__Override>>
2539
public function hasAlias(): bool {
2640
return $this->type->hasAlias();
2741
}
2842

2943
<<__Override>>
30-
public function isOptional(): bool {
31-
return true;
44+
public function isClosedShape(): bool {
45+
return $this->type->isClosedShape();
3246
}
3347

3448
<<__Override>>
35-
public function render(): string {
36-
$type_name = $this->type->render();
37-
switch ($type_name) {
38-
case ConcreteTypeName::NOTHING:
39-
return 'null';
40-
case ConcreteTypeName::NONNULL:
41-
return 'mixed';
42-
default:
43-
return '?'.$type_name;
44-
}
45-
}
46-
}
49+
public function isOptional(): bool {
50+
return true;
51+
}
52+
}

0 commit comments

Comments
 (0)