From 0615a5a02c2a4bcea361fa67dce0012396c4ceca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sat, 29 Nov 2025 07:55:25 +0100 Subject: [PATCH 1/4] Adding the random id generation to WOQL JS --- integration_tests/woql_random_idgen.test.ts | 195 ++++++++++++++++++++ lib/query/woqlQuery.js | 17 ++ lib/woql.js | 13 ++ test/woql.spec.js | 7 + test/woqlJson/woqlRandomKeyJson.js | 11 ++ 5 files changed, 243 insertions(+) create mode 100644 integration_tests/woql_random_idgen.test.ts create mode 100644 test/woqlJson/woqlRandomKeyJson.js diff --git a/integration_tests/woql_random_idgen.test.ts b/integration_tests/woql_random_idgen.test.ts new file mode 100644 index 00000000..a041402c --- /dev/null +++ b/integration_tests/woql_random_idgen.test.ts @@ -0,0 +1,195 @@ +//@ts-check +import { describe, expect, test, beforeAll, afterAll } from "@jest/globals"; +import { WOQLClient, WOQL } from "../index.js"; +import { DbDetails } from "../dist/typescript/lib/typedef.js"; + +let client: WOQLClient; + +beforeAll(() => { + client = new WOQLClient("http://127.0.0.1:6363", { + user: "admin", + organization: "admin", + key: process.env.TDB_ADMIN_PASS ?? "root" + }); +}); + +const testDb = "db__test_woql_random_idgen"; + +describe("WOQL Random ID Generation", () => { + test("Setup: Create database and schema", async () => { + const dbObj: DbDetails = { + label: testDb, + comment: "Test database for random ID generation", + schema: true + }; + const result = await client.createDatabase(testDb, dbObj); + expect(result["@type"]).toEqual("api:DbCreateResponse"); + + const schema = [ + { + "@type": "Class", + "@id": "Person", + "@key": { "@type": "Random" }, + name: "xsd:string" + } + ]; + + await client.addDocument(schema, { graph_type: "schema" }); + }); + + test("Generate random ID using WOQL", async () => { + const query = WOQL.random_idgen("Person/", "v:person_id"); + + const result = await client.query(query); + expect(result?.bindings).toBeDefined(); + expect(result?.bindings?.length).toBeGreaterThan(0); + + // Server returns bindings without the 'v:' prefix + const binding = result?.bindings?.[0]; + const personId = binding["person_id"] || binding["v:person_id"]; + expect(personId).toBeDefined(); + expect(personId).toContain("Person/"); + + // Should have a 16-character random suffix + const suffix = personId.split("Person/")[1]; + expect(suffix.length).toBe(16); + }); + + test("Generate multiple unique IDs", async () => { + const query = WOQL.and( + WOQL.random_idgen("Person/", "v:id1"), + WOQL.random_idgen("Person/", "v:id2"), + WOQL.random_idgen("Person/", "v:id3") + ); + + const result = await client.query(query); + expect(result?.bindings).toBeDefined(); + expect(result?.bindings?.length).toBe(1); + + const binding = result?.bindings?.[0]; + const id1 = binding["id1"] || binding["v:id1"]; + const id2 = binding["id2"] || binding["v:id2"]; + const id3 = binding["id3"] || binding["v:id3"]; + + // All IDs should be different + expect(id1).not.toEqual(id2); + expect(id1).not.toEqual(id3); + expect(id2).not.toEqual(id3); + + // All should start with prefix + expect(id1).toContain("Person/"); + expect(id2).toContain("Person/"); + expect(id3).toContain("Person/"); + }); + + test("Use random ID to create document", async () => { + //Generate random ID + const query = WOQL.random_idgen("Person/", "v:new_person"); + + const result = await client.query(query); + expect(result?.bindings).toBeDefined(); + + const binding = result?.bindings?.[0]; + const personId = binding["new_person"] || binding["v:new_person"]; + expect(personId).toBeDefined(); + expect(personId).toContain("Person/"); + + // Create the document using the generated ID + const doc = { + "@type": "Person", + "@id": personId, + name: "Alice" + }; + + await client.addDocument(doc); + + // Verify the document was created + const retrieved: any = await client.getDocument({ id: personId }); + expect(retrieved["@id"]).toEqual(personId); + expect(retrieved.name).toEqual("Alice"); + }); + + test("Generate different IDs on repeated query execution", async () => { + const query = WOQL.random_idgen("Data/", "v:id"); + const ids = new Set(); + + for (let i = 0; i < 10; i++) { + const result = await client.query(query); + const binding = result?.bindings?.[0]; + const id = binding["id"] || binding["v:id"]; + ids.add(id); + } + + // All 10 executions should produce unique IDs + expect(ids.size).toBe(10); + }); + + test("Mix WOQL builder with raw JSON-LD", async () => { + // Test mixing WOQL builder syntax with raw JSON-LD + // This verifies the pattern documented in woql-json-ld-queries guide + const query1 = WOQL.and( + WOQL.random_idgen("Test/", "v:test_id"), + { + "@type": "LexicalKey", + base: { + "@type": "DataValue", + data: { + "@type": "xsd:string", + "@value": "Display/" + } + }, + key_list: [], + uri: { + "@type": "NodeValue", + variable: "out" + } + } as any + ); + + const result1 = await client.query(query1); + const binding1 = result1?.bindings?.[0]; + + // Verify random_idgen result + const id1 = binding1["test_id"] || binding1["v:test_id"]; + expect(id1).toBeDefined(); + expect(id1).toContain("Test/"); + + // Verify the ID has correct 16-character suffix + const suffix = id1.split("Test/")[1]; + expect(suffix.length).toBe(16); + + // Verify LexicalKey result (empty key_list generates just the base) + const out = binding1["out"] || binding1["v:out"]; + expect(out).toBeDefined(); + expect(out).toContain("Display/"); + }); + + test("Both random_idgen and idgen_random aliases work", async () => { + // Test random_idgen alias + const query1 = WOQL.random_idgen("Test/", "v:test_id"); + const result1 = await client.query(query1); + const binding1 = result1?.bindings?.[0]; + const id1 = binding1["test_id"] || binding1["v:test_id"]; + expect(id1).toBeDefined(); + expect(id1).toContain("Test/"); + + // Test idgen_random alias (should produce same structure) + const query2 = WOQL.idgen_random("Test/", "v:test_id"); + const result2 = await client.query(query2); + const binding2 = result2?.bindings?.[0]; + const id2 = binding2["test_id"] || binding2["v:test_id"]; + expect(id2).toBeDefined(); + expect(id2).toContain("Test/"); + + // Both queries should produce the same JSON structure (same variable name) + expect(query1.json()).toEqual(query2.json()); + }); + + afterAll(async () => { + try { + await client.deleteDatabase(testDb); + } catch (e) { + // Ignore errors + } + }); +}); diff --git a/lib/query/woqlQuery.js b/lib/query/woqlQuery.js index f0786b0d..643342df 100644 --- a/lib/query/woqlQuery.js +++ b/lib/query/woqlQuery.js @@ -1175,6 +1175,23 @@ WOQLQuery.prototype.idgen = function (prefix, inputVarList, outputVar) { WOQLQuery.prototype.idgenerator = WOQLQuery.prototype.idgen; +/** + * Generates a random ID with a specified prefix + * Uses cryptographically secure random base64 encoding to generate unique identifiers + * @param {string} prefix - prefix for the generated ID + * @param {string} outputVar - variable that stores the generated ID + * @returns {WOQLQuery} A WOQLQuery which contains the random ID generation pattern + * @example + * idgen_random("Person/", "v:person_id") + */ +WOQLQuery.prototype.idgen_random = function (prefix, outputVar) { + if (this.cursor['@type']) this.wrapCursorWithAnd(); + this.cursor['@type'] = 'RandomKey'; + this.cursor.base = this.cleanDataValue(prefix, 'xsd:string'); + this.cursor.uri = this.cleanNodeValue(outputVar); + return this; +}; + /** * Changes a string to upper-case * @param {string|Var} inputVarName - string or variable representing the uncapitalized string diff --git a/lib/woql.js b/lib/woql.js index f709b613..9a609e42 100644 --- a/lib/woql.js +++ b/lib/woql.js @@ -749,6 +749,19 @@ WOQL.idgenerator = function (prefix, inputVarList, resultVarName) { return new WOQLQuery().idgen(prefix, inputVarList, resultVarName); }; +/** + * Generates a random ID with a specified prefix + * @param {string} prefix - prefix for the generated ID + * @param {string} resultVarName - variable that stores the generated ID + * @returns {WOQLQuery} A WOQLQuery object containing the random ID generation function + * @example + * let [newid] = vars("newid") + * idgen_random("Person/", newid) + */ +WOQL.idgen_random = function (prefix, resultVarName) { + return new WOQLQuery().idgen_random(prefix, resultVarName); +}; + /** * * Changes a string to upper-case diff --git a/test/woql.spec.js b/test/woql.spec.js index 6209d717..dbed9479 100644 --- a/test/woql.spec.js +++ b/test/woql.spec.js @@ -4,6 +4,7 @@ const WOQL = require('../lib/woql'); const { Var, VarUnique, Vars } = require('../lib/query/woqlDoc'); const idGenJson = require('./woqlJson/woqlIdgenJson'); +const randomKeyJson = require('./woqlJson/woqlRandomKeyJson'); const woqlStarJson = require('./woqlJson/woqlStarJson'); const woqlInsertJson = require('./woqlJson/woqlInsertJson'); const woqlDoctypeJson = require('./woqlJson/woqlDoctypeJson'); @@ -285,6 +286,12 @@ describe('woql queries', () => { expect(woqlObject.json()).to.eql(idGenJson); }); + it('check the idgen_random method', () => { + const woqlObject = WOQL.idgen_random('Person/', 'v:Person_ID'); + + expect(woqlObject.json()).to.eql(randomKeyJson); + }); + it('check the typecast method', () => { const woqlObject = WOQL.typecast('v:Duration', 'xsd:integer', 'v:Duration_Cast'); diff --git a/test/woqlJson/woqlRandomKeyJson.js b/test/woqlJson/woqlRandomKeyJson.js new file mode 100644 index 00000000..a4394d01 --- /dev/null +++ b/test/woqlJson/woqlRandomKeyJson.js @@ -0,0 +1,11 @@ +module.exports = { + '@type': 'RandomKey', + base: { + '@type': 'DataValue', + data: { '@type': 'xsd:string', '@value': 'Person/' }, + }, + uri: { + '@type': 'NodeValue', + variable: 'Person_ID', + }, +}; From 215e231bbdd3b69c6614e2854ec0769c75012c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sat, 29 Nov 2025 08:09:17 +0100 Subject: [PATCH 2/4] Fix update_document for lists in document templates --- .../woql_update_document_list.test.ts | 153 ++++++++++++++++++ lib/query/woqlDoc.js | 25 +++ test/woqlUpdateDocWithList.spec.js | 122 ++++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 integration_tests/woql_update_document_list.test.ts create mode 100644 test/woqlUpdateDocWithList.spec.js diff --git a/integration_tests/woql_update_document_list.test.ts b/integration_tests/woql_update_document_list.test.ts new file mode 100644 index 00000000..b28f1457 --- /dev/null +++ b/integration_tests/woql_update_document_list.test.ts @@ -0,0 +1,153 @@ +//@ts-check +import { describe, expect, test, beforeAll, afterAll } from '@jest/globals'; +import { WOQLClient, WOQL, Doc } from '../index.js'; +import WOQLQuery from '../lib/query/woqlQuery.js'; + +/** + * Integration test for update_document with a list of subdocuments. + * + * This test verifies that the WOQL builder correctly handles nested Doc() + * objects when updating documents containing lists of subdocuments. + * + * Related issue: Nested Doc() in update_document was producing incorrect + * WOQL JSON structure due to double-conversion. + */ + +let client: WOQLClient; +const testDbName = `update_list_test_${Date.now()}`; + +// Schema with a subdocument list +const schema = [ + { + '@base': 'terminusdb:///data/', + '@schema': 'terminusdb:///schema#', + '@type': '@context', + }, + { + '@type': 'Class', + '@id': 'UpdateList', + '@key': { '@type': 'Random' }, + list: { '@class': 'Structure', '@type': 'List' }, + }, + { + '@type': 'Class', + '@id': 'Structure', + '@key': { '@type': 'Random' }, + '@subdocument': [], + string: 'xsd:string', + }, +]; + +beforeAll(async () => { + client = new WOQLClient('http://127.0.0.1:6363', { + user: 'admin', + organization: 'admin', + key: process.env.TDB_ADMIN_PASS ?? 'root', + }); + + // Create test database + await client.createDatabase(testDbName, { + label: 'Update List Test', + comment: 'Test database for update_document with subdocument lists', + schema: true, + }); + client.db(testDbName); + + // Add schema + await client.addDocument(schema, { graph_type: 'schema', full_replace: true }); +}); + +afterAll(async () => { + try { + await client.deleteDatabase(testDbName); + } catch (e) { + // Database might not exist + } +}); + +describe('update_document with list of subdocuments', () => { + const docId = 'UpdateList/test-doc'; + + test('should insert initial document with subdocument list', async () => { + const initialDoc = { + '@type': 'UpdateList', + '@id': docId, + list: [ + { '@type': 'Structure', string: 'initial-1' }, + { '@type': 'Structure', string: 'initial-2' }, + ], + }; + + const result = await client.addDocument(initialDoc); + // Result contains full IRI with prefix + expect(result[0]).toContain('UpdateList/test-doc'); + }); + + test('should update document list using WOQL.update_document with nested Doc()', async () => { + // This is the pattern that was failing before the fix + const query = WOQL.update_document( + new (Doc as any)({ + '@type': 'UpdateList', + '@id': docId, + list: [ + new (Doc as any)({ '@type': 'Structure', string: 'updated-1' }), + new (Doc as any)({ '@type': 'Structure', string: 'updated-2' }), + new (Doc as any)({ '@type': 'Structure', string: 'updated-3' }), + ], + }), + ) as WOQLQuery; + + const result = await client.query(query); + expect(result).toBeDefined(); + expect(result?.inserts).toBeGreaterThan(0); + expect(result?.deletes).toBeGreaterThan(0); + + // Verify the document was updated correctly + const doc = await client.getDocument({ id: docId }); + expect(doc['@type']).toEqual('UpdateList'); + expect(doc.list).toHaveLength(3); + expect(doc.list[0].string).toEqual('updated-1'); + expect(doc.list[1].string).toEqual('updated-2'); + expect(doc.list[2].string).toEqual('updated-3'); + }); + + test('should update document list using plain objects (alternative syntax)', async () => { + // Alternative approach without nested Doc() - should also work + const query = WOQL.update_document( + new (Doc as any)({ + '@type': 'UpdateList', + '@id': docId, + list: [ + { '@type': 'Structure', string: 'plain-1' }, + { '@type': 'Structure', string: 'plain-2' }, + ], + }), + ) as WOQLQuery; + + const result = await client.query(query); + expect(result).toBeDefined(); + + // Verify the document was updated correctly + const doc = await client.getDocument({ id: docId }); + expect(doc.list).toHaveLength(2); + expect(doc.list[0].string).toEqual('plain-1'); + expect(doc.list[1].string).toEqual('plain-2'); + }); + + test('should update to empty list', async () => { + const query = WOQL.update_document( + new (Doc as any)({ + '@type': 'UpdateList', + '@id': docId, + list: [], + }), + ) as WOQLQuery; + + const result = await client.query(query); + expect(result).toBeDefined(); + + // Verify the list is now empty + const doc = await client.getDocument({ id: docId }); + expect(doc.list).toEqual([]); + }); +}); diff --git a/lib/query/woqlDoc.js b/lib/query/woqlDoc.js index 3b169c01..6168441b 100644 --- a/lib/query/woqlDoc.js +++ b/lib/query/woqlDoc.js @@ -1,7 +1,32 @@ +/** + * Check if an object is already a converted WOQL Value structure + * @param {object} obj + * @returns {boolean} + */ +function isAlreadyConverted(obj) { + if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) { + return false; + } + // A converted Value has @type: 'Value' and one of: dictionary, list, data, node, variable + if (obj['@type'] === 'Value') { + return ( + obj.dictionary !== undefined + || obj.list !== undefined + || obj.data !== undefined + || obj.node !== undefined + || obj.variable !== undefined + ); + } + return false; +} + // eslint-disable-next-line consistent-return function convert(obj) { if (obj == null) { return null; + } if (isAlreadyConverted(obj)) { + // Object is already a converted WOQL Value structure, return as-is + return obj; } if (typeof (obj) === 'number') { return { '@type': 'Value', diff --git a/test/woqlUpdateDocWithList.spec.js b/test/woqlUpdateDocWithList.spec.js new file mode 100644 index 00000000..3736a3ac --- /dev/null +++ b/test/woqlUpdateDocWithList.spec.js @@ -0,0 +1,122 @@ +const { expect } = require('chai'); + +const WOQL = require('../lib/woql'); +const { Doc } = require('../lib/query/woqlDoc'); + +/** + * Test for update_document with a list of subdocuments + * + * This test verifies that the WOQL builder correctly converts + * update_document calls with nested Doc() objects containing lists + * of subdocuments. + * + * Issue: When using new Doc() inside another Doc(), the convert() + * function may double-wrap the already-converted structure. + */ +describe('WOQL update_document with list of subdocuments', () => { + // The expected correct WOQL JSON structure for updating a document + // with a list of subdocuments + const expectedUpdateDocWithListJson = { + '@type': 'UpdateDocument', + document: { + '@type': 'Value', + dictionary: { + '@type': 'DictionaryTemplate', + data: [ + { + '@type': 'FieldValuePair', + field: '@type', + value: { '@type': 'Value', data: { '@type': 'xsd:string', '@value': 'UpdateList' } }, + }, + { + '@type': 'FieldValuePair', + field: '@id', + value: { '@type': 'Value', data: { '@type': 'xsd:string', '@value': 'UpdateList/list' } }, + }, + { + '@type': 'FieldValuePair', + field: 'list', + value: { + '@type': 'Value', + list: [ + { + '@type': 'Value', + dictionary: { + '@type': 'DictionaryTemplate', + data: [ + { + '@type': 'FieldValuePair', + field: '@type', + value: { '@type': 'Value', data: { '@type': 'xsd:string', '@value': 'Structure' } }, + }, + { + '@type': 'FieldValuePair', + field: 'string', + value: { '@type': 'Value', data: { '@type': 'xsd:string', '@value': '3' } }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + }; + + it('should correctly convert update_document with nested Doc in list', () => { + // This is what the user is trying to do: + const woqlObject = WOQL.update_document( + new Doc({ + '@type': 'UpdateList', + '@id': 'UpdateList/list', + list: [new Doc({ '@type': 'Structure', string: '3' })], + }), + ); + + const result = woqlObject.json(); + expect(result).to.deep.equal(expectedUpdateDocWithListJson); + }); + + it('should correctly convert Doc with list of plain objects (no nested Doc)', () => { + // Alternative approach: use plain objects in the list + const woqlObject = WOQL.update_document( + new Doc({ + '@type': 'UpdateList', + '@id': 'UpdateList/list', + list: [{ '@type': 'Structure', string: '3' }], // Plain object, not new Doc() + }), + ); + + const result = woqlObject.json(); + expect(result).to.deep.equal(expectedUpdateDocWithListJson); + }); + + it('should show what new Doc() returns directly', () => { + // Test what Doc returns to understand the conversion + const subdoc = new Doc({ '@type': 'Structure', string: '3' }); + + // Doc should return a properly converted Value/DictionaryTemplate structure + const expectedSubdoc = { + '@type': 'Value', + dictionary: { + '@type': 'DictionaryTemplate', + data: [ + { + '@type': 'FieldValuePair', + field: '@type', + value: { '@type': 'Value', data: { '@type': 'xsd:string', '@value': 'Structure' } }, + }, + { + '@type': 'FieldValuePair', + field: 'string', + value: { '@type': 'Value', data: { '@type': 'xsd:string', '@value': '3' } }, + }, + ], + }, + }; + + expect(subdoc).to.deep.equal(expectedSubdoc); + }); +}); From 27e98c89b1b85e7dd7bdda3bacfa3a7601c1e623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Sat, 29 Nov 2025 08:12:21 +0100 Subject: [PATCH 3/4] Results change format in 11.2 --- integration_tests/create_database.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/integration_tests/create_database.test.ts b/integration_tests/create_database.test.ts index 0d474ac5..83caf7e3 100644 --- a/integration_tests/create_database.test.ts +++ b/integration_tests/create_database.test.ts @@ -55,13 +55,13 @@ describe('Create a database, schema and insert data', () => { test('Query Person by name', async () => { const queryTemplate = {"name":"Tom", "@type":"Person" } const result = await client.getDocument({query:queryTemplate}); - expect(result).toStrictEqual({ '@id': 'Child/Tom', '@type': 'Child', age: "10", name: 'Tom' }); + expect(result).toStrictEqual({ '@id': 'Child/Tom', '@type': 'Child', age: 10, name: 'Tom' }); }) test('Query Person by ege', async () => { const queryTemplate = {"age":"40", "@type":"Person" } const result = await client.getDocument({query:queryTemplate}); - expect(result).toStrictEqual({"@id": "Parent/Tom%20Senior", "age":"40","name":"Tom Senior","@type":"Parent" , "has_child":"Child/Tom"}); + expect(result).toStrictEqual({"@id": "Parent/Tom%20Senior", "age":40,"name":"Tom Senior","@type":"Parent" , "has_child":"Child/Tom"}); }) const change_request = "change_request02"; @@ -77,7 +77,7 @@ describe('Create a database, schema and insert data', () => { }) test('Update Child Tom, link Parent', async () => { - const childTom = { '@id': 'Child/Tom', '@type': 'Child', age: "10", name: 'Tom' , has_parent:"Parent/Tom%20Senior"} + const childTom = { '@id': 'Child/Tom', '@type': 'Child', age: 10, name: 'Tom' , has_parent:"Parent/Tom%20Senior"} const result = await client.updateDocument(childTom); expect(result).toStrictEqual(["terminusdb:///data/Child/Tom" ]); }) @@ -113,7 +113,7 @@ describe('Create a database, schema and insert data', () => { expect(result).toStrictEqual({ '@id': 'Child/Tom', '@type': 'Child', - age: "10", + age: 10, name: 'Tom', has_parent: 'Parent/Tom%20Senior' }); From c285711ca0ba01f548dbd50c67c9bcd929074448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20H=C3=B6ij?= Date: Mon, 1 Dec 2025 20:47:47 +0100 Subject: [PATCH 4/4] Fix to include the previsous random_idgen alias --- lib/query/woqlQuery.js | 6 ++++++ lib/woql.js | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/lib/query/woqlQuery.js b/lib/query/woqlQuery.js index 643342df..0f60413f 100644 --- a/lib/query/woqlQuery.js +++ b/lib/query/woqlQuery.js @@ -1192,6 +1192,12 @@ WOQLQuery.prototype.idgen_random = function (prefix, outputVar) { return this; }; +/** + * Backward-compatible alias for idgen_random + * @deprecated Use idgen_random instead + */ +WOQLQuery.prototype.random_idgen = WOQLQuery.prototype.idgen_random; + /** * Changes a string to upper-case * @param {string|Var} inputVarName - string or variable representing the uncapitalized string diff --git a/lib/woql.js b/lib/woql.js index f1c04e1f..fac2e8d2 100644 --- a/lib/woql.js +++ b/lib/woql.js @@ -765,6 +765,17 @@ WOQL.idgen_random = function (prefix, resultVarName) { return new WOQLQuery().idgen_random(prefix, resultVarName); }; +/** + * Backward-compatible alias for idgen_random + * @deprecated Use idgen_random instead + * @param {string} prefix - prefix for the generated ID + * @param {string} resultVarName - variable that stores the generated ID + * @returns {WOQLQuery} A WOQLQuery object containing the random ID generation function + */ +WOQL.random_idgen = function (prefix, resultVarName) { + return new WOQLQuery().idgen_random(prefix, resultVarName); +}; + /** * * Changes a string to upper-case