Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/commands/ft.aggregate.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,6 @@ The following reducer functions are available. The reducer functions that take a
| MAX 1 <expression> | The largest numerical values of the expression. |
| AVG 1 <expression> | The numerical average of the values of the expression. |
| STDDEV 1 <expression> | The standard deviation the values of the expression. |
| FIRST_VALUE 1 <expression> | The first value of the expression encountered in the group. Order depends on record retrieval order and is non-deterministic. Use only when order does not matter. |
| FIRST_VALUE 3 <expression> BY <expression> | The value of the first expression from the record with the smallest comparison expression (ascending). Ties broken by first-encountered order. |
| FIRST_VALUE 4 <expression> BY <expression> ASC\|DESC | The value of the first expression from the record with the minimum (ASC) or maximum (DESC) comparison expression. Ties broken by first-encountered order. Invalid keyword arguments (e.g., wrong BY token or unrecognised direction) produce a nil result. |
Binary file modified integration/compatibility/aggregate-answers.pickle.gz
Binary file not shown.
225 changes: 224 additions & 1 deletion integration/compatibility/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ def test_bad_numeric_data(self, key_type, dialect):
self.check(dialect, "ft.search", f"{key_type}_idx1", "-@n1:[-inf inf]")
self.check(dialect, "ft.search", f"{key_type}_idx1", "@n2:[-inf inf]")
self.check(dialect, "ft.search", f"{key_type}_idx1", "-@n2:[-inf inf]")

'''
def test_search_reverse(self, key_type, dialect):
self.setup_data("reverse vector numbers", key_type)
self.checkall(dialect, f"ft.search {key_type}_idx1 *")
Expand Down Expand Up @@ -468,3 +468,226 @@ def test_search_sortby(self, key_type, dialect):
for limit in ["LIMIT 0 5", "LIMIT 2 3", ""]:
self.check(dialect, f"ft.search {key_type}_idx1 * SORTBY {sort_key} {direction} {return_keys} {limit} {wsk}")


# test_first_value_simple_mode is intentionally omitted.
# FIRST_VALUE without a BY clause is non-deterministic: the order of
# records within a group depends on retrieval order, which differs between
# Redis and Valkey implementations. Compatibility testing requires
# deterministic results, so only BY-clause (sorted) mode is tested here.

def test_first_value_by_clause(self, key_type, dialect):
"""Test FIRST_VALUE with BY clause - sorted mode."""
self.setup_data("sortable numbers", key_type)

# Test BY clause with ASC - numeric comparison
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 4 @n1 BY @n1 ASC as first_n1_asc"
)

# Test BY clause with DESC - numeric comparison
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 4 @n1 BY @n1 DESC as first_n1_desc"
)

# Test BY clause with default order (3 args)
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 3 @n1 BY @n1 as first_n1_default"
)

# Test BY clause with string comparison - ASC
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @t1 @t2 "
f"groupby 1 @t2 "
f"reduce first_value 4 @t1 BY @t1 ASC as first_t1_asc"
)

# Test BY clause with string comparison - DESC
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @t1 @t2 "
f"groupby 1 @t2 "
f"reduce first_value 4 @t1 BY @t1 DESC as first_t1_desc"
)

# Test returning different property than comparison property (cross-field)
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 4 @__key @t1 @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 4 @t1 BY @n1 ASC as first_t1_by_n1"
)

# Test comparing property to itself
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 4 @n1 BY @n1 ASC as first_n1_by_n1"
)

# Test with duplicate comparison values (tie-breaking)
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 4 @n1 BY @n2 ASC as first_n1_by_n2"
)

def test_first_value_keyword_case(self, key_type, dialect):
"""Test FIRST_VALUE with case-insensitive keywords."""
self.setup_data("sortable numbers", key_type)

# Test lowercase 'by' keyword
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 3 @n1 by @n1 as first_by_lower"
)

# Test uppercase 'BY' keyword
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 3 @n1 BY @n1 as first_by_upper"
)

# Test mixed case 'By' keyword
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 3 @n1 By @n1 as first_by_mixed"
)

# Test lowercase 'asc' keyword
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 4 @n1 BY @n1 asc as first_asc_lower"
)

# Test uppercase 'ASC' keyword
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 4 @n1 BY @n1 ASC as first_asc_upper"
)

# Test mixed case 'Asc' keyword
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 4 @n1 BY @n1 Asc as first_asc_mixed"
)

# Test lowercase 'desc' keyword
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 4 @n1 BY @n1 desc as first_desc_lower"
)

# Test uppercase 'DESC' keyword
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 4 @n1 BY @n1 DESC as first_desc_upper"
)

# Test mixed case 'Desc' keyword
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 4 @n1 BY @n1 Desc as first_desc_mixed"
)

def test_first_value_edge_cases(self, key_type, dialect):
"""Test FIRST_VALUE with edge cases like nil values."""
# Use "hard numbers" data set for nil values
self.setup_data("hard numbers", key_type)

# Test with nil comparison values
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 4 @n1 BY @n1 ASC as first_nil_asc"
)

# Test with nil comparison values - DESC
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 4 @n1 BY @n1 DESC as first_nil_desc"
)

# NOTE: Simple mode test removed due to non-deterministic ordering.
# When FIRST_VALUE is used without a BY clause, the order of values
# within each group is undefined, leading to inconsistent results.

# Switch to sortable numbers for duplicate comparison values
self.client.execute_command("FLUSHALL SYNC")
time.sleep(0.5)
self.setup_data("sortable numbers", key_type)

# Test with duplicate comparison values (tie-breaking)
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 4 @n1 BY @n2 ASC as first_dup_tie"
)

def test_first_value_errors(self, key_type, dialect):
"""Test FIRST_VALUE error conditions."""
self.setup_data("sortable numbers", key_type)

# Test nargs=0 (too few arguments) - this will be caught by parser
# Note: This may not be testable via compatibility tests if parser rejects it

# Test nargs=2 (incomplete BY clause)
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 2 @n1 @n2 as first_error_nargs2"
)

# Test nargs=5 (too many arguments) - this will be caught by parser
# Note: This may not be testable via compatibility tests if parser rejects it

# Test invalid BY keyword (e.g., NOTBY)
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 3 @n1 NOTBY @n2 as first_error_notby"
)

# Test invalid sort order (not ASC/DESC)
self.check(dialect,
f"ft.aggregate {key_type}_idx1 * "
f"load 3 @__key @n1 @n2 "
f"groupby 1 @n2 "
f"reduce first_value 4 @n1 BY @n2 INVALID as first_error_invalid"
)

Loading
Loading