diff --git a/package.json b/package.json index c9a2ab8..836dbee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sinclair/typebox", - "version": "0.26.6", + "version": "0.26.7", "description": "JSONSchema Type Builder with Static Type Resolution for TypeScript", "keywords": [ "typescript", diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 62ef4f6..1d269f9 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -287,9 +287,10 @@ export namespace TypeCompiler { function* Promise(schema: Types.TPromise, references: Types.TSchema[], value: string): IterableIterator { yield `(typeof value === 'object' && typeof ${value}.then === 'function')` } - function* Record(schema: Types.TRecord, references: Types.TSchema[], value: string): IterableIterator { yield IsRecordCheck(value) + if (IsNumber(schema.minProperties)) yield `Object.getOwnPropertyNames(${value}).length >= ${schema.minProperties}` + if (IsNumber(schema.maxProperties)) yield `Object.getOwnPropertyNames(${value}).length <= ${schema.maxProperties}` const [keyPattern, valueSchema] = globalThis.Object.entries(schema.patternProperties)[0] const local = PushLocal(`new RegExp(/${keyPattern}/)`) yield `(Object.getOwnPropertyNames(${value}).every(key => ${local}.test(key)))` diff --git a/src/errors/errors.ts b/src/errors/errors.ts index 51476a6..5213439 100644 --- a/src/errors/errors.ts +++ b/src/errors/errors.ts @@ -390,6 +390,12 @@ export namespace ValueErrors { if (!IsRecordObject(value)) { return yield { type: ValueErrorType.Object, schema, path, value, message: `Expected record object` } } + if (IsDefined(schema.minProperties) && !(globalThis.Object.getOwnPropertyNames(value).length >= schema.minProperties)) { + yield { type: ValueErrorType.ObjectMinProperties, schema, path, value, message: `Expected object to have at least ${schema.minProperties} properties` } + } + if (IsDefined(schema.maxProperties) && !(globalThis.Object.getOwnPropertyNames(value).length <= schema.maxProperties)) { + yield { type: ValueErrorType.ObjectMaxProperties, schema, path, value, message: `Expected object to have less than ${schema.minProperties} properties` } + } const [keyPattern, valueSchema] = globalThis.Object.entries(schema.patternProperties)[0] const regex = new RegExp(keyPattern) if (!globalThis.Object.getOwnPropertyNames(value).every((key) => regex.test(key))) { diff --git a/src/value/check.ts b/src/value/check.ts index c30e406..bf3f429 100644 --- a/src/value/check.ts +++ b/src/value/check.ts @@ -270,6 +270,12 @@ export namespace ValueCheck { if (!IsRecordObject(value)) { return false } + if (IsDefined(schema.minProperties) && !(globalThis.Object.getOwnPropertyNames(value).length >= schema.minProperties)) { + return false + } + if (IsDefined(schema.maxProperties) && !(globalThis.Object.getOwnPropertyNames(value).length <= schema.maxProperties)) { + return false + } const [keyPattern, valueSchema] = globalThis.Object.entries(schema.patternProperties)[0] const regex = new RegExp(keyPattern) if (!globalThis.Object.getOwnPropertyNames(value).every((key) => regex.test(key))) { diff --git a/test/runtime/compiler/record.ts b/test/runtime/compiler/record.ts index d12e65d..428c35f 100644 --- a/test/runtime/compiler/record.ts +++ b/test/runtime/compiler/record.ts @@ -6,24 +6,36 @@ describe('type/compiler/Record', () => { const T = Type.Record(Type.String(), Type.Number()) Ok(T, { a: 1, b: 2, c: 3 }) }) - it('Should validate when all property keys are strings', () => { const T = Type.Record(Type.String(), Type.Number()) Ok(T, { a: 1, b: 2, c: 3, '0': 4 }) }) - + it('Should not validate when below minProperties', () => { + const T = Type.Record(Type.String(), Type.Number(), { minProperties: 4 }) + Ok(T, { a: 1, b: 2, c: 3, d: 4 }) + Fail(T, { a: 1, b: 2, c: 3 }) + }) + it('Should not validate when above maxProperties', () => { + const T = Type.Record(Type.String(), Type.Number(), { maxProperties: 4 }) + Ok(T, { a: 1, b: 2, c: 3, d: 4 }) + Fail(T, { a: 1, b: 2, c: 3, d: 4, e: 5 }) + }) + it('Should not validate with illogical minProperties | maxProperties', () => { + const T = Type.Record(Type.String(), Type.Number(), { minProperties: 5, maxProperties: 4 }) + Fail(T, { a: 1, b: 2, c: 3 }) + Fail(T, { a: 1, b: 2, c: 3, d: 4 }) + Fail(T, { a: 1, b: 2, c: 3, d: 4, e: 5 }) + }) it('Should validate when specifying string union literals when additionalProperties is true', () => { const K = Type.Union([Type.Literal('a'), Type.Literal('b'), Type.Literal('c')]) const T = Type.Record(K, Type.Number()) Ok(T, { a: 1, b: 2, c: 3, d: 'hello' }) }) - it('Should not validate when specifying string union literals when additionalProperties is false', () => { const K = Type.Union([Type.Literal('a'), Type.Literal('b'), Type.Literal('c')]) const T = Type.Record(K, Type.Number(), { additionalProperties: false }) Fail(T, { a: 1, b: 2, c: 3, d: 'hello' }) }) - it('Should validate for keyof records', () => { const T = Type.Object({ a: Type.String(), @@ -33,7 +45,6 @@ describe('type/compiler/Record', () => { const R = Type.Record(Type.KeyOf(T), Type.Number()) Ok(R, { a: 1, b: 2, c: 3 }) }) - it('Should not validate for unknown key via keyof', () => { const T = Type.Object({ a: Type.String(), @@ -43,7 +54,6 @@ describe('type/compiler/Record', () => { const R = Type.Record(Type.KeyOf(T), Type.Number(), { additionalProperties: false }) Fail(R, { a: 1, b: 2, c: 3, d: 4 }) }) - it('Should should validate when specifying regular expressions', () => { const K = Type.RegEx(/^op_.*$/) const T = Type.Record(K, Type.Number()) @@ -53,7 +63,6 @@ describe('type/compiler/Record', () => { op_c: 3, }) }) - it('Should should not validate when specifying regular expressions and passing invalid property', () => { const K = Type.RegEx(/^op_.*$/) const T = Type.Record(K, Type.Number()) @@ -63,21 +72,17 @@ describe('type/compiler/Record', () => { aop_c: 3, }) }) - // ------------------------------------------------------------ // Integer Keys // ------------------------------------------------------------ - it('Should validate when all property keys are integers', () => { const T = Type.Record(Type.Integer(), Type.Number()) Ok(T, { '0': 1, '1': 2, '2': 3, '3': 4 }) }) - it('Should validate when all property keys are integers, but one property is a string with varying type', () => { const T = Type.Record(Type.Integer(), Type.Number()) Fail(T, { '0': 1, '1': 2, '2': 3, '3': 4, a: 'hello' }) }) - it('Should not validate if passing a leading zeros for integers keys', () => { const T = Type.Record(Type.Integer(), Type.Number()) Fail(T, { @@ -87,7 +92,6 @@ describe('type/compiler/Record', () => { '03': 4, }) }) - it('Should not validate if passing a signed integers keys', () => { const T = Type.Record(Type.Integer(), Type.Number()) Fail(T, { @@ -97,21 +101,17 @@ describe('type/compiler/Record', () => { '-3': 4, }) }) - // ------------------------------------------------------------ // Number Keys // ------------------------------------------------------------ - it('Should validate when all property keys are numbers', () => { const T = Type.Record(Type.Number(), Type.Number()) Ok(T, { '0': 1, '1': 2, '2': 3, '3': 4 }) }) - it('Should validate when all property keys are numbers, but one property is a string with varying type', () => { const T = Type.Record(Type.Number(), Type.Number()) Fail(T, { '0': 1, '1': 2, '2': 3, '3': 4, a: 'hello' }) }) - it('Should not validate if passing a leading zeros for numeric keys', () => { const T = Type.Record(Type.Number(), Type.Number()) Fail(T, { @@ -121,7 +121,6 @@ describe('type/compiler/Record', () => { '03': 4, }) }) - it('Should not validate if passing a signed numeric keys', () => { const T = Type.Record(Type.Number(), Type.Number()) Fail(T, { @@ -131,7 +130,6 @@ describe('type/compiler/Record', () => { '-3': 4, }) }) - it('Should not validate when all property keys are numbers, but one property is a string with varying type', () => { const T = Type.Record(Type.Number(), Type.Number()) Fail(T, { '0': 1, '1': 2, '2': 3, '3': 4, a: 'hello' }) diff --git a/test/runtime/schema/record.ts b/test/runtime/schema/record.ts index e9f2de8..bacc80f 100644 --- a/test/runtime/schema/record.ts +++ b/test/runtime/schema/record.ts @@ -6,24 +6,36 @@ describe('type/schema/Record', () => { const T = Type.Record(Type.String(), Type.Number()) Ok(T, { a: 1, b: 2, c: 3 }) }) - it('Should validate when all property keys are strings', () => { const T = Type.Record(Type.String(), Type.Number()) Ok(T, { a: 1, b: 2, c: 3, '0': 4 }) }) - + it('Should not validate when below minProperties', () => { + const T = Type.Record(Type.String(), Type.Number(), { minProperties: 4 }) + Ok(T, { a: 1, b: 2, c: 3, d: 4 }) + Fail(T, { a: 1, b: 2, c: 3 }) + }) + it('Should not validate when above maxProperties', () => { + const T = Type.Record(Type.String(), Type.Number(), { maxProperties: 4 }) + Ok(T, { a: 1, b: 2, c: 3, d: 4 }) + Fail(T, { a: 1, b: 2, c: 3, d: 4, e: 5 }) + }) + it('Should not validate with illogical minProperties | maxProperties', () => { + const T = Type.Record(Type.String(), Type.Number(), { minProperties: 5, maxProperties: 4 }) + Fail(T, { a: 1, b: 2, c: 3 }) + Fail(T, { a: 1, b: 2, c: 3, d: 4 }) + Fail(T, { a: 1, b: 2, c: 3, d: 4, e: 5 }) + }) it('Should validate when specifying string union literals when additionalProperties is true', () => { const K = Type.Union([Type.Literal('a'), Type.Literal('b'), Type.Literal('c')]) const T = Type.Record(K, Type.Number()) Ok(T, { a: 1, b: 2, c: 3, d: 'hello' }) }) - it('Should not validate when specifying string union literals when additionalProperties is false', () => { const K = Type.Union([Type.Literal('a'), Type.Literal('b'), Type.Literal('c')]) const T = Type.Record(K, Type.Number(), { additionalProperties: false }) Fail(T, { a: 1, b: 2, c: 3, d: 'hello' }) }) - it('Should validate for keyof records', () => { const T = Type.Object({ a: Type.String(), @@ -33,7 +45,6 @@ describe('type/schema/Record', () => { const R = Type.Record(Type.KeyOf(T), Type.Number()) Ok(R, { a: 1, b: 2, c: 3 }) }) - it('Should not validate for unknown key via keyof', () => { const T = Type.Object({ a: Type.String(), @@ -43,7 +54,6 @@ describe('type/schema/Record', () => { const R = Type.Record(Type.KeyOf(T), Type.Number(), { additionalProperties: false }) Fail(R, { a: 1, b: 2, c: 3, d: 4 }) }) - it('Should should validate when specifying regular expressions', () => { const K = Type.RegEx(/^op_.*$/) const T = Type.Record(K, Type.Number()) @@ -53,7 +63,6 @@ describe('type/schema/Record', () => { op_c: 3, }) }) - it('Should should not validate when specifying regular expressions and passing invalid property', () => { const K = Type.RegEx(/^op_.*$/) const T = Type.Record(K, Type.Number()) @@ -63,21 +72,17 @@ describe('type/schema/Record', () => { aop_c: 3, }) }) - // ------------------------------------------------------------ // Integer Keys // ------------------------------------------------------------ - it('Should validate when all property keys are integers', () => { const T = Type.Record(Type.Integer(), Type.Number()) Ok(T, { '0': 1, '1': 2, '2': 3, '3': 4 }) }) - it('Should validate when all property keys are integers, but one property is a string with varying type', () => { const T = Type.Record(Type.Integer(), Type.Number()) Fail(T, { '0': 1, '1': 2, '2': 3, '3': 4, a: 'hello' }) }) - it('Should not validate if passing a leading zeros for integers keys', () => { const T = Type.Record(Type.Integer(), Type.Number()) Fail(T, { @@ -87,7 +92,6 @@ describe('type/schema/Record', () => { '03': 4, }) }) - it('Should not validate if passing a signed integers keys', () => { const T = Type.Record(Type.Integer(), Type.Number()) Fail(T, { @@ -97,21 +101,17 @@ describe('type/schema/Record', () => { '-3': 4, }) }) - // ------------------------------------------------------------ // Number Keys // ------------------------------------------------------------ - it('Should validate when all property keys are numbers', () => { const T = Type.Record(Type.Number(), Type.Number()) Ok(T, { '0': 1, '1': 2, '2': 3, '3': 4 }) }) - it('Should validate when all property keys are numbers, but one property is a string with varying type', () => { const T = Type.Record(Type.Number(), Type.Number()) Fail(T, { '0': 1, '1': 2, '2': 3, '3': 4, a: 'hello' }) }) - it('Should not validate if passing a leading zeros for numeric keys', () => { const T = Type.Record(Type.Number(), Type.Number()) Fail(T, { @@ -121,7 +121,6 @@ describe('type/schema/Record', () => { '03': 4, }) }) - it('Should not validate if passing a signed numeric keys', () => { const T = Type.Record(Type.Number(), Type.Number()) Fail(T, { @@ -131,7 +130,6 @@ describe('type/schema/Record', () => { '-3': 4, }) }) - it('Should not validate when all property keys are numbers, but one property is a string with varying type', () => { const T = Type.Record(Type.Number(), Type.Number()) Fail(T, { '0': 1, '1': 2, '2': 3, '3': 4, a: 'hello' }) diff --git a/test/runtime/value/check/record.ts b/test/runtime/value/check/record.ts index 3bea978..2df1222 100644 --- a/test/runtime/value/check/record.ts +++ b/test/runtime/value/check/record.ts @@ -22,6 +22,22 @@ describe('value/check/Record', () => { const result = Value.Check(T, value) Assert.equal(result, true) }) + it('Should fail when below minProperties', () => { + const T = Type.Record(Type.String(), Type.Number(), { minProperties: 4 }) + Assert.equal(Value.Check(T, { a: 1, b: 2, c: 3, d: 4 }), true) + Assert.equal(Value.Check(T, { a: 1, b: 2, c: 3 }), false) + }) + it('Should fail when above maxProperties', () => { + const T = Type.Record(Type.String(), Type.Number(), { maxProperties: 4 }) + Assert.equal(Value.Check(T, { a: 1, b: 2, c: 3, d: 4 }), true) + Assert.equal(Value.Check(T, { a: 1, b: 2, c: 3, d: 4, e: 5 }), false) + }) + it('Should fail with illogical minProperties | maxProperties', () => { + const T = Type.Record(Type.String(), Type.Number(), { minProperties: 5, maxProperties: 4 }) + Assert.equal(Value.Check(T, { a: 1, b: 2, c: 3 }), false) + Assert.equal(Value.Check(T, { a: 1, b: 2, c: 3, d: 4 }), false) + Assert.equal(Value.Check(T, { a: 1, b: 2, c: 3, d: 4, e: 5 }), false) + }) it('Should fail record with Date', () => { const T = Type.Record(Type.String(), Type.String()) const result = Value.Check(T, new Date()) @@ -50,7 +66,6 @@ describe('value/check/Record', () => { const result = Value.Check(T, value) Assert.equal(result, false) }) - it('Should fail record with invalid property', () => { const T = Type.Record( Type.String(), @@ -70,7 +85,6 @@ describe('value/check/Record', () => { const result = Value.Check(T, value) Assert.equal(result, false) }) - it('Should pass record with optional property', () => { const T = Type.Record( Type.String(), @@ -89,7 +103,6 @@ describe('value/check/Record', () => { const result = Value.Check(T, value) Assert.equal(result, true) }) - it('Should pass record with optional property', () => { const T = Type.Record( Type.String(), @@ -108,11 +121,9 @@ describe('value/check/Record', () => { const result = Value.Check(T, value) Assert.equal(result, true) }) - // ------------------------------------------------- // Number Key // ------------------------------------------------- - it('Should pass record with number key', () => { const T = Type.Record(Type.Number(), Type.String()) const value = { @@ -134,11 +145,9 @@ describe('value/check/Record', () => { const result = Value.Check(T, value) Assert.equal(result, false) }) - // ------------------------------------------------- // Integer Key // ------------------------------------------------- - it('Should pass record with integer key', () => { const T = Type.Record(Type.Integer(), Type.String()) const value = { @@ -149,7 +158,6 @@ describe('value/check/Record', () => { const result = Value.Check(T, value) Assert.equal(result, true) }) - it('Should not pass record with invalid integer key', () => { const T = Type.Record(Type.Integer(), Type.String()) const value = {