Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for generating hack enums from json def #81

Merged
merged 12 commits into from
Jan 4, 2024
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
80 changes: 43 additions & 37 deletions src/Codegen/Constraints/BaseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,56 +108,62 @@ protected function addHackEnumConstraintCheck(HackBuilder $hb): void {
if (!Shapes::keyExists($schema, 'hackEnum')) {
return;
}
$generateHackEnum = $schema['generateHackEnum'] ?? false;
if (!$generateHackEnum) {

try {
$rc = new \ReflectionClass($schema['hackEnum']);
} catch (\ReflectionException $e) {
throw new \Exception(Str\format("Hack enum '%s' does not exist", $schema['hackEnum']));
}

invariant($rc->isEnum(), "'%s' is not an enum", $schema['hackEnum']);

$schema_type = $schema['type'] ?? null;
$hack_enum_values = keyset[];
foreach ($rc->getConstants() as $hack_enum_value) {
if ($schema_type === TSchemaType::INTEGER_T) {
$hack_enum_value = $hack_enum_value ?as int;
} else {
$hack_enum_value = $hack_enum_value ?as string;
try {
$rc = new \ReflectionClass($schema['hackEnum']);
} catch (\ReflectionException $e) {
throw new \Exception(Str\format("Hack enum '%s' does not exist", $schema['hackEnum']));
}
invariant(
$hack_enum_value is nonnull,
"'%s' must contain only values of type %s",
$rc->getName(),
$schema_type === TSchemaType::INTEGER_T ? 'int' : 'string',
);
$hack_enum_values[] = $hack_enum_value;
}

if (Shapes::keyExists($schema, 'enum')) {
// If both `enum` and `hackEnum` are specified, assert that `enum` is a subset of
// `hackEnum` values. Any value not also in `hackEnum` can't be valid.
foreach ($schema['enum'] as $enum_value) {
invariant(
$enum_value is string,
"Enum value '%s' is not a valid value for '%s'",
\print_r($enum_value, true),
$rc->getName(),
);
invariant($rc->isEnum(), "'%s' is not an enum", $schema['hackEnum']);

$schema_type = $schema['type'] ?? null;
$hack_enum_values = keyset[];
foreach ($rc->getConstants() as $hack_enum_value) {
if ($schema_type === TSchemaType::INTEGER_T) {
$hack_enum_value = $hack_enum_value ?as int;
} else {
$hack_enum_value = $hack_enum_value ?as string;
}
invariant(
C\contains_key($hack_enum_values, $enum_value),
"Enum value '%s' is unexpectedly not present in '%s'",
\print_r($enum_value, true),
$hack_enum_value is nonnull,
"'%s' must contain only values of type %s",
$rc->getName(),
$schema_type === TSchemaType::INTEGER_T ? 'int' : 'string',
);
$hack_enum_values[] = $hack_enum_value;
}

if (Shapes::keyExists($schema, 'enum')) {
// If both `enum` and `hackEnum` are specified, assert that `enum` is a subset of
// `hackEnum` values. Any value not also in `hackEnum` can't be valid.
foreach ($schema['enum'] as $enum_value) {
invariant(
$enum_value is string,
"Enum value '%s' is not a valid value for '%s'",
\print_r($enum_value, true),
$rc->getName(),
);
invariant(
C\contains_key($hack_enum_values, $enum_value),
"Enum value '%s' is unexpectedly not present in '%s'",
\print_r($enum_value, true),
$rc->getName(),
);
}
}
$enum_name = Str\format('\%s::class', $rc->getName());
} else {
$enum_name = $schema['hackEnum'].'::class';
}

$hb->addMultilineCall(
'$typed = Constraints\HackEnumConstraint::check',
vec[
'$typed',
Str\format('\%s::class', $rc->getName()),
$enum_name,
'$pointer',
],
);
Expand Down
31 changes: 28 additions & 3 deletions src/Codegen/Constraints/StringBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
?'minLength' => int,
?'enum' => vec<string>,
?'hackEnum' => string,
?'generateHackEnum' => bool,
?'pattern' => string,
?'format' => string,
?'sanitize' => shape(
Expand Down Expand Up @@ -59,8 +60,28 @@ public function build(): this {
}

$enum = $this->getEnumCodegenProperty();
$generateHackEnum = $this->typed_schema['generateHackEnum'] ?? false;
if ($enum is nonnull) {
$properties[] = $enum;
if ($generateHackEnum) {
$enum = $this->typed_schema['enum'] ?? vec[];
$factory = $this->ctx->getHackCodegenFactory();
$members = \HH\Lib\Vec\map(
$enum,
$member ==> $factory->codegenEnumMember(Str\uppercase($member))
->setValue($member, HackBuilderValues::export()),
);
$enumName = $this->typed_schema['hackEnum'] ?? null;
invariant($enumName is string, 'hackEnum is required when generating hack enum.');
invariant(!Str\contains($enumName, '\\'), 'hackEnum must not contain a slash.');
$enum_obj = $factory->codegenEnum($enumName, 'string')
->addMembers($members)
->setIsAs('string');
$this->ctx->getFile()->addEnum($enum_obj);
} else {
$properties[] = $enum;
}
} else {
invariant(!$generateHackEnum, 'enum is required when generating hack enum');
}

$coerce = $this->typed_schema['coerce'] ?? $this->ctx->getCoerceDefault();
Expand Down Expand Up @@ -99,8 +120,9 @@ protected function getCheckMethod(): CodegenMethod {
->addAssignment('$typed', '$sanitize_string($typed)', HackBuilderValues::literal())
->ensureEmptyLine();
}

$this->addEnumConstraintCheck($hb);
if (!($this->typed_schema['generateHackEnum'] ?? false)) {
$this->addEnumConstraintCheck($hb);
}

$max_length = $this->typed_schema['maxLength'] ?? null;
$min_length = $this->typed_schema['minLength'] ?? null;
Expand Down Expand Up @@ -146,6 +168,9 @@ protected function getCheckMethod(): CodegenMethod {
<<__Override>>
public function getType(): string {
if (Shapes::keyExists($this->typed_schema, 'hackEnum')) {
if ($this->typed_schema['generateHackEnum'] ?? false) {
return $this->typed_schema['hackEnum'];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm it’s a bummer that we have to fork here. I bet we could update the pre-existing branch to drop the leading \ without causing issues, though it would require regenerating a lot of code.

}
return Str\format('\%s', $this->typed_schema['hackEnum']);
}

Expand Down
35 changes: 35 additions & 0 deletions tests/GeneratedHackEnumSchemaValidatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?hh // strict

namespace Slack\Hack\JsonSchema\Tests;


use type Slack\Hack\JsonSchema\Tests\Generated\GeneratedHackEnumSchemaValidator;

final class GeneratedHackEnumSchemaValidatorTest extends BaseCodegenTestCase {

<<__Override>>
public static async function beforeFirstTestAsync(): Awaitable<void> {
$ret = self::getBuilder('generated-hack-enum-schema.json', 'GeneratedHackEnumSchemaValidator');
$ret['codegen']->build();
require_once($ret['path']);
}
public function testStringEnum(): void {
$cases = vec[
shape(
'input' => dict['enum_string' => 'one'],
'output' => dict['enum_string' => 'one'],
'valid' => true,
),
shape(
'input' => dict['enum_string' => 'four'],
'valid' => false,
),
shape(
'input' => dict['enum_string' => 1],
'valid' => false,
),
];

$this->expectCases($cases, $input ==> new GeneratedHackEnumSchemaValidator($input));
}
}
85 changes: 85 additions & 0 deletions tests/examples/codegen/GeneratedHackEnumSchemaValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?hh // strict
/**
* This file is generated. Do not modify it manually!
*
* To re-generate this file run `make test`
*
*
* @generated SignedSource<<ff65e010c6ed29ad1f95451eac4f8a17>>
*/
namespace Slack\Hack\JsonSchema\Tests\Generated;
use namespace Slack\Hack\JsonSchema;
use namespace Slack\Hack\JsonSchema\Constraints;

type TGeneratedHackEnumSchemaValidator = shape(
?'enum_string' => myCoolTestEnum,
...
);


enum myCoolTestEnum : string as string {
ONE = 'one';
TWO = 'two';
THREE = 'three';
}

final class GeneratedHackEnumSchemaValidatorPropertiesEnumString {

private static bool $coerce = false;

public static function check(mixed $input, string $pointer): myCoolTestEnum {
$typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce);

$typed = Constraints\HackEnumConstraint::check(
$typed,
myCoolTestEnum::class,
$pointer,
);
return $typed;
}
}

final class GeneratedHackEnumSchemaValidator
extends JsonSchema\BaseValidator<TGeneratedHackEnumSchemaValidator> {

private static bool $coerce = false;

public static function check(
mixed $input,
string $pointer,
): TGeneratedHackEnumSchemaValidator {
$typed = Constraints\ObjectConstraint::check($input, $pointer, self::$coerce);

$errors = vec[];
$output = shape();

/*HHAST_IGNORE_ERROR[UnusedVariable] Some loops generated with this statement do not use their $value*/
foreach ($typed as $key => $value) {
/* HH_IGNORE_ERROR[4051] allow dynamic access to preserve input. See comment in the codegen lib for reasoning and alternatives if needed. */
$output[$key] = $value;
}

if (\HH\Lib\C\contains_key($typed, 'enum_string')) {
try {
$output['enum_string'] = GeneratedHackEnumSchemaValidatorPropertiesEnumString::check(
$typed['enum_string'],
JsonSchema\get_pointer($pointer, 'enum_string'),
);
} catch (JsonSchema\InvalidFieldException $e) {
$errors = \HH\Lib\Vec\concat($errors, $e->errors);
}
}

if (\HH\Lib\C\count($errors)) {
throw new JsonSchema\InvalidFieldException($pointer, $errors);
}

/* HH_IGNORE_ERROR[4163] */
return $output;
}

<<__Override>>
protected function process(): TGeneratedHackEnumSchemaValidator {
return self::check($this->input, $this->pointer);
}
}
11 changes: 11 additions & 0 deletions tests/examples/generated-hack-enum-schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"type": "object",
"properties": {
"enum_string": {
"type": "string",
"enum": ["one", "two", "three"],
"generateHackEnum": true,
"hackEnum": "myCoolTestEnum"
}
}
}
Loading