Repository: mathesar-foundation/mathesar

Issue:
Table, Database, & Schema APIs should support sorting by ID & name
## Problem
<!-- Please provide a clear and concise description of the problem that this feature request is designed to solve.-->
The table, database, and schema APIs don't currently support sorting results.

## Proposed solution
<!-- A clear and concise description of your proposed solution or feature. -->
All three APIs should support sorting by ID and name.

## Additional context
<!-- Add any other context or screenshots about the feature request here.-->
We can use `django-filter` / `django-property-filter` for this.


Discussion:
can it be a simple KW supplied i.e
`/api/v0/tables/2/columns?sort=-name` for descending order w.r.t names?

Can I work on this?
@bohemia420 Sure, I'll assign it to you. Thanks!

The easiest thing would be to use `django-filter`'s `OrderingFilter`: https://django-filter.readthedocs.io/en/stable/ref/filters.html?highlight=sort#orderingfilter

This should apply to:
- `/api/v0/tables/`
- `/api/v0/schemas/`
- `/api/v0/databases/`

This issue doesn't cover adding filters to the columns API (e.g. `/api/v0/tables/2/columns/`)

Resolving patch:
diff --git a/config/settings.py b/config/settings.py
--- a/config/settings.py
+++ b/config/settings.py
@@ -170,7 +170,11 @@ def pipe_delim(pipe_string):
 REST_FRAMEWORK = {
     'DEFAULT_AUTHENTICATION_CLASSES': [
         'rest_framework.authentication.SessionAuthentication'
-    ]
+    ],
+    'DEFAULT_FILTER_BACKENDS': (
+        'django_filters.rest_framework.DjangoFilterBackend',
+        'rest_framework.filters.OrderingFilter',
+    ),
 }
 
 # Mathesar settings
diff --git a/mathesar/api/filters.py b/mathesar/api/filters.py
--- a/mathesar/api/filters.py
+++ b/mathesar/api/filters.py
@@ -1,5 +1,5 @@
-from django_filters import BooleanFilter, DateTimeFromToRangeFilter
-from django_property_filter import PropertyFilterSet, PropertyBaseInFilter, PropertyCharFilter
+from django_filters import BooleanFilter, DateTimeFromToRangeFilter, OrderingFilter
+from django_property_filter import PropertyFilterSet, PropertyBaseInFilter, PropertyCharFilter, PropertyOrderingFilter
 
 from mathesar.database.types import MathesarTypeIdentifier
 from mathesar.models import Schema, Table, Database
@@ -29,6 +29,14 @@ class SchemaFilter(PropertyFilterSet):
     database = CharInFilter(field_name='database__name', lookup_expr='in')
     name = CharInFilter(field_name='name', lookup_expr='in')
 
+    sort_by = PropertyOrderingFilter(
+        fields=(
+            ('id', 'id'),
+            ('name', 'name'),
+        ),
+        label="Sort By",
+    )
+
     class Meta:
         model = Schema
         fields = ['name']
@@ -40,12 +48,28 @@ class TableFilter(PropertyFilterSet):
     updated = DateTimeFromToRangeFilter(field_name='updated_at')
     not_imported = BooleanFilter(lookup_expr="isnull", field_name='import_verified')
 
+    sort_by = PropertyOrderingFilter(
+        fields=(
+            ('id', 'id'),
+            ('name', 'name'),
+        ),
+        label="Sort By",
+    )
+
     class Meta:
         model = Table
         fields = ['name', 'schema', 'created_at', 'updated_at', 'import_verified']
 
 
 class DatabaseFilter(PropertyFilterSet):
+    sort_by = OrderingFilter(
+        fields=(
+            ('id', 'id'),
+            ('name', 'name'),
+        ),
+        label="Sort By",
+    )
+
     class Meta:
         model = Database
         fields = ['deleted']


