Repository: lablup/backend.ai-manager (language: python)

Issue:
Add a mini-language parser for generic GQL list filtering
Defined a method to change the query statement to sql statement.


Discussion / hints:
[![CLA assistant check](https://cla-assistant.io/pull/badge/signed)](https://cla-assistant.io/lablup/backend.ai-manager?pullRequest=305) <br/>All committers have signed the CLA.
# [Codecov](https://codecov.io/gh/lablup/backend.ai-manager/pull/305?src=pr&el=h1) Report
> Merging [#305](https://codecov.io/gh/lablup/backend.ai-manager/pull/305?src=pr&el=desc) into [master](https://codecov.io/gh/lablup/backend.ai-manager/commit/c946ef30bcba0e60f7655f36cf7437bca8fff190?el=desc) will **increase** coverage by `1.15%`.
> The diff coverage is `88.00%`.

[![Impacted file tree graph](https://codecov.io/gh/lablup/backend.ai-manager/pull/305/graphs/tree.svg?width=650&height=150&src=pr&token=MexLLGDXfU)](https://codecov.io/gh/lablup/backend.ai-manager/pull/305?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #305      +/-   ##
==========================================
+ Coverage   49.42%   50.57%   +1.15%     
==========================================
  Files          42       46       +4     
  Lines        6503     7786    +1283     
==========================================
+ Hits         3214     3938     +724     
- Misses       3289     3848     +559     
```


| [Impacted Files](https://codecov.io/gh/lablup/backend.ai-manager/pull/305?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [.../ai/backend/manager/models/minilang/queryfilter.py](https://codecov.io/gh/lablup/backend.ai-manager/pull/305/diff?src=pr&el=tree#diff-c3JjL2FpL2JhY2tlbmQvbWFuYWdlci9tb2RlbHMvbWluaWxhbmcvcXVlcnlmaWx0ZXIucHk=) | `88.00% <88.00%> (ø)` | |
| [src/ai/backend/manager/models/domain.py](https://codecov.io/gh/lablup/backend.ai-manager/pull/305/diff?src=pr&el=tree#diff-c3JjL2FpL2JhY2tlbmQvbWFuYWdlci9tb2RlbHMvZG9tYWluLnB5) | `62.25% <0.00%> (-5.02%)` | :arrow_down: |
| [src/ai/backend/gateway/auth.py](https://codecov.io/gh/lablup/backend.ai-manager/pull/305/diff?src=pr&el=tree#diff-c3JjL2FpL2JhY2tlbmQvZ2F0ZXdheS9hdXRoLnB5) | `50.00% <0.00%> (-4.42%)` | :arrow_down: |
| [src/ai/backend/manager/models/user.py](https://codecov.io/gh/lablup/backend.ai-manager/pull/305/diff?src=pr&el=tree#diff-c3JjL2FpL2JhY2tlbmQvbWFuYWdlci9tb2RlbHMvdXNlci5weQ==) | `34.65% <0.00%> (-0.17%)` | :arrow_down: |
| [src/ai/backend/manager/plugin/exceptions.py](https://codecov.io/gh/lablup/backend.ai-manager/pull/305/diff?src=pr&el=tree#diff-c3JjL2FpL2JhY2tlbmQvbWFuYWdlci9wbHVnaW4vZXhjZXB0aW9ucy5weQ==) | `100.00% <0.00%> (ø)` | |
| [src/ai/backend/manager/exceptions.py](https://codecov.io/gh/lablup/backend.ai-manager/pull/305/diff?src=pr&el=tree#diff-c3JjL2FpL2JhY2tlbmQvbWFuYWdlci9leGNlcHRpb25zLnB5) | `100.00% <0.00%> (ø)` | |
| [src/ai/backend/manager/plugin/error\_monitor.py](https://codecov.io/gh/lablup/backend.ai-manager/pull/305/diff?src=pr&el=tree#diff-c3JjL2FpL2JhY2tlbmQvbWFuYWdlci9wbHVnaW4vZXJyb3JfbW9uaXRvci5weQ==) | `50.00% <0.00%> (ø)` | |
| [src/ai/backend/gateway/exceptions.py](https://codecov.io/gh/lablup/backend.ai-manager/pull/305/diff?src=pr&el=tree#diff-c3JjL2FpL2JhY2tlbmQvZ2F0ZXdheS9leGNlcHRpb25zLnB5) | `88.03% <0.00%> (+0.47%)` | :arrow_up: |
| [src/ai/backend/manager/models/group.py](https://codecov.io/gh/lablup/backend.ai-manager/pull/305/diff?src=pr&el=tree#diff-c3JjL2FpL2JhY2tlbmQvbWFuYWdlci9tb2RlbHMvZ3JvdXAucHk=) | `49.22% <0.00%> (+2.13%)` | :arrow_up: |
| ... and [5 more](https://codecov.io/gh/lablup/backend.ai-manager/pull/305/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/lablup/backend.ai-manager/pull/305?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/lablup/backend.ai-manager/pull/305?src=pr&el=footer). Last update [c946ef3...cb6ad1e](https://codecov.io/gh/lablup/backend.ai-manager/pull/305?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

While I'm reviewing, please sign up the CLA and fix up the style issues indicated by lint checkers.
Now we need to use `ai.backend.manager.models.minilang.queryfilter.QueryFilterParser` in all paginated list GQL query resolver functions under `ai.backend.manager.models`, adding an optional string-type "filter" argument to all such queries.
I will file a new issue for the application of `QueryFilterParser`, after merging this.

Fix patch:
diff --git a/changes/449.breaking b/changes/449.breaking
new file mode 100644
index 000000000..e24a761be
--- /dev/null
+++ b/changes/449.breaking
@@ -0,0 +1,1 @@
+Removed never-used `order_key` and `order_asc` arguments in GraphQL pagination queries in favor of the new generic `order` argument
diff --git a/changes/449.feature b/changes/449.feature
new file mode 100644
index 000000000..a687445a9
--- /dev/null
+++ b/changes/449.feature
@@ -0,0 +1,1 @@
+Now all paginated list GraphQL queries have optional `filter` and `order` arguments where the client may specify the filtering/ordering conditions using a simple mini-language expression
diff --git a/setup.cfg b/setup.cfg
index a76bb0b08..5013952f5 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -71,7 +71,7 @@ install_requires =
     backend.ai-cli~=0.3
     PyJWT~=2.0
     zipstream-new~=1.1.8
-    lark-parser>=0.9.0
+    lark-parser~=0.11.3
     aiomonitor~=0.4.5
 zip_safe = false
 include_package_data = true
diff --git a/src/ai/backend/manager/models/agent.py b/src/ai/backend/manager/models/agent.py
index 34882f58c..36898ee9c 100644
--- a/src/ai/backend/manager/models/agent.py
+++ b/src/ai/backend/manager/models/agent.py
@@ -8,6 +8,7 @@
     TYPE_CHECKING,
 )
 
+from dateutil.parser import parse as dtparse
 import graphene
 from graphene.types.datetime import DateTime as GQLDateTime
 import sqlalchemy as sa
@@ -31,6 +32,8 @@
     EnumType, Item, PaginatedList,
     ResourceSlotColumn,
 )
+from .minilang.queryfilter import QueryFilterParser
+from .minilang.ordering import QueryOrderParser
 if TYPE_CHECKING:
     from ai.backend.manager.models.gql import GraphQueryContext
 
@@ -181,11 +184,40 @@ async def resolve_hardware_metadata(
         graph_ctx: GraphQueryContext = info.context
         return await graph_ctx.registry.gather_agent_hwinfo(self.id)
 
-    @staticmethod
+    _queryfilter_fieldspec = {
+        "id": ("id", None),
+        "status": ("status", lambda s: AgentStatus[s]),
+        "status_changed": ("status_changed", dtparse),
+        "region": ("region", None),
+        "scaling_group": ("scaling_group", None),
+        "schedulable": ("schedulabe", None),
+        "addr": ("addr", None),
+        "first_contact": ("first_contat", dtparse),
+        "lost_at": ("lost_at", dtparse),
+        "version": ("version", None),
+    }
+
+    _queryorder_colmap = {
+        "id": "id",
+        "status": "status",
+        "status_changed": "status_changed",
+        "region": "region",
+        "scaling_group": "scaling_group",
+        "schedulable": "schedulable",
+        "first_contact": "first_contact",
+        "lost_at": "lost_at",
+        "version": "version",
+        "available_slots": "available_slots",
+        "occupied_slots": "occupied_slots",
+    }
+
+    @classmethod
     async def load_count(
+        cls,
         graph_ctx: GraphQueryContext, *,
         scaling_group: str = None,
         raw_status: str = None,
+        filter: str = None,
     ) -> int:
         query = (
             sa.select([sa.func.count(agents.c.id)])
@@ -196,6 +228,9 @@ async def load_count(
         if raw_status is not None:
             status = AgentStatus[raw_status]
             query = query.where(agents.c.status == status)
+        if filter is not None:
+            qfparser = QueryFilterParser(cls._queryfilter_fieldspec)
+            query = qfparser.append_filter(query, filter)
         async with graph_ctx.db.begin_readonly() as conn:
             result = await conn.execute(query)
             return result.scalar()
@@ -207,19 +242,12 @@ async def load_slice(
         limit: int, offset: int, *,
         scaling_group: str = None,
         raw_status: str = None,
-        order_key: str = None,
-        order_asc: bool = True,
+        filter: str = None,
+        order: str = None,
     ) -> Sequence[Agent]:
-        # TODO: optimization for pagination using subquery, join
-        if order_key is None:
-            _ordering = agents.c.id
-        else:
-            _order_func = sa.asc if order_asc else sa.desc
-            _ordering = _order_func(getattr(agents.c, order_key))
         query = (
             sa.select([agents])
             .select_from(agents)
-            .order_by(_ordering)
             .limit(limit)
             .offset(offset)
         )
@@ -228,6 +256,18 @@ async def load_slice(
         if raw_status is not None:
             status = AgentStatus[raw_status]
             query = query.where(agents.c.status == status)
+        if filter is not None:
+            qfparser = QueryFilterParser(cls._queryfilter_fieldspec)
+            query = qfparser.append_filter(query, filter)
+        if order is not None:
+            qoparser = QueryOrderParser(cls._queryorder_colmap)
+            query = qoparser.append_ordering(query, order)
+        else:
+            query = query.order_by(
+                agents.c.status.asc(),
+                agents.c.scaling_group.asc(),
+                agents.c.id.asc(),
+            )
         async with graph_ctx.db.begin_readonly() as conn:
             return [
                 cls.from_row(graph_ctx, row)
diff --git a/src/ai/backend/manager/models/gql.py b/src/ai/backend/manager/models/gql.py
index 5a6ee584f..1c8e019ed 100644
--- a/src/ai/backend/manager/models/gql.py
+++ b/src/ai/backend/manager/models/gql.py
@@ -216,6 +216,8 @@ class Queries(graphene.ObjectType):
         AgentList,
         limit=graphene.Int(required=True),
         offset=graphene.Int(required=True),
+        # generic filter
+        filter=graphene.String(),
         # ordering customization
         order_key=graphene.String(),
         order_asc=graphene.Boolean(),
@@ -291,6 +293,8 @@ class Queries(graphene.ObjectType):
         UserList,
         limit=graphene.Int(required=True),
         offset=graphene.Int(required=True),
+        # generic filter
+        filter=graphene.String(),
         # ordering customization
         order_key=graphene.String(),
         order_asc=graphene.Boolean(),
@@ -315,6 +319,8 @@ class Queries(graphene.ObjectType):
         KeyPairList,
         limit=graphene.Int(required=True),
         offset=graphene.Int(required=True),
+        # generic filter
+        filter=graphene.String(),
         # ordering customization
         order_key=graphene.String(),
         order_asc=graphene.Boolean(),
@@ -385,6 +391,8 @@ class Queries(graphene.ObjectType):
         VirtualFolderList,
         limit=graphene.Int(required=True),
         offset=graphene.Int(required=True),
+        # generic filter
+        filter=graphene.String(),
         # ordering customization
         order_key=graphene.String(),
         order_asc=graphene.Boolean(),
@@ -413,6 +421,8 @@ class Queries(graphene.ObjectType):
         ComputeSessionList,
         limit=graphene.Int(required=True),
         offset=graphene.Int(required=True),
+        # generic filter
+        filter=graphene.String(),
         # ordering customization
         order_key=graphene.String(),
         order_asc=graphene.Boolean(),
@@ -427,6 +437,8 @@ class Queries(graphene.ObjectType):
         ComputeContainerList,
         limit=graphene.Int(required=True),
         offset=graphene.Int(required=True),
+        # generic filter
+        filter=graphene.String(),
         # ordering customization
         order_key=graphene.String(),
         order_asc=graphene.Boolean(),
@@ -490,22 +502,23 @@ async def resolve_agent_list(
         limit: int,
         offset: int,
         *,
+        filter: str = None,
+        order: str = None,
         scaling_group: str = None,
         status: str = None,
-        order_key: str = None,
-        order_asc: bool = True,
     ) -> AgentList:
         total_count = await Agent.load_count(
             info.context,
             scaling_group=scaling_group,
             raw_status=status,
+            filter=filter,
         )
         agent_list = await Agent.load_slice(
             info.context, limit, offset,
             scaling_group=scaling_group,
             raw_status=status,
-            order_key=order_key,
-            order_asc=order_asc,
+            filter=filter,
+            order=order,
         )
         return AgentList(agent_list, total_count)
 
@@ -766,12 +779,12 @@ async def resolve_user_list(
         limit: int,
         offset: int,
         *,
+        filter: str = None,
+        order: str = None,
         domain_name: str = None,
         group_id: uuid.UUID = None,
         is_active: bool = None,
         status: UserStatus = None,
-        order_key: str = None,
-        order_asc: bool = True,
     ) -> UserList:
         from .user import UserRole
         ctx: GraphQueryContext = info.context
@@ -794,6 +807,7 @@ async def resolve_user_list(
             group_id=group_id,
             is_active=is_active,
             status=status,
+            filter=filter,
         )
         user_list = await User.load_slice(
             info.context,
@@ -803,8 +817,8 @@ async def resolve_user_list(
             group_id=group_id,
             is_active=is_active,
             status=status,
-            order_key=order_key,
-            order_asc=order_asc,
+            filter=filter,
+            order=order,
         )
         return UserList(user_list, total_count)
 
@@ -860,17 +874,18 @@ async def resolve_keypair_list(
         limit: int,
         offset: int,
         *,
+        filter: str = None,
+        order: str = None,
         domain_name: str = None,
         email: str = None,
         is_active: bool = None,
-        order_key: str = None,
-        order_asc: bool = True,
     ) -> KeyPairList:
         total_count = await KeyPair.load_count(
             info.context,
             domain_name=domain_name,
             email=email,
             is_active=is_active,
+            filter=filter,
         )
         keypair_list = await KeyPair.load_slice(
             info.context,
@@ -879,8 +894,8 @@ async def resolve_keypair_list(
             domain_name=domain_name,
             email=email,
             is_active=is_active,
-            order_key=order_key,
-            order_asc=order_asc,
+            filter=filter,
+            order=order,
         )
         return KeyPairList(keypair_list, total_count)
 
@@ -1030,8 +1045,8 @@ async def resolve_vfolder_list(
         domain_name: str = None,
         group_id: uuid.UUID = None,
         user_id: uuid.UUID = None,
-        order_key: str = None,
-        order_asc: bool = True,
+        filter: str = None,
+        order: str = None,
     ) -> VirtualFolderList:
         # TODO: adopt the generic queryfilter language
         total_count = await VirtualFolder.load_count(
@@ -1039,6 +1054,7 @@ async def resolve_vfolder_list(
             domain_name=domain_name,  # scope
             group_id=group_id,        # scope
             user_id=user_id,          # scope
+            filter=filter,
         )
         items = await VirtualFolder.load_slice(
             info.context,
@@ -1047,8 +1063,8 @@ async def resolve_vfolder_list(
             domain_name=domain_name,  # scope
             group_id=group_id,        # scope
             user_id=user_id,          # scope
-            order_key=order_key,      # order
-            order_asc=order_asc,      # order
+            filter=filter,
+            order=order,
         )
         return VirtualFolderList(items, total_count)
 
@@ -1061,12 +1077,12 @@ async def resolve_compute_container_list(
         offset: int,
         *,
         session_id: SessionId,
+        filter: str = None,
+        order: str = None,
         role: UserRole = None,
         domain_name: str = None,
         group_id: uuid.UUID = None,
         access_key: AccessKey = None,
-        order_key: str = None,
-        order_asc: bool = True,
     ) -> ComputeContainerList:
         # TODO: adopt the generic queryfilter language
         total_count = await ComputeContainer.load_count(
@@ -1076,6 +1092,7 @@ async def resolve_compute_container_list(
             domain_name=domain_name,  # scope
             group_id=group_id,        # scope
             access_key=access_key,    # scope
+            filter=filter,
         )
         items = await ComputeContainer.load_slice(
             info.context,
@@ -1085,8 +1102,8 @@ async def resolve_compute_container_list(
             domain_name=domain_name,  # scope
             group_id=group_id,        # scope
             access_key=access_key,    # scope
-            order_key=order_key,      # order
-            order_asc=order_asc,      # order
+            filter=filter,
+            order=order,
         )
         return ComputeContainerList(items, total_count)
 
@@ -1113,12 +1130,12 @@ async def resolve_compute_session_list(
         limit: int,
         offset: int,
         *,
+        filter: str = None,
+        order: str = None,
         domain_name: str = None,
         group_id: uuid.UUID = None,
         access_key: AccessKey = None,
         status: str = None,
-        order_key: str = None,
-        order_asc: bool = True,
     ) -> ComputeSessionList:
         total_count = await ComputeSession.load_count(
             info.context,
@@ -1126,6 +1143,7 @@ async def resolve_compute_session_list(
             domain_name=domain_name,  # scope
             group_id=group_id,        # scope
             access_key=access_key,    # scope
+            filter=filter,
         )
         items = await ComputeSession.load_slice(
             info.context,
@@ -1134,8 +1152,8 @@ async def resolve_compute_session_list(
             domain_name=domain_name,  # scope
             group_id=group_id,        # scope
             access_key=access_key,    # scope
-            order_key=order_key,      # order
-            order_asc=order_asc,      # order
+            filter=filter,
+            order=order,
         )
         return ComputeSessionList(items, total_count)
 
diff --git a/src/ai/backend/manager/models/kernel.py b/src/ai/backend/manager/models/kernel.py
index 468b33d30..6273e22ab 100644
--- a/src/ai/backend/manager/models/kernel.py
+++ b/src/ai/backend/manager/models/kernel.py
@@ -22,6 +22,7 @@
 import uuid
 
 from aioredis import Redis
+from dateutil.parser import parse as dtparse
 import graphene
 from graphene.types.datetime import DateTime as GQLDateTime
 import sqlalchemy as sa
@@ -57,6 +58,8 @@
     metadata,
 )
 from .group import groups
+from .minilang.queryfilter import QueryFilterParser
+from .minilang.ordering import QueryOrderParser
 from .user import users
 from .keypair import keypairs
 if TYPE_CHECKING:
@@ -572,6 +575,32 @@ async def resolve_live_stat(self, info: graphene.ResolveInfo) -> Optional[Mappin
         else:
             return self.last_stat
 
+    _queryfilter_fieldspec = {
+        "image": ("image", None),
+        "agent": ("agent", None),
+        "cluster_idx": ("cluster_idx", None),
+        "cluster_role": ("cluster_role", None),
+        "cluster_hostname": ("cluster_hostname", None),
+        "status": ("status", lambda s: KernelStatus[s]),
+        "status_info": ("status_info", None),
+        "created_at": ("created_at", dtparse),
+        "status_changed": ("status_changed", dtparse),
+        "terminated_at": ("terminated_at", dtparse),
+    }
+
+    _queryorder_colmap = {
+        "image": "image",
+        "agent": "agent",
+        "cluster_idx": "cluster_idx",
+        "cluster_role": "cluster_role",
+        "cluster_hostname": "cluster_hostname",
+        "status": "status",
+        "status_info": "status_info",
+        "status_changed": "status_info",
+        "created_at": "created_at",
+        "terminated_at": "terminated_at",
+    }
+
     @classmethod
     async def load_count(
         cls,
@@ -582,6 +611,7 @@ async def load_count(
         domain_name: str = None,
         group_id: uuid.UUID = None,
         access_key: str = None,
+        filter: str = None,
     ) -> int:
         query = (
             sa.select([sa.func.count(kernels.c.id)])
@@ -596,6 +626,9 @@ async def load_count(
             query = query.where(kernels.c.group_id == group_id)
         if access_key is not None:
             query = query.where(kernels.c.access_key == access_key)
+        if filter is not None:
+            qfparser = QueryFilterParser(cls._queryfilter_fieldspec)
+            query = qfparser.append_filter(query, filter)
         async with ctx.db.begin_readonly() as conn:
             result = await conn.execute(query)
             return result.scalar()
@@ -612,19 +645,13 @@ async def load_slice(
         domain_name: str = None,
         group_id: uuid.UUID = None,
         access_key: AccessKey = None,
-        order_key: str = None,
-        order_asc: bool = True,
+        filter: str = None,
+        order: str = None,
     ) -> Sequence[Optional[ComputeContainer]]:
-        if order_key is None:
-            _ordering = DEFAULT_SESSION_ORDERING
-        else:
-            _order_func = sa.asc if order_asc else sa.desc
-            _ordering = [_order_func(getattr(kernels.c, order_key))]
         query = (
             sa.select([kernels])
             .select_from(kernels)
             .where(kernels.c.session_id == session_id)
-            .order_by(*_ordering)
             .limit(limit)
             .offset(offset)
         )
@@ -636,6 +663,14 @@ async def load_slice(
             query = query.where(kernels.c.group_id == group_id)
         if access_key is not None:
             query = query.where(kernels.c.access_key == access_key)
+        if filter is not None:
+            qfparser = QueryFilterParser(cls._queryfilter_fieldspec)
+            query = qfparser.append_filter(query, filter)
+        if order is not None:
+            qoparser = QueryOrderParser(cls._queryorder_colmap)
+            query = qoparser.append_ordering(query, order)
+        else:
+            query = query.order_by(DEFAULT_SESSION_ORDERING)
         async with ctx.db.begin_readonly() as conn:
             return [cls.from_row(ctx, r) async for r in (await conn.stream(query))]
 
@@ -830,6 +865,49 @@ async def resolve_dependencies(
         loader = graph_ctx.dataloader_manager.get_loader(graph_ctx, 'ComputeSession.by_dependency')
         return await loader.load(self.id)
 
+    _queryfilter_fieldspec = {
+        "type": ("kernels_session_type", lambda s: SessionTypes[s]),
+        "name": ("kernels_session_name", None),
+        "image": ("kernels_image", None),
+        "domain_name": ("kernels_domain_name", None),
+        "group_name": ("groups_group_name", None),
+        "user_email": ("users_email", None),
+        "access_key": ("kernels_access_key", None),
+        "scaling_group": ("kernels_scaling_groups_name", None),
+        "cluster_mode": ("kernels_cluster_mode", lambda s: ClusterMode[s]),
+        "cluster_template": ("kernels_cluster_template", None),
+        "cluster_size": ("kernels_cluster_size", None),
+        "status": ("kernels_status", lambda s: KernelStatus[s]),
+        "status_info": ("kernels_status_info", None),
+        "status_changed": ("kernels_status_changed", dtparse),
+        "result": ("kernels_result", lambda s: SessionResult[s]),
+        "created_at": ("kernels_created_at", dtparse),
+        "terminated_at": ("kernels_terminated_at", dtparse),
+        "starts_at": ("kernels_starts_at", dtparse),
+        "startup_command": ("kernels_startup_command", None),
+    }
+
+    _queryorder_colmap = {
+        "type": "kernels_session_type",
+        "name": "kernels_session_name",
+        "image": "kernels_image",
+        "domain_name": "kernels_domain_name",
+        "group_name": "kernels_group_name",
+        "user_email": "users_email",
+        "access_key": "kernels_access_key",
+        "scaling_group": "kernels_scaling_groups_name",
+        "cluster_mode": "kernels_cluster_mode",
+        "cluster_template": "kernels_cluster_template",
+        "cluster_size": "kernels_cluster_size",
+        "status": "kernels_status",
+        "status_info": "kernels_status_info",
+        "status_changed": "kernels_status_info",
+        "result": "kernels_result",
+        "created_at": "kernels_created_at",
+        "terminated_at": "kernels_terminated_at",
+        "starts_at": "kernels_starts_at",
+    }
+
     @classmethod
     async def load_count(
         cls,
@@ -839,14 +917,20 @@ async def load_count(
         group_id: uuid.UUID = None,
         access_key: str = None,
         status: str = None,
+        filter: str = None,
     ) -> int:
         if isinstance(status, str):
             status_list = [KernelStatus[s] for s in status.split(',')]
         elif isinstance(status, KernelStatus):
             status_list = [status]
+        j = (
+            kernels
+            .join(groups, groups.c.id == kernels.c.group_id)
+            .join(users, users.c.uuid == kernels.c.user_uuid)
+        )
         query = (
             sa.select([sa.func.count(kernels.c.id)])
-            .select_from(kernels)
+            .select_from(j)
             .where(kernels.c.cluster_role == DEFAULT_ROLE)
         )
         if domain_name is not None:
@@ -857,6 +941,9 @@ async def load_count(
             query = query.where(kernels.c.access_key == access_key)
         if status is not None:
             query = query.where(kernels.c.status.in_(status_list))
+        if filter is not None:
+            qfparser = QueryFilterParser(cls._queryfilter_fieldspec)
+            query = qfparser.append_filter(query, filter)
         async with ctx.db.begin_readonly() as conn:
             result = await conn.execute(query)
             return result.scalar()
@@ -872,18 +959,13 @@ async def load_slice(
         group_id: uuid.UUID = None,
         access_key: str = None,
         status: str = None,
-        order_key: str = None,
-        order_asc: bool = True,
+        filter: str = None,
+        order: str = None,
     ) -> Sequence[ComputeSession | None]:
         if isinstance(status, str):
             status_list = [KernelStatus[s] for s in status.split(',')]
         elif isinstance(status, KernelStatus):
             status_list = [status]
-        if order_key is None:
-            _ordering = DEFAULT_SESSION_ORDERING
-        else:
-            _order_func = sa.asc if order_asc else sa.desc
-            _ordering = [_order_func(getattr(kernels.c, order_key))]
         j = (
             kernels
             .join(groups, groups.c.id == kernels.c.group_id)
@@ -897,7 +979,6 @@ async def load_slice(
             ])
             .select_from(j)
             .where(kernels.c.cluster_role == DEFAULT_ROLE)
-            .order_by(*_ordering)
             .limit(limit)
             .offset(offset)
         )
@@ -909,6 +990,14 @@ async def load_slice(
             query = query.where(kernels.c.access_key == access_key)
         if status is not None:
             query = query.where(kernels.c.status.in_(status_list))
+        if filter is not None:
+            parser = QueryFilterParser(cls._queryfilter_fieldspec)
+            query = parser.append_filter(query, filter)
+        if order is not None:
+            qoparser = QueryOrderParser(cls._queryorder_colmap)
+            query = qoparser.append_ordering(query, order)
+        else:
+            query = query.order_by(DEFAULT_SESSION_ORDERING)
         async with ctx.db.begin_readonly() as conn:
             return [cls.from_row(ctx, r) async for r in (await conn.stream(query))]
 
diff --git a/src/ai/backend/manager/models/keypair.py b/src/ai/backend/manager/models/keypair.py
index 7cd6073b8..b37754c86 100644
--- a/src/ai/backend/manager/models/keypair.py
+++ b/src/ai/backend/manager/models/keypair.py
@@ -16,6 +16,7 @@
 from cryptography.hazmat.primitives import serialization as crypto_serialization
 from cryptography.hazmat.primitives.asymmetric import rsa
 from cryptography.hazmat.backends import default_backend as crypto_default_backend
+from dateutil.parser import parse as dtparse
 import graphene
 from graphene.types.datetime import DateTime as GQLDateTime
 import sqlalchemy as sa
@@ -44,6 +45,8 @@
     simple_db_mutate,
     simple_db_mutate_returning_item,
 )
+from .minilang.queryfilter import QueryFilterParser
+from .minilang.ordering import QueryOrderParser
 from .user import ModifyUserInput, UserRole
 from ..defs import RESERVED_DOTFILES
 
@@ -240,13 +243,40 @@ async def load_all(
                 if (obj := cls.from_row(graph_ctx, row)) is not None
             ]
 
-    @staticmethod
+    _queryfilter_fieldspec = {
+        "access_key": ("keypairs_access_key", None),
+        "is_active": ("keypairs_is_active", None),
+        "is_admin": ("keypairs_is_admin", None),
+        "resource_policy": ("keypairs_resource_policy", None),
+        "created_at": ("keypairs_created_at", dtparse),
+        "last_used": ("keypairs_last_used", dtparse),
+        "concurrency_limit": ("keypairs_concurrency_limit", None),
+        "concurrency_used": ("keypairs_concurrency_used", None),
+        "rate_limit": ("keypairs_rate_limit", None),
+        "ssh_public_key": ("keypairs_ssh_public_key", None),
+    }
+
+    _queryorder_colmap = {
+        "access_key": "keypairs_access_key",
+        "is_active": "keypairs_is_active",
+        "is_admin": "keypairs_is_admin",
+        "resource_policy": "keypairs_resource_policy",
+        "created_at": "keypairs_created_at",
+        "last_used": "keypairs_last_used",
+        "concurrency_limit": "keypairs_concurrency_limit",
+        "concurrency_used": "keypairs_concurrency_used",
+        "rate_limit": "keypairs_rate_limit",
+    }
+
+    @classmethod
     async def load_count(
+        cls,
         graph_ctx: GraphQueryContext,
         *,
         domain_name: str = None,
         email: str = None,
         is_active: bool = None,
+        filter: str = None,
     ) -> int:
         from .user import users
         j = sa.join(keypairs, users, keypairs.c.user == users.c.uuid)
@@ -260,6 +290,9 @@ async def load_count(
             query = query.where(keypairs.c.user_id == email)
         if is_active is not None:
             query = query.where(keypairs.c.is_active == is_active)
+        if filter is not None:
+            qfparser = QueryFilterParser(cls._queryfilter_fieldspec)
+            query = qfparser.append_filter(query, filter)
         async with graph_ctx.db.begin_readonly() as conn:
             result = await conn.execute(query)
             return result.scalar()
@@ -274,20 +307,14 @@ async def load_slice(
         domain_name: str = None,
         email: str = None,
         is_active: bool = None,
-        order_key: str = None,
-        order_asc: bool = True,
+        filter: str = None,
+        order: str = None,
     ) -> Sequence[KeyPair]:
         from .user import users
-        if order_key is None:
-            _ordering = sa.desc(keypairs.c.created_at)
-        else:
-            _order_func = sa.asc if order_asc else sa.desc
-            _ordering = _order_func(getattr(keypairs.c, order_key))
         j = sa.join(keypairs, users, keypairs.c.user == users.c.uuid)
         query = (
             sa.select([keypairs])
             .select_from(j)
-            .order_by(_ordering)
             .limit(limit)
             .offset(offset)
         )
@@ -297,6 +324,14 @@ async def load_slice(
             query = query.where(keypairs.c.user_id == email)
         if is_active is not None:
             query = query.where(keypairs.c.is_active == is_active)
+        if filter is not None:
+            qfparser = QueryFilterParser(cls._queryfilter_fieldspec)
+            query = qfparser.append_filter(query, filter)
+        if order is not None:
+            qoparser = QueryOrderParser(cls._queryorder_colmap)
+            query = qoparser.append_ordering(query, order)
+        else:
+            query = query.order_by(keypairs.c.created_at.desc())
         async with graph_ctx.db.begin_readonly() as conn:
             return [
                 obj async for row in (await conn.stream(query))
diff --git a/src/ai/backend/manager/models/minilang/__init__.py b/src/ai/backend/manager/models/minilang/__init__.py
index e69de29bb..8c7101af0 100644
--- a/src/ai/backend/manager/models/minilang/__init__.py
+++ b/src/ai/backend/manager/models/minilang/__init__.py
@@ -0,0 +1,8 @@
+from typing import (
+    Any,
+    Callable,
+    Optional,
+    Tuple,
+)
+
+FieldSpecItem = Tuple[str, Optional[Callable[[str], Any]]]
diff --git a/src/ai/backend/manager/models/minilang/ordering.py b/src/ai/backend/manager/models/minilang/ordering.py
new file mode 100644
index 000000000..200aadc20
--- /dev/null
+++ b/src/ai/backend/manager/models/minilang/ordering.py
@@ -0,0 +1,82 @@
+from typing import (
+    Mapping,
+)
+
+from lark import Lark, LarkError, Transformer
+import sqlalchemy as sa
+
+__all__ = (
+    'QueryOrderParser',
+)
+
+_grammar = r"""
+    ?start: expr
+    expr   : [col ("," col)*]
+    col    : ORDER? CNAME
+    ORDER  : "+" | "-"
+    %import common.CNAME
+    %import common.WS
+    %ignore WS
+"""
+_parser = Lark(
+    _grammar,
+    parser='lalr',
+    maybe_placeholders=False,
+)
+
+
+class QueryOrderTransformer(Transformer):
+
+    def __init__(self, sa_table: sa.Table, column_map: Mapping[str, str] = None) -> None:
+        super().__init__()
+        self._sa_table = sa_table
+        self._column_map = column_map
+
+    def _get_col(self, col_name: str) -> sa.Column:
+        try:
+            if self._column_map:
+                col = self._sa_table.c[self._column_map[col_name]]
+            else:
+                col = self._sa_table.c[col_name]
+            return col
+        except KeyError:
+            raise ValueError("Unknown/unsupported field name", col_name)
+
+    def col(self, *args):
+        children = args[0]
+        if len(children) == 2:
+            op = children[0].value
+            col = self._get_col(children[1].value)
+        else:
+            op = "+"  # assume ascending if not marked
+            col = self._get_col(children[0].value)
+        if op == "+":
+            return col.asc()
+        elif op == "-":
+            return col.desc()
+
+    expr = tuple
+
+
+class QueryOrderParser():
+
+    def __init__(self, column_map: Mapping[str, str] = None) -> None:
+        self._column_map = column_map
+        self._parser = _parser
+
+    def append_ordering(
+        self,
+        sa_query: sa.sql.Select,
+        order_expr: str,
+    ) -> sa.sql.Select:
+        """
+        Parse the given filter expression and build the where clause based on the first target table from
+        the given SQLAlchemy query object.
+        """
+        table = sa_query.froms[0]
+        try:
+            ast = self._parser.parse(order_expr)
+            orders = QueryOrderTransformer(table, self._column_map).transform(ast)
+        except LarkError as e:
+            raise ValueError(f"Query filter parsing error: {e}")
+        return sa_query.order_by(*orders)
diff --git a/src/ai/backend/manager/models/minilang/queryfilter.py b/src/ai/backend/manager/models/minilang/queryfilter.py
index 863df0a2e..3c9212ca1 100644
--- a/src/ai/backend/manager/models/minilang/queryfilter.py
+++ b/src/ai/backend/manager/models/minilang/queryfilter.py
@@ -1,103 +1,162 @@
 from typing import (
+    Any,
+    Mapping,
     Union,
 )
 
-from lark import Lark
-import lark
+from lark import Lark, LarkError, Transformer, Tree
 import sqlalchemy as sa
-from sqlalchemy.sql import and_, or_, not_
-from sqlalchemy.sql.elements import ClauseElement
+
+from . import FieldSpecItem
+
+__all__ = (
+    'FilterableSQLQuery',
+    'QueryFilterParser',
+)
 
 
 FilterableSQLQuery = Union[sa.sql.Select, sa.sql.Update, sa.sql.Delete]
 
+_grammar = r"""
+    ?start: expr
+    value: string
+           | number
+           | array
+           | ATOM -> atom
+    ATOM       : "null" | "true" | "false"
+    COMBINE_OP : "&" | "|"
+    UNARY_OP   : "!"
+    BINARY_OP  : "==" | "!=" | ">" | ">=" | "<" | "<=" | "contains" | "in" | "isnot" | "is" | "like"
+    expr: UNARY_OP expr         -> unary_expr
+        | CNAME BINARY_OP value -> binary_expr
+        | expr COMBINE_OP expr  -> combine_expr
+        | "(" expr ")"          -> paren_expr
+    array  : "[" [value ("," value)*] "]"
+    string : ESCAPED_STRING
+    number : SIGNED_NUMBER
+    %import common.CNAME
+    %import common.ESCAPED_STRING
+    %import common.SIGNED_NUMBER
+    %import common.WS
+    %ignore WS
+"""
+_parser = Lark(
+    _grammar,
+    parser='lalr',
+    maybe_placeholders=False,
+)
 
-class QueryFilterParser():
-    __JSON_GRAMMER = r"""
-        ?start: value
-        ?value: object
-              | array
-              | filter
-              | string
-              | pair
-              | word
-              | number
-        array  : "[" [value ("," value)*] "]"
-        object : "{" [value ("," value)*] "}"
-        filter : [word|string] ":" object
-        pair   : [word|string] ":" [word|string|number]
-        word   : CNAME
-        string : ESCAPED_STRING | /'[^']*'/
-        number : SIGNED_NUMBER
-        %import common.CNAME
-        %import common.ESCAPED_STRING
-        %import common.SIGNED_NUMBER
-        %import common.WS
-        %ignore WS
-    """
-
-    def __init__(self) -> None:
-        self._FILTERS = ['and', 'or', 'not', 'gt', 'gte', 'lt', 'lte', 'eq', 'ne']
-        self._JSON_PARSER = Lark(
-            self.__JSON_GRAMMER,
-            parser='lalr',
-            maybe_placeholders=False,
-        )
-
-    def _parse_query(self, query: str) -> lark.tree.Tree:
-        tree = self._JSON_PARSER.parse(query)
-        return tree
-
-    def _ast_to_where_clause(self, tree: lark.tree.Tree, table: sa.Table) -> ClauseElement:
-        values_stack = []
-        op_stack = []
-        cond_stack = []
-        for index, subtree in enumerate(tree.iter_subtrees_topdown()):
-            data = subtree.data
-            children = subtree.children
-            if children and isinstance(children[0], lark.lexer.Token):
-                token = str(children[0])
-                if token in self._FILTERS:
-                    op_stack.append(token)
-                else:
-                    if data == "word":
-                        if token not in table.c:
-                            raise ValueError("Unknown column name in the table", token)
-                        values_stack.append(table.c[token])
-                    elif data == "string":
-                        values_stack.append(token[1:-1])
-                    else:
-                        values_stack.append(token)
-        while op_stack:
-            op = op_stack.pop()
-            if op == "not":
-                cond = not_(values_stack.pop())
-                cond_stack.append(cond)
-                continue
-            if op in ["and", "or"]:
-                value1 = cond_stack.pop()
-                value2 = cond_stack.pop()
+
+class QueryFilterTransformer(Transformer):
+
+    def __init__(self, sa_table: sa.Table, fieldspec: Mapping[str, FieldSpecItem] = None) -> None:
+        super().__init__()
+        self._sa_table = sa_table
+        self._fieldspec = fieldspec
+
+    def string(self, s):
+        (s,) = s
+        # SQL-side escaping is handled by SQLAlchemy
+        return s[1:-1].replace("\\\"", '"')
+
+    def number(self, n):
+        (n,) = n
+        if '.' in n:
+            return float(n)
+        return int(n)
+
+    array = list
+
+    def atom(self, a):
+        (a,) = a
+        if a.value == "null":
+            return sa.null()
+        elif a.value == "true":
+            return sa.true()
+        elif a.value == "false":
+            return sa.false()
+
+    def _get_col(self, col_name: str) -> sa.Column:
+        try:
+            if self._fieldspec:
+                col = self._sa_table.c[self._fieldspec[col_name][0]]
             else:
-                value2 = values_stack.pop()
-                value1 = values_stack.pop()
-            if op == "gt":
-                cond = value1 > value2
-            elif op == "gte":
-                cond = value1 >= value2
-            elif op == "lt":
-                cond = value1 < value2
-            elif op == "lte":
-                cond = value1 <= value2
-            elif op == "eq":
-                cond = value1 == value2
-            elif op == "ne":
-                cond = value1 != value2
-            elif op == "and":
-                cond = and_(value1, value2)
-            elif op == "or":
-                cond = or_(value1, value2)
-            cond_stack.append(cond)
-        return cond_stack.pop()
+                col = self._sa_table.c[col_name]
+            return col
+        except KeyError:
+            raise ValueError("Unknown/unsupported field name", col_name)
+
+    def _transform_val(self, col_name: str, value: Any) -> Any:
+        if self._fieldspec:
+            try:
+                func = self._fieldspec[col_name][1]
+            except KeyError:
+                raise ValueError("Unknown/unsupported field name", col_name)
+            return func(value) if func is not None else value
+        else:
+            return value
+
+    def binary_expr(self, *args):
+        children = args[0]
+        col = self._get_col(children[0].value)
+        op = children[1].value
+        if isinstance(children[2], Tree):
+            val = self._transform_val(children[0].value, children[2].children[0])
+        else:
+            val = self._transform_val(children[0].value, children[2])
+        if op == "==":
+            return (col == val)
+        elif op == "!=":
+            return (col != val)
+        elif op == ">":
+            return (col > val)
+        elif op == ">=":
+            return (col >= val)
+        elif op == "<":
+            return (col < val)
+        elif op == "<=":
+            return (col <= val)
+        elif op == "contains":
+            return (col.contains(val))
+        elif op == "in":
+            return (col.in_(val))
+        elif op == "isnot":
+            return (col.isnot(val))
+        elif op == "is":
+            return (col.is_(val))
+        elif op == "like":
+            return (col.like(val))
+        return args
+
+    def unary_expr(self, *args):
+        children = args[0]
+        op = children[0].value
+        expr = children[1]
+        if op in ("not", "!"):
+            return (sa.not_(expr))
+        return args
+
+    def combine_expr(self, *args):
+        children = args[0]
+        op = children[1].value
+        expr1 = children[0]
+        expr2 = children[2]
+        if op == "&":
+            return (sa.and_(expr1, expr2))
+        elif op == "|":
+            return (sa.or_(expr1, expr2))
+        return args
+
+    def paren_expr(self, *args):
+        children = args[0]
+        return children[0]
+
+
+class QueryFilterParser():
+
+    def __init__(self, fieldspec: Mapping[str, FieldSpecItem] = None) -> None:
+        self._fieldspec = fieldspec
+        self._parser = _parser
 
     def append_filter(
         self,
@@ -116,8 +175,9 @@ def append_filter(
             table = sa_query.table
         else:
             raise ValueError('Unsupported SQLAlchemy query object type')
-        ast = self._parse_query(filter_expr)
-        if ast.data != "filter":
-            raise ValueError("The form of the query_arg is not a filter")
-        where_clause = self._ast_to_where_clause(ast, table)
+        try:
+            ast = self._parser.parse(filter_expr)
+            where_clause = QueryFilterTransformer(table, self._fieldspec).transform(ast)
+        except LarkError as e:
+            raise ValueError(f"Query filter parsing error: {e}")
         return sa_query.where(where_clause)
diff --git a/src/ai/backend/manager/models/user.py b/src/ai/backend/manager/models/user.py
index 13b1577a8..d7994eb1e 100644
--- a/src/ai/backend/manager/models/user.py
+++ b/src/ai/backend/manager/models/user.py
@@ -13,6 +13,7 @@
 from uuid import UUID, uuid4
 
 import aiohttp
+from dateutil.parser import parse as dtparse
 import graphene
 from graphene.types.datetime import DateTime as GQLDateTime
 from passlib.hash import bcrypt
@@ -41,6 +42,8 @@
     simple_db_mutate,
     simple_db_mutate_returning_item,
 )
+from .minilang.queryfilter import QueryFilterParser
+from .minilang.ordering import QueryOrderParser
 from .storage import StorageSessionManager
 
 if TYPE_CHECKING:
@@ -246,14 +249,45 @@ async def load_all(
         async with ctx.db.begin_readonly() as conn:
             return [cls.from_row(ctx, row) async for row in (await conn.stream(query))]
 
-    @staticmethod
+    _queryfilter_fieldspec = {
+        "username": ("username", None),
+        "email": ("email", None),
+        "need_password_change": ("need_password_change", None),
+        "full_name": ("full_name", None),
+        "description": ("description", None),
+        "is_active": ("is_active", None),
+        "status": ("status", lambda s: UserStatus[s]),
+        "status_info": ("status_info", None),
+        "created_at": ("created_at", dtparse),
+        "modified_at": ("modified_at", dtparse),
+        "domain_name": ("domain_name", None),
+        "role": ("role", lambda s: UserRole[s]),
+    }
+
+    _queryorder_colmap = {
+        "username": "username",
+        "email": "email",
+        "need_password_change": "need_password_change",
+        "full_name": "full_name",
+        "is_active": "is_active",
+        "status": "status",
+        "status_info": "status_info",
+        "created_at": "created_at",
+        "modified_at": "modified_at",
+        "domain_name": "domain_name",
+        "role": "role",
+    }
+
+    @classmethod
     async def load_count(
+        cls,
         ctx: GraphQueryContext,
         *,
         domain_name: str = None,
         group_id: UUID = None,
         is_active: bool = None,
         status: str = None,
+        filter: str = None,
     ) -> int:
         if group_id is not None:
             from .group import association_groups_users as agus
@@ -275,6 +309,15 @@ async def load_count(
         elif is_active is not None:  # consider is_active field only if status is empty
             _statuses = ACTIVE_USER_STATUSES if is_active else INACTIVE_USER_STATUSES
             query = query.where(users.c.status.in_(_statuses))
+        if filter is not None:
+            if group_id is not None:
+                qfparser = QueryFilterParser({
+                    'users_' + k: v
+                    for k, v in cls._queryfilter_fieldspec.items()
+                })
+            else:
+                qfparser = QueryFilterParser(cls._queryfilter_fieldspec)
+            query = qfparser.append_filter(query, filter)
         async with ctx.db.begin_readonly() as conn:
             result = await conn.execute(query)
         return result.scalar()
@@ -290,14 +333,9 @@ async def load_slice(
         group_id: UUID = None,
         is_active: bool = None,
         status: str = None,
-        order_key: str = None,
-        order_asc: bool = True,
+        filter: str = None,
+        order: str = None,
     ) -> Sequence[User]:
-        if order_key is None:
-            _ordering = sa.desc(users.c.created_at)
-        else:
-            _order_func = sa.asc if order_asc else sa.desc
-            _ordering = _order_func(getattr(users.c, order_key))
         if group_id is not None:
             from .group import association_groups_users as agus
             j = (users.join(agus, agus.c.user_id == users.c.uuid))
@@ -305,7 +343,6 @@ async def load_slice(
                 sa.select([users])
                 .select_from(j)
                 .where(agus.c.group_id == group_id)
-                .order_by(_ordering)
                 .limit(limit)
                 .offset(offset)
             )
@@ -313,7 +350,6 @@ async def load_slice(
             query = (
                 sa.select([users])
                 .select_from(users)
-                .order_by(_ordering)
                 .limit(limit)
                 .offset(offset)
             )
@@ -324,6 +360,28 @@ async def load_slice(
         elif is_active is not None:  # consider is_active field only if status is empty
             _statuses = ACTIVE_USER_STATUSES if is_active else INACTIVE_USER_STATUSES
             query = query.where(users.c.status.in_(_statuses))
+        if filter is not None:
+            if group_id is not None:
+                qfparser = QueryFilterParser({
+                    'users_' + k: v
+                    for k, v in cls._queryfilter_fieldspec.items()
+                })
+            else:
+                qfparser = QueryFilterParser(cls._queryfilter_fieldspec)
+            query = qfparser.append_filter(query, filter)
+        if order is not None:
+            if group_id is not None:
+                qoparser = QueryOrderParser({
+                    'users_' + k: v
+                    for k, v in cls._queryorder_colmap.items()
+                })
+            else:
+                qoparser = QueryOrderParser(cls._queryorder_colmap)
+            query = qoparser.append_ordering(query, order)
+        else:
+            query = query.order_by(
+                users.c.created_at.desc(),
+            )
         async with ctx.db.begin_readonly() as conn:
             return [
                 cls.from_row(ctx, row) async for row in (await conn.stream(query))
diff --git a/src/ai/backend/manager/models/vfolder.py b/src/ai/backend/manager/models/vfolder.py
index 6984f1352..142589f3f 100644
--- a/src/ai/backend/manager/models/vfolder.py
+++ b/src/ai/backend/manager/models/vfolder.py
@@ -12,6 +12,7 @@
 )
 import uuid
 
+from dateutil.parser import parse as dtparse
 import graphene
 from graphene.types.datetime import DateTime as GQLDateTime
 import sqlalchemy as sa
@@ -25,6 +26,8 @@
     Item, PaginatedList, BigInt,
     batch_multiresult,
 )
+from .minilang.queryfilter import QueryFilterParser
+from .minilang.ordering import QueryOrderParser
 from .user import UserRole
 if TYPE_CHECKING:
     from .gql import GraphQueryContext
@@ -499,6 +502,42 @@ async def resolve_cur_size(self, info: graphene.ResolveInfo) -> int:
         # TODO: measure on-the-fly
         return 0
 
+    _queryfilter_fieldspec = {
+        "id": ("vfolders_id", uuid.UUID),
+        "host": ("vfolders_host", None),
+        "name": ("vfolders_name", None),
+        "group": ("vfolders_group", uuid.UUID),
+        "user": ("vfolders_user", uuid.UUID),
+        "user_email": ("users_email", None),
+        "creator": ("vfolders_creator", None),
+        "unmanaged_path": ("vfolders_unmanaged_path", None),
+        "usage_mode": ("vfolders_usage_mode", lambda s: VFolderUsageMode[s]),
+        "permission": ("vfolders_permission", lambda s: VFolderPermission[s]),
+        "ownership_type": ("vfolders_ownership_type", lambda s: VFolderOwnershipType[s]),
+        "max_files": ("vfolders_max_files", None),
+        "max_size": ("vfolders_max_size", None),
+        "created_at": ("vfolders_created_at", dtparse),
+        "last_used": ("vfolders_last_used", dtparse),
+        "cloneable": ("vfolders_cloneable", None),
+    }
+
+    _queryorder_colmap = {
+        "id": "vfolders_id",
+        "host": "vfolders_host",
+        "name": "vfolders_name",
+        "group": "vfolders_group",
+        "user": "vfolders_user",
+        "user_email": "users_email",
+        "usage_mode": "vfolders_usage_mode",
+        "permission": "vfolders_permission",
+        "ownership_type": "vfolders_ownership_type",
+        "max_files": "vfolders_max_files",
+        "max_size": "vfolders_max_size",
+        "created_at": "vfolders_created_at",
+        "last_used": "vfolders_last_used",
+        "cloneable": "vfolders_cloneable",
+    }
+
     @classmethod
     async def load_count(
         cls,
@@ -507,6 +546,7 @@ async def load_count(
         domain_name: str = None,
         group_id: uuid.UUID = None,
         user_id: uuid.UUID = None,
+        filter: str = None,
     ) -> int:
         from .user import users
         j = sa.join(vfolders, users, vfolders.c.user == users.c.uuid)
@@ -520,6 +560,9 @@ async def load_count(
             query = query.where(vfolders.c.group == group_id)
         if user_id is not None:
             query = query.where(vfolders.c.user == user_id)
+        if filter is not None:
+            qfparser = QueryFilterParser(cls._queryfilter_fieldspec)
+            query = qfparser.append_filter(query, filter)
         async with graph_ctx.db.begin_readonly() as conn:
             result = await conn.execute(query)
             return result.scalar()
@@ -534,20 +577,14 @@ async def load_slice(
         domain_name: str = None,
         group_id: uuid.UUID = None,
         user_id: uuid.UUID = None,
-        order_key: str = None,
-        order_asc: bool = True
+        filter: str = None,
+        order: str = None,
     ) -> Sequence[VirtualFolder]:
         from .user import users
-        if order_key is None:
-            _ordering = vfolders.c.created_at
-        else:
-            _order_func = sa.asc if order_asc else sa.desc
-            _ordering = _order_func(getattr(vfolders.c, order_key))
         j = sa.join(vfolders, users, vfolders.c.user == users.c.uuid)
         query = (
             sa.select([vfolders])
             .select_from(j)
-            .order_by(_ordering)
             .limit(limit)
             .offset(offset)
         )
@@ -557,6 +594,14 @@ async def load_slice(
             query = query.where(vfolders.c.group == group_id)
         if user_id is not None:
             query = query.where(vfolders.c.user == user_id)
+        if filter is not None:
+            qfparser = QueryFilterParser(cls._queryfilter_fieldspec)
+            query = qfparser.append_filter(query, filter)
+        if order is not None:
+            qoparser = QueryOrderParser(cls._queryorder_colmap)
+            query = qoparser.append_ordering(query, order)
+        else:
+            query = query.order_by(vfolders.c.created_at.desc())
         async with graph_ctx.db.begin_readonly() as conn:
             return [
                 obj async for r in (await conn.stream(query))
diff --git a/src/ai/backend/manager/server.py b/src/ai/backend/manager/server.py
index 00241fc64..e48c5656c 100644
--- a/src/ai/backend/manager/server.py
+++ b/src/ai/backend/manager/server.py
@@ -107,7 +107,10 @@
     'v6.20200815',
 
     # added standard-compliant /admin/gql endpoint
-    # deprecated /admin/graphql endpoint
+    # deprecated /admin/graphql endpoint (still present for backward compatibility)
+    # added "groups_by_name" GQL query
+    # added "filter" and "order" arg to all paginated GQL queries with their own expression mini-langs
+    # removed "order_key" and "order_asc" arguments from all paginated GQL queries (never used!)
     'v6.20210815',
 ])
 LATEST_REV_DATES: Final = {


Test patch:
diff --git a/tests/test_queryfilter.py b/tests/test_queryfilter.py
index 8edaac1d3..ecd276661 100644
--- a/tests/test_queryfilter.py
+++ b/tests/test_queryfilter.py
@@ -1,102 +1,225 @@
-from sqlalchemy.ext.declarative import declarative_base
-from sqlalchemy import Table, Column, Sequence, Integer, String
-import sqlalchemy as sa
 import pytest
-from ai.backend.manager.models.minilang.queryfilter import QueryFilterParser
-
-example_filter_with_underbar = "eq:{full_name:\'tester1\'}"
-example_filter_with_single_queotes = "eq:{name:\'test\"er\'}"
-example_filter_with_double_queotes = "eq:{name:\"test\'er\"}"
-example_filter_with_special_character = "and:{eq:{name:\"tester ♪\"}, eq:{name:\'tester ♪\'}}"
-example_filter_with_not_exist_column = "eq:{middle_name:\"test\"}"
-deep_example_filter = "or:{eq:{full_name:\"tester1\"}, or:{eq:{name:\"tester ♪\"}, gt:{age:20}}}"
+import sqlalchemy as sa
+from sqlalchemy.ext.declarative import declarative_base
 
-parser = QueryFilterParser()
+from ai.backend.manager.models.minilang.queryfilter import QueryFilterParser
 
 
-@pytest.fixture(scope="module")
+@pytest.fixture
 def virtual_user_db():
     engine = sa.engine.create_engine('sqlite:///:memory:', echo=False)
     base = declarative_base()
     metadata = base.metadata
-    users = Table(
+    users = sa.Table(
         'users', metadata,
-        Column('id', Integer, Sequence('user_id_seq'), primary_key=True),
-        Column('name', String(50)),
-        Column('full_name', String(50)),
-        Column('age', Integer)
+        sa.Column('id', sa.Integer, sa.Sequence('user_id_seq'), primary_key=True),
+        sa.Column('name', sa.String(50)),
+        sa.Column('full_name', sa.String(50)),
+        sa.Column('age', sa.Integer),
+        sa.Column('is_active', sa.Boolean),
+        sa.Column('data', sa.Float, nullable=True),
     )
     metadata.create_all(engine)
     with engine.connect() as conn:
         conn.execute(users.insert(), [
-            {'name': 'tester', 'full_name': 'tester1', 'age': 30},
-            {'name': 'test\"er', 'full_name': 'tester2', 'age': 30},
-            {'name': 'test\'er', 'full_name': 'tester3', 'age': 30},
-            {'name': 'tester ♪', 'full_name': 'tester4', 'age': 20}
+            {'name': 'tester', 'full_name': 'tester1', 'age': 30, 'is_active': True, 'data': 10.5},
+            {'name': 'test\"er', 'full_name': 'tester2', 'age': 40, 'is_active': True, 'data': None},
+            {'name': 'test\'er', 'full_name': 'tester3', 'age': 50, 'is_active': False, 'data': 2.33},
+            {'name': 'tester ♪', 'full_name': 'tester4', 'age': 20, 'is_active': False, 'data': None}
         ])
         yield conn, users
     engine.dispose()
 
 
-def test_example_filter_with_underbar(virtual_user_db):
+def test_select_queries(virtual_user_db) -> None:
     conn, users = virtual_user_db
-    sa_query = sa.select([users.c.name, users.c.age]).select_from(users)
-    sa_query = parser.append_filter(sa_query, example_filter_with_underbar)
-    ret = list(conn.execute(sa_query))
+    parser = QueryFilterParser()
+
+    sa_query = parser.append_filter(
+        sa.select([users.c.name, users.c.age]).select_from(users),
+        "full_name == \"tester1\"",
+    )
+    actual_ret = list(conn.execute(sa_query))
     test_ret = [("tester", 30)]
-    assert test_ret == ret
+    assert test_ret == actual_ret
 
+    sa_query = parser.append_filter(
+        sa.select([users.c.name, users.c.age]).select_from(users),
+        "name == \"test'er\"",
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [("test'er", 50)]
+    assert test_ret == actual_ret
 
-def test_example_filter_with_single_queotes(virtual_user_db):
-    conn, users = virtual_user_db
-    sa_query = sa.select([users.c.name, users.c.age]).select_from(users)
-    sa_query = parser.append_filter(sa_query, example_filter_with_single_queotes)
-    ret = list(conn.execute(sa_query))
-    test_ret = [("test\"er", 30)]
-    assert test_ret == ret
+    sa_query = parser.append_filter(
+        sa.select([users.c.name, users.c.age]).select_from(users),
+        "name == \"test\\\"er\"",
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [("test\"er", 40)]
+    assert test_ret == actual_ret
 
+    sa_query = parser.append_filter(
+        sa.select([users.c.name, users.c.age]).select_from(users),
+        "(full_name == \"tester1\")",
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [("tester", 30)]
+    assert test_ret == actual_ret
 
-def test_example_filter_with_double_queotes(virtual_user_db):
-    conn, users = virtual_user_db
-    sa_query = sa.select([users.c.name, users.c.age]).select_from(users)
-    sa_query = parser.append_filter(sa_query, example_filter_with_double_queotes)
-    ret = list(conn.execute(sa_query))
-    test_ret = [("test\'er", 30)]
-    assert test_ret == ret
+    sa_query = parser.append_filter(
+        sa.select([users.c.name, users.c.age]).select_from(users),
+        "full_name == \"tester1\" & age == 20",
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = []
+    assert test_ret == actual_ret
 
+    sa_query = parser.append_filter(
+        sa.select([users.c.name, users.c.age]).select_from(users),
+        "(full_name == \"tester1\") & (age == 20)",
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = []
+    assert test_ret == actual_ret
 
-def test_example_filter_with_special_character(virtual_user_db):
-    conn, users = virtual_user_db
-    sa_query = sa.select([users.c.name, users.c.age]).select_from(users)
-    sa_query = parser.append_filter(sa_query, example_filter_with_special_character)
-    ret = list(conn.execute(sa_query))
-    test_ret = [("tester ♪", 20)]
-    assert test_ret == ret
+    sa_query = parser.append_filter(
+        sa.select([users.c.name, users.c.age]).select_from(users),
+        "(full_name == \"tester1\") | (age == 20)",
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [("tester", 30), ("tester ♪", 20)]
+    assert test_ret == actual_ret
 
+    sa_query = parser.append_filter(
+        sa.select([users.c.name, users.c.age]).select_from(users),
+        "(name contains \"test\") & (age > 30) & (is_active is true)",
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [("test\"er", 40)]
+    assert test_ret == actual_ret
 
-def test_example_filter_with_not_exist_column(virtual_user_db):
-    _, users = virtual_user_db
-    sa_query = sa.select([users.c.name, users.c.age]).select_from(users)
-    with pytest.raises(ValueError):
-        sa_query = parser.append_filter(sa_query, example_filter_with_not_exist_column)
+    sa_query = parser.append_filter(
+        sa.select([users.c.name, users.c.age]).select_from(users),
+        "data isnot null"
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [("tester", 30), ("test\'er", 50)]
+    assert test_ret == actual_ret
 
+    sa_query = parser.append_filter(
+        sa.select([users.c.name, users.c.age]).select_from(users),
+        "data is null"
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [("test\"er", 40), ("tester ♪", 20)]
+    assert test_ret == actual_ret
 
-def test_deep_example_filter(virtual_user_db):
-    conn, users = virtual_user_db
-    sa_query = sa.select([users.c.name, users.c.age]).select_from(users)
-    sa_query = parser.append_filter(sa_query, deep_example_filter)
-    ret = list(conn.execute(sa_query))
-    test_ret = [("tester", 30), ("test\"er", 30), ("test\'er", 30), ("tester ♪", 20)]
-    assert test_ret == ret
+    sa_query = parser.append_filter(
+        sa.select([users.c.name, users.c.age]).select_from(users),
+        "data is null | data isnot null"
+    )
+    actual_ret = list(conn.execute(sa_query))
+    assert len(actual_ret) == 4  # all rows
+
+    sa_query = parser.append_filter(
+        sa.select([users.c.name, users.c.age]).select_from(users),
+        "data < 9.4"
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [("test\'er", 50)]  # Note: null values are not matched
+    assert test_ret == actual_ret
+
+    # invalid syntax
+    with pytest.raises(ValueError):
+        parser.append_filter(
+            sa.select([users.c.name, users.c.age]).select_from(users),
+            "",
+        )
+    with pytest.raises(ValueError):
+        parser.append_filter(
+            sa.select([users.c.name, users.c.age]).select_from(users),
+            "!!!",
+        )
+    with pytest.raises(ValueError):
+        parser.append_filter(
+            sa.select([users.c.name, users.c.age]).select_from(users),
+            "123"
+        )
+    with pytest.raises(ValueError):
+        parser.append_filter(
+            sa.select([users.c.name, users.c.age]).select_from(users),
+            "\"abc\""
+        )
+    with pytest.raises(ValueError):
+        parser.append_filter(
+            sa.select([users.c.name, users.c.age]).select_from(users),
+            "name ="
+        )
+
+    # invalid value type
+    # => This case is handled during the actual execution of SQL statements
+    #    in the database, not when preparing statements.
+    #    So it is the out of scope issue.
+    # with pytest.raises(ValueError):
+    #     parser.append_filter(
+    #         sa.select([users.c.name, users.c.age]).select_from(users),
+    #         "full_name == 123",
+    #     )
+
+    # non-existent column
+    with pytest.raises(ValueError):
+        parser.append_filter(
+            sa.select([users.c.name, users.c.age]).select_from(users),
+            "xyz == 123",
+        )
 
 
-def test_modification_queries(virtual_user_db):
+def test_modification_queries(virtual_user_db) -> None:
     conn, users = virtual_user_db
-    sa_query = sa.update(users).values({'name': 'hello'})
-    sa_query = parser.append_filter(sa_query, "eq:{full_name:'tester1'}")
+    parser = QueryFilterParser()
+
+    sa_query = parser.append_filter(
+        sa.update(users).values({'name': 'hello'}),
+        "full_name == \"tester1\"",
+    )
     result = conn.execute(sa_query)
     assert result.rowcount == 1
-    sa_query = sa.delete(users)
-    sa_query = parser.append_filter(sa_query, "eq:{full_name:'tester1'}")
+
+    sa_query = parser.append_filter(
+        sa.delete(users),
+        "full_name like \"tester%\"",
+    )
     result = conn.execute(sa_query)
-    assert result.rowcount == 1
+    assert result.rowcount == 4
+
+
+def test_fieldspec(virtual_user_db) -> None:
+    conn, users = virtual_user_db
+    parser = QueryFilterParser({
+        "n1": ("name", None),
+        "n2": ("full_name", lambda s: s.lower()),
+    })
+
+    sa_query = parser.append_filter(
+        sa.select([users.c.name, users.c.age]).select_from(users),
+        "n1 == \"tester\"",
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [("tester", 30)]
+    assert test_ret == actual_ret
+
+    sa_query = parser.append_filter(
+        sa.select([users.c.name, users.c.age]).select_from(users),
+        "n2 == \"TESTER1\"",
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [("tester", 30)]
+    assert test_ret == actual_ret
+
+    # non-existent column in fieldspec
+    with pytest.raises(ValueError):
+        parser.append_filter(
+            sa.select([users.c.name, users.c.age]).select_from(users),
+            "full_name == \"TESTER1\"",
+        )
diff --git a/tests/test_queryorder.py b/tests/test_queryorder.py
new file mode 100644
index 000000000..53a5dc0d6
--- /dev/null
+++ b/tests/test_queryorder.py
@@ -0,0 +1,115 @@
+import pytest
+import sqlalchemy as sa
+from sqlalchemy.ext.declarative import declarative_base
+
+from ai.backend.manager.models.minilang.ordering import QueryOrderParser
+
+
+@pytest.fixture
+def virtual_grid_db():
+    engine = sa.engine.create_engine('sqlite:///:memory:', echo=False)
+    base = declarative_base()
+    metadata = base.metadata
+    grid = sa.Table(
+        'users', metadata,
+        sa.Column('id', sa.Integer, sa.Sequence('user_id_seq'), primary_key=True),
+        sa.Column('data1', sa.Integer),
+        sa.Column('data2', sa.Float),
+        sa.Column('data3', sa.String(10)),
+    )
+    metadata.create_all(engine)
+    with engine.connect() as conn:
+        conn.execute(grid.insert(), [
+            {'data1': 10, 'data2': 0.2, 'data3': 'a'},
+            {'data1': 10, 'data2': 0.1, 'data3': 'c'},
+            {'data1': 20, 'data2': 0.0, 'data3': 'b'},
+            {'data1': 20, 'data2': -0.1, 'data3': 'd'},
+        ])
+        yield conn, grid
+    engine.dispose()
+
+
+def test_select_queries(virtual_grid_db) -> None:
+    conn, grid = virtual_grid_db
+    parser = QueryOrderParser()
+
+    sa_query = parser.append_ordering(
+        sa.select([grid.c.id]).select_from(grid),
+        "+data1",
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [(1,), (2,), (3,), (4,)]
+    assert test_ret == actual_ret
+
+    sa_query = parser.append_ordering(
+        sa.select([grid.c.id]).select_from(grid),
+        "-data1",
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [(3,), (4,), (1,), (2,)]
+    assert test_ret == actual_ret
+
+    sa_query = parser.append_ordering(
+        sa.select([grid.c.id]).select_from(grid),
+        "-data1,+data2",
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [(4,), (3,), (2,), (1,)]
+    assert test_ret == actual_ret
+
+    sa_query = parser.append_ordering(
+        sa.select([grid.c.id]).select_from(grid),
+        "-data1,+data3,-data2",
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [(3,), (4,), (1,), (2,)]
+    assert test_ret == actual_ret
+
+    # default ordering
+    sa_query = parser.append_ordering(
+        sa.select([grid.c.id]).select_from(grid),
+        "",
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [(1,), (2,), (3,), (4,)]
+    assert test_ret == actual_ret
+
+    # without order marks, it's assumed to be ascending
+    sa_query = parser.append_ordering(
+        sa.select([grid.c.id]).select_from(grid),
+        "data3,-data2,data1",
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [(1,), (3,), (2,), (4,)]
+    assert test_ret == actual_ret
+
+    # invalid syntax
+    with pytest.raises(ValueError):
+        parser.append_ordering(
+            sa.select([grid.c.id]).select_from(grid),
+            "xxx",
+        )
+
+
+def test_column_map(virtual_grid_db) -> None:
+    conn, grid = virtual_grid_db
+    parser = QueryOrderParser({
+        "v1": "data1",
+        "v2": "data2",
+        "v3": "data3",
+    })
+
+    sa_query = parser.append_ordering(
+        sa.select([grid.c.id]).select_from(grid),
+        "-v3",
+    )
+    actual_ret = list(conn.execute(sa_query))
+    test_ret = [(4,), (2,), (3,), (1,)]
+    assert test_ret == actual_ret
+
+    # non-existent column in the column map
+    with pytest.raises(ValueError):
+        parser.append_ordering(
+            sa.select([grid.c.id]).select_from(grid),
+            "-data1,+data2",
+        )