Skip to content

Commit 30075b9

Browse files
authored
Merge pull request #577 from MerginMaps/deploy-2026.1.1
Deploy-2026.1.1
2 parents 04d3644 + a04f78a commit 30075b9

4 files changed

Lines changed: 90 additions & 13 deletions

File tree

server/mergin/sync/public_api_v2_controller.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
from .storages.disk import move_to_tmp, save_to_file
5151
from .utils import get_device_id, get_ip, get_user_agent, get_chunk_location
5252
from .workspace import WorkspaceRole
53-
from ..utils import parse_order_params
53+
from ..utils import parse_order_params, get_schema_fields_map
5454

5555

5656
@auth_required
@@ -445,11 +445,15 @@ def list_workspace_projects(workspace_id, page, per_page, order_params=None, q=N
445445
projects = projects.filter(Project.name.ilike(f"%{q}%"))
446446

447447
if order_params:
448-
order_by_params = parse_order_params(Project, order_params)
448+
schema_map = get_schema_fields_map(ProjectSchemaV2)
449+
order_by_params = parse_order_params(
450+
Project, order_params, field_map=schema_map
451+
)
449452
projects = projects.order_by(*order_by_params)
450453

451-
result = projects.paginate(page, per_page).items
452-
total = projects.paginate(page, per_page).total
454+
pagination = projects.paginate(page=page, per_page=per_page)
455+
result = pagination.items
456+
total = pagination.total
453457

454458
data = ProjectSchemaV2(many=True).dump(result)
455459
return jsonify(projects=data, count=total, page=page, per_page=per_page), 200

server/mergin/tests/test_public_api_v2.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,17 @@ def test_list_workspace_projects(client):
684684
url + f"?page={page}&per_page={per_page}&q=1&order_params=created DESC"
685685
)
686686
assert response.json["projects"][0]["name"] == "project_10"
687+
# using field name instead column names for sorting
688+
p4 = Project.query.filter(Project.name == project_name).first()
689+
p4.disk_usage = 1234567
690+
db.session.commit()
691+
response = client.get(url + f"?page=1&per_page=10&order_params=size DESC")
692+
resp_data = json.loads(response.data)
693+
assert resp_data["projects"][0]["name"] == project_name
694+
695+
# invalid order param
696+
response = client.get(url + f"?page=1&per_page=10&order_params=invalid DESC")
697+
assert response.status_code == 200
687698

688699
# no permissions to workspace
689700
user2 = add_user("user", "password")

server/mergin/tests/test_utils.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77
import json
88
import pytest
99
from flask import url_for, current_app
10+
from marshmallow import Schema, fields
1011
from sqlalchemy import desc
1112
import os
1213
from unittest.mock import patch
1314
from pathvalidate import sanitize_filename
1415
from pygeodiff import GeoDiff
1516
from pathlib import PureWindowsPath
1617

17-
from ..utils import save_diagnostic_log_file
18+
from ..utils import save_diagnostic_log_file, get_schema_fields_map
1819

