diff --git a/.changeset/fifty-suns-march.md b/.changeset/fifty-suns-march.md new file mode 100644 index 0000000..b51aa25 --- /dev/null +++ b/.changeset/fifty-suns-march.md @@ -0,0 +1,5 @@ +--- +"@neo4j/graph-schema-utils": patch +--- + +fix: detect malformed graph schema with duplicated node label ids and object type ids diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..af9d4c6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,21 @@ +{ + "editor.formatOnSave": true, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f708569..5f7208b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4449,13 +4449,13 @@ }, "packages/graph-schema-utils": { "name": "@neo4j/graph-schema-utils", - "version": "1.0.0-next.15", + "version": "1.0.0-next.16", "license": "Apache-2.0", "dependencies": { "ajv": "^8.11.0" }, "devDependencies": { - "@neo4j/graph-json-schema": "1.0.0-next.4" + "@neo4j/graph-json-schema": "1.0.0-next.5" } }, "packages/introspection": { @@ -4463,13 +4463,13 @@ "version": "1.0.0-next.2", "license": "Apache-2.0", "devDependencies": { - "@neo4j/graph-json-schema": "1.0.0-next.4", + "@neo4j/graph-json-schema": "1.0.0-next.5", "neo4j-driver": "^5.12.0" } }, "packages/json-schema": { "name": "@neo4j/graph-json-schema", - "version": "1.0.0-next.4", + "version": "1.0.0-next.5", "license": "Apache-2.0" } }, @@ -5012,7 +5012,7 @@ "@neo4j/graph-introspection": { "version": "file:packages/introspection", "requires": { - "@neo4j/graph-json-schema": "1.0.0-next.4", + "@neo4j/graph-json-schema": "1.0.0-next.5", "neo4j-driver": "^5.12.0" } }, @@ -5022,7 +5022,7 @@ "@neo4j/graph-schema-utils": { "version": "file:packages/graph-schema-utils", "requires": { - "@neo4j/graph-json-schema": "1.0.0-next.4", + "@neo4j/graph-json-schema": "1.0.0-next.5", "ajv": "^8.11.0" } }, diff --git a/packages/graph-schema-utils/src/formatters/json/extensions.test.ts b/packages/graph-schema-utils/src/formatters/json/extensions.test.ts new file mode 100644 index 0000000..cab91fe --- /dev/null +++ b/packages/graph-schema-utils/src/formatters/json/extensions.test.ts @@ -0,0 +1,75 @@ +import { describe, test } from "vitest"; +import { readFile } from "../../../test/fs.utils.js"; +import path from "path"; +import { fromJson } from "./index.js"; +import { strict as assert } from "node:assert"; +import { + hasDuplicateNodeLabelIds, + hasDuplicateNodeObjectTypeIds, +} from "./extensions.js"; +import { RootSchemaJsonStruct } from "./types.js"; + +describe("JSON formatter", () => { + describe("hasDuplicateNodeLabelIds", () => { + test("Identifies duplicated node labels", () => { + const schema = readFile( + path.resolve(__dirname, "./test-schemas/duplicated-nodeLabel-ids.json") + ); + const schemaJson = JSON.parse(schema) as RootSchemaJsonStruct; + + assert.strictEqual( + hasDuplicateNodeLabelIds( + schemaJson.graphSchemaRepresentation.graphSchema + ), + true + ); + }); + }); + describe("hasDuplicateNodeObjectTypeIds", () => { + test("Identifies duplicated node object types", () => { + const schema = readFile( + path.resolve( + __dirname, + "./test-schemas/duplicated-nodeObjectType-ids.json" + ) + ); + const schemaJson = JSON.parse(schema) as RootSchemaJsonStruct; + + assert.strictEqual( + hasDuplicateNodeObjectTypeIds( + schemaJson.graphSchemaRepresentation.graphSchema + ), + true + ); + }); + }); + describe("fromJson", () => { + const schemaWithDuplicatedNodeLabels = readFile( + path.resolve(__dirname, "./test-schemas/duplicated-nodeLabel-ids.json") + ); + test("Identifies duplicated node labels adn throws an error", () => { + assert.throws(() => fromJson(schemaWithDuplicatedNodeLabels), { + message: "Duplicate node label IDs found in schema", + }); + }); + + test("Identifies duplicated node object types and throws an error", () => { + const schema = readFile( + path.resolve( + __dirname, + "./test-schemas/duplicated-nodeObjectType-ids.json" + ) + ); + assert.throws(() => fromJson(schema), { + message: "Duplicate node object type IDs found in schema", + }); + }); + + test("Does not throw an error if there are no duplicated ids in node labels or node object types", () => { + const schema = readFile( + path.resolve(__dirname, "./test-schemas/full.json") + ); + assert.doesNotThrow(() => fromJson(schema)); + }); + }); +}); diff --git a/packages/graph-schema-utils/src/formatters/json/extensions.ts b/packages/graph-schema-utils/src/formatters/json/extensions.ts index abe6ab2..00297fd 100644 --- a/packages/graph-schema-utils/src/formatters/json/extensions.ts +++ b/packages/graph-schema-utils/src/formatters/json/extensions.ts @@ -23,6 +23,7 @@ import { } from "../../model/index.js"; import { ConstraintJsonStruct, + GraphSchemaJsonStruct, IndexJsonStruct, isLookupIndexJsonStruct, isNodeLabelConstraintJsonStruct, @@ -107,12 +108,48 @@ export function fromJson(schema: string): GraphSchema { return fromJsonStruct(schemaJson); } +export function hasDuplicateNodeLabelIds( + schema: GraphSchemaJsonStruct +): boolean { + const ids = new Set(); + + for (const nodeLabel of schema.nodeLabels) { + if (ids.has(nodeLabel.$id)) { + return true; + } + ids.add(nodeLabel.$id); + } + + return false; +} + +export function hasDuplicateNodeObjectTypeIds( + schema: GraphSchemaJsonStruct +): boolean { + const ids = new Set(); + + for (const nodeObjectType of schema.nodeObjectTypes) { + if (ids.has(nodeObjectType.$id)) { + return true; + } + ids.add(nodeObjectType.$id); + } + + return false; +} + export function fromJsonStruct(schemaJson: RootSchemaJsonStruct): GraphSchema { const { graphSchema } = schemaJson.graphSchemaRepresentation; + if (hasDuplicateNodeLabelIds(graphSchema)) { + throw new Error("Duplicate node label IDs found in schema"); + } const labels = graphSchema.nodeLabels.map(nodeLabel.create); const relationshipTypes = graphSchema.relationshipTypes.map( relationshipType.create ); + if (hasDuplicateNodeObjectTypeIds(graphSchema)) { + throw new Error("Duplicate node object type IDs found in schema"); + } const nodeObjectTypes = graphSchema.nodeObjectTypes.map( (nodeObjectTypeJson) => nodeObjectType.create(nodeObjectTypeJson, (ref) => { diff --git a/packages/graph-schema-utils/src/formatters/json/test-schemas/duplicated-nodeLabel-ids.json b/packages/graph-schema-utils/src/formatters/json/test-schemas/duplicated-nodeLabel-ids.json new file mode 100644 index 0000000..a5f3121 --- /dev/null +++ b/packages/graph-schema-utils/src/formatters/json/test-schemas/duplicated-nodeLabel-ids.json @@ -0,0 +1,123 @@ +{ + "graphSchemaRepresentation": { + "version": "1.0.0", + "graphSchema": { + "nodeLabels": [ + { + "$id": "nl:0", + "token": "DOCS_CHUNKS_TABLE", + "properties": [ + { + "$id": "p:0_0", + "token": "RELATIVE_PATH", + "type": { + "type": "string" + }, + "nullable": false + }, + { + "$id": "p:0_1", + "token": "SIZE", + "type": { + "type": "integer" + }, + "nullable": false + }, + { + "$id": "p:0_2", + "token": "FILE_URL", + "type": { + "type": "string" + }, + "nullable": false + }, + { + "$id": "p:0_3", + "token": "SCOPED_FILE_URL", + "type": { + "type": "string" + }, + "nullable": false + }, + { + "$id": "p:0_4", + "token": "CHUNK", + "type": { + "type": "string" + }, + "nullable": false + } + ] + }, + { + "$id": "nl:0", + "token": "DOCS_METADATA", + "properties": [ + { + "$id": "p:0_0", + "token": "RELATIVE_PATH", + "type": { + "type": "string" + }, + "nullable": false + }, + { + "$id": "p:0_1", + "token": "MODEL_NAME", + "type": { + "type": "string" + }, + "nullable": false + }, + { + "$id": "p:0_2", + "token": "SKILLS", + "type": { + "type": "string" + }, + "nullable": false + }, + { + "$id": "p:0_3", + "token": "DOMAINS", + "type": { + "type": "string" + }, + "nullable": false + }, + { + "$id": "p:0_4", + "token": "SUMMARY", + "type": { + "type": "string" + }, + "nullable": false + } + ] + } + ], + "relationshipTypes": [], + "nodeObjectTypes": [ + { + "$id": "n:0", + "labels": [ + { + "$ref": "#nl:0" + } + ] + }, + { + "$id": "n:0", + "labels": [ + { + "$ref": "#nl:0" + } + ] + } + ], + "relationshipObjectTypes": [], + "constraints": [], + "indexes": [] + } + } +} diff --git a/packages/graph-schema-utils/src/formatters/json/test-schemas/duplicated-nodeObjectType-ids.json b/packages/graph-schema-utils/src/formatters/json/test-schemas/duplicated-nodeObjectType-ids.json new file mode 100644 index 0000000..cc3f323 --- /dev/null +++ b/packages/graph-schema-utils/src/formatters/json/test-schemas/duplicated-nodeObjectType-ids.json @@ -0,0 +1,123 @@ +{ + "graphSchemaRepresentation": { + "version": "1.0.0", + "graphSchema": { + "nodeLabels": [ + { + "$id": "nl:0", + "token": "DOCS_CHUNKS_TABLE", + "properties": [ + { + "$id": "p:0_0", + "token": "RELATIVE_PATH", + "type": { + "type": "string" + }, + "nullable": false + }, + { + "$id": "p:0_1", + "token": "SIZE", + "type": { + "type": "integer" + }, + "nullable": false + }, + { + "$id": "p:0_2", + "token": "FILE_URL", + "type": { + "type": "string" + }, + "nullable": false + }, + { + "$id": "p:0_3", + "token": "SCOPED_FILE_URL", + "type": { + "type": "string" + }, + "nullable": false + }, + { + "$id": "p:0_4", + "token": "CHUNK", + "type": { + "type": "string" + }, + "nullable": false + } + ] + }, + { + "$id": "nl:1", + "token": "DOCS_METADATA", + "properties": [ + { + "$id": "p:0_0", + "token": "RELATIVE_PATH", + "type": { + "type": "string" + }, + "nullable": false + }, + { + "$id": "p:0_1", + "token": "MODEL_NAME", + "type": { + "type": "string" + }, + "nullable": false + }, + { + "$id": "p:0_2", + "token": "SKILLS", + "type": { + "type": "string" + }, + "nullable": false + }, + { + "$id": "p:0_3", + "token": "DOMAINS", + "type": { + "type": "string" + }, + "nullable": false + }, + { + "$id": "p:0_4", + "token": "SUMMARY", + "type": { + "type": "string" + }, + "nullable": false + } + ] + } + ], + "relationshipTypes": [], + "nodeObjectTypes": [ + { + "$id": "n:0", + "labels": [ + { + "$ref": "#nl:0" + } + ] + }, + { + "$id": "n:0", + "labels": [ + { + "$ref": "#nl:0" + } + ] + } + ], + "relationshipObjectTypes": [], + "constraints": [], + "indexes": [] + } + } +}