ursa-engine 0.8.0

Ursa's SQL engine session: a DataFusion session that reads files by path, locally or in object storage.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
//! Graph algorithms as table functions: planning, errors, and the index cache.
//!
//! Graphs are defined through the session's registry, as `CREATE PROPERTY
//! GRAPH` does, over views of CSV files.

use std::path::{Path, PathBuf};

use datafusion::arrow::util::pretty::pretty_format_batches;
use datafusion::common::TableReference;
use datafusion::error::Result;
use datafusion::prelude::SessionContext;
use ursa_engine::graph::{index_builds, registry_of, EdgeTable, GraphDef};
use ursa_engine::{new_session, user_message};

fn data(file: &str) -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../python/ursa/datasets/data")
        .join(file)
}

/// Define `graph` over a view `view` of the CSV at `path`, with endpoints
/// `src` and `dst`.
async fn define(ctx: &SessionContext, graph: &str, view: &str, path: &Path) {
    ctx.sql(&format!(
        "CREATE OR REPLACE VIEW {view} AS SELECT * FROM '{}'",
        path.display()
    ))
    .await
    .unwrap();
    registry_of(ctx).put(GraphDef {
        name: graph.into(),
        edges: EdgeTable::new(TableReference::bare(view), "src", "dst"),
        vertices: None,
    });
}

async fn karate() -> SessionContext {
    let ctx = new_session();
    define(&ctx, "karate", "ties", &data("karate_edges.csv")).await;
    ctx
}

async fn table(ctx: &SessionContext, sql: &str) -> Result<String> {
    let batches = ctx.sql(sql).await?.collect().await?;
    Ok(pretty_format_batches(&batches)?.to_string())
}

async fn error(ctx: &SessionContext, sql: &str) -> String {
    match ctx.sql(sql).await {
        Ok(frame) => match frame.collect().await {
            Ok(_) => panic!("{sql} should have failed"),
            Err(e) => user_message(&e),
        },
        Err(e) => user_message(&e),
    }
}

/// Getting Started, "Run an algorithm".
#[tokio::test]
async fn run_an_algorithm_matches_getting_started() {
    let ctx = karate().await;
    let out = table(
        &ctx,
        "SELECT id, round(pagerank, 4) AS pagerank \
         FROM pagerank(karate, direction => 'both') \
         ORDER BY pagerank DESC LIMIT 5",
    )
    .await
    .unwrap();
    assert_eq!(
        out,
        "\
+----+----------+
| id | pagerank |
+----+----------+
| 33 | 0.1009   |
| 0  | 0.097    |
| 32 | 0.0717   |
| 2  | 0.0571   |
| 1  | 0.0529   |
+----+----------+"
    );
}

/// Getting Started, "Combine algorithms and attributes", with the node
/// attributes read from a view of the file (`karate.nodes` belongs to the
/// graph DDL), and the joins written with `ON`: DataFusion 55 cannot plan a
/// chain of four `USING` joins, for plain tables too (see
/// `four_using_joins_are_ambiguous_in_datafusion`).
#[tokio::test]
async fn combine_algorithms_and_attributes() {
    let ctx = karate().await;
    ctx.sql(&format!(
        "CREATE VIEW members AS SELECT * FROM '{}'",
        data("karate_nodes.csv").display()
    ))
    .await
    .unwrap();
    let out = table(
        &ctx,
        "SELECT m.id, club, degree, triangle_count AS triangles, louvain AS community \
         FROM members AS m \
         JOIN degree(karate, direction => 'both') AS d ON d.id = m.id \
         JOIN triangle_count(karate)               AS t ON t.id = m.id \
         JOIN louvain(karate, seed => 1)           AS l ON l.id = m.id \
         ORDER BY degree DESC \
         LIMIT 5",
    )
    .await
    .unwrap();
    assert_eq!(
        out,
        "\
+----+---------+--------+-----------+-----------+
| id | club    | degree | triangles | community |
+----+---------+--------+-----------+-----------+
| 33 | Officer | 17     | 15        | 2         |
| 0  | Mr. Hi  | 16     | 18        | 0         |
| 32 | Officer | 12     | 13        | 2         |
| 2  | Mr. Hi  | 10     | 11        | 0         |
| 1  | Mr. Hi  | 9      | 12        | 0         |
+----+---------+--------+-----------+-----------+"
    );
    // Three algorithms, one graph, one query: one index.
    assert_eq!(index_builds(&ctx), 1);
}

/// The DataFusion limitation the test above works around. When this fails,
/// DataFusion has fixed it: run the Getting Started query as written.
#[tokio::test]
async fn four_using_joins_are_ambiguous_in_datafusion() {
    let ctx = new_session();
    let message = error(
        &ctx,
        "WITH a AS (SELECT 1 AS id), b AS (SELECT 1 AS id), c AS (SELECT 1 AS id), \
         d AS (SELECT 1 AS id) \
         SELECT a.id FROM a JOIN b USING (id) JOIN c USING (id) JOIN d USING (id)",
    )
    .await;
    assert_eq!(message, "Ambiguous reference to unqualified field id");
}

