Skip to content

Commit 4bbe098

Browse files
authored
Merge pull request DeusData#1436 from DeusData/fix/1334-multikey-orderby-limit
fix(cypher): parse and apply the full multi-key ORDER BY list, keeping LIMIT (DeusData#1334)
2 parents c9bbd88 + d129246 commit 4bbe098

3 files changed

Lines changed: 204 additions & 56 deletions

File tree

‎src/cypher/cypher.c‎

Lines changed: 91 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1655,7 +1655,6 @@ static int parse_return_item(parser_t *p, cbm_return_item_t *item) {
16551655
return 0;
16561656
}
16571657

1658-
/* Parse ORDER BY field into r->order_by and r->order_dir */
16591658
/* Parse aggregate function call for ORDER BY */
16601659
static void parse_order_by_agg(parser_t *p, char *buf, size_t buf_sz) {
16611660
const char *fn = agg_func_name(peek(p)->type);
@@ -1696,18 +1695,33 @@ static char *parse_order_by_expr(parser_t *p, char *buf, size_t buf_sz) {
16961695
return buf;
16971696
}
16981697

1699-
static void parse_order_by_clause(parser_t *p, cbm_return_clause_t *r) {
1698+
/* Parse the full comma-separated ORDER BY key list (#1334). Consuming only the
1699+
* first key left ", key2 ... LIMIT n" unparsed, which silently dropped the
1700+
* LIMIT and flooded the caller with the whole result set. Returns 0 on
1701+
* success, CBM_NOT_FOUND when the key list exceeds the modeled maximum. */
1702+
static int parse_order_by_clause(parser_t *p, cbm_return_clause_t *r) {
17001703
expect(p, TOK_BY);
1701-
char order_buf[CBM_SZ_256];
1702-
parse_order_by_expr(p, order_buf, sizeof(order_buf));
1703-
r->order_by = heap_strdup(order_buf);
1704-
if (match(p, TOK_ASC)) {
1705-
r->order_dir = heap_strdup("ASC");
1706-
} else if (match(p, TOK_DESC)) {
1707-
r->order_dir = heap_strdup("DESC");
1708-
}
1704+
do {
1705+
if (r->order_key_count >= CBM_CYPHER_ORDER_KEYS_MAX) {
1706+
return CBM_NOT_FOUND;
1707+
}
1708+
char order_buf[CBM_SZ_256];
1709+
parse_order_by_expr(p, order_buf, sizeof(order_buf));
1710+
bool desc = false;
1711+
if (match(p, TOK_ASC)) {
1712+
desc = false;
1713+
} else if (match(p, TOK_DESC)) {
1714+
desc = true;
1715+
}
1716+
r->order_keys[r->order_key_count] = heap_strdup(order_buf);
1717+
r->order_descs[r->order_key_count] = desc;
1718+
r->order_key_count++;
1719+
} while (match(p, TOK_COMMA));
1720+
return 0;
17091721
}
17101722

1723+
static void free_return_clause(cbm_return_clause_t *r);
1724+
17111725
/* Parse RETURN/WITH clause (shared logic) */
17121726
static int parse_return_or_with(parser_t *p, cbm_return_clause_t **out, bool is_with) {
17131727
cbm_token_type_t tok = (int)is_with ? TOK_WITH : TOK_RETURN;
@@ -1741,8 +1755,7 @@ static int parse_return_or_with(parser_t *p, cbm_return_clause_t **out, bool is_
17411755

17421756
cbm_return_item_t item = {0};
17431757
if (parse_return_item(p, &item) < 0) {
1744-
free(r->items);
1745-
free(r);
1758+
free_return_clause(r);
17461759
return CBM_NOT_FOUND;
17471760
}
17481761

@@ -1759,15 +1772,17 @@ static int parse_return_or_with(parser_t *p, cbm_return_clause_t **out, bool is_
17591772
* parsed item count to that width so an over-wide RETURN is rejected here
17601773
* instead of writing past those arrays downstream. */
17611774
if (r->count > CBM_SZ_32) {
1762-
free(r->items);
1763-
free(r);
1775+
free_return_clause(r);
17641776
return CBM_NOT_FOUND;
17651777
}
17661778

17671779
tail:
17681780
/* Optional ORDER BY */
17691781
if (match(p, TOK_ORDER)) {
1770-
parse_order_by_clause(p, r);
1782+
if (parse_order_by_clause(p, r) < 0) {
1783+
free_return_clause(r);
1784+
return CBM_NOT_FOUND;
1785+
}
17711786
}
17721787

17731788
/* Optional SKIP */
@@ -2100,8 +2115,9 @@ static void free_return_clause(cbm_return_clause_t *r) {
21002115
free(r->items[i].args);
21012116
}
21022117
free(r->items);
2103-
safe_str_free(&r->order_by);
2104-
safe_str_free(&r->order_dir);
2118+
for (int k = 0; k < r->order_key_count; k++) {
2119+
safe_str_free(&r->order_keys[k]);
2120+
}
21052121
free(r);
21062122
}
21072123

@@ -3308,16 +3324,17 @@ static void expand_pattern_rels(cbm_store_t *store, cbm_pattern_t *pat, binding_
33083324

33093325
/* ── Result postprocessing helpers ─────────────────────────────── */
33103326

3311-
/* Find the column index for ORDER BY, checking both column names and aliases.
3312-
* Returns -1 if not found. */
3313-
static int rb_find_order_column(const result_builder_t *rb, const cbm_return_clause_t *ret) {
3327+
/* Find the column index for one ORDER BY key, checking both column names and
3328+
* aliases. Returns -1 if not found. */
3329+
static int rb_find_order_column(const result_builder_t *rb, const cbm_return_clause_t *ret,
3330+
const char *key) {
33143331
for (int ci = 0; ci < rb->col_count; ci++) {
3315-
if (strcmp(rb->columns[ci], ret->order_by) == 0) {
3332+
if (strcmp(rb->columns[ci], key) == 0) {
33163333
return ci;
33173334
}
33183335
}
33193336
for (int ci = 0; ci < ret->count; ci++) {
3320-
if (ret->items[ci].alias && strcmp(ret->items[ci].alias, ret->order_by) == 0) {
3337+
if (ret->items[ci].alias && strcmp(ret->items[ci].alias, key) == 0) {
33213338
return ci;
33223339
}
33233340
}
@@ -3345,26 +3362,44 @@ static bool rb_is_numeric_column(const result_builder_t *rb, int col) {
33453362
}
33463363

33473364
static void rb_apply_order_by(result_builder_t *rb, const cbm_return_clause_t *ret) {
3348-
if (!ret->order_by) {
3365+
if (ret->order_key_count == 0) {
33493366
return;
33503367
}
3351-
int order_col = rb_find_order_column(rb, ret);
3352-
if (order_col < 0) {
3368+
/* Resolve every key up front; an unresolvable key is skipped, matching the
3369+
* single-key forgiveness (sorting on what can be resolved beats dropping
3370+
* the whole ORDER BY). */
3371+
int cols[CBM_CYPHER_ORDER_KEYS_MAX];
3372+
bool numeric[CBM_CYPHER_ORDER_KEYS_MAX];
3373+
bool descs[CBM_CYPHER_ORDER_KEYS_MAX];
3374+
int keys = 0;
3375+
for (int k = 0; k < ret->order_key_count; k++) {
3376+
int order_col = rb_find_order_column(rb, ret, ret->order_keys[k]);
3377+
if (order_col < 0) {
3378+
continue;
3379+
}
3380+
cols[keys] = order_col;
3381+
numeric[keys] = rb_is_numeric_column(rb, order_col);
3382+
descs[keys] = ret->order_descs[k];
3383+
keys++;
3384+
}
3385+
if (keys == 0) {
33533386
return;
33543387
}
3355-
3356-
bool desc = ret->order_dir && strcmp(ret->order_dir, "DESC") == 0;
3357-
bool numeric = rb_is_numeric_column(rb, order_col);
33583388
for (int i = 0; i < rb->row_count - SKIP_ONE; i++) {
33593389
for (int j = 0; j < rb->row_count - i - SKIP_ONE; j++) {
3360-
int cmp;
3361-
if (numeric) {
3362-
cmp = (int)strtol(rb->rows[j][order_col], NULL, CBM_DECIMAL_BASE) -
3363-
(int)strtol(rb->rows[j + SKIP_ONE][order_col], NULL, CBM_DECIMAL_BASE);
3364-
} else {
3365-
cmp = strcmp(rb->rows[j][order_col], rb->rows[j + SKIP_ONE][order_col]);
3390+
int cmp = 0;
3391+
for (int k = 0; k < keys && cmp == 0; k++) {
3392+
if (numeric[k]) {
3393+
cmp = (int)strtol(rb->rows[j][cols[k]], NULL, CBM_DECIMAL_BASE) -
3394+
(int)strtol(rb->rows[j + SKIP_ONE][cols[k]], NULL, CBM_DECIMAL_BASE);
3395+
} else {
3396+
cmp = strcmp(rb->rows[j][cols[k]], rb->rows[j + SKIP_ONE][cols[k]]);
3397+
}
3398+
if (descs[k]) {
3399+
cmp = -cmp;
3400+
}
33663401
}
3367-
if (desc ? cmp < 0 : cmp > 0) {
3402+
if (cmp > 0) {
33683403
const char **tmp = rb->rows[j];
33693404
rb->rows[j] = rb->rows[j + SKIP_ONE];
33703405
rb->rows[j + SKIP_ONE] = tmp;
@@ -3674,18 +3709,26 @@ static void distinct_list_add(char ***list, int *count, const char *val) {
36743709
(*list)[idx] = heap_strdup(val);
36753710
}
36763711

3677-
/* Sort bindings by a virtual variable using bubble sort */
3678-
static void sort_bindings(binding_t *vbindings, int count, const char *key, bool desc) {
3712+
/* Sort bindings by the ORDER BY key list (virtual variables) using bubble
3713+
* sort; later keys break ties, direction is per key (#1334). */
3714+
static void sort_bindings(binding_t *vbindings, int count, const cbm_return_clause_t *wc) {
36793715
for (int i = 0; i < count - SKIP_ONE; i++) {
36803716
for (int j = 0; j < count - i - SKIP_ONE; j++) {
3681-
const char *va = binding_get_virtual(&vbindings[j], key, NULL);
3682-
const char *vb2 = binding_get_virtual(&vbindings[j + SKIP_ONE], key, NULL);
3683-
char *ea = NULL;
3684-
char *eb = NULL;
3685-
double da = strtod(va, &ea);
3686-
double db = strtod(vb2, &eb);
3687-
int cmp = (ea != va && eb != vb2) ? ((da > db) - (da < db)) : strcmp(va, vb2);
3688-
if (desc ? cmp < 0 : cmp > 0) {
3717+
int cmp = 0;
3718+
for (int k = 0; k < wc->order_key_count && cmp == 0; k++) {
3719+
const char *va = binding_get_virtual(&vbindings[j], wc->order_keys[k], NULL);
3720+
const char *vb2 =
3721+
binding_get_virtual(&vbindings[j + SKIP_ONE], wc->order_keys[k], NULL);
3722+
char *ea = NULL;
3723+
char *eb = NULL;
3724+
double da = strtod(va, &ea);
3725+
double db = strtod(vb2, &eb);
3726+
cmp = (ea != va && eb != vb2) ? ((da > db) - (da < db)) : strcmp(va, vb2);
3727+
if (wc->order_descs[k]) {
3728+
cmp = -cmp;
3729+
}
3730+
}
3731+
if (cmp > 0) {
36893732
binding_t tmp = vbindings[j];
36903733
vbindings[j] = vbindings[j + SKIP_ONE];
36913734
vbindings[j + SKIP_ONE] = tmp;
@@ -3718,9 +3761,8 @@ static void bindings_skip_limit(binding_t *vbindings, int *count, int skip, int
37183761

37193762
/* Sort, skip, and limit binding array in-place */
37203763
static void with_sort_skip_limit(const cbm_return_clause_t *wc, binding_t *vbindings, int *vcount) {
3721-
if (wc->order_by) {
3722-
bool wdesc = wc->order_dir && strcmp(wc->order_dir, "DESC") == 0;
3723-
sort_bindings(vbindings, *vcount, wc->order_by, wdesc);
3764+
if (wc->order_key_count > 0) {
3765+
sort_bindings(vbindings, *vcount, wc);
37243766
}
37253767
bindings_skip_limit(vbindings, vcount, wc->skip, wc->limit);
37263768
}
@@ -4390,7 +4432,7 @@ static void build_return_columns(result_builder_t *rb, cbm_return_clause_t *ret)
43904432
static void execute_return_simple(cbm_return_clause_t *ret, binding_t *bindings, int bind_count,
43914433
int max_rows, result_builder_t *rb) {
43924434
int proj_cap = max_rows;
4393-
if (ret->limit > 0 && !ret->distinct && !ret->order_by && ret->skip <= 0) {
4435+
if (ret->limit > 0 && !ret->distinct && ret->order_key_count == 0 && ret->skip <= 0) {
43944436
proj_cap = ret->limit;
43954437
}
43964438
for (int bi = 0; bi < bind_count && rb->row_count < proj_cap; bi++) {

‎src/cypher/cypher.h‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -262,15 +262,23 @@ typedef struct {
262262
int arg_count;
263263
} cbm_return_item_t;
264264

265+
/* Upper bound on ORDER BY sort keys. Queries with more keys are rejected at
266+
* parse time: an unmodeled key must be a loud error, never a silently dropped
267+
* remainder (#1334 - the unconsumed tail swallowed the LIMIT clause). */
268+
#define CBM_CYPHER_ORDER_KEYS_MAX 8
269+
265270
typedef struct {
266271
cbm_return_item_t *items;
267272
int count;
268273
bool distinct;
269-
bool star; /* RETURN * */
270-
const char *order_by; /* "variable.property" or "COUNT(var)" or alias */
271-
const char *order_dir; /* "ASC" or "DESC", NULL = default */
272-
int skip; /* SKIP N, 0 = none */
273-
int limit; /* 0 = default */
274+
bool star; /* RETURN * */
275+
/* ORDER BY key list, in priority order. Each key is "variable.property",
276+
* "COUNT(var)" or an alias; direction is per key (Cypher semantics). */
277+
const char *order_keys[CBM_CYPHER_ORDER_KEYS_MAX];
278+
bool order_descs[CBM_CYPHER_ORDER_KEYS_MAX]; /* false = ASC (default) */
279+
int order_key_count; /* 0 = no ORDER BY */
280+
int skip; /* SKIP N, 0 = none */
281+
int limit; /* 0 = default */
274282
} cbm_return_clause_t;
275283

276284
/* Full query AST */

‎tests/test_cypher.c‎

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,14 +378,48 @@ TEST(cypher_parse_return_order_limit) {
378378
int rc =
379379
cbm_cypher_parse("MATCH (f:Function) RETURN f.name ORDER BY f.name DESC LIMIT 5", &q, &err);
380380
ASSERT_EQ(rc, 0);
381-
ASSERT_NOT_NULL(q->ret->order_by);
382-
ASSERT_STR_EQ(q->ret->order_dir, "DESC");
381+
ASSERT_EQ(q->ret->order_key_count, 1);
382+
ASSERT_STR_EQ(q->ret->order_keys[0], "f.name");
383+
ASSERT(q->ret->order_descs[0]);
383384
ASSERT_EQ(q->ret->limit, 5);
384385

385386
cbm_query_free(q);
386387
PASS();
387388
}
388389

390+
/* #1334: every ORDER BY key is parsed (per-key direction) and the LIMIT that
391+
* follows the key list is consumed instead of silently dropped. */
392+
TEST(cypher_parse_multikey_order_by_issue1334) {
393+
cbm_query_t *q = NULL;
394+
char *err = NULL;
395+
int rc = cbm_cypher_parse(
396+
"MATCH (f:Function) RETURN f.name ORDER BY f.complexity DESC, f.name ASC LIMIT 5", &q,
397+
&err);
398+
ASSERT_EQ(rc, 0);
399+
ASSERT_EQ(q->ret->order_key_count, 2);
400+
ASSERT_STR_EQ(q->ret->order_keys[0], "f.complexity");
401+
ASSERT(q->ret->order_descs[0]);
402+
ASSERT_STR_EQ(q->ret->order_keys[1], "f.name");
403+
ASSERT_FALSE(q->ret->order_descs[1]);
404+
ASSERT_EQ(q->ret->limit, 5);
405+
406+
cbm_query_free(q);
407+
PASS();
408+
}
409+
410+
/* #1334: more keys than the modeled maximum is a loud parse error - the old
411+
* failure mode (ignore the remainder, drop the LIMIT) must never come back. */
412+
TEST(cypher_parse_order_by_over_cap_rejected_issue1334) {
413+
cbm_query_t *q = NULL;
414+
char *err = NULL;
415+
int rc = cbm_cypher_parse("MATCH (f:Function) RETURN f.name ORDER BY "
416+
"f.a, f.b, f.c, f.d, f.e, f.f, f.g, f.h, f.i LIMIT 5",
417+
&q, &err);
418+
ASSERT(rc != 0);
419+
free(err);
420+
PASS();
421+
}
422+
389423
TEST(cypher_parse_return_distinct) {
390424
cbm_query_t *q = NULL;
391425
char *err = NULL;
@@ -2648,6 +2682,65 @@ TEST(cypher_exec_skip_limit) {
26482682
PASS();
26492683
}
26502684

2685+
/* Regression for #1334: a LIMIT must survive a multi-key ORDER BY. The parser
2686+
* used to stop at the first sort key, leaving ", key2 ... LIMIT n" unconsumed —
2687+
* the whole result set came back (6326 rows instead of 5 on the reporter's
2688+
* graph: a token-flood into agent context). */
2689+
TEST(cypher_exec_multikey_order_by_keeps_limit_issue1334) {
2690+
cbm_store_t *s = setup_cypher_store();
2691+
cbm_cypher_result_t r = {0};
2692+
/* start_lines: HandleOrder=10, ValidateOrder=5, SubmitOrder=0, LogError=0 */
2693+
int rc = cbm_cypher_execute(
2694+
s,
2695+
"MATCH (f:Function) RETURN f.name, f.start_line "
2696+
"ORDER BY f.start_line DESC, f.name ASC LIMIT 2",
2697+
"test", 0, &r);
2698+
ASSERT_EQ(rc, 0);
2699+
ASSERT_EQ(r.row_count, 2);
2700+
ASSERT_STR_EQ(r.rows[0][0], "HandleOrder");
2701+
ASSERT_STR_EQ(r.rows[1][0], "ValidateOrder");
2702+
cbm_cypher_result_free(&r);
2703+
cbm_store_close(s);
2704+
PASS();
2705+
}
2706+
2707+
/* #1334: the secondary key must actually break ties, per-key direction. */
2708+
TEST(cypher_exec_multikey_order_by_tiebreak_issue1334) {
2709+
cbm_store_t *s = setup_cypher_store();
2710+
cbm_cypher_result_t r = {0};
2711+
/* start_line ASC puts the two 0-line functions first; name DESC breaks the
2712+
* tie: SubmitOrder before LogError. */
2713+
int rc = cbm_cypher_execute(
2714+
s,
2715+
"MATCH (f:Function) RETURN f.name, f.start_line "
2716+
"ORDER BY f.start_line ASC, f.name DESC LIMIT 2",
2717+
"test", 0, &r);
2718+
ASSERT_EQ(rc, 0);
2719+
ASSERT_EQ(r.row_count, 2);
2720+
ASSERT_STR_EQ(r.rows[0][0], "SubmitOrder");
2721+
ASSERT_STR_EQ(r.rows[1][0], "LogError");
2722+
cbm_cypher_result_free(&r);
2723+
cbm_store_close(s);
2724+
PASS();
2725+
}
2726+
2727+
/* #1334: the WITH-clause pipeline has the same multi-key ORDER BY contract. */
2728+
TEST(cypher_exec_with_multikey_order_by_keeps_limit_issue1334) {
2729+
cbm_store_t *s = setup_cypher_store();
2730+
cbm_cypher_result_t r = {0};
2731+
int rc = cbm_cypher_execute(s,
2732+
"MATCH (f:Function) WITH f.name AS n, f.start_line AS sl "
2733+
"ORDER BY sl DESC, n ASC LIMIT 2 RETURN n",
2734+
"test", 0, &r);
2735+
ASSERT_EQ(rc, 0);
2736+
ASSERT_EQ(r.row_count, 2);
2737+
ASSERT_STR_EQ(r.rows[0][0], "HandleOrder");
2738+
ASSERT_STR_EQ(r.rows[1][0], "ValidateOrder");
2739+
cbm_cypher_result_free(&r);
2740+
cbm_store_close(s);
2741+
PASS();
2742+
}
2743+
26512744
TEST(cypher_exec_sum) {
26522745
cbm_store_t *s = setup_cypher_store();
26532746
cbm_cypher_result_t r = {0};
@@ -3563,6 +3656,8 @@ SUITE(cypher) {
35633656
RUN_TEST(cypher_parse_return_simple);
35643657
RUN_TEST(cypher_parse_return_count);
35653658
RUN_TEST(cypher_parse_return_order_limit);
3659+
RUN_TEST(cypher_parse_multikey_order_by_issue1334);
3660+
RUN_TEST(cypher_parse_order_by_over_cap_rejected_issue1334);
35663661
RUN_TEST(cypher_parse_return_distinct);
35673662
RUN_TEST(cypher_parse_inline_props);
35683663
RUN_TEST(cypher_parse_error);
@@ -3677,6 +3772,9 @@ SUITE(cypher) {
36773772
/* Phase 4: SKIP + aggregation */
36783773
RUN_TEST(cypher_exec_skip);
36793774
RUN_TEST(cypher_exec_skip_limit);
3775+
RUN_TEST(cypher_exec_multikey_order_by_keeps_limit_issue1334);
3776+
RUN_TEST(cypher_exec_multikey_order_by_tiebreak_issue1334);
3777+
RUN_TEST(cypher_exec_with_multikey_order_by_keeps_limit_issue1334);
36803778
RUN_TEST(cypher_exec_sum);
36813779
RUN_TEST(cypher_exec_avg);
36823780
RUN_TEST(cypher_exec_min);

0 commit comments

Comments
 (0)