Skip to content

Commit

Permalink
fix: accurate extension logic
Browse files Browse the repository at this point in the history
  • Loading branch information
franklevasseur committed Feb 9, 2024
1 parent 9ccd780 commit 13b6471
Show file tree
Hide file tree
Showing 5 changed files with 250 additions and 102 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,4 @@ gen/
# Other
*.config.json
.ignore.me.*
/.vscode

19 changes: 19 additions & 0 deletions jex/.vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Test File",
"autoAttachChildProcesses": true,
"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
"program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
"args": ["run", "${relativeFile}"],
"smartStep": true,
"console": "integratedTerminal"
}
]
}
224 changes: 139 additions & 85 deletions jex/src/jex-representation/jex-extends.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,118 +15,172 @@ const expectJex = (jexType: types.JexType) => ({
}
})

type TypeOf<T extends string | number | boolean> = T extends string
? 'string'
: T extends number
? 'number'
: T extends boolean
? 'boolean'
: never

type TupleOf<T extends any> =
| [T, T]
| [T, T, T]
| [T, T, T, T]
| [T, T, T, T, T]
| [T, T, T, T, T, T]
| [T, T, T, T, T, T, T]
| [T, T, T, T, T, T, T, T] // this is enough for the test

type jex = typeof jex
const jex = {
any: () => ({ type: 'any' }) satisfies types.JexType,
string: () => ({ type: 'string' }) satisfies types.JexType,
number: () => ({ type: 'number' }) satisfies types.JexType,
boolean: () => ({ type: 'boolean' }) satisfies types.JexType,
null: () => ({ type: 'null' }) satisfies types.JexType,
undefined: () => ({ type: 'undefined' }) satisfies types.JexType,
literal: <T extends string | number | boolean>(value: T): types.JexType => ({
type: typeof value as TypeOf<T>,
value
}),
object: (properties: Record<string, types.JexType>) => ({ type: 'object', properties }) satisfies types.JexType,
array: (items: types.JexType): types.JexType => ({ type: 'array', items }),
map: (items: types.JexType): types.JexType => ({ type: 'map', items }),
tuple: (items: types.JexType[]): types.JexType => ({ type: 'tuple', items }),
union: (anyOf: TupleOf<types.JexType>): types.JexType => ({ type: 'union', anyOf })
}

// any extends string, string extends any, any extends any
test('jex-extends should be true if child or parent is any', () => {
expectJex({ type: 'any' }).toExtend({ type: 'any' })
expectJex({ type: 'string' }).toExtend({ type: 'any' })
expectJex({ type: 'any' }).toExtend({ type: 'string' })
expectJex(jex.any()).toExtend(jex.any())
expectJex(jex.string()).toExtend(jex.any())
expectJex(jex.any()).toExtend(jex.string())
})

// string extends string, { a: string } extends { a: string }, etc..
test('jex-extends should be true if child and parent are the same', () => {
expectJex({ type: 'string' }).toExtend({ type: 'string' })
expectJex({ type: 'number' }).toExtend({ type: 'number' })
expectJex({ type: 'boolean' }).toExtend({ type: 'boolean' })
expectJex({ type: 'null' }).toExtend({ type: 'null' })
expectJex({ type: 'undefined' }).toExtend({ type: 'undefined' })
expectJex({
type: 'object',
properties: {
a: { type: 'string' }
}
}).toExtend({
type: 'object',
properties: {
a: { type: 'string' }
}
})
expectJex({
type: 'union',
anyOf: [{ type: 'string' }, { type: 'number' }]
}).toExtend({
type: 'union',
anyOf: [{ type: 'string' }, { type: 'number' }]
})
expectJex(jex.string()).toExtend(jex.string())
expectJex(jex.number()).toExtend(jex.number())
expectJex(jex.boolean()).toExtend(jex.boolean())
expectJex(jex.null()).toExtend(jex.null())
expectJex(jex.undefined()).toExtend(jex.undefined())
expectJex(jex.object({ a: jex.string() })).toExtend(jex.object({ a: jex.string() }))
expectJex(jex.union([jex.string(), jex.number()])).toExtend(jex.union([jex.string(), jex.number()]))
})

