|
| 1 | +//! UNWIND-driven bulk-ingestion tests. |
| 2 | +//! |
| 3 | +//! Covers the playground's canonical "fill the graph from a range" pattern: |
| 4 | +//! `UNWIND range(...) AS id CREATE (n {id, computed properties...})`. The |
| 5 | +//! shapes here are exactly what a user pastes into the playground when |
| 6 | +//! seeding a graph for exploration — Cypher-style function names, a CASE |
| 7 | +//! expression in the property map, and a non-trivial per-row workload. |
| 8 | +
|
| 9 | +mod test_helpers; |
| 10 | + |
| 11 | +use serde_json::Value as JsonValue; |
| 12 | +use test_helpers::TestDb; |
| 13 | + |
| 14 | +// ============================================================ |
| 15 | +// Cypher-compat aliases — these are the names a Neo4j user reaches for first |
| 16 | +// and must resolve to the canonical namespaced builtin without the analyzer |
| 17 | +// raising UnknownFunction. |
| 18 | +// ============================================================ |
| 19 | + |
| 20 | +#[test] |
| 21 | +fn range_alias_resolves_to_list_range() { |
| 22 | + let db = TestDb::new(); |
| 23 | + let rows = db.run("UNWIND range(1, 5) AS k RETURN k"); |
| 24 | + assert_eq!(rows.len(), 5); |
| 25 | + assert_eq!( |
| 26 | + rows.iter() |
| 27 | + .map(|r| r["k"].as_i64().unwrap()) |
| 28 | + .collect::<Vec<_>>(), |
| 29 | + vec![1, 2, 3, 4, 5], |
| 30 | + ); |
| 31 | +} |
| 32 | + |
| 33 | +#[test] |
| 34 | +fn range_alias_supports_step_argument() { |
| 35 | + let db = TestDb::new(); |
| 36 | + let rows = db.run("UNWIND range(0, 10, 2) AS k RETURN k"); |
| 37 | + let values: Vec<i64> = rows.iter().map(|r| r["k"].as_i64().unwrap()).collect(); |
| 38 | + assert_eq!(values, vec![0, 2, 4, 6, 8, 10]); |
| 39 | +} |
| 40 | + |
| 41 | +#[test] |
| 42 | +fn rand_alias_resolves_to_math_random() { |
| 43 | + let db = TestDb::new(); |
| 44 | + let rows = db.run("RETURN rand() AS r"); |
| 45 | + let r = rows[0]["r"].as_f64().unwrap(); |
| 46 | + assert!((0.0..1.0).contains(&r), "rand() outside [0,1): {r}"); |
| 47 | +} |
| 48 | + |
| 49 | +#[test] |
| 50 | +fn datetime_alias_resolves_to_temporal_now() { |
| 51 | + let db = TestDb::new(); |
| 52 | + let rows = db.run("RETURN datetime() AS t"); |
| 53 | + // temporal.now() is rendered as a typed datetime object; the shape |
| 54 | + // and exact representation belong to temporal.rs — here we just assert |
| 55 | + // the alias resolved (no error) and produced a non-null value. |
| 56 | + assert!(!rows[0]["t"].is_null(), "datetime() returned null"); |
| 57 | +} |
| 58 | + |
| 59 | +// ============================================================ |
| 60 | +// The playground's canonical UNWIND-CREATE pattern. |
| 61 | +// ============================================================ |
| 62 | + |
| 63 | +#[test] |
| 64 | +fn unwind_range_creates_nodes_with_properties() { |
| 65 | + let db = TestDb::new(); |
| 66 | + db.run( |
| 67 | + "WITH range(1, 100) AS ids \ |
| 68 | + UNWIND ids AS id \ |
| 69 | + CREATE (n:TestRecord { \ |
| 70 | + id: id, \ |
| 71 | + name: 'Record ' + toString(id), \ |
| 72 | + createdAt: datetime(), \ |
| 73 | + randomValue: rand(), \ |
| 74 | + status: CASE \ |
| 75 | + WHEN id % 3 = 0 THEN 'ACTIVE' \ |
| 76 | + WHEN id % 3 = 1 THEN 'PENDING' \ |
| 77 | + ELSE 'ARCHIVED' \ |
| 78 | + END \ |
| 79 | + })", |
| 80 | + ); |
| 81 | + |
| 82 | + db.assert_count("MATCH (n:TestRecord) RETURN n", 100); |
| 83 | +} |
| 84 | + |
| 85 | +#[test] |
| 86 | +fn unwind_range_case_expression_distributes_across_buckets() { |
| 87 | + let db = TestDb::new(); |
| 88 | + db.run( |
| 89 | + "UNWIND range(1, 30) AS id \ |
| 90 | + CREATE (:Bucketed { \ |
| 91 | + id: id, \ |
| 92 | + status: CASE \ |
| 93 | + WHEN id % 3 = 0 THEN 'ACTIVE' \ |
| 94 | + WHEN id % 3 = 1 THEN 'PENDING' \ |
| 95 | + ELSE 'ARCHIVED' \ |
| 96 | + END \ |
| 97 | + })", |
| 98 | + ); |
| 99 | + |
| 100 | + // 30 ids, divisible by 3 → 10 in each bucket. |
| 101 | + let active = db |
| 102 | + .exec_count("MATCH (n:Bucketed {status:'ACTIVE'}) RETURN n") |
| 103 | + .unwrap(); |
| 104 | + let pending = db |
| 105 | + .exec_count("MATCH (n:Bucketed {status:'PENDING'}) RETURN n") |
| 106 | + .unwrap(); |
| 107 | + let archived = db |
| 108 | + .exec_count("MATCH (n:Bucketed {status:'ARCHIVED'}) RETURN n") |
| 109 | + .unwrap(); |
| 110 | + assert_eq!((active, pending, archived), (10, 10, 10)); |
| 111 | +} |
| 112 | + |
| 113 | +#[test] |
| 114 | +fn unwind_range_tostring_concatenation_produces_unique_names() { |
| 115 | + let db = TestDb::new(); |
| 116 | + db.run( |
| 117 | + "UNWIND range(1, 50) AS id \ |
| 118 | + CREATE (:Named { id: id, name: 'Record ' + toString(id) })", |
| 119 | + ); |
| 120 | + |
| 121 | + // Properties are unique per id, so name distinct count == row count. |
| 122 | + let names: Vec<JsonValue> = db.column("MATCH (n:Named) RETURN n.name AS name", "name"); |
| 123 | + let mut set = std::collections::BTreeSet::new(); |
| 124 | + for n in &names { |
| 125 | + set.insert(n.as_str().unwrap().to_string()); |
| 126 | + } |
| 127 | + assert_eq!(set.len(), 50); |
| 128 | + assert!(set.contains("Record 1")); |
| 129 | + assert!(set.contains("Record 50")); |
| 130 | +} |
| 131 | + |
| 132 | +#[test] |
| 133 | +fn unwind_range_returning_created_nodes_yields_one_row_per_id() { |
| 134 | + let db = TestDb::new(); |
| 135 | + let rows = db.run( |
| 136 | + "UNWIND range(1, 25) AS id \ |
| 137 | + CREATE (n:Returned { id: id }) \ |
| 138 | + RETURN n.id AS id ORDER BY id", |
| 139 | + ); |
| 140 | + assert_eq!(rows.len(), 25); |
| 141 | + for (i, row) in rows.iter().enumerate() { |
| 142 | + assert_eq!(row["id"].as_i64().unwrap() as usize, i + 1); |
| 143 | + } |
| 144 | +} |
| 145 | + |
| 146 | +// ============================================================ |
| 147 | +// Larger scale: the upper end of what the playground will plausibly accept |
| 148 | +// in a single statement. Kept modest (10k) because the parser/analyzer |
| 149 | +// recompile the entire UNWIND body per query — for true bulk loads the |
| 150 | +// playground should batch in ~2k chunks, see scale.rs. |
| 151 | +// ============================================================ |
| 152 | + |
| 153 | +#[test] |
| 154 | +fn unwind_range_ten_thousand_nodes_single_statement() { |
| 155 | + let db = TestDb::new(); |
| 156 | + db.run( |
| 157 | + "UNWIND range(1, 10000) AS id \ |
| 158 | + CREATE (:BulkNode { id: id, kind: id % 7 })", |
| 159 | + ); |
| 160 | + |
| 161 | + assert_eq!(db.service.node_count(), 10_000); |
| 162 | + |
| 163 | + // Spot-check a value in the middle and at the boundaries. |
| 164 | + let middle = db.scalar("MATCH (n:BulkNode {id: 5000}) RETURN n.kind"); |
| 165 | + assert_eq!(middle.as_i64().unwrap(), 5000 % 7); |
| 166 | + let last = db.scalar("MATCH (n:BulkNode {id: 10000}) RETURN n.kind"); |
| 167 | + assert_eq!(last.as_i64().unwrap(), 10_000 % 7); |
| 168 | +} |
| 169 | + |
| 170 | +#[test] |
| 171 | +#[ignore = "100k-in-one-statement is slow; run with --ignored. Real bulk loads should batch."] |
| 172 | +fn unwind_range_one_hundred_thousand_nodes_single_statement() { |
| 173 | + let db = TestDb::new(); |
| 174 | + db.run( |
| 175 | + "WITH range(1, 100000) AS ids \ |
| 176 | + UNWIND ids AS id \ |
| 177 | + CREATE (n:TestRecord { \ |
| 178 | + id: id, \ |
| 179 | + name: 'Record ' + toString(id), \ |
| 180 | + randomValue: rand(), \ |
| 181 | + status: CASE \ |
| 182 | + WHEN id % 3 = 0 THEN 'ACTIVE' \ |
| 183 | + WHEN id % 3 = 1 THEN 'PENDING' \ |
| 184 | + ELSE 'ARCHIVED' \ |
| 185 | + END \ |
| 186 | + })", |
| 187 | + ); |
| 188 | + assert_eq!(db.service.node_count(), 100_000); |
| 189 | +} |
| 190 | + |
| 191 | +#[test] |
| 192 | +fn batched_unwind_range_one_hundred_thousand_nodes() { |
| 193 | + // The pattern the playground should actually use for large ingest: |
| 194 | + // many small UNWIND statements rather than one huge one. Mirrors |
| 195 | + // scale.rs's approach but at a size that's reasonable for the |
| 196 | + // default `cargo test` run. |
| 197 | + let db = TestDb::new(); |
| 198 | + const TOTAL: usize = 100_000; |
| 199 | + const BATCH: usize = 2_000; |
| 200 | + |
| 201 | + let mut i = 0; |
| 202 | + while i < TOTAL { |
| 203 | + let end = (i + BATCH).min(TOTAL); |
| 204 | + db.run(&format!( |
| 205 | + "UNWIND range({i}, {}) AS id \ |
| 206 | + CREATE (:Bulk {{ id: id, kind: id % 5 }})", |
| 207 | + end - 1, |
| 208 | + )); |
| 209 | + i = end; |
| 210 | + } |
| 211 | + |
| 212 | + assert_eq!(db.service.node_count(), TOTAL); |
| 213 | + |
| 214 | + let buckets = db.run("MATCH (n:Bulk) RETURN n.kind AS k, count(n) AS c ORDER BY k"); |
| 215 | + assert_eq!(buckets.len(), 5); |
| 216 | + for row in &buckets { |
| 217 | + assert_eq!(row["c"].as_i64().unwrap() as usize, TOTAL / 5); |
| 218 | + } |
| 219 | +} |
0 commit comments