mirror of
https://github.com/makeplane/plane.git
synced 2026-06-14 03:30:00 +00:00
feat: implement ORDER BY and LIMIT clauses in PQL with corresponding tests and autocomplete suggestions (#6641)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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: "'" /.*?/ "'"
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
Reference in New Issue
Block a user