diff --git a/Cargo.lock b/Cargo.lock index 8cabe50a6..6bef1a8c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -427,9 +427,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" diff --git a/minigu-test/gql/dml/match_filter.gql b/minigu-test/gql/dml/match_filter.gql new file mode 100644 index 000000000..673333c11 --- /dev/null +++ b/minigu-test/gql/dml/match_filter.gql @@ -0,0 +1,24 @@ +-- Setup: create test graph and set as current +CALL create_test_graph_data('g', 10); +SESSION SET GRAPH g; + +-- Test 1: Simple property equality filter on single vertex +MATCH (p:PERSON) WHERE p.name = 'person0' RETURN p; + +-- Test 2: Multi-hop with filter on target vertex +MATCH (p:PERSON)-[w:WORKS_AT]->(c:COMPANY) WHERE c.name = 'company0' RETURN p, w, c; + +-- Test 3: Filter on source vertex in a path +MATCH (p:PERSON)-[w:WORKS_AT]->(c:COMPANY) WHERE p.name = 'person0' RETURN p, w, c; + +-- Test 4: Comparison filter (age > value) +MATCH (p:PERSON) WHERE p.age > 22 RETURN p; + +-- Test 5: Return only a property with filter +MATCH (p:PERSON) WHERE p.age > 22 RETURN p.name; + +-- Test 6: Filter with no matching rows +MATCH (p:PERSON) WHERE p.name = 'nonexistent' RETURN p; + +-- Test 7: Two-hop path with filter on source vertex +MATCH (p:PERSON)-[f:FRIEND]->(p2:PERSON)-[w:WORKS_AT]->(c:COMPANY) WHERE p.name = 'person0' RETURN p, p2, c; diff --git a/minigu-test/gql/dml/match_filter@e2e.snap b/minigu-test/gql/dml/match_filter@e2e.snap new file mode 100644 index 000000000..a63a84ab0 --- /dev/null +++ b/minigu-test/gql/dml/match_filter@e2e.snap @@ -0,0 +1,71 @@ +--- +source: minigu-test/src/insta_test.rs +--- +Statement OK. No results +--- +Statement OK. No results +--- +┌──────────────────────────────────────────────┐ +│ p │ +│ vertex { name::string,age::int8 } │ +├──────────────────────────────────────────────┤ +│ {_vid: 0, _label: 0, name: person0, age: 20} │ +└──────────────────────────────────────────────┘ +1 rows + +--- +┌──────────────────────────────────────────────┬────────────────────┬────────────────────────────────────────────────────────┐ +│ p │ w │ c │ +│ vertex { name::string,age::int8 } │ edge { id::int64 } │ vertex { name::string,revenue::int64 } │ +├──────────────────────────────────────────────┼────────────────────┼────────────────────────────────────────────────────────┤ +│ {_vid: 0, _label: 0, name: person0, age: 20} │ 25 │ {_vid: 5, _label: 0, name: company0, revenue: 1000000} │ +├──────────────────────────────────────────────┼────────────────────┼────────────────────────────────────────────────────────┤ +│ {_vid: 2, _label: 0, name: person2, age: 22} │ 27 │ {_vid: 5, _label: 0, name: company0, revenue: 1000000} │ +└──────────────────────────────────────────────┴────────────────────┴────────────────────────────────────────────────────────┘ +2 rows + +--- +┌──────────────────────────────────────────────┬────────────────────┬────────────────────────────────────────────────────────┐ +│ p │ w │ c │ +│ vertex { name::string,age::int8 } │ edge { id::int64 } │ vertex { name::string,revenue::int64 } │ +├──────────────────────────────────────────────┼────────────────────┼────────────────────────────────────────────────────────┤ +│ {_vid: 0, _label: 0, name: person0, age: 20} │ 25 │ {_vid: 5, _label: 0, name: company0, revenue: 1000000} │ +└──────────────────────────────────────────────┴────────────────────┴────────────────────────────────────────────────────────┘ +1 rows + +--- +┌──────────────────────────────────────────────┐ +│ p │ +│ vertex { name::string,age::int8 } │ +├──────────────────────────────────────────────┤ +│ {_vid: 3, _label: 0, name: person3, age: 23} │ +├──────────────────────────────────────────────┤ +│ {_vid: 4, _label: 0, name: person4, age: 24} │ +└──────────────────────────────────────────────┘ +2 rows + +--- +┌─────────┐ +│ p.name │ +│ string │ +├─────────┤ +│ person3 │ +├─────────┤ +│ person4 │ +└─────────┘ +2 rows + +--- +Statement OK. No results +--- +┌──────────────────────────────────────────────┬──────────────────────────────────────────────┬────────────────────────────────────────────────────────┐ +│ p │ p2 │ c │ +│ vertex { name::string,age::int8 } │ vertex { name::string,age::int8 } │ vertex { name::string,revenue::int64 } │ +├──────────────────────────────────────────────┼──────────────────────────────────────────────┼────────────────────────────────────────────────────────┤ +│ {_vid: 0, _label: 0, name: person0, age: 20} │ {_vid: 0, _label: 0, name: person0, age: 20} │ {_vid: 5, _label: 0, name: company0, revenue: 1000000} │ +├──────────────────────────────────────────────┼──────────────────────────────────────────────┼────────────────────────────────────────────────────────┤ +│ {_vid: 0, _label: 0, name: person0, age: 20} │ {_vid: 1, _label: 0, name: person1, age: 21} │ {_vid: 6, _label: 0, name: company1, revenue: 2000000} │ +├──────────────────────────────────────────────┼──────────────────────────────────────────────┼────────────────────────────────────────────────────────┤ +│ {_vid: 0, _label: 0, name: person0, age: 20} │ {_vid: 2, _label: 0, name: person2, age: 22} │ {_vid: 5, _label: 0, name: company0, revenue: 1000000} │ +└──────────────────────────────────────────────┴──────────────────────────────────────────────┴────────────────────────────────────────────────────────┘ +3 rows diff --git a/minigu-test/gql/dml/match_filter@parser.snap b/minigu-test/gql/dml/match_filter@parser.snap new file mode 100644 index 000000000..aabb68980 --- /dev/null +++ b/minigu-test/gql/dml/match_filter@parser.snap @@ -0,0 +1,992 @@ +--- +source: minigu-test/src/insta_test.rs +--- +- Ok: + - activity: + - Transaction: + start: ~ + procedure: + - at: ~ + binding_variable_defs: [] + statement: + - Catalog: + - - Call: + optional: false + procedure: + - Named: + name: + - Ref: + schema: ~ + objects: + - - create_test_graph_data + - start: 5 + end: 27 + - start: 5 + end: 27 + args: + - - Value: + Literal: + String: + kind: Char + literal: g + - start: 28 + end: 31 + - - Value: + Literal: + Numeric: + Integer: + - kind: Decimal + integer: "10" + - start: 33 + end: 35 + - start: 33 + end: 35 + yield_clause: ~ + - start: 5 + end: 36 + - start: 0 + end: 36 + - start: 0 + end: 36 + next_statements: [] + - start: 0 + end: 36 + end: ~ + - start: 0 + end: 36 + session_close: false + - start: 0 + end: 36 +- Ok: + - activity: + - Session: + set: + - - Graph: + - Name: g + - start: 18 + end: 19 + - start: 0 + end: 19 + reset: [] + - start: 0 + end: 19 + session_close: false + - start: 0 + end: 19 +- Ok: + - activity: + - Transaction: + start: ~ + procedure: + - at: ~ + binding_variable_defs: [] + statement: + - Query: + Primary: + Ambient: + Parts: + parts: + - - Match: + Simple: + - pattern: + - match_mode: ~ + patterns: + - - variable: ~ + prefix: ~ + expr: + - Concat: + - - Pattern: + Node: + variable: + - p + - start: 7 + end: 8 + label: + - Label: PERSON + - start: 9 + end: 15 + predicate: ~ + - start: 6 + end: 16 + - start: 6 + end: 16 + - start: 6 + end: 16 + keep: ~ + where_clause: + - Binary: + op: + - Eq + - start: 30 + end: 31 + left: + - Property: + source: + - Variable: p + - start: 23 + end: 24 + trailing_names: + - - name + - start: 25 + end: 29 + - start: 23 + end: 29 + right: + - Value: + Literal: + String: + kind: Char + literal: person0 + - start: 32 + end: 41 + - start: 23 + end: 41 + - start: 6 + end: 41 + yield_clause: [] + - start: 6 + end: 41 + - start: 0 + end: 41 + result: + - Return: + statement: + - quantifier: ~ + items: + - Items: + - - value: + - Variable: p + - start: 49 + end: 50 + alias: ~ + - start: 49 + end: 50 + - start: 49 + end: 50 + group_by: ~ + - start: 42 + end: 50 + order_by: ~ + - start: 42 + end: 50 + - start: 0 + end: 50 + next_statements: [] + - start: 0 + end: 50 + end: ~ + - start: 0 + end: 50 + session_close: false + - start: 0 + end: 50 +- Ok: + - activity: + - Transaction: + start: ~ + procedure: + - at: ~ + binding_variable_defs: [] + statement: + - Query: + Primary: + Ambient: + Parts: + parts: + - - Match: + Simple: + - pattern: + - match_mode: ~ + patterns: + - - variable: ~ + prefix: ~ + expr: + - Concat: + - - Pattern: + Node: + variable: + - p + - start: 7 + end: 8 + label: + - Label: PERSON + - start: 9 + end: 15 + predicate: ~ + - start: 6 + end: 16 + - - Pattern: + Edge: + kind: Right + filler: + variable: + - w + - start: 18 + end: 19 + label: + - Label: WORKS_AT + - start: 20 + end: 28 + predicate: ~ + - start: 16 + end: 31 + - - Pattern: + Node: + variable: + - c + - start: 32 + end: 33 + label: + - Label: COMPANY + - start: 34 + end: 41 + predicate: ~ + - start: 31 + end: 42 + - start: 6 + end: 42 + - start: 6 + end: 42 + keep: ~ + where_clause: + - Binary: + op: + - Eq + - start: 56 + end: 57 + left: + - Property: + source: + - Variable: c + - start: 49 + end: 50 + trailing_names: + - - name + - start: 51 + end: 55 + - start: 49 + end: 55 + right: + - Value: + Literal: + String: + kind: Char + literal: company0 + - start: 58 + end: 68 + - start: 49 + end: 68 + - start: 6 + end: 68 + yield_clause: [] + - start: 6 + end: 68 + - start: 0 + end: 68 + result: + - Return: + statement: + - quantifier: ~ + items: + - Items: + - - value: + - Variable: p + - start: 76 + end: 77 + alias: ~ + - start: 76 + end: 77 + - - value: + - Variable: w + - start: 79 + end: 80 + alias: ~ + - start: 79 + end: 80 + - - value: + - Variable: c + - start: 82 + end: 83 + alias: ~ + - start: 82 + end: 83 + - start: 76 + end: 83 + group_by: ~ + - start: 69 + end: 83 + order_by: ~ + - start: 69 + end: 83 + - start: 0 + end: 83 + next_statements: [] + - start: 0 + end: 83 + end: ~ + - start: 0 + end: 83 + session_close: false + - start: 0 + end: 83 +- Ok: + - activity: + - Transaction: + start: ~ + procedure: + - at: ~ + binding_variable_defs: [] + statement: + - Query: + Primary: + Ambient: + Parts: + parts: + - - Match: + Simple: + - pattern: + - match_mode: ~ + patterns: + - - variable: ~ + prefix: ~ + expr: + - Concat: + - - Pattern: + Node: + variable: + - p + - start: 7 + end: 8 + label: + - Label: PERSON + - start: 9 + end: 15 + predicate: ~ + - start: 6 + end: 16 + - - Pattern: + Edge: + kind: Right + filler: + variable: + - w + - start: 18 + end: 19 + label: + - Label: WORKS_AT + - start: 20 + end: 28 + predicate: ~ + - start: 16 + end: 31 + - - Pattern: + Node: + variable: + - c + - start: 32 + end: 33 + label: + - Label: COMPANY + - start: 34 + end: 41 + predicate: ~ + - start: 31 + end: 42 + - start: 6 + end: 42 + - start: 6 + end: 42 + keep: ~ + where_clause: + - Binary: + op: + - Eq + - start: 56 + end: 57 + left: + - Property: + source: + - Variable: p + - start: 49 + end: 50 + trailing_names: + - - name + - start: 51 + end: 55 + - start: 49 + end: 55 + right: + - Value: + Literal: + String: + kind: Char + literal: person0 + - start: 58 + end: 67 + - start: 49 + end: 67 + - start: 6 + end: 67 + yield_clause: [] + - start: 6 + end: 67 + - start: 0 + end: 67 + result: + - Return: + statement: + - quantifier: ~ + items: + - Items: + - - value: + - Variable: p + - start: 75 + end: 76 + alias: ~ + - start: 75 + end: 76 + - - value: + - Variable: w + - start: 78 + end: 79 + alias: ~ + - start: 78 + end: 79 + - - value: + - Variable: c + - start: 81 + end: 82 + alias: ~ + - start: 81 + end: 82 + - start: 75 + end: 82 + group_by: ~ + - start: 68 + end: 82 + order_by: ~ + - start: 68 + end: 82 + - start: 0 + end: 82 + next_statements: [] + - start: 0 + end: 82 + end: ~ + - start: 0 + end: 82 + session_close: false + - start: 0 + end: 82 +- Ok: + - activity: + - Transaction: + start: ~ + procedure: + - at: ~ + binding_variable_defs: [] + statement: + - Query: + Primary: + Ambient: + Parts: + parts: + - - Match: + Simple: + - pattern: + - match_mode: ~ + patterns: + - - variable: ~ + prefix: ~ + expr: + - Concat: + - - Pattern: + Node: + variable: + - p + - start: 7 + end: 8 + label: + - Label: PERSON + - start: 9 + end: 15 + predicate: ~ + - start: 6 + end: 16 + - start: 6 + end: 16 + - start: 6 + end: 16 + keep: ~ + where_clause: + - Binary: + op: + - Gt + - start: 29 + end: 30 + left: + - Property: + source: + - Variable: p + - start: 23 + end: 24 + trailing_names: + - - age + - start: 25 + end: 28 + - start: 23 + end: 28 + right: + - Value: + Literal: + Numeric: + Integer: + - kind: Decimal + integer: "22" + - start: 31 + end: 33 + - start: 31 + end: 33 + - start: 23 + end: 33 + - start: 6 + end: 33 + yield_clause: [] + - start: 6 + end: 33 + - start: 0 + end: 33 + result: + - Return: + statement: + - quantifier: ~ + items: + - Items: + - - value: + - Variable: p + - start: 41 + end: 42 + alias: ~ + - start: 41 + end: 42 + - start: 41 + end: 42 + group_by: ~ + - start: 34 + end: 42 + order_by: ~ + - start: 34 + end: 42 + - start: 0 + end: 42 + next_statements: [] + - start: 0 + end: 42 + end: ~ + - start: 0 + end: 42 + session_close: false + - start: 0 + end: 42 +- Ok: + - activity: + - Transaction: + start: ~ + procedure: + - at: ~ + binding_variable_defs: [] + statement: + - Query: + Primary: + Ambient: + Parts: + parts: + - - Match: + Simple: + - pattern: + - match_mode: ~ + patterns: + - - variable: ~ + prefix: ~ + expr: + - Concat: + - - Pattern: + Node: + variable: + - p + - start: 7 + end: 8 + label: + - Label: PERSON + - start: 9 + end: 15 + predicate: ~ + - start: 6 + end: 16 + - start: 6 + end: 16 + - start: 6 + end: 16 + keep: ~ + where_clause: + - Binary: + op: + - Gt + - start: 29 + end: 30 + left: + - Property: + source: + - Variable: p + - start: 23 + end: 24 + trailing_names: + - - age + - start: 25 + end: 28 + - start: 23 + end: 28 + right: + - Value: + Literal: + Numeric: + Integer: + - kind: Decimal + integer: "22" + - start: 31 + end: 33 + - start: 31 + end: 33 + - start: 23 + end: 33 + - start: 6 + end: 33 + yield_clause: [] + - start: 6 + end: 33 + - start: 0 + end: 33 + result: + - Return: + statement: + - quantifier: ~ + items: + - Items: + - - value: + - Property: + source: + - Variable: p + - start: 41 + end: 42 + trailing_names: + - - name + - start: 43 + end: 47 + - start: 41 + end: 47 + alias: ~ + - start: 41 + end: 47 + - start: 41 + end: 47 + group_by: ~ + - start: 34 + end: 47 + order_by: ~ + - start: 34 + end: 47 + - start: 0 + end: 47 + next_statements: [] + - start: 0 + end: 47 + end: ~ + - start: 0 + end: 47 + session_close: false + - start: 0 + end: 47 +- Ok: + - activity: + - Transaction: + start: ~ + procedure: + - at: ~ + binding_variable_defs: [] + statement: + - Query: + Primary: + Ambient: + Parts: + parts: + - - Match: + Simple: + - pattern: + - match_mode: ~ + patterns: + - - variable: ~ + prefix: ~ + expr: + - Concat: + - - Pattern: + Node: + variable: + - p + - start: 7 + end: 8 + label: + - Label: PERSON + - start: 9 + end: 15 + predicate: ~ + - start: 6 + end: 16 + - start: 6 + end: 16 + - start: 6 + end: 16 + keep: ~ + where_clause: + - Binary: + op: + - Eq + - start: 30 + end: 31 + left: + - Property: + source: + - Variable: p + - start: 23 + end: 24 + trailing_names: + - - name + - start: 25 + end: 29 + - start: 23 + end: 29 + right: + - Value: + Literal: + String: + kind: Char + literal: nonexistent + - start: 32 + end: 45 + - start: 23 + end: 45 + - start: 6 + end: 45 + yield_clause: [] + - start: 6 + end: 45 + - start: 0 + end: 45 + result: + - Return: + statement: + - quantifier: ~ + items: + - Items: + - - value: + - Variable: p + - start: 53 + end: 54 + alias: ~ + - start: 53 + end: 54 + - start: 53 + end: 54 + group_by: ~ + - start: 46 + end: 54 + order_by: ~ + - start: 46 + end: 54 + - start: 0 + end: 54 + next_statements: [] + - start: 0 + end: 54 + end: ~ + - start: 0 + end: 54 + session_close: false + - start: 0 + end: 54 +- Ok: + - activity: + - Transaction: + start: ~ + procedure: + - at: ~ + binding_variable_defs: [] + statement: + - Query: + Primary: + Ambient: + Parts: + parts: + - - Match: + Simple: + - pattern: + - match_mode: ~ + patterns: + - - variable: ~ + prefix: ~ + expr: + - Concat: + - - Pattern: + Node: + variable: + - p + - start: 7 + end: 8 + label: + - Label: PERSON + - start: 9 + end: 15 + predicate: ~ + - start: 6 + end: 16 + - - Pattern: + Edge: + kind: Right + filler: + variable: + - f + - start: 18 + end: 19 + label: + - Label: FRIEND + - start: 20 + end: 26 + predicate: ~ + - start: 16 + end: 29 + - - Pattern: + Node: + variable: + - p2 + - start: 30 + end: 32 + label: + - Label: PERSON + - start: 33 + end: 39 + predicate: ~ + - start: 29 + end: 40 + - - Pattern: + Edge: + kind: Right + filler: + variable: + - w + - start: 42 + end: 43 + label: + - Label: WORKS_AT + - start: 44 + end: 52 + predicate: ~ + - start: 40 + end: 55 + - - Pattern: + Node: + variable: + - c + - start: 56 + end: 57 + label: + - Label: COMPANY + - start: 58 + end: 65 + predicate: ~ + - start: 55 + end: 66 + - start: 6 + end: 66 + - start: 6 + end: 66 + keep: ~ + where_clause: + - Binary: + op: + - Eq + - start: 80 + end: 81 + left: + - Property: + source: + - Variable: p + - start: 73 + end: 74 + trailing_names: + - - name + - start: 75 + end: 79 + - start: 73 + end: 79 + right: + - Value: + Literal: + String: + kind: Char + literal: person0 + - start: 82 + end: 91 + - start: 73 + end: 91 + - start: 6 + end: 91 + yield_clause: [] + - start: 6 + end: 91 + - start: 0 + end: 91 + result: + - Return: + statement: + - quantifier: ~ + items: + - Items: + - - value: + - Variable: p + - start: 99 + end: 100 + alias: ~ + - start: 99 + end: 100 + - - value: + - Variable: p2 + - start: 102 + end: 104 + alias: ~ + - start: 102 + end: 104 + - - value: + - Variable: c + - start: 106 + end: 107 + alias: ~ + - start: 106 + end: 107 + - start: 99 + end: 107 + group_by: ~ + - start: 92 + end: 107 + order_by: ~ + - start: 92 + end: 107 + - start: 0 + end: 107 + next_statements: [] + - start: 0 + end: 107 + end: ~ + - start: 0 + end: 107 + session_close: false + - start: 0 + end: 107 diff --git a/minigu-test/gql/persistence/persistence_reopen@e2e.snap b/minigu-test/gql/persistence/persistence_reopen@e2e.snap new file mode 100644 index 000000000..9e6d10722 --- /dev/null +++ b/minigu-test/gql/persistence/persistence_reopen@e2e.snap @@ -0,0 +1,36 @@ +--- +source: minigu-test/src/insta_test.rs +--- +--- Phase 1: Initial data --- +┌──────────────────────────────────────────────┐ +│ p │ +│ vertex { name::string,age::int8 } │ +├──────────────────────────────────────────────┤ +│ {_vid: 0, _label: 0, name: person0, age: 20} │ +├──────────────────────────────────────────────┤ +│ {_vid: 1, _label: 0, name: person1, age: 21} │ +├──────────────────────────────────────────────┤ +│ {_vid: 2, _label: 0, name: person2, age: 22} │ +├──────────────────────────────────────────────┤ +│ {_vid: 3, _label: 0, name: person3, age: 23} │ +├──────────────────────────────────────────────┤ +│ {_vid: 4, _label: 0, name: person4, age: 24} │ +└──────────────────────────────────────────────┘ +5 rows + +--- Phase 2: After reopen --- +┌──────────────────────────────────────────────┐ +│ p │ +│ vertex { name::string,age::int8 } │ +├──────────────────────────────────────────────┤ +│ {_vid: 0, _label: 0, name: person0, age: 20} │ +├──────────────────────────────────────────────┤ +│ {_vid: 1, _label: 0, name: person1, age: 21} │ +├──────────────────────────────────────────────┤ +│ {_vid: 2, _label: 0, name: person2, age: 22} │ +├──────────────────────────────────────────────┤ +│ {_vid: 3, _label: 0, name: person3, age: 23} │ +├──────────────────────────────────────────────┤ +│ {_vid: 4, _label: 0, name: person4, age: 24} │ +└──────────────────────────────────────────────┘ +5 rows diff --git a/minigu-test/src/insta_test.rs b/minigu-test/src/insta_test.rs index 23460e269..c3af0d93e 100644 --- a/minigu-test/src/insta_test.rs +++ b/minigu-test/src/insta_test.rs @@ -288,7 +288,16 @@ add_e2e_tests!( ); add_e2e_tests!("dql", ["dql"]); add_e2e_tests!("dcl", ["session_set"]); -add_e2e_tests!("dml", ["insert", "match_and_insert", "match", "dml_dql"]); +add_e2e_tests!( + "dml", + [ + "insert", + "match_and_insert", + "match", + "match_filter", + "dml_dql" + ] +); add_e2e_tests!("misc", ["text2graph", "vector_index"]); add_e2e_tests!( "utility", @@ -320,7 +329,16 @@ add_parser_tests!( ); add_parser_tests!("dql", ["dql"]); add_parser_tests!("dcl", ["session_set"]); -add_parser_tests!("dml", ["insert", "match_and_insert", "match", "dml_dql"]); +add_parser_tests!( + "dml", + [ + "insert", + "match_and_insert", + "match", + "match_filter", + "dml_dql" + ] +); add_parser_tests!("misc", ["text2graph", "vector_index"]); add_parser_tests!( "utility", @@ -337,3 +355,60 @@ add_parser_tests!( "explain_vector_index_scan" ] ); + +// ============================================================================ +// Persistence tests: open database, create data, close, reopen, verify data +// ============================================================================ + +#[test] +fn e2e_persistence_reopen_database() { + let _guard = setup_insta_settings("e2e", "../gql/persistence/"); + + let temp_dir = tempdir().unwrap(); + let db_path = temp_dir.path().join("test_db"); + + // Phase 1: Create database, create graph with data, close + let result_phase1 = { + let config = DatabaseConfig { + db_path: Some(db_path.clone()), + ..Default::default() + }; + let db = Database::open(db_path.clone(), config).unwrap(); + let mut session = db.session().unwrap(); + + // Create test graph with data + session + .query("CALL create_test_graph_data('g', 10)") + .unwrap(); + session.query("SESSION SET GRAPH g").unwrap(); + + // Query to verify data exists + let result = session.query("MATCH (p:PERSON) RETURN p").unwrap(); + result_to_string(&result) + // session and db dropped here — data should be persisted + }; + + // Phase 2: Reopen the same database, verify data is still there + let result_phase2 = { + let config = DatabaseConfig { + db_path: Some(db_path.clone()), + ..Default::default() + }; + let db = Database::open(db_path.clone(), config).unwrap(); + let mut session = db.session().unwrap(); + + // Set current graph (should be loaded from catalog.json) + session.query("SESSION SET GRAPH g").unwrap(); + + // Query again — should return the same data + let result = session.query("MATCH (p:PERSON) RETURN p").unwrap(); + result_to_string(&result) + }; + + // Both phases should produce the same output + let output = format!( + "--- Phase 1: Initial data ---\n{}\n--- Phase 2: After reopen ---\n{}", + result_phase1, result_phase2 + ); + assert_snapshot!("persistence_reopen", &output); +} diff --git a/minigu/core/src/catalog_persistence.rs b/minigu/core/src/catalog_persistence.rs new file mode 100644 index 000000000..226ac6f5b --- /dev/null +++ b/minigu/core/src/catalog_persistence.rs @@ -0,0 +1,233 @@ +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use minigu_catalog::label_set::LabelSet; +use minigu_catalog::memory::graph_type::{ + MemoryEdgeTypeCatalog, MemoryGraphTypeCatalog, MemoryVertexTypeCatalog, +}; +use minigu_catalog::memory::schema::MemorySchemaCatalog; +use minigu_catalog::property::Property; +use minigu_catalog::provider::{ + EdgeTypeProvider, GraphTypeProvider, PropertiesProvider, SchemaProvider, VertexTypeProvider, +}; +use minigu_common::types::LabelId; +use minigu_context::graph::GraphContainer; +use serde::{Deserialize, Serialize}; + +use crate::error::Result; + +const CATALOG_FILE: &str = "catalog.json"; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct DatabaseCatalog { + pub graphs: HashMap, +} +#[derive(Debug, Serialize, Deserialize)] +pub struct GraphCatalogEntry { + pub labels: Vec, + pub vertex_types: Vec, + pub edge_types: Vec, +} + +/// A label name with its original ID, so we can restore the exact same mapping. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LabelDef { + pub id: u32, + pub name: String, +} + +/// Vertex type definition (label + properties). +#[derive(Debug, Serialize, Deserialize)] +pub struct VertexTypeDef { + pub label: String, + pub properties: Vec, +} + +/// Edge type definition (label + src/dst labels + properties). +#[derive(Debug, Serialize, Deserialize)] +pub struct EdgeTypeDef { + pub label: String, + pub src_label: String, + pub dst_label: String, + pub properties: Vec, +} + +/// Save the database catalog to `/catalog.json`. +pub fn save_catalog(db_path: &Path, schema: &MemorySchemaCatalog) -> Result<()> { + let mut catalog = DatabaseCatalog::default(); + + for graph_name in schema.graph_names() { + if let Ok(Some(graph_ref)) = schema.get_graph(&graph_name) { + let container = minigu_catalog::provider::GraphProvider::as_any(graph_ref.as_ref()) + .downcast_ref::() + .expect("graph should be a GraphContainer"); + let graph_type = container.graph_type(); + let entry = graph_type_to_catalog_entry(&graph_type); + catalog.graphs.insert(graph_name, entry); + } + } + + let json = serde_json::to_string_pretty(&catalog)?; + std::fs::write(db_path.join(CATALOG_FILE), json)?; + Ok(()) +} + +/// Load the database catalog from `/catalog.json`. +/// Returns `None` if the file does not exist. +pub fn load_catalog(db_path: &Path) -> Result> { + let path = db_path.join(CATALOG_FILE); + if !path.exists() { + return Ok(None); + } + let data = std::fs::read_to_string(path)?; + let catalog: DatabaseCatalog = serde_json::from_str(&data)?; + Ok(Some(catalog)) +} + +/// Extract type definitions from a `MemoryGraphTypeCatalog`. +fn graph_type_to_catalog_entry(graph_type: &MemoryGraphTypeCatalog) -> GraphCatalogEntry { + // Build reverse map: LabelId → label name + let label_names = graph_type.label_names(); + let mut id_to_name: HashMap = HashMap::new(); + for name in &label_names { + if let Ok(Some(id)) = graph_type.get_label_id(name) { + id_to_name.insert(id, name.clone()); + } + } + + // Save all labels with their IDs + let mut labels: Vec = id_to_name + .iter() + .map(|(id, name)| LabelDef { + id: id.get(), + name: name.clone(), + }) + .collect(); + labels.sort_by_key(|l| l.id); + + // Extract vertex types + let mut vertex_types = Vec::new(); + for label_set in graph_type.vertex_type_keys() { + if let Ok(Some(vt)) = graph_type.get_vertex_type(&label_set) { + let label_id = label_set.first().expect("vertex should have a label"); + let label = id_to_name + .get(&label_id) + .expect("label name should exist") + .clone(); + let properties: Vec = vt.properties().into_iter().map(|(_, p)| p).collect(); + vertex_types.push(VertexTypeDef { label, properties }); + } + } + // Sort for deterministic output + vertex_types.sort_by(|a, b| a.label.cmp(&b.label)); + + // Extract edge types + let mut edge_types = Vec::new(); + for label_set in graph_type.edge_type_keys() { + if let Ok(Some(et)) = graph_type.get_edge_type(&label_set) { + let label_id = label_set.first().expect("edge should have a label"); + let label = id_to_name + .get(&label_id) + .expect("label name should exist") + .clone(); + + let src_label_set = et.src().label_set(); + let src_label_id = src_label_set.first().expect("src should have a label"); + let src_label = id_to_name + .get(&src_label_id) + .expect("src label name should exist") + .clone(); + + let dst_label_set = et.dst().label_set(); + let dst_label_id = dst_label_set.first().expect("dst should have a label"); + let dst_label = id_to_name + .get(&dst_label_id) + .expect("dst label name should exist") + .clone(); + + let properties: Vec = et.properties().into_iter().map(|(_, p)| p).collect(); + edge_types.push(EdgeTypeDef { + label, + src_label, + dst_label, + properties, + }); + } + } + edge_types.sort_by(|a, b| a.label.cmp(&b.label)); + + GraphCatalogEntry { + labels, + vertex_types, + edge_types, + } +} + +/// Reconstruct a `MemoryGraphTypeCatalog` from a `GraphCatalogEntry`. +pub fn catalog_entry_to_graph_type(entry: &GraphCatalogEntry) -> Arc { + let mut graph_type = MemoryGraphTypeCatalog::new(); + + // Add all labels in ID order so they get the same IDs as the original. + // `add_label` assigns sequential IDs starting from 1, so adding in sorted + // ID order reproduces the original mapping. + let mut sorted_labels = entry.labels.clone(); + sorted_labels.sort_by_key(|l| l.id); + for label_def in &sorted_labels { + let label_id = graph_type + .add_label(label_def.name.clone()) + .expect("add label failed"); + debug_assert_eq!(label_id.get(), label_def.id, "label ID mismatch on restore"); + } + + // Build label name → vertex type map for edge type src/dst references + let mut label_vertex_type: HashMap> = HashMap::new(); + + // Create vertex types + for vt_def in &entry.vertex_types { + let label_id = graph_type + .get_label_id(&vt_def.label) + .expect("get label failed") + .expect("label should exist"); + let label_set = LabelSet::from_iter(vec![label_id]); + let vertex_type = Arc::new(MemoryVertexTypeCatalog::new( + label_set.clone(), + vt_def.properties.clone(), + )); + graph_type.add_vertex_type(label_set, Arc::clone(&vertex_type)); + label_vertex_type.insert(vt_def.label.clone(), vertex_type); + } + + // Create edge types + for et_def in &entry.edge_types { + let label_id = graph_type + .get_label_id(&et_def.label) + .expect("get label failed") + .expect("label should exist"); + let label_set = LabelSet::from_iter(vec![label_id]); + + let src_type = label_vertex_type + .get(&et_def.src_label) + .expect("src vertex type not found") + .clone(); + let dst_type = label_vertex_type + .get(&et_def.dst_label) + .expect("dst vertex type not found") + .clone(); + + let edge_type = MemoryEdgeTypeCatalog::new( + label_set.clone(), + src_type, + dst_type, + et_def.properties.clone(), + ); + graph_type.add_edge_type(label_set, Arc::new(edge_type)); + } + + Arc::new(graph_type) +} + +/// Get the `.minigu` file path for a graph. +pub fn graph_data_path(db_path: &Path, graph_name: &str) -> std::path::PathBuf { + db_path.join(format!("{}.minigu", graph_name)) +} diff --git a/minigu/core/src/database.rs b/minigu/core/src/database.rs index 01edb7181..bba5d36a4 100644 --- a/minigu/core/src/database.rs +++ b/minigu/core/src/database.rs @@ -1,16 +1,18 @@ -use std::env; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use minigu_catalog::memory::MemoryCatalog; use minigu_catalog::memory::directory::MemoryDirectoryCatalog; use minigu_catalog::memory::schema::MemorySchemaCatalog; -use minigu_catalog::provider::{CatalogProvider, DirectoryOrSchema, SchemaRef}; +use minigu_catalog::provider::DirectoryOrSchema; use minigu_common::constants::DEFAULT_SCHEMA_NAME; pub use minigu_context::database::DatabaseConfig; use minigu_context::database::DatabaseContext; +use minigu_context::graph::{GraphContainer, GraphStorage}; +use minigu_storage::tp::MemoryGraph; use rayon::ThreadPoolBuilder; +use crate::catalog_persistence::{catalog_entry_to_graph_type, graph_data_path, load_catalog}; use crate::error::Result; use crate::procedures::build_predefined_procedures; use crate::session::Session; @@ -21,8 +23,44 @@ pub struct Database { } impl Database { - pub fn open>(_path: P, _config: DatabaseConfig) -> Result { - todo!("on-disk database is not implemented yet") + /// Open (or create) an on-disk database at the given directory path. + /// + /// The directory will be created if it does not exist. On subsequent opens, + /// the catalog and graph data are restored from disk. + pub fn open>(path: P, config: DatabaseConfig) -> Result { + let db_path = path.as_ref().to_path_buf(); + + // Create the database directory if it doesn't exist + if !db_path.exists() { + std::fs::create_dir_all(&db_path)?; + } + + let (catalog, default_schema) = init_memory_catalog()?; + + // Load catalog from disk and restore graphs + if let Some(db_catalog) = load_catalog(&db_path)? { + for (graph_name, entry) in &db_catalog.graphs { + let data_path = graph_data_path(&db_path, graph_name); + let graph = MemoryGraph::with_db_file(&data_path)?; + let graph_type = catalog_entry_to_graph_type(entry); + let container = + Arc::new(GraphContainer::new(graph_type, GraphStorage::Memory(graph))); + default_schema.add_graph(graph_name.clone(), container); + } + } + + let config = DatabaseConfig { + db_path: Some(db_path), + ..config + }; + let runtime = ThreadPoolBuilder::new() + .num_threads(config.num_threads) + .build()?; + let context = Arc::new(DatabaseContext::new(catalog, runtime, config)); + Ok(Self { + context, + default_schema, + }) } pub fn open_in_memory(config: DatabaseConfig) -> Result { diff --git a/minigu/core/src/error.rs b/minigu/core/src/error.rs index f79e8a2ad..380018365 100644 --- a/minigu/core/src/error.rs +++ b/minigu/core/src/error.rs @@ -32,6 +32,15 @@ pub enum Error { #[error(transparent)] #[diagnostic(transparent)] NotImplemented(#[from] NotImplemented), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + #[error("storage error: {0}")] + Storage(#[from] minigu_storage::error::StorageError), } pub type Result = std::result::Result; diff --git a/minigu/core/src/lib.rs b/minigu/core/src/lib.rs index f105717ba..cc266febf 100644 --- a/minigu/core/src/lib.rs +++ b/minigu/core/src/lib.rs @@ -1,6 +1,7 @@ #![feature(impl_trait_in_assoc_type)] #![allow(unused)] +pub mod catalog_persistence; pub mod database; pub mod error; pub mod metrics; diff --git a/minigu/core/src/procedures/create_test_graph_data.rs b/minigu/core/src/procedures/create_test_graph_data.rs index 6c69b2d02..e5945dfb4 100644 --- a/minigu/core/src/procedures/create_test_graph_data.rs +++ b/minigu/core/src/procedures/create_test_graph_data.rs @@ -16,6 +16,8 @@ use minigu_storage::tp::MemoryGraph; use minigu_transaction::IsolationLevel::Serializable; use minigu_transaction::{GraphTxnManager, Transaction}; +use crate::catalog_persistence; + /// Creates a test graph with multiple vertex types (PERSON, COMPANY, CITY) and edge types (FRIEND, /// WORKS_AT, LOCATED_IN) with sample data. pub fn build_procedure() -> Procedure { @@ -44,7 +46,14 @@ pub fn build_procedure() -> Procedure { .as_ref() .ok_or_else(|| anyhow::anyhow!("current schema not set"))?; - let graph = MemoryGraph::in_memory(); + // Use file-backed storage when db_path is set, otherwise in-memory + let db_path = context.database().config().db_path.clone(); + let graph = if let Some(ref db_path) = db_path { + let data_path = catalog_persistence::graph_data_path(db_path, &graph_name); + MemoryGraph::with_db_file(&data_path)? + } else { + MemoryGraph::in_memory() + }; let mut graph_type = MemoryGraphTypeCatalog::new(); // Add labels @@ -281,6 +290,12 @@ pub fn build_procedure() -> Procedure { } txn.commit()?; + + // Persist catalog if using on-disk database + if let Some(ref db_path) = db_path { + catalog_persistence::save_catalog(db_path, schema)?; + } + Ok(vec![]) }) } diff --git a/minigu/core/src/procedures/export_graph.rs b/minigu/core/src/procedures/export_graph.rs index dad64e0e5..19eed9505 100644 --- a/minigu/core/src/procedures/export_graph.rs +++ b/minigu/core/src/procedures/export_graph.rs @@ -657,7 +657,7 @@ mod tests { { let manifest_path = export_dir1.join(manifest_rel_path); - let (graph, graph_type) = import_internal(manifest_path).unwrap(); + let (graph, graph_type) = import_internal(manifest_path, None, "test").unwrap(); export( graph, diff --git a/minigu/core/src/procedures/import_graph.rs b/minigu/core/src/procedures/import_graph.rs index 7d9f5e26b..a2a97b5d5 100644 --- a/minigu/core/src/procedures/import_graph.rs +++ b/minigu/core/src/procedures/import_graph.rs @@ -35,7 +35,7 @@ //! schema mismatch, duplicate graph name, etc.) are surfaced via `Result`. use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::str::FromStr; use std::sync::Arc; @@ -46,7 +46,7 @@ use minigu_catalog::memory::graph_type::{ }; use minigu_catalog::property::Property; use minigu_catalog::provider::{GraphTypeProvider, SchemaProvider}; -use minigu_common::data_type::{DataSchema, LogicalType}; +use minigu_common::data_type::LogicalType; use minigu_common::error::not_implemented; use minigu_common::types::VertexId; use minigu_common::value::ScalarValue; @@ -57,7 +57,7 @@ use minigu_storage::common::{Edge, PropertyRecord, Vertex}; use minigu_storage::tp::MemoryGraph; use minigu_transaction::{GraphTxnManager, IsolationLevel, Transaction}; -use super::common::{EdgeSpec, FileSpec, Manifest, RecordType, Result, VertexSpec}; +use super::common::{Manifest, Result}; // ============================================================================ // Import-specific implementation @@ -138,7 +138,9 @@ pub fn import>( return Err(anyhow::anyhow!("graph {graph_name} already exists").into()); } - let (graph, graph_type) = import_internal(manifest_path.as_ref())?; + let db_path = context.database().config().db_path.clone(); + let (graph, graph_type) = + import_internal(manifest_path.as_ref(), db_path.as_deref(), &graph_name)?; let container = GraphContainer::new( Arc::clone(&graph_type), @@ -149,18 +151,30 @@ pub fn import>( return Err(anyhow::anyhow!("graph {graph_name} already exists").into()); } + // Persist catalog if using on-disk database + if let Some(ref db_path) = db_path { + crate::catalog_persistence::save_catalog(db_path, schema)?; + } + Ok(()) } pub(crate) fn import_internal>( manifest_path: P, + db_path: Option<&Path>, + graph_name: &str, ) -> Result<(Arc, Arc)> { // Graph type let manifest = build_manifest(&manifest_path)?; let graph_type = get_graph_type_from_manifest(&manifest)?; - // Graph - let graph = MemoryGraph::in_memory(); + // Graph - use file-backed storage when db_path is set + let graph = if let Some(db_path) = db_path { + let data_path = crate::catalog_persistence::graph_data_path(db_path, graph_name); + MemoryGraph::with_db_file(&data_path)? + } else { + MemoryGraph::in_memory() + }; let txn = graph .txn_manager() .begin_transaction(IsolationLevel::Serializable)?; diff --git a/minigu/gql/execution/src/builder.rs b/minigu/gql/execution/src/builder.rs index 4083d809c..cd8c7d7dd 100644 --- a/minigu/gql/execution/src/builder.rs +++ b/minigu/gql/execution/src/builder.rs @@ -8,10 +8,11 @@ use minigu_common::data_type::{DataField, DataSchema, LogicalType}; use minigu_common::types::VertexIdArray; use minigu_context::graph::GraphContainer; use minigu_context::session::SessionContext; -use minigu_planner::bound::{BoundExpr, BoundExprKind}; +use minigu_planner::bound::{BoundBinaryOp, BoundExpr, BoundExprKind}; use minigu_planner::plan::{PlanData, PlanNode}; use crate::evaluator::BoxedEvaluator; +use crate::evaluator::binary::{Binary, BinaryOp}; use crate::evaluator::column_ref::ColumnRef; use crate::evaluator::constant::Constant; use crate::evaluator::vector_distance::VectorDistanceEvaluator; @@ -36,25 +37,94 @@ impl ExecutorBuilder { } pub fn build(self, plan: &PlanNode) -> BoxedExecutor { - self.build_executor(plan) + self.build_executor(plan).0 } - fn build_executor(&self, physical_plan: &PlanNode) -> BoxedExecutor { + /// Returns (executor, actual_output_schema). + /// The actual schema may differ from the plan schema when property columns + /// are added at executor-build time (e.g., by Filter for WHERE predicates). + fn build_executor(&self, physical_plan: &PlanNode) -> (BoxedExecutor, Arc) { let children = physical_plan.children(); match physical_plan { PlanNode::PhysicalFilter(filter) => { assert_eq!(children.len(), 1); - let schema = children[0].schema().expect("child should have a schema"); - let predicate = self.build_evaluator(&filter.predicate, schema); - Box::new(self.build_executor(&children[0]).filter(move |c| { + let (mut child_executor, child_schema) = self.build_executor(&children[0]); + let mut updated_schema = child_schema.clone(); + + // Insert property scans for vertex variables accessed via Property expressions + let property_sources = collect_property_sources(&filter.predicate); + for var_name in &property_sources { + if let Some(field) = child_schema.get_field_by_name(var_name) + && matches!(field.ty(), LogicalType::Int64) + { + let vid_index = child_schema + .get_field_index_by_name(var_name) + .expect("variable should be present in child schema"); + + let container: Arc = self + .session + .current_graph + .clone() + .expect("current graph should be set") + .object() + .clone() + .downcast_arc::() + .expect("failed to downcast to GraphContainer"); + + let mut property_names = Vec::new(); + let property_list = if let Some(label_specs) = + child_schema.get_var_label(var_name) + { + let graph_type = container.graph_type(); + let mut property_ids = Vec::new(); + if let Some(first_label_set) = label_specs.first() + && let Ok(Some(vertex_type)) = graph_type + .get_vertex_type(&LabelSet::from_iter(first_label_set.clone())) + { + for property in vertex_type.properties().iter() { + property_ids.push(property.0); + property_names.push(property.1.name().to_string()); + } + } + property_ids + } else { + Vec::new() + }; + + child_executor = Box::new(child_executor.scan_vertex_property( + vid_index, + property_list, + container, + )); + + let mut new_fields = updated_schema.fields().to_vec(); + for prop_name in &property_names { + let qualified_name = format!("{}_{}", var_name, prop_name); + new_fields.push(DataField::new( + qualified_name, + LogicalType::String, + true, + )); + } + updated_schema = Arc::new(DataSchema::new(new_fields)); + } + } + + let predicate = self.build_evaluator(&filter.predicate, &updated_schema); + let executor = Box::new(child_executor.filter(move |c| { predicate .evaluate(c) .map(|a| a.into_array().as_boolean().clone()) - })) + })); + (executor, updated_schema) } PlanNode::PhysicalNodeScan(node_scan) => { // NodeScan provide graph id and label, Handle in next pr. assert_eq!(children.len(), 0); + let plan_schema = physical_plan + .schema() + .expect("NodeScan should have a schema") + .clone(); let container: Arc = self .session .current_graph @@ -70,11 +140,15 @@ impl ExecutorBuilder { .vertex_source(&Some(node_scan.labels.clone()), 1024) .expect("failed to create vertex source"); let source = batches.map(|arr: Arc| Ok(arr)); - Box::new(source.scan_vertex()) + (Box::new(source.scan_vertex()), plan_schema) } PlanNode::PhysicalExpand(expand) => { assert_eq!(children.len(), 1); - let child = self.build_executor(&children[0]); + let plan_schema = physical_plan + .schema() + .expect("Expand should have a schema") + .clone(); + let (child, child_actual_schema) = self.build_executor(&children[0]); let container: Arc = self .session .current_graph @@ -85,9 +159,8 @@ impl ExecutorBuilder { .downcast_arc::() .expect("failed to downcast to GraphContainer"); - // Get the number of columns before expand - let child_schema = children[0].schema().expect("child should have a schema"); - let num_child_columns = child_schema.fields().len(); + // Get the number of columns before expand (use actual schema) + let num_child_columns = child_actual_schema.fields().len(); // Expand adds new columns (as ListArray) that need to be flattened. // ExpandSource returns 2 columns: edge IDs and target vertex IDs @@ -101,15 +174,18 @@ impl ExecutorBuilder { ); let column_indices_to_flatten: Vec = (num_child_columns..num_child_columns + 2).collect(); - Box::new(expand_executor.flatten(column_indices_to_flatten)) + ( + Box::new(expand_executor.flatten(column_indices_to_flatten)), + plan_schema, + ) } PlanNode::PhysicalProject(project) => { assert_eq!(children.len(), 1); - let child_schema = children[0].schema().expect("child should have a schema"); - let mut child_executor = self.build_executor(&children[0]); + let (mut child_executor, child_actual_schema) = self.build_executor(&children[0]); let output_schema = physical_plan.schema().expect("there should be a schema"); - let mut updated_schema = child_schema.clone(); + // Use actual child schema (includes any property columns added by Filter etc.) + let mut updated_schema = child_actual_schema; // Check if any expression is a Vertex type that needs properties // If output type is Vertex, we need to scan properties @@ -117,14 +193,50 @@ impl ExecutorBuilder { if let LogicalType::Vertex(_) = &expr.logical_type && let BoundExprKind::Variable(var_name) = &expr.kind { + // Check if properties are already scanned (e.g., by a child Filter) + let first_prop_qualified = if let Some(label_specs) = + output_schema.get_var_label(var_name.as_str()) + { + let container_tmp: Arc = self + .session + .current_graph + .clone() + .expect("current graph should be set") + .object() + .clone() + .downcast_arc::() + .expect("failed to downcast to GraphContainer"); + let graph_type = container_tmp.graph_type(); + if let Some(first_label_set) = label_specs.first() + && let Ok(Some(vertex_type)) = graph_type + .get_vertex_type(&LabelSet::from_iter(first_label_set.clone())) + { + vertex_type + .properties() + .first() + .map(|p| format!("{}_{}", var_name, p.1.name())) + } else { + None + } + } else { + None + }; + + // Skip if properties already exist in schema (scanned by child) + if let Some(ref first_prop) = first_prop_qualified + && updated_schema.get_field_index_by_name(first_prop).is_some() + { + continue; + } + // Check child schema to see if this variable only has id (Int64) - let child_field = child_schema + let child_field = updated_schema .get_field_by_name(var_name) .expect("variable should be present in child schema"); // If child schema only has id (Int64), need to add VertexPropertyScan if matches!(child_field.ty(), LogicalType::Int64) { - let vid_index = child_schema + let vid_index = updated_schema .get_field_index_by_name(var_name) .expect("variable should be present in child schema"); @@ -166,8 +278,6 @@ impl ExecutorBuilder { container, )); - // Format: {var_name}_{prop_name} to handle cases where multiple - // variables let mut new_fields = updated_schema.fields().to_vec(); for prop_name in property_names.iter() { let qualified_name = format!("{}_{}", var_name, prop_name); @@ -188,14 +298,25 @@ impl ExecutorBuilder { .iter() .map(|e| self.build_evaluator(e, &updated_schema)) .collect(); - Box::new(child_executor.project(evaluators)) + let output_schema = physical_plan + .schema() + .expect("there should be a schema") + .clone(); + (Box::new(child_executor.project(evaluators)), output_schema) } PlanNode::PhysicalCall(call) => { assert!(children.is_empty()); + let plan_schema = physical_plan + .schema() + .cloned() + .unwrap_or_else(|| Arc::new(DataSchema::new(vec![]))); let procedure = call.procedure.object().clone(); let session = self.session.clone(); let args = call.args.clone(); - Box::new(ProcedureCallBuilder::new(procedure, session, args).into_executor()) + ( + Box::new(ProcedureCallBuilder::new(procedure, session, args).into_executor()), + plan_schema, + ) } // We don't need an independent executor for PhysicalOneRow. Returning a chunk with a // single row is enough. @@ -208,54 +329,87 @@ impl ExecutorBuilder { assert!(!field.is_nullable()); let columns = vec![Arc::new(Int32Array::from_iter_values([0])) as _]; let chunk = DataChunk::new(columns); - Box::new([Ok(chunk)].into_executor()) + (Box::new([Ok(chunk)].into_executor()), (*schema).clone()) } PlanNode::PhysicalSort(sort) => { assert_eq!(children.len(), 1); - let schema = children[0].schema().expect("child should have a schema"); + let (child_executor, child_actual_schema) = self.build_executor(&children[0]); let specs = sort .specs .iter() .map(|s| { - let key = self.build_evaluator(&s.key, schema); + let key = self.build_evaluator(&s.key, &child_actual_schema); SortSpec::new(key, s.ordering, s.null_ordering) }) .collect(); - Box::new( - self.build_executor(&children[0]) - .sort(specs, DEFAULT_CHUNK_SIZE), + ( + Box::new(child_executor.sort(specs, DEFAULT_CHUNK_SIZE)), + child_actual_schema, ) } PlanNode::PhysicalLimit(limit) => { assert_eq!(children.len(), 1); - Box::new(self.build_executor(&children[0]).limit(limit.limit)) + let (child_executor, child_actual_schema) = self.build_executor(&children[0]); + ( + Box::new(child_executor.limit(limit.limit)), + child_actual_schema, + ) } PlanNode::PhysicalOffset(offset) => { assert_eq!(children.len(), 1); - Box::new(self.build_executor(&children[0]).offset(offset.offset)) + let (child_executor, child_actual_schema) = self.build_executor(&children[0]); + ( + Box::new(child_executor.offset(offset.offset)), + child_actual_schema, + ) } PlanNode::PhysicalVectorIndexScan(vector_scan) => { assert!(children.is_empty()); - VectorIndexScanBuilder::new(self.session.clone(), vector_scan.clone()) - .into_executor() + let plan_schema = physical_plan + .schema() + .expect("VectorIndexScan should have a schema") + .clone(); + ( + VectorIndexScanBuilder::new(self.session.clone(), vector_scan.clone()) + .into_executor(), + plan_schema, + ) } PlanNode::PhysicalExplain(explain) => { + let plan_schema = physical_plan + .schema() + .cloned() + .unwrap_or_else(|| Arc::new(DataSchema::new(vec![]))); let explain_str = explain.explain(0).unwrap_or_default(); let lines: Vec<&str> = explain_str.lines().collect(); let string_array = arrow::array::StringArray::from_iter_values(lines); let columns = vec![Arc::new(string_array) as _]; let chunk = DataChunk::new(columns); - Box::new([Ok(chunk)].into_executor()) + (Box::new([Ok(chunk)].into_executor()), plan_schema) } PlanNode::PhysicalCreateVectorIndex(create_index) => { assert!(children.is_empty()); - CreateVectorIndexBuilder::new(self.session.clone(), create_index.clone()) - .into_executor() + let plan_schema = physical_plan + .schema() + .cloned() + .unwrap_or_else(|| Arc::new(DataSchema::new(vec![]))); + ( + CreateVectorIndexBuilder::new(self.session.clone(), create_index.clone()) + .into_executor(), + plan_schema, + ) } PlanNode::PhysicalDropVectorIndex(drop_index) => { assert!(children.is_empty()); - DropVectorIndexBuilder::new(self.session.clone(), drop_index.clone()) - .into_executor() + let plan_schema = physical_plan + .schema() + .cloned() + .unwrap_or_else(|| Arc::new(DataSchema::new(vec![]))); + ( + DropVectorIndexBuilder::new(self.session.clone(), drop_index.clone()) + .into_executor(), + plan_schema, + ) } _ => unreachable!(), } @@ -302,6 +456,40 @@ impl ExecutorBuilder { .expect("variable should be present in the schema"); Box::new(ColumnRef::new(index)) } + BoundExprKind::Binary { op, left, right } => { + let left_eval = self.build_evaluator(left.as_ref(), schema); + let right_eval = self.build_evaluator(right.as_ref(), schema); + let binary_op = match op { + BoundBinaryOp::Add => BinaryOp::Add, + BoundBinaryOp::Sub => BinaryOp::Sub, + BoundBinaryOp::Mul => BinaryOp::Mul, + BoundBinaryOp::Div => BinaryOp::Div, + BoundBinaryOp::And => BinaryOp::And, + BoundBinaryOp::Or => BinaryOp::Or, + BoundBinaryOp::Lt => BinaryOp::Lt, + BoundBinaryOp::Le => BinaryOp::Le, + BoundBinaryOp::Gt => BinaryOp::Gt, + BoundBinaryOp::Ge => BinaryOp::Ge, + BoundBinaryOp::Eq => BinaryOp::Eq, + BoundBinaryOp::Ne => BinaryOp::Ne, + BoundBinaryOp::Concat | BoundBinaryOp::Xor => { + unimplemented!("concat and xor binary ops not yet supported in evaluator") + } + }; + Box::new(Binary::new(binary_op, left_eval, right_eval)) + } + BoundExprKind::Property { source, property } => { + let qualified_name = format!("{}_{}", source, property); + let index = schema + .get_field_index_by_name(&qualified_name) + .unwrap_or_else(|| { + panic!( + "property column '{}' should be present in schema", + qualified_name + ) + }); + Box::new(ColumnRef::new(index)) + } BoundExprKind::VectorDistance { lhs, rhs, @@ -315,3 +503,27 @@ impl ExecutorBuilder { } } } + +/// Collect unique vertex variable names that have property accesses in an expression. +fn collect_property_sources(expr: &BoundExpr) -> Vec { + let mut sources = Vec::new(); + collect_property_sources_impl(expr, &mut sources); + sources.sort(); + sources.dedup(); + sources +} + +fn collect_property_sources_impl(expr: &BoundExpr, sources: &mut Vec) { + match &expr.kind { + BoundExprKind::Property { source, .. } => sources.push(source.clone()), + BoundExprKind::Binary { left, right, .. } => { + collect_property_sources_impl(left, sources); + collect_property_sources_impl(right, sources); + } + BoundExprKind::VectorDistance { lhs, rhs, .. } => { + collect_property_sources_impl(lhs, sources); + collect_property_sources_impl(rhs, sources); + } + _ => {} + } +} diff --git a/minigu/gql/planner/src/binder/common.rs b/minigu/gql/planner/src/binder/common.rs index d2da962d3..fa6387a65 100644 --- a/minigu/gql/planner/src/binder/common.rs +++ b/minigu/gql/planner/src/binder/common.rs @@ -346,15 +346,10 @@ impl Binder<'_> { ))); } - let predicate = match &f.predicate { - None => None, - Some(sp) => None, - }; - Ok(BoundVertexPattern { var, label: label_set_vec, - predicate, + predicate: None, }) } diff --git a/minigu/gql/planner/src/binder/value_expr.rs b/minigu/gql/planner/src/binder/value_expr.rs index 16de6333a..3975e125f 100644 --- a/minigu/gql/planner/src/binder/value_expr.rs +++ b/minigu/gql/planner/src/binder/value_expr.rs @@ -18,7 +18,12 @@ use crate::bound::{BoundBinaryOp, BoundExpr, BoundUnsignedInteger}; impl Binder<'_> { pub fn bind_value_expression(&self, expr: &Expr) -> BindResult { match expr { - Expr::Binary { .. } => not_implemented("binary expression", None), + Expr::Binary { op, left, right } => { + let bound_left = self.bind_value_expression(left.value())?; + let bound_right = self.bind_value_expression(right.value())?; + let bound_op = bind_binary_op(op.value()); + Ok(BoundExpr::binary(bound_op, bound_left, bound_right)) + } Expr::Unary { .. } => not_implemented("unary expression", None), Expr::DurationBetween { .. } => not_implemented("duration between expression", None), Expr::Is { .. } => not_implemented("is expression", None), @@ -40,7 +45,45 @@ impl Binder<'_> { } Expr::Value(value) => bind_value(value), Expr::Path(_) => not_implemented("path expression", None), - Expr::Property { .. } => not_implemented("property expression", None), + Expr::Property { + source, + trailing_names, + } => { + if let Expr::Variable(var_name) = source.value() { + let schema = self + .active_data_schema + .as_ref() + .ok_or_else(|| BindError::VariableNotFound(var_name.clone()))?; + let field = schema + .get_field_by_name(var_name) + .ok_or_else(|| BindError::VariableNotFound(var_name.clone()))?; + if let LogicalType::Vertex(vertex_fields) = field.ty() { + if trailing_names.len() != 1 { + return not_implemented("chained property access", None); + } + let prop_name = trailing_names[0].value().as_str(); + let prop_field = vertex_fields + .iter() + .find(|f| f.name() == prop_name) + .ok_or_else(|| { + BindError::VariableNotFound( + format!("{}.{}", var_name, prop_name).into(), + ) + })?; + Ok(BoundExpr::property( + var_name.to_string(), + prop_name.to_string(), + prop_field.ty().clone(), + )) + } else { + Err(BindError::VariableNotFound( + format!("{} is not a vertex", var_name).into(), + )) + } + } else { + not_implemented("non-variable property source", None) + } + } Expr::Graph(_) => not_implemented("graph expression", None), } } diff --git a/minigu/gql/planner/src/bound/value_expr.rs b/minigu/gql/planner/src/bound/value_expr.rs index 34fd756c0..a4bdae481 100644 --- a/minigu/gql/planner/src/bound/value_expr.rs +++ b/minigu/gql/planner/src/bound/value_expr.rs @@ -9,6 +9,15 @@ use serde::Serialize; pub enum BoundExprKind { Value(ScalarValue), Variable(String), + Binary { + op: BoundBinaryOp, + left: Box, + right: Box, + }, + Property { + source: String, + property: String, + }, VectorDistance { lhs: Box, rhs: Box, @@ -23,6 +32,12 @@ impl Display for BoundExprKind { // TODO: Use `Display` rather than `Debug` representation for `value`. BoundExprKind::Value(value) => write!(f, "{value:?}"), BoundExprKind::Variable(variable) => write!(f, "{variable}"), + BoundExprKind::Binary { op, left, right } => { + write!(f, "({} {:?} {})", left, op, right) + } + BoundExprKind::Property { source, property } => { + write!(f, "{}.{}", source, property) + } BoundExprKind::VectorDistance { lhs, rhs, metric, .. } => { @@ -56,6 +71,42 @@ impl BoundExpr { } } + pub fn binary(op: BoundBinaryOp, left: BoundExpr, right: BoundExpr) -> Self { + let nullable = left.nullable || right.nullable; + let logical_type = match &op { + BoundBinaryOp::Lt + | BoundBinaryOp::Le + | BoundBinaryOp::Gt + | BoundBinaryOp::Ge + | BoundBinaryOp::Eq + | BoundBinaryOp::Ne + | BoundBinaryOp::And + | BoundBinaryOp::Or + | BoundBinaryOp::Xor => LogicalType::Boolean, + BoundBinaryOp::Concat => LogicalType::String, + BoundBinaryOp::Add | BoundBinaryOp::Sub | BoundBinaryOp::Mul | BoundBinaryOp::Div => { + left.logical_type.clone() + } + }; + Self { + kind: BoundExprKind::Binary { + op, + left: Box::new(left), + right: Box::new(right), + }, + logical_type, + nullable, + } + } + + pub fn property(source: String, property: String, logical_type: LogicalType) -> Self { + Self { + kind: BoundExprKind::Property { source, property }, + logical_type, + nullable: true, + } + } + pub fn vector_distance( lhs: BoundExpr, rhs: BoundExpr, diff --git a/minigu/gql/planner/src/optimizer/mod.rs b/minigu/gql/planner/src/optimizer/mod.rs index 5e6f369d3..8ac4ad291 100644 --- a/minigu/gql/planner/src/optimizer/mod.rs +++ b/minigu/gql/planner/src/optimizer/mod.rs @@ -32,9 +32,6 @@ impl Optimizer { } fn extract_path_pattern_from_graph_pattern(g: &BoundGraphPattern) -> PlanResult { - if g.predicate.is_some() { - return not_implemented("MATCH with predicate (WHERE) is not supported yet", Some(1)); - } if g.paths.len() != 1 { return not_implemented("multiple paths in MATCH are not supported yet", Some(1)); } @@ -115,37 +112,51 @@ fn create_physical_plan_impl(logical_plan: &PlanNode) -> PlanResult { .map(create_physical_plan_impl) .try_collect()?; match logical_plan { - PlanNode::LogicalMatch(m) => match extract_path_pattern_from_graph_pattern(&m.pattern)? { - PathPatternInfo::SingleVertex { var, label_specs } => { - let node = NodeIdScan::new(var.as_str(), label_specs); - Ok(PlanNode::PhysicalNodeScan(Arc::new(node))) - } - PathPatternInfo::Path { vertices, edges } => { - if vertices.is_empty() { - return not_implemented("empty path patterns", None); + PlanNode::LogicalMatch(m) => { + let mut plan = match extract_path_pattern_from_graph_pattern(&m.pattern)? { + PathPatternInfo::SingleVertex { var, label_specs } => { + let node = NodeIdScan::new(var.as_str(), label_specs); + PlanNode::PhysicalNodeScan(Arc::new(node)) } - let (first_var, first_labels) = vertices[0].clone(); - let mut current_plan = PlanNode::PhysicalNodeScan(Arc::new(NodeIdScan::new( - first_var.as_str(), - first_labels, - ))); - for (edge_info, next_vertex) in edges.iter().zip(vertices.iter().skip(1)) { - let (edge_var, edge_labels, direction) = edge_info; - let (next_var, next_labels) = next_vertex; - let expand = Expand::new( - current_plan.clone(), - 0, - edge_labels.clone(), - Some(next_labels.clone()), - edge_var.clone(), - Some(next_var.clone()), - direction.clone(), - ); - current_plan = PlanNode::PhysicalExpand(Arc::new(expand)); + PathPatternInfo::Path { vertices, edges } => { + if vertices.is_empty() { + return not_implemented("empty path patterns", None); + } + let (first_var, first_labels) = vertices[0].clone(); + let mut current_plan = PlanNode::PhysicalNodeScan(Arc::new(NodeIdScan::new( + first_var.as_str(), + first_labels, + ))); + // After NodeScan, the source vertex is at column 0. + // Each expand+flatten adds 2 columns (edge, target vertex), + // so the target vertex of the Nth expand is at column 2*N. + let mut input_column_index = 0; + for (edge_info, next_vertex) in edges.iter().zip(vertices.iter().skip(1)) { + let (edge_var, edge_labels, direction) = edge_info; + let (next_var, next_labels) = next_vertex; + let expand = Expand::new( + current_plan.clone(), + input_column_index, + edge_labels.clone(), + Some(next_labels.clone()), + edge_var.clone(), + Some(next_var.clone()), + direction.clone(), + ); + current_plan = PlanNode::PhysicalExpand(Arc::new(expand)); + // After this expand+flatten, the target vertex is 2 columns + // after the current input column (edge + target vertex). + input_column_index += 2; + } + current_plan } - Ok(current_plan) + }; + if let Some(predicate) = &m.pattern.predicate { + let filter = Filter::new(plan, predicate.clone()); + plan = PlanNode::PhysicalFilter(Arc::new(filter)); } - }, + Ok(plan) + } PlanNode::LogicalFilter(filter) => { let [child] = children .try_into() diff --git a/minigu/gql/planner/src/plan/expand.rs b/minigu/gql/planner/src/plan/expand.rs index 8f3d974dc..bd212321f 100644 --- a/minigu/gql/planner/src/plan/expand.rs +++ b/minigu/gql/planner/src/plan/expand.rs @@ -66,9 +66,26 @@ impl Expand { } } - let schema = Some(Arc::new(DataSchema::new(new_fields))); + let mut schema = DataSchema::new(new_fields); + + // Propagate var_labels from child schema + if let Some(child_schema) = child.schema() { + for field in child_schema.fields() { + if let Some(labels) = child_schema.get_var_label(field.name()) { + schema.set_var_label(field.name().to_string(), labels); + } + } + } + + // Set var_label for target vertex + if let Some(vertex_var) = &target_vertex_var + && let Some(labels) = &target_vertex_labels + { + schema.set_var_label(vertex_var.clone(), labels.clone()); + } + let base = PlanBase { - schema, + schema: Some(Arc::new(schema)), children: vec![child], }; Self { diff --git a/minigu/gql/planner/src/plan/scan.rs b/minigu/gql/planner/src/plan/scan.rs index 6899bc2e9..ccca89a8b 100644 --- a/minigu/gql/planner/src/plan/scan.rs +++ b/minigu/gql/planner/src/plan/scan.rs @@ -21,7 +21,8 @@ impl NodeIdScan { pub fn new(var: &str, labels: Vec>) -> Self { // For Single Node Scan, We just assume the id is only needed. let field = DataField::new(var.to_string(), LogicalType::Int64, false); - let schema = DataSchema::new(vec![field]); + let mut schema = DataSchema::new(vec![field]); + schema.set_var_label(var.to_string(), labels.clone()); let base = PlanBase { schema: Some(Arc::new(schema)), children: vec![],