Skip to content

Commit 134c943

Browse files
authored
Merge pull request #74 from slackhq/ih_oneof
Support type inference for oneOf schemas
2 parents 06164dc + 742e998 commit 134c943

7 files changed

+362
-13
lines changed

src/Codegen/Constraints/UntypedBuilder.php

+16-4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
HackBuilderKeys,
1212
HackBuilderValues,
1313
};
14+
use type Slack\Hack\JsonSchema\Sentinel;
1415

1516
type TUntypedSchema = shape(
1617
?'anyOf' => vec<TSchema>,
@@ -141,24 +142,35 @@ private function generateNotChecks(vec<TSchema> $schemas, HackBuilder $hb): void
141142

142143
}
143144

144-
// TODO: Determine Lowest Upper Bound for oneOf constraint.
145145
private function generateOneOfChecks(vec<TSchema> $schemas, HackBuilder $hb): void {
146146
$constraints = vec[];
147+
$types = vec[];
147148
foreach ($schemas as $index => $schema) {
148149
$schema_builder =
149150
new SchemaBuilder($this->ctx, $this->generateClassName($this->suffix, 'oneOf', (string)$index), $schema);
150151
$schema_builder->build();
151152
$constraints[] = "{$schema_builder->getClassName()}::check<>";
153+
$types[] = $schema_builder->getTypeInfo();
152154
}
153155

156+
$type_info = Typing\TypeSystem::union($types);
157+
// For now, keep upcasting nonnull to mixed.
158+
// This is a temporary cludge to reduce the amount of code changed by generating unions.
159+
// TODO: Stop doing the above.
160+
if ($type_info is Typing\ConcreteType && $type_info->getConcreteTypeName() === Typing\ConcreteTypeName::NONNULL) {
161+
$type_info = Typing\TypeSystem::mixed();
162+
}
163+
$this->type_info = $type_info;
164+
154165
$hb
155166
->addAssignment('$constraints', $constraints, HackBuilderValues::vec(HackBuilderValues::literal()))
156167
->ensureEmptyLine();
157168

158169
$hb
159170
->addAssignment('$passed_any', false, HackBuilderValues::export())
160171
->addAssignment('$passed_multi', false, HackBuilderValues::export())
161-
->addAssignment('$output', null, HackBuilderValues::export())
172+
// Use `Sentinel` rather than `null` so that it's obvious when we failed to match any constraint.
173+
->addAssignment('$output', Str\format('\%s::get()', Sentinel::class), HackBuilderValues::literal())
162174
->startForeachLoop('$constraints', null, '$constraint')
163175
->startTryBlock()
164176
->addMultilineCall('$output = $constraint', vec['$input', '$pointer'])
@@ -181,7 +193,7 @@ private function generateOneOfChecks(vec<TSchema> $schemas, HackBuilder $hb): vo
181193
);
182194

