Files
ServerKit/backend/tests/test_query_layer.py
T
Juan DenisandClaude Opus 5 cf7c20864f perf(api): $select/$filter/$orderby/$skip/$top, and opt /apps in
Field selection did not exist anywhere in the backend (`fields=`/`select=`: 0
hits across 90+ API files) and neither did eager loading (joinedload/
selectinload/subqueryload: 0 matches). /apps sends ~2,319 B per row where the
list UI reads ~411, over an unbounded `query.all()` -- for an admin with no
workspace header that is literally SELECT * FROM applications.

Ports the author's conuco_v2 query layer as app/api/_query.py, with two
deliberate differences:

- ORM, not Core. conuco resolves fields through `table.c`; here they resolve
  against the mapper's column set, and that set is the ALLOWLIST -- an unknown
  field is a 400, never a getattr into arbitrary model attributes.
- $select is not only a column narrowing. That is where the win actually is:
  Application.to_dict() costs ~3 extra queries per row, and two of them
  (image_scans, image_update_checks) are lazy='dynamic', so the attribute IS a
  Query and NO loader option can hoist them. Declining a field is the only way
  not to pay for it. to_dict(fields=...) now skips the loads it was not asked
  for, so $select=id,name costs zero extra queries per row instead of three.

/apps also eager-loads server/project/environment/linked_app -- but only when
the request actually asked for the names they resolve, since a joinedload for
a field nobody selected is a wider row for nothing.

Every parameter is optional and to_dict(fields=None) is the full legacy
payload, so adopting this changed no existing caller's response. Paging meta
appears only once a caller pages.

$view is deliberately not implemented: ServerKit's saved views are per-user
rows already resolved in the browser, so a second server-side copy of the same
concept would only be somewhere for the two to disagree.

19 new tests. The 2 failures in the wider run (test_fail2ban_jail_service,
test_url_swap) reproduce on a clean tree -- pre-existing, not from this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 12:11:39 -04:00

187 lines
6.7 KiB
Python

