Skip to content

Commit

Permalink
fix(validation): pass path and parent correctly to validateItem
Browse files Browse the repository at this point in the history
  • Loading branch information
ricokahler committed Sep 7, 2021
1 parent 6629482 commit 060c9a2
Show file tree
Hide file tree
Showing 8 changed files with 756 additions and 278 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const normalizeRules = (
)
}
if (!validation) return []
if (Array.isArray(validation)) return validation
if (Array.isArray(validation)) return validation as Rule[]
return [validation]
}

Expand Down
2 changes: 1 addition & 1 deletion packages/@sanity/validation/src/inferFromSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import inferFromSchemaType from './inferFromSchemaType'
function inferFromSchema(schema: Schema): Schema {
const typeNames = schema.getTypeNames()
typeNames.forEach((typeName) => {
inferFromSchemaType(schema.get(typeName), schema)
inferFromSchemaType(schema.get(typeName))
})
return schema
}
Expand Down
154 changes: 28 additions & 126 deletions packages/@sanity/validation/src/inferFromSchemaType.ts
Original file line number Diff line number Diff line change
@@ -1,148 +1,50 @@
import {Schema, SchemaType, Rule as IRule} from '@sanity/types'
import RuleClass from './Rule'
import {slugValidator} from './validators/slugValidator'
import {blockValidator} from './validators/blockValidator'
import {Schema, SchemaType} from '@sanity/types'
import normalizeValidationRules from './util/normalizeValidationRules'

