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

feat(jex): extension check returns a reason on failure #120

Merged
merged 5 commits into from
Feb 15, 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
2 changes: 1 addition & 1 deletion jex/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bpinternal/jex",
"version": "0.0.1",
"version": "0.0.2",
"description": "JSON-Extends; JSON Schema type checking library",
"main": "dist/index.cjs",
"types": "dist/src/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion jex/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const jsonSchemaEquals = async (a: JSONSchema7, b: JSONSchema7): Promise<
return jex.jexEquals(jexA, jexB)
}

export const jsonSchemaExtends = async (child: JSONSchema7, parent: JSONSchema7): Promise<boolean> => {
export const jsonSchemaExtends = async (child: JSONSchema7, parent: JSONSchema7): Promise<jex.JexExtensionResult> => {
const jexChild = await jex.toJex(child)
const jexParent = await jex.toJex(parent)
return jex.jexExtends(jexChild, jexParent)
Expand Down
49 changes: 41 additions & 8 deletions jex/src/jex-representation/jex-extends.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
import * as utils from '../utils'
import { JexInfer } from './jex-infer'
import * as types from './typings'
import { jexExtends } from './jex-extends'
import { JexExtensionResult, jexExtends } from './jex-extends'
import { expect, test } from 'vitest'
import { $ } from './jex-builder'
import { toString } from './to-string'

const expectJex = (jexType: types.JexType) => ({
const SUBSET = `\u2286`

const failureMessage = (res: JexExtensionResult): string => {
if (res.extends) return ''
return '\n' + res.reasons.map((r) => ` - ${r}\n`).join('')
}

const successMessage = (typeA: types.JexType, typeB: types.JexType): string => {
return `${toString(typeA)} ${SUBSET} ${toString(typeB)}`
}

const expectJex = (typeA: types.JexType) => ({
not: {
toExtend: (parent: types.JexType) => {
const actual = jexExtends(jexType, parent)
expect(actual).toBe(false)
toExtend: (typeB: types.JexType) => {
const actual = jexExtends(typeA, typeB)
expect(actual.extends).to.eq(false, successMessage(typeA, typeB))
}
},
toExtend: (parent: types.JexType) => {
const actual = jexExtends(jexType, parent)
expect(actual).toBe(true)
toExtend: (typeB: types.JexType) => {
const actual = jexExtends(typeA, typeB)
expect(actual.extends).to.eq(true, failureMessage(actual))
}
})

Expand Down Expand Up @@ -50,13 +62,19 @@ test('jex-extends should be true if child is an object with more properties than
test('jex-extends should be false if child is an object with less properties than parent', () => {
const child = $.object({ a: $.string() })
const parent = $.object({ a: $.string(), b: $.number() })
type _child = JexInfer<typeof child>
type _parent = JexInfer<typeof parent>
type _childNotExtendsParent = utils.types.ExpectNot<utils.types.Extends<_child, _parent>>
expectJex(child).not.toExtend(parent)
})

// { a: string | undefined } does not extend { a: string }
test('jex-extends should be false if an optional property of child is required in parent', () => {
const child = $.object({ a: $.union([$.string(), $.undefined()]) })
const parent = $.object({ a: $.string() })
type _child = JexInfer<typeof child>
type _parent = JexInfer<typeof parent>
type _childNotExtendsParent = utils.types.ExpectNot<utils.types.Extends<_child, _parent>>
expectJex(child).not.toExtend(parent)
})

Expand Down Expand Up @@ -91,6 +109,9 @@ test('jex-extends should be true if child is a literal with same base type than
test('jex-extends should be false if child is a primitive and parent is a literal', () => {
const child = $.string()
const parent = $.literal('banana')
type _child = JexInfer<typeof child>
type _parent = JexInfer<typeof parent>
type _childNotExtendsParent = utils.types.ExpectNot<utils.types.Extends<_child, _parent>>
expectJex(child).not.toExtend(parent)
})

Expand All @@ -108,6 +129,9 @@ test('jex-extends should be true if child is a literal included in parent union'
test('jex-extends should be false if child is a literal not included in parent union', () => {
const child = $.literal('banana')
const parent = $.union([$.number(), $.literal('apple')])
type _child = JexInfer<typeof child>
type _parent = JexInfer<typeof parent>
type _childNotExtendsParent = utils.types.ExpectNot<utils.types.Extends<_child, _parent>>
expectJex(child).not.toExtend(parent)
})

Expand All @@ -125,6 +149,9 @@ test('jex-extends should be true if child and parents are arrays and child items
test('jex-extends should be false if child and parents are arrays and child items do not extends parent items', () => {
const child = $.array($.string())
const parent = $.array($.number())
type _child = JexInfer<typeof child>
type _parent = JexInfer<typeof parent>
type _childNotExtendsParent = utils.types.ExpectNot<utils.types.Extends<_child, _parent>>
expectJex(child).not.toExtend(parent)
})

Expand All @@ -149,6 +176,9 @@ test('jex-extends should be true if child is a tuple and parent is an array with
test('jex-extends should be false if child is a tuple and parent is an array with different items', () => {
const child = $.tuple([$.string(), $.number()])
const parent = $.array($.number())
type _child = JexInfer<typeof child>
type _parent = JexInfer<typeof parent>
type _childNotExtendsParent = utils.types.ExpectNot<utils.types.Extends<_child, _parent>>
expectJex(child).not.toExtend(parent)
})

Expand Down Expand Up @@ -176,5 +206,8 @@ test('jex-extends should be true if child and parents are maps and child items e
test('jex-extends should be false if child and parents are maps and child items do not extends parent items', () => {
const child = $.map($.union([$.string(), $.undefined(), $.boolean()]))
const parent = $.map($.string())
type _child = JexInfer<typeof child>
type _parent = JexInfer<typeof parent>
type _childNotExtendsParent = utils.types.ExpectNot<utils.types.Extends<_child, _parent>>
expectJex(child).not.toExtend(parent)
})
186 changes: 135 additions & 51 deletions jex/src/jex-representation/jex-extends.ts
Original file line number Diff line number Diff line change
@@ -1,84 +1,168 @@
import * as types from './typings'
import { jexEquals } from './jex-equals'
import { PropertyPath, pathToString } from './property-path'
import { toString } from './to-string'

type LiteralOf<T extends types.JexPrimitive> = Extract<types.JexLiteral, { type: T['type'] }>
type _JexFailureReason = {
path: PropertyPath
typeA: types.JexType
typeB: types.JexType
}
type _JexExtensionSuccess = { result: true }
type _JexExtensionFailure = { result: false; reasons: _JexFailureReason[] }
type _JexExtensionResult = _JexExtensionSuccess | _JexExtensionFailure

const _splitSuccessFailure = (results: _JexExtensionResult[]): [_JexExtensionSuccess[], _JexExtensionFailure[]] => {
const success: _JexExtensionSuccess[] = []
const failure: _JexExtensionFailure[] = []
results.forEach((r) => {
if (r.result) success.push(r)
else failure.push(r)
})
return [success, failure]
}

const _primitiveExtends = <T extends types.JexPrimitive>(child: T | LiteralOf<T>, parent: types.JexType): boolean => {
const isT = (x: types.JexType): x is T | LiteralOf<T> => x.type === child.type
if (!isT(parent)) return false
type _LiteralOf<T extends types.JexPrimitive> = Extract<types.JexLiteral, { type: T['type'] }>
const _primitiveExtends = <T extends types.JexPrimitive>(
path: PropertyPath,
typeA: T | _LiteralOf<T>,
typeB: types.JexType
): _JexExtensionResult => {
const isT = (x: types.JexType): x is T | _LiteralOf<T> => x.type === typeA.type
if (!isT(typeB)) {
return {
result: false,
reasons: [{ path, typeA, typeB }]
}
}

type Primitive = LiteralOf<T> | (T & { value: undefined })
const asPrimitive = (value: T | LiteralOf<T>): Primitive =>
type Primitive = _LiteralOf<T> | (T & { value: undefined })
const asPrimitive = (value: T | _LiteralOf<T>): Primitive =>
'value' in value ? value : { ...value, value: undefined }

const _child = asPrimitive(child)
const _parent = asPrimitive(parent)
const _typeA = asPrimitive(typeA)
const _typeB = asPrimitive(typeB)

if (_parent.value === undefined && _child.value === undefined) {
return true
}
if (_parent.value === undefined) {
return true
if (_typeB.value !== undefined && _typeA.value === undefined) {
return {
result: false,
reasons: [{ path, typeA, typeB }]
}
}
if (_parent.value === _child.value) {
return true
if (_typeB.value !== undefined && _typeA.value !== _typeB.value) {
return {
result: false,
reasons: [{ path, typeA, typeB }]
}
}
return false
return { result: true }
}

export const jexExtends = (child: types.JexType, parent: types.JexType): boolean => {
if (parent.type === 'any' || child.type === 'any') {
return true
const _jexExtends = (path: PropertyPath, typeA: types.JexType, typeB: types.JexType): _JexExtensionResult => {
if (typeB.type === 'any' || typeA.type === 'any') {
return { result: true }
}

if (child.type === 'union') {
return child.anyOf.every((c) => jexExtends(c, parent))
if (typeA.type === 'union') {
const extensions = typeA.anyOf.map((c) => _jexExtends(path, c, typeB))
const [_, failures] = _splitSuccessFailure(extensions)
if (failures.length === 0) return { result: true } // A union all extends B
return { result: false, reasons: failures.flatMap((f) => f.reasons) }
}

if (parent.type === 'union') {
return parent.anyOf.some((p) => jexExtends(child, p))
if (typeB.type === 'union') {
const extensions = typeB.anyOf.map((c) => _jexExtends(path, typeA, c))
const [success, failures] = _splitSuccessFailure(extensions)
if (success.length > 0) return { result: true } // A extends at least one of the B union
return { result: false, reasons: failures.flatMap((f) => f.reasons) }
}

if (child.type === 'object') {
if (parent.type === 'map') {
return Object.values(child.properties).every((c) => jexExtends(c, parent.items))
if (typeA.type === 'object') {
if (typeB.type === 'map') {
const extensions = Object.entries(typeA.properties).map(([key, valueA]) => {
const newPath: PropertyPath = [...path, { type: 'key', value: key }]
return _jexExtends(newPath, valueA, typeB.items)
})
const [_, failures] = _splitSuccessFailure(extensions)
if (failures.length === 0) return { result: true } // All properties of A extend B
return { result: false, reasons: failures.flatMap((f) => f.reasons) }
}
if (parent.type === 'object') {
return Object.entries(parent.properties).every(([key, parentValue]) => {
const childValue = child.properties[key]
if (childValue === undefined) return false
return jexExtends(childValue, parentValue)

if (typeB.type === 'object') {
const extensions = Object.entries(typeB.properties).map(([key, valueB]): _JexExtensionResult => {
const valueA = typeA.properties[key]
if (valueA === undefined)
return { result: false, reasons: [{ path, typeA: { type: 'undefined' }, typeB: valueB }] } // undefined does not extend valueB
const newPath: PropertyPath = [...path, { type: 'key', value: key }]
return _jexExtends(newPath, valueA, valueB)
})

const [_, failures] = _splitSuccessFailure(extensions)
if (failures.length === 0) return { result: true } // All properties of A extend B
return { result: false, reasons: failures.flatMap((f) => f.reasons) }
}
return false

return { result: false, reasons: [{ path, typeA, typeB }] }
}

if (child.type === 'map') {
if (parent.type !== 'map') return false
return jexExtends(child.items, parent.items)
if (typeA.type === 'map') {
if (typeB.type !== 'map') return { result: false, reasons: [{ path, typeA, typeB }] }

const newPath: PropertyPath = [...path, { type: 'string-index' }]
return _jexExtends(newPath, typeA.items, typeB.items)
}

if (child.type === 'tuple') {
if (parent.type === 'array') {
return child.items.every((c) => jexExtends(c, parent.items))
if (typeA.type === 'tuple') {
if (typeB.type === 'array') {
const extensions = typeA.items.map((c, i) => {
const newPath: PropertyPath = [...path, { type: 'number-index', value: i }]
return _jexExtends(newPath, c, typeB.items)
})
const [_, failures] = _splitSuccessFailure(extensions)
if (failures.length === 0) return { result: true } // All items of A extend B
return { result: false, reasons: failures.flatMap((f) => f.reasons) }
}
if (parent.type === 'tuple') {
const zipped = child.items.map(
(c, i) => [c, parent.items[i]] satisfies [types.JexType, types.JexType | undefined]
)
return zipped.every(([c, p]) => p === undefined || jexExtends(c, p))
if (typeB.type === 'tuple') {
const zipped = typeA.items.map((c, i) => [c, typeB.items[i]] satisfies [types.JexType, types.JexType | undefined])
const extensions = zipped.map(([c, p], i): _JexExtensionResult => {
if (p === undefined) {
return { result: true } // A tuple is longer than B
}
const newPath: PropertyPath = [...path, { type: 'number-index', value: i }]
return _jexExtends(newPath, c, p)
})
const [_, failures] = _splitSuccessFailure(extensions)
if (failures.length === 0) return { result: true } // All items of A extend B
return { result: false, reasons: failures.flatMap((f) => f.reasons) }
}
return false

return { result: false, reasons: [{ path, typeA, typeB }] }
}

if (child.type === 'array') {
if (parent.type !== 'array') return false
return jexExtends(child.items, parent.items)
if (typeA.type === 'array') {
if (typeB.type !== 'array') {
return { result: false, reasons: [{ path, typeA, typeB }] }
}
const newPath: PropertyPath = [...path, { type: 'number-index' }]
return _jexExtends(newPath, typeA.items, typeB.items)
}

if (child.type === 'string' || child.type === 'number' || child.type === 'boolean') {
return _primitiveExtends(child, parent)
if (typeA.type === 'string' || typeA.type === 'number' || typeA.type === 'boolean') {
return _primitiveExtends(path, typeA, typeB)
}

return jexEquals(child, parent)
if (typeA.type !== typeB.type) {
return { result: false, reasons: [{ path, typeA, typeB }] }
}

return { result: true }
}

const _reasonToString = (reason: _JexFailureReason): string =>
`${pathToString(reason.path)}: ${toString(reason.typeA)} ⊈ ${toString(reason.typeB)}`

export type JexExtensionResult = { extends: true } | { extends: false; reasons: string[] }
export const jexExtends = (typeA: types.JexType, typeB: types.JexType): JexExtensionResult => {
const extension = _jexExtends([], typeA, typeB)
if (extension.result) return { extends: true }
return { extends: false, reasons: extension.reasons.map(_reasonToString) }
}
26 changes: 26 additions & 0 deletions jex/src/jex-representation/property-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { test, expect } from 'vitest'
import { PropertyPath, PropertyPathSection, pathToString } from './property-path'

const keyA: PropertyPathSection = { type: 'key', value: 'a' }
const keyB: PropertyPathSection = { type: 'key', value: 'b' }
const stringIndexBanana: PropertyPathSection = { type: 'string-index', value: 'banana' }
const numberIndex0: PropertyPathSection = { type: 'number-index', value: 0 }
const numberIndex: PropertyPathSection = { type: 'number-index' }
const stringIndex: PropertyPathSection = { type: 'string-index' }

const expectPath = (path: PropertyPath) => ({
toEqual: (expected: string) => {
const actual = pathToString(path)
expect(actual).to.eq(expected)
}
})

test('property path should correctly indicate which property is targeted', () => {
expectPath([]).toEqual('#')
expectPath([keyA]).toEqual('#.a')
expectPath([keyA, keyB]).toEqual('#.a.b')
expectPath([keyA, keyB, stringIndexBanana]).toEqual('#.a.b["banana"]')
expectPath([keyA, keyB, numberIndex0]).toEqual('#.a.b[0]')
expectPath([keyA, keyB, numberIndex]).toEqual('#.a.b[number]')
expectPath([keyA, keyB, stringIndex]).toEqual('#.a.b[string]')
})
Loading
Loading