183195
$hb
184-
->startIfBlock('$passed_multi || !$passed_any')
196+
->startIfBlockf('$passed_multi || !$passed_any || $output is \%s', Sentinel::class)
185197
->addAssignment(
186198
'$error',
187199
$error,
@@ -452,7 +464,7 @@ private function getOptimizedAnyOfTypes(vec<SchemaBuilder> $schema_builders): ?T
452464

453465
if ($property_type === TSchemaType::STRING_T && C\contains($required, $property_name)) {
454466
$typed_property_schema =
455-
type_assert_shape($property_schema, 'Slack\Hack\JsonSchema\Codegen\TStringSchema');
467+
type_assert_type($property_schema, \Slack\Hack\JsonSchema\Codegen\TStringSchema::class);
456468

457469
$enum = $typed_property_schema['enum'] ?? null;
458470
if ($enum is nonnull && C\count($enum) === 1) {

src/Sentinel.hack

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace Slack\Hack\JsonSchema;
2+
3+
/**
4+
* Represents an unset value.
5+
*/
6+
final class Sentinel {
7+
8+
/**
9+
* The singleton instance of the sentinal value.
10+
*/
11+
public static function get(): this {
12+
return new self();
13+
}
14+
15+
private function __construct () {}
16+
}

tests/DiscardAddititionalPropertiesValidatorTest.php

+1-4
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,7 @@ final class DiscardAddititionalPropertiesValidatorTest extends BaseCodegenTestCa
1010

1111
<<__Override>>
1212
public static async function beforeFirstTestAsync(): Awaitable<void> {
13-
$ret = self::getBuilder(
14-
'discard-additional-properties-schema.json',
15-
'DiscardAddititionalPropertiesValidator',
16-
);
13+
$ret = self::getBuilder('discard-additional-properties-schema.json', 'DiscardAddititionalPropertiesValidator');
1714
$ret['codegen']->build();
1815
require_once($ret['path']);
1916
}

tests/UntypedSchemaValidatorTest.php

+32
Original file line numberDiff line numberDiff line change
@@ -202,4 +202,36 @@ public function testAnyOfOptimizedEnumCoercionInvalid(): void {
202202
expect($error['pointer'] ?? null)->toBeSame('/any_of_optimized_enum');
203203
}
204204

205+
public function testOneOfNullableString(): void {
206+
$this->expectCases(
207+
vec[
208+
shape('input' => 'foo', 'output' => dict['one_of_nullable_string' => 'foo'], 'valid' => true),
209+
shape('input' => null, 'output' => dict['one_of_nullable_string' => null], 'valid' => true),
210+
shape('input' => true, 'valid' => false),
211+
],
212+
$input ==> new UntypedSchemaValidator(dict['one_of_nullable_string' => $input]),
213+
);
214+
}
215+
216+
public function testOneOfStringAndInt(): void {
217+
$this->expectCases(
218+
vec[
219+
shape('input' => 'foo', 'output' => dict['one_of_string_and_int' => 'foo'], 'valid' => true),
220+
shape('input' => 3, 'output' => dict['one_of_string_and_int' => 3], 'valid' => true),
221+
shape('input' => true, 'valid' => false),
222+
],
223+
$input ==> new UntypedSchemaValidator(dict['one_of_string_and_int' => $input]),
224+
);
225+
}
226+
227+
public function testOneOfStringAndVec(): void {
228+
$this->expectCases(
229+
vec[
230+
shape('input' => 'foo', 'output' => dict['one_of_string_and_vec' => 'foo'], 'valid' => true),
231+
shape('input' => vec[3], 'output' => dict['one_of_string_and_vec' => vec[3]], 'valid' => true),
232+
shape('input' => 3, 'valid' => false),
233+
],
234+
$input ==> new UntypedSchemaValidator(dict['one_of_string_and_vec' => $input]),
235+
);
236+
}
205237
}

tests/examples/codegen/AddressSchemaValidator.php

+4-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* To re-generate this file run `make test`
66
*
77
*
8-
* @generated SignedSource<<f55b7686ddc5695a203819aec8bb8742>>
8+
* @generated SignedSource<<bbd4e20ed2f469e62e0efcc5610d2ccc>>
99
*/
1010
namespace Slack\Hack\JsonSchema\Tests\Generated;
1111
use namespace Slack\Hack\JsonSchema;
@@ -23,7 +23,7 @@
2323

2424
type TAddressSchemaValidatorPropertiesLongitude = mixed;
2525

26-
type TAddressSchemaValidatorPropertiesLatitude = mixed;
26+
type TAddressSchemaValidatorPropertiesLatitude = num;
2727

2828
type TAddressSchemaValidator = shape(
2929
?'post-office-box' => string,
@@ -425,7 +425,7 @@ public static function check(
425425

426426
$passed_any = false;
427427
$passed_multi = false;
428-
$output = null;
428+
$output = \Slack\Hack\JsonSchema\Sentinel::get();
429429
foreach ($constraints as $constraint) {
430430
try {
431431
$output = $constraint($input, $pointer);
@@ -438,7 +438,7 @@ public static function check(
438438
}
439439
}
440440

441-
if ($passed_multi || !$passed_any) {
441+
if ($passed_multi || !$passed_any || $output is \Slack\Hack\JsonSchema\Sentinel) {
442442
$error = shape(
443443
'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT,
444444
'constraint' => shape(

0 commit comments

Comments
 (0)