Skip to content

Commit

Permalink
Merge pull request #78 from slackhq/ih_shape_unions_1
Browse files Browse the repository at this point in the history
Generate unions of shapes
  • Loading branch information
ianhoffman authored Jan 3, 2023
2 parents 5075855 + 21f3219 commit 8fa6864
Show file tree
Hide file tree
Showing 30 changed files with 3,295 additions and 83 deletions.
20 changes: 14 additions & 6 deletions src/Codegen/Constraints/ObjectBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,6 @@ public function build(): this {

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

$members = vec[];
$shape_fields = dict[];
foreach ($property_classes as $property => $builder) {
$member = new CodegenShapeMember($property, $builder->getType());
if (!C\contains($required, $property) && !C\contains_key($defaults, $property)) {
$member->setIsOptional();
}
$field = shape('type' => $builder->getTypeInfo());

$is_optional = !C\contains($required, $property) && !C\contains_key($defaults, $property);
$member->setIsOptional($is_optional);
$field['required'] = !$is_optional;

$members[] = $member;
$shape_fields[$property] = $field;
}

Typing\TypeSystem::registerAlias($this->getType(), Typing\TypeSystem::shape($shape_fields, !$allow_subtyping));

$shape = $this->ctx
->getHackCodegenFactory()
->codegenShape(...$members)
Expand All @@ -549,12 +553,16 @@ private function codegenType(
->codegenType($this->getType())
->setShape($shape);
} else if ($pattern_property_classes is nonnull && C\count($pattern_property_classes) === 1) {
// TODO: Register pattern properties as dicts.
Typing\TypeSystem::registerAlias($this->getType(), Typing\TypeSystem::nonnull());
$builder = vec($pattern_property_classes)[0];
return $this->ctx
->getHackCodegenFactory()
->codegenType($this->getType())
->setType(Str\format('dict<string, %s>', $builder->getType()));
} else {
// TODO: Register as dict.
Typing\TypeSystem::registerAlias($this->getType(), Typing\TypeSystem::nonnull());
return $this->ctx
->getHackCodegenFactory()
->codegenType($this->getType())
Expand All @@ -566,4 +574,4 @@ private function codegenType(
public function getTypeInfo(): Typing\Type {
return Typing\TypeSystem::alias($this->getType());
}
}
}
32 changes: 18 additions & 14 deletions src/Codegen/Constraints/UntypedBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
use type Facebook\HackCodegen\{
CodegenClass,
CodegenMethod,
CodegenType,
HackBuilder,
HackBuilderKeys,
HackBuilderValues,
Expand All @@ -18,6 +17,13 @@
?'allOf' => vec<TSchema>,
?'not' => vec<TSchema>,
?'oneOf' => vec<TSchema>,
// Disable generating best-effort unions from shapes,
// preferring nonnull instead.
// This makes it easy to safely upgrade to versions of
// Hack-JSON-Schema which enable shape unification.
?'disableShapeUnification' => bool,
...
);

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

$this->addBuilderClass($class);

$type = $this->codegenType();
$this->ctx->getFile()->addBeforeType($type);
Typing\TypeSystem::registerAlias($this->getType(), $this->type_info);
$renderer = new TypeRenderer($this->ctx);
$renderer->render($this->type_info, $this->getType());

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

$this->type_info = Typing\TypeSystem::union($types);
$this->type_info = Typing\TypeSystem::union(
$types,
shape('disable_shape_unification' => $this->typed_schema['disableShapeUnification'] ?? false)
);

$hb
->addAssignment('$constraints', $constraints, HackBuilderValues::vec(HackBuilderValues::literal()))
Expand Down Expand Up @@ -503,7 +511,10 @@ private function generateGenericAnyOfChecks(vec<SchemaBuilder> $schema_builders,
}
}

$this->type_info = Typing\TypeSystem::union($present_types);
$this->type_info = Typing\TypeSystem::union(
$present_types,
shape('disable_shape_unification' => $this->typed_schema['disableShapeUnification'] ?? false)
);
if ($this->type_info->isOptional()) {
$hb
->startIfBlock('$input === null')
Expand Down Expand Up @@ -629,15 +640,8 @@ public function getType(): string {
return $this->generateTypeName($this->getClassName());
}

private function codegenType(): CodegenType {
return $this->ctx
->getHackCodegenFactory()
->codegenType($this->getType())
->setType($this->type_info->render());
}

<<__Override>>
public function getTypeInfo(): Typing\Type {
return Typing\TypeSystem::alias($this->getType());
}
}
}
118 changes: 118 additions & 0 deletions src/Codegen/TypeRenderer.hack
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
namespace Slack\Hack\JsonSchema\Codegen;

use namespace HH\Lib\{Str, Vec};
use type Facebook\HackCodegen\{CodegenShapeMember};

