Skip to content
Open
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
83 changes: 83 additions & 0 deletions packages/groq-lint/src/rules/__tests__/invalid-type-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ const testSchema: SchemaType = [
title: { type: 'objectAttribute', value: { type: 'string' } },
},
},
// Object types for discriminated unions (used in arrays)
{
type: 'object',
name: 'imageBlock',
attributes: {
_type: { type: 'objectAttribute', value: { type: 'string', value: 'imageBlock' } },
url: { type: 'objectAttribute', value: { type: 'string' } },
},
},
{
type: 'object',
name: 'textBlock',
attributes: {
_type: { type: 'objectAttribute', value: { type: 'string', value: 'textBlock' } },
text: { type: 'objectAttribute', value: { type: 'string' } },
},
},
]

describe('invalid-type-filter', () => {
Expand Down Expand Up @@ -110,4 +127,70 @@ describe('invalid-type-filter', () => {
expect(typeErrors).toHaveLength(0)
})
})

describe('discriminated unions (nested array filters)', () => {
it('accepts object type in nested array filter (issue #27)', () => {
// This was the exact case reported in issue #27
const result = lint('*[_type == "post"]{ "images": content[_type == "imageBlock"] }', {
schema: testSchema,
})
const typeErrors = result.findings.filter((f) => f.ruleId === 'invalid-type-filter')
expect(typeErrors).toHaveLength(0)
})

it('accepts multiple object types in nested filter', () => {
const result = lint(
'*[_type == "post"]{ content[_type == "imageBlock" || _type == "textBlock"] }',
{ schema: testSchema }
)
const typeErrors = result.findings.filter((f) => f.ruleId === 'invalid-type-filter')
expect(typeErrors).toHaveLength(0)
})

it('accepts object type after attribute access', () => {
const result = lint('*[_type == "post"].content[_type == "imageBlock"]', {
schema: testSchema,
})
const typeErrors = result.findings.filter((f) => f.ruleId === 'invalid-type-filter')
expect(typeErrors).toHaveLength(0)
})

it('accepts object type in deeply nested filter', () => {
const result = lint('*[_type == "post"]{ sections[]{ blocks[_type == "imageBlock"] } }', {
schema: testSchema,
})
const typeErrors = result.findings.filter((f) => f.ruleId === 'invalid-type-filter')
expect(typeErrors).toHaveLength(0)
})

it('still detects typo in top-level filter with nested filter present', () => {
// Top-level filter has typo, nested filter is fine
const result = lint('*[_type == "psot"]{ content[_type == "imageBlock"] }', {
schema: testSchema,
})
const typeErrors = result.findings.filter((f) => f.ruleId === 'invalid-type-filter')
expect(typeErrors).toHaveLength(1)
expect(typeErrors[0].message).toContain('psot')
})

it('validates both top-level filters in subquery', () => {
// Both filters have Everything as base, both should be validated
const result = lint('*[_type == "post" && references(*[_type == "athor"]._id)]', {
schema: testSchema,
})
const typeErrors = result.findings.filter((f) => f.ruleId === 'invalid-type-filter')
expect(typeErrors).toHaveLength(1)
expect(typeErrors[0].message).toContain('athor')
expect(typeErrors[0].help).toContain('author')
})

it('validates top-level filter in subquery correctly', () => {
// Valid subquery - both "post" and "author" are document types
const result = lint('*[_type == "post" && references(*[_type == "author"]._id)]', {
schema: testSchema,
})
const typeErrors = result.findings.filter((f) => f.ruleId === 'invalid-type-filter')
expect(typeErrors).toHaveLength(0)
})
})
})
32 changes: 30 additions & 2 deletions packages/groq-lint/src/rules/invalid-type-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

import type { Rule, Suggestion } from '@sanity-labs/lint-core'
import type { OpCallNode } from 'groq-js'
import type { ExprNode, OpCallNode } from 'groq-js'
import { walk } from '../walker'

/**
Expand Down Expand Up @@ -105,6 +105,27 @@ function isTypeComparison(node: OpCallNode): { typeName: string } | null {
return null
}

/**
* Check if we're inside a top-level document filter (*[...])
* Returns true only if the nearest Filter ancestor has Everything as its base
*
* This distinguishes between:
* - Top-level filters: *[_type == "post"] - should validate against document types
* - Nested array filters: content[_type == "imageBlock"] - object types in arrays
*/
function isInTopLevelDocumentFilter(parents: ExprNode[]): boolean {
// Walk up the parent chain to find the nearest Filter
for (let i = parents.length - 1; i >= 0; i--) {
const parent = parents[i]
if (parent && parent.type === 'Filter') {
// Check if this filter's base is Everything (i.e., *)
// Filter nodes always have a base property per groq-js types
return (parent as { base: ExprNode }).base.type === 'Everything'
}
}
return false
}

export const invalidTypeFilter: Rule = {
id: 'invalid-type-filter',
name: 'Invalid Type Filter',
Expand All @@ -120,12 +141,19 @@ export const invalidTypeFilter: Rule = {
const documentTypes = getSchemaDocumentTypes(schema as { type: string; name: string }[])
const documentTypeSet = new Set(documentTypes)

walk(ast, (node) => {
walk(ast, (node, walkContext) => {
if (node.type !== 'OpCall') return

const typeComparison = isTypeComparison(node as OpCallNode)
if (!typeComparison) return

// Only validate document types in top-level filters (*[...])
// Skip validation for nested array filters (e.g., content[_type == "imageBlock"])
// which are used for discriminated union object types
if (!isInTopLevelDocumentFilter(walkContext.parents)) {
return
}

const { typeName } = typeComparison

// Check if the type exists in the schema
Expand Down