diff --git a/OPTIMIZATION_OPPORTUNITIES.md b/OPTIMIZATION_OPPORTUNITIES.md new file mode 100644 index 000000000..ad370579e --- /dev/null +++ b/OPTIMIZATION_OPPORTUNITIES.md @@ -0,0 +1,276 @@ +# Schema Validation — Optimization Opportunities + +## Context + +The most common validation call site is in `form.js` (`inputEventHandler`): + +```js +const modelErrors = model.validate(model.attributes, { attributes: [prop] }); +``` + +This fires on **every input event** (keystroke / change) for form-bound inputs. Performance here directly impacts UI responsiveness. + +--- + +## Current Code Path: `validate(model.attributes, { attributes: ['firstName'] })` + +When called from `form.js`, the `validate()` method executes these steps: + +| # | Code | Operation | Allocations | +|---|------|-----------|-------------| +| 1 | `getSchema(this.constructor)` | Cached schema lookup | none | +| 2 | `extend({}, defaultOptions, setOptions)` | Merge options | 1 object | +| 3 | `getValidationPaths(model, attrs, ['firstName'], schema)` | Returns `['firstName'].slice()` (fast: hits first branch) | 1 array (1 element) | +| 4 | `getDefaultAttrs(['firstName'], schema)` | Creates `{ firstName: undefined }` via Set + reduce | 1 Set, 1 object | +| 5 | `extend({}, getDefaultAttrs(...), model.attributes, attrs)` | Shallow-merges all model attributes — **attrs === model.attributes**, so merges twice | 1 object (N keys) | +| 6 | **`schema.safeParse(allAttrs)`** | **Full Zod schema parse of ALL fields** | Zod internals | +| 7 | `formatZodErrors(result.error)` | Formats errors if any | 1 object (if errors) | +| 8 | `pickMatchingErrors(allErrors, ['firstName'])` | Filters to related paths | 1 object | +| 9 | `getMatchingError(invalidAttrs, 'firstName')` | Finds error message for callbacks | none | +| 10 | `model.trigger('validated', ...)` | Event emission | event args | + +**The dominant cost is step 6** — Zod parses _every_ field in the schema even though `attributes: ['firstName']` means we only care about one field's errors. Everything else is sub-microsecond overhead. + +--- + +## Optimization Opportunity 1: Fast-Path Single-Field Validation in `validate()` + +**Impact: HIGH** — eliminates full-schema parse for the most common call pattern. + +### Problem + +`preValidate()` already has a fast path (`validateAttrFast`) that validates a single top-level field via `shape[attr].safeParse(value)` when the schema has no object-level refinements. But `validate()` — the method actually called by `form.js` — always does `schema.safeParse(allAttrs)`. + +### Proposed Change + +When all conditions are met: +- `opt.attributes` has exactly 1 entry +- That entry is a top-level key (no dots) +- `isSimpleObjectSchema(schema)` is true (no refinements/superRefine) +- Default callbacks are used (`valid` and `invalid` are `Function.prototype`) + +Then call `shape[attr].safeParse(attrs[attr])` instead of `schema.safeParse(allAttrs)`. + +```js +// Inside validate(), after computing requestedPaths: +if ( + requestedPaths.length === 1 && + !requestedPaths[0].includes('.') && + isSimpleObjectSchema(schema) && + opt.valid === Function.prototype && + opt.invalid === Function.prototype +) { + var attr = requestedPaths[0]; + var error = validateAttrFast(attr, (attrs || model.attributes)[attr], schema); + var result = error ? { [attr]: error } : null; + model.trigger('validated', model, result, setOptions); + return result || undefined; +} +``` + +### Estimated Impact + +Based on benchmarks of the existing `preValidate` fast path vs full parse: +- Valid model: ~3µs → ~0.7µs (**~4x faster**) +- Invalid model: ~24µs → ~18µs (**~25% faster**, Zod error creation dominates) + +### Caveats + +- Cannot be used when `valid`/`invalid` callbacks are provided (they expect to be called for each path, and skipping breaks the contract) +- Cannot be used with schemas that have `.refine()`, `.superRefine()`, or `.transform()` at the object level +- The `form.js` call site never passes callbacks, so it always qualifies + +--- + +## Optimization Opportunity 2: Use `preValidate` from `form.js` Instead of `validate` + +**Impact: HIGH** — avoids all the ceremony of `validate()` (callbacks, events, path matching). + +### Problem + +`form.js` calls `model.validate()` but doesn't use any of the features unique to `validate()`: +- It doesn't pass `valid`/`invalid` callbacks +- It ignores the `validated` event +- It only cares about the returned error object + +This means `form.js` could call `preValidate()` instead, which already has the fast path. + +### Proposed Change + +In `form.js`, `inputEventHandler`: + +```js +// Before: +const modelErrors = model.validate(model.attributes, { attributes: [prop] }); + +// After: +const modelErrors = model.preValidate + ? model.preValidate({ [prop]: getPath(model.attributes, prop) }) + : model.validate(model.attributes, { attributes: [prop] }); +``` + +Or, if `preValidate` always exists on schema models: + +```js +const modelErrors = model.preValidate({ [prop]: getPath(model.attributes, prop) }); +``` + +### Estimated Impact + +- For simple schemas (most forms): **4-30x faster** per keystroke depending on valid/invalid +- Eliminates: `extend` for options, `getValidationPaths`, `getDefaultAttrs`, `trigger('validated')`, `pickMatchingErrors`, `getMatchingError`, `hasMatchingPaths` + +### Caveats + +- `preValidate` returns `undefined` when valid, `validate` returns `undefined` when valid — compatible +- `preValidate` returns error object when invalid — compatible with `form.js`'s `isPlainObject(modelErrors)` check +- `form.js` should handle models that don't have `preValidate` (i.e., not using `withSchema`) +- `validate()` would need to be called separately if someone listens to the `validated` event in conjunction with form state + +--- + +## Optimization Opportunity 3: Avoid Redundant Object Spread When `attrs === model.attributes` + +**Impact: LOW-MEDIUM** — reduces allocations on every call. + +### Problem + +In the `form.js` call pattern, `attrs` is literally `model.attributes`: + +```js +model.validate(model.attributes, { attributes: [prop] }) +``` + +Inside `validate()`: +```js +allAttrs = extend({}, getDefaultAttrs(opt.attributes, schema), model.attributes, attrs); +``` + +This merges `model.attributes` twice (since `attrs === model.attributes`). It also creates a `getDefaultAttrs` object that's immediately overwritten by the model's actual attributes. + +### Proposed Change + +Detect `attrs === model.attributes` or `!attrs` and skip the merge: + +```js +var allAttrs = attrs === model.attributes + ? extend({}, model.attributes) + : extend({}, getDefaultAttrs(opt.attributes, schema), model.attributes, attrs); +``` + +Or even more aggressively — when `attrs` is present and `opt.attributes` restricts to specific paths, we don't need default attrs at all. + +### Estimated Impact + +Saves 1 intermediate object allocation + N property copies per call. Marginal improvement (~0.1-0.3µs) but multiplied by every keystroke. + +--- + +## Optimization Opportunity 4: Cache `isSimpleObjectSchema` Result + +**Impact: LOW** — avoids re-checking `_def.checks` on every call. + +### Problem + +`isSimpleObjectSchema(schema)` is called on every `preValidate()` and could be called on every `validate()` if Opportunity 1 is implemented. The schema doesn't change between calls. + +### Proposed Change + +Cache the result alongside the schema: + +```js +const getSchema = (ctor) => { + if (ctor.hasOwnProperty('__schemaInstance')) { + return ctor.__schemaInstance; + } + var schema = ctor.schema; + if (schema) { + schema.__isSimple = isSimpleObjectSchema(schema); + } + return (ctor.__schemaInstance = schema); +}; +``` + +Then replace `isSimpleObjectSchema(schema)` with `schema.__isSimple`. + +### Estimated Impact + +Negligible per-call savings (~0.05µs), but establishes a pattern for caching more schema metadata. + +--- + +## Optimization Opportunity 5: Avoid `requestedPaths.slice()` When Not Mutated + +**Impact: NEGLIGIBLE** — micro-optimization. + +### Problem + +```js +var getValidationPaths = function (model, attrs, requestedPaths, schema) { + if (requestedPaths && requestedPaths.length) { + return requestedPaths.slice(); // defensive copy + } + ... +}; +``` + +The returned array is never mutated by any caller. The `.slice()` is a defensive copy that creates garbage. + +### Proposed Change + +Return the array directly: + +```js +return requestedPaths; +``` + +### Estimated Impact + +Saves one array allocation per call. Negligible but zero-cost to implement. + +--- + +## Optimization Opportunity 6: Skip `trigger('validated')` When No Listeners + +**Impact: LOW-MEDIUM** — avoids event dispatch overhead on the hot path. + +### Problem + +`model.trigger('validated', model, reportedErrors, setOptions)` runs on every `validate()` call. In the `form.js` use case, nothing listens for this event — the form state manages its own error tracking. + +### Proposed Change + +Option A — check for listeners before triggering: +```js +if (model._events && model._events.validated) { + model.trigger('validated', model, reportedErrors, setOptions); +} +``` + +Option B — make `validate()` accept an option to skip events: +```js +if (!opt.silent) { + model.trigger('validated', model, reportedErrors, setOptions); +} +``` + +Option C — use `preValidate` from form.js (Opportunity 2) which never triggers events. + +### Estimated Impact + +`trigger()` with no listeners is already fast (~0.1µs) due to the early return in the Events mixin. But avoiding the function call overhead and argument creation has marginal benefit at scale. + +--- + +## Summary: Recommended Priority + +| # | Opportunity | Impact | Effort | Risk | +|---|-----------|--------|--------|------| +| 2 | Use `preValidate` from `form.js` | **HIGH** | Low | Low — behavior is equivalent for the form.js use case | +| 1 | Fast-path single-field in `validate()` | **HIGH** | Medium | Medium — must preserve callback/event contracts | +| 3 | Avoid redundant spread | **LOW-MEDIUM** | Low | None | +| 6 | Skip trigger when no listeners | **LOW-MEDIUM** | Low | Low | +| 4 | Cache `isSimpleObjectSchema` | **LOW** | Low | None | +| 5 | Skip `.slice()` on requestedPaths | **NEGLIGIBLE** | Trivial | None | + +**Recommendation:** Start with Opportunity 2 (switch `form.js` to use `preValidate`). It's the lowest-risk, highest-impact change and requires no modifications to `schema.js`. If broader `validate()` performance is needed (e.g., for `model.set({...}, { validate: true })` paths), then implement Opportunity 1. diff --git a/docs/schema-validation-alternatives.md b/docs/schema-validation-alternatives.md new file mode 100644 index 000000000..03eec4c63 --- /dev/null +++ b/docs/schema-validation-alternatives.md @@ -0,0 +1,155 @@ +--- +outline: deep +--- + +# Schema Validation Alternatives + +This document captures the two alternative designs that were evaluated for nested Zod validation support in `schema.js`, but not selected for the current implementation. + +The current implementation keeps the public contract flat, validates the real nested object, and maps Zod issue paths back to dot-path keys such as `person.address.street`. The alternatives below describe the other viable directions and the tradeoffs attached to each one. + +## Alternative 1: Return genuinely nested error objects + +### Summary + +Instead of returning a flat error object like this: + +```js +{ + 'person.name': 'Name is required', + 'person.address.street': 'Street is required' +} +``` + +the schema mixin would preserve the original nested structure and return errors like this: + +```js +{ + person: { + name: 'Name is required', + address: { + street: 'Street is required' + } + } +} +``` + +### How it would work + +The implementation would validate the entire nested model object once with Zod and then rebuild a nested error tree from each `issue.path` array. + +The rest of the validation pipeline would need to become nested-aware as well: + +- `validationError` would store a nested object. +- `validated` and `invalid` event consumers would receive a nested error structure. +- `options.invalid(attr, message, model)` would no longer map cleanly to a single flat key unless it were redesigned. +- `isValid` and `preValidate` would need nested path lookups instead of direct key checks. + +### Pros + +- The error shape matches the shape of the validated data, which is easier to reason about for nested forms and nested model editors. +- It aligns naturally with Zod's own path-based error model, since there is no lossy conversion into flat strings. +- Consumers that already work with nested UI state could bind errors directly without re-expanding dot paths. +- Object-level validation becomes more expressive because parent objects can hold their own errors without competing with descendant dot-path keys. +- It is a cleaner conceptual model for future features such as grouped error summaries, nested touched state, or tree-based form rendering. + +### Cons + +- It is a breaking API change for `validationError`, which currently behaves like a flat object keyed by dot paths. +- Existing consumers of `invalid` and `validated` events would need to change if they expect flat keys. +- `options.invalid(attr, message, model)` becomes awkward because there may no longer be a single string key or a single leaf-level message for the requested attribute. +- `isValid('person.address.street')` and `preValidate('person.address.street', value)` would require additional traversal helpers anyway, so the change does not actually remove all path-handling complexity. +- Mixed cases such as object-level refinements plus leaf-level field errors become harder to normalize for callers that want one consistent error access pattern. + +### Compatibility impact + +This option would be the most disruptive one. + +The following surfaces would change materially: + +- `model.validationError` +- return values from `validate` +- payloads passed to `invalid` listeners +- callback behavior for `options.invalid` +- any external code using dot-path lookups such as `errors['person.address.street']` + +### When this option makes sense + +This is the right direction if the library decides that nested object validation should be a first-class API and backward compatibility with the flat error contract is no longer a hard requirement. + +It is especially attractive if future work includes richer nested form primitives or view helpers that expect error trees rather than flat maps. + +## Alternative 2: Keep top-level callbacks only and validate the whole object once + +### Summary + +This option would still validate the full nested object with Zod in one pass, but it would stop trying to expose nested paths in the callback layer. + +Validation would effectively remain top-level from the public API point of view. Nested failures such as `person.address.street` would be treated as failures of `person`, and the callback layer would only report `person` as invalid. + +### How it would work + +The model would call `schema.safeParse` on the entire object and then use Zod issues only to determine whether a top-level attribute is valid or invalid. + +A nested issue like `['person', 'address', 'street']` would be reduced to the top-level key `person` for callback dispatch. The implementation would likely keep a flat or semi-flat `validationError`, but callbacks and attribute filtering would remain top-level only. + +In practice: + +- `validate` would become simpler because there would be one full-object validation pass. +- `options.valid` and `options.invalid` would only receive top-level attributes. +- nested path requests like `person.address.street` would not be supported as first-class validation targets. + +### Pros + +- The implementation is simpler than full nested-path support because validation happens once and callback dispatch only needs top-level grouping. +- It avoids deep schema-path introspection and most of the path filtering logic. +- It preserves a familiar Backbone-style mental model where validation is centered on top-level model attributes. +- It is easier to reason about for models whose nested objects are treated as opaque blobs rather than editable sub-fields. +- It avoids a breaking change to the overall flat error contract if the returned error object remains keyed by flattened paths. + +### Cons + +- It loses useful nested-path behavior such as `preValidate('person.address.street', value)`. +- It also loses precise `isValid('person.address.street')` semantics, which makes nested field-level checks much less useful in forms. +- `options.attributes` can no longer target nested paths in a meaningful way. +- `options.valid` and `options.invalid` callbacks become too coarse for views that update nested fields independently. +- It creates a mismatch between the precision of Zod issue paths and the reduced precision of the public callback API. +- It would still leave consumers doing extra work if they need field-level feedback for nested editors. + +### Compatibility impact + +This option is less disruptive than nested error trees, but it still changes expectations for nested validation behavior. + +The main compatibility risk is semantic rather than structural: + +- callers may expect nested path callbacks once nested validation exists +- callers cannot target nested fields through `isValid`, `preValidate`, or `options.attributes` +- nested forms would need additional custom logic outside the schema mixin + +### When this option makes sense + +This is a reasonable choice if the goal is only to simplify the schema implementation and keep nested validation as an internal correctness improvement, not as a field-level API. + +It fits codebases where nested objects are validated as a unit and not edited incrementally by field-specific UI bindings. + +## Comparison + +### Alternative 1: nested error objects + +- Best conceptual model for nested data. +- Best long-term fit for tree-shaped form APIs. +- Highest implementation and migration cost. +- Breaking change for current flat consumers. + +### Alternative 2: top-level callbacks only + +- Simplest internal model. +- Lowest implementation cost among the non-selected options. +- Preserves more of the existing top-level validation contract. +- Too limited for nested field workflows. + +## Why they were not selected + +The current direction was chosen because it preserves the flat public contract while still adding real nested-path support. + +That gives the library most of the practical benefits of nested validation without forcing a breaking migration and without reducing nested validation back down to top-level-only semantics. diff --git a/package.json b/package.json index 4f3a682ea..67411338f 100644 --- a/package.json +++ b/package.json @@ -173,4 +173,4 @@ "LICENSE" ], "packageManager": "yarn@4.13.0" -} \ No newline at end of file +} diff --git a/schema.js b/schema.js index 8e229b59c..e71773d82 100644 --- a/schema.js +++ b/schema.js @@ -1,4 +1,4 @@ -import { isString, isArray, isObject, isEmpty, extend, pick, each, keys } from 'lodash-es'; +import { isString, isArray, isObject, isEmpty, extend, each } from 'lodash-es'; /** * @import { Model } from './nextbone.js' @@ -25,73 +25,239 @@ var defaultOptions = { // Helper functions // ---------------- -// Flattens an object -// eg: -// -// var o = { -// owner: { -// name: 'Backbone', -// address: { -// street: 'Street', -// zip: 1234 -// } -// } -// }; -// -// becomes: -// -// var o = { -// 'owner': { -// name: 'Backbone', -// address: { -// street: 'Street', -// zip: 1234 -// } -// }, -// 'owner.name': 'Backbone', -// 'owner.address': { -// street: 'Street', -// zip: 1234 -// }, -// 'owner.address.street': 'Street', -// 'owner.address.zip': 1234 -// }; - -var flatten = function (obj, into, prefix) { - into = into || {}; +var hasOwn = Object.prototype.hasOwnProperty; + +var isPlainObject = function (value) { + return !!value && typeof value === 'object' && value.constructor === Object; +}; + +var getPathParts = function (path) { + return path.split('.'); +}; + +var isIndexPathPart = function (part) { + for (var i = 0; i < part.length; i++) { + var c = part.charCodeAt(i); + if (c < 48 || c > 57) return false; + } + return part.length > 0; +}; + +var areRelatedPaths = function (path1, path2) { + if (path1 === '_root' || path2 === '_root') return true; + return path1 === path2 || path1.startsWith(path2 + '.') || path2.startsWith(path1 + '.'); +}; + +var getDefaultAttrs = function (paths, schema) { + var topLevelKeys = paths + ? Array.from( + new Set( + paths.map(function (path) { + return getPathParts(path)[0]; + }), + ), + ) + : Object.keys(schema.shape || {}); + + return topLevelKeys.reduce(function (memo, key) { + memo[key] = void 0; + return memo; + }, {}); +}; + +var cloneValue = function (value) { + if (isArray(value)) { + return value.map(cloneValue); + } + + if (isPlainObject(value)) { + return Object.keys(value).reduce(function (memo, key) { + memo[key] = cloneValue(value[key]); + return memo; + }, {}); + } + + return value; +}; + +var collectLeafPaths = function (value, prefix, into) { + into = into || []; prefix = prefix || ''; - each(obj, function (val, key) { - if (obj.hasOwnProperty(key)) { - if (!!val && typeof val === 'object' && val.constructor === Object) { - flatten(val, into, prefix + key + '.'); - } + if (isArray(value)) { + if (value.length === 0) { + prefix && into.push(prefix); + return into; + } - // Register the current level object as well - into[prefix + key] = val; + value.forEach(function (item, index) { + collectLeafPaths(item, prefix ? prefix + '.' + index : String(index), into); + }); + return into; + } + + if (isPlainObject(value)) { + var hasChildren = false; + + each(value, function (item, key) { + if (!hasOwn.call(value, key)) return; + hasChildren = true; + collectLeafPaths(item, prefix ? prefix + '.' + key : key, into); + }); + + if (!hasChildren && prefix) { + into.push(prefix); } - }); + return into; + } + + prefix && into.push(prefix); return into; }; -// Determines if two objects have at least one key in common -var hasCommonKeys = function (obj1, obj2) { - for (let key in obj1) { - if (key in obj2) return true; +var unwrapSchema = function (schema) { + var current = schema; + + while (current && !current.shape && !current.element && typeof current.unwrap === 'function') { + current = current.unwrap(); } - return false; + + return current; }; -// Returns an object with undefined properties for all -// attributes that have defined schema validation. -var getValidatedAttrs = function (attrs, schema) { - const schemaKeys = Object.keys(schema.shape || {}); - attrs = attrs || schemaKeys; - return attrs.reduce(function (memo, key) { - memo[key] = void 0; - return memo; - }, {}); +var getSchemaAtPath = function (schema, path) { + var current = schema; + var parts = getPathParts(path); + + for (var i = 0; i < parts.length; i++) { + current = unwrapSchema(current); + if (!current) return; + + var part = parts[i]; + + if (current.shape) { + current = current.shape[part]; + continue; + } + + if (current.element && isIndexPathPart(part)) { + current = current.element; + continue; + } + + return; + } + + return unwrapSchema(current); +}; + +var isSchemaPath = function (schema, path) { + return !!getSchemaAtPath(schema, path); +}; + +var setPathValue = function (obj, path, value) { + var parts = getPathParts(path); + var target = obj; + + for (var i = 0; i < parts.length - 1; i++) { + var part = parts[i]; + if (part === '__proto__' || part === 'constructor' || part === 'prototype') return obj; + var nextPart = parts[i + 1]; + + if (!isObject(target[part])) { + target[part] = isIndexPathPart(nextPart) ? [] : {}; + } + + target = target[part]; + } + + var lastPart = parts[parts.length - 1]; + if (lastPart === '__proto__' || lastPart === 'constructor' || lastPart === 'prototype') return obj; + target[lastPart] = value; + return obj; +}; + +var getChangedPaths = function (model, attrs) { + var changedAttrs = model.changedAttributes(attrs) || {}; + return collectLeafPaths(changedAttrs); +}; + +var getValidationPaths = function (model, attrs, requestedPaths, schema) { + if (requestedPaths && requestedPaths.length) { + return requestedPaths.slice(); + } + + if (!attrs) { + return Object.keys(schema.shape || {}); + } + + return getChangedPaths(model, attrs); +}; + +var pickMatchingErrors = function (errors, paths) { + if (!errors) return null; + if (!paths || !paths.length) return errors; + + var matchedErrors = {}; + + each(errors, function (message, path) { + for (var i = 0; i < paths.length; i++) { + if (areRelatedPaths(paths[i], path)) { + matchedErrors[path] = message; + return; + } + } + }); + + return isEmpty(matchedErrors) ? null : matchedErrors; +}; + +var getMatchingError = function (errors, path) { + if (!errors) return ''; + + // Direct match + if (hasOwn.call(errors, path)) { + return errors[path]; + } + + // Root-level errors match everything + if (hasOwn.call(errors, '_root')) { + return errors['_root']; + } + + // Single pass: prefer child errors (more specific) over parent errors. + // Child match = error at a deeper path (e.g., path is 'person', error is 'person.name'). + // Parent match = error at a shallower path (e.g., path is 'person.name', error is 'person'). + var childMatch = ''; + var parentMatch = ''; + var pathDot = path + '.'; + + for (var errorPath in errors) { + if (!childMatch && errorPath.startsWith(pathDot)) { + childMatch = errors[errorPath]; + break; + } + if (!parentMatch && path.startsWith(errorPath + '.')) { + parentMatch = errors[errorPath]; + } + } + + return childMatch || parentMatch || ''; +}; + +var hasMatchingPaths = function (errors, paths) { + if (!errors || !paths || !paths.length) return false; + + for (var errorPath in errors) { + for (var i = 0; i < paths.length; i++) { + if (areRelatedPaths(paths[i], errorPath)) { + return true; + } + } + } + + return false; }; // Formats Zod error messages into a flat object keyed by attribute name @@ -111,50 +277,32 @@ var formatZodErrors = function (zodError) { return isEmpty(errors) ? null : errors; }; -// Validates attributes using the Zod schema -var validateWithSchema = function (model, attrs, schema) { - const result = schema.safeParse(attrs); - if (result.success) { - return null; - } - return formatZodErrors(result.error); +// Returns true if a schema is a simple ZodObject with no object-level checks/refinements. +// When true, individual top-level fields can be validated in isolation. +var isSimpleObjectSchema = function (schema) { + var def = schema && schema._def; + return !!def && !!def.shape && (!def.checks || def.checks.length === 0); }; -// Validates a specific attribute using the Zod schema -var validateAttr = function (model, attr, value, allAttrs, schema) { - // Create an object with just the attribute to validate - const shape = schema.shape || {}; +// Fast-path: validates a single top-level attribute against its field schema. +// Only safe when the schema has no object-level checks (refinements, superRefine, etc). +var validateAttrFast = function (attr, value, schema) { + var shape = schema.shape; + if (!shape || !shape[attr]) return ''; - // Check if the attribute is in the schema - if (!shape[attr]) { - return ''; - } + var result = shape[attr].safeParse(value); + if (result.success) return ''; - // Validate just this attribute - const result = shape[attr].safeParse(value); - if (result.success) { - return ''; - } - - // Return the first error message return result.error.issues[0]?.message || 'Invalid value'; }; -// Loops through the model's attributes and validates the specified attrs. -// Returns an object containing names of invalid attributes and error messages. -var validateModel = function (model, allAttrs, validatedAttrs, schema) { - var error, - invalidAttrs = null; - - for (var attr in validatedAttrs) { - error = validateAttr(model, attr, validatedAttrs[attr], allAttrs, schema); - if (error) { - invalidAttrs || (invalidAttrs = {}); - invalidAttrs[attr] = error; - } +// Validates attributes using the Zod schema +var validateWithSchema = function (attrs, schema) { + const result = schema.safeParse(attrs); + if (result.success) { + return null; } - - return invalidAttrs; + return formatZodErrors(result.error); }; const getSchema = (ctor) => { @@ -181,27 +329,72 @@ function createClass(ModelClass) { var schema = getSchema(this.constructor); if (!schema) return; - var self = this, - result = {}, - error, - allAttrs = extend({}, this.attributes); + var allAttrs; if (isObject(attr)) { - // If multiple attributes are passed at once we would like for the validation functions to - // have access to the fresh values sent for all attributes, in the same way they do in the - // regular validation - extend(allAttrs, attr); + // Fast path: all keys are top-level and schema has no cross-field checks + var attrPaths = Object.keys(attr); + var canFastPath = isSimpleObjectSchema(schema); + + if (canFastPath) { + var allTopLevel = true; + for (var i = 0; i < attrPaths.length; i++) { + if (attrPaths[i].includes('.')) { + allTopLevel = false; + break; + } + } + + if (allTopLevel) { + var result = {}; + var hasError = false; + each(attr, function (attrValue, attrKey) { + var error = validateAttrFast(attrKey, attrValue, schema); + if (error) { + result[attrKey] = error; + hasError = true; + } + }); + return hasError ? result : undefined; + } + } + + allAttrs = cloneValue(extend({}, getDefaultAttrs(attrPaths, schema), this.attributes)); each(attr, function (attrValue, attrKey) { - error = validateAttr(self, attrKey, attrValue, allAttrs, schema); - if (error) { - result[attrKey] = error; + if (attrKey.includes('.')) { + setPathValue(allAttrs, attrKey, cloneValue(attrValue)); + } else { + allAttrs[attrKey] = cloneValue(attrValue); } }); - return isEmpty(result) ? undefined : result; + var invalidAttrs = pickMatchingErrors(validateWithSchema(allAttrs, schema), attrPaths); + + return invalidAttrs || undefined; } - return validateAttr(self, attr, value, allAttrs, schema); + + if (!isSchemaPath(schema, attr)) { + return ''; + } + + // Fast path: top-level attribute on schema without cross-field checks + if (!attr.includes('.') && isSimpleObjectSchema(schema)) { + return validateAttrFast(attr, value, schema); + } + + allAttrs = cloneValue(extend({}, getDefaultAttrs([attr], schema), this.attributes)); + + if (attr.includes('.')) { + setPathValue(allAttrs, attr, cloneValue(value)); + } else { + allAttrs[attr] = cloneValue(value); + } + + return getMatchingError( + pickMatchingErrors(validateWithSchema(allAttrs, schema), [attr]), + attr, + ); } /** @@ -241,32 +434,30 @@ function createClass(ModelClass) { var model = this, validateAll = !attrs, opt = extend({}, defaultOptions, setOptions), - schemaKeys = Object.keys(schema.shape || {}), - validatedAttrs = getValidatedAttrs(opt.attributes, schema), - allAttrs = extend({}, validatedAttrs, model.attributes, attrs), - flattened = flatten(allAttrs), - changedAttrs = attrs ? flatten(attrs) : flattened, - invalidAttrs = validateModel(model, allAttrs, pick(flattened, keys(validatedAttrs)), schema); + requestedPaths = getValidationPaths(model, attrs, opt.attributes, schema), + allAttrs = extend({}, getDefaultAttrs(opt.attributes, schema), model.attributes, attrs), + allErrors = validateWithSchema(allAttrs, schema), + invalidAttrs = pickMatchingErrors(allErrors, requestedPaths), + reportedErrors = opt.attributes ? invalidAttrs : allErrors; // After validation is performed, loop through all validated and changed attributes // and call the valid and invalid callbacks so the view is updated. - each(validatedAttrs, function (val, attr) { - var invalid = invalidAttrs && attr in invalidAttrs, - changed = attr in changedAttrs; + each(requestedPaths, function (attr) { + var errorMsg = getMatchingError(invalidAttrs, attr); - if (!invalid) { + if (!errorMsg) { opt.valid(attr, model); } - if (invalid && (changed || validateAll)) { - opt.invalid(attr, invalidAttrs[attr], model); + if (errorMsg && (requestedPaths.length || validateAll)) { + opt.invalid(attr, errorMsg, model); } }); // Trigger validated events. - model.trigger('validated', model, invalidAttrs, setOptions); + model.trigger('validated', model, reportedErrors, setOptions); // Return any error messages to Nextbone. - if (invalidAttrs && hasCommonKeys(invalidAttrs, changedAttrs)) { + if (invalidAttrs && hasMatchingPaths(invalidAttrs, requestedPaths)) { return invalidAttrs; } } diff --git a/test/schema/complexSchema.test.ts b/test/schema/complexSchema.test.ts index 5b4bb7f3c..89ceb70e6 100644 --- a/test/schema/complexSchema.test.ts +++ b/test/schema/complexSchema.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, it } from 'vitest'; +import { beforeEach, describe, it, vi } from 'vitest'; import { assert } from 'chai'; @@ -81,6 +81,121 @@ describe('Complex Schema Validation', () => { assert.isDefined(model.validationError); assert.isFalse(model.isValid()); }); + + it('returns flat nested validation errors', () => { + model.set( + { + person: { + name: '', + address: { + street: '', + city: '', + }, + }, + }, + { validate: true }, + ); + + assert.deepEqual(model.validationError, { + 'person.name': 'Name is required', + 'person.address.street': 'Street is required', + 'person.address.city': 'City is required', + }); + }); + + it('calls the invalid callback for nested paths', () => { + const invalid = vi.fn(); + + model.set( + { + person: { + name: '', + address: { + street: '', + city: '', + }, + }, + }, + { validate: true, invalid } as any, + ); + + assert.deepEqual( + invalid.mock.calls.map(([path, message]) => [path, message]), + [ + ['person.name', 'Name is required'], + ['person.address.street', 'Street is required'], + ['person.address.city', 'City is required'], + ], + ); + }); + + it('calls the valid callback for nested paths', () => { + const valid = vi.fn(); + + model.set( + { + person: { + name: 'John', + address: { + street: '123 Main St', + city: 'New York', + }, + }, + }, + { validate: true, valid } as any, + ); + + assert.deepEqual( + valid.mock.calls.map(([path]) => path), + ['person.name', 'person.address.street', 'person.address.city'], + ); + }); + + it('supports nested paths in isValid', () => { + model.set({ + person: { + name: 'John', + address: { + street: '', + city: 'New York', + }, + }, + }); + + assert.isTrue(model.isValid('person.name')); + assert.isFalse(model.isValid('person.address.street')); + }); + + it('supports nested paths in preValidate without mutating the model', () => { + model.set({ + person: { + name: 'John', + address: { + street: '123 Main St', + city: 'New York', + }, + }, + }); + + assert.strictEqual(model.preValidate('person.address.street', ''), 'Street is required'); + assert.strictEqual(model.get('person')?.address?.street, '123 Main St'); + }); + + it('filters validation to nested paths through options.attributes', () => { + model.set({ + person: { + name: '', + address: { + street: '', + city: '', + }, + }, + }); + + assert.deepEqual(model.validate(undefined, { attributes: ['person.address.street'] }), { + 'person.address.street': 'Street is required', + }); + }); }); describe('arrays', () => {