Skip to content

Commit

Permalink
Additional Record Constraints (#363)
Browse files Browse the repository at this point in the history
  • Loading branch information
sinclairzx81 committed Mar 29, 2023
1 parent e8b213c commit 069c95e
Show file tree
Hide file tree
Showing 7 changed files with 63 additions and 46 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/compiler/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,9 +287,10 @@ export namespace TypeCompiler {
function* Promise(schema: Types.TPromise<any>, references: Types.TSchema[], value: string): IterableIterator<string> {
yield `(typeof value === 'object' && typeof ${value}.then === 'function')`
}

function* Record(schema: Types.TRecord<any, any>, references: Types.TSchema[], value: string): IterableIterator<string> {
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)))`
Expand Down
6 changes: 6 additions & 0 deletions src/errors/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,12 @@ export namespace ValueErrors {
if (!IsRecordObject(value)) {
return yield { type: ValueErrorType.Object, schema, path, value, message: `Expected record object` }
}
if (IsDefined<number>(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<number>(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))) {
Expand Down
6 changes: 6 additions & 0 deletions src/value/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,12 @@ export namespace ValueCheck {
if (!IsRecordObject(value)) {
return false
}
if (IsDefined<number>(schema.minProperties) && !(globalThis.Object.getOwnPropertyNames(value).length >= schema.minProperties)) {
return false
}
if (IsDefined<number>(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))) {
Expand Down
34 changes: 16 additions & 18 deletions test/runtime/compiler/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -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())
Expand All @@ -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())
Expand All @@ -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, {
Expand All @@ -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, {
Expand All @@ -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, {
Expand All @@ -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, {
Expand All @@ -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' })
Expand Down
34 changes: 16 additions & 18 deletions test/runtime/schema/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -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())
Expand All @@ -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())
Expand All @@ -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, {
Expand All @@ -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, {
Expand All @@ -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, {
Expand All @@ -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, {
Expand All @@ -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' })
Expand Down
24 changes: 16 additions & 8 deletions test/runtime/value/check/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand Down

0 comments on commit 069c95e

Please sign in to comment.