Skip to content

Commit

Permalink
fix: various type evaluation bugs
Browse files Browse the repository at this point in the history
  • Loading branch information
judofyr committed Mar 18, 2024
1 parent 8e3f7e4 commit 3da42d7
Show file tree
Hide file tree
Showing 3 changed files with 198 additions and 169 deletions.
238 changes: 80 additions & 158 deletions src/typeEvaluator/typeEvaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,98 +369,41 @@ function handleSelectNode(node: SelectNode, scope: Scope): TypeNode {
function handleArrayCoerceNode(node: ArrayCoerceNode, scope: Scope): TypeNode {
const base = walk({node: node.base, scope})
$trace('arrayCoerce.base %O', base)
return mapUnion(base, (base) => {
if (base.type !== 'array') {
return {type: 'null'} satisfies NullTypeNode
}

return base
})
return mapArray(base, scope, (base) => base)
}
function handleFlatMap(node: FlatMapNode, scope: Scope): TypeNode {
const base = walk({node: node.base, scope})
return mapUnion(base, (base) => {
if (base.type !== 'array') {
return base
}

return walk({node: node.expr, scope: scope.createHidden([base.of])})
})
}
function handleMap(node: MapNode, scope: Scope): TypeNode {
const base = walk({node: node.base, scope})
$trace('map.base %O', base)
return mapUnion(base, (base) => {
if (base.type !== 'array') {
return base
}

// if this is an inline type we resolve it since we want to map over the actual value.
return maybeResolveInline(base.of, scope, (base) => {
if (base.type === 'union') {
const value = walk({node: node.expr, scope: scope.createHidden(base.of)}) // re use the current parent, this is a "sub" scope
$trace('map.expr %O', value)

return {
type: 'array',
of: value,
} satisfies ArrayTypeNode
}
if (base.type === 'object') {
const value = walk({node: node.expr, scope: scope.createHidden([base])}) // re use the current parent, this is a "sub" scope
$trace('map.expr %O', value)
return mapArray(base, scope, (base) => {
const inner = walk({node: node.expr, scope: scope.createHidden([base.of])})

return {
type: 'array',
of: value,
} satisfies ArrayTypeNode
return mapConcrete(inner, scope, (inner) => {
if (inner.type === 'array') {
return inner
}

return {type: 'unknown'} satisfies UnknownTypeNode
return {type: 'array', of: inner}
})
})
}
function handleMap(node: MapNode, scope: Scope): TypeNode {
const base = walk({node: node.base, scope})
$trace('map.base %O', base)

function mapProjectionInScope(
base: TypeNode,
scope: Scope,
mapper: (field: TypeNode) => TypeNode,
): TypeNode {
if (base.type === 'union') {
if (base.of.length === 1) {
return mapProjectionInScope(base.of[0], scope, mapper)
}
const of = base.of.map((node) => mapProjectionInScope(node, scope, mapper))
return mapArray(base, scope, (base) => {
return {
type: 'union',
of,
type: 'array',
of: walk({node: node.expr, scope: scope.createHidden([base.of])}),
}
}

if (base.type === 'array') {
return mapper(base.of)
}

return mapper(base)
})
}

function handleProjectionNode(node: ProjectionNode, scope: Scope): TypeNode {
const base = walk({node: node.base, scope})
$trace('projection.base %O', base)

if (base.type === 'unknown' || base.type === 'null') {
return {type: 'null'}
}

return mapProjectionInScope(base, scope, (field) => {
if (field.type === 'null' || field.type === 'unknown') {
return {type: 'null'}
}
return walk({
node: node.expr,
scope: scope.createNested([field]),
})
})
return mapObject(base, scope, (base) =>
walk({node: node.expr, scope: scope.createNested([base])}),
)
}

function createFilterScope(base: TypeNode, scope: Scope): Scope {
Expand All @@ -487,67 +430,15 @@ function handleFilterNode(node: FilterNode, scope: Scope): TypeNode {
}
}

function mergeInlineObject(dst: ObjectTypeNode, src: ObjectTypeNode): ObjectTypeNode {
return {
type: 'object',
attributes: {...dst.attributes, ...src.attributes},
rest: dst.rest,
dereferencesTo: dst.dereferencesTo,
}
}

function mapFieldInScope(
field: TypeNode,
scope: Scope,
mapper: (field: Document | ObjectTypeNode) => TypeNode,
): TypeNode {
if (field.type === 'union') {
return {
type: 'union',
of: field.of.map((subField) => mapFieldInScope(subField, scope, mapper)),
}
}

if (field.type === 'inline') {
return mapFieldInScope(scope.context.lookupTypeDeclaration(field), scope, mapper)
}

if (field.type === 'object') {
// If the rest is not defined we can just map the object
if (field.rest === undefined) {
return mapper(field)
}

// If it's an unknown type it means that it's an open object and we can't be sure of the types
if (field.rest.type === 'unknown') {
return {type: 'unknown'}
}

// If it's an inline type we need to merge the inline type with the rest type
// throw an error if the rest type is not an object
if (field.rest.type === 'inline') {
const rest = scope.context.lookupTypeDeclaration(field.rest)
if (rest.type !== 'object') {
throw new Error(`rest inline type must be an object, got ${rest.type}`)
}
return mapper(mergeInlineObject(field, rest))
}

// We need to merge the rest type, which could now only be an object, with the current object
return mapper(mergeInlineObject(field, field.rest))
}

return {type: 'null'}
}

export function handleAccessAttributeNode(node: AccessAttributeNode, scope: Scope): TypeNode {
let attributeBase: TypeNode = scope.value
if (node.base) {
attributeBase = walk({node: node.base, scope})
}

$trace('accessAttribute.base %s %O', node.name, attributeBase)

return mapFieldInScope(attributeBase, scope, (base) => {
return mapObject(attributeBase, scope, (base) => {
const attribute = base.attributes[node.name]
if (attribute !== undefined) {
$debug(`accessAttribute.attribute found ${node.name} %O`, attribute)
Expand All @@ -557,29 +448,15 @@ export function handleAccessAttributeNode(node: AccessAttributeNode, scope: Scop

return attribute.value
}
$warn(
`attribute "${node.name}" not found in ${base.type === 'document' ? `document "${base.name}"` : 'object'}`,
)
$warn(`attribute "${node.name}" not found in object`)
return {type: 'null'}
})
}

function handleAccessElementNode(node: AccessElementNode, scope: Scope): TypeNode {
if (!node.base) {
return {type: 'unknown'} satisfies UnknownTypeNode
}
const base = walk({node: node.base, scope})
$trace('accessElement.base %O', base)
return mapUnion(base, (base) => {
if (base.type !== 'array') {
return {type: 'null'} satisfies NullTypeNode
}

return {
type: 'union',
of: [base.of, {type: 'null'}],
} satisfies UnionTypeNode
})
return mapArray(base, scope, (base) => nullUnion(base.of))
}

function handleArrayNode(node: ArrayNode, scope: Scope): TypeNode {
Expand Down Expand Up @@ -651,15 +528,8 @@ function handleValueNode(node: ValueNode, scope: Scope): TypeNode {

function handleSlice(node: SliceNode, scope: Scope): TypeNode {
$trace('slice.node %O', node)

const base = walk({node: node.base, scope})
return mapUnion(base, (base) => {
if (base.type !== 'array') {
return {type: 'null'} satisfies NullTypeNode
}

return base
})
return mapArray(base, scope, (base) => base)
}

function handleParentNode({n}: ParentNode, scope: Scope): TypeNode {
Expand Down Expand Up @@ -1144,14 +1014,66 @@ function mapUnion(node: TypeNode, mapper: (node: TypeNode) => TypeNode): TypeNod
return mapper(node)
}

function maybeResolveInline(
type ConcreteTypeNode =
| BooleanTypeNode
| NullTypeNode
| NumberTypeNode
| StringTypeNode
| ArrayTypeNode
| ObjectTypeNode

/**
* mapConcrete extracts a _concrete type_ from a type node, applies the mapping
* function to it and returns. Most notably, this will work through unions
* (applying the mapping function for each variant) and inline (resolving the
* reference).
*
* An `unknown` input type causes it to return `unknown` as well.
*/
function mapConcrete(
node: TypeNode,
scope: Scope,
mapper: (node: TypeNode) => TypeNode,
mapper: (node: ConcreteTypeNode) => TypeNode,
): TypeNode {
if (node.type === 'inline') {
const resolvedInline = scope.context.lookupTypeDeclaration(node)
return mapper(resolvedInline)
switch (node.type) {
case 'boolean':
case 'array':
case 'null':
case 'object':
case 'string':
case 'number':
return mapper(node)
case 'unknown':
return node
case 'union':
return optimizeUnions({
type: 'union',
of: node.of.map((inner) => mapConcrete(inner, scope, mapper)),
})
case 'inline': {
const resolvedInline = scope.context.lookupTypeDeclaration(node)
return mapConcrete(resolvedInline, scope, mapper)
}
default:
// @ts-expect-error
throw new Error(`Unknown type: ${node.type}`)
}
return mapper(node)
}

function mapArray(
node: TypeNode,
scope: Scope,
mapper: (node: ArrayTypeNode) => TypeNode,
): TypeNode {
return mapConcrete(node, scope, (base) => (base.type === 'array' ? mapper(base) : {type: 'null'}))
}

function mapObject(
node: TypeNode,
scope: Scope,
mapper: (node: ObjectTypeNode) => TypeNode,
): TypeNode {
return mapConcrete(node, scope, (base) =>
base.type === 'object' ? mapper(base) : {type: 'null'},
)
}
9 changes: 6 additions & 3 deletions tap-snapshots/test/typeEvaluate.test.ts.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,12 @@ Object {
exports[`test/typeEvaluate.test.ts TAP flatmap > must match snapshot 1`] = `
Object {
"of": Array [
Object {
"of": Object {
"type": "null",
},
"type": "array",
},
Object {
"of": Object {
"of": Array [
Expand Down Expand Up @@ -1073,9 +1079,6 @@ Object {
},
"type": "array",
},
Object {
"type": "null",
},
],
"type": "union",
}
Expand Down
Loading

0 comments on commit 3da42d7

Please sign in to comment.