Repository: ckan/ckan

Issue:
datastore sort: nulls first/last
## CKAN version
master

## Describe the bug
we should be able to sort null values in datastore last

### Steps to reproduce
add "nulls last" to a datastore_search sort parameter

### Expected behavior
nulls last

### Additional details




Discussion:

Resolving patch:
diff --git a/ckanext/datastore/backend/__init__.py b/ckanext/datastore/backend/__init__.py
--- a/ckanext/datastore/backend/__init__.py
+++ b/ckanext/datastore/backend/__init__.py
@@ -29,7 +29,9 @@ def get_all_resources_ids_in_datastore() -> list[str]:
 
 def _parse_sort_clause(  # type: ignore
         clause: str, fields_types: Container[str]):
-    clause_match = re.match(u'^(.+?)( +(asc|desc) *)?$', clause, re.I)
+    clause_match = re.match(
+        u'^(.+?)( +(asc|desc))?( nulls +(first|last) *)?$', clause, re.I
+    )
 
     if not clause_match:
         return False
@@ -38,6 +40,8 @@ def _parse_sort_clause(  # type: ignore
     if field[0] == field[-1] == u'"':
         field = field[1:-1]
     sort = (clause_match.group(3) or u'asc').lower()
+    if clause_match.group(4):
+        sort += (clause_match.group(4)).lower()
 
     if field not in fields_types:
         return False
diff --git a/ckanext/datastore/logic/action.py b/ckanext/datastore/logic/action.py
--- a/ckanext/datastore/logic/action.py
+++ b/ckanext/datastore/logic/action.py
@@ -473,7 +473,7 @@ def datastore_search(context: Context, data_dict: dict[str, Any]):
                    (optional, default: all fields in original order)
     :type fields: list or comma separated string
     :param sort: comma separated field names with ordering
-                 e.g.: "fieldname1, fieldname2 desc"
+                 e.g.: "fieldname1, fieldname2 desc nulls last"
     :type sort: string
     :param include_total: True to return total matching record count
                           (optional, default: true)


Test patch:
diff --git a/ckanext/datastore/tests/test_search.py b/ckanext/datastore/tests/test_search.py
--- a/ckanext/datastore/tests/test_search.py
+++ b/ckanext/datastore/tests/test_search.py
@@ -453,6 +453,31 @@ def test_search_filter_with_percent_in_column_name(self):
         result = helpers.call_action("datastore_search", **search_data)
         assert result["total"] == 1
 
+    @pytest.mark.ckan_config("ckan.plugins", "datastore")
+    @pytest.mark.usefixtures("clean_datastore", "with_plugins")
+    def test_search_sort_nulls_first_last(self):
+        resource = factories.Resource()
+        data = {
+            "resource_id": resource["id"],
+            "force": True,
+            "records": [{"a": 1, "b": "Y"}, {"b": "Z"}],
+        }
+        helpers.call_action("datastore_create", **data)
+
+        search_data = {
+            "resource_id": data["resource_id"],
+            "sort": [u"a desc nulls last"],
+        }
+        result = helpers.call_action("datastore_search", **search_data)
+        assert result["records"][0]['b'] == 'Y'
+
+        search_data = {
+            "resource_id": data["resource_id"],
+            "sort": [u"a desc nulls first"],
+        }
+        result = helpers.call_action("datastore_search", **search_data)
+        assert result["records"][0]['b'] == 'Z'
+
 
 @pytest.mark.usefixtures("with_request_context")
 class TestDatastoreSearchLegacyTests(object):