blob: 9471acd56d1494943eb34d1f6a784e72ba42aa1f [file]
<?php
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
namespace Apache\Avro\Schema;
use Apache\Avro\AvroException;
use Apache\Avro\AvroUtil;
use Apache\Avro\Datum\Type\AvroDuration;
/** TODO
* - ARRAY have only type and item attributes (what about metadata?)
* - MAP keys are (assumed?) to be strings
* - FIXED size must be integer (must be positive? less than MAXINT?)
* - primitive type names cannot have a namespace (so throw an error? or ignore?)
* - schema may contain multiple definitions of a named schema
* if definitions are equivalent (?)
* - Cleanup default namespace and named schemata handling.
* - For one, it appears to be *too* global. According to the spec,
* we should only be referencing schemas that are named within the
* *enclosing* schema, so those in sibling schemas (say, unions or fields)
* shouldn't be referenced, if I understand the spec correctly.
* - Also, if a named schema is defined more than once in the same schema,
* it must have the same definition: so it appears we *do* need to keep
* track of named schemata globally as well. (And does this play well
* with the requirements regarding enclosing schema?
* - default values for bytes and fixed fields are JSON strings,
* where unicode code points 0-255 are mapped to unsigned 8-bit byte values 0-255
* - make sure other default values for other schema are of appropriate type
* - Should AvroField really be an AvroSchema object? Avro Fields have a name
* attribute, but not a namespace attribute (and the name can't be namespace
* qualified). It also has additional attributes such as doc, which named schemas
* enum and record have (though not fixed schemas, which also have names), and
* fields also have default and order attributes, shared by no other schema type.
*
* @phpstan-type AvroPrimitiveType 'null'|'boolean'|'int'|'long'|'float'|'double'|'string'|'bytes'
* @phpstan-type AvroComplexType 'array'|'map'|'union'|'enum'|'fixed'|'record'|'error'|'request'
* @phpstan-type AvroSchemaType AvroPrimitiveType|AvroComplexType
* @phpstan-type AvroSchemaDefinitionArray array<string, mixed>
* @phpstan-type AvroSchemaUnionDefinitionArray list<AvroSchemaDefinitionArray>
*/
class AvroSchema implements \Stringable
{
/**
* @var int lower bound of integer values: -(1 << 31)
*/
public const INT_MIN_VALUE = -2147483648;
/**
* @var int upper bound of integer values: (1 << 31) - 1
*/
public const INT_MAX_VALUE = 2147483647;
/**
* @var int upper bound of integer values: (1 << 31) - 1
*/
public const INT_RANGE = 4294967296;
/**
* @var float lower bound of long values: -(1 << 63)
*/
public const LONG_MIN_VALUE = -9223372036854775808;
/**
* @var int upper bound of long values: (1 << 63) - 1
*/
public const LONG_MAX_VALUE = 9223372036854775807;
/**
* @var string null schema type name
*/
public const NULL_TYPE = 'null';
/**
* @var string boolean schema type name
*/
public const BOOLEAN_TYPE = 'boolean';
/**
* int schema type value is a 32-bit signed int
* @var string int schema type name.
*/
public const INT_TYPE = 'int';
/**
* long schema type value is a 64-bit signed int
* @var string long schema type name
*/
public const LONG_TYPE = 'long';
/**
* float schema type value is a 32-bit IEEE 754 floating-point number
* @var string float schema type name
*/
public const FLOAT_TYPE = 'float';
/**
* double schema type value is a 64-bit IEEE 754 floating-point number
* @var string double schema type name
*/
public const DOUBLE_TYPE = 'double';
/**
* string schema type value is a Unicode character sequence
* @var string string schema type name
*/
public const STRING_TYPE = 'string';
/**
* bytes schema type value is a sequence of 8-bit unsigned bytes
* @var string bytes schema type name
*/
public const BYTES_TYPE = 'bytes';
// Logical Types
/** @var string */
public const DECIMAL_LOGICAL_TYPE = 'decimal';
/** @var string */
public const UUID_LOGICAL_TYPE = 'uuid';
/** @var string */
public const DATE_LOGICAL_TYPE = 'date';
/** @var string */
public const TIME_MILLIS_LOGICAL_TYPE = 'time-millis';
/** @var string */
public const TIME_MICROS_LOGICAL_TYPE = 'time-micros';
/** @var string */
public const TIMESTAMP_MILLIS_LOGICAL_TYPE = 'timestamp-millis';
/** @var string */
public const TIMESTAMP_MICROS_LOGICAL_TYPE = 'timestamp-micros';
/** @var string */
public const LOCAL_TIMESTAMP_MILLIS_LOGICAL_TYPE = 'local-timestamp-millis';
/** @var string */
public const LOCAL_TIMESTAMP_MICROS_LOGICAL_TYPE = 'local-timestamp-micros';
/** @var string */
public const DURATION_LOGICAL_TYPE = 'duration';
// Complex Types
// Unnamed Schema
/**
* @var string array schema type name
*/
public const ARRAY_SCHEMA = 'array';
/**
* @var string map schema type name
*/
public const MAP_SCHEMA = 'map';
/**
* @var string union schema type name
*/
public const UNION_SCHEMA = 'union';
/**
* Unions of error schemas are used by Avro messages
* @var string error_union schema type name
*/
public const ERROR_UNION_SCHEMA = 'error_union';
// Named Schema
/**
* @var string enum schema type name
*/
public const ENUM_SCHEMA = 'enum';
/**
* @var string fixed schema type name
*/
public const FIXED_SCHEMA = 'fixed';
/**
* @var string record schema type name
*/
public const RECORD_SCHEMA = 'record';
// Other Schema
/**
* @var string error schema type name
*/
public const ERROR_SCHEMA = 'error';
/**
* @var string request schema type name
*/
public const REQUEST_SCHEMA = 'request';
// Schema attribute names
/**
* @var string schema type name attribute name
*/
public const TYPE_ATTR = 'type';
/**
* @var string named schema name attribute name
*/
public const NAME_ATTR = 'name';
/**
* @var string named schema namespace attribute name
*/
public const NAMESPACE_ATTR = 'namespace';
/**
* @var string derived attribute: doesn't appear in schema
*/
public const FULLNAME_ATTR = 'fullname';
/**
* @var string array schema size attribute name
*/
public const SIZE_ATTR = 'size';
/**
* @var string record fields attribute name
*/
public const FIELDS_ATTR = 'fields';
/**
* @var string array schema items attribute name
*/
public const ITEMS_ATTR = 'items';
/**
* @var string enum schema symbols attribute name
*/
public const SYMBOLS_ATTR = 'symbols';
/**
* @var string map schema values attribute name
*/
public const VALUES_ATTR = 'values';
/**
* @var string document string attribute name
*/
public const DOC_ATTR = 'doc';
/** @var string aliases string attribute name */
public const ALIASES_ATTR = 'aliases';
/** @var string logical type attribute name */
public const LOGICAL_TYPE_ATTR = 'logicalType';
protected ?AvroLogicalType $logicalType = null;
/**
* @var array<AvroPrimitiveType> list of primitive schema type names
*/
private static array $primitiveTypes = [
self::NULL_TYPE,
self::BOOLEAN_TYPE,
self::STRING_TYPE,
self::BYTES_TYPE,
self::INT_TYPE,
self::LONG_TYPE,
self::FLOAT_TYPE,
self::DOUBLE_TYPE,
];
/**
* @var array<string> list of named schema type names
*/
private static array $namedTypes = [
self::FIXED_SCHEMA,
self::ENUM_SCHEMA,
self::RECORD_SCHEMA,
self::ERROR_SCHEMA,
];
/**
* @var array<string> list of names of reserved attributes
* @phpstan-ignore property.onlyWritten
*/
private static array $reservedAttrs = [
self::TYPE_ATTR,
self::NAME_ATTR,
self::NAMESPACE_ATTR,
self::FIELDS_ATTR,
self::ITEMS_ATTR,
self::SIZE_ATTR,
self::SYMBOLS_ATTR,
self::VALUES_ATTR,
self::LOGICAL_TYPE_ATTR,
];
/**
* @param AvroSchema|string $type a schema type name
* @internal Should only be called from within the constructor of
* a class which extends AvroSchema
*/
public function __construct(
public readonly string|self $type
) {
}
/**
* @throws \JsonException
* @return string the JSON-encoded representation of this Avro schema.
*/
public function __toString(): string
{
return json_encode($this->toAvro(), JSON_THROW_ON_ERROR);
}
/**
* @uses self::realParse()
*/
public static function parse(string $json): self
{
$schemata = new AvroNamedSchemata();
return self::realParse(
avro: json_decode($json, true, JSON_THROW_ON_ERROR),
schemata: $schemata
);
}
/**
* @param null|AvroSchemaDefinitionArray|AvroSchemaUnionDefinitionArray|string $avro JSON-decoded schema
* @param null|string $defaultNamespace namespace of enclosing schema
* @param AvroNamedSchemata $schemata reference to named schemas
* @throws AvroSchemaParseException
* @throws AvroException
*/
public static function realParse(
array|string|null $avro,
?string $defaultNamespace = null,
AvroNamedSchemata $schemata = new AvroNamedSchemata()
): AvroSchema {
if (is_array($avro)) {
$type = $avro[self::TYPE_ATTR] ?? null;
if (self::isPrimitiveType($type)) {
switch ($avro[self::LOGICAL_TYPE_ATTR] ?? null) {
case self::DECIMAL_LOGICAL_TYPE:
[$precision, $scale] = self::extractPrecisionAndScaleForDecimal($avro);
return AvroPrimitiveSchema::decimal($precision, $scale);
case self::UUID_LOGICAL_TYPE:
return AvroPrimitiveSchema::uuid();
case self::DATE_LOGICAL_TYPE:
return AvroPrimitiveSchema::date();
case self::TIME_MILLIS_LOGICAL_TYPE:
return AvroPrimitiveSchema::timeMillis();
case self::TIME_MICROS_LOGICAL_TYPE:
return AvroPrimitiveSchema::timeMicros();
case self::TIMESTAMP_MILLIS_LOGICAL_TYPE:
return AvroPrimitiveSchema::timestampMillis();
case self::TIMESTAMP_MICROS_LOGICAL_TYPE:
return AvroPrimitiveSchema::timestampMicros();
case self::LOCAL_TIMESTAMP_MILLIS_LOGICAL_TYPE:
return AvroPrimitiveSchema::localTimestampMillis();
case self::LOCAL_TIMESTAMP_MICROS_LOGICAL_TYPE:
return AvroPrimitiveSchema::localTimestampMicros();
default:
return new AvroPrimitiveSchema($type);
}
}
if (self::isNamedType($type)) {
$name = $avro[self::NAME_ATTR] ?? null;
$namespace = $avro[self::NAMESPACE_ATTR] ?? null;
$new_name = new AvroName($name, $namespace, $defaultNamespace);
$doc = $avro[self::DOC_ATTR] ?? null;
$aliases = $avro[self::ALIASES_ATTR] ?? null;
self::hasValidAliases($aliases);
self::hasValidDoc($doc);
switch ($type) {
case self::FIXED_SCHEMA:
$size = $avro[self::SIZE_ATTR] ?? throw new AvroSchemaParseException(
"Size is required for fixed schema"
);
$size = (int) $size;
if (array_key_exists(self::LOGICAL_TYPE_ATTR, $avro)) {
switch ($avro[self::LOGICAL_TYPE_ATTR]) {
case self::DURATION_LOGICAL_TYPE:
return AvroFixedSchema::duration(
name: $new_name,
doc: $doc,
schemata: $schemata,
aliases: $aliases
);
case self::DECIMAL_LOGICAL_TYPE:
[$precision, $scale] = self::extractPrecisionAndScaleForDecimal($avro);
return AvroFixedSchema::decimal(
name: $new_name,
doc: $doc,
size: $size,
precision: $precision,
scale: $scale,
schemata: $schemata,
aliases: $aliases
);
}
}
return new AvroFixedSchema(
name: $new_name,
doc: $doc,
size: $size,
schemata: $schemata,
aliases: $aliases
);
case self::ENUM_SCHEMA:
$symbols = $avro[self::SYMBOLS_ATTR] ?? null;
return new AvroEnumSchema(
name: $new_name,
doc: $doc,
symbols: $symbols,
schemata: $schemata,
aliases: $aliases
);
case self::RECORD_SCHEMA:
case self::ERROR_SCHEMA:
$fields = $avro[self::FIELDS_ATTR] ?? null;
return new AvroRecordSchema(
name: $new_name,
doc: $doc,
fields: $fields,
schemata: $schemata,
schemaType: $type,
aliases: $aliases
);
default:
throw new AvroSchemaParseException(sprintf('Unknown named type: %s', $type));
}
} elseif (self::isValidType($type)) {
return match ($type) {
self::ARRAY_SCHEMA => new AvroArraySchema(
items: $avro[self::ITEMS_ATTR],
defaultNamespace: $defaultNamespace,
schemata: $schemata
),
self::MAP_SCHEMA => new AvroMapSchema(
values: $avro[self::VALUES_ATTR],
defaultNamespace: $defaultNamespace,
schemata: $schemata
),
default => throw new AvroSchemaParseException(
sprintf('Unknown valid type: %s', $type)
),
};
} elseif (
!array_key_exists(self::TYPE_ATTR, $avro)
&& AvroUtil::isList($avro)
) {
return new AvroUnionSchema($avro, $defaultNamespace, $schemata);
} else {
throw new AvroSchemaParseException(sprintf(
'Undefined type: %s',
$type
));
}
} elseif (self::isPrimitiveType($avro)) {
return new AvroPrimitiveSchema($avro);
} else {
throw new AvroSchemaParseException(
sprintf(
'%s is not a schema we know about.',
print_r($avro, true)
)
);
}
}
/**
* @param ?string $type a schema type name
* @return bool true if the given type name is a valid schema type
* name and false otherwise.
*/
public static function isValidType(?string $type): bool
{
return self::isPrimitiveType($type)
|| self::isNamedType($type)
|| in_array($type, [
self::ARRAY_SCHEMA,
self::MAP_SCHEMA,
self::UNION_SCHEMA,
self::REQUEST_SCHEMA,
self::ERROR_UNION_SCHEMA,
]);
}
/**
* @param null|string $type a schema type name
* @return bool true if the given type name is a primitive schema type
* name and false otherwise.
*/
public static function isPrimitiveType(?string $type): bool
{
return in_array($type, self::$primitiveTypes, true);
}
/**
* @param null|string $type a schema type name
* @return bool true if the given type name is a named schema type name
* and false otherwise.
*/
public static function isNamedType(?string $type): bool
{
return in_array($type, self::$namedTypes, true);
}
public static function hasValidAliases(mixed $aliases): void
{
if (null === $aliases) {
return;
}
if (!is_array($aliases)) {
throw new AvroSchemaParseException(
'Invalid aliases value. Must be an array of strings.'
);
}
foreach ((array) $aliases as $alias) {
if (!is_string($alias)) {
throw new AvroSchemaParseException(
'Invalid aliases value. Must be an array of strings.'
);
}
}
}
public static function hasValidDoc(mixed $doc): void
{
if (is_string($doc) || is_null($doc)) {
return;
}
throw new AvroSchemaParseException(
'Invalid doc value. Must be a string or null.'
);
}
/**
* @param mixed $datum
* @throws AvroSchemaParseException
* @return bool true if $datum is valid for $expected_schema
* and false otherwise.
*/
public static function isValidDatum(AvroSchema $expected_schema, $datum): bool
{
switch ($expected_schema->type) {
case self::NULL_TYPE:
return is_null($datum);
case self::BOOLEAN_TYPE:
return is_bool($datum);
case self::STRING_TYPE:
case self::BYTES_TYPE:
return is_string($datum);
case self::INT_TYPE:
return is_int($datum)
&& (self::INT_MIN_VALUE <= $datum)
&& ($datum <= self::INT_MAX_VALUE);
case self::LONG_TYPE:
return is_int($datum)
&& (self::LONG_MIN_VALUE <= $datum)
&& ($datum <= self::LONG_MAX_VALUE);
case self::FLOAT_TYPE:
case self::DOUBLE_TYPE:
return is_float($datum) || is_int($datum);
case self::ARRAY_SCHEMA:
if (is_array($datum) && $expected_schema instanceof AvroArraySchema) {
foreach ($datum as $d) {
if (!self::isValidDatum($expected_schema->items(), $d)) {
return false;
}
}
return true;
}
return false;
case self::MAP_SCHEMA:
if (is_array($datum) && $expected_schema instanceof AvroMapSchema) {
foreach ($datum as $k => $v) {
if (
!is_string($k)
|| !self::isValidDatum($expected_schema->values(), $v)
) {
return false;
}
}
return true;
}
return false;
case self::UNION_SCHEMA:
if (!$expected_schema instanceof AvroUnionSchema) {
return false;
}
foreach ($expected_schema->schemas() as $schema) {
if (self::isValidDatum($schema, $datum)) {
return true;
}
}
return false;
case self::ENUM_SCHEMA:
if (!$expected_schema instanceof AvroEnumSchema) {
return false;
}
return in_array($datum, $expected_schema->symbols(), true);
case self::FIXED_SCHEMA:
if (!$expected_schema instanceof AvroFixedSchema) {
return false;
}
if (
$expected_schema->logicalType() instanceof AvroLogicalType
) {
switch ($expected_schema->logicalType->name()) {
case self::DECIMAL_LOGICAL_TYPE:
$value = abs((float) $datum);
$maxMagnitude = AvroFixedSchema::maxDecimalMagnitude((int) $expected_schema->size());
return $value <= $maxMagnitude;
case self::DURATION_LOGICAL_TYPE:
return $datum instanceof AvroDuration;
default:
throw new AvroSchemaParseException(
sprintf(
'Logical type %s not supported for fixed schema validation.',
$expected_schema->logicalType->name()
)
);
}
}
return is_string($datum)
&& (strlen($datum) === $expected_schema->size());
case self::RECORD_SCHEMA:
case self::ERROR_SCHEMA:
case self::REQUEST_SCHEMA:
if (!$expected_schema instanceof AvroRecordSchema) {
return false;
}
if (is_array($datum)) {
foreach ($expected_schema->fields() as $field) {
if (!self::isValidDatum($field->type(), $datum[$field->name()] ?? null)) {
return false;
}
}
return true;
}
return false;
default:
throw new AvroSchemaParseException(sprintf('%s is not allowed.', $expected_schema));
}
}
/**
* @return AvroNamedSchema|AvroSchemaType schema type name of this schema
*/
public function type()
{
return $this->type;
}
public function logicalType(): ?AvroLogicalType
{
return $this->logicalType;
}
/**
* @return AvroSchemaDefinitionArray|string JSON-encodable representation of this Avro schema
*/
public function toAvro(): string|array
{
$avro = [self::TYPE_ATTR => $this->type];
if (!is_null($this->logicalType)) {
$avro = array_merge($avro, $this->logicalType->toAvro());
}
return $avro;
}
/**
* @param mixed $attribute
* @return mixed value of the attribute with the given attribute name
*/
public function attribute($attribute)
{
return $this->$attribute();
}
/**
* @param null|AvroSchemaDefinitionArray|string $avro JSON-decoded sub-schema
* @throws AvroSchemaParseException
* @uses AvroSchema::realParse()
*/
protected static function subparse(array|string|null $avro, ?string $defaultNamespace, AvroNamedSchemata $schemata): self
{
try {
return self::realParse($avro, $defaultNamespace, $schemata);
} catch (AvroSchemaParseException $e) {
throw $e;
} catch (\Throwable) {
throw new AvroSchemaParseException(
sprintf(
'Sub-schema is not a valid Avro schema. Bad schema: %s',
print_r($avro, true)
)
);
}
}
/**
* @param AvroSchemaDefinitionArray $avro
* @throws AvroSchemaParseException
* @return array{0: int, 1: int} [precision, scale]
*/
private static function extractPrecisionAndScaleForDecimal(array $avro): array
{
$precision = $avro[AvroLogicalType::ATTRIBUTE_DECIMAL_PRECISION] ?? null;
if (!is_int($precision)) {
throw new AvroSchemaParseException(
"Invalid value '{$precision}' for 'precision' attribute of decimal logical type."
);
}
$scale = $avro[AvroLogicalType::ATTRIBUTE_DECIMAL_SCALE] ?? 0;
if (!is_int($scale)) {
throw new AvroSchemaParseException(
"Invalid value '{$scale}' for 'scale' attribute of decimal logical type."
);
}
return [$precision, $scale];
}
}