// { a: string, b: number } extends { a: string }
test('jex-extends should be true if child is an object with more properties than parent', () => {
const child: types.JexType = {
type: 'object',
properties: {
a: { type: 'string' },
b: { type: 'number' }
}
}
const child = jex.object({ a: jex.string(), b: jex.number() })
const parent = jex.object({ a: jex.string() })
expectJex(child).toExtend(parent)
})

const parent: types.JexType = {
type: 'object',
properties: {
a: { type: 'string' }
}
}
// { a: string } does not extend { a: string, b: number }
test('jex-extends should be false if child is an object with less properties than parent', () => {
const child = jex.object({ a: jex.string() })
const parent = jex.object({ a: jex.string(), b: jex.number() })
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 = jex.object({ a: jex.union([jex.string(), jex.undefined()]) })
const parent = jex.object({ a: jex.string() })
expectJex(child).not.toExtend(parent)
})

// string does not extend string | number
test('jex-extends should be true if when child is a union with less types than parent', () => {
const child = jex.union([jex.string(), jex.number()])
const parent = jex.union([jex.string(), jex.number(), jex.undefined(), jex.null()])
expectJex(child).toExtend(parent)
})

test('jex-extends should be false if child is an object with less properties than parent', () => {
const child: types.JexType = {
type: 'object',
properties: {
a: { type: 'string' }
}
}
// string | number does not extends string
test('jex-extends should be false if child is a union with more types than parent', () => {
const child = jex.union([jex.string(), jex.number(), jex.undefined(), jex.null()])
const parent = jex.union([jex.string(), jex.number()])
expectJex(child).not.toExtend(parent)
})

const parent: types.JexType = {
type: 'object',
properties: {
a: { type: 'string' },
b: { type: 'number' }
}
}
// "banana" extends string
test('jex-extends should be true if child is a literal with same base type than parent', () => {
const child = jex.literal('banana')
const parent = jex.union([jex.string(), jex.number()])
expectJex(child).toExtend(parent)
})

// string does not extend "banana"
test('jex-extends should be false if child is a primitive and parent is a literal', () => {
const child = jex.string()
const parent = jex.literal('banana')
expectJex(child).not.toExtend(parent)
})

test('jex-extends should be false if an optional property of child is required in parent', () => {
const child: types.JexType = {
type: 'object',
properties: {
a: { type: 'union', anyOf: [{ type: 'string' }, { type: 'undefined' }] }
}
}
// "banana" extends string | "apple" | number
test('jex-extends should be true if child is a literal included in parent union', () => {
const child = jex.literal('banana')
const parent = jex.union([jex.string(), jex.number(), jex.literal('banana')])
expectJex(child).toExtend(parent)
})

const parent: types.JexType = {
type: 'object',
properties: {
a: { type: 'string' }
}
}
// "banana" does not extend "apple" | number
test('jex-extends should be false if child is a literal not included in parent union', () => {
const child = jex.literal('banana')
const parent = jex.union([jex.number(), jex.literal('apple')])
expectJex(child).not.toExtend(parent)
})

// { a: string }[] extends { a: string | null }[]
test('jex-extends should be true if child and parents are arrays and child items extends parent items', () => {
const child = jex.array(jex.string())
const parent = jex.array(jex.union([jex.string(), jex.undefined()]))
expectJex(child).toExtend(parent)
})

// { a: string }[] does not extend { a: number }[]
test('jex-extends should be false if child and parents are arrays and child items do not extends parent items', () => {
const child = jex.array(jex.string())
const parent = jex.array(jex.number())
expectJex(child).not.toExtend(parent)
})

test('jex-extends should be true child is a union with more types than parent', () => {
const child: types.JexType = {
type: 'union',
anyOf: [{ type: 'string' }, { type: 'number' }]
}
// [string, string] extends string[], [string, number] extends 🔥 (string | number)[]
test('jex-extends should be true if child is a tuple and parent is an array with the same items', () => {
const tupleStrStr = jex.tuple([jex.string(), jex.string()])
const arrayStr = jex.array(jex.string())
expectJex(tupleStrStr).toExtend(arrayStr)

const parent: types.JexType = {
type: 'union',
anyOf: [{ type: 'string' }]
}
const tupleStrNum = jex.tuple([jex.string(), jex.number()])
const arrayStrNum = jex.array(jex.union([jex.string(), jex.number()]))
expectJex(tupleStrNum).toExtend(arrayStrNum)
})

expectJex(child).toExtend(parent)
// [string, string] does not extend number[]
test('jex-extends should be false if child is a tuple and parent is an array with different items', () => {
const child = jex.tuple([jex.string(), jex.number()])
const parent = jex.array(jex.number())
expectJex(child).not.toExtend(parent)
})

test('jex-extends should be false child is a union with less types than parent', () => {
const child: types.JexType = {
type: 'union',
anyOf: [{ type: 'string' }]
}
// { a: string } extends { a: string | number }
test('jex-extends should be true if child and parent are objects and child items extends parent items', () => {
const child = jex.object({ a: jex.string() })
const parent = jex.object({ a: jex.union([jex.string(), jex.number()]) })
expectJex(child).toExtend(parent)
})

const parent: types.JexType = {
type: 'union',
anyOf: [{ type: 'string' }, { type: 'number' }]
}
// Record<string, string> extends Record<string, string | undefined | boolean>
test('jex-extends should be true if child and parents are maps and child items extends parent items', () => {
const child = jex.map(jex.string())
const parent = jex.map(jex.union([jex.string(), jex.undefined(), jex.boolean()]))
expectJex(child).toExtend(parent)
})

// Record<string, string | undefined | boolean> does not extend Record<string, string>
test('jex-extends should be false if child and parents are maps and child items do not extends parent items', () => {
const child = jex.map(jex.union([jex.string(), jex.undefined(), jex.boolean()]))
const parent = jex.map(jex.string())
expectJex(child).not.toExtend(parent)
})
88 changes: 72 additions & 16 deletions jex/src/jex-representation/jex-extends.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,85 @@
import * as types from './typings'
import { JexSet, jexEquals } from './jex-equals'

const _primitiveExtends = <T extends types.JexPrimitive>(
child: T | types.JexLiteral<T>,
parent: types.JexType
): boolean => {
const isT = (x: types.JexType): x is T | types.JexLiteral<T> => x.type === child.type
if (!isT(parent)) return false

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

const _child = asPrimitive(child)
const _parent = asPrimitive(parent)

if (_parent.value === undefined && _child.value === undefined) {
return true
}
if (_parent.value === undefined) {
return true
}
if (_parent.value === _child.value) {
return true
}
return false
}

export const jexExtends = (child: types.JexType, parent: types.JexType): boolean => {
if (parent.type === 'any') return true
if (child.type === 'any') return true
if (parent.type === 'any' || child.type === 'any') {
return true
}

if (child.type === 'union') {
return child.anyOf.every((c) => jexExtends(c, parent))
}

const areEqual = jexEquals(child, parent)
if (areEqual) return true
if (parent.type === 'union') {
return parent.anyOf.some((p) => jexExtends(child, p))
}

if (child.type === 'object') {
if (parent.type !== 'object') return false
for (const [key, parentValue] of Object.entries(parent.properties)) {
const childValue = child.properties[key]
if (!childValue) return false
if (!jexExtends(childValue, parentValue)) return false
if (parent.type === 'map') {
return Object.values(child.properties).every((c) => jexExtends(c, parent.items))
}
return true
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)
})
}
return false
}

if (child.type === 'union') {
if (parent.type !== 'union') return false
const childProps = new JexSet(child.anyOf)
const parentProps = new JexSet(parent.anyOf)
return parentProps.isSubsetOf(childProps) // parentProps ⊆ childProps
if (child.type === 'map') {
if (parent.type !== 'map') return false
return jexExtends(child.items, parent.items)
}

return false
if (child.type === 'tuple') {
if (parent.type === 'array') {
return child.items.every((c) => jexExtends(c, parent.items))
}
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))
}
return false
}

if (child.type === 'array') {
if (parent.type !== 'array') return false
return jexExtends(child.items, parent.items)
}

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

return jexEquals(child, parent)
}
Loading

0 comments on commit 13b6471

Please sign in to comment.