33import datetime
44import json
55import 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
1111from ..database import database as db
1212from ..database import models
1313
1414# Logger setup
1515logger = logging .getLogger (__name__ )
1616logger .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
3455def _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
184163def _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 ()
0 commit comments