function inferFromSchemaType(
typeDef: SchemaType,
schema: Schema,
visited = new Set<SchemaType>()
): SchemaType {
function traverse(typeDef: SchemaType, visited: Set<SchemaType>) {
if (visited.has(typeDef)) {
return typeDef
return
}

visited.add(typeDef)

if (typeDef.validation === false) {
typeDef.validation = []
return typeDef
}

const isInitialized =
Array.isArray(typeDef.validation) &&
typeDef.validation.every((item) => typeof item?.validate === 'function')

if (isInitialized) {
inferForFields(typeDef, schema, visited)
inferForMemberTypes(typeDef, schema, visited)
return typeDef
}

const type = typeDef.type
const typed = RuleClass[typeDef.jsonType]
let base = typed ? typed(typeDef) : new RuleClass(typeDef)

if (type && type.name === 'datetime') {
base = base.type('Date')
}

if (type && type.name === 'date') {
base = base.type('Date')
}

if (type && type.name === 'url') {
base = base.uri()
}

if (type && type.name === 'slug') {
base = base.custom(slugValidator)
}
typeDef.validation = normalizeValidationRules(typeDef)

if (type && type.name === 'reference') {
base = base.reference()
if ('fields' in typeDef) {
for (const field of typeDef.fields) {
traverse(field.type, visited)
}
}

if (type && type.name === 'email') {
base = base.email()
}

if (type && type.name === 'block') {
base = base.block(blockValidator)
if ('of' in typeDef) {
for (const candidate of typeDef.of) {
traverse(candidate, visited)
}
}

// eslint-disable-next-line no-warning-comments
// @ts-expect-error TODO (eventually): `annotations` does not exist on the SchemaType yet
if (typeDef.annotations) {
// eslint-disable-next-line no-warning-comments
// @ts-expect-error TODO (eventually): `annotations` does not exist on the SchemaType yet
typeDef.annotations.forEach((annotation) => inferFromSchemaType(annotation))
for (const annotation of typeDef.annotations) {
traverse(annotation, visited)
}
}

// eslint-disable-next-line no-warning-comments
// @ts-expect-error TODO (eventually): fix options list grabbing
if (typeDef.options && typeDef.options.list && Array.isArray(typeDef.options.list)) {
base = base.valid(
// eslint-disable-next-line no-warning-comments
// @ts-expect-error TODO (eventually): fix options list grabbing
typeDef.options.list.map((option) => extractValueFromListOption(option, typeDef))
)
}

typeDef.validation = inferValidation(typeDef, base)
inferForFields(typeDef, schema, visited)
inferForMemberTypes(typeDef, schema, visited)

return typeDef
}

function inferForFields(typeDef: SchemaType, schema: Schema, visited: Set<SchemaType>): void {
if (typeDef.jsonType !== 'object' || !typeDef.fields) {
return
}

typeDef.fields.forEach((field) => {
inferFromSchemaType(field.type, schema, visited)
})
}

function inferForMemberTypes(typeDef: SchemaType, schema: Schema, visited: Set<SchemaType>): void {
if (typeDef.jsonType === 'array' && typeDef.of) {
typeDef.of.forEach((candidate) => inferFromSchemaType(candidate, schema, visited))
}
}

function extractValueFromListOption(option: unknown, typeDef: SchemaType): unknown {
// If you define a `list` option with object items, where the item has a `value` field,
// we don't want to treat that as the value but rather the surrounding object
// This differs from the case where you have a title/value pair setup for a string/number, for instance
if (typeDef.jsonType === 'object' && hasValueField(typeDef)) {
return option
}

return (option as Record<string, unknown>).value === undefined
? option
: (option as Record<string, unknown>).value
}

function hasValueField(typeDef: SchemaType): boolean {
if (!('fields' in typeDef) && typeDef.type) {
return hasValueField(typeDef.type)
}

if (!typeDef || !('fields' in typeDef)) {
return false
}

if (!Array.isArray(typeDef.fields)) {
return false
}

if (typeDef.fields.some((field) => field.name === 'value')) {
return true
}

return false
}

function inferValidation(field: SchemaType, baseRule: IRule): IRule[] {
if (!field.validation) {
return [baseRule]
}

const validation =
typeof field.validation === 'function' ? field.validation(baseRule) : field.validation
return Array.isArray(validation) ? validation : [validation]
// NOTE: this overload is for TS API compatibility with a previous implementation
function inferFromSchemaType(
typeDef: SchemaType,
// these are intentionally unused
_schema: Schema,
_visited?: Set<SchemaType>
): SchemaType
// note: this seemingly redundant overload is required
function inferFromSchemaType(typeDef: SchemaType): SchemaType
function inferFromSchemaType(typeDef: SchemaType): SchemaType {
traverse(typeDef, new Set())
return typeDef
}

export default inferFromSchemaType
170 changes: 170 additions & 0 deletions packages/@sanity/validation/src/util/normalizeValidationRules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import {NumberSchemaType, SchemaType, StringSchemaType} from '@sanity/types'
import RuleClass from '../Rule'
import normalizeValidationRules from './normalizeValidationRules'

describe('normalizeValidationRules', () => {
// see `infer.test.ts` for more related tests.
// note the Schema.compile runs this function indirectly via `inferFromSchema`
it('utilizes schema types to infer base rules', () => {
const coolNumberType: NumberSchemaType = {
jsonType: 'number',
name: 'coolNumber',
}

const rules = normalizeValidationRules(coolNumberType)
expect(rules).toHaveLength(1)
const [rule] = rules

expect(rule).toBeInstanceOf(RuleClass)
expect(rule._rules).toMatchObject([
{
constraint: 'Number',
flag: 'type',
},
])
})

it('follows the type chain to determine the base rule', () => {
const sickDatetime = {
type: {
type: {
jsonType: 'string',
},
name: 'datetime',
},
name: 'sickDatetime',
}

const rules = normalizeValidationRules(sickDatetime as SchemaType)
expect(rules).toHaveLength(1)
const [rule] = rules

// type chain is applied from inner to outer so the resulting type should be
// date instead of string
expect(rule._rules).toMatchObject([
{
constraint: 'Date',
flag: 'type',
},
])
})

it('converts a validation function to a rule instance', () => {
const coolStringType: StringSchemaType = {
jsonType: 'string',
name: 'coolString',
validation: (rule) => rule.uppercase(),
}

const rules = normalizeValidationRules(coolStringType)
expect(rules).toHaveLength(1)
const [rule] = rules

expect(rule).toBeInstanceOf(RuleClass)
expect(rule._rules).toMatchObject([
{
constraint: 'String',
flag: 'type',
},
{
constraint: 'uppercase',
flag: 'stringCasing',
},
])
})

it('converts falsy values to an empty array', () => {
expect(normalizeValidationRules(undefined)).toEqual([])
})

it('converts schema list options with titles to `rule.valid` constraints', () => {
const stringTypeWithOptions: StringSchemaType = {
jsonType: 'string',
name: 'stringTypeWithOptions',
options: {
list: [
{title: 'Blue', value: 'blue'},
{title: 'Red', value: 'red'},
],
},
}

const rules = normalizeValidationRules(stringTypeWithOptions)
expect(rules).toHaveLength(1)
const [rule] = rules

expect(rule).toBeInstanceOf(RuleClass)
expect(rule._rules).toMatchObject([
{
constraint: 'String',
flag: 'type',
},
{
constraint: ['blue', 'red'],
flag: 'valid',
},
])
})

it('converts schema list options with strings only to `rule.valid` constraints', () => {
const stringTypeWithOptions: StringSchemaType = {
jsonType: 'string',
name: 'stringTypeWithOptions',
options: {
list: ['blue', 'red'],
},
}

const rules = normalizeValidationRules(stringTypeWithOptions)
expect(rules).toHaveLength(1)
const [rule] = rules

expect(rule).toBeInstanceOf(RuleClass)
expect(rule._rules).toMatchObject([
{
constraint: 'String',
flag: 'type',
},
{
constraint: ['blue', 'red'],
flag: 'valid',
},
])
})

it('converts arrays of validation', () => {
const coolNumberType: NumberSchemaType = {
jsonType: 'number',
name: 'coolNumber',
validation: [(rule) => rule.greaterThan(3), RuleClass.number().lessThan(5)],
}

const rules = normalizeValidationRules(coolNumberType)
expect(rules).toHaveLength(2)
const [first, second] = rules

expect(first).toBeInstanceOf(RuleClass)
expect(first._rules).toMatchObject([
{
constraint: 'Number',
flag: 'type',
},
{
constraint: 3,
flag: 'greaterThan',
},
])

expect(second).toBeInstanceOf(RuleClass)
expect(second._rules).toMatchObject([
{
constraint: 'Number',
flag: 'type',
},
{
constraint: 5,
flag: 'lessThan',
},
])
})
})
Loading

2 comments on commit 060c9a2

@vercel
Copy link

@vercel vercel bot commented on 060c9a2 Sep 7, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

test-studio – ./

test-studio-git-next.sanity.build
test-studio.sanity.build

@vercel
Copy link

@vercel vercel bot commented on 060c9a2 Sep 7, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

perf-studio – ./

perf-studio.sanity.build
perf-studio-git-next.sanity.build

Please sign in to comment.