feat: implement ORDER BY and LIMIT clauses in PQL with corresponding tests and autocomplete suggestions (#6641)

This commit is contained in:
Nikhil
2026-04-08 18:56:11 +05:30
committed by GitHub
parent 0688f89813
commit 769f78ed75
12 changed files with 529 additions and 19 deletions
+139
View File
@@ -1266,3 +1266,142 @@ class TestWorkItemIdentifier:
{"priority": "high"},
]
}
# =========================================================================
# ORDER BY and LIMIT
# =========================================================================
@pytest.mark.unit
class TestOrderBy:
"""Tests for the ORDER BY clause."""
def test_order_by_single_field_desc(self):
r = _parse('ORDER BY createdAt DESC')
assert r.rich_filter is None
assert r.order_by == [("created_at", "DESC")]
assert r.limit is None
def test_order_by_single_field_asc(self):
r = _parse('ORDER BY createdAt ASC')
assert r.order_by == [("created_at", "ASC")]
def test_order_by_default_asc(self):
r = _parse('ORDER BY createdAt')
assert r.order_by == [("created_at", "ASC")]
def test_order_by_multiple_fields(self):
r = _parse('ORDER BY dueDate DESC, priority ASC')
assert r.order_by == [("target_date", "DESC"), ("priority", "ASC")]
def test_order_by_case_insensitive(self):
r = _parse('order by createdAt desc')
assert r.order_by == [("created_at", "DESC")]
def test_order_by_with_filter(self):
r = _parse('priority = "high" ORDER BY createdAt DESC')
assert r.rich_filter == {"priority": "high"}
assert r.order_by == [("created_at", "DESC")]
def test_order_by_field_aliases(self):
"""ORDER BY aliases map to the correct Django ORM fields."""
cases = {
"priority": "priority",
"state": "state__group",
"stateGroup": "state__group",
"dueDate": "target_date",
"startDate": "start_date",
"title": "name",
"assignee": "assignees__first_name",
"label": "labels__name",
"module": "issue_module__module__name",
"createdBy": "created_by__first_name",
"sequenceId": "sequence_id",
"sortOrder": "sort_order",
"type": "type__name",
}
for pql_field, django_field in cases.items():
r = _parse(f"ORDER BY {pql_field}")
assert r.order_by == [(django_field, "ASC")], f"Failed for field: {pql_field}"
def test_order_by_unknown_field_raises(self):
with pytest.raises(ValidationError):
_parse('ORDER BY unknownField')
def test_order_by_cf_field_raises(self):
uid = str(uuid4())
with pytest.raises(ValidationError):
_parse(f'ORDER BY cf["{uid}"]')
@pytest.mark.unit
class TestLimit:
"""Tests for the LIMIT clause."""
def test_limit_only(self):
r = _parse('LIMIT 50')
assert r.rich_filter is None
assert r.order_by is None
assert r.limit == 50
def test_limit_with_filter(self):
r = _parse('priority = "high" LIMIT 10')
assert r.rich_filter == {"priority": "high"}
assert r.limit == 10
def test_limit_case_insensitive(self):
r = _parse('limit 25')
assert r.limit == 25
def test_limit_exceeds_max_raises(self):
with pytest.raises(ValidationError):
_parse('LIMIT 5000')
def test_limit_zero_raises(self):
"""LIMIT 0 is rejected by the POSITIVE_INT grammar rule."""
with pytest.raises(ValidationError):
_parse('LIMIT 0')
def test_limit_negative_raises(self):
with pytest.raises(ValidationError):
_parse('LIMIT -5')
@pytest.mark.unit
class TestOrderByAndLimit:
"""Tests for ORDER BY and LIMIT used together."""
def test_filter_order_limit(self):
r = _parse('priority = "high" ORDER BY createdAt DESC LIMIT 10')
assert r.rich_filter == {"priority": "high"}
assert r.order_by == [("created_at", "DESC")]
assert r.limit == 10
def test_order_and_limit_no_filter(self):
r = _parse('ORDER BY dueDate ASC LIMIT 25')
assert r.rich_filter is None
assert r.order_by == [("target_date", "ASC")]
assert r.limit == 25
def test_limit_before_order_by(self):
"""LIMIT can come before ORDER BY."""
r = _parse('LIMIT 10 ORDER BY createdAt')
assert r.limit == 10
assert r.order_by == [("created_at", "ASC")]
def test_existing_filters_still_work(self):
"""Existing filter-only queries still parse correctly."""
r = _parse('priority = "high" AND state IN openStates()')
assert r.rich_filter is not None
assert r.order_by is None
assert r.limit is None
def test_merge_does_not_propagate_order_limit(self):
"""merge() only combines rich_filter, not order_by/limit."""
left = PQLResult(rich_filter={"priority": "high"}, order_by=[("created_at", "DESC")], limit=10)
right = PQLResult(rich_filter={"state_id": "abc"}, order_by=[("name", "ASC")], limit=5)
merged = left.merge(right, "and")
assert merged.rich_filter == {"and": [{"priority": "high"}, {"state_id": "abc"}]}
assert merged.order_by is None
assert merged.limit is None
+40 -12
View File
@@ -113,6 +113,7 @@ class OffsetPaginator:
max_offset=None,
on_results=None,
total_count_queryset=None,
result_cap=None,
):
# Key tuple and remove `-` if descending order by
self.key = (
@@ -127,6 +128,8 @@ class OffsetPaginator:
self.max_offset = max_offset
self.on_results = on_results
self.total_count_queryset = total_count_queryset
# Optional cap on total results (used by PQL LIMIT)
self.result_cap = result_cap
def get_result(self, limit=1000, cursor=None):
# offset is page #
@@ -165,15 +168,24 @@ class OffsetPaginator:
total_count = self.total_count_queryset.count() if self.total_count_queryset else queryset.count()
# Apply result cap (PQL LIMIT)
if self.result_cap is not None:
total_count = min(total_count, self.result_cap)
# Check if there are more results available after the current page
has_next = page_results.count() > limit and (offset + limit) < total_count
# Adjust cursors based on the results for pagination
next_cursor = Cursor(limit, page + 1, False, page_results.count() > limit)
next_cursor = Cursor(limit, page + 1, False, has_next)
# If the page is greater than 0, then set the previous cursor
prev_cursor = Cursor(limit, page - 1, True, page > 0)
# Process the results
results = results[:limit]
# Process the results — trim to the capped result set if needed
if self.result_cap is not None:
remaining = max(0, self.result_cap - offset)
results = results[:min(limit, remaining)]
else:
results = results[:limit]
# Process the results
if self.on_results:
@@ -183,7 +195,7 @@ class OffsetPaginator:
count = total_count
# Optionally, calculate the total count and max_hits if needed
max_hits = math.ceil(count / limit)
max_hits = math.ceil(count / limit) if count > 0 else 0
# Return the cursor results
return CursorResult(
@@ -273,15 +285,20 @@ class GroupedOffsetPaginator(OffsetPaginator):
F("created_at").desc(),
)
# Count the queryset
count = queryset.count()
# Apply result cap (PQL LIMIT)
if self.result_cap is not None:
count = min(count, self.result_cap)
# Adjust cursors based on the grouped results for pagination
next_cursor = Cursor(limit, page + 1, False, queryset.filter(row_number__gte=stop).exists())
has_next = queryset.filter(row_number__gte=stop).exists() and (offset + limit) < count
next_cursor = Cursor(limit, page + 1, False, has_next)
# Add previous cursors
prev_cursor = Cursor(limit, page - 1, True, page > 0)
# Count the queryset
count = queryset.count()
# Optionally, calculate the total count and max_hits if needed
# This might require adjustments based on specific use cases
if results:
@@ -488,15 +505,20 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
F("created_at").desc(),
)
# Count the queryset
count = queryset.count()
# Apply result cap (PQL LIMIT)
if self.result_cap is not None:
count = min(count, self.result_cap)
# Adjust cursors based on the grouped results for pagination
next_cursor = Cursor(limit, page + 1, False, queryset.filter(row_number__gte=stop).exists())
has_next = queryset.filter(row_number__gte=stop).exists() and (offset + limit) < count
next_cursor = Cursor(limit, page + 1, False, has_next)
# Add previous cursors
prev_cursor = Cursor(limit, page - 1, True, page > 0)
# Count the queryset
count = queryset.count()
# Optionally, calculate the total count and max_hits if needed
# This might require adjustments based on specific use cases
if results:
@@ -705,6 +727,12 @@ class BasePaginator:
except ValueError:
raise ParseError(detail="Invalid cursor parameter.")
# Apply PQL LIMIT: pass a result_cap to the paginator so it caps
# total counts and pagination metadata without extra queries.
pql_limit = getattr(request, "_pql_limit", None)
if pql_limit is not None:
paginator_kwargs["result_cap"] = pql_limit
if not paginator:
if group_by_field_name:
paginator_kwargs["group_by_field_name"] = group_by_field_name
+15
View File
@@ -55,4 +55,19 @@ class PQLFilterBackend(filters.BaseFilterBackend):
backend = ComplexFilterBackend()
queryset = backend.filter_queryset(request, queryset, view, filter_data=result.rich_filter)
# Inject PQL ordering into request.GET so views pick it up
# via request.GET.get("order_by", ...) → order_issue_queryset() → paginator
if result.order_by:
django_field, direction = result.order_by[0]
order_by_param = f"-{django_field}" if direction == "DESC" else django_field
qd = request.GET.copy()
qd["order_by"] = order_by_param
request.GET = qd
# Store PQL limit on the request for BasePaginator.paginate() to apply.
# Applied as a subquery filter after ordering, before pagination, so
# cursor/page metadata stays correct.
if result.limit is not None:
request._pql_limit = result.limit
return queryset
+28
View File
@@ -51,6 +51,34 @@ FIELD_ALIASES = {
"text": "text",
}
# ---------------------------------------------------------------------------
# ORDER BY: PQL field name → Django ORM ordering expression
# These map to the same strings _validate_order_by_field() accepts.
# ---------------------------------------------------------------------------
ORDER_BY_ALIASES = {
"priority": "priority",
"state": "state__group",
"stateGroup": "state__group",
"createdAt": "created_at",
"updatedAt": "updated_at",
"startDate": "start_date",
"dueDate": "target_date",
"title": "name",
"assignee": "assignees__first_name",
"label": "labels__name",
"module": "issue_module__module__name",
"createdBy": "created_by__first_name",
"sequenceId": "sequence_id",
"sortOrder": "sort_order",
"completedAt": "completed_at",
"archivedAt": "archived_at",
"isDraft": "is_draft",
"type": "type__name",
}
# Maximum allowed LIMIT value
PQL_MAX_LIMIT = 1000
# ---------------------------------------------------------------------------
# Operator → lookup suffix mapping
# ---------------------------------------------------------------------------
+16 -1
View File
@@ -5,7 +5,13 @@
// NAME followed by "(" → predicate_call or func_call
// NAME followed by operator → condition
start: expr
start: query
query: expr order_clause? limit_clause?
| expr limit_clause order_clause?
| order_clause limit_clause?
| limit_clause order_clause?
| limit_clause
// Logical operators (lowest precedence)
?expr: or_expr
@@ -70,6 +76,15 @@ func_args: value ("," value)*
value_list: value ("," value)*
// ORDER BY and LIMIT clauses
order_clause: "ORDER"i "BY"i order_field ("," order_field)*
order_field: field sort_dir?
sort_dir: "ASC"i -> asc
| "DESC"i -> desc
limit_clause: "LIMIT"i POSITIVE_INT
POSITIVE_INT: /[1-9][0-9]*/
NAME: /[a-zA-Z_][a-zA-Z0-9_.]*/
SINGLE_QUOTED_STRING: "'" /.*?/ "'"
+58 -1
View File
@@ -35,6 +35,8 @@ from .constants import (
ISNULL_TRUE_OPERATORS,
NEGATED_OPERATORS,
OPERATOR_LOOKUP,
ORDER_BY_ALIASES,
PQL_MAX_LIMIT,
PREDICATE_FUNCTIONS,
)
@@ -73,9 +75,15 @@ class PQLResult:
"""Container for the output of PQL transformation."""
rich_filter: dict | None = None
order_by: list | None = None # list of (django_field, "ASC"|"DESC") tuples
limit: int | None = None
def merge(self, other: PQLResult, operator: str = "and") -> PQLResult:
"""Merge two results under a logical operator."""
"""Merge two results under a logical operator.
order_by and limit are NOT merged — they only exist at the
top-level query node.
"""
left = self.rich_filter
right = other.rich_filter
@@ -113,6 +121,55 @@ class PQLTransformer(Transformer):
def start(self, result: PQLResult) -> PQLResult:
return result
# ------------------------------------------------------------------
# Query: expr? order_clause? limit_clause?
# ------------------------------------------------------------------
def query(self, *children) -> PQLResult:
"""Assemble final PQLResult from filter, order, and limit."""
rich_filter = None
order_by = None
limit = None
for child in children:
if isinstance(child, PQLResult):
rich_filter = child.rich_filter
elif isinstance(child, list):
order_by = child
elif isinstance(child, int):
limit = child
return PQLResult(rich_filter=rich_filter, order_by=order_by, limit=limit)
def order_clause(self, *order_fields) -> list:
"""Collect order field tuples into a list."""
return list(order_fields)
def order_field(self, field, direction=None) -> tuple:
"""Resolve an order field to (django_field, direction)."""
if isinstance(field, CfField):
raise ValueError("Custom property fields cannot be used in ORDER BY")
field_name = field
django_field = ORDER_BY_ALIASES.get(field_name)
if django_field is None:
raise ValueError(f"Cannot order by field: {field_name}")
dir_str = direction if direction else "ASC"
return (django_field, dir_str)
def asc(self) -> str:
return "ASC"
def desc(self) -> str:
return "DESC"
def limit_clause(self, token) -> int:
value = int(str(token))
if value > PQL_MAX_LIMIT:
raise ValueError(f"LIMIT cannot exceed {PQL_MAX_LIMIT}")
return value
# ------------------------------------------------------------------
# Conditions: field <op> value
# ------------------------------------------------------------------
@@ -16,7 +16,14 @@ import type { EditorState } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
// local imports
import { PQL_HIGHLIGHTER_KEY } from "./highlighter-plugin";
import { isFunctionToken, isFieldToken, isCompOp, isConditionEnd, tokenKindToCompOp } from "./token-utils";
import {
isFunctionToken,
isFieldToken,
isCompOp,
isConditionEnd,
isOrderByClauseToken,
tokenKindToCompOp,
} from "./token-utils";
import type { Token, SuggestionContext } from "../types";
import { TokenKind } from "../types";
@@ -186,6 +193,43 @@ export function determineSuggestionContext(tokens: Token[], cursorChar: number):
return { kind: "START", tokenStart: cursorChar };
}
// ── ORDER BY / LIMIT clause contexts ─────────────────────────────────────────
// After "ORDER BY" → suggest sortable fields
if (last.kind === TokenKind.BY && prev?.kind === TokenKind.ORDER) {
return { kind: "AFTER_ORDER_BY", tokenStart: cursorChar };
}
// After a FIELD inside ORDER BY clause → suggest ASC/DESC
if (isFieldToken(last.kind) && isInOrderByClause(before)) {
return { kind: "AFTER_ORDER_FIELD", tokenStart: last.from };
}
// After ASC/DESC → suggest comma (more fields), LIMIT, or end
if (last.kind === TokenKind.ASC || last.kind === TokenKind.DESC) {
return { kind: "AFTER_SORT_DIR", tokenStart: cursorChar };
}
// After COMMA inside ORDER BY clause → suggest more sortable fields
if (last.kind === TokenKind.COMMA && isInOrderByClause(before)) {
return { kind: "AFTER_ORDER_BY", tokenStart: cursorChar };
}
// After "LIMIT" → suggest a number
if (last.kind === TokenKind.LIMIT) {
return { kind: "AFTER_LIMIT", tokenStart: cursorChar };
}
// After an INTEGER that follows LIMIT → no more suggestions
if (last.kind === TokenKind.INTEGER && prev?.kind === TokenKind.LIMIT) {
return null;
}
// After "ORDER" without "BY" yet → no suggestions (wait for BY)
if (last.kind === TokenKind.ORDER) {
return null;
}
// ── Logical operators (OR, NOT) → start of a new condition ───────────────────
if (last.kind === TokenKind.OR || last.kind === TokenKind.NOT) {
return { kind: "START", tokenStart: cursorChar };
@@ -396,6 +440,33 @@ function contextFromPrecedingTokens(preceding: Token[], tokenStart: number): Sug
return { kind: "AFTER_CONDITION", tokenStart };
}
// ── ORDER BY / LIMIT clause contexts ─────────────────────────────────────────
// After "ORDER BY" → partial is a sortable field name
if (last.kind === TokenKind.BY && prev && prev.kind === TokenKind.ORDER) {
return { kind: "AFTER_ORDER_BY", tokenStart };
}
// After FIELD inside ORDER BY → partial is ASC/DESC
if (isFieldToken(last.kind) && isInOrderByClause(preceding)) {
return { kind: "AFTER_ORDER_FIELD", tokenStart };
}
// After ASC/DESC → partial is LIMIT or comma
if (last.kind === TokenKind.ASC || last.kind === TokenKind.DESC) {
return { kind: "AFTER_SORT_DIR", tokenStart };
}
// After COMMA in ORDER BY → partial is a sortable field name
if (last.kind === TokenKind.COMMA && isInOrderByClause(preceding)) {
return { kind: "AFTER_ORDER_BY", tokenStart };
}
// After LIMIT → partial is a number (no suggestions)
if (last.kind === TokenKind.LIMIT) {
return { kind: "AFTER_LIMIT", tokenStart };
}
// After AND, OR, NOT (as group) → still starting a new condition
if (last.kind === TokenKind.AND || last.kind === TokenKind.OR || last.kind === TokenKind.NOT) {
return { kind: "START", tokenStart };
@@ -412,7 +483,7 @@ function contextFromPrecedingTokens(preceding: Token[], tokenStart: number): Sug
* derived from the tokens that precede it.
*/
function isPartialWordToken(kind: TokenKind): boolean {
return kind === TokenKind.IDENTIFIER || isFunctionToken(kind);
return kind === TokenKind.IDENTIFIER || isFunctionToken(kind) || isOrderByClauseToken(kind);
}
/**
@@ -513,3 +584,17 @@ function findBetweenField(before: Token[]): string | undefined {
}
return undefined;
}
/**
* Checks whether the cursor is currently inside an ORDER BY clause by
* scanning backwards for an ORDER + BY token pair without encountering
* a LIMIT token (which would mean we've moved past ORDER BY into LIMIT).
*/
function isInOrderByClause(before: Token[]): boolean {
for (let i = before.length - 1; i >= 0; i--) {
const kind = before[i].kind;
if (kind === TokenKind.LIMIT) return false;
if (kind === TokenKind.BY && i > 0 && before[i - 1].kind === TokenKind.ORDER) return true;
}
return false;
}
@@ -672,6 +672,31 @@ export const FUNCTION_DEFS: FunctionDef[] = [
// },
];
// ─── Sortable field names for ORDER BY ───────────────────────────────────────
// Mirrors the Python ORDER_BY_ALIASES in apps/api/plane/utils/pql/constants.py.
// Only these PQL field names are valid in ORDER BY clauses.
export const SORTABLE_FIELDS = new Set<string>([
"priority",
"state",
"stateGroup",
"createdAt",
"updatedAt",
"startDate",
"dueDate",
"title",
"assignee",
"label",
"module",
"createdBy",
"sequenceId",
"sortOrder",
"completedAt",
"archivedAt",
"isDraft",
"type",
]);
/** Map from function name → FunctionDef */
export const FUNCTION_MAP = new Map<string, FunctionDef>(FUNCTION_DEFS.map((f) => [f.name, f]));
@@ -711,4 +736,10 @@ export const KEYWORD_MAP = new Map<string, TokenKind>([
["between", TokenKind.BETWEEN],
["true", TokenKind.TRUE_KW],
["false", TokenKind.FALSE_KW],
// ORDER BY / LIMIT clause keywords
["order", TokenKind.ORDER],
["by", TokenKind.BY],
["limit", TokenKind.LIMIT],
["asc", TokenKind.ASC],
["desc", TokenKind.DESC],
]);
@@ -70,6 +70,13 @@ const TOKEN_CLASS: Partial<Record<TokenKind, string>> = {
[TokenKind.IS]: "operator",
[TokenKind.BETWEEN]: "operator",
// ── ORDER BY / LIMIT clause keywords ──────────────────────────────────
[TokenKind.ORDER]: "operator",
[TokenKind.BY]: "operator",
[TokenKind.LIMIT]: "operator",
[TokenKind.ASC]: "operator",
[TokenKind.DESC]: "operator",
// ── Field names ──────────────────────────────────────────────────────────
[TokenKind.FIELD]: "field",
[TokenKind.CUSTOM_PROPERTY_FIELD_NODE]: "field",
@@ -78,6 +78,19 @@ export function isConditionEnd(kind: TokenKind): boolean {
);
}
/**
* Returns true for tokens that are ORDER BY / LIMIT clause keywords.
*/
export function isOrderByClauseToken(kind: TokenKind): boolean {
return (
kind === TokenKind.ORDER ||
kind === TokenKind.BY ||
kind === TokenKind.ASC ||
kind === TokenKind.DESC ||
kind === TokenKind.LIMIT
);
}
/**
* Maps a comparison-operator token kind to its string representation.
* Returns `undefined` when the kind is not a comparison operator.
@@ -58,6 +58,12 @@ export enum TokenKind {
FN_STATE = "FN_STATE",
FN_RELATION = "FN_RELATION",
FN_HISTORY = "FN_HISTORY",
// ORDER BY / LIMIT clause keywords
ORDER = "ORDER",
BY = "BY",
LIMIT = "LIMIT",
ASC = "ASC",
DESC = "DESC",
// Unknown identifier (will be flagged by validator)
IDENTIFIER = "IDENTIFIER",
// Unrecognized character sequence
@@ -316,7 +322,11 @@ export type SuggestionContextKind =
| "AFTER_IS"
| "AFTER_BETWEEN"
| "AFTER_BETWEEN_AND"
| "AFTER_CONDITION";
| "AFTER_CONDITION"
| "AFTER_ORDER_BY"
| "AFTER_ORDER_FIELD"
| "AFTER_SORT_DIR"
| "AFTER_LIMIT";
export type SuggestionContext = {
kind: SuggestionContextKind;
@@ -17,7 +17,7 @@ import { Ampersand, CircleIcon, Diamond, Ellipsis, SquareFunction } from "lucide
import type { IFilterOption, IFilterOptionGroup, TAllAvailableOperatorsForDisplay, TFilterValue } from "@plane/types";
// local imports
import type { AppendCharacter, CompOp, FieldDef, Suggestion, SuggestionContext } from "../extensions/pql-editor/types";
import { FUNCTION_DEFS } from "../extensions/pql-editor/plugins/grammar";
import { FUNCTION_DEFS, SORTABLE_FIELDS, FIELD_ALIASES } from "../extensions/pql-editor/plugins/grammar";
import { isCustomPropertyField } from "../extensions/pql-editor/custom-property-field/utils";
// ─── Operator key mapping ──────────────────────────────────────────────────────
@@ -202,6 +202,75 @@ function buildLogicalSuggestions(): Suggestion[] {
];
}
function buildOrderByKeywordSuggestion(): Suggestion {
return {
kind: "keyword",
label: "ORDER BY",
icon: Diamond,
i18n_description: "Sort results by a field",
insertText: "ORDER BY",
appendCharacter: "whitespace",
sortOrder: 2,
};
}
function buildLimitKeywordSuggestion(): Suggestion {
return {
kind: "keyword",
label: "LIMIT",
icon: Diamond,
i18n_description: "Limit the number of results",
insertText: "LIMIT",
appendCharacter: "whitespace",
sortOrder: 3,
};
}
function buildSortableFieldSuggestions(fieldDefs: FieldDef[]): Suggestion[] {
// Build a reverse lookup: PQL alias → FieldDef
// FIELD_ALIASES maps internal key → PQL alias (e.g. "created_at" → "createdAt")
const suggestions: Suggestion[] = [];
let i = 0;
for (const def of fieldDefs) {
const pqlAlias = FIELD_ALIASES[def.value] ?? def.value;
if (!SORTABLE_FIELDS.has(pqlAlias)) continue;
suggestions.push({
kind: "field",
label: def.name,
icon: def.icon,
insertText: pqlAlias,
appendCharacter: "whitespace",
sortOrder: i++,
});
}
return suggestions;
}
function buildSortDirectionSuggestions(): Suggestion[] {
return [
{
kind: "keyword",
label: "ASC",
icon: Diamond,
i18n_description: "Ascending order",
insertText: "ASC",
appendCharacter: "whitespace",
sortOrder: 0,
},
{
kind: "keyword",
label: "DESC",
icon: Diamond,
i18n_description: "Descending order",
insertText: "DESC",
appendCharacter: "whitespace",
sortOrder: 1,
},
];
}
// ─── Field-specific function suggestion builder ───────────────────────────────
/**
@@ -346,7 +415,20 @@ export async function computeAllSuggestions(
return buildFunctionSuggestions(["DATE"]);
case "AFTER_CONDITION":
return buildLogicalSuggestions();
return [...buildLogicalSuggestions(), buildOrderByKeywordSuggestion(), buildLimitKeywordSuggestion()];
case "AFTER_ORDER_BY":
return buildSortableFieldSuggestions(fieldDefs);
case "AFTER_ORDER_FIELD":
return [...buildSortDirectionSuggestions(), buildLimitKeywordSuggestion()];
case "AFTER_SORT_DIR":
return [buildLimitKeywordSuggestion()];
case "AFTER_LIMIT":
// No autocomplete for numeric values — user types the number manually
return [];
default:
return [];