Skip to content

Commit

Permalink
feat: improved zod types and async refinements
Browse files Browse the repository at this point in the history
  • Loading branch information
victorgarciaesgi committed Dec 7, 2024
1 parent 5d2de4a commit 2008e2a
Show file tree
Hide file tree
Showing 10 changed files with 818 additions and 36 deletions.
2 changes: 1 addition & 1 deletion packages/zod/src/core/parser/processZodTypeDef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function processZodTypeDef(
} else {
return {
[schema.constructor.name]: withMessage(
transformZodValidatorAdapter(schema),
transformZodValidatorAdapter(schema) as any,
extractIssuesMessages() as any
),
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import type { RegleRuleMetadataDefinition } from '@regle/core';
import { withAsync } from '@regle/rules';
import type { z } from 'zod';

export function transformZodValidatorAdapter(schema: z.ZodSchema<any>) {
return (value: unknown): RegleRuleMetadataDefinition | Promise<RegleRuleMetadataDefinition> => {
const isAsync = hasAsyncRefinement(schema);
const validatorFn = (
value: unknown
): RegleRuleMetadataDefinition | Promise<RegleRuleMetadataDefinition> => {
const result = trySafeTransform(schema, value);

if (result instanceof Promise) {
Expand All @@ -21,6 +25,8 @@ export function transformZodValidatorAdapter(schema: z.ZodSchema<any>) {
};
}
};

return isAsync ? withAsync(validatorFn) : validatorFn;
}

function trySafeTransform(
Expand Down Expand Up @@ -51,3 +57,70 @@ function trySafeTransform(
}
}
}

function isAsyncFunctionOrPromiseReturning(fn: unknown): boolean {
if (typeof fn !== 'function') return false;
if (fn.constructor.name === 'AsyncFunction') {
return true;
}
try {
const result = fn();
return result instanceof Promise;
} catch {
return false;
}
}

function hasAsyncRefinement(schema: z.ZodTypeAny): boolean {
if (schema._def.typeName === 'ZodEffects') {
// Handle ZodEffects (used for refinements and transformations)
const effect = schema._def.effect;
if (effect?.type === 'refinement' || effect?.type === 'transform') {
return isAsyncFunctionOrPromiseReturning(effect.refinement || effect.transform);
}
if (effect?.type === 'preprocess') {
// Preprocessors can include nested schemas
return hasAsyncRefinement(effect.schema);
}
}

if (schema._def.typeName === 'ZodObject') {
// Check each field in a ZodObject
return Object.values(schema._def.shape()).some((schema) => hasAsyncRefinement(schema as any));
}

if (schema._def.typeName === 'ZodUnion' || schema._def.typeName === 'ZodIntersection') {
// Check each option in a ZodUnion or ZodIntersection
return schema._def.options.some(hasAsyncRefinement);
}

if (schema._def.typeName === 'ZodArray') {
// Check the array's element schema
return hasAsyncRefinement(schema._def.type);
}

if (schema._def.typeName === 'ZodOptional' || schema._def.typeName === 'ZodNullable') {
// Check the wrapped schema
return hasAsyncRefinement(schema._def.innerType);
}

if (schema._def.typeName === 'ZodTuple') {
// Check each item in a ZodTuple
return schema._def.items.some(hasAsyncRefinement);
}

if (
schema._def.typeName === 'ZodString' ||
schema._def.typeName === 'ZodNumber' ||
schema._def.typeName === 'ZodDate'
) {
// Check for async refinements in primitive types
return schema._def.checks?.some((check: any) =>
isAsyncFunctionOrPromiseReturning(check.refinement)
);
}

// Add other cases if needed (e.g., ZodRecord, ZodMap, etc.)

return false; // Default: No async refinements found
}
6 changes: 5 additions & 1 deletion packages/zod/src/types/core.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export interface ZodRegleFieldStatus<
> extends RegleCommonStatus<TState> {
$value: TState[TKey];
readonly $externalErrors?: string[];
readonly $errors: string[];
readonly $silentErrors: string[];
readonly $rules: {
[Key in `${string & TSchema['_def']['typeName']}`]: RegleRuleStatus<TState[TKey], []>;
};
Expand All @@ -91,7 +93,9 @@ export interface ZodRegleFieldStatus<
* @public
*/
export interface ZodRegleCollectionStatus<TSchema extends z.ZodTypeAny, TState extends any[]>
extends ZodRegleFieldStatus<TSchema, TState> {
extends Omit<ZodRegleFieldStatus<TSchema, TState>, '$errors' | '$silentErrors'> {
readonly $each: Array<InferZodRegleStatusType<NonNullable<TSchema>, TState, number>>;
readonly $errors: ZodToRegleCollectionErrors<TSchema>;
readonly $silentErrors: ZodToRegleCollectionErrors<TSchema>;
$validate: () => Promise<false | z.output<TSchema>>;
}
15 changes: 7 additions & 8 deletions packages/zod/src/types/zod.types.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
import type { Maybe } from '@regle/core';
import type { z } from 'zod';

export type ZodObj<T extends Record<PropertyKey, any>> = {
[K in keyof T]: ZodChild<T[K]> | z.ZodOptional<ZodChild<T[K]>> | z.ZodEffects<T[K]>;
[K in keyof T]: ZodChild<T[K]>;
};

export type ZodChild<T extends any> = NonNullable<
T extends Array<infer A>
? z.ZodArray<ZodChild<A>>
: T extends Record<string, any>
? z.ZodObject<ZodObj<T>>
: z.ZodType<NonNullable<T>>
: T extends Date | File
? z.ZodType<Maybe<T>, z.ZodTypeDef, Maybe<T>>
: T extends Record<string, any>
? z.ZodObject<ZodObj<T>>
: z.ZodType<Maybe<T>, z.ZodTypeDef, Maybe<T>>
>;

export type toZod<T extends Record<PropertyKey, any>> = z.ZodObject<ZodObj<T>>;

export type NonPresentKeys<
TSource extends Record<string, any>,
Target extends Record<string, any>,
> = Omit<Target, keyof TSource>;
// Types

export type PossibleDefTypes =
Expand Down
11 changes: 3 additions & 8 deletions tests/fixtures/validations.fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import { computed, reactive, ref, type UnwrapRef } from 'vue';
import type {
Regle,
RegleComputedRules,
ReglePartialRuleTree,
RegleRuleDefinition,
} from '@regle/core';
import type { RegleComputedRules, ReglePartialRuleTree } from '@regle/core';
import { defineRegleConfig, useRegle } from '@regle/core';
import { email, required } from '@regle/rules';
import { computed, reactive, ref, type UnwrapRef } from 'vue';
import { ruleMockIsEven } from './rules.fixtures';
import { email, required, requiredIf } from '@regle/rules';
// eslint-disable-next-line
export type { RefSymbol } from '@vue/reactivity';

Expand Down
74 changes: 74 additions & 0 deletions tests/unit/createRule/createRule.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { createRule, type RegleRuleDefinition } from '@regle/core';

describe('createRule', () => {
it('should error when creating a rule without a function', () => {
assert.throw(() => createRule({} as any), 'Validator must be a function');
});

it('should create a rule definition without parameters', () => {
const rule = createRule({
validator() {
return true;
},
message: '',
});

expect(rule.exec('fooo')).toBe(true);
expectTypeOf(rule).toMatchTypeOf<RegleRuleDefinition<unknown, [], false, true, unknown>>();
});

it('should handle metadata', () => {
const rule = createRule({
validator() {
return { $valid: true };
},
message: '',
});

expect(rule.exec('fooo')).toBe(true);
expectTypeOf(rule).toMatchTypeOf<
RegleRuleDefinition<
unknown,
[],
false,
{
$valid: true;
},
unknown
>
>();
});

it('should handle async metadata', async () => {
const rule = createRule({
async validator() {
return Promise.resolve({ $valid: true });
},
message: '',
});

expect(await rule.exec('fooo')).toBe(true);
expectTypeOf(rule).toMatchTypeOf<
RegleRuleDefinition<
unknown,
[],
true,
{
$valid: true;
},
unknown
>
>();
});

it('should be false if not given boolean or $valid response', async () => {
const rule = createRule({
validator() {
return 'foo' as unknown as boolean;
},
message: '',
});

expect(await rule.exec('fooo')).toBe(false);
});
});
Loading

0 comments on commit 2008e2a

Please sign in to comment.