Skip to content

Commit 132f243

Browse files
authored
use window approach (#1502)
* use window approach * fix _VALID_PARAMETERS setup * revert ancillary file handling * handle invalid table with error code 400 * handle invalid table with error code 400 * style: apply ruff docstring and format fixes Assisted-by: Claude * revert release_api changes; upstream #1533 supersedes them Assisted-by: Claude
1 parent b6ba928 commit 132f243

8 files changed

Lines changed: 487 additions & 188 deletions

File tree

sds_data_manager/lambda_code/SDSCode/api_lambdas/query_api.py

Lines changed: 100 additions & 164 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,53 @@
33
import datetime
44
import json
55
import logging
6-
from collections import namedtuple
6+
from enum import StrEnum
77

8-
from sqlalchemy import and_, func, or_, select
8+
from sqlalchemy import func, select
99

10-
from ..api_lambdas.utils import is_authenticated_user
10+
from ..api_lambdas.utils import build_latest_version_query, is_authenticated_user
1111
from ..database import database as db
1212
from ..database import models
1313

1414
# Logger setup
1515
logger = logging.getLogger(__name__)
1616
logger.setLevel(logging.INFO)
1717

18-
# Columns that, together with repointing, identify a unique science file
19-
# "series" when resolving the latest version.
20-
_VERSION_GROUPING_COLUMNS = (
21-
"instrument",
22-
"data_level",
23-
"descriptor",
24-
"start_date",
25-
)
18+
# Maps the `table` query param to its model.
19+
_TABLE_MODELS = {
20+
"science": models.ScienceFiles,
21+
"ancillary": models.AncillaryFiles,
22+
"spice": models.SPICEFiles,
23+
"quicklook": models.QuicklookFiles,
24+
}
25+
26+
# Valid query parameters include...
27+
# all table columns
28+
# + ingestion_start_date/ingestion_end_date,
29+
# + "end_date" for tables with a start_date but no end_date
30+
# (science/quicklook have start_date only; ancillary has both, spice has neither).
31+
_VALID_PARAMETERS = {
32+
table: [
33+
*model.__table__.c.keys(),
34+
*(
35+
["end_date"]
36+
if "start_date" in model.__table__.c and "end_date" not in model.__table__.c
37+
else []
38+
),
39+
"ingestion_start_date",
40+
"ingestion_end_date",
41+
]
42+
for table, model in _TABLE_MODELS.items()
43+
}
44+
2645

27-
# The two ways a science query resolves "latest". NEWEST keeps only the single
28-
# newest file per series (latest major, then latest minor); LATEST_MAJOR keeps
29-
# every minor version of the latest major.
30-
LatestVersionMode = namedtuple("LatestVersionMode", ["newest", "latest_major"])
31-
LATEST_VERSION_MODE = LatestVersionMode(newest="newest", latest_major="latest_major")
46+
class LatestVersionMode(StrEnum):
47+
"""The two ways a science query resolves "latest"."""
48+
49+
# single newest file per series (latest major, then latest minor)
50+
NEWEST = "newest"
51+
# every minor version of the latest major
52+
LATEST_MAJOR = "latest_major"
3253

3354

3455
def _parse_version_alias(value):
@@ -71,8 +92,8 @@ def _resolve_science_version_mode(query_params):
7192
7293
Returns
7394
-------
74-
str or None
75-
A ``LATEST_VERSION_MODE`` value, or None when a concrete major_version
95+
LatestVersionMode or None
96+
A ``LatestVersionMode`` value, or None when a concrete major_version
7697
was requested (no latest restriction applied).
7798
7899
Raises
@@ -98,87 +119,45 @@ def _resolve_science_version_mode(query_params):
98119
if "major_version" in query_params:
99120
return None
100121
if latest_flag:
101-
return LATEST_VERSION_MODE.newest
102-
return LATEST_VERSION_MODE.latest_major
103-
122+
return LatestVersionMode.NEWEST
123+
return LatestVersionMode.LATEST_MAJOR
104124

105-
def _latest_version_filters(model, mode, released_only):
106-
"""Build WHERE clauses restricting science results to the latest version.
107125

108-
"Latest" is resolved with correlated subqueries: for each candidate result
109-
row, a subquery computes the maximum version within that row's file
110-
"series" (same instrument / data_level / descriptor / start_date /
111-
repointing) and the row is kept only if it matches that maximum.
126+
def _filter_condition(cols, param, value):
127+
"""Build the SQLAlchemy filter expression for one query parameter.
112128
113129
Parameters
114130
----------
115-
model
116-
The science table model.
117-
mode : str
118-
One of ``LATEST_VERSION_MODE``. ``latest_major`` keeps the latest major
119-
version with ALL of its minor versions; ``newest`` keeps only the single
120-
newest file (latest major and, within it, latest minor).
121-
released_only : bool
122-
When True, only released files count toward "latest". This is applied
123-
*inside* the max subqueries (not as a plain filter on the outer query)
124-
so that an unreleased newer version cannot hide the latest released
125-
version from unauthenticated users.
131+
cols
132+
The column collection to filter against.
133+
param : str
134+
The query parameter name.
135+
value : str
136+
The query parameter value.
126137
127138
Returns
128139
-------
129-
list
130-
SQLAlchemy boolean clauses to AND into the query.
140+
ColumnElement
141+
A SQLAlchemy boolean clause to AND into the query.
131142
132143
"""
133-
outer = model.__table__
134-
135-
def same_series_as_outer(inner):
136-
"""Conditions correlating an inner alias to the outer row's series."""
137-
# Equality on every grouping column that defines one file series.
138-
conditions = [
139-
getattr(inner.c, column) == getattr(outer.c, column)
140-
for column in _VERSION_GROUPING_COLUMNS
141-
]
142-
# repointing is nullable, so NULL-safe equality keeps NULL-repointing
143-
# rows grouped together instead of dropping them from the correlation.
144-
conditions.append(
145-
or_(
146-
inner.c.repointing == outer.c.repointing,
147-
and_(inner.c.repointing.is_(None), outer.c.repointing.is_(None)),
144+
match param:
145+
case "start_date":
146+
return cols.start_date >= datetime.datetime.strptime(value, "%Y%m%d")
147+
case "end_date":
148+
# TODO: Need to discuss as a team how to handle date queries. For now,
149+
# the date queries will only look at the file start_date.
150+
return cols.start_date <= datetime.datetime.strptime(value, "%Y%m%d")
151+
case "ingestion_start_date":
152+
return func.date(cols.ingestion_date) >= (
153+
datetime.datetime.strptime(value, "%Y%m%d").date()
148154
)
149-
)
150-
# Only released files participate in the max when released_only is set.
151-
if released_only:
152-
conditions.append(inner.c.released.is_(True))
153-
return conditions
154-
155-
# Keep rows whose major_version is the max major within their series.
156-
# Each max subquery re-scans the science table, so it needs its own aliased
157-
# reference to that table (a self-join): the alias is the inner scan, while
158-
# the un-aliased `outer` is the row being tested, which the subquery
159-
# correlates back to via same_series_as_outer().
160-
major_inner = outer.alias("latest_major_inner")
161-
max_major = (
162-
select(func.max(major_inner.c.major_version))
163-
.where(*same_series_as_outer(major_inner))
164-
.scalar_subquery()
165-
)
166-
filters = [outer.c.major_version == max_major]
167-
168-
# For "newest", additionally keep only the max minor within that major.
169-
if mode == LATEST_VERSION_MODE.newest:
170-
# A second, independent self-join alias for the minor-version scan.
171-
minor_inner = outer.alias("latest_minor_inner")
172-
max_minor = (
173-
select(func.max(minor_inner.c.minor_version))
174-
.where(
175-
*same_series_as_outer(minor_inner),
176-
minor_inner.c.major_version == outer.c.major_version,
155+
case "ingestion_end_date":
156+
return func.date(cols.ingestion_date) <= (
157+
datetime.datetime.strptime(value, "%Y%m%d").date()
177158
)
178-
.scalar_subquery()
179-
)
180-
filters.append(outer.c.minor_version == max_minor)
181-
return filters
159+
case _:
160+
return cols[param] == value
182161

183162

184163
def _format_search_results(search_results):
@@ -211,7 +190,7 @@ def _format_search_results(search_results):
211190
return search_results
212191

213192

214-
def lambda_handler(event, context): # noqa: PLR0912
193+
def lambda_handler(event, context):
215194
"""Entry point to the query API lambda.
216195
217196
Parameters
@@ -230,35 +209,23 @@ def lambda_handler(event, context): # noqa: PLR0912
230209

231210
logger.info("Received event: " + json.dumps(event, indent=2))
232211

233-
TableModels = namedtuple(
234-
"TableModels", ["science", "ancillary", "spice", "quicklook"]
235-
)
236-
237-
table_models = TableModels(
238-
science=models.ScienceFiles,
239-
ancillary=models.AncillaryFiles,
240-
spice=models.SPICEFiles,
241-
quicklook=models.QuicklookFiles,
242-
)
243-
244-
# add session, pick model like in indexer and add query to filter_as
245212
# Make a mutable copy so we can pre-process science version parameters.
246213
query_params = dict(event["queryStringParameters"])
247-
# get desired table for query
248-
query_table = query_params.get("table", "science")
249-
214+
query_table = query_params.pop("table", "science")
250215
logger.info(f"Querying table: {query_table}")
251-
model = getattr(table_models, query_table)
252216

253-
# select the given table for the query
254-
authenticated = is_authenticated_user(event)
255-
query = select(model.__table__)
256-
if not authenticated:
257-
query = query.filter(model.released)
217+
if query_table not in _TABLE_MODELS:
218+
return {
219+
"statusCode": 400,
220+
"body": json.dumps(
221+
f"{query_table} is not a valid table. "
222+
f"Valid tables are: {list(_TABLE_MODELS)}"
223+
),
224+
}
225+
226+
model = _TABLE_MODELS[query_table]
227+
table_columns = model.__table__.c
258228

259-
# Science-only version handling: a backwards-compatible `version` alias plus
260-
# server-side resolution of "latest". Other tables keep `version` as a real
261-
# column and are left untouched.
262229
version_mode = None
263230
if query_table == "science":
264231
try:
@@ -271,26 +238,9 @@ def lambda_handler(event, context): # noqa: PLR0912
271238
),
272239
}
273240

274-
# get a list of all valid search parameters
275-
valid_parameters = [
276-
column.key for column in model.__table__.columns if column.key not in ["id"]
277-
]
278-
# Up until this point, valid_parameters are the same as the
279-
# columns in the selected table. And looks like we removed
280-
# the "id" column from the list. But we also need to add
281-
# 'end_date' to the list of valid_parameters but only for
282-
# the science table.
283-
if query_table != "ancillary":
284-
valid_parameters.append("end_date")
285-
valid_parameters.append("ingestion_start_date")
286-
valid_parameters.append("ingestion_end_date")
287-
288-
# go through each query parameter to set up sqlalchemy query conditions
241+
valid_parameters = _VALID_PARAMETERS[query_table]
242+
filters = []
289243
for param, value in query_params.items():
290-
# skip the table parameter
291-
if param == "table":
292-
continue
293-
# confirm that the query parameter is valid
294244
if param not in valid_parameters:
295245
response = {
296246
"statusCode": 400,
@@ -304,45 +254,31 @@ def lambda_handler(event, context): # noqa: PLR0912
304254
"{query_table}, valid options are: {valid_parameters}"
305255
)
306256
return response
307-
# check if we're search for start_date or end date or ingestion dates to
308-
# setup the correct "where" time condition
309-
if param == "start_date":
310-
query = query.where(
311-
model.start_date >= datetime.datetime.strptime(value, "%Y%m%d")
312-
)
313-
elif param == "end_date":
314-
# TODO: Need to discuss as a team how to handle date queries. For now,
315-
# the date queries will only look at the file start_date.
316-
query = query.where(
317-
model.start_date <= datetime.datetime.strptime(value, "%Y%m%d")
318-
)
319-
elif param == "ingestion_start_date":
320-
# filtering by ingestion date
321-
query = query.where(
322-
func.date(model.ingestion_date)
323-
>= datetime.datetime.strptime(value, "%Y%m%d").date()
324-
)
325-
elif param == "ingestion_end_date":
326-
query = query.where(
327-
func.date(model.ingestion_date)
328-
<= datetime.datetime.strptime(value, "%Y%m%d").date()
329-
)
330-
# all non-time string matching parameters
331-
else:
332-
query = query.where(getattr(model, param) == value)
257+
filters.append(_filter_condition(table_columns, param, value))
333258

334-
# Restrict science results to the latest version when no concrete
335-
# major_version was requested. NEWEST keeps a single file per series;
336-
# LATEST_MAJOR keeps all minor versions of the latest major.
337-
if version_mode is not None:
338-
for clause in _latest_version_filters(model, version_mode, not authenticated):
339-
query = query.where(clause)
259+
# if not authenticated, restrict to released only
260+
authenticated = is_authenticated_user(event)
261+
if not authenticated:
262+
filters.append(table_columns.released)
263+
264+
if version_mode is None:
265+
# if not filtering by version, simply SELECT ... WHERE ...
266+
query = select(model.__table__).where(*filters)
267+
cols = table_columns
268+
else:
269+
# otherwise, also include the rank subquery
270+
query = build_latest_version_query(
271+
filters=filters,
272+
major_only=version_mode == LatestVersionMode.LATEST_MAJOR,
273+
)
274+
# important not to use table_columns hereafter
275+
cols = query.selected_columns
340276

341277
# We want to order the query returns by the filename
342278
# This will implicitly sort by: instrument, data level, descriptor, start_date, ...
343279
# Default for the table is by the ascending id so by insertion order
344280
# This fails for the SPICE table because it uses 'file_name'
345-
query = query.order_by(model.file_path)
281+
query = query.order_by(cols.file_path)
346282

347283
with db.Session() as session:
348284
search_results = session.execute(query).all()

tests/conftest.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,39 @@
11
"""Setup items for all test types."""
22

33
import os
4+
from contextlib import contextmanager
5+
from unittest.mock import patch
46

57
import boto3
68
import pytest
79
from moto import mock_dynamodb
10+
from sqlalchemy import create_engine
11+
from sqlalchemy.orm import sessionmaker
12+
13+
from sds_data_manager.lambda_code.SDSCode.database import database as db
14+
from sds_data_manager.lambda_code.SDSCode.database.models import Base
15+
16+
17+
@contextmanager
18+
def in_memory_session(engine=None):
19+
"""Yield an in-memory SQLite session with ``db.Session`` patched to it.
20+
21+
Shared scaffolding for the DB-backed ``session`` fixtures. Pass a pre-built
22+
``engine`` (e.g. with event listeners already attached) or let it build a
23+
default in-memory engine.
24+
"""
25+
if engine is None:
26+
engine = create_engine("sqlite:///:memory:")
27+
Base.metadata.create_all(engine)
28+
with patch.object(db, "Session") as mock_session:
29+
session = sessionmaker(bind=engine)()
30+
mock_session.return_value = session
31+
try:
32+
yield session
33+
finally:
34+
session.rollback()
35+
session.close()
36+
Base.metadata.drop_all(engine)
837

938

1039
@pytest.fixture

0 commit comments

Comments
 (0)