Skip to content

Commit

Permalink
perf(validation): refactor validation to be based on observables inst…
Browse files Browse the repository at this point in the history
…ead of promises, run in idle callback
  • Loading branch information
bjoerge committed Sep 26, 2022
1 parent 6cd45cb commit 8b32bf9
Show file tree
Hide file tree
Showing 3 changed files with 86 additions and 45 deletions.
3 changes: 2 additions & 1 deletion packages/@sanity/validation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
"dependencies": {
"@sanity/types": "3.0.0-dev-preview.20",
"date-fns": "^2.26.1",
"lodash": "^4.17.21"
"lodash": "^4.17.21",
"rxjs": "^6.5.3"
},
"devDependencies": {
"@sanity/client": "^3.4.1",
Expand Down
2 changes: 2 additions & 0 deletions packages/@sanity/validation/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ import inferFromSchemaType from './inferFromSchemaType'

// export default {Rule: RuleClass, validateDocument, inferFromSchema, inferFromSchemaType}
export {RuleClass as Rule, validateDocument, inferFromSchema, inferFromSchemaType}

export {validateDocumentObservable} from './validateDocument'
126 changes: 82 additions & 44 deletions packages/@sanity/validation/src/validateDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import {
isBlockSchemaType,
isSpanSchemaType,
} from '@sanity/types'
import {uniqBy} from 'lodash'
import {concat, defer, merge, Observable, of} from 'rxjs'
import {catchError, map, mergeAll, mergeMap, toArray} from 'rxjs/operators'
import {flatten, uniqBy} from 'lodash'
import type {SanityClient} from '@sanity/client'
import typeString from './util/typeString'
import {requestIdleCallback, cancelIdleCallback} from './util/requestIdleCallback'
import {cancelIdleCallback, requestIdleCallback} from './util/requestIdleCallback'
import ValidationErrorClass from './ValidationError'
import normalizeValidationRules from './util/normalizeValidationRules'

Expand Down Expand Up @@ -47,17 +49,27 @@ export function resolveTypeForArrayItem(
candidates.find((candidate) => candidate.name === 'object' && primitive === 'object')
)
}
const EMPTY_MARKERS: ValidationMarker[] = []

export default async function validateDocument(
getClient: (options: {apiVersion: string}) => SanityClient,
doc: SanityDocument,
schema: Schema,
context?: Pick<ValidationContext, 'getDocumentExists'>
): Promise<ValidationMarker[]> {
return validateDocumentObservable(getClient, doc, schema, context).toPromise()
}

export function validateDocumentObservable(
getClient: (options: {apiVersion: string}) => SanityClient,
doc: SanityDocument,
schema: Schema,
context?: Pick<ValidationContext, 'getDocumentExists'>
): Observable<ValidationMarker[]> {
const documentType = schema.get(doc._type)
if (!documentType) {
console.warn('Schema type for object type "%s" not found, skipping validation', doc._type)
return []
return of(EMPTY_MARKERS)
}

const client = getClient({apiVersion: '2021-06-07'})
Expand Down Expand Up @@ -96,18 +108,19 @@ export default async function validateDocument(
/* eslint-enable no-proto */
// </TEMPORARY UGLY HACK TO PRINT DEPRECATION WARNINGS ON USE>

try {
return await validateItem(validationOptions)
} catch (err) {
console.error(err)
return [
{
level: 'error',
path: [],
item: new ValidationErrorClass(err?.message),
},
]
}
return validateItemObservable(validationOptions).pipe(
catchError((err) => {
console.error(err)
return of([
{
type: 'validation' as const,
level: 'error' as const,
path: [],
item: new ValidationErrorClass(err?.message),
},
])
})
)
}

/**
Expand All @@ -125,27 +138,32 @@ type ValidateItemOptions = {
value: unknown
} & ExplicitUndefined<ValidationContext>

export async function validateItem({
export function validateItem(opts: ValidateItemOptions): Promise<ValidationMarker[]> {
return validateItemObservable(opts).toPromise()
}

function validateItemObservable({
value,
type,
path = [],
parent,
...restOfContext
}: ValidateItemOptions): Promise<ValidationMarker[]> {
}: ValidateItemOptions): Observable<ValidationMarker[]> {
const rules = normalizeValidationRules(type)

// run validation for the current value
const selfChecks = rules.map((rule) =>
rule.validate(value, {
...restOfContext,
parent,
path,
type,
})
defer(() =>
rule.validate(value, {
...restOfContext,
parent,
path,
type,
})
)
)

// run validation for nested values (conditionally)
let nestedChecks: Array<Promise<ValidationMarker[]>> = []
let nestedChecks: Array<Observable<ValidationMarker[]>> = []

const selfIsRequired = rules.some((rule) => rule.isRequired())
const shouldRunNestedObjectValidation =
Expand All @@ -172,20 +190,22 @@ export async function validateItem({
const fieldType = fieldTypes[name]
return normalizeValidationRules({...fieldType, validation}).map((subRule) => {
const nestedValue = isRecord(value) ? value[name] : undefined
return subRule.validate(nestedValue, {
...restOfContext,
parent: value,
path: path.concat(name),
type: fieldType,
})
return defer(() =>
subRule.validate(nestedValue, {
...restOfContext,
parent: value,
path: path.concat(name),
type: fieldType,
})
)
})
})
)

// Validation from each field's schema `validation: Rule => {/* ... */}` function
nestedChecks = nestedChecks.concat(
type.fields.map((field) =>
validateItem({
validateItemObservable({
...restOfContext,
parent: value,
value: isRecord(value) ? value[field.name] : undefined,
Expand All @@ -205,7 +225,7 @@ export async function validateItem({
if (shouldRunNestedValidationForArrays) {
nestedChecks = nestedChecks.concat(
value.map((item) =>
validateItem({
validateItemObservable({
...restOfContext,
parent: value,
value: item,
Expand All @@ -226,16 +246,16 @@ export async function validateItem({
const spanType = spanChildrenField.type.of.find(isSpanSchemaType)

const annotations = (spanType?.annotations || []).reduce<Map<string, SchemaType>>(
(map, annotationType) => {
map.set(annotationType.name, annotationType)
return map
(acc, annotationType) => {
acc.set(annotationType.name, annotationType)
return acc
},
new Map()
)

nestedChecks = nestedChecks.concat(
value.markDefs.map((markDef) =>
validateItem({
validateItemObservable({
...restOfContext,
parent: value,
value: markDef,
Expand All @@ -246,13 +266,31 @@ export async function validateItem({
)
}

const results = (await Promise.all([...selfChecks, ...nestedChecks])).flat()
return defer(() => merge([...selfChecks, ...nestedChecks])).pipe(
mergeMap((validateNode) => concat(idle(), validateNode), 40),
mergeAll(),
toArray(),
map(flatten),
map((results) => {
// run `uniqBy` if `_fieldRules` are present because they can
// cause repeat markers
if (rules.some((rule) => rule._fieldRules)) {
return uniqBy(results, (rule) => JSON.stringify(rule))
}
return results
})
)
}

// run `uniqBy` if `_fieldRules` are present because they can
// cause repeat markers
if (rules.some((rule) => rule._fieldRules)) {
return uniqBy(results, (rule) => JSON.stringify(rule))
}
function idle(timeout?: number): Observable<never> {
return new Observable<never>((observer) => {
const handle = requestIdleCallback(
() => {
observer.complete()
},
timeout ? {timeout} : undefined
)

return results
return () => cancelIdleCallback(handle)
})
}

0 comments on commit 8b32bf9

Please sign in to comment.