From b5541793c82c00e90ccb6b3b3a8f9c119cc61e20 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 11 Sep 2025 15:07:20 +0300 Subject: [PATCH] commit --- package-lock.json | 111 ++-- package.json | 1 + src/graph.ts | 1041 +++++++++++++++++++---------------- tests/graphAndQuery.spec.ts | 996 ++++++++++++++++++++------------- 4 files changed, 1227 insertions(+), 922 deletions(-) diff --git a/package-lock.json b/package-lock.json index 188c2a5..7929400 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "./packages/*" ], "dependencies": { + "@js-temporal/polyfill": "^0.5.1", "@redis/client": "^1.6.0", "cluster-key-slot": "1.1.2", "generic-pool": "^3.9.0", @@ -863,9 +864,9 @@ } }, "node_modules/@inquirer/core": { - "version": "10.1.15", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.15.tgz", - "integrity": "sha512-8xrp836RZvKkpNbVvgWUlxjT4CraKk2q+I3Ksy+seI2zkcE+y6wNs1BVhgcv8VyImFecUhdQrYLdW32pAjwBdA==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.2.0.tgz", + "integrity": "sha512-NyDSjPqhSvpZEMZrLCYUquWNl+XC/moEcVFqS55IEYIYsY0a1cUCevSqk7ctOlnm/RaSBU5psFryNlxcmGrjaA==", "dev": true, "license": "MIT", "dependencies": { @@ -904,15 +905,15 @@ } }, "node_modules/@inquirer/editor": { - "version": "4.2.15", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.15.tgz", - "integrity": "sha512-wst31XT8DnGOSS4nNJDIklGKnf+8shuauVrWzgKegWUe28zfCftcWZ2vktGdzJgcylWSS2SrDnYUb6alZcwnCQ==", + "version": "4.2.18", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.18.tgz", + "integrity": "sha512-yeQN3AXjCm7+Hmq5L6Dm2wEDeBRdAZuyZ4I7tWSSanbxDzqM0KqzoDbKM7p4ebllAYdoQuPJS6N71/3L281i6w==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.15", - "@inquirer/type": "^3.0.8", - "external-editor": "^3.1.0" + "@inquirer/core": "^10.2.0", + "@inquirer/external-editor": "^1.0.1", + "@inquirer/type": "^3.0.8" }, "engines": { "node": ">=18" @@ -949,6 +950,28 @@ } } }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.1.tgz", + "integrity": "sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.0", + "iconv-lite": "^0.6.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@inquirer/figures": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", @@ -1601,6 +1624,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-temporal/polyfill": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", + "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2856,9 +2891,9 @@ } }, "node_modules/chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", + "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", "dev": true, "license": "MIT" }, @@ -3719,21 +3754,6 @@ "dev": true, "license": "MIT" }, - "node_modules/external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/fast-content-type-parse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", @@ -4330,13 +4350,13 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" @@ -5402,6 +5422,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", + "license": "Apache-2.0" + }, "node_modules/jsbn": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", @@ -6087,16 +6113,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7275,19 +7291,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/package.json b/package.json index b6b3138..10a48e4 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "typescript": "^5.7.2" }, "dependencies": { + "@js-temporal/polyfill": "^0.5.1", "@redis/client": "^1.6.0", "cluster-key-slot": "1.1.2", "generic-pool": "^3.9.0", diff --git a/src/graph.ts b/src/graph.ts index af1bf33..5e4a723 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -3,525 +3,602 @@ import { QueryOptions } from "./commands"; import { QueryReply } from "./commands/QUERY"; import { ConstraintType, EntityType } from "./commands/CONSTRAINT_CREATE"; import { Client } from "./clients/client"; +import { Temporal } from "@js-temporal/polyfill"; export { ConstraintType, EntityType }; interface GraphMetadata { - labels: Array; - relationshipTypes: Array; - propertyKeys: Array; + labels: Array; + relationshipTypes: Array; + propertyKeys: Array; } // https://github.com/FalkorDB/FalkorDB/blob/master/src/resultset/formatters/resultset_formatter.h#L20 enum GraphValueTypes { - UNKNOWN = 0, - NULL = 1, - STRING = 2, - INTEGER = 3, - BOOLEAN = 4, - DOUBLE = 5, - ARRAY = 6, - EDGE = 7, - NODE = 8, - PATH = 9, - MAP = 10, - POINT = 11, - VECTORF32 = 12 + UNKNOWN = 0, + NULL = 1, + STRING = 2, + INTEGER = 3, + BOOLEAN = 4, + DOUBLE = 5, + ARRAY = 6, + EDGE = 7, + NODE = 8, + PATH = 9, + MAP = 10, + POINT = 11, + VECTORF32 = 12, + DATETIME = 13, + DATE = 14, + TIME = 15, + DURATION = 16, } -type GraphEntityRawProperties = Array<[ - id: number, - ...value: GraphRawValue -]>; +type GraphEntityRawProperties = Array<[id: number, ...value: GraphRawValue]>; type GraphEdgeRawValue = [ - GraphValueTypes.EDGE, - [ - id: number, - relationshipTypeId: number, - sourceId: number, - destinationId: number, - properties: GraphEntityRawProperties - ] + GraphValueTypes.EDGE, + [ + id: number, + relationshipTypeId: number, + sourceId: number, + destinationId: number, + properties: GraphEntityRawProperties + ] ]; type GraphNodeRawValue = [ - GraphValueTypes.NODE, - [ - id: number, - labelIds: Array, - properties: GraphEntityRawProperties - ] + GraphValueTypes.NODE, + [id: number, labelIds: Array, properties: GraphEntityRawProperties] ]; type GraphPathRawValue = [ - GraphValueTypes.PATH, - [ - nodes: [ - GraphValueTypes.ARRAY, - Array - ], - edges: [ - GraphValueTypes.ARRAY, - Array - ] - ] + GraphValueTypes.PATH, + [ + nodes: [GraphValueTypes.ARRAY, Array], + edges: [GraphValueTypes.ARRAY, Array] + ] ]; -type GraphMapRawValue = [ - GraphValueTypes.MAP, - Array -]; - -type GraphRawValue = [ - GraphValueTypes.NULL, - null -] | [ - GraphValueTypes.STRING, - string -] | [ - GraphValueTypes.INTEGER, - number -] | [ - GraphValueTypes.BOOLEAN, - string -] | [ - GraphValueTypes.DOUBLE, - string -] | [ - GraphValueTypes.ARRAY, - Array -] | GraphEdgeRawValue | GraphNodeRawValue | GraphPathRawValue | GraphMapRawValue | [ - GraphValueTypes.POINT, - [ - latitude: string, - longitude: string - ] -] | [ - GraphValueTypes.VECTORF32, - number[] -] -; - +type GraphMapRawValue = [GraphValueTypes.MAP, Array]; + +type GraphRawValue = + | [GraphValueTypes.NULL, null] + | [GraphValueTypes.STRING, string] + | [GraphValueTypes.INTEGER, number] + | [GraphValueTypes.BOOLEAN, string] + | [GraphValueTypes.DOUBLE, string] + | [GraphValueTypes.ARRAY, Array] + | GraphEdgeRawValue + | GraphNodeRawValue + | GraphPathRawValue + | GraphMapRawValue + | [GraphValueTypes.POINT, [latitude: string, longitude: string]] + | [GraphValueTypes.VECTORF32, number[]] + | [GraphValueTypes.DATETIME, number] + | [GraphValueTypes.DATE, number] + | [GraphValueTypes.TIME, number] + | [GraphValueTypes.DURATION, number]; type GraphEntityProperties = Record; interface GraphEdge { - id: number; - relationshipType: string; - sourceId: number; - destinationId: number; - properties: GraphEntityProperties; + id: number; + relationshipType: string; + sourceId: number; + destinationId: number; + properties: GraphEntityProperties; } interface GraphNode { - id: number; - labels: Array; - properties: GraphEntityProperties; + id: number; + labels: Array; + properties: GraphEntityProperties; } interface GraphPath { - nodes: Array; - edges: Array; + nodes: Array; + edges: Array; } type GraphMap = { - [key: string]: GraphValue; + [key: string]: GraphValue; }; -type GraphValue = null | string | number | boolean | Array - | GraphEdge | GraphNode | GraphPath | GraphMap | { - latitude: string; - longitude: string; - } | number[]; - -export type GraphReply = Omit & { - data?: Array; +type GraphValue = + | null + | string + | number + | boolean + | Array + | GraphEdge + | GraphNode + | GraphPath + | GraphMap + | { + latitude: string; + longitude: string; + } + | number[] + | Temporal.PlainDateTime + | Temporal.PlainDate + | Temporal.PlainTime + | Temporal.Duration; + +export type GraphReply = Omit & { + data?: Array; }; // export type GraphConnection = SingleGraphConnection | ClusterGraphConnection; export default class Graph { - #client: Client; - #name: string; - #metadata?: GraphMetadata; - - constructor( - client: Client, - name: string - ) { - this.#client = client; - this.#name = name; - } - - async query( - query: RedisCommandArgument, - options?: QueryOptions - ) { - const reply = await this.#client.query(this.#name, query, options); - return this.#parseReply(reply); - } - - async roQuery( - query: RedisCommandArgument, - options?: QueryOptions - ) { - const reply = await this.#client.roQuery(this.#name, query, options); - return this.#parseReply(reply); - } - - async delete() { - return this.#client.delete(this.#name) - } - - async explain( - query: string, - ) { - return this.#client.explain( - this.#name, - query - ) - } - - async profile( - query: string, - ) { - return this.#client.profile( this.#name, query) - } - - async slowLog() { - return this.#client.slowLog( - this.#name, - ) - } - - async constraintCreate(constraintType: ConstraintType, entityType: EntityType, - label: string, ...properties: string[]) { - return this.#client.constraintCreate( - this.#name, - constraintType, - entityType, - label, - ...properties - ) - } - - async constraintDrop(constraintType: ConstraintType, entityType: EntityType, - label: string, ...properties: string[]) { - return this.#client.constraintDrop( - this.#name, - constraintType, - entityType, - label, - ...properties - ) - } - - async copy(destGraph: string) { - return this.#client.copy( - this.#name, - destGraph - ) - } - - #setMetadataPromise?: Promise; - - #updateMetadata(): Promise { - this.#setMetadataPromise ??= this.#setMetadata() - .finally(() => this.#setMetadataPromise = undefined); - return this.#setMetadataPromise; - } - - // DO NOT use directly, use #updateMetadata instead - async #setMetadata(): Promise { - const [labels, relationshipTypes, propertyKeys] = await Promise.all([ - this.#client.roQuery(this.#name, 'CALL db.labels()', undefined, false), - this.#client.roQuery(this.#name, 'CALL db.relationshipTypes()', undefined, false), - this.#client.roQuery(this.#name, 'CALL db.propertyKeys()', undefined, false) - ]); - - this.#metadata = { - labels: this.#cleanMetadataArray(labels.data as Array<[string]>), - relationshipTypes: this.#cleanMetadataArray(relationshipTypes.data as Array<[string]>), - propertyKeys: this.#cleanMetadataArray(propertyKeys.data as Array<[string]>) - }; - - return this.#metadata; - } - - #cleanMetadataArray(arr: Array<[string]>): Array { - return arr.map(([value]) => value); - } - - #getMetadata( - key: T, - id: number - ): GraphMetadata[T][number] | Promise { - return this.#metadata?.[key][id] ?? this.#getMetadataAsync(key, id); - } - - // DO NOT use directly, use #getMetadata instead - async #getMetadataAsync( - key: T, - id: number - ): Promise { - const value = (await this.#updateMetadata())[key][id]; - if (value === undefined) throw new Error(`Cannot find value from ${key}[${id}]`); - return value; - } - - async #parseReply(reply: QueryReply): Promise> { - if (!reply.data) return reply; - - const promises: Array> = [], - parsed = { - metadata: reply.metadata, - data: reply.data!.map((row) => { - const data: Record = {}; - if (Array.isArray(row)) { - for (let i = 0; i < row.length; i++) { - const value = row[i] as GraphRawValue; - data[reply.headers[i][1]] = this.#parseValue(value, promises); - } - } - - return data as unknown as T; - }) - }; - - if (promises.length) await Promise.all(promises); - - return parsed; - } - - #parseValue([valueType, value]: GraphRawValue, promises: Array>): GraphValue { - switch (valueType) { - case GraphValueTypes.NULL: - return null; - - case GraphValueTypes.STRING: - case GraphValueTypes.INTEGER: - return value; - - case GraphValueTypes.BOOLEAN: - return value === 'true'; - - case GraphValueTypes.DOUBLE: - return parseFloat(value); - - case GraphValueTypes.ARRAY: - return value.map(x => this.#parseValue(x, promises)); - - case GraphValueTypes.EDGE: - return this.#parseEdge(value, promises); - - case GraphValueTypes.NODE: - return this.#parseNode(value, promises); - - case GraphValueTypes.PATH: - return { - nodes: value[0][1].map(([, node]) => this.#parseNode(node, promises)), - edges: value[1][1].map(([, edge]) => this.#parseEdge(edge, promises)) - }; - - case GraphValueTypes.MAP: { - const map: GraphMap = {}; - for (let i = 0; i < value.length; i++) { - map[value[i++] as string] = this.#parseValue(value[i] as GraphRawValue, promises); - } - - return map; - } - - case GraphValueTypes.POINT: - return { - latitude: parseFloat(value[0]), - longitude: parseFloat(value[1]) - }; - case GraphValueTypes.VECTORF32: - return value.map(x => Number(x)); - - default: - throw new Error(`unknown scalar type: ${valueType}`); - } - } - - #parseEdge([ - id, - relationshipTypeId, - sourceId, - destinationId, - properties - ]: GraphEdgeRawValue[1], promises: Array>): GraphEdge { - const edge = { - id, - sourceId, - destinationId, - properties: this.#parseProperties(properties, promises) - } as GraphEdge; - - const relationshipType = this.#getMetadata('relationshipTypes', relationshipTypeId); - if (relationshipType instanceof Promise) { - promises.push( - relationshipType.then(value => edge.relationshipType = value) - ); - } else { - edge.relationshipType = relationshipType; - } - - return edge; - } - - #parseNode([ - id, - labelIds, - properties - ]: GraphNodeRawValue[1], promises: Array>): GraphNode { - const labels = new Array(labelIds.length); - for (let i = 0; i < labelIds.length; i++) { - const value = this.#getMetadata('labels', labelIds[i]); - if (value instanceof Promise) { - promises.push(value.then(value => labels[i] = value)); - } else { - labels[i] = value; - } - } - - return { - id, - labels, - properties: this.#parseProperties(properties, promises) - }; - } - - #parseProperties(raw: GraphEntityRawProperties, promises: Array>): GraphEntityProperties { - const parsed: GraphEntityProperties = {}; - for (const [id, type, value] of raw) { - const parsedValue = this.#parseValue([type, value] as GraphRawValue, promises), - key = this.#getMetadata('propertyKeys', id); - if (key instanceof Promise) { - promises.push(key.then(key => parsed[key] = parsedValue)); - } else { - parsed[key] = parsedValue; - } - } - - return parsed; - } - - async createTypedIndex( - idxType: string, - entityType: "NODE" | "EDGE", - label: string, - properties: string[], - options?: Record - ): Promise { - const pattern = entityType === "NODE" ? `(e:${label})` : `()-[e:${label}]->()`; - - if(idxType === "RANGE"){ - idxType = "" - } - - let query = `CREATE ${idxType ? idxType + " " : ""}INDEX FOR ${pattern} ON (${properties - .map(prop => `e.${prop}`) - .join(", ")})`; - - if (options) { - const optionsMap = Object.entries(options) - .map(([key, value]) => - typeof value === "string" ? `${key}:'${value}'` : `${key}:${value}` - ) - .join(", "); - query += ` OPTIONS {${optionsMap}}`; - } - - return this.#client.query(this.#name, query); - } - - async createNodeRangeIndex(label: string, ...properties: string[]): Promise { - return this.createTypedIndex("RANGE", "NODE", label, properties); - } - - async createNodeFulltextIndex(label: string, ...properties: string[]): Promise { - return this.createTypedIndex("FULLTEXT", "NODE", label, properties); - } - - async createNodeVectorIndex( - label: string, - dim: number = 0, - similarityFunction: string = "euclidean", - ...properties: string[] - ): Promise { - const options = { - dimension: dim, - similarityFunction: similarityFunction, - }; - - return await this.createTypedIndex("VECTOR", "NODE", label, properties, options); - } - - - - async createEdgeRangeIndex(label: string, ...properties: string[]): Promise { - return this.createTypedIndex("RANGE", "EDGE", label, properties); - } - - async createEdgeFulltextIndex(label: string, ...properties: string[]): Promise { - return this.createTypedIndex("FULLTEXT", "EDGE", label, properties); - } - - async createEdgeVectorIndex( - label: string, - dim: number = 0, - similarityFunction: string = "euclidean", - ...properties: string[] - ): Promise { - const options = { - dimension: dim, - similarityFunction: similarityFunction, - }; - - return await this.createTypedIndex("VECTOR", "EDGE", label, properties, options); - } - - - async dropTypedIndex( - idxType: string, - entityType: "NODE" | "EDGE", - label: string, - attribute: string - ): Promise { - const pattern = entityType === "NODE" ? `(e:${label})` : `()-[e:${label}]->()`; - - if(idxType === "RANGE"){ - idxType = "" - } - - let query = `DROP ${idxType ? idxType + " " : ""}INDEX FOR ${pattern} ON (e.${attribute})`; - - return this.#client.query(this.#name, query); - } - - async dropNodeRangeIndex(label: string, attribute: string): Promise { - return this.dropTypedIndex("RANGE", "NODE", label, attribute); - } - - async dropNodeFulltextIndex(label: string, attribute: string): Promise { - return this.dropTypedIndex("FULLTEXT", "NODE", label, attribute); - } - - async dropNodeVectorIndex(label: string, attribute: string): Promise { - return this.dropTypedIndex("VECTOR", "NODE", label, attribute); - } - - async dropEdgeRangeIndex(label: string, attribute: string): Promise { - return this.dropTypedIndex("RANGE", "EDGE", label, attribute); - } - - async dropEdgeFulltextIndex(label: string, attribute: string): Promise { - return this.dropTypedIndex("FULLTEXT", "EDGE", label, attribute); - } - - async dropEdgeVectorIndex(label: string, attribute: string): Promise { - return this.dropTypedIndex("VECTOR", "EDGE", label, attribute); - } + #client: Client; + #name: string; + #metadata?: GraphMetadata; + + constructor(client: Client, name: string) { + this.#client = client; + this.#name = name; + } + + async query(query: RedisCommandArgument, options?: QueryOptions) { + const reply = await this.#client.query(this.#name, query, options); + return this.#parseReply(reply); + } + + async roQuery(query: RedisCommandArgument, options?: QueryOptions) { + const reply = await this.#client.roQuery(this.#name, query, options); + return this.#parseReply(reply); + } + + async delete() { + return this.#client.delete(this.#name); + } + + async explain(query: string) { + return this.#client.explain(this.#name, query); + } + + async profile(query: string) { + return this.#client.profile(this.#name, query); + } + + async slowLog() { + return this.#client.slowLog(this.#name); + } + + async constraintCreate( + constraintType: ConstraintType, + entityType: EntityType, + label: string, + ...properties: string[] + ) { + return this.#client.constraintCreate( + this.#name, + constraintType, + entityType, + label, + ...properties + ); + } + + async constraintDrop( + constraintType: ConstraintType, + entityType: EntityType, + label: string, + ...properties: string[] + ) { + return this.#client.constraintDrop( + this.#name, + constraintType, + entityType, + label, + ...properties + ); + } + + async copy(destGraph: string) { + return this.#client.copy(this.#name, destGraph); + } + + #setMetadataPromise?: Promise; + + #updateMetadata(): Promise { + this.#setMetadataPromise ??= this.#setMetadata().finally( + () => (this.#setMetadataPromise = undefined) + ); + return this.#setMetadataPromise; + } + + // DO NOT use directly, use #updateMetadata instead + async #setMetadata(): Promise { + const [labels, relationshipTypes, propertyKeys] = await Promise.all([ + this.#client.roQuery(this.#name, "CALL db.labels()", undefined, false), + this.#client.roQuery( + this.#name, + "CALL db.relationshipTypes()", + undefined, + false + ), + this.#client.roQuery( + this.#name, + "CALL db.propertyKeys()", + undefined, + false + ), + ]); + + this.#metadata = { + labels: this.#cleanMetadataArray(labels.data as Array<[string]>), + relationshipTypes: this.#cleanMetadataArray( + relationshipTypes.data as Array<[string]> + ), + propertyKeys: this.#cleanMetadataArray( + propertyKeys.data as Array<[string]> + ), + }; + + return this.#metadata; + } + + #cleanMetadataArray(arr: Array<[string]>): Array { + return arr.map(([value]) => value); + } + + #getMetadata( + key: T, + id: number + ): GraphMetadata[T][number] | Promise { + return this.#metadata?.[key][id] ?? this.#getMetadataAsync(key, id); + } + + // DO NOT use directly, use #getMetadata instead + async #getMetadataAsync( + key: T, + id: number + ): Promise { + const value = (await this.#updateMetadata())[key][id]; + if (value === undefined) + throw new Error(`Cannot find value from ${key}[${id}]`); + return value; + } + + async #parseReply(reply: QueryReply): Promise> { + if (!reply.data) return reply; + + const promises: Array> = [], + parsed = { + metadata: reply.metadata, + data: reply.data!.map((row) => { + const data: Record = {}; + if (Array.isArray(row)) { + for (let i = 0; i < row.length; i++) { + const value = row[i] as GraphRawValue; + data[reply.headers[i][1]] = this.#parseValue(value, promises); + } + } + + return data as unknown as T; + }), + }; + + if (promises.length) await Promise.all(promises); + + return parsed; + } + + #parseValue( + [valueType, value]: GraphRawValue, + promises: Array> + ): GraphValue { + switch (valueType) { + case GraphValueTypes.NULL: + return null; + + case GraphValueTypes.STRING: + case GraphValueTypes.INTEGER: + return value; + + case GraphValueTypes.BOOLEAN: + return value === "true"; + + case GraphValueTypes.DOUBLE: + return parseFloat(value); + + case GraphValueTypes.ARRAY: + return value.map((x) => this.#parseValue(x, promises)); + + case GraphValueTypes.EDGE: + return this.#parseEdge(value, promises); + + case GraphValueTypes.NODE: + return this.#parseNode(value, promises); + + case GraphValueTypes.PATH: + return { + nodes: value[0][1].map(([, node]) => this.#parseNode(node, promises)), + edges: value[1][1].map(([, edge]) => this.#parseEdge(edge, promises)), + }; + + case GraphValueTypes.MAP: { + const map: GraphMap = {}; + for (let i = 0; i < value.length; i++) { + map[value[i++] as string] = this.#parseValue( + value[i] as GraphRawValue, + promises + ); + } + + return map; + } + + case GraphValueTypes.POINT: + return { + latitude: parseFloat(value[0]), + longitude: parseFloat(value[1]), + }; + + case GraphValueTypes.VECTORF32: + return value.map((x) => Number(x)); + + case GraphValueTypes.DATETIME: + return Temporal.Instant.fromEpochMilliseconds(value * 1000) + .toZonedDateTimeISO("UTC") + .toPlainDateTime(); + case GraphValueTypes.DATE: + return Temporal.Instant.fromEpochMilliseconds(value * 1000) + .toZonedDateTimeISO("UTC") + .toPlainDate(); + case GraphValueTypes.TIME: + return Temporal.Instant.fromEpochMilliseconds(value * 1000) + .toZonedDateTimeISO("UTC") + .toPlainTime(); + case GraphValueTypes.DURATION: + const time = Temporal.Instant.fromEpochMilliseconds(value * 1000); + const epoch = Temporal.Instant.fromEpochMilliseconds(0); + + return epoch + .toZonedDateTimeISO("UTC") + .until(time.toZonedDateTimeISO("UTC"), { + largestUnit: "years", + }); + default: + throw new Error(`unknown scalar type: ${valueType}`); + } + } + + #parseEdge( + [ + id, + relationshipTypeId, + sourceId, + destinationId, + properties, + ]: GraphEdgeRawValue[1], + promises: Array> + ): GraphEdge { + const edge = { + id, + sourceId, + destinationId, + properties: this.#parseProperties(properties, promises), + } as GraphEdge; + + const relationshipType = this.#getMetadata( + "relationshipTypes", + relationshipTypeId + ); + if (relationshipType instanceof Promise) { + promises.push( + relationshipType.then((value) => (edge.relationshipType = value)) + ); + } else { + edge.relationshipType = relationshipType; + } + + return edge; + } + + #parseNode( + [id, labelIds, properties]: GraphNodeRawValue[1], + promises: Array> + ): GraphNode { + const labels = new Array(labelIds.length); + for (let i = 0; i < labelIds.length; i++) { + const value = this.#getMetadata("labels", labelIds[i]); + if (value instanceof Promise) { + promises.push(value.then((value) => (labels[i] = value))); + } else { + labels[i] = value; + } + } + + return { + id, + labels, + properties: this.#parseProperties(properties, promises), + }; + } + + #parseProperties( + raw: GraphEntityRawProperties, + promises: Array> + ): GraphEntityProperties { + const parsed: GraphEntityProperties = {}; + for (const [id, type, value] of raw) { + const parsedValue = this.#parseValue( + [type, value] as GraphRawValue, + promises + ), + key = this.#getMetadata("propertyKeys", id); + if (key instanceof Promise) { + promises.push(key.then((key) => (parsed[key] = parsedValue))); + } else { + parsed[key] = parsedValue; + } + } + + return parsed; + } + + async createTypedIndex( + idxType: string, + entityType: "NODE" | "EDGE", + label: string, + properties: string[], + options?: Record + ): Promise { + const pattern = + entityType === "NODE" ? `(e:${label})` : `()-[e:${label}]->()`; + + if (idxType === "RANGE") { + idxType = ""; + } + + let query = `CREATE ${ + idxType ? idxType + " " : "" + }INDEX FOR ${pattern} ON (${properties + .map((prop) => `e.${prop}`) + .join(", ")})`; + + if (options) { + const optionsMap = Object.entries(options) + .map(([key, value]) => + typeof value === "string" ? `${key}:'${value}'` : `${key}:${value}` + ) + .join(", "); + query += ` OPTIONS {${optionsMap}}`; + } + + return this.#client.query(this.#name, query); + } + + async createNodeRangeIndex( + label: string, + ...properties: string[] + ): Promise { + return this.createTypedIndex("RANGE", "NODE", label, properties); + } + + async createNodeFulltextIndex( + label: string, + ...properties: string[] + ): Promise { + return this.createTypedIndex("FULLTEXT", "NODE", label, properties); + } + + async createNodeVectorIndex( + label: string, + dim: number = 0, + similarityFunction: string = "euclidean", + ...properties: string[] + ): Promise { + const options = { + dimension: dim, + similarityFunction: similarityFunction, + }; + + return await this.createTypedIndex( + "VECTOR", + "NODE", + label, + properties, + options + ); + } + + async createEdgeRangeIndex( + label: string, + ...properties: string[] + ): Promise { + return this.createTypedIndex("RANGE", "EDGE", label, properties); + } + + async createEdgeFulltextIndex( + label: string, + ...properties: string[] + ): Promise { + return this.createTypedIndex("FULLTEXT", "EDGE", label, properties); + } + + async createEdgeVectorIndex( + label: string, + dim: number = 0, + similarityFunction: string = "euclidean", + ...properties: string[] + ): Promise { + const options = { + dimension: dim, + similarityFunction: similarityFunction, + }; + + return await this.createTypedIndex( + "VECTOR", + "EDGE", + label, + properties, + options + ); + } + + async dropTypedIndex( + idxType: string, + entityType: "NODE" | "EDGE", + label: string, + attribute: string + ): Promise { + const pattern = + entityType === "NODE" ? `(e:${label})` : `()-[e:${label}]->()`; + + if (idxType === "RANGE") { + idxType = ""; + } + + let query = `DROP ${ + idxType ? idxType + " " : "" + }INDEX FOR ${pattern} ON (e.${attribute})`; + + return this.#client.query(this.#name, query); + } + + async dropNodeRangeIndex( + label: string, + attribute: string + ): Promise { + return this.dropTypedIndex("RANGE", "NODE", label, attribute); + } + + async dropNodeFulltextIndex( + label: string, + attribute: string + ): Promise { + return this.dropTypedIndex("FULLTEXT", "NODE", label, attribute); + } + + async dropNodeVectorIndex( + label: string, + attribute: string + ): Promise { + return this.dropTypedIndex("VECTOR", "NODE", label, attribute); + } + + async dropEdgeRangeIndex( + label: string, + attribute: string + ): Promise { + return this.dropTypedIndex("RANGE", "EDGE", label, attribute); + } + + async dropEdgeFulltextIndex( + label: string, + attribute: string + ): Promise { + return this.dropTypedIndex("FULLTEXT", "EDGE", label, attribute); + } + + async dropEdgeVectorIndex( + label: string, + attribute: string + ): Promise { + return this.dropTypedIndex("VECTOR", "EDGE", label, attribute); + } } diff --git a/tests/graphAndQuery.spec.ts b/tests/graphAndQuery.spec.ts index 64734ed..20216a2 100644 --- a/tests/graphAndQuery.spec.ts +++ b/tests/graphAndQuery.spec.ts @@ -1,425 +1,649 @@ -import FalkorDB from '../src/falkordb'; -import { ConstraintType, EntityType } from '../src/graph'; -import { client } from './dbConnection'; -import { expect } from '@jest/globals'; +import FalkorDB from "../src/falkordb"; +import { ConstraintType, EntityType } from "../src/graph"; +import { client } from "./dbConnection"; +import { expect } from "@jest/globals"; +import { Temporal } from "@js-temporal/polyfill"; function getRandomNumber(): number { - return Math.floor(Math.random() * 999999); + return Math.floor(Math.random() * 999999); } -describe('FalkorDB Execute Query', () => { - let clientInstance: FalkorDB; +describe("FalkorDB Execute Query", () => { + let clientInstance: FalkorDB; + + beforeAll(async () => { + try { + clientInstance = await client(); + } catch (error) { + console.error("Failed to initialize database connection:", error); + throw error; + } + }); + + afterAll(async () => { + try { + await clientInstance.close(); + } catch (error) { + console.error("Failed to close database connection:", error); + throw error; + } + }); + + it("Create a graph and check for its existence", async () => { + const graphName = `graph_${getRandomNumber()}`; + const graph = clientInstance.selectGraph(graphName); + await graph.query("CREATE (:Person {name:'Alice'})"); + const currCount = await clientInstance.list(); + const exists = currCount.includes(graphName); + await graph.delete(); + expect(exists).toBe(true); + }); + + it("Execute a query and return the correct results", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + await graph.query("CREATE (:Person {name:'Alice'})"); + const result: any = await graph.query("MATCH (n:Person) RETURN n.name"); + await graph.delete(); + expect(result.data?.[0]?.["n.name"]).toBe("Alice"); + }); + + it("Copy an existing graph and validate the existence of the new graph", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + await graph.query("CREATE (:Person {name:'Alice'})"); + await graph.copy("graphcopy"); + await graph.delete(); + const copyGraph = await clientInstance.selectGraph("graphcopy"); + const currCount = await clientInstance.list(); + const exists = currCount.includes("graphcopy"); + await copyGraph.delete(); + expect(exists).toBe(true); + }); + + it("Execute a roQuery and return the correct results", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + await graph.query("CREATE (:Person {name:'Alice'})"); + const result: any = await graph.query("MATCH (n:Person) RETURN n.name"); + await graph.delete(); + expect(result.data?.[0]?.["n.name"]).toBe("Alice"); + }); + + it("fail test: when trying to execute a write query with roQuery", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + await graph.query("CREATE (:Person {name:'Alice'})"); + await expect( + graph.roQuery("CREATE (:Person {name:'Bob'})") + ).rejects.toThrow(); + await graph.delete(); + }); + + it("fail test: when executing an invalid query", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + await expect(graph.query("INVALID QUERY SYNTAX")).rejects.toThrow(); + }); + + it("creates two nodes and a relationship, then retrieves and validates nodes and relationship", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + const query = ` + CREATE (n1:Person {name: 'Alice', age: 30})-[r:KNOWS]->(n2:Person {name: 'Bob', age: 25}) + RETURN n1, n2, r + `; + const result = await graph.query(query); - beforeAll(async () => { - try { - clientInstance = await client(); - } catch (error) { - console.error('Failed to initialize database connection:', error); - throw error; - } - }); + expect(result.data).toHaveLength(1); - afterAll(async () => { - try { - await clientInstance.close() - } catch (error){ - console.error('Failed to close database connection:', error); - throw error; - } - }); + const [row]: any = result.data; + const { n1, n2, r } = row; - it('Create a graph and check for its existence', async () => { - const graphName = `graph_${getRandomNumber()}` - const graph = clientInstance.selectGraph(graphName); - await graph.query("CREATE (:Person {name:'Alice'})"); - const currCount = await clientInstance.list() - const exists = currCount.includes(graphName); - await graph.delete() - expect(exists).toBe(true) - }); - - it('Execute a query and return the correct results', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - await graph.query("CREATE (:Person {name:'Alice'})"); - const result : any = await graph.query("MATCH (n:Person) RETURN n.name"); - await graph.delete() - expect(result.data?.[0]?.['n.name']).toBe('Alice'); - }); - - it('Copy an existing graph and validate the existence of the new graph', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - await graph.query("CREATE (:Person {name:'Alice'})"); - await graph.copy("graphcopy") - await graph.delete() - const copyGraph = await clientInstance.selectGraph("graphcopy"); - const currCount = await clientInstance.list(); - const exists = currCount.includes("graphcopy"); - await copyGraph.delete(); - expect(exists).toBe(true) - }); + // Check node n1 properties + expect(n1.labels).toContain("Person"); + expect(n1.properties.name).toBe("Alice"); + expect(n1.properties.age).toBe(30); - it('Execute a roQuery and return the correct results', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - await graph.query("CREATE (:Person {name:'Alice'})"); - const result:any = await graph.query("MATCH (n:Person) RETURN n.name"); - await graph.delete() - expect(result.data?.[0]?.['n.name']).toBe('Alice'); - }); + // Check node n2 properties + expect(n2.labels).toContain("Person"); + expect(n2.properties.name).toBe("Bob"); + expect(n2.properties.age).toBe(25); - it('fail test: when trying to execute a write query with roQuery', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - await graph.query("CREATE (:Person {name:'Alice'})"); - await expect(graph.roQuery("CREATE (:Person {name:'Bob'})")).rejects.toThrow(); - await graph.delete(); - }); - - it('fail test: when executing an invalid query', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - await expect(graph.query("INVALID QUERY SYNTAX")).rejects.toThrow(); - }); - - it('creates two nodes and a relationship, then retrieves and validates nodes and relationship', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - const query = ` - CREATE (n1:Person {name: 'Alice', age: 30})-[r:KNOWS]->(n2:Person {name: 'Bob', age: 25}) - RETURN n1, n2, r - `; - const result = await graph.query(query); + // Check the edge properties + expect(r.relationshipType).toBe("KNOWS"); + expect(r.sourceId).toBe(n1.id); + expect(r.destinationId).toBe(n2.id); + await graph.delete(); + }); - expect(result.data).toHaveLength(1); - - const [row] : any = result.data; - const { n1, n2, r } = row; - - // Check node n1 properties - expect(n1.labels).toContain('Person'); - expect(n1.properties.name).toBe('Alice'); - expect(n1.properties.age).toBe(30); - - // Check node n2 properties - expect(n2.labels).toContain('Person'); - expect(n2.properties.name).toBe('Bob'); - expect(n2.properties.age).toBe(25); - - // Check the edge properties - expect(r.relationshipType).toBe('KNOWS'); - expect(r.sourceId).toBe(n1.id); - expect(r.destinationId).toBe(n2.id); - await graph.delete() - }); - - it('creates nodes with array properties and verifies query results', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - const query = ` + it("creates nodes with array properties and verifies query results", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + const query = ` CREATE (n:Person {name: 'Alice', hobbies: ['Reading', 'Hiking', 'Cooking']}) RETURN n `; - const result = await graph.query(query); - - // Verify returned node - expect(result.data).toHaveLength(1); - const [row] : any = result.data; - const { n } = row; - - // Check node properties - expect(n.labels).toContain('Person'); - expect(n.properties.name).toBe('Alice'); - expect(n.properties.hobbies).toEqual(['Reading', 'Hiking', 'Cooking']); - await graph.delete() - }); - - it('validates the creation of a path between nodes with edges', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - const query = ` + const result = await graph.query(query); + + // Verify returned node + expect(result.data).toHaveLength(1); + const [row]: any = result.data; + const { n } = row; + + // Check node properties + expect(n.labels).toContain("Person"); + expect(n.properties.name).toBe("Alice"); + expect(n.properties.hobbies).toEqual(["Reading", "Hiking", "Cooking"]); + await graph.delete(); + }); + + it("validates the creation of a path between nodes with edges", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + const query = ` CREATE (n1:Person {name: 'Alice', age: 30})-[r:KNOWS {since: 2020}]->(n2:Person {name: 'Bob', age: 25}) RETURN n1, n2, r `; - await graph.query(query); - - // Query to retrieve the path - const pathQuery = "MATCH p=(n1:Person)-[r:KNOWS]->(n2:Person) RETURN p"; - const result = await graph.query(pathQuery); - - // Check the structure of the returned path - expect(result.data).toHaveLength(1); - const [row] : any = result.data; - const { p } = row; - - // Validate nodes and relationship in the path - expect(p.nodes).toHaveLength(2); - expect(p.edges).toHaveLength(1); - expect(p.edges[0].relationshipType).toBe('KNOWS'); - expect(p.edges[0].properties.since).toBe(2020); - expect(p.nodes[0].properties.name).toBe('Alice'); - expect(p.nodes[1].properties.name).toBe('Bob'); - await graph.delete() - }); - - it('runs a query with various parameter types and checks if they return correctly', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - const params = [1, 5.4, "hello world", true, false, null, ["apple", "banana", "cherry"], '\\\" RETURN 1122 //']; - - for (const param of params) { - const query = `RETURN ${JSON.stringify(param)} AS param`; - const result = await graph.query(query); - expect(result.data).toEqual([{ param }]); - } - await graph.delete(); - }); - - it('runs a query and validates the returned map structure', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - const query = `RETURN {name: 'John', age: 29, isEmployed: True, skills: ['Python', 'JavaScript'], salary: null, address: {city: 'New York', zip: 10001}}`; - const result:any = await graph.query(query); - - expect(result.data ?? []).toHaveLength(1); - const mapKey = Object.keys(result.data?.[0] ?? {})[0]; - const map = result.data?.[0]?.[mapKey]; - - - expect(map.name).toBe('John'); - expect(map.age).toBe(29); - expect(map.isEmployed).toBe(true); - expect(map.skills).toEqual(['Python', 'JavaScript']); - expect(map.salary).toBeNull(); - expect(map.address.city).toBe('New York'); - expect(map.address.zip).toBe(10001); - - await graph.delete(); - }); - - it('tests geographic points with latitude and longitude values', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - const query = `RETURN point({latitude: 40.7128, longitude: -74.0060}) AS point`; - const result: any = await graph.query(query); - const createdPoint = result.data?.[0].point; - const fixedLat = parseFloat((createdPoint.latitude).toFixed(4)) - const fixedLong = parseFloat((createdPoint.longitude).toFixed(4)) - expect(fixedLat).toBe(40.7128); - expect(fixedLong).toBe(-74.0060); - await graph.delete(); - }); - - it('Create and delete an index on a property and handle duplicate operations', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - await graph.query("CREATE (:Person {name: 'Alice', age: 30}), (:Person {name: 'Bob', age: 25})"); - await graph.query("CREATE INDEX ON :Person(name)"); - const indexResult: any = await graph.query("CALL db.indexes"); - expect(indexResult.data[0].label).toBe('Person'); - expect(indexResult.data[0].properties).toEqual(['name']); - await expect(graph.query("CREATE INDEX ON :Person(name)")).rejects.toThrow(); - await graph.query("DROP INDEX ON :Person(name)"); - const updatedIndexResult = await graph.query("CALL db.indexes"); - expect(updatedIndexResult).not.toContainEqual(expect.objectContaining({ label: 'Person', properties: ['name'] })); - await graph.delete(); - }); - - it('Validate correct string representations of nodes and edges in query results', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - await graph.query("CREATE (n1:Person {name: 'Alice'})-[:KNOWS]->(n2:Person {name: 'Bob'})"); - const query = 'MATCH (n1)-[r]->(n2) RETURN n1, r, n2'; - const result: any = await graph.query(query); - expect(result.data).not.toBeNull(); - const node1 = result.data?.[0].n1; - const edge = result.data?.[0].r; - const node2 = result.data?.[0].n2; - - expect(typeof node1.toString()).toBe('string'); - expect(typeof edge.toString()).toBe('string'); - expect(typeof node2.toString()).toBe('string'); - await graph.delete(); - }); - - it('Validate absence of matching edges and return null for non-existing relationships', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - await graph.query("CREATE (n:Person {name: 'Alice'})"); - const query = 'MATCH (n:Person) OPTIONAL MATCH (n)-[r]->(m) RETURN n, r, m'; + await graph.query(query); + + // Query to retrieve the path + const pathQuery = "MATCH p=(n1:Person)-[r:KNOWS]->(n2:Person) RETURN p"; + const result = await graph.query(pathQuery); + + // Check the structure of the returned path + expect(result.data).toHaveLength(1); + const [row]: any = result.data; + const { p } = row; + + // Validate nodes and relationship in the path + expect(p.nodes).toHaveLength(2); + expect(p.edges).toHaveLength(1); + expect(p.edges[0].relationshipType).toBe("KNOWS"); + expect(p.edges[0].properties.since).toBe(2020); + expect(p.nodes[0].properties.name).toBe("Alice"); + expect(p.nodes[1].properties.name).toBe("Bob"); + await graph.delete(); + }); + + it("runs a query with various parameter types and checks if they return correctly", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + const params = [ + 1, + 5.4, + "hello world", + true, + false, + null, + ["apple", "banana", "cherry"], + '\\" RETURN 1122 //', + ]; + + for (const param of params) { + const query = `RETURN ${JSON.stringify(param)} AS param`; const result = await graph.query(query); - expect(result.data).not.toBeNull(); - const matchResult: any = result.data![0]; - expect(matchResult.r).toBeNull(); - expect(matchResult.m).toBeNull(); - await graph.delete(); - }); - - it('Validate cached query results after the first run', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - await graph.query("CREATE (n:Person {name: 'Alice'})"); - const query = 'MATCH (n:Person) RETURN n'; - const firstResult = await graph.query(query); - expect(firstResult.data).not.toBeNull(); - const secondResult = await graph.query(query); - expect(secondResult.metadata[0]).toContain('Cached execution: 1'); - await graph.delete(); - }); - - it('Verify slow query logging', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - const createQuery = `CREATE (:Director {name:'Christopher Nolan'})-[:DIRECTED]->(:Movie {title:'Inception'}), + expect(result.data).toEqual([{ param }]); + } + await graph.delete(); + }); + + it("runs a query and validates the returned map structure", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + const query = `RETURN {name: 'John', age: 29, isEmployed: True, skills: ['Python', 'JavaScript'], salary: null, address: {city: 'New York', zip: 10001}}`; + const result: any = await graph.query(query); + + expect(result.data ?? []).toHaveLength(1); + const mapKey = Object.keys(result.data?.[0] ?? {})[0]; + const map = result.data?.[0]?.[mapKey]; + + expect(map.name).toBe("John"); + expect(map.age).toBe(29); + expect(map.isEmployed).toBe(true); + expect(map.skills).toEqual(["Python", "JavaScript"]); + expect(map.salary).toBeNull(); + expect(map.address.city).toBe("New York"); + expect(map.address.zip).toBe(10001); + + await graph.delete(); + }); + + it("tests geographic points with latitude and longitude values", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + const query = `RETURN point({latitude: 40.7128, longitude: -74.0060}) AS point`; + const result: any = await graph.query(query); + const createdPoint = result.data?.[0].point; + const fixedLat = parseFloat(createdPoint.latitude.toFixed(4)); + const fixedLong = parseFloat(createdPoint.longitude.toFixed(4)); + expect(fixedLat).toBe(40.7128); + expect(fixedLong).toBe(-74.006); + await graph.delete(); + }); + + it("Create and delete an index on a property and handle duplicate operations", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + await graph.query( + "CREATE (:Person {name: 'Alice', age: 30}), (:Person {name: 'Bob', age: 25})" + ); + await graph.query("CREATE INDEX ON :Person(name)"); + const indexResult: any = await graph.query("CALL db.indexes"); + expect(indexResult.data[0].label).toBe("Person"); + expect(indexResult.data[0].properties).toEqual(["name"]); + await expect( + graph.query("CREATE INDEX ON :Person(name)") + ).rejects.toThrow(); + await graph.query("DROP INDEX ON :Person(name)"); + const updatedIndexResult = await graph.query("CALL db.indexes"); + expect(updatedIndexResult).not.toContainEqual( + expect.objectContaining({ label: "Person", properties: ["name"] }) + ); + await graph.delete(); + }); + + it("Validate correct string representations of nodes and edges in query results", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + await graph.query( + "CREATE (n1:Person {name: 'Alice'})-[:KNOWS]->(n2:Person {name: 'Bob'})" + ); + const query = "MATCH (n1)-[r]->(n2) RETURN n1, r, n2"; + const result: any = await graph.query(query); + expect(result.data).not.toBeNull(); + const node1 = result.data?.[0].n1; + const edge = result.data?.[0].r; + const node2 = result.data?.[0].n2; + + expect(typeof node1.toString()).toBe("string"); + expect(typeof edge.toString()).toBe("string"); + expect(typeof node2.toString()).toBe("string"); + await graph.delete(); + }); + + it("Validate absence of matching edges and return null for non-existing relationships", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + await graph.query("CREATE (n:Person {name: 'Alice'})"); + const query = "MATCH (n:Person) OPTIONAL MATCH (n)-[r]->(m) RETURN n, r, m"; + const result = await graph.query(query); + expect(result.data).not.toBeNull(); + const matchResult: any = result.data![0]; + expect(matchResult.r).toBeNull(); + expect(matchResult.m).toBeNull(); + await graph.delete(); + }); + + it("Validate cached query results after the first run", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + await graph.query("CREATE (n:Person {name: 'Alice'})"); + const query = "MATCH (n:Person) RETURN n"; + const firstResult = await graph.query(query); + expect(firstResult.data).not.toBeNull(); + const secondResult = await graph.query(query); + expect(secondResult.metadata[0]).toContain("Cached execution: 1"); + await graph.delete(); + }); + + it("Verify slow query logging", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + const createQuery = `CREATE (:Director {name:'Christopher Nolan'})-[:DIRECTED]->(:Movie {title:'Inception'}), (:Director {name:'Steven Spielberg'})-[:DIRECTED]->(:Movie {title:'Jurassic Park'}), (:Director {name:'Quentin Tarantino'})-[:DIRECTED]->(:Movie {title:'Pulp Fiction'})`; - await graph.query(createQuery); - const slowLogResults = await graph.slowLog(); - expect(slowLogResults).toBeDefined(); - expect(slowLogResults[0].command).toBe("GRAPH.QUERY"); - expect(slowLogResults[0].query).toBe(createQuery); - expect(slowLogResults[0].took).toBeGreaterThan(0); - await graph.delete(); - }); - - it('Assert query execution time exceeds 1-sec limit', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - await graph.query("UNWIND range(0, 1000) AS val CREATE (:Node {v: val})"); - const result = await graph.query("MATCH (a), (b), (c), (d) RETURN *"); - const executionTimeStr = result.metadata[1]; - const executionTime = parseFloat(executionTimeStr.split(': ')[1]); - expect(() => { expect(executionTime).toBeLessThan(1)}).toThrow(); - await graph.delete(); - }); - - it('Create and match nodes with multiple labels', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - await graph.query("CREATE (a:Person:Employee {name: 'Alice', age: 30})"); - await graph.query("CREATE (b:Person:Manager {name: 'Bob', age: 45})"); - const resultA: any = await graph.roQuery("MATCH (n:Person:Employee {name: 'Alice'}) RETURN n"); - const resultB: any = await graph.roQuery("MATCH (n:Person:Manager {name: 'Bob'}) RETURN n"); - expect(resultA.data?.length).toBe(1); - expect(resultA.data![0].n.properties.name).toBe('Alice'); - expect(resultB.data!.length).toBe(1); - expect(resultB.data[0].n.properties.name).toBe('Bob'); - await graph.delete(); - }); - - it('Verify that client cache stays in sync with simple node creation and query', async () => { - const graphA = clientInstance.selectGraph("cache-sync"); - const graphB = clientInstance.selectGraph("cache-sync"); - await graphA.query("CREATE (:LabelA)"); - await graphB.query("CREATE (:LabelB)"); - - const resultA = await graphA.query("MATCH (n) RETURN n"); - expect(resultA.data?.length).toBe(2); - await graphB.delete(); - await graphA.query("CREATE (:LabelC)"); - const resultB = await graphA.query("MATCH (n) RETURN n"); - expect(resultB.data?.length).toBe(1); - await graphA.delete(); - }); - - it('Generate and verify the query execution plan', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - await graph.query("CREATE (:Person {name: 'Alice'})"); - const executionPlan = await graph.explain("MATCH (n:Person) RETURN n"); - expect(executionPlan).toContain('Results'); - expect(executionPlan).toContain(' Project'); - expect(executionPlan).toContain(' Node By Label Scan | (n:Person)'); - await graph.delete(); - }); - - it('Validates the execution plan generated from a single query', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - const createQuery = ` + await graph.query(createQuery); + const slowLogResults = await graph.slowLog(); + expect(slowLogResults).toBeDefined(); + expect(slowLogResults[0].command).toBe("GRAPH.QUERY"); + expect(slowLogResults[0].query).toBe(createQuery); + expect(slowLogResults[0].took).toBeGreaterThan(0); + await graph.delete(); + }); + + it("Assert query execution time exceeds 1-sec limit", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + await graph.query("UNWIND range(0, 1000) AS val CREATE (:Node {v: val})"); + const result = await graph.query("MATCH (a), (b), (c), (d) RETURN *"); + const executionTimeStr = result.metadata[1]; + const executionTime = parseFloat(executionTimeStr.split(": ")[1]); + expect(() => { + expect(executionTime).toBeLessThan(1); + }).toThrow(); + await graph.delete(); + }); + + it("Create and match nodes with multiple labels", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + await graph.query("CREATE (a:Person:Employee {name: 'Alice', age: 30})"); + await graph.query("CREATE (b:Person:Manager {name: 'Bob', age: 45})"); + const resultA: any = await graph.roQuery( + "MATCH (n:Person:Employee {name: 'Alice'}) RETURN n" + ); + const resultB: any = await graph.roQuery( + "MATCH (n:Person:Manager {name: 'Bob'}) RETURN n" + ); + expect(resultA.data?.length).toBe(1); + expect(resultA.data![0].n.properties.name).toBe("Alice"); + expect(resultB.data!.length).toBe(1); + expect(resultB.data[0].n.properties.name).toBe("Bob"); + await graph.delete(); + }); + + it("Verify that client cache stays in sync with simple node creation and query", async () => { + const graphA = clientInstance.selectGraph("cache-sync"); + const graphB = clientInstance.selectGraph("cache-sync"); + await graphA.query("CREATE (:LabelA)"); + await graphB.query("CREATE (:LabelB)"); + + const resultA = await graphA.query("MATCH (n) RETURN n"); + expect(resultA.data?.length).toBe(2); + await graphB.delete(); + await graphA.query("CREATE (:LabelC)"); + const resultB = await graphA.query("MATCH (n) RETURN n"); + expect(resultB.data?.length).toBe(1); + await graphA.delete(); + }); + + it("Generate and verify the query execution plan", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + await graph.query("CREATE (:Person {name: 'Alice'})"); + const executionPlan = await graph.explain("MATCH (n:Person) RETURN n"); + expect(executionPlan).toContain("Results"); + expect(executionPlan).toContain(" Project"); + expect(executionPlan).toContain(" Node By Label Scan | (n:Person)"); + await graph.delete(); + }); + + it("Validates the execution plan generated from a single query", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + const createQuery = ` CREATE (:Rider {name:'Valentino Rossi'})-[:rides]->(:Team {name:'Yamaha'}), (:Rider {name:'Dani Pedrosa'})-[:rides]->(:Team {name:'Honda'}), (:Rider {name:'Andrea Dovizioso'})-[:rides]->(:Team {name:'Ducati'}) `; - await graph.query(createQuery); + await graph.query(createQuery); - const result = await graph.explain( - `MATCH (r:Rider)-[:rides]->(t:Team) + const result = await graph.explain( + `MATCH (r:Rider)-[:rides]->(t:Team) WHERE t.name = $name - RETURN r.name, t.name`, - ); - - const expectedParts = [ - 'Results', - ' Project', - ' Conditional Traverse | (t)->(r:Rider)', - ' Filter', - ' Node By Label Scan | (t:Team)' - ]; - - expectedParts.forEach((expectedPart, index) => { - expect(result[index]).toEqual(expectedPart); - }); - await graph.delete(); + RETURN r.name, t.name` + ); + + const expectedParts = [ + "Results", + " Project", + " Conditional Traverse | (t)->(r:Rider)", + " Filter", + " Node By Label Scan | (t:Team)", + ]; + + expectedParts.forEach((expectedPart, index) => { + expect(result[index]).toEqual(expectedPart); }); + await graph.delete(); + }); - it('Validates the execution plan generated from multiple queries combined with a UNION clause', async () => { - const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - const createQuery = ` + it("Validates the execution plan generated from multiple queries combined with a UNION clause", async () => { + const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); + const createQuery = ` CREATE (:Rider {name:'Valentino Rossi'})-[:rides]->(:Team {name:'Yamaha'}), (:Rider {name:'Dani Pedrosa'})-[:rides]->(:Team {name:'Honda'}), (:Rider {name:'Andrea Dovizioso'})-[:rides]->(:Team {name:'Ducati'}) `; - await graph.query(createQuery); - const result = await graph.explain( - `MATCH (r:Rider)-[:rides]->(t:Team) + await graph.query(createQuery); + const result = await graph.explain( + `MATCH (r:Rider)-[:rides]->(t:Team) WHERE t.name = $name RETURN r.name, t.name UNION MATCH (r:Rider)-[:rides]->(t:Team) WHERE t.name = $name - RETURN r.name, t.name`, - ); - - const expectedParts = [ - 'Results', - ' Distinct', - ' Join', - ' Project', - ' Conditional Traverse | (t)->(r:Rider)', - ' Filter', - ' Node By Label Scan | (t:Team)', - ' Project', - ' Conditional Traverse | (t)->(r:Rider)', - ' Filter', - ' Node By Label Scan | (t:Team)' - ]; - - expectedParts.forEach((expectedPart, index) => { - expect(result[index]).toEqual(expectedPart); - }); - await graph.delete(); + RETURN r.name, t.name` + ); + + const expectedParts = [ + "Results", + " Distinct", + " Join", + " Project", + " Conditional Traverse | (t)->(r:Rider)", + " Filter", + " Node By Label Scan | (t:Team)", + " Project", + " Conditional Traverse | (t)->(r:Rider)", + " Filter", + " Node By Label Scan | (t:Team)", + ]; + + expectedParts.forEach((expectedPart, index) => { + expect(result[index]).toEqual(expectedPart); }); - - it('validate creating a graph, copy it, and validate the entities, schema, indices, and constraints', async () => { - const graphName = `copy_src_${getRandomNumber()}`; - const copyGraphName = `copy_dest_${getRandomNumber()}`; - const srcGraph = clientInstance.selectGraph(graphName); - await srcGraph.query(`CREATE (:User {name: 'Alice'})-[:FRIEND]->(:User {name: 'Bob'})`); - await srcGraph.query("CREATE INDEX ON :User(name)"); - await srcGraph.constraintCreate('UNIQUE' as ConstraintType, 'NODE' as EntityType, 'User', 'name'); - await srcGraph.copy(copyGraphName); - const destGraph = clientInstance.selectGraph(copyGraphName); - - // Validate entities - const entityQuery = "MATCH (n) RETURN n ORDER BY ID(n)"; - const srcRes = await srcGraph.roQuery(entityQuery); - const destRes = await destGraph.roQuery(entityQuery); - expect(srcRes.data).toEqual(destRes.data); - - const edgeQuery = "MATCH ()-[r]->() RETURN r ORDER BY ID(r)"; - const srcEdgesRes = await srcGraph.roQuery(edgeQuery); - const destEdgesRes = await destGraph.roQuery(edgeQuery); - expect(srcEdgesRes.data).toEqual(destEdgesRes.data); - - // Validate schema - const srcLabels = await srcGraph.roQuery("CALL db.labels()"); - const destLabels = await destGraph.roQuery("CALL db.labels()"); - expect(srcLabels.data).toEqual(destLabels.data); - - const srcPropertyKeys = await srcGraph.roQuery("CALL db.propertyKeys()"); - const destPropertyKeys = await destGraph.roQuery("CALL db.propertyKeys()"); - expect(srcPropertyKeys.data).toEqual(destPropertyKeys.data); - - // // Validate indices - const srcIndicesRes = await srcGraph.explain("CALL DB.INDEXES() YIELD label, properties, types, language, stopwords, entitytype, status RETURN *"); - const destIndicesRes = await destGraph.explain("CALL DB.INDEXES() YIELD label, properties, types, language, stopwords, entitytype, status RETURN *"); - expect(srcIndicesRes.data).toEqual(destIndicesRes.data); - - await srcGraph.delete(); - await destGraph.delete(); - }); -}); \ No newline at end of file + await graph.delete(); + }); + + it("validate creating a graph, copy it, and validate the entities, schema, indices, and constraints", async () => { + const graphName = `copy_src_${getRandomNumber()}`; + const copyGraphName = `copy_dest_${getRandomNumber()}`; + const srcGraph = clientInstance.selectGraph(graphName); + await srcGraph.query( + `CREATE (:User {name: 'Alice'})-[:FRIEND]->(:User {name: 'Bob'})` + ); + await srcGraph.query("CREATE INDEX ON :User(name)"); + await srcGraph.constraintCreate( + "UNIQUE" as ConstraintType, + "NODE" as EntityType, + "User", + "name" + ); + await srcGraph.copy(copyGraphName); + const destGraph = clientInstance.selectGraph(copyGraphName); + + // Validate entities + const entityQuery = "MATCH (n) RETURN n ORDER BY ID(n)"; + const srcRes = await srcGraph.roQuery(entityQuery); + const destRes = await destGraph.roQuery(entityQuery); + expect(srcRes.data).toEqual(destRes.data); + + const edgeQuery = "MATCH ()-[r]->() RETURN r ORDER BY ID(r)"; + const srcEdgesRes = await srcGraph.roQuery(edgeQuery); + const destEdgesRes = await destGraph.roQuery(edgeQuery); + expect(srcEdgesRes.data).toEqual(destEdgesRes.data); + + // Validate schema + const srcLabels = await srcGraph.roQuery("CALL db.labels()"); + const destLabels = await destGraph.roQuery("CALL db.labels()"); + expect(srcLabels.data).toEqual(destLabels.data); + + const srcPropertyKeys = await srcGraph.roQuery("CALL db.propertyKeys()"); + const destPropertyKeys = await destGraph.roQuery("CALL db.propertyKeys()"); + expect(srcPropertyKeys.data).toEqual(destPropertyKeys.data); + + // // Validate indices + const srcIndicesRes = await srcGraph.explain( + "CALL DB.INDEXES() YIELD label, properties, types, language, stopwords, entitytype, status RETURN *" + ); + const destIndicesRes = await destGraph.explain( + "CALL DB.INDEXES() YIELD label, properties, types, language, stopwords, entitytype, status RETURN *" + ); + expect(srcIndicesRes.data).toEqual(destIndicesRes.data); + + await srcGraph.delete(); + await destGraph.delete(); + }); + + it("validate the creation of a duration", async () => { + try { + const graphName = `duration_${getRandomNumber()}`; + const graph = clientInstance.selectGraph(graphName); + const queries: [string, Temporal.Duration][] = [ + ["P1Y", Temporal.Duration.from({ years: 1 })], + ["P1Y1M", Temporal.Duration.from({ years: 1, months: 1 })], + ["P1Y1M1D", Temporal.Duration.from({ years: 1, months: 1, days: 1 })], + [ + "P1Y1M1DT1H", + Temporal.Duration.from({ years: 1, months: 1, days: 1, hours: 1 }), + ], + [ + "P1Y1M1DT1H1M", + Temporal.Duration.from({ + years: 1, + months: 1, + days: 1, + hours: 1, + minutes: 1, + }), + ], + [ + "P1Y1M1DT1H1M1S", + Temporal.Duration.from({ + years: 1, + months: 1, + days: 1, + hours: 1, + minutes: 1, + seconds: 1, + }), + ], + ]; + + for (const [duration, durationObj] of queries) { + const query = `return duration($duration) as duration`; + const result = await graph.query(query, { params: { duration } }); + expect( + ( + result.data as { duration: Temporal.Duration }[] + )[0].duration.toString() + ).toEqual(durationObj.toString()); + } + await graph.delete(); + } catch (error) { + console.log(error); + throw error; + } + }); + + it("validate the creation of a date", async () => { + try { + const graphName = `date_${getRandomNumber()}`; + const graph = clientInstance.selectGraph(graphName); + const queries: [string, Temporal.PlainDate][] = [ + [ + "2025-01-01", + Temporal.PlainDate.from({ year: 2025, month: 1, day: 1 }), + ], + [ + "2025-02-01", + Temporal.PlainDate.from({ year: 2025, month: 2, day: 1 }), + ], + [ + "2025-01-02", + Temporal.PlainDate.from({ year: 2025, month: 1, day: 2 }), + ], + ]; + for (const [date, dateObj] of queries) { + const query = `return date($date) as date`; + const result = await graph.query(query, { params: { date } }); + expect( + (result.data as { date: Temporal.PlainDate }[])[0].date.toString() + ).toEqual(dateObj.toString()); + } + await graph.delete(); + } catch (error) { + console.log(error); + throw error; + } + }); + + it("validate the creation of a time", async () => { + try { + const graphName = `time_${getRandomNumber()}`; + const graph = clientInstance.selectGraph(graphName); + const queries: [string, Temporal.PlainTime][] = [ + [ + "01:00:00", + Temporal.PlainTime.from({ hour: 1, minute: 0, second: 0 }), + ], + [ + "00:01:00", + Temporal.PlainTime.from({ hour: 0, minute: 1, second: 0 }), + ], + [ + "00:00:01", + Temporal.PlainTime.from({ hour: 0, minute: 0, second: 1 }), + ], + ]; + for (const [time, timeObj] of queries) { + const query = `return localtime($time) as time`; + const result = await graph.query(query, { params: { time } }); + console.log( + (result.data as { time: Temporal.PlainTime }[])[0].time.toString() + ); + console.log(timeObj.toString()); + expect( + (result.data as { time: Temporal.PlainTime }[])[0].time.toString() + ).toEqual(timeObj.toString()); + } + await graph.delete(); + } catch (error) { + console.log(error); + throw error; + } + }); + + it("validate the creation of a datetime", async () => { + try { + const graphName = `datetime_${getRandomNumber()}`; + const graph = clientInstance.selectGraph(graphName); + + const queries: [string, Temporal.PlainDateTime][] = [ + [ + "{ year: 2025, month: 1, day: 1, hour: 0, minute: 0, second: 0}", + Temporal.PlainDateTime.from({ year: 2025, month: 1, day: 1 }), + ], + [ + "{ year: 2025, month: 1, day: 1, hour: 1, minute: 0, second: 0}", + Temporal.PlainDateTime.from({ + year: 2025, + month: 1, + day: 1, + hour: 1, + }), + ], + [ + "{ year: 2025, month: 1, day: 1, hour: 0, minute: 1, second: 0}", + Temporal.PlainDateTime.from({ + year: 2025, + month: 1, + day: 1, + hour: 0, + minute: 1, + }), + ], + [ + "{ year: 2025, month: 1, day: 1, hour: 0, minute: 0, second: 1}", + Temporal.PlainDateTime.from({ + year: 2025, + month: 1, + day: 1, + hour: 0, + minute: 0, + second: 1, + }), + ], + [ + "{ year: 1984, month: 10, day: 11, hour: 12, minute: 31, second: 14 }", + Temporal.PlainDateTime.from({ + year: 1984, + month: 10, + day: 11, + hour: 12, + minute: 31, + second: 14, + }), + ], + ]; + + for (const [datetime, expectedDate] of queries) { + const query = `return localdatetime(${datetime}) as datetime`; + const result = await graph.query(query); + expect( + ( + result.data as { datetime: Temporal.PlainDateTime }[] + )[0].datetime.toString() + ).toEqual(expectedDate.toString()); + } + await graph.delete(); + } catch (error) { + console.log(error); + throw error; + } + }); +});