#[tokio::test]
async fn three_queries_build_the_index_once() {
    let ctx = karate().await;
    for sql in [
        "SELECT * FROM pagerank(karate)",
        "SELECT * FROM degree(karate, direction => 'in')",
        "SELECT * FROM connected_components(karate, mode => 'strong')",
    ] {
        table(&ctx, sql).await.unwrap();
    }
    assert_eq!(index_builds(&ctx), 1);
    // Two graphs over the same edges share it too: the key is the edges.
    define(&ctx, "club", "ties", &data("karate_edges.csv")).await;
    table(&ctx, "SELECT * FROM triangle_count(club)")
        .await
        .unwrap();
    assert_eq!(index_builds(&ctx), 1);
}

#[tokio::test]
async fn rewriting_the_file_rebuilds_the_index() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("edges.csv");
    std::fs::write(&path, "src,dst\n1,2\n2,3\n").unwrap();
    let ctx = new_session();
    define(&ctx, "g", "e", &path).await;
    let sql = "SELECT id, degree FROM degree(g, direction => 'both') ORDER BY id";
    let before = table(&ctx, sql).await.unwrap();
    assert!(before.contains("| 3  | 1      |"), "{before}");
    table(&ctx, sql).await.unwrap();
    assert_eq!(index_builds(&ctx), 1);

    // Same schema, new contents (and a new size and modification time).
    std::fs::write(&path, "src,dst\n1,2\n2,3\n3,1\n3,4\n").unwrap();
    let after = table(&ctx, sql).await.unwrap();
    assert_eq!(index_builds(&ctx), 2);
    assert_eq!(
        after,
        "\
+----+--------+
| id | degree |
+----+--------+
| 1  | 2      |
| 2  | 2      |
| 3  | 3      |
| 4  | 1      |
+----+--------+"
    );
    table(&ctx, sql).await.unwrap();
    assert_eq!(index_builds(&ctx), 2);
}

#[tokio::test]
async fn a_filtered_view_is_a_subgraph_with_its_own_index() {
    let ctx = karate().await;
    ctx.sql("CREATE VIEW near AS SELECT * FROM ties WHERE src < 10 AND dst < 10")
        .await
        .unwrap();
    registry_of(&ctx).put(GraphDef {
        name: "near".into(),
        edges: EdgeTable::new(TableReference::bare("near"), "src", "dst"),
        vertices: None,
    });
    let out = table(
        &ctx,
        "SELECT count(*) AS n, max(id) AS top FROM degree(near)",
    )
    .await
    .unwrap();
    assert!(out.contains("| 10 | 9   |"), "{out}");
    table(&ctx, "SELECT * FROM degree(karate)").await.unwrap();
    assert_eq!(index_builds(&ctx), 2);
}

#[tokio::test]
async fn the_scan_reads_only_the_endpoints() {
    let ctx = new_session();
    define(&ctx, "lesmis", "scenes", &data("lesmis_edges.csv")).await;
    let plan = table(&ctx, "EXPLAIN SELECT * FROM degree(lesmis)")
        .await
        .unwrap();
    assert!(plan.contains("GraphAlgorithm: graph=lesmis"), "{plan}");
    assert!(plan.contains("GraphAlgorithmExec: graph=lesmis"), "{plan}");
    // lesmis_edges.csv has src, dst and weight; only the endpoints are read.
    assert!(plan.contains("projection=[src, dst]"), "{plan}");
    assert!(!plan.contains("weight"), "{plan}");
}

#[tokio::test]
async fn aliases_and_schema() {
    let ctx = karate().await;
    let out = table(
        &ctx,
        "SELECT p.id, p.pagerank > 0 AS positive FROM pagerank(karate) AS p WHERE p.id = 0",
    )
    .await
    .unwrap();
    assert!(out.contains("| 0  | true     |"), "{out}");
    let schema = table(&ctx, "DESCRIBE SELECT * FROM pagerank(karate)")
        .await
        .unwrap();
    assert!(
        schema.contains("| id          | Int64     | NO          |"),
        "{schema}"
    );
    assert!(
        schema.contains("| pagerank    | Float64   | NO          |"),
        "{schema}"
    );
    // Function and graph names are identifiers, normalized as table names are.
    table(&ctx, "SELECT * FROM PageRank(KARATE)").await.unwrap();
}

#[tokio::test]
async fn string_ids_keep_their_type() {
    let ctx = new_session();
    define(
        &ctx,
        "florentine",
        "marriages",
        &data("florentine_edges.csv"),
    )
    .await;
    let out = table(
        &ctx,
        "SELECT id, degree FROM degree(florentine, direction => 'both') \
         ORDER BY degree DESC, id LIMIT 2",
    )
    .await
    .unwrap();
    assert_eq!(
        out,
        "\
+----------+--------+
| id       | degree |
+----------+--------+
| Medici   | 6      |
| Guadagni | 4      |
+----------+--------+"
    );
}