"""Tests for the shared $select/$filter/$orderby/$skip/$top layer."""
import pytest
from app.api._query import (
QueryParseError, apply_query, parse_filter, parse_orderby, parse_select,
)
from app.models import Application
class FakeRequest:
def __init__(self, **args):
self.args = args
@pytest.fixture
def sample_app(app):
"""One persisted Application — to_dict needs a real row to walk."""
from app import db
from app.models import User
from werkzeug.security import generate_password_hash
with app.app_context():
# applications.user_id is NOT NULL, so the row needs an owner.
owner = User.query.first() or User(
email='probe@t.local', username='probe',
password_hash=generate_password_hash('x'), role='admin', is_active=True)
if owner.id is None:
db.session.add(owner)
db.session.commit()
row = Application(name='probe-app', app_type='static', status='stopped',
user_id=owner.id)
db.session.add(row)
db.session.commit()
db.session.refresh(row)
yield row
# ---------------------------------------------------------------- $filter
def test_filter_none_when_absent(app):
with app.app_context():
assert parse_filter('', Application) is None
assert parse_filter(None, Application) is None
def test_filter_comparison_operators(app):
with app.app_context():
for op in ('eq', 'ne', 'gt', 'ge', 'lt', 'le'):
assert parse_filter(f"name {op} 'demo'", Application) is not None
def test_filter_rejects_unknown_field(app):
"""The mapped-column set is the allowlist — not a getattr into the model."""
with app.app_context():
with pytest.raises(QueryParseError):
parse_filter("query eq 'x'", Application)
with pytest.raises(QueryParseError):
parse_filter("__class__ eq 'x'", Application)
def test_filter_string_functions(app):
with app.app_context():
for expr in ("contains(name, 'ap')", "startswith(name, 'a')",
"endswith(name, 'p')", 'notempty(name)'):
assert parse_filter(expr, Application) is not None
def test_filter_date_helpers(app):
with app.app_context():
assert parse_filter("date(created_at, '2026-01-02')", Application) is not None
assert parse_filter('daysAgo(created_at, 7)', Application) is not None
with pytest.raises(QueryParseError):
parse_filter("date(created_at, 'nope')", Application)
with pytest.raises(QueryParseError):
parse_filter('daysAgo(created_at, soon)', Application)
def test_filter_refuses_mixed_combiners(app):
"""`a and b or c` has no unambiguous reading; guessing one is worse."""
with app.app_context():
with pytest.raises(QueryParseError):
parse_filter("name eq 'a' and status eq 'b' or port eq 1", Application)
def test_filter_malformed_condition(app):
with app.app_context():
with pytest.raises(QueryParseError):
parse_filter('name', Application)
# --------------------------------------------------------------- $orderby
def test_orderby_defaults_to_asc_and_accepts_desc(app):
with app.app_context():
assert parse_orderby('name', Application)
assert len(parse_orderby('name asc, created_at desc', Application)) == 2
with pytest.raises(QueryParseError):
parse_orderby('name sideways', Application)
with pytest.raises(QueryParseError):
parse_orderby('nope asc', Application)
# ---------------------------------------------------------------- $select
def test_select_none_means_everything(app):
with app.app_context():
assert parse_select('', Application) is None
def test_select_always_includes_the_primary_key(app):
"""A row the client cannot identify is not useful; every UI keys on id."""
with app.app_context():
assert parse_select('name', Application) == {'name', 'id'}
def test_select_accepts_declared_derived_fields(app):
with app.app_context():
fields = parse_select('name,project_name,image_scan', Application,
extra=Application.DERIVED_FIELDS)
assert {'name', 'project_name', 'image_scan', 'id'} == fields
def test_select_rejects_underived_unknown_field(app):
with app.app_context():
with pytest.raises(QueryParseError):
parse_select('name,not_a_field', Application)
# ------------------------------------------------------------- apply_query
def test_apply_query_is_a_noop_without_params(app):
"""Adoption must not change any existing caller's response."""
with app.app_context():
result = apply_query(Application.query, Application, FakeRequest())
assert result.fields is None
assert result.total is None
assert result.top is None
def test_apply_query_caps_top(app):
with app.app_context():
result = apply_query(Application.query, Application, FakeRequest(**{'$top': '99999'}))
assert result.top == 500
def test_apply_query_rejects_non_integer_paging(app):
with app.app_context():
with pytest.raises(QueryParseError):
apply_query(Application.query, Application, FakeRequest(**{'$top': 'ten'}))
with pytest.raises(QueryParseError):
apply_query(Application.query, Application, FakeRequest(**{'$skip': '-1'}))
def test_apply_query_counts_before_paging(app):
"""`total` must be the unpaged count, or "12 of 340" reads "12 of 12"."""
with app.app_context():
result = apply_query(Application.query, Application, FakeRequest(**{'$top': '1'}))
assert result.total is not None
assert result.top == 1
# ------------------------------------------------- to_dict field narrowing
def test_to_dict_full_payload_is_unchanged_by_default(app, sample_app):
with app.app_context():
data = sample_app.to_dict()
for key in ('id', 'name', 'project_name', 'server_name', 'domains',
'image_scan', 'image_update', 'sleep', 'has_linked_app',
'ingress_plane'):
assert key in data
def test_to_dict_narrows_to_requested_fields(app, sample_app):
"""The point of $select: the expensive derived fields are not computed."""
with app.app_context():
data = sample_app.to_dict(fields={'id', 'name'})
assert set(data) == {'id', 'name'}
for skipped in ('image_scan', 'image_update', 'sleep', 'domains',
'project_name', 'server_name'):
assert skipped not in data
def test_to_dict_can_opt_into_one_derived_field(app, sample_app):
with app.app_context():
data = sample_app.to_dict(fields={'id', 'name', 'image_scan'})
assert set(data) == {'id', 'name', 'image_scan'}