/**
* Visitor which renders types generated by the Typing namespace to a file.
*
* This could live in Typing, but I decided to put it here because Typing
* currently doesn't know anything about the HackCodegen namespace.
*/
final class TypeRenderer {
use Factory;

public function __construct(protected Context $ctx) {}

protected function generateTypeNameFromParts(string ...$parts): string {
$config = $this->ctx->getJsonSchemaCodegenConfig();
if ($parts) {
$parts[0] = $parts[0]
|> Str\strip_prefix($$, $config->getTypeNamePrefix())
|> Str\strip_suffix($$, $config->getTypeNameSuffix());
}
return $this->generateTypeName(Str\join($parts, '_'));
}

/**
* Render the given type as the given name.
*/
public function render(Typing\Type $type, string $type_name): void {
// Visiting a shape causes it to be rendered to a file, so we only need to codegen
// a type in the case of non-shapes.
if ($type is Typing\ConcreteType && $type->getConcreteTypeName() === Typing\ConcreteTypeName::SHAPE) {
$this->visitShape($type, $type_name);
} else {
$type_value = $this->visitType($type, $type_name);
$this->ctx
->getFile()
->addBeforeType($this->ctx->getHackCodegenFactory()->codegenType($type_name)->setType($type_value));
Typing\TypeSystem::registerAlias($type_name, $type);
}
}

private function visitType(Typing\Type $type, string $type_name): string {
if ($type is Typing\OptionalType) {
return $this->visitOptionalType($type, $type_name);
} else if ($type is Typing\TypeAlias) {
return $this->visitTypeAlias($type);
} else {
$type as Typing\ConcreteType;
return $this->visitConcreteType($type, $type_name);
}
}

private function visitConcreteType(Typing\ConcreteType $type, string $type_name): string {
if ($type->getConcreteTypeName() === Typing\ConcreteTypeName::SHAPE) {
return $this->visitShape($type, $type_name);
} else {
$codegen_value = (string)$type->getConcreteTypeName();
$generics = $type->getGenerics();
if ($generics) {
$codegen_value = Str\format(
'%s<%s>',
$codegen_value,
Str\join(
Vec\map_with_key($generics, ($i, $generic) ==> {
$generic_type_name = $this->generateTypeNameFromParts($type_name, 'generic', (string)$i);
return $this->visitType($generic, $generic_type_name);
}),
', ',
),
);
}
return $codegen_value;
}
}

private function visitOptionalType(Typing\OptionalType $type, string $type_name): string {
// Visit the inner type and then generate a type like '?<inner type>'
$codegen_type = $this->visitType($type->getType(), $this->generateTypeNameFromParts($type_name, 'nonnull'));
switch ($codegen_type) {
case Typing\ConcreteTypeName::NONNULL:
return 'mixed';
case Typing\ConcreteTypeName::NOTHING:
return 'null';
default:
return '?'.$codegen_type;
}
}

private function visitShape(Typing\ConcreteType $type, string $type_name): string {
$shape_members = vec[];
foreach ($type->getShapeFields() as $field_name => $field) {
$field_type_name = $this->generateTypeNameFromParts($type_name, $field_name, 'field');
$field_type_name = $this->visitType($field['type'], $field_type_name);
$shape_member = new CodegenShapeMember($field_name, $field_type_name);
$shape_member->setIsOptional(!Shapes::idx($field, 'required', false));
$shape_members[] = $shape_member;
}
$this->ctx->getFile()->addBeforeType(
$this->ctx
->getHackCodegenFactory()
->codegenType($type_name)
->setShape(
$this->ctx
->getHackCodegenFactory()
->codegenShape(...$shape_members)
->setAllowsSubtyping(!$type->isClosedShape()),
),
);
Typing\TypeSystem::registerAlias($type_name, $type);
return $type_name;
}

private function visitTypeAlias(Typing\TypeAlias $type): string {
return $type->getName();
}
}
33 changes: 21 additions & 12 deletions src/Codegen/Typing/ConcreteType.hack
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace Slack\Hack\JsonSchema\Codegen\Typing;

use namespace HH\Lib\{Str, Vec};
use Facebook\HackCodegen\CodegenType;

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

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

<<__Override>>
public function getName(): string {
return (string)$this->getConcreteTypeName();
}

<<__Override>>
public function getShapeFields(): this::TShapeFields {
return $this->shape_fields;
}

<<__Override>>
public function hasAlias(): bool {
return false;
}

<<__Override>>
public function isOptional(): bool {
return false;
public function isClosedShape(): bool {
return $this->is_closed_shape;
}

<<__Override>>
public function render(): string {
$out = (string)$this->name;
$generics = $this->getGenerics();
if ($generics) {
$out = $out.'<'.Str\join(Vec\map($generics, $generic ==> $generic->render()), ', ').'>';
}
// TODO: Handle shape fields
return $out;
public function isOptional(): bool {
return false;
}
}
34 changes: 20 additions & 14 deletions src/Codegen/Typing/OptionalType.hack
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ namespace Slack\Hack\JsonSchema\Codegen\Typing;
final class OptionalType extends Type {
public function __construct(private Type $type) {}

public function getType(): Type {
return $this->type;
}

<<__Override>>
public function getConcreteTypeName(): ConcreteTypeName {
return $this->type->getConcreteTypeName();
Expand All @@ -21,26 +25,28 @@ final class OptionalType extends Type {
return $this->type->getGenerics();
}

<<__Override>>
public function getName(): string {
return '?'.$this->type->getName();
}

<<__Override>>
public function getShapeFields(): this::TShapeFields {
return $this->type->getShapeFields();
}

<<__Override>>
public function hasAlias(): bool {
return $this->type->hasAlias();
}

<<__Override>>
public function isOptional(): bool {
return true;
public function isClosedShape(): bool {
return $this->type->isClosedShape();
}

<<__Override>>
public function render(): string {
$type_name = $this->type->render();
switch ($type_name) {
case ConcreteTypeName::NOTHING:
return 'null';
case ConcreteTypeName::NONNULL:
return 'mixed';
default:
return '?'.$type_name;
}
}
}
public function isOptional(): bool {
return true;
}
}
Loading

0 comments on commit 8fa6864

Please sign in to comment.