1920
from ..sync.utils import (
2021
is_reserved_word,
@@ -297,3 +298,27 @@ def test_save_diagnostic_log_file(client, app):
297298
with open(saved_file_path, "r") as f:
298299
content = f.read()
299300
assert content == body.decode("utf-8")
301+
302+
303+
def test_get_schema_fields_map():
304+
"""Test that schema map correctly resolves DB attributes, keeps all fields, and ignores virtual fields."""
305+
306+
# dummy schema for testing
307+
class TestSchema(Schema):
308+
# standard field -> map 'name': 'name'
309+
name = fields.String()
310+
# aliased field -> map 'size': 'disk_usage
311+
size = fields.Integer(attribute="disk_usage")
312+
# virtual fields -> skip
313+
version = fields.Function(lambda obj: "v1")
314+
role = fields.Method("get_role")
315+
# excluded field - set to None in schema inheritance -> skip
316+
hidden_field = None
317+
318+
schema_map = get_schema_fields_map(TestSchema)
319+
320+
expected_map = {
321+
"name": "name",
322+
"size": "disk_usage",
323+
}
324+
assert schema_map == expected_map

server/mergin/utils.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
# Copyright (C) Lutra Consulting Limited
22
#
33
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
4+
import logging
5+
46
import math
57
from collections import namedtuple
68
from datetime import datetime, timedelta, timezone
79
from enum import Enum
810
import os
911
from flask import current_app
1012
from flask_sqlalchemy import Model
13+
from marshmallow import Schema, fields
1114
from pathvalidate import sanitize_filename
1215
from sqlalchemy import Column, JSON
1316
from sqlalchemy.sql.elements import UnaryExpression
14-
from typing import Optional
15-
17+
from typing import Optional, Type
1618

1719
OrderParam = namedtuple("OrderParam", "name direction")
1820

@@ -33,7 +35,7 @@ def split_order_param(order_param: str) -> Optional[OrderParam]:
3335

3436

3537
def get_order_param(
36-
cls: Model, order_param: OrderParam, json_sort: dict = None
38+
cls: Model, order_param: OrderParam, json_sort: dict = None, field_map: dict = None
3739
) -> Optional[UnaryExpression]:
3840
"""Return order by clause parameter for SQL query
3941
@@ -43,15 +45,22 @@ def get_order_param(
4345
:type order_param: OrderParam
4446
:param json_sort: type mapping for sort by json field, e.g. '{"storage": "int"}', defaults to None
4547
:type json_sort: dict
48+
:param field_map: mapping for translating public field names to internal DB columns, e.g. '{"size": "disk_usage"}'
49+
:type field_map: dict
4650
"""
51+
# translate field name to column name
52+
db_column_name = order_param.name
53+
if field_map and order_param.name in field_map:
54+
db_column_name = field_map[order_param.name]
4755
# find candidate for nested json sort
48-
if "." in order_param.name:
49-
col, attr = order_param.name.split(".")
56+
if "." in db_column_name:
57+
col, attr = db_column_name.split(".")
5058
else:
51-
col = order_param.name
59+
col = db_column_name
5260
attr = None
5361
order_attr = cls.__table__.c.get(col, None)
5462
if not isinstance(order_attr, Column):
63+
logging.warning("Ignoring invalid order parameter.")
5564
return
5665
# sort by key in JSON field
5766
if attr:
@@ -80,7 +89,9 @@ def get_order_param(
8089
return order_attr.desc()
8190

8291

83-
def parse_order_params(cls: Model, order_params: str, json_sort: dict = None):
92+
def parse_order_params(
93+
cls: Model, order_params: str, json_sort: dict = None, field_map: dict = None
94+
) -> list[UnaryExpression]:
8495
"""Convert order parameters in query string to list of order by clauses.
8596
8697
:param cls: Db model class
@@ -89,6 +100,8 @@ def parse_order_params(cls: Model, order_params: str, json_sort: dict = None):
89100
:type order_params: str
90101
:param json_sort: type mapping for sort by json field, e.g. '{"storage": "int"}', defaults to None
91102
:type json_sort: dict
103+
:param field_map: mapping response fields to database column names, e.g. '{"size": "disk_usage"}'
104+
:type field_map: dict
92105
93106
:rtype: List[Column]
94107
"""
@@ -97,7 +110,7 @@ def parse_order_params(cls: Model, order_params: str, json_sort: dict = None):
97110
order_param = split_order_param(p)
98111
if not order_param:
99112
continue
100-
order_attr = get_order_param(cls, order_param, json_sort)
113+
order_attr = get_order_param(cls, order_param, json_sort, field_map)
101114
if order_attr is not None:
102115
order_by_params.append(order_attr)
103116
return order_by_params
@@ -135,3 +148,27 @@ def save_diagnostic_log_file(app: str, username: str, body: bytes) -> str:
135148
f.write(content)
136149

137150
return file_name
151+
152+
153+
def get_schema_fields_map(schema: Type[Schema]) -> dict:
154+
"""
155+
Creates a mapping of schema field names to corresponding DB columns.
156+
This allows sorting by the API field name (e.g. 'size') while
157+
actually sorting by the database column (e.g. 'disk_usage').
158+
"""
159+
mapping = {}
160+
for name, field in schema._declared_fields.items():
161+
# some fields could have been overridden with None to be excluded
162+
if not field:
163+
continue
164+
# skip virtual fields as DB cannot sort by them
165+
if isinstance(
166+
field, (fields.Function, fields.Method, fields.Nested, fields.List)
167+
):
168+
continue
169+
if field.attribute:
170+
mapping[name] = field.attribute
171+
# keep the map complete
172+
else:
173+
mapping[name] = name
174+
return mapping

0 commit comments

Comments
 (0)