jsonbinpack
Version:
A space-efficient schema-driven binary JSON serialization format based on JSON Schema
465 lines (412 loc) • 14.8 kB
text/typescript
/*
* Copyright 2021 Juan Cruz Viotti
*
* Licensed 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
*
* http://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.
*/
import {
isDeepStrictEqual
} from 'util'
import {
JSONValue
} from '../json'
import {
getStringEncoding,
StringEncoding
} from './string'
import {
Encoding,
getStates,
getEncoding
} from './index'
import {
EncodingType
} from '../encoder'
import {
BaseEncodingDefinition
} from './base-encoding-definition'
import {
EnumEncodingNames,
EnumEncoding,
getEnumEncoding
} from './enum'
import {
TypedKeysOptions,
BoundedTypedOptions,
UnboundedTypedOptions,
RequiredUnboundedTypedOptions,
OptionalBoundedTypedOptions,
OptionalUnboundedTypedOptions,
RequiredBoundedTypedOptions,
PackedUnboundedOptions,
PackedRequiredBoundedOptions,
SizeTypedKeysOptions
} from '../encoder/object/options'
import {
generatePermutations
} from '../utils/permutation'
import {
EncodingSchema,
ObjectEncodingSchema
} from '../schema'
export interface REQUIRED_ONLY_BOUNDED_TYPED_OBJECT_ENCODING extends BaseEncodingDefinition {
readonly type: EncodingType.Object;
readonly encoding: 'REQUIRED_ONLY_BOUNDED_TYPED_OBJECT';
readonly options: RequiredBoundedTypedOptions;
}
export interface NON_REQUIRED_BOUNDED_TYPED_OBJECT_ENCODING extends BaseEncodingDefinition {
readonly type: EncodingType.Object;
readonly encoding: 'NON_REQUIRED_BOUNDED_TYPED_OBJECT';
readonly options: OptionalBoundedTypedOptions;
}
export interface MIXED_BOUNDED_TYPED_OBJECT_ENCODING extends BaseEncodingDefinition {
readonly type: EncodingType.Object;
readonly encoding: 'MIXED_BOUNDED_TYPED_OBJECT';
readonly options: BoundedTypedOptions;
}
export interface ARBITRARY_TYPED_KEYS_OBJECT_WITHOUT_LENGTH_ENCODING extends BaseEncodingDefinition {
readonly type: EncodingType.Object;
readonly encoding: 'ARBITRARY_TYPED_KEYS_OBJECT_WITHOUT_LENGTH';
readonly options: SizeTypedKeysOptions;
}
export interface ARBITRARY_TYPED_KEYS_OBJECT_ENCODING extends BaseEncodingDefinition {
readonly type: EncodingType.Object;
readonly encoding: 'ARBITRARY_TYPED_KEYS_OBJECT';
readonly options: TypedKeysOptions;
}
export interface REQUIRED_UNBOUNDED_TYPED_OBJECT_ENCODING extends BaseEncodingDefinition {
readonly type: EncodingType.Object;
readonly encoding: 'REQUIRED_UNBOUNDED_TYPED_OBJECT';
readonly options: RequiredUnboundedTypedOptions;
}
export interface OPTIONAL_UNBOUNDED_TYPED_OBJECT_ENCODING extends BaseEncodingDefinition {
readonly type: EncodingType.Object;
readonly encoding: 'OPTIONAL_UNBOUNDED_TYPED_OBJECT';
readonly options: OptionalUnboundedTypedOptions;
}
export interface MIXED_UNBOUNDED_TYPED_OBJECT_ENCODING extends BaseEncodingDefinition {
readonly type: EncodingType.Object;
readonly encoding: 'MIXED_UNBOUNDED_TYPED_OBJECT';
readonly options: UnboundedTypedOptions;
}
export interface PACKED_UNBOUNDED_OBJECT_ENCODING extends BaseEncodingDefinition {
readonly type: EncodingType.Object;
readonly encoding: 'PACKED_UNBOUNDED_OBJECT';
readonly options: PackedUnboundedOptions;
}
export interface PACKED_BOUNDED_REQUIRED_OBJECT_ENCODING extends BaseEncodingDefinition {
readonly type: EncodingType.Object;
readonly encoding: 'PACKED_BOUNDED_REQUIRED_OBJECT';
readonly options: PackedRequiredBoundedOptions;
}
export type ObjectEncodingNames =
EnumEncodingNames |
'REQUIRED_ONLY_BOUNDED_TYPED_OBJECT' |
'NON_REQUIRED_BOUNDED_TYPED_OBJECT' |
'MIXED_BOUNDED_TYPED_OBJECT' |
'ARBITRARY_TYPED_KEYS_OBJECT' |
'ARBITRARY_TYPED_KEYS_OBJECT_WITHOUT_LENGTH' |
'REQUIRED_UNBOUNDED_TYPED_OBJECT' |
'OPTIONAL_UNBOUNDED_TYPED_OBJECT' |
'MIXED_UNBOUNDED_TYPED_OBJECT' |
'PACKED_UNBOUNDED_OBJECT' |
'PACKED_BOUNDED_REQUIRED_OBJECT'
export type ObjectEncoding =
EnumEncoding |
REQUIRED_ONLY_BOUNDED_TYPED_OBJECT_ENCODING |
NON_REQUIRED_BOUNDED_TYPED_OBJECT_ENCODING |
MIXED_BOUNDED_TYPED_OBJECT_ENCODING |
ARBITRARY_TYPED_KEYS_OBJECT_ENCODING |
ARBITRARY_TYPED_KEYS_OBJECT_WITHOUT_LENGTH_ENCODING |
REQUIRED_UNBOUNDED_TYPED_OBJECT_ENCODING |
OPTIONAL_UNBOUNDED_TYPED_OBJECT_ENCODING |
MIXED_UNBOUNDED_TYPED_OBJECT_ENCODING |
PACKED_UNBOUNDED_OBJECT_ENCODING |
PACKED_BOUNDED_REQUIRED_OBJECT_ENCODING
const parseAdditionalProperties = (
value: undefined | boolean | EncodingSchema,
level: number
): Encoding | null => {
if (typeof value === 'boolean' && !value) {
return null
}
const schema: EncodingSchema =
(typeof value === 'undefined' || (typeof value === 'boolean' && value))
? {} : value
return getEncoding(schema, level + 1)
}
// TODO: This definition can probably be greatly improved once we
// support the maxProperties JSON Schema keyword.
export const getObjectStates = (schema: ObjectEncodingSchema): number | JSONValue[] => {
if (typeof schema.additionalProperties === 'boolean' && !schema.additionalProperties) {
const schemaProperties: Record<string, EncodingSchema> = schema.properties ?? {}
const requiredProperties: string[] = schema.required ?? []
// A required key that is not defined is an "any" type
for (const key of requiredProperties) {
if (typeof schemaProperties[key] === 'undefined') {
return Infinity
}
}
let choices: number | JSONValue[][] = []
for (const [ key, value ] of Object.entries(schemaProperties)) {
const states: number | JSONValue[] = getStates(value)
// We can break out if one of the states is infinite
if (typeof states === 'number' && !Number.isFinite(states)) {
return Infinity
}
let absoluteStates: number | (JSONValue | undefined)[] = states
// As non being present (optional) counts as yet another state
if (!requiredProperties.includes(key)) {
if (Array.isArray(absoluteStates)) {
absoluteStates.push(undefined)
} else {
absoluteStates += 1
}
}
if (Array.isArray(choices) && Array.isArray(absoluteStates)) {
choices.push(absoluteStates.map((state: JSONValue | undefined) => {
return typeof state === 'undefined' ? {} : {
[key]: state
}
}))
} else {
choices =
(Array.isArray(choices) ? choices.length : choices) *
(Array.isArray(absoluteStates) ? absoluteStates.length : absoluteStates)
}
}
if (typeof choices === 'number') {
return choices
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return generatePermutations(...choices).map((result: JSONValue[]) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Object.assign({}, ...result)
})
}
return Infinity
}
export const getObjectEncoding = (schema: ObjectEncodingSchema, level: number): ObjectEncoding => {
const states: number | JSONValue[] = getObjectStates(schema)
if (Array.isArray(states)) {
// We only want to match objects with more than one state
// as empty bounded objects are already encoded using 0 bytes
if (states.length > 1 || (states.length <= 1 && level === 0)) {
return getEnumEncoding({
enum: states
}, level)
}
}
const additionalProperties: Encoding | null =
parseAdditionalProperties(schema.additionalProperties, level)
const properties: Record<string, EncodingSchema> = schema.properties ?? {}
const propertyEncodings: Record<string, Encoding> = Object.keys(properties)
.reduce((accumulator: Record<string, Encoding>, key: string) => {
accumulator[key] = getEncoding(properties[key], level + 1)
return accumulator
}, {})
const unsortedRequiredBooleanProperties: string[] = []
const nonBooleanRequiredProperties: string[] = []
for (const key of schema.required ?? []) {
const encoding: Encoding | null = propertyEncodings[key] ?? additionalProperties ?? null
if (encoding !== null &&
encoding.type === EncodingType.Enum &&
encoding.encoding !== 'CONST_NONE' &&
isDeepStrictEqual(encoding.options.choices, [ false, true ])) {
unsortedRequiredBooleanProperties.push(key)
} else {
nonBooleanRequiredProperties.push(key)
}
}
const booleanRequiredProperties: string[] =
unsortedRequiredBooleanProperties.sort((left: string, right: string) => {
return left.localeCompare(right)
})
const requiredProperties: string[] =
nonBooleanRequiredProperties.sort((left: string, right: string) => {
return left.localeCompare(right)
})
const optionalProperties: string[] = Object.keys(properties)
.filter((key: string) => {
return !requiredProperties.includes(key) && !booleanRequiredProperties.includes(key)
}).sort((left: string, right: string) => {
return left.localeCompare(right)
})
const allRequiredProperties: string[] =
booleanRequiredProperties.concat(requiredProperties)
const allProperties: string[] = allRequiredProperties.concat(optionalProperties)
for (const key of allProperties) {
if (!(key in propertyEncodings)) {
propertyEncodings[key] = additionalProperties ?? getEncoding({}, level + 1)
}
}
const keyEncoding: StringEncoding = typeof schema.propertyNames === 'undefined'
? {
type: EncodingType.String,
encoding: 'STRING_UNBOUNDED_SCOPED_PREFIX_LENGTH',
options: {}
}
: getStringEncoding(schema.propertyNames, level + 1)
// Bounded encodings
if (additionalProperties === null) {
if (optionalProperties.length === 0) {
return {
type: EncodingType.Object,
encoding: 'REQUIRED_ONLY_BOUNDED_TYPED_OBJECT',
options: {
propertyEncodings,
requiredProperties,
booleanRequiredProperties
}
}
} else if (allRequiredProperties.length === 0) {
return {
type: EncodingType.Object,
encoding: 'NON_REQUIRED_BOUNDED_TYPED_OBJECT',
options: {
propertyEncodings,
optionalProperties
}
}
}
return {
type: EncodingType.Object,
encoding: 'MIXED_BOUNDED_TYPED_OBJECT',
options: {
propertyEncodings,
optionalProperties,
requiredProperties,
booleanRequiredProperties
}
}
}
if (additionalProperties !== null &&
requiredProperties.length > 0 &&
additionalProperties.type === EncodingType.Integer &&
additionalProperties.encoding === 'BOUNDED_MULTIPLE_8BITS_ENUM_FIXED') {
const propertiesDefinition: Record<string, EncodingSchema> =
schema.properties ?? {}
const packedRequiredProperties: string[] = []
const unpackedRequiredProperties: string[] = []
for (const key of schema.required ?? []) {
if (booleanRequiredProperties.includes(key)) {
continue
}
if (!(key in propertiesDefinition)) {
packedRequiredProperties.push(key)
} else if (isDeepStrictEqual(additionalProperties,
getEncoding(propertiesDefinition[key], level + 1))) {
packedRequiredProperties.push(key)
} else {
unpackedRequiredProperties.push(key)
}
}
const packedPropertyEncodings: Record<string, Encoding> =
Object.keys(propertyEncodings).reduce((accumulator: Record<string, Encoding>, key: string) => {
if (!packedRequiredProperties.includes(key)) {
accumulator[key] = propertyEncodings[key]
}
return accumulator
}, {})
if (typeof schema.maxProperties === 'number' &&
schema.maxProperties === packedRequiredProperties.length +
unpackedRequiredProperties.length +
booleanRequiredProperties.length) {
return {
type: EncodingType.Object,
encoding: 'PACKED_BOUNDED_REQUIRED_OBJECT',
options: {
packedEncoding: additionalProperties,
packedRequiredProperties: packedRequiredProperties.sort(
(left: string, right: string) => {
return left.localeCompare(right)
}),
propertyEncodings: packedPropertyEncodings,
requiredProperties: unpackedRequiredProperties.sort(
(left: string, right: string) => {
return left.localeCompare(right)
}),
booleanRequiredProperties
}
}
}
return {
type: EncodingType.Object,
encoding: 'PACKED_UNBOUNDED_OBJECT',
options: {
packedEncoding: additionalProperties,
packedRequiredProperties: packedRequiredProperties.sort(
(left: string, right: string) => {
return left.localeCompare(right)
}),
encoding: {
type: EncodingType.Any,
encoding: 'ANY_PACKED_TYPE_TAG_BYTE_PREFIX',
options: {}
},
propertyEncodings: packedPropertyEncodings,
optionalProperties,
requiredProperties: unpackedRequiredProperties.sort(
(left: string, right: string) => {
return left.localeCompare(right)
}),
booleanRequiredProperties,
keyEncoding
}
}
}
if (allRequiredProperties.length > 0 && optionalProperties.length > 0) {
return {
type: EncodingType.Object,
encoding: 'MIXED_UNBOUNDED_TYPED_OBJECT',
options: {
propertyEncodings,
optionalProperties,
requiredProperties,
booleanRequiredProperties,
keyEncoding,
encoding: additionalProperties
}
}
} else if (allRequiredProperties.length > 0 && optionalProperties.length === 0) {
return {
type: EncodingType.Object,
encoding: 'REQUIRED_UNBOUNDED_TYPED_OBJECT',
options: {
encoding: additionalProperties,
propertyEncodings,
keyEncoding,
requiredProperties,
booleanRequiredProperties
}
}
} else if (allRequiredProperties.length === 0 && optionalProperties.length > 0) {
return {
type: EncodingType.Object,
encoding: 'OPTIONAL_UNBOUNDED_TYPED_OBJECT',
options: {
encoding: additionalProperties,
propertyEncodings,
keyEncoding,
optionalProperties
}
}
}
return {
type: EncodingType.Object,
encoding: 'ARBITRARY_TYPED_KEYS_OBJECT',
options: {
encoding: additionalProperties,
keyEncoding
}
}
}