diff --git a/.eslintrc.js b/.eslintrc.js index b3872a6..671187d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -13,7 +13,7 @@ module.exports = { files: ["*.ts"], rules: { ...tsConfig.rules, - complexity: ["error", 18], + complexity: ["error", 26], "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-unnecessary-condition": "warn", "@typescript-eslint/no-unsafe-assignment": "off", diff --git a/README.md b/README.md index 63e784f..ff0e6ea 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,11 @@ The keyword allows to check that some properties in array items are unique. This keyword applies only to arrays. If the data is not an array, the validation succeeds. -The value of this keyword must be an array of strings - property names that should have unique values across all items. +The value of this keyword must be either be an array of strings, an array or array of strings, or a combination of the two. + +##### Where Values are Array of Strings + +Where the values are an array of strings the strings should be property names that should have unique values across all items. ```javascript const schema = { @@ -293,6 +297,41 @@ const invalidData2 = [ ] ``` +##### Where Values are Arrays of Array of Strings + +Where the values are arrays of array of strings the strings, property names that are used the the nested array should be unique together across all items. + +```javascript +const schema = { + type: "array", + uniqueItemProperties: [["id", "name"]], +} + +const validData1 = [ + {id: 1, name: "taco"}, + {id: 2, name: "burrito"}, + {id: 3, name: "salsa"}, +] + +const validData2 = [ + {id: 1, name: "taco"}, + {id: 1, name: "burrito"}, // duplicate "id" + {id: 3, name: "salsa"}, +] + +const validData3 = [ + {id: 1, name: "taco"}, + {id: 2, name: "taco"}, // duplicate "name" + {id: 3, name: "salsa"}, +] + +const invalidData = [ + {id: 1, name: "taco"}, + {id: 1, name: "taco"}, // duplicate "name" and 'id + {id: 3, name: "salsa"}, +] +``` + This keyword is contributed by [@blainesch](https://github.com/blainesch). **Please note**: currently `uniqueItemProperties` is not supported in [standalone validation code](https://github.com/ajv-validator/ajv/blob/master/docs/standalone.md) - it has to be implemented as [`code` keyword](https://github.com/ajv-validator/ajv/blob/master/docs/keywords.md#define-keyword-with-code-generation-function) to support it (PR is welcome). diff --git a/spec/tests/uniqueItemProperties.json b/spec/tests/uniqueItemProperties.json index 81f36e1..df4fc27 100644 --- a/spec/tests/uniqueItemProperties.json +++ b/spec/tests/uniqueItemProperties.json @@ -2,34 +2,86 @@ { "description": "uniqueItemProperties keyword validation with single property", "schema": { - "uniqueItemProperties": ["id"] + "uniqueItemProperties": [ + "id" + ] }, "tests": [ { "description": "with all unique ids", - "data": [{"id": 1}, {"id": 2}, {"id": 3}], + "data": [ + { + "id": 1 + }, + { + "id": 2 + }, + { + "id": 3 + } + ], "valid": true }, { "description": "without unique ids", - "data": [{"id": 1}, {"id": 1}, {"id": 3}], + "data": [ + { + "id": 1 + }, + { + "id": 1 + }, + { + "id": 3 + } + ], "valid": false }, { "description": "with all unique object-ids", "data": [ - {"id": {"_id": 1, "date": 1495213151726}}, - {"id": {"_id": 2, "date": 1495213151727}}, - {"id": {"_id": 3, "date": 1495213151728}} + { + "id": { + "_id": 1, + "date": 1495213151726 + } + }, + { + "id": { + "_id": 2, + "date": 1495213151727 + } + }, + { + "id": { + "_id": 3, + "date": 1495213151728 + } + } ], "valid": true }, { "description": "without unique object-ids", "data": [ - {"id": {"_id": 1, "date": 1495213151726}}, - {"id": {"_id": 1, "date": 1495213151726}}, - {"id": {"_id": 3, "date": 1495213151728}} + { + "id": { + "_id": 1, + "date": 1495213151726 + } + }, + { + "id": { + "_id": 1, + "date": 1495213151726 + } + }, + { + "id": { + "_id": 3, + "date": 1495213151728 + } + } ], "valid": false }, @@ -41,15 +93,23 @@ { "description": "non-array is valid even for pseudo-arrays", "data": { - "0": {"id": 1}, - "1": {"id": 1}, + "0": { + "id": 1 + }, + "1": { + "id": 1 + }, "length": 2 }, "valid": true }, { "description": "array with one item is valid", - "data": [{"id": 1}], + "data": [ + { + "id": 1 + } + ], "valid": true }, { @@ -59,7 +119,10 @@ }, { "description": "array with non-objects is valid", - "data": [1, 1], + "data": [ + 1, + 1 + ], "valid": true } ] @@ -68,24 +131,45 @@ "description": "uniqueItemProperties keyword validation with multiple properties", "schema": { "type": "array", - "uniqueItemProperties": ["id", "name"] + "uniqueItemProperties": [ + "id", + "name" + ] }, "tests": [ { "description": "with all unique ids and names", "data": [ - {"id": 1, "name": "taco"}, - {"id": 2, "name": "burrito"}, - {"id": 3, "name": "salsa"} + { + "id": 1, + "name": "taco" + }, + { + "id": 2, + "name": "burrito" + }, + { + "id": 3, + "name": "salsa" + } ], "valid": true }, { "description": "with unique ids but not unique names", "data": [ - {"id": 1, "name": "taco"}, - {"id": 2, "name": "taco"}, - {"id": 3, "name": "salsa"} + { + "id": 1, + "name": "taco" + }, + { + "id": 2, + "name": "taco" + }, + { + "id": 3, + "name": "salsa" + } ], "valid": false } @@ -100,7 +184,17 @@ "tests": [ { "description": "with deepEqual like objects", - "data": [{"id": 1}, {"id": 1}, {"id": 1}], + "data": [ + { + "id": 1 + }, + { + "id": 1 + }, + { + "id": 1 + } + ], "valid": true } ] @@ -108,39 +202,93 @@ { "description": "uniqueItemProperties keyword validation with single property of scalar type", "schema": { - "uniqueItemProperties": ["id"], + "uniqueItemProperties": [ + "id" + ], "items": { "properties": { - "id": {"type": "number"} + "id": { + "type": "number" + } } } }, "tests": [ { "description": "with all unique ids", - "data": [{"id": 1}, {"id": 2}, {"id": 3}], + "data": [ + { + "id": 1 + }, + { + "id": 2 + }, + { + "id": 3 + } + ], "valid": true }, { "description": "without unique ids", - "data": [{"id": 1}, {"id": 1}, {"id": 3}], + "data": [ + { + "id": 1 + }, + { + "id": 1 + }, + { + "id": 3 + } + ], "valid": false }, { "description": "with all unique object-ids", "data": [ - {"id": {"_id": 1, "date": 1495213151726}}, - {"id": {"_id": 2, "date": 1495213151727}}, - {"id": {"_id": 3, "date": 1495213151728}} + { + "id": { + "_id": 1, + "date": 1495213151726 + } + }, + { + "id": { + "_id": 2, + "date": 1495213151727 + } + }, + { + "id": { + "_id": 3, + "date": 1495213151728 + } + } ], "valid": false }, { "description": "without unique object-ids", "data": [ - {"id": {"_id": 1, "date": 1495213151726}}, - {"id": {"_id": 1, "date": 1495213151726}}, - {"id": {"_id": 3, "date": 1495213151728}} + { + "id": { + "_id": 1, + "date": 1495213151726 + } + }, + { + "id": { + "_id": 1, + "date": 1495213151726 + } + }, + { + "id": { + "_id": 3, + "date": 1495213151728 + } + } ], "valid": false }, @@ -152,15 +300,23 @@ { "description": "non-array is valid even for pseudo-arrays", "data": { - "0": {"id": 1}, - "1": {"id": 1}, + "0": { + "id": 1 + }, + "1": { + "id": 1 + }, "length": 2 }, "valid": true }, { "description": "array with one item is valid", - "data": [{"id": 1}], + "data": [ + { + "id": 1 + } + ], "valid": true }, { @@ -170,7 +326,10 @@ }, { "description": "array with non-objects is valid", - "data": [1, 1], + "data": [ + 1, + 1 + ], "valid": true } ] @@ -178,10 +337,14 @@ { "description": "uniqueItemProperties keyword validation with single property of non-scalar type", "schema": { - "uniqueItemProperties": ["id"], + "uniqueItemProperties": [ + "id" + ], "items": { "properties": { - "id": {"type": "object"} + "id": { + "type": "object" + } } } }, @@ -189,18 +352,48 @@ { "description": "with all unique object-ids", "data": [ - {"id": {"_id": 1, "date": 1495213151726}}, - {"id": {"_id": 2, "date": 1495213151727}}, - {"id": {"_id": 3, "date": 1495213151728}} + { + "id": { + "_id": 1, + "date": 1495213151726 + } + }, + { + "id": { + "_id": 2, + "date": 1495213151727 + } + }, + { + "id": { + "_id": 3, + "date": 1495213151728 + } + } ], "valid": true }, { "description": "without unique object-ids", "data": [ - {"id": {"_id": 1, "date": 1495213151726}}, - {"id": {"_id": 1, "date": 1495213151726}}, - {"id": {"_id": 3, "date": 1495213151728}} + { + "id": { + "_id": 1, + "date": 1495213151726 + } + }, + { + "id": { + "_id": 1, + "date": 1495213151726 + } + }, + { + "id": { + "_id": 3, + "date": 1495213151728 + } + } ], "valid": false } @@ -209,22 +402,67 @@ { "description": "uniqueItemProperties keyword validation with single property of multiple scalar types", "schema": { - "uniqueItemProperties": ["id"], + "uniqueItemProperties": [ + "id" + ], "items": { "properties": { - "id": {"type": ["number", "string"]} + "id": { + "type": [ + "number", + "string" + ] + } } } }, "tests": [ { "description": "with all unique ids", - "data": [{"id": 1}, {"id": 2}, {"id": 3}, {"id": "1"}, {"id": "2"}, {"id": "3"}], + "data": [ + { + "id": 1 + }, + { + "id": 2 + }, + { + "id": 3 + }, + { + "id": "1" + }, + { + "id": "2" + }, + { + "id": "3" + } + ], "valid": true }, { "description": "without unique ids", - "data": [{"id": 1}, {"id": 1}, {"id": 3}, {"id": "1"}, {"id": "2"}, {"id": "3"}], + "data": [ + { + "id": 1 + }, + { + "id": 1 + }, + { + "id": 3 + }, + { + "id": "1" + }, + { + "id": "2" + }, + { + "id": "3" + } + ], "valid": false } ] @@ -232,10 +470,17 @@ { "description": "uniqueItemProperties keyword validation with single property of multiple types", "schema": { - "uniqueItemProperties": ["id"], + "uniqueItemProperties": [ + "id" + ], "items": { "properties": { - "id": {"type": ["number", "object"]} + "id": { + "type": [ + "number", + "object" + ] + } } } }, @@ -243,24 +488,66 @@ { "description": "with all unique ids", "data": [ - {"id": 1}, - {"id": 2}, - {"id": 3}, - {"id": {"_id": 1, "date": 1495213151726}}, - {"id": {"_id": 2, "date": 1495213151727}}, - {"id": {"_id": 3, "date": 1495213151728}} + { + "id": 1 + }, + { + "id": 2 + }, + { + "id": 3 + }, + { + "id": { + "_id": 1, + "date": 1495213151726 + } + }, + { + "id": { + "_id": 2, + "date": 1495213151727 + } + }, + { + "id": { + "_id": 3, + "date": 1495213151728 + } + } ], "valid": true }, { "description": "without unique ids", "data": [ - {"id": 1}, - {"id": 2}, - {"id": 3}, - {"id": {"_id": 1, "date": 1495213151726}}, - {"id": {"_id": 1, "date": 1495213151726}}, - {"id": {"_id": 3, "date": 1495213151728}} + { + "id": 1 + }, + { + "id": 2 + }, + { + "id": 3 + }, + { + "id": { + "_id": 1, + "date": 1495213151726 + } + }, + { + "id": { + "_id": 1, + "date": 1495213151726 + } + }, + { + "id": { + "_id": 3, + "date": 1495213151728 + } + } ], "valid": false } @@ -270,24 +557,45 @@ "description": "uniqueItemProperties keyword validation with multiple properties", "schema": { "type": "array", - "uniqueItemProperties": ["id", "name"] + "uniqueItemProperties": [ + "id", + "name" + ] }, "tests": [ { "description": "with all unique ids and names", "data": [ - {"id": 1, "name": "taco"}, - {"id": 2, "name": "burrito"}, - {"id": 3, "name": "salsa"} + { + "id": 1, + "name": "taco" + }, + { + "id": 2, + "name": "burrito" + }, + { + "id": 3, + "name": "salsa" + } ], "valid": true }, { "description": "with unique ids but not unique names", "data": [ - {"id": 1, "name": "taco"}, - {"id": 2, "name": "taco"}, - {"id": 3, "name": "salsa"} + { + "id": 1, + "name": "taco" + }, + { + "id": 2, + "name": "taco" + }, + { + "id": 3, + "name": "salsa" + } ], "valid": false } @@ -297,11 +605,18 @@ "description": "uniqueItemProperties keyword validation with multiple properties of scalar types", "schema": { "type": "array", - "uniqueItemProperties": ["id", "name"], + "uniqueItemProperties": [ + "id", + "name" + ], "items": { "properties": { - "id": {"type": "number"}, - "name": {"type": "string"} + "id": { + "type": "number" + }, + "name": { + "type": "string" + } } } }, @@ -309,18 +624,36 @@ { "description": "with all unique ids and names", "data": [ - {"id": 1, "name": "taco"}, - {"id": 2, "name": "burrito"}, - {"id": 3, "name": "salsa"} + { + "id": 1, + "name": "taco" + }, + { + "id": 2, + "name": "burrito" + }, + { + "id": 3, + "name": "salsa" + } ], "valid": true }, { "description": "with unique ids but not unique names", "data": [ - {"id": 1, "name": "taco"}, - {"id": 2, "name": "taco"}, - {"id": 3, "name": "salsa"} + { + "id": 1, + "name": "taco" + }, + { + "id": 2, + "name": "taco" + }, + { + "id": 3, + "name": "salsa" + } ], "valid": false } @@ -330,11 +663,18 @@ "description": "uniqueItemProperties keyword validation with multiple properties with some scalar types", "schema": { "type": "array", - "uniqueItemProperties": ["id", "name"], + "uniqueItemProperties": [ + "id", + "name" + ], "items": { "properties": { - "id": {"type": "object"}, - "name": {"type": "string"} + "id": { + "type": "object" + }, + "name": { + "type": "string" + } } } }, @@ -342,18 +682,54 @@ { "description": "with all unique ids and names", "data": [ - {"id": {"_id": 1, "date": 1495213151726}, "name": "taco"}, - {"id": {"_id": 2, "date": 1495213151727}, "name": "burrito"}, - {"id": {"_id": 3, "date": 1495213151728}, "name": "salsa"} + { + "id": { + "_id": 1, + "date": 1495213151726 + }, + "name": "taco" + }, + { + "id": { + "_id": 2, + "date": 1495213151727 + }, + "name": "burrito" + }, + { + "id": { + "_id": 3, + "date": 1495213151728 + }, + "name": "salsa" + } ], "valid": true }, { "description": "with non-unique ids but unique names", "data": [ - {"id": {"_id": 1, "date": 1495213151726}, "name": "taco"}, - {"id": {"_id": 1, "date": 1495213151726}, "name": "burrito"}, - {"id": {"_id": 3, "date": 1495213151727}, "name": "salsa"} + { + "id": { + "_id": 1, + "date": 1495213151726 + }, + "name": "taco" + }, + { + "id": { + "_id": 1, + "date": 1495213151726 + }, + "name": "burrito" + }, + { + "id": { + "_id": 3, + "date": 1495213151727 + }, + "name": "salsa" + } ], "valid": false } @@ -363,22 +739,44 @@ "description": "uniqueItemProperties keyword with null item(s)", "schema": { "type": "array", - "uniqueItemProperties": ["id"], + "uniqueItemProperties": [ + "id" + ], "items": { "properties": { - "id": {"type": "integer"} + "id": { + "type": "integer" + } } } }, "tests": [ { "description": "with all unique ids and null items is valid", - "data": [{"id": 1}, {"id": 2}, null, null], + "data": [ + { + "id": 1 + }, + { + "id": 2 + }, + null, + null + ], "valid": true }, { "description": "with non-unique ids and null item is invalid", - "data": [{"id": 1}, {"id": 1}, null, null], + "data": [ + { + "id": 1 + }, + { + "id": 1 + }, + null, + null + ], "valid": false } ] @@ -387,24 +785,170 @@ "description": "uniqueItemProperties keyword with null item(s) and object keys", "schema": { "type": "array", - "uniqueItemProperties": ["id"], + "uniqueItemProperties": [ + "id" + ], "items": { "properties": { - "id": {"type": "object"} + "id": { + "type": "object" + } } } }, "tests": [ { "description": "with all unique ids and null items is valid", - "data": [{"id": {"_id": 1}}, {"id": {"_id": 2}}, null, null], + "data": [ + { + "id": { + "_id": 1 + } + }, + { + "id": { + "_id": 2 + } + }, + null, + null + ], "valid": true }, { "description": "with non-unique ids and null item is invalid", - "data": [{"id": {"_id": 1}}, {"id": {"_id": 1}}, null, null], + "data": [ + { + "id": { + "_id": 1 + } + }, + { + "id": { + "_id": 1 + } + }, + null, + null + ], + "valid": false + } + ] + }, + { + "description": "uniqueItemProperties keyword arrays", + "schema": { + "type": "array", + "uniqueItemProperties": [ + [ + "id", + "name" + ] + ], + "items": { + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + } + } + }, + "tests": [ + { + "description": "with all unique combination is valid", + "data": [ + { + "id": 1, + "name": "taco" + }, + { + "id": 2, + "name": "burrito" + }, + { + "id": 3, + "name": "salsa" + } + ], + "valid": true + }, + { + "description": "with all unique combination but same id or name is valid", + "data": [ + { + "id": 1, + "name": "taco" + }, + { + "id": 1, + "name": "burrito" + }, + { + "id": 2, + "name": "burrito" + } + ], + "valid": true + }, + { + "description": "with all unique combination but same id or name is valid", + "data": [ + { + "id": 1, + "name": "taco" + }, + { + "id": 1, + "name": "burrito" + }, + { + "id": 2, + "name": "burrito" + }, + null + ], + "valid": true + }, + { + "description": "with non-unique combination is invalid", + "data": [ + { + "id": 1, + "name": "taco" + }, + { + "id": 1, + "name": "taco" + }, + { + "id": 1, + "name": "salsa" + } + ], + "valid": false + }, + { + "description": "with non-unique combination and null items is invalid", + "data": [ + { + "id": 1, + "name": "taco" + }, + { + "id": 1, + "name": "taco" + }, + { + "id": 1, + "name": "salsa" + }, + null + ], "valid": false } ] } -] +] \ No newline at end of file diff --git a/src/definitions/uniqueItemProperties.ts b/src/definitions/uniqueItemProperties.ts index 8b2c6f9..c36d6ca 100644 --- a/src/definitions/uniqueItemProperties.ts +++ b/src/definitions/uniqueItemProperties.ts @@ -8,14 +8,32 @@ export default function getDef(): FuncKeywordDefinition { keyword: "uniqueItemProperties", type: "array", schemaType: "array", - compile(keys: string[], parentSchema: AnySchemaObject) { + compile(keys: string[] | string[][], parentSchema: AnySchemaObject) { const scalar = getScalarKeys(keys, parentSchema) return (data) => { if (data.length <= 1) return true + for (let k = 0; k < keys.length; k++) { const key = keys[k] - if (scalar[k]) { + if (Array.isArray(key)) { + for (let i = data.length; i--; ) { + const x = data[i] + if (!x || typeof x != "object") continue + + for (let j = i; j--; ) { + const y = data[j] + if ( + y && + typeof y == "object" && + key.map((ki: string): boolean => equal(x[ki], y[ki])).filter(Boolean).length === + key.length + ) { + return false + } + } + } + } else if (scalar[k]) { const hash: Record = {} for (const x of data) { if (!x || typeof x != "object") continue @@ -29,6 +47,7 @@ export default function getDef(): FuncKeywordDefinition { for (let i = data.length; i--; ) { const x = data[i] if (!x || typeof x != "object") continue + for (let j = i; j--; ) { const y = data[j] if (y && typeof y == "object" && equal(x[key], y[key])) return false @@ -36,18 +55,20 @@ export default function getDef(): FuncKeywordDefinition { } } } + return true } }, metaSchema: { type: "array", - items: {type: "string"}, + items: {type: ["string", "array"]}, }, } } -function getScalarKeys(keys: string[], schema: AnySchemaObject): boolean[] { +function getScalarKeys(keys: string[] | string[][], schema: AnySchemaObject): boolean[] { return keys.map((key) => { + if (Array.isArray(key)) return false const t = schema.items?.properties?.[key]?.type return Array.isArray(t) ? !t.includes("object") && !t.includes("array")