diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 60ea4d1..54a0db0 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -103,9 +103,6 @@ jobs: echo "127.0.0.1 $i" | sudo tee -a /etc/hosts done - - name: install docker-compose client - run: sudo apt update && sudo apt install docker-compose -y - - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/tests/cluster.spec.ts b/tests/cluster.spec.ts index 40ac949..ba89a36 100644 --- a/tests/cluster.spec.ts +++ b/tests/cluster.spec.ts @@ -191,7 +191,7 @@ describe('Cluster Client Tests', () => { it('should test slowLog method', async () => { try { const graph = clusterClient!.selectGraph(`cluster-test-${getRandomNumber()}`); - const longQuery = 'UNWIND range (0, 200000) AS x RETURN max(x)'; + const longQuery = 'UNWIND range (0, 1000000) AS x RETURN max(x)'; await graph.query(longQuery); const result = await graph.slowLog(); expect(Array.isArray(result)).toBe(true); diff --git a/tests/constraints.spec.ts b/tests/constraints.spec.ts index 8f352f0..b58131a 100644 --- a/tests/constraints.spec.ts +++ b/tests/constraints.spec.ts @@ -96,7 +96,6 @@ describe('Constraint Tests', () => { await graphName.query(` MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'}) CREATE (a)-[:KNOWS {since: 2020}]->(b) - RETURN exists((a)-[:KNOWS]->(b)) as hasRelationship `); await graphName.query("CREATE INDEX ON :KNOWS(since)"); await graphName.constraintCreate("MANDATORY" as ConstraintType, "RELATIONSHIP" as EntityType, "KNOWS", "since"); diff --git a/tests/graphAndQuery.spec.ts b/tests/graphAndQuery.spec.ts index 297800f..aa6f972 100644 --- a/tests/graphAndQuery.spec.ts +++ b/tests/graphAndQuery.spec.ts @@ -2,6 +2,7 @@ import { describe, it, beforeAll, afterAll, expect } from '@jest/globals'; import FalkorDB from "../src/falkordb"; import { ConstraintType, EntityType } from "../src/graph"; import { client } from "./dbConnection"; +import { expectExecutionPlan, expectPlanShape } from "./planHelpers"; import { Temporal } from "@js-temporal/polyfill"; function getRandomNumber(): number { @@ -274,7 +275,7 @@ describe("FalkorDB Execute Query", () => { it("Verify slow query logging", async () => { const graph = clientInstance.selectGraph(`graph_${getRandomNumber()}`); - const longQuery = "UNWIND range (0, 200000) AS x RETURN max(x)"; + const longQuery = "UNWIND range (0, 1000000) AS x RETURN max(x)"; await graph.query(longQuery); const slowLogResults = await graph.slowLog(); @@ -333,9 +334,11 @@ describe("FalkorDB Execute Query", () => { 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)"); + + expectPlanShape(executionPlan, [ + "Project", + " Node By Label Scan | (n:Person)", + ]); await graph.delete(); }); @@ -355,17 +358,14 @@ describe("FalkorDB Execute Query", () => { 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); - }); + // The traverse direction is rendered differently by each engine, so pin + // its operation and nesting but not its arguments. + expectPlanShape(result, [ + "Project", + " Conditional Traverse", + " Filter", + " Node By Label Scan | (t:Team)", + ]); await graph.delete(); }); @@ -388,23 +388,9 @@ describe("FalkorDB Execute Query", () => { 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); - }); + // Rust names the combining operation "Union"; C names it "Join". + // There is no single exact operation tree to assert for this query. + expectExecutionPlan(result, 7); await graph.delete(); }); diff --git a/tests/planHelpers.ts b/tests/planHelpers.ts new file mode 100644 index 0000000..92253bd --- /dev/null +++ b/tests/planHelpers.ts @@ -0,0 +1,112 @@ +/** + * Assertions for the raw execution-plan arrays returned by the client. + * + * Prefer expectPlanShape wherever both engines produce the same operation + * tree. Keep expectExecutionPlan for documented engine-specific plans. + */ + +import { expect } from "@jest/globals"; + +const INDENT = " "; +const ENGINE_ROOT_OPERATIONS = new Set(["Results", "Commit"]); +const PROFILE_STATS = /\s*\|\s*Records produced: \d+,\s*Execution time: \d+\.\d+ ms\s*$/; + +export function indentOf(line: string): number { + return (line.length - line.trimStart().length) / INDENT.length; +} + +export function operationName(line: string): string { + return line.split("|")[0].trim(); +} + +function operationLine(line: string): string { + return line.replace(PROFILE_STATS, "").trim(); +} + +/** Asserts the reply is a well formed execution plan. */ +export function expectExecutionPlan(plan: string[], minOperations = 1): void { + expect(Array.isArray(plan)).toBe(true); + expect(plan.length).toBeGreaterThanOrEqual(minOperations); + + plan.forEach((line, index) => { + expect(typeof line).toBe("string"); + + // every line names an operation, optionally followed by its arguments + expect(operationName(line)).not.toBe(""); + + // indentation is what conveys nesting, so it has to be whole levels, and + // an operation can only ever be one level deeper than the one above it + const indent = indentOf(line); + expect(Number.isInteger(indent)).toBe(true); + expect(indent).toBe(index === 0 ? 0 : Math.min(indent, indentOf(plan[index - 1]) + 1)); + }); +} + +/** + * Asserts operation names and nesting exactly, and arguments on expected lines + * that include them after a `|`. Engine-only driver roots are ignored. + */ +export function expectPlanShape(plan: string[], expected: readonly string[]): void { + expectExecutionPlan(plan); + expect(expected.length).toBeGreaterThan(0); + + const expectedRoot = operationName(expected[0]); + const skipRoot = + ENGINE_ROOT_OPERATIONS.has(operationName(plan[0])) && + operationName(plan[0]) !== expectedRoot; + const rootOffset = skipRoot ? 1 : 0; + const actual = plan.slice(rootOffset); + + expect(actual).toHaveLength(expected.length); + + expected.forEach((expectedLine, index) => { + const actualLine = actual[index]; + const expectedIndent = indentOf(expectedLine); + const actualIndent = indentOf(actualLine) - rootOffset; + + expect(Number.isInteger(expectedIndent)).toBe(true); + expect(actualIndent).toBe(expectedIndent); + + const expectedOperation = operationLine(expectedLine); + const actualOperation = expectedOperation.includes("|") + ? operationLine(actualLine) + : operationName(actualLine); + expect(actualOperation).toBe(expectedOperation); + }); +} + +/** Asserts the reply is a well formed profile, statistics included. */ +export function expectProfile( + plan: string[], + minOperations = 1, + recordsProduced?: number +): void { + expectExecutionPlan(plan, minOperations); + + const counts = plan.map((line) => { + const records = line.match(/Records produced: (\d+)/); + const time = line.match(/Execution time: (\d+\.\d+) ms/); + + // every operation is profiled, whichever operations they turn out to be + expect(records).not.toBeNull(); + expect(time).not.toBeNull(); + + return parseInt(records![1], 10); + }); + + if (recordsProduced !== undefined) { + // how many rows the query yields is a property of the query, not of the + // engine, so the client must report it whichever engine answered + expect(Math.max(...counts)).toBe(recordsProduced); + } +} + +/** Asserts an exact profile plan while retaining all profile-stat checks. */ +export function expectProfileShape( + plan: string[], + expected: readonly string[], + recordsProduced?: number +): void { + expectPlanShape(plan, expected); + expectProfile(plan, expected.length, recordsProduced); +} diff --git a/tests/profile.spec.ts b/tests/profile.spec.ts index 62c4d44..594ca76 100644 --- a/tests/profile.spec.ts +++ b/tests/profile.spec.ts @@ -1,7 +1,8 @@ -import { describe, test, beforeAll, beforeEach, afterAll, afterEach, expect } from '@jest/globals'; +import { describe, test, beforeAll, beforeEach, afterAll, afterEach } from '@jest/globals'; import { client } from './dbConnection'; import FalkorDB from '../src/falkordb'; import Graph from '../src/graph'; +import { expectProfileShape } from './planHelpers'; describe('Profile Tests', () => { @@ -37,31 +38,20 @@ describe('Profile Tests', () => { test('Verifies query execution plan structure with UNWIND operation', async () => { const plan = await graphName.profile("UNWIND range(0, 3) AS x RETURN x"); - expect(plan[0]).toMatch(/Results/); - expect(plan[1]).toMatch(/Project/); - expect(plan[2]).toMatch(/Unwind/); - expect(plan[0]).toContain('Records produced: 4'); + expectProfileShape(plan, [ + "Project", + " Unwind", + ], 4); }); test('Verifies query execution plan structure with Cartesian operation', async () => { const plan = await graphName.profile("MATCH (a), (b) RETURN *"); - type PlanStep = string | { name: string; alias: string }; - const expectedPlanSteps: PlanStep[] = [ - 'Results', - 'Project', - 'Cartesian Product', - { name: 'All Node Scan', alias: '(a)' }, - { name: 'All Node Scan', alias: '(b)' } - ]; - - expectedPlanSteps.forEach((step, index) => { - if (typeof step === 'string') { - expect(plan[index]).toContain(step); - } else { - expect(plan[index]).toContain(step.name); - expect(plan[index]).toContain(step.alias); - } - }) + expectProfileShape(plan, [ + "Project", + " Cartesian Product", + " All Node Scan | (a)", + " All Node Scan | (b)", + ], 0); }); }); diff --git a/tests/single.spec.ts b/tests/single.spec.ts index 8365aba..0f40bd4 100644 --- a/tests/single.spec.ts +++ b/tests/single.spec.ts @@ -246,7 +246,7 @@ describe("Single Client Tests", () => { const graph = singleClient.selectGraph( `test-single-slowlog-${getRandomNumber()}` ); - const longQuery = "UNWIND range (0, 200000) AS x RETURN max(x)" + const longQuery = "UNWIND range (0, 1000000) AS x RETURN max(x)" await graph.query(longQuery); const slowLog = await graph.slowLog(); expect(Array.isArray(slowLog)).toBe(true);