From 769f78ed751f22e1dbfdb83aeb08b2a5ff5decd5 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:56:11 +0530 Subject: [PATCH] feat: implement ORDER BY and LIMIT clauses in PQL with corresponding tests and autocomplete suggestions (#6641) --- apps/api/plane/tests/unit/utils/test_pql.py | 139 ++++++++++++++++++ apps/api/plane/utils/paginator.py | 52 +++++-- apps/api/plane/utils/pql/backend.py | 15 ++ apps/api/plane/utils/pql/constants.py | 28 ++++ apps/api/plane/utils/pql/grammar.lark | 17 ++- apps/api/plane/utils/pql/transformer.py | 59 +++++++- .../pql-editor/plugins/autocomplete-plugin.ts | 89 ++++++++++- .../extensions/pql-editor/plugins/grammar.ts | 31 ++++ .../pql-editor/plugins/highlighter-plugin.ts | 7 + .../pql-editor/plugins/token-utils.ts | 13 ++ .../src/core/extensions/pql-editor/types.ts | 12 +- .../editor/src/core/utils/pql-suggestions.ts | 86 ++++++++++- 12 files changed, 529 insertions(+), 19 deletions(-) diff --git a/apps/api/plane/tests/unit/utils/test_pql.py b/apps/api/plane/tests/unit/utils/test_pql.py index 46d6825303..6d29be4644 100644 --- a/apps/api/plane/tests/unit/utils/test_pql.py +++ b/apps/api/plane/tests/unit/utils/test_pql.py @@ -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 diff --git a/apps/api/plane/utils/paginator.py b/apps/api/plane/utils/paginator.py index 34c4c2f9cb..30bec6b175 100644 --- a/apps/api/plane/utils/paginator.py +++ b/apps/api/plane/utils/paginator.py @@ -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 diff --git a/apps/api/plane/utils/pql/backend.py b/apps/api/plane/utils/pql/backend.py index 7fdb0f4189..ed2b5d319c 100644 --- a/apps/api/plane/utils/pql/backend.py +++ b/apps/api/plane/utils/pql/backend.py @@ -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 diff --git a/apps/api/plane/utils/pql/constants.py b/apps/api/plane/utils/pql/constants.py index 93ca0a3519..fb17d88cee 100644 --- a/apps/api/plane/utils/pql/constants.py +++ b/apps/api/plane/utils/pql/constants.py @@ -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 # --------------------------------------------------------------------------- diff --git a/apps/api/plane/utils/pql/grammar.lark b/apps/api/plane/utils/pql/grammar.lark index 9c553975db..c55aba322d 100644 --- a/apps/api/plane/utils/pql/grammar.lark +++ b/apps/api/plane/utils/pql/grammar.lark @@ -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: "'" /.*?/ "'" diff --git a/apps/api/plane/utils/pql/transformer.py b/apps/api/plane/utils/pql/transformer.py index 9459364507..90738fc22f 100644 --- a/apps/api/plane/utils/pql/transformer.py +++ b/apps/api/plane/utils/pql/transformer.py @@ -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 value # ------------------------------------------------------------------ diff --git a/packages/editor/src/core/extensions/pql-editor/plugins/autocomplete-plugin.ts b/packages/editor/src/core/extensions/pql-editor/plugins/autocomplete-plugin.ts index 4532ce19f9..176c74d032 100644 --- a/packages/editor/src/core/extensions/pql-editor/plugins/autocomplete-plugin.ts +++ b/packages/editor/src/core/extensions/pql-editor/plugins/autocomplete-plugin.ts @@ -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; +} diff --git a/packages/editor/src/core/extensions/pql-editor/plugins/grammar.ts b/packages/editor/src/core/extensions/pql-editor/plugins/grammar.ts index e852a88b5e..7a58ca6c35 100644 --- a/packages/editor/src/core/extensions/pql-editor/plugins/grammar.ts +++ b/packages/editor/src/core/extensions/pql-editor/plugins/grammar.ts @@ -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([ + "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(FUNCTION_DEFS.map((f) => [f.name, f])); @@ -711,4 +736,10 @@ export const KEYWORD_MAP = new Map([ ["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], ]); diff --git a/packages/editor/src/core/extensions/pql-editor/plugins/highlighter-plugin.ts b/packages/editor/src/core/extensions/pql-editor/plugins/highlighter-plugin.ts index 3d830140a4..d51328016f 100644 --- a/packages/editor/src/core/extensions/pql-editor/plugins/highlighter-plugin.ts +++ b/packages/editor/src/core/extensions/pql-editor/plugins/highlighter-plugin.ts @@ -70,6 +70,13 @@ const TOKEN_CLASS: Partial> = { [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", diff --git a/packages/editor/src/core/extensions/pql-editor/plugins/token-utils.ts b/packages/editor/src/core/extensions/pql-editor/plugins/token-utils.ts index 11057c3270..341fced02f 100644 --- a/packages/editor/src/core/extensions/pql-editor/plugins/token-utils.ts +++ b/packages/editor/src/core/extensions/pql-editor/plugins/token-utils.ts @@ -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. diff --git a/packages/editor/src/core/extensions/pql-editor/types.ts b/packages/editor/src/core/extensions/pql-editor/types.ts index 2f06e253e3..cf250ec864 100644 --- a/packages/editor/src/core/extensions/pql-editor/types.ts +++ b/packages/editor/src/core/extensions/pql-editor/types.ts @@ -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; diff --git a/packages/editor/src/core/utils/pql-suggestions.ts b/packages/editor/src/core/utils/pql-suggestions.ts index c79043980e..a4b17b308f 100644 --- a/packages/editor/src/core/utils/pql-suggestions.ts +++ b/packages/editor/src/core/utils/pql-suggestions.ts @@ -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 [];