#[tokio::test]
async fn planning_errors() {
    let ctx = karate().await;
    for (sql, expected) in [
        (
            "SELECT * FROM pagerank(karete)",
            "graph 'karete' does not exist; did you mean 'karate'?",
        ),
        (
            "SELECT * FROM pagerank(social)",
            "graph 'social' does not exist. The graphs are: karate",
        ),
        (
            "SELECT * FROM pagerenk(karate)",
            "there is no graph function 'pagerenk'; did you mean 'pagerank'?",
        ),
        (
            "SELECT * FROM dijkstra(karate)",
            "there is no graph function 'dijkstra'. The graph functions are: degree, \
             pagerank, connected_components, triangle_count, clustering_coefficient, \
             betweenness, closeness, label_propagation, louvain, neighbors_agg, density, \
             avg_path_length, diameter, describe, hop, shortest_path, random_walk, algorithms",
        ),
        (
            "SELECT * FROM shortest_paths(karate)",
            "there is no graph function 'shortest_paths'; did you mean 'shortest_path'?",
        ),
        (
            "SELECT * FROM pagerank(karate, dampng => 0.9)",
            "pagerank() has no parameter 'dampng'; did you mean 'damping'? Its parameters are: \
             damping, max_iter, tol, weight, direction",
        ),
        (
            "SELECT * FROM pagerank(karate, damping => 0.9, damping => 0.8)",
            "pagerank() was given 'damping' more than once",
        ),
        (
            "SELECT * FROM pagerank(karate, damping => 'high')",
            "pagerank(damping =>) must be a number; got 'high'",
        ),
        (
            "SELECT * FROM pagerank(karate, max_iter => -1)",
            "pagerank(max_iter =>) must be a non-negative integer below 2^32; got -1",
        ),
        (
            "SELECT * FROM pagerank(karate, direction => 'in')",
            "pagerank(direction => 'in') is not supported; use one of 'out', 'both'",
        ),
        (
            "SELECT * FROM pagerank(karate, damping => 1 - 0.1)",
            "pagerank(damping =>) must be a literal (a number, a string, a boolean or NULL); got \
             1 - 0.1",
        ),
        (
            "SELECT * FROM pagerank()",
            "pagerank() needs a graph as its first argument: pagerank(<graph>, damping => 0.85, \
             max_iter => 30, tol => 1e-6, weight => NULL, direction => 'out')",
        ),
        (
            "SELECT * FROM pagerank(damping => 0.9)",
            "pagerank() needs a graph as its first argument: pagerank(<graph>, damping => 0.85, \
             max_iter => 30, tol => 1e-6, weight => NULL, direction => 'out')",
        ),
        (
            "SELECT * FROM pagerank('karate')",
            "the first argument of pagerank() must be the name of a graph; got 'karate' (write \
             the name without quotes: karate)",
        ),
        (
            "SELECT * FROM pagerank(karate.edges)",
            "the first argument of pagerank() must be the name of a graph; got karate.edges",
        ),
        (
            "SELECT * FROM pagerank(karate, 0.9)",
            "pagerank() takes its parameters by name, as name => value; got 0.9. Write \
             pagerank(<graph>, damping => 0.85, max_iter => 30, tol => 1e-6, weight => NULL, direction => 'out')",
        ),
        (
            "SELECT * FROM triangle_count(karate, direction => 'both')",
            "triangle_count() takes no parameters besides the graph; got 'direction'",
        ),
    ] {
        assert_eq!(error(&ctx, sql).await, expected, "{sql}");
    }
    // A table function that is not a graph function is DataFusion's.
    let out = table(&ctx, "SELECT count(*) AS n FROM generate_series(1, 3)")
        .await
        .unwrap();
    assert!(out.contains("| 3 |"), "{out}");
}

#[tokio::test]
async fn null_endpoints_are_an_error() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("edges.csv");
    std::fs::write(&path, "src,dst\n1,2\n2,\n").unwrap();
    let ctx = new_session();
    define(&ctx, "g", "e", &path).await;
    let message = error(&ctx, "SELECT * FROM degree(g)").await;
    assert!(
        message.starts_with("graph 'g': an edge has a null source or destination"),
        "{message}"
    );
}

#[tokio::test]
async fn an_empty_edge_set_has_no_nodes() {
    let ctx = karate().await;
    let out = table(
        &ctx,
        "SELECT count(*) AS n FROM (SELECT * FROM pagerank(karate) WHERE id < 0)",
    )
    .await
    .unwrap();
    assert!(out.contains("| 0 |"), "{out}");
    ctx.sql("CREATE VIEW none AS SELECT * FROM ties WHERE src < 0")
        .await
        .unwrap();
    registry_of(&ctx).put(GraphDef {
        name: "empty".into(),
        edges: EdgeTable::new(TableReference::bare("none"), "src", "dst"),
        vertices: None,
    });
    for f in [
        "degree",
        "pagerank",
        "connected_components",
        "triangle_count",
        "louvain",
    ] {
        let out = table(&ctx, &format!("SELECT count(*) AS n FROM {f}(empty)"))
            .await
            .unwrap();
        assert!(out.contains("| 0 |"), "{f}: {out}");
    }
}