From b99330311334e524750590ca2658329c8c7c515a Mon Sep 17 00:00:00 2001 From: myNameIsDu Date: Wed, 19 Mar 2025 15:02:36 +0800 Subject: [PATCH] feat: Array support explicit type --- README.md | 18 ++++- src/ArrayType.ts | 90 +++++++++++++-------- src/MixedType.ts | 60 +++++++++----- test/ArrayTypeSpec.js | 182 +++++++++++++++++++++++++++++++++++++----- test/MixedTypeSpec.js | 119 +++++++++++++++++++++++++++ 5 files changed, 391 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 4c0713f..49149d9 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Schema for data modeling & validation - [`minLength(minLength: number, errorMessage?: string)`](#minlengthminlength-number-errormessage-string-1) - [`maxLength(maxLength: number, errorMessage?: string)`](#maxlengthmaxlength-number-errormessage-string-1) - [`unrepeatable(errorMessage?: string)`](#unrepeatableerrormessage-string) - - [`of(type: object)`](#oftype-object) + - [`of()`](#of) - [DateType(errorMessage?: string)](#datetypeerrormessage-string) - [`range(min: Date, max: Date, errorMessage?: string)`](#rangemin-date-max-date-errormessage-string) - [`min(min: Date, errorMessage?: string)`](#minmin-date-errormessage-string) @@ -726,10 +726,24 @@ ArrayType().maxLength(3, "Can't exceed three"); ArrayType().unrepeatable('Duplicate options cannot appear'); ``` -#### `of(type: object)` +#### `of()` ```js +// for every element of array ArrayType().of(StringType('The tag should be a string').isRequired()); +// for every element of array +ArrayType().of( + ObjectType().shape({ + name: StringType().isEmail() + }) +); +// just specify the first and the second element +ArrayType().of( + StringType().isEmail(), + ObjectType().shape({ + name: StringType().isEmail() + }) +); ``` ### DateType(errorMessage?: string) diff --git a/src/ArrayType.ts b/src/ArrayType.ts index 05d19ef..0fea0ff 100644 --- a/src/ArrayType.ts +++ b/src/ArrayType.ts @@ -8,7 +8,7 @@ export class ArrayType extends MixedType< E, ArrayTypeLocale > { - [arrayTypeSchemaSpec]: MixedType; + [arrayTypeSchemaSpec]: MixedType | MixedType[]; private isArrayTypeNested = false; constructor(errorMessage?: E | string) { @@ -75,44 +75,66 @@ export class ArrayType extends MixedType< return this; } - of(type: MixedType) { - this[arrayTypeSchemaSpec] = type; - - // Mark inner ArrayType as nested when dealing with nested arrays - if (type instanceof ArrayType) { - type.isArrayTypeNested = true; - } + of(...types: MixedType[]) { + if (types.length === 1) { + const type = types[0]; + this[arrayTypeSchemaSpec] = type; - super.pushRule({ - onValid: (items, data, fieldName) => { - // For non-array values in nested arrays, pass directly to inner type validation - if (!Array.isArray(items) && this.isArrayTypeNested) { - return type.check(items, data, fieldName); - } + // Mark inner ArrayType as nested when dealing with nested arrays + if (type instanceof ArrayType) { + type.isArrayTypeNested = true; + } - // For non-array values in non-nested arrays, return array type error - if (!Array.isArray(items)) { - return { - hasError: true, - errorMessage: this.locale.type - }; - } + super.pushRule({ + onValid: (items, data, fieldName) => { + // For non-array values in nested arrays, pass directly to inner type validation + if (!Array.isArray(items) && this.isArrayTypeNested) { + return type.check(items, data, fieldName); + } - const checkResults = items.map((value, index) => { - const name = Array.isArray(fieldName) - ? [...fieldName, `[${index}]`] - : [fieldName, `[${index}]`]; + // For non-array values in non-nested arrays, return array type error + if (!Array.isArray(items)) { + return { + hasError: true, + errorMessage: this.locale.type + }; + } - return type.check(value, data, name as string[]); - }); - const hasError = !!checkResults.find(item => item?.hasError); + const checkResults = items.map((value, index) => { + const name = Array.isArray(fieldName) + ? [...fieldName, `[${index}]`] + : [fieldName, `[${index}]`]; - return { - hasError, - array: checkResults - } as CheckResult; - } - }); + return type.check(value, data, name as string[]); + }); + const hasError = !!checkResults.find(item => item?.hasError); + + return { + hasError, + array: checkResults + } as CheckResult; + } + }); + } else { + this[arrayTypeSchemaSpec] = types; + super.pushRule({ + onValid: (items, data, fieldName) => { + const checkResults = items.map((value, index) => { + const name = Array.isArray(fieldName) + ? [...fieldName, `[${index}]`] + : [fieldName, `[${index}]`]; + + return types[index].check(value, data, name as string[]); + }); + const hasError = !!checkResults.find(item => item?.hasError); + + return { + hasError, + array: checkResults + } as CheckResult; + } + }); + } return this; } diff --git a/src/MixedType.ts b/src/MixedType.ts index 2cea14e..b316f85 100644 --- a/src/MixedType.ts +++ b/src/MixedType.ts @@ -32,34 +32,50 @@ export const arrayTypeSchemaSpec = 'arrayTypeSchemaSpec'; * Get the field type from the schema object */ export function getFieldType(schemaSpec: any, fieldName: string, nestedObject?: boolean) { - if (nestedObject) { - const namePath = fieldName.split('.'); - const currentField = namePath[0]; - const arrayMatch = currentField.match(/(\w+)\[(\d+)\]/); + if (schemaSpec) { + if (nestedObject) { + const namePath = fieldName.split('.'); + const currentField = namePath[0]; + const arrayMatch = currentField.match(/(\w+)\[(\d+)\]/); + if (arrayMatch) { + const [, arrayField, arrayIndex] = arrayMatch; + const type = schemaSpec[arrayField]; + if (type?.[arrayTypeSchemaSpec]) { + const arrayType = type[arrayTypeSchemaSpec]; - if (arrayMatch) { - const [, arrayField] = arrayMatch; - const type = schemaSpec[arrayField]; + if (namePath.length > 1) { + if (arrayType[schemaSpecKey]) { + return getFieldType(arrayType[schemaSpecKey], namePath.slice(1).join('.'), true); + } + if (Array.isArray(arrayType) && arrayType[parseInt(arrayIndex)][schemaSpecKey]) { + return getFieldType( + arrayType[parseInt(arrayIndex)][schemaSpecKey], + namePath.slice(1).join('.'), + true + ); + } + } + if (Array.isArray(arrayType)) { + return arrayType[parseInt(arrayIndex)]; + } + // Otherwise return the array element type directly + return arrayType; + } + return type; + } else { + const type = schemaSpec[currentField]; - if (type?.[arrayTypeSchemaSpec]) { - // If there are remaining paths and the type is ObjectType (has schemaSpecKey) - if (namePath.length > 1 && type[arrayTypeSchemaSpec][schemaSpecKey]) { - return getFieldType( - type[arrayTypeSchemaSpec][schemaSpecKey], - namePath.slice(1).join('.'), - true - ); + if (namePath.length === 1) { + return type; + } + + if (namePath.length > 1 && type[schemaSpecKey]) { + return getFieldType(type[schemaSpecKey], namePath.slice(1).join('.'), true); } - // Otherwise return the array element type directly - return type[arrayTypeSchemaSpec]; } - return type; } - - const joinedPath = namePath.join(`.${schemaSpecKey}.`); - return get(schemaSpec, joinedPath); + return schemaSpec?.[fieldName]; } - return schemaSpec?.[fieldName]; } /** diff --git a/test/ArrayTypeSpec.js b/test/ArrayTypeSpec.js index 7b3179e..5acf04f 100644 --- a/test/ArrayTypeSpec.js +++ b/test/ArrayTypeSpec.js @@ -511,26 +511,6 @@ describe('#ArrayType', () => { ) }); - const data = { - users: [ - { - name: 'John Doe', - tasks: [ - { - title: 'Frontend Development', - assignees: [ - { - email: 'test@example.com', - role: 'owner', - priority: 3 - } - ] - } - ] - } - ] - }; - // Test valid email expect( schema.checkForField( @@ -815,5 +795,167 @@ describe('#ArrayType', () => { errorMessage: 'Task title required' }); }); + + it('Should validate explicit nested array type', () => { + const schema = new Schema({ + users: ArrayType().of( + StringType().isRequired().isEmail(), + ObjectType().shape({ + name: StringType().isEmail(), + email: StringType().isEmail() + }) + ) + }); + + expect( + schema.checkForField( + 'users[0]', + { + users: ['xx'] + }, + options + ) + ).to.deep.equal({ + hasError: true, + errorMessage: 'users[0] must be a valid email' + }); + + expect( + schema.checkForField( + 'users[0]', + { + users: ['ddd@bbb.com'] + }, + options + ) + ).to.deep.equal({ + hasError: false + }); + + expect( + schema.checkForField( + 'users[1].name', + { + users: ['ddd@bbb.com', { name: 'xxx' }] + }, + options + ) + ).to.deep.equal({ + hasError: true, + errorMessage: 'users[1].name must be a valid email' + }); + + expect( + schema.checkForField( + 'users[1].name', + { + users: ['ddd@bbb.com', { name: 'ddd@bbb.com' }] + }, + options + ) + ).to.deep.equal({ + hasError: false + }); + + expect( + schema.check({ + users: [ + 'xxx', + { + name: 'xx', + email: 'xx' + } + ] + }) + ).to.deep.equal({ + users: { + hasError: true, + array: [ + { + hasError: true, + errorMessage: 'users.[0] must be a valid email' + }, + { + hasError: true, + object: { + name: { + hasError: true, + errorMessage: 'name must be a valid email' + }, + email: { + hasError: true, + errorMessage: 'email must be a valid email' + } + } + } + ] + } + }); + }); + + it('Should validate nested array within an object', () => { + const schema = new Schema({ + user: ObjectType().shape({ + emails: ArrayType().of( + StringType().isEmail(), + ObjectType().shape({ + name: StringType().isEmail() + }) + ) + }) + }); + + expect( + schema.checkForField( + 'user.emails[0]', + { + user: { + emails: ['xxx'] + } + }, + options + ) + ).to.deep.equal({ + hasError: true, + errorMessage: 'user.emails[0] must be a valid email' + }); + + expect( + schema.check({ + user: { + emails: [ + 'xxx', + { + name: 'xxx' + } + ] + } + }) + ).to.deep.equal({ + user: { + hasError: true, + object: { + emails: { + hasError: true, + array: [ + { + hasError: true, + errorMessage: 'emails.[0] must be a valid email' + }, + { + hasError: true, + object: { + name: { + hasError: true, + errorMessage: 'name must be a valid email' + } + } + } + ] + } + } + } + }); + }); }); }); diff --git a/test/MixedTypeSpec.js b/test/MixedTypeSpec.js index 8d10caf..48a0a78 100644 --- a/test/MixedTypeSpec.js +++ b/test/MixedTypeSpec.js @@ -1,5 +1,6 @@ import chai, { expect } from 'chai'; import * as schema from '../src'; +import { getFieldType, schemaSpecKey, arrayTypeSchemaSpec } from '../src/MixedType'; chai.should(); @@ -1092,4 +1093,122 @@ describe('#MixedType', () => { }); }); }); + + describe('getFieldType', () => { + it('Should return the field type directly', () => { + const schema = { + username: StringType().isRequired(), + email: StringType().isEmail(), + age: NumberType().range(18, 30) + }; + + expect(getFieldType(schema, 'username')).to.equal(schema.username); + expect(getFieldType(schema, 'email')).to.equal(schema.email); + expect(getFieldType(schema, 'age')).to.equal(schema.age); + expect(getFieldType(schema, 'nonexistent')).to.be.undefined; + }); + + it('Should return the nested field type when nestedObject is true', () => { + const schema = { + user: ObjectType().shape({ + name: StringType().isRequired(), + profile: ObjectType().shape({ + age: NumberType().range(18, 30) + }) + }) + }; + + expect(getFieldType(schema, 'user.name', true)).to.equal(schema.user[schemaSpecKey].name); + expect(getFieldType(schema, 'user.profile.age', true)).to.equal( + schema.user[schemaSpecKey].profile[schemaSpecKey].age + ); + expect(getFieldType(schema, 'user.nonexistent', true)).to.be.undefined; + }); + + it('Should return array element type when dealing with arrays', () => { + const itemType = StringType().isRequired(); + const schema = { + tags: ArrayType().of(itemType) + }; + + expect(getFieldType(schema, 'tags[0]', true)).to.equal(itemType); + expect(getFieldType(schema, 'nonexistent[0]', true)).to.be.undefined; + }); + + it('Should return nested field type within array objects', () => { + const schema = { + users: ArrayType().of( + ObjectType().shape({ + name: StringType().isRequired(), + age: NumberType().range(18, 30) + }) + ) + }; + + expect(getFieldType(schema, 'users[0].name', true)).to.equal( + schema.users[arrayTypeSchemaSpec][schemaSpecKey].name + ); + expect(getFieldType(schema, 'users[0].age', true)).to.equal( + schema.users[arrayTypeSchemaSpec][schemaSpecKey].age + ); + expect(getFieldType(schema, 'users[0].nonexistent', true)).to.be.undefined; + }); + + it('Should handle complex nested structures', () => { + const schema = { + company: ObjectType().shape({ + departments: ArrayType().of( + ObjectType().shape({ + name: StringType().isRequired(), + employees: ArrayType().of( + ObjectType().shape({ + name: StringType().isRequired(), + email: StringType().isEmail() + }) + ) + }) + ) + }) + }; + + expect(getFieldType(schema, 'company.departments[0].name', true)).to.equal( + schema.company[schemaSpecKey].departments[arrayTypeSchemaSpec][schemaSpecKey].name + ); + + expect(getFieldType(schema, 'company.departments[0].employees[0].email', true)).to.equal( + schema.company[schemaSpecKey].departments[arrayTypeSchemaSpec][schemaSpecKey].employees[ + arrayTypeSchemaSpec + ][schemaSpecKey].email + ); + }); + + it('Should return explicit filed of ArrayType().of', () => { + const schema = { + users: ArrayType().of( + StringType().isRequired(), + ObjectType().shape({ + name: StringType().isRequired(), + email: StringType().isEmail() + }) + ) + }; + expect(getFieldType(schema, 'users[0]', true)).to.equal(schema.users[arrayTypeSchemaSpec][0]); + expect(getFieldType(schema, 'users[1].name', true)).to.equal( + schema.users[arrayTypeSchemaSpec][1][schemaSpecKey].name + ); + }); + + it('Should handle edge cases', () => { + const schema = { + a: StringType() + }; + + expect(getFieldType(null, 'a')).to.be.undefined; + expect(getFieldType(undefined, 'a')).to.be.undefined; + expect(getFieldType({}, 'a')).to.be.undefined; + expect(getFieldType(schema, '')).to.be.undefined; + expect(getFieldType(schema, '..')).to.be.undefined; + expect(getFieldType(schema, 'a.b.c[0].d', true)).to.be.undefined; + }); + }); });