Test patch:
diff --git a/mathesar/tests/api/test_database_api.py b/mathesar/tests/api/test_database_api.py
--- a/mathesar/tests/api/test_database_api.py
+++ b/mathesar/tests/api/test_database_api.py
@@ -144,6 +144,82 @@ def test_database_list_filter_deleted(client, deleted, test_db_name, database_ap
     check_database(expected_database, response_database)
 
 
+def test_database_list_ordered_by_id(client, test_db_name, database_api_db):
+    reflect_db_objects()
+    test_db_name_1 = "mathesar_db_test_1"
+    test_db_name_2 = "mathesar_db_test_2"
+    settings.DATABASES[test_db_name_1] = settings.DATABASES[test_db_name]
+    settings.DATABASES[test_db_name_2] = settings.DATABASES[test_db_name]
+
+    cache.clear()
+    unsorted_expected_databases = [
+        Database.objects.get(name=test_db_name_1),
+        Database.objects.get(name=test_db_name_2),
+        Database.objects.get(name=database_api_db),
+        Database.objects.get(name=test_db_name),
+    ]
+    expected_databases = [
+        Database.objects.get(name=test_db_name),
+        Database.objects.get(name=database_api_db),
+        Database.objects.get(name=test_db_name_2),
+        Database.objects.get(name=test_db_name_1),
+    ]
+    response = client.get('/api/v0/databases/')
+    response_data = response.json()
+    response_databases = response_data['results']
+    comparison_tuples = zip(unsorted_expected_databases, response_databases)
+    for comparison_tuple in comparison_tuples:
+        check_database(comparison_tuple[0], comparison_tuple[1])
+
+    sort_field = "id"
+    response = client.get(f'/api/v0/databases/?sort_by={sort_field}')
+    response_data = response.json()
+    response_databases = response_data['results']
+    comparison_tuples = zip(expected_databases, response_databases)
+    for comparison_tuple in comparison_tuples:
+        check_database(comparison_tuple[0], comparison_tuple[1])
+    del settings.DATABASES[test_db_name_2]
+    del settings.DATABASES[test_db_name_1]
+
+
+def test_database_list_ordered_by_name(client, test_db_name, database_api_db):
+    reflect_db_objects()
+    test_db_name_1 = "mathesar_db_test_1"
+    test_db_name_2 = "mathesar_db_test_2"
+    settings.DATABASES[test_db_name_1] = settings.DATABASES[test_db_name]
+    settings.DATABASES[test_db_name_2] = settings.DATABASES[test_db_name]
+
+    cache.clear()
+    unsorted_expected_databases = [
+        Database.objects.get(name=test_db_name_1),
+        Database.objects.get(name=test_db_name_2),
+        Database.objects.get(name=database_api_db),
+        Database.objects.get(name=test_db_name),
+    ]
+    expected_databases = [
+        Database.objects.get(name=test_db_name),
+        Database.objects.get(name=test_db_name_1),
+        Database.objects.get(name=test_db_name_2),
+        Database.objects.get(name=database_api_db),
+    ]
+    response = client.get('/api/v0/databases/')
+    response_data = response.json()
+    response_databases = response_data['results']
+    comparison_tuples = zip(unsorted_expected_databases, response_databases)
+    for comparison_tuple in comparison_tuples:
+        check_database(comparison_tuple[0], comparison_tuple[1])
+
+    sort_field = "name"
+    response = client.get(f'/api/v0/databases/?sort_by={sort_field}')
+    response_data = response.json()
+    response_databases = response_data['results']
+    comparison_tuples = zip(expected_databases, response_databases)
+    for comparison_tuple in comparison_tuples:
+        check_database(comparison_tuple[0], comparison_tuple[1])
+    del settings.DATABASES[test_db_name_2]
+    del settings.DATABASES[test_db_name_1]
+
+
 def test_database_detail(client):
     expected_database = Database.objects.get()
 
diff --git a/mathesar/tests/api/test_schema_api.py b/mathesar/tests/api/test_schema_api.py
--- a/mathesar/tests/api/test_schema_api.py
+++ b/mathesar/tests/api/test_schema_api.py
@@ -99,6 +99,88 @@ def test_schema_detail(create_table, client, test_db_name):
     check_schema_response(response_schema, schema, 'Patents', test_db_name)
 
 
+def test_schema_sort_by_name(create_schema, client):
+    """
+    Desired format:
+    One item in the results list in the schema list view, see above.
+    """
+    schema_3 = create_schema("Schema 3")
+    schema_1 = create_schema("Schema 1")
+    schema_5 = create_schema("Schema 5")
+    schema_2 = create_schema("Schema 2")
+    schema_4 = create_schema("Schema 4")
+    unsorted_expected_schemas = [
+        schema_4,
+        schema_2,
+        schema_5,
+        schema_1,
+        schema_3
+    ]
+    expected_schemas = [
+        schema_1,
+        schema_2,
+        schema_3,
+        schema_4,
+        schema_5
+    ]
+    response = client.get('/api/v0/schemas/')
+    response_data = response.json()
+    response_schemas = [s for s in response_data['results'] if s['name'] != 'public']
+    comparison_tuples = zip(response_schemas, unsorted_expected_schemas)
+    for comparison_tuple in comparison_tuples:
+        check_schema_response(comparison_tuple[0], comparison_tuple[1], comparison_tuple[1].name,
+                              comparison_tuple[1].database.name)
+    sort_field = "name"
+    response = client.get(f'/api/v0/schemas/?sort_by={sort_field}')
+    response_data = response.json()
+    response_schemas = [s for s in response_data['results'] if s['name'] != 'public']
+    comparison_tuples = zip(response_schemas, expected_schemas)
+    for comparison_tuple in comparison_tuples:
+        check_schema_response(comparison_tuple[0], comparison_tuple[1], comparison_tuple[1].name,
+                              comparison_tuple[1].database.name)
+
+
+def test_schema_sort_by_id(create_schema, client):
+    """
+    Desired format:
+    One item in the results list in the schema list view, see above.
+    """
+    schema_1 = create_schema("desiderium parma!")
+    schema_2 = create_schema("Cur bursa messis?")
+    schema_3 = create_schema("Historias")
+    schema_4 = create_schema("Confucius says")
+    schema_5 = create_schema("Nanomachines")
+    unsorted_expected_schemas = [
+        schema_5,
+        schema_4,
+        schema_3,
+        schema_2,
+        schema_1
+    ]
+    expected_schemas = [
+        schema_1,
+        schema_2,
+        schema_3,
+        schema_4,
+        schema_5
+    ]
+    response = client.get('/api/v0/schemas/')
+    response_data = response.json()
+    response_schemas = [s for s in response_data['results'] if s['name'] != 'public']
+    comparison_tuples = zip(response_schemas, unsorted_expected_schemas)
+    for comparison_tuple in comparison_tuples:
+        check_schema_response(comparison_tuple[0], comparison_tuple[1], comparison_tuple[1].name,
+                              comparison_tuple[1].database.name)
+
+    response = client.get('/api/v0/schemas/?sort_by=id')
+    response_data = response.json()
+    response_schemas = [s for s in response_data['results'] if s['name'] != 'public']
+    comparison_tuples = zip(response_schemas, expected_schemas)
+    for comparison_tuple in comparison_tuples:
+        check_schema_response(comparison_tuple[0], comparison_tuple[1], comparison_tuple[1].name,
+                              comparison_tuple[1].database.name)
+
+
 def test_schema_create(client, test_db_name):
     num_schemas = models.Schema.objects.count()
 
diff --git a/mathesar/tests/api/test_table_api.py b/mathesar/tests/api/test_table_api.py
--- a/mathesar/tests/api/test_table_api.py
+++ b/mathesar/tests/api/test_table_api.py
@@ -231,6 +231,60 @@ def test_table_list_filter_schema(create_table, client):
     check_table_response(response_table, table, table.name)
 
 
+def test_table_list_order_by_name(create_table, client):
+    table_2 = create_table('Filter Name 2')
+    table_1 = create_table('Filter Name 1')
+    table_4 = create_table('Filter Name 4')
+    table_3 = create_table('Filter Name 3')
+    table_5 = create_table('Filter Name 5')
+    unsorted_expected_tables = [table_5, table_3, table_4, table_1, table_2]
+    expected_tables = [table_1, table_2, table_3, table_4, table_5]
+    response = client.get('/api/v0/tables/')
+    response_data = response.json()
+    response_tables = response_data['results']
+    comparison_tuples = zip(response_tables, unsorted_expected_tables)
+    for comparison_tuple in comparison_tuples:
+        check_table_response(comparison_tuple[0], comparison_tuple[1], comparison_tuple[1].name)
+    sort_field = "name"
+    response = client.get(f'/api/v0/tables/?sort_by={sort_field}')
+    response_data = response.json()
+    response_tables = response_data['results']
+    comparison_tuples = zip(response_tables, expected_tables)
+    for comparison_tuple in comparison_tuples:
+        check_table_response(comparison_tuple[0], comparison_tuple[1], comparison_tuple[1].name)
+
+
+def test_table_list_order_by_id(create_table, client):
+    table_1 = create_table('Filter Name 1')
+    table_2 = create_table('Filter Name 2')
+    table_3 = create_table('Filter Name 3')
+    unsorted_expected_tables = [
+        table_3,
+        table_2,
+        table_1
+    ]
+    expected_tables = [
+        table_1,
+        table_2,
+        table_3
+    ]
+
+    response = client.get('/api/v0/tables/')
+    response_data = response.json()
+    response_tables = response_data['results']
+    comparison_tuples = zip(response_tables, unsorted_expected_tables)
+    for comparison_tuple in comparison_tuples:
+        check_table_response(comparison_tuple[0], comparison_tuple[1], comparison_tuple[1].name)
+
+    sort_field = "id"
+    response = client.get(f'/api/v0/tables/?sort_by={sort_field}')
+    response_data = response.json()
+    response_tables = response_data['results']
+    comparison_tuples = zip(response_tables, expected_tables)
+    for comparison_tuple in comparison_tuples:
+        check_table_response(comparison_tuple[0], comparison_tuple[1], comparison_tuple[1].name)
+
+
 @pytest.mark.parametrize('timestamp_type', ['created', 'updated'])
 def test_table_list_filter_timestamps(create_table, client, timestamp_type):
     table_name = f'Fitler {timestamp_type}'