blob: a6e5f173b14273df4352cf3026c3810980c73e58 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# Copyright 2016 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style
3# license that can be found in the LICENSE file or at
4# https://developers.google.com/open-source/licenses/bsd
5
6"""Convert a user's issue search AST into SQL clauses.
7
8The main query is done on the Issues table.
9 + Some simple conditions are implemented as WHERE conditions on the Issue
10 table rows. These are generated by the _Compare() function.
11 + More complex conditions are implemented via a "LEFT JOIN ... ON ..." clause
12 plus a check in the WHERE clause to select only rows where the join's ON
13 condition was satisfied. These are generated by appending a clause to
14 the left_joins list plus calling _CompareAlreadyJoined(). Each such left
15 join defines a unique alias to keep it separate from other conditions.
16
17The functions that generate SQL snippets need to insert table names, column
18names, alias names, and value placeholders into the generated string. These
19functions use the string format() method and the "{varname}" syntax to avoid
20confusion with the "%s" syntax used for SQL value placeholders.
21"""
22from __future__ import print_function
23from __future__ import division
24from __future__ import absolute_import
25
26import logging
27
28from framework import sql
29from proto import ast_pb2
30from proto import tracker_pb2
31from search import query2ast
32from services import tracker_fulltext
33
34
35NATIVE_SEARCHABLE_FIELDS = {
36 'id': 'local_id',
37 'is_spam': 'is_spam',
38 'stars': 'star_count',
39 'attachments': 'attachment_count',
40 'opened': 'opened',
41 'closed': 'closed',
42 'modified': 'modified',
43 'ownermodified': 'owner_modified',
44 'statusmodified': 'status_modified',
45 'componentmodified': 'component_modified',
46 }
47
48
49def BuildSQLQuery(query_ast, snapshot_mode=False):
50 """Translate the user's query into an SQL query.
51
52 Args:
53 query_ast: user query abstract syntax tree parsed by query2ast.py.
54
55 Returns:
56 A pair of lists (left_joins, where) to use when building the SQL SELECT
57 statement. Each of them is a list of (str, [val, ...]) pairs.
58 """
59 left_joins = []
60 where = []
61 unsupported_conds = []
62 # OR-queries are broken down into multiple simpler queries before they
63 # are sent to the backends, so we should never see an "OR"..
64 assert len(query_ast.conjunctions) == 1, 'OR-query should have been split'
65 conj = query_ast.conjunctions[0]
66
67 for cond_num, cond in enumerate(conj.conds):
68 cond_left_joins, cond_where, unsupported = _ProcessCond(cond_num, cond,
69 snapshot_mode)
70 left_joins.extend(cond_left_joins)
71 where.extend(cond_where)
72 unsupported_conds.extend(unsupported)
73
74 return left_joins, where, unsupported_conds
75
76
77def _ProcessBlockedOnIDCond(cond, alias, _spare_alias, snapshot_mode):
78 """Convert a blockedon_id=issue_id cond to SQL."""
79 return _ProcessRelatedIDCond(cond, alias, 'blockedon',
80 snapshot_mode=snapshot_mode)
81
82
83def _ProcessBlockingIDCond(cond, alias, _spare_alias, snapshot_mode):
84 """Convert a blocking_id:1,2 cond to SQL."""
85 return _ProcessRelatedIDCond(cond, alias, 'blockedon', reverse_relation=True,
86 snapshot_mode=snapshot_mode)
87
88
89def _ProcessMergedIntoIDCond(cond, alias, _spare_alias, snapshot_mode):
90 """Convert a mergedinto:1,2 cond to SQL."""
91 return _ProcessRelatedIDCond(cond, alias, 'mergedinto',
92 snapshot_mode=snapshot_mode)
93
94
95def _ProcessRelatedIDCond(cond, alias, kind, reverse_relation=False,
96 snapshot_mode=False):
97 """Convert either blocking_id, blockedon_id, or mergedinto_id cond to SQL.
98
99 Normally, we query for issue_id values where the dst_issue_id matches the
100 IDs specified in the cond. However, when reverse_relation is True, we
101 query for dst_issue_id values where issue_id matches. This is done for
102 blockedon_id.
103 """
104 if snapshot_mode:
105 return [], [], [cond]
106
107 matching_issue_col = 'issue_id' if reverse_relation else 'dst_issue_id'
108 ret_issue_col = 'dst_issue_id' if reverse_relation else 'issue_id'
109 ext_kind = 'blocking' if reverse_relation else kind
110 left_join = []
111 where = []
112
113 issue_ids = cond.int_values
114 ext_issue_ids = cond.str_values
115 # Filter has:blockedon and has:blocking.
116 if (not issue_ids) and (not ext_issue_ids):
117 kind_cond_str, kind_cond_args = _Compare(
118 alias, ast_pb2.QueryOp.EQ, tracker_pb2.FieldTypes.STR_TYPE, 'kind',
119 [kind])
120 left_join_str = (
121 'IssueRelation AS {alias} ON Issue.id = {alias}.{ret_issue_col} AND '
122 '{kind_cond}').format(
123 alias=alias, ret_issue_col=ret_issue_col, kind_cond=kind_cond_str)
124 left_join_args = kind_cond_args
125 left_join.append((left_join_str, left_join_args))
126 kind_cond_str, kind_cond_args = _Compare(
127 'DIR', ast_pb2.QueryOp.EQ, tracker_pb2.FieldTypes.STR_TYPE, 'kind',
128 [ext_kind])
129 ext_left_join_str = ('DanglingIssueRelation AS DIR ON '
130 'Issue.id = DIR.issue_id AND {kind_cond}').format(
131 kind_cond=kind_cond_str)
132 left_join.append((ext_left_join_str, kind_cond_args))
133 where_str, where_args = _CompareAlreadyJoined(alias,
134 cond.op, ret_issue_col)
135 ext_where_str, ext_where_args = _CompareAlreadyJoined('DIR',
136 cond.op, 'issue_id')
137 where.append(('({where} OR {ext_where})'.format(
138 where=where_str, ext_where=ext_where_str),
139 where_args + ext_where_args))
140 # Filter kind using provided issue ids.
141 if issue_ids:
142 kind_cond_str, kind_cond_args = _Compare(
143 alias, ast_pb2.QueryOp.EQ, tracker_pb2.FieldTypes.STR_TYPE, 'kind',
144 [kind])
145 left_join_str = (
146 'IssueRelation AS {alias} ON Issue.id = {alias}.{ret_issue_col} AND '
147 '{kind_cond}').format(
148 alias=alias, ret_issue_col=ret_issue_col, kind_cond=kind_cond_str)
149 left_join_args = kind_cond_args
150 related_cond_str, related_cond_args = _Compare(
151 alias, ast_pb2.QueryOp.EQ, tracker_pb2.FieldTypes.INT_TYPE,
152 matching_issue_col, issue_ids)
153 left_join_str += ' AND {related_cond}'.format(related_cond=related_cond_str)
154 left_join_args += related_cond_args
155
156 left_join.append((left_join_str, left_join_args))
157 where.append(_CompareAlreadyJoined(alias, cond.op, ret_issue_col))
158 # Filter kind using provided external issue ids.
159 if ext_issue_ids:
160 kind_cond_str, kind_cond_args = _Compare(
161 'DIR', ast_pb2.QueryOp.EQ, tracker_pb2.FieldTypes.STR_TYPE, 'kind',
162 [ext_kind])
163 ext_left_join_str = ('DanglingIssueRelation AS DIR ON '
164 'Issue.id = DIR.issue_id AND {kind_cond}').format(
165 kind_cond=kind_cond_str)
166 related_cond_str, related_cond_args = _Compare(
167 'DIR', ast_pb2.QueryOp.EQ, tracker_pb2.FieldTypes.INT_TYPE,
168 'ext_issue_identifier', ext_issue_ids)
169 ext_left_join_str += ' AND {related_cond}'.format(
170 related_cond=related_cond_str)
171 kind_cond_args += related_cond_args
172
173 left_join.append((ext_left_join_str, kind_cond_args))
174 where.append(_CompareAlreadyJoined('DIR', cond.op, 'issue_id'))
175 return left_join, where, []
176
177
178def _GetFieldTypeAndValues(cond):
179 """Returns the field type and values to use from the condition.
180
181 This function should be used when we do not know what values are present on
182 the condition. Eg: cond.int_values could be set if ast2ast.py preprocessing is
183 first done. If that preprocessing is not done then str_values could be set
184 instead.
185 If both int values and str values exist on the condition then the int values
186 are returned.
187 """
188 if cond.int_values:
189 return tracker_pb2.FieldTypes.INT_TYPE, cond.int_values
190 else:
191 return tracker_pb2.FieldTypes.STR_TYPE, cond.str_values
192
193
194def _ProcessOwnerCond(cond, alias, _spare_alias, snapshot_mode):
195 """Convert an owner:substring cond to SQL."""
196 if snapshot_mode:
197 left_joins = [(
198 'User AS {alias} ON '
199 'IssueSnapshot.owner_id = {alias}.user_id'.format(alias=alias),
200 [])]
201 else:
202 left_joins = [(
203 'User AS {alias} ON (Issue.owner_id = {alias}.user_id '
204 'OR Issue.derived_owner_id = {alias}.user_id)'.format(alias=alias),
205 [])]
206 where = [_Compare(alias, cond.op, tracker_pb2.FieldTypes.STR_TYPE, 'email',
207 cond.str_values)]
208
209 return left_joins, where, []
210
211
212def _ProcessOwnerIDCond(cond, _alias, _spare_alias, snapshot_mode):
213 """Convert an owner_id=user_id cond to SQL."""
214 if snapshot_mode:
215 field_type, field_values = _GetFieldTypeAndValues(cond)
216 explicit_str, explicit_args = _Compare(
217 'IssueSnapshot', cond.op, field_type, 'owner_id', field_values)
218 where = [(explicit_str, explicit_args)]
219 else:
220 field_type, field_values = _GetFieldTypeAndValues(cond)
221 explicit_str, explicit_args = _Compare(
222 'Issue', cond.op, field_type, 'owner_id', field_values)
223 derived_str, derived_args = _Compare(
224 'Issue', cond.op, field_type, 'derived_owner_id', field_values)
225 if cond.op in (ast_pb2.QueryOp.NE, ast_pb2.QueryOp.NOT_TEXT_HAS):
226 where = [(explicit_str, explicit_args), (derived_str, derived_args)]
227 else:
228 if cond.op == ast_pb2.QueryOp.IS_NOT_DEFINED:
229 op = ' AND '
230 else:
231 op = ' OR '
232 where = [
233 ('(' + explicit_str + op + derived_str + ')',
234 explicit_args + derived_args)]
235
236 return [], where, []
237
238
239def _ProcessOwnerLastVisitCond(cond, alias, _spare_alias, snapshot_mode):
240 """Convert an ownerlastvisit<timestamp cond to SQL."""
241 # TODO(jeffcarp): It is possible to support this on snapshots.
242 if snapshot_mode:
243 return [], [], [cond]
244
245 left_joins = [(
246 'User AS {alias} '
247 'ON (Issue.owner_id = {alias}.user_id OR '
248 'Issue.derived_owner_id = {alias}.user_id)'.format(alias=alias),
249 [])]
250 where = [_Compare(alias, cond.op, tracker_pb2.FieldTypes.INT_TYPE,
251 'last_visit_timestamp', cond.int_values)]
252 return left_joins, where, []
253
254
255def _ProcessIsOwnerBouncing(cond, alias, _spare_alias, snapshot_mode):
256 """Convert an is:ownerbouncing cond to SQL."""
257 if snapshot_mode:
258 return [], [], [cond]
259
260 left_joins = [(
261 'User AS {alias} '
262 'ON (Issue.owner_id = {alias}.user_id OR '
263 'Issue.derived_owner_id = {alias}.user_id)'.format(alias=alias),
264 [])]
265 if cond.op == ast_pb2.QueryOp.EQ:
266 op = ast_pb2.QueryOp.IS_DEFINED
267 else:
268 op = ast_pb2.QueryOp.IS_NOT_DEFINED
269
270 where = [_Compare(alias, op, tracker_pb2.FieldTypes.INT_TYPE,
271 'email_bounce_timestamp', [])]
272 return left_joins, where, []
273
274
275def _ProcessReporterCond(cond, alias, _spare_alias, snapshot_mode):
276 """Convert a reporter:substring cond to SQL."""
277 if snapshot_mode:
278 left_joins = [(
279 'User AS {alias} ON IssueSnapshot.reporter_id = {alias}.user_id'.format(
280 alias=alias), [])]
281 else:
282 left_joins = [(
283 'User AS {alias} ON Issue.reporter_id = {alias}.user_id'.format(
284 alias=alias), [])]
285 where = [_Compare(alias, cond.op, tracker_pb2.FieldTypes.STR_TYPE, 'email',
286 cond.str_values)]
287
288 return left_joins, where, []
289
290
291def _ProcessReporterIDCond(cond, _alias, _spare_alias, snapshot_mode):
292 """Convert a reporter_ID=user_id cond to SQL."""
293 field_type, field_values = _GetFieldTypeAndValues(cond)
294
295 if snapshot_mode:
296 where = [_Compare(
297 'IssueSnapshot', cond.op, field_type, 'reporter_id', field_values)]
298 else:
299 where = [_Compare(
300 'Issue', cond.op, field_type, 'reporter_id', field_values)]
301 return [], where, []
302
303
304def _ProcessCcCond(cond, alias, user_alias, snapshot_mode):
305 """Convert a cc:substring cond to SQL."""
306 email_cond_str, email_cond_args = _Compare(
307 user_alias, ast_pb2.QueryOp.TEXT_HAS, tracker_pb2.FieldTypes.STR_TYPE,
308 'email', cond.str_values)
309
310 if snapshot_mode:
311 left_joins = [(
312 '(IssueSnapshot2Cc AS {alias} JOIN User AS {user_alias} '
313 'ON {alias}.cc_id = {user_alias}.user_id AND {email_cond}) '
314 'ON IssueSnapshot.id = {alias}.issuesnapshot_id'.format(
315 alias=alias, user_alias=user_alias, email_cond=email_cond_str),
316 email_cond_args)]
317 else:
318 # Note: email_cond_str will have parens, if needed.
319 left_joins = [(
320 '(Issue2Cc AS {alias} JOIN User AS {user_alias} '
321 'ON {alias}.cc_id = {user_alias}.user_id AND {email_cond}) '
322 'ON Issue.id = {alias}.issue_id AND '
323 'Issue.shard = {alias}.issue_shard'.format(
324 alias=alias, user_alias=user_alias, email_cond=email_cond_str),
325 email_cond_args)]
326 where = [_CompareAlreadyJoined(user_alias, cond.op, 'email')]
327
328 return left_joins, where, []
329
330
331def _ProcessCcIDCond(cond, alias, _spare_alias, snapshot_mode):
332 """Convert a cc_id=user_id cond to SQL."""
333 if snapshot_mode:
334 join_str = (
335 'IssueSnapshot2Cc AS {alias} '
336 'ON IssueSnapshot.id = {alias}.issuesnapshot_id'.format(alias=alias))
337 else:
338 join_str = (
339 'Issue2Cc AS {alias} ON Issue.id = {alias}.issue_id AND '
340 'Issue.shard = {alias}.issue_shard'.format(
341 alias=alias))
342 if cond.op in (ast_pb2.QueryOp.IS_DEFINED, ast_pb2.QueryOp.IS_NOT_DEFINED):
343 left_joins = [(join_str, [])]
344 else:
345 field_type, field_values = _GetFieldTypeAndValues(cond)
346 cond_str, cond_args = _Compare(
347 alias, ast_pb2.QueryOp.EQ, field_type, 'cc_id', field_values)
348 left_joins = [(join_str + ' AND ' + cond_str, cond_args)]
349
350 where = [_CompareAlreadyJoined(alias, cond.op, 'cc_id')]
351 return left_joins, where, []
352
353
354def _ProcessStarredByCond(cond, alias, user_alias, snapshot_mode):
355 """Convert a starredby:substring cond to SQL."""
356 if snapshot_mode:
357 return [], [], [cond]
358
359 email_cond_str, email_cond_args = _Compare(
360 user_alias, cond.op, tracker_pb2.FieldTypes.STR_TYPE, 'email',
361 cond.str_values)
362 # Note: email_cond_str will have parens, if needed.
363 left_joins = [(
364 '(IssueStar AS {alias} JOIN User AS {user_alias} '
365 'ON {alias}.user_id = {user_alias}.user_id AND {email_cond}) '
366 'ON Issue.id = {alias}.issue_id'.format(
367 alias=alias, user_alias=user_alias, email_cond=email_cond_str),
368 email_cond_args)]
369 where = [_CompareAlreadyJoined(user_alias, cond.op, 'email')]
370
371 return left_joins, where, []
372
373
374def _ProcessStarredByIDCond(cond, alias, _spare_alias, snapshot_mode):
375 """Convert a starredby_id=user_id cond to SQL."""
376 if snapshot_mode:
377 return [], [], [cond]
378
379 join_str = 'IssueStar AS {alias} ON Issue.id = {alias}.issue_id'.format(
380 alias=alias)
381 if cond.op in (ast_pb2.QueryOp.IS_DEFINED, ast_pb2.QueryOp.IS_NOT_DEFINED):
382 left_joins = [(join_str, [])]
383 else:
384 field_type, field_values = _GetFieldTypeAndValues(cond)
385 cond_str, cond_args = _Compare(
386 alias, ast_pb2.QueryOp.EQ, field_type, 'user_id', field_values)
387 left_joins = [(join_str + ' AND ' + cond_str, cond_args)]
388
389 where = [_CompareAlreadyJoined(alias, cond.op, 'user_id')]
390 return left_joins, where, []
391
392
393def _ProcessCommentByCond(cond, alias, user_alias, snapshot_mode):
394 """Convert a commentby:substring cond to SQL."""
395 if snapshot_mode:
396 return [], [], [cond]
397
398 email_cond_str, email_cond_args = _Compare(
399 user_alias, ast_pb2.QueryOp.TEXT_HAS, tracker_pb2.FieldTypes.STR_TYPE,
400 'email', cond.str_values)
401 # Note: email_cond_str will have parens, if needed.
402 left_joins = [(
403 '(Comment AS {alias} JOIN User AS {user_alias} '
404 'ON {alias}.commenter_id = {user_alias}.user_id AND {email_cond}) '
405 'ON Issue.id = {alias}.issue_id AND '
406 '{alias}.deleted_by IS NULL'.format(
407 alias=alias, user_alias=user_alias, email_cond=email_cond_str),
408 email_cond_args)]
409 where = [_CompareAlreadyJoined(user_alias, cond.op, 'email')]
410
411 return left_joins, where, []
412
413
414def _ProcessCommentByIDCond(cond, alias, _spare_alias, snapshot_mode):
415 """Convert a commentby_id=user_id cond to SQL."""
416 if snapshot_mode:
417 return [], [], [cond]
418
419 field_type, field_values = _GetFieldTypeAndValues(cond)
420 commenter_cond_str, commenter_cond_args = _Compare(
421 alias, ast_pb2.QueryOp.EQ, field_type, 'commenter_id', field_values)
422 left_joins = [(
423 'Comment AS {alias} ON Issue.id = {alias}.issue_id AND '
424 '{commenter_cond} AND '
425 '{alias}.deleted_by IS NULL'.format(
426 alias=alias, commenter_cond=commenter_cond_str),
427 commenter_cond_args)]
428 where = [_CompareAlreadyJoined(alias, cond.op, 'commenter_id')]
429
430 return left_joins, where, []
431
432
433def _ProcessStatusIDCond(cond, _alias, _spare_alias, snapshot_mode):
434 """Convert a status_id=ID cond to SQL."""
435 field_type, field_values = _GetFieldTypeAndValues(cond)
436 if snapshot_mode:
437 explicit_str, explicit_args = _Compare(
438 'IssueSnapshot', cond.op, field_type, 'status_id', field_values)
439 where = [(explicit_str, explicit_args)]
440 else:
441 explicit_str, explicit_args = _Compare(
442 'Issue', cond.op, field_type, 'status_id', field_values)
443 derived_str, derived_args = _Compare(
444 'Issue', cond.op, field_type, 'derived_status_id', field_values)
445 if cond.op in (ast_pb2.QueryOp.IS_NOT_DEFINED, ast_pb2.QueryOp.NE):
446 where = [(explicit_str, explicit_args), (derived_str, derived_args)]
447 else:
448 where = [
449 ('(' + explicit_str + ' OR ' + derived_str + ')',
450 explicit_args + derived_args)]
451
452 return [], where, []
453
454
455def _ProcessSummaryCond(cond, alias, _spare_alias, snapshot_mode):
456 """Convert a summary="exact string" cond to SQL."""
457 left_joins = []
458 where = []
459 field_type, field_values = _GetFieldTypeAndValues(cond)
460 if snapshot_mode:
461 return [], [], [cond]
462 elif cond.op in (ast_pb2.QueryOp.EQ, ast_pb2.QueryOp.NE,
463 ast_pb2.QueryOp.GT, ast_pb2.QueryOp.LT,
464 ast_pb2.QueryOp.GE, ast_pb2.QueryOp.LE,
465 ast_pb2.QueryOp.IS_DEFINED, ast_pb2.QueryOp.IS_NOT_DEFINED):
466 summary_cond_str, summary_cond_args = _Compare(
467 alias, cond.op, field_type, 'summary', field_values)
468 left_joins = [(
469 'IssueSummary AS {alias} ON Issue.id = {alias}.issue_id AND '
470 '{summary_cond}'.format(
471 alias=alias, summary_cond=summary_cond_str),
472 summary_cond_args)]
473 where = [_CompareAlreadyJoined(alias, ast_pb2.QueryOp.EQ, 'issue_id')]
474
475 return left_joins, where, []
476
477
478def _ProcessLabelIDCond(cond, alias, _spare_alias, snapshot_mode):
479 """Convert a label_id=ID cond to SQL."""
480 if snapshot_mode:
481 join_str = (
482 'IssueSnapshot2Label AS {alias} '
483 'ON IssueSnapshot.id = {alias}.issuesnapshot_id'.format(alias=alias))
484 else:
485 join_str = (
486 'Issue2Label AS {alias} ON Issue.id = {alias}.issue_id AND '
487 'Issue.shard = {alias}.issue_shard'.format(alias=alias))
488
489 field_type, field_values = _GetFieldTypeAndValues(cond)
490 if not field_values and cond.op == ast_pb2.QueryOp.NE:
491 return [], [], []
492 cond_str, cond_args = _Compare(
493 alias, ast_pb2.QueryOp.EQ, field_type, 'label_id', field_values)
494 left_joins = [(join_str + ' AND ' + cond_str, cond_args)]
495 where = [_CompareAlreadyJoined(alias, cond.op, 'label_id')]
496 return left_joins, where, []
497
498
499def _ProcessComponentIDCond(cond, alias, _spare_alias, snapshot_mode):
500 """Convert a component_id=ID cond to SQL."""
501 # This is a built-in field, so it shadows any other fields w/ the same name.
502 if snapshot_mode:
503 join_str = (
504 'IssueSnapshot2Component AS {alias} '
505 'ON IssueSnapshot.id = {alias}.issuesnapshot_id'.format(alias=alias))
506 else:
507 join_str = (
508 'Issue2Component AS {alias} ON Issue.id = {alias}.issue_id AND '
509 'Issue.shard = {alias}.issue_shard'.format(alias=alias))
510 if cond.op in (ast_pb2.QueryOp.IS_DEFINED, ast_pb2.QueryOp.IS_NOT_DEFINED):
511 left_joins = [(join_str, [])]
512 else:
513 field_type, field_values = _GetFieldTypeAndValues(cond)
514 cond_str, cond_args = _Compare(
515 alias, ast_pb2.QueryOp.EQ, field_type, 'component_id', field_values)
516 left_joins = [(join_str + ' AND ' + cond_str, cond_args)]
517
518 where = [_CompareAlreadyJoined(alias, cond.op, 'component_id')]
519 return left_joins, where, []
520
521
522# TODO(jojang): monorail:3819, check for cond.phase_name and process
523# appropriately so users can search 'Canary.UXReview-status:Approved'
524def _ProcessApprovalFieldCond(cond, alias, user_alias, snapshot_mode):
525 """Convert a custom approval field cond to SQL."""
526 if snapshot_mode:
527 return [], [], [cond]
528
529 approval_fd = cond.field_defs[0]
530 left_joins = []
531
532 join_str_tmpl = (
533 '{tbl_name} AS {alias} ON Issue.id = {alias}.issue_id AND '
534 '{alias}.approval_id = %s')
535
536 join_args = [approval_fd.field_id]
537
538 val_type, values = _GetFieldTypeAndValues(cond)
539 if val_type is tracker_pb2.FieldTypes.STR_TYPE:
540 values = [val.lower() for val in values]
541 # TODO(jojwang):monorail:3809, check if there is a cond.key_suffx.
542 # status, approver should always have a value, so 'has:UXReview-approver'
543 # should return the same issues as 'has:UXReview'.
544 # There will not always be values approval.setter_id and approval.set_on
545 # and the current code would not process 'has:UXReview-by' correctly.
546 if cond.op in (
547 ast_pb2.QueryOp.IS_DEFINED, ast_pb2.QueryOp.IS_NOT_DEFINED):
548 join_str = join_str_tmpl.format(
549 tbl_name='Issue2ApprovalValue', alias=alias)
550 left_joins = [(join_str, join_args)]
551 else:
552 op = cond.op
553 if op == ast_pb2.QueryOp.NE:
554 op = ast_pb2.QueryOp.EQ # Negation is done in WHERE clause.
555 elif op == ast_pb2.QueryOp.NOT_TEXT_HAS:
556 op = ast_pb2.QueryOp.TEXT_HAS
557
558 if (not cond.key_suffix) or cond.key_suffix == query2ast.STATUS_SUFFIX:
559 tbl_str = 'Issue2ApprovalValue'
560 cond_str, cond_args = _Compare(
561 alias, op, val_type, 'status', values)
562 elif cond.key_suffix == query2ast.SET_ON_SUFFIX:
563 tbl_str = 'Issue2ApprovalValue'
564 cond_str, cond_args = _Compare(
565 alias, op, val_type, 'set_on', values)
566 elif cond.key_suffix in [
567 query2ast.APPROVER_SUFFIX, query2ast.SET_BY_SUFFIX]:
568 if cond.key_suffix == query2ast.SET_BY_SUFFIX:
569 tbl_str = 'Issue2ApprovalValue'
570 col_name = 'setter_id'
571 else:
572 tbl_str = 'IssueApproval2Approver'
573 col_name = 'approver_id'
574
575 if val_type == tracker_pb2.FieldTypes.INT_TYPE:
576 cond_str, cond_args = _Compare(
577 alias, op, val_type, col_name, values)
578 else:
579 email_cond_str, email_cond_args = _Compare(
580 user_alias, op, val_type, 'email', values)
581 left_joins.append((
582 'User AS {user_alias} ON {email_cond}'.format(
583 user_alias=user_alias, email_cond=email_cond_str),
584 email_cond_args))
585
586 cond_str = '{alias}.{col_name} = {user_alias}.user_id'.format(
587 alias=alias, col_name=col_name, user_alias=user_alias)
588 cond_args = []
589 if cond_str or cond_args:
590 join_str = join_str_tmpl.format(tbl_name=tbl_str, alias=alias)
591 join_str += ' AND ' + cond_str
592 join_args.extend(cond_args)
593 left_joins.append((join_str, join_args))
594
595 where = [_CompareAlreadyJoined(alias, cond.op, 'approval_id')]
596 return left_joins, where, []
597
598
599def _ProcessCustomFieldCond(
600 cond, alias, user_alias, phase_alias, snapshot_mode):
601 """Convert a custom field cond to SQL."""
602 if snapshot_mode:
603 return [], [], [cond]
604
605 # TODO(jrobbins): handle ambiguous field names that map to multiple
606 # field definitions, especially for cross-project search.
607 field_def = cond.field_defs[0]
608 field_type = field_def.field_type
609 left_joins = []
610
611 join_str = (
612 'Issue2FieldValue AS {alias} ON Issue.id = {alias}.issue_id AND '
613 'Issue.shard = {alias}.issue_shard AND '
614 '{alias}.field_id = %s'.format(alias=alias))
615 join_args = [field_def.field_id]
616
617 if cond.op not in (
618 ast_pb2.QueryOp.IS_DEFINED, ast_pb2.QueryOp.IS_NOT_DEFINED):
619 op = cond.op
620 if op == ast_pb2.QueryOp.NE:
621 op = ast_pb2.QueryOp.EQ # Negation is done in WHERE clause.
622 if field_type == tracker_pb2.FieldTypes.INT_TYPE:
623 cond_str, cond_args = _Compare(
624 alias, op, field_type, 'int_value', cond.int_values)
625 elif field_type == tracker_pb2.FieldTypes.STR_TYPE:
626 cond_str, cond_args = _Compare(
627 alias, op, field_type, 'str_value', cond.str_values)
628 elif field_type == tracker_pb2.FieldTypes.USER_TYPE:
629 if cond.int_values:
630 cond_str, cond_args = _Compare(
631 alias, op, field_type, 'user_id', cond.int_values)
632 else:
633 email_cond_str, email_cond_args = _Compare(
634 user_alias, op, field_type, 'email', cond.str_values)
635 left_joins.append((
636 'User AS {user_alias} ON {email_cond}'.format(
637 user_alias=user_alias, email_cond=email_cond_str),
638 email_cond_args))
639 cond_str = '{alias}.user_id = {user_alias}.user_id'.format(
640 alias=alias, user_alias=user_alias)
641 cond_args = []
642 elif field_type == tracker_pb2.FieldTypes.URL_TYPE:
643 cond_str, cond_args = _Compare(
644 alias, op, field_type, 'url_value', cond.str_values)
645 if field_type == tracker_pb2.FieldTypes.DATE_TYPE:
646 cond_str, cond_args = _Compare(
647 alias, op, field_type, 'date_value', cond.int_values)
648 if cond_str or cond_args:
649 join_str += ' AND ' + cond_str
650 join_args.extend(cond_args)
651
652 if cond.phase_name:
653 phase_cond_str, phase_cond_args = _Compare(
654 phase_alias, ast_pb2.QueryOp.EQ, tracker_pb2.FieldTypes.STR_TYPE,
655 'name', [cond.phase_name])
656 left_joins.append((
657 'IssuePhaseDef AS {phase_alias} ON {phase_cond}'.format(
658 phase_alias=phase_alias, phase_cond=phase_cond_str),
659 phase_cond_args))
660 cond_str = '{alias}.phase_id = {phase_alias}.id'.format(
661 alias=alias, phase_alias=phase_alias)
662 cond_args = []
663 join_str += ' AND ' + cond_str
664 join_args.extend(cond_args)
665
666 left_joins.append((join_str, join_args))
667 where = [_CompareAlreadyJoined(alias, cond.op, 'field_id')]
668 return left_joins, where, []
669
670
671def _ProcessAttachmentCond(cond, alias, _spare_alias, snapshot_mode):
672 """Convert has:attachment and -has:attachment cond to SQL."""
673 if snapshot_mode:
674 return [], [], [cond]
675
676 if cond.op in (ast_pb2.QueryOp.IS_DEFINED, ast_pb2.QueryOp.IS_NOT_DEFINED):
677 left_joins = []
678 where = [_Compare('Issue', cond.op, tracker_pb2.FieldTypes.INT_TYPE,
679 'attachment_count', cond.int_values)]
680 else:
681 field_def = cond.field_defs[0]
682 field_type = field_def.field_type
683 left_joins = [
684 ('Attachment AS {alias} ON Issue.id = {alias}.issue_id AND '
685 '{alias}.deleted = %s'.format(alias=alias),
686 [False])]
687 where = [_Compare(alias, cond.op, field_type, 'filename', cond.str_values)]
688
689 return left_joins, where, []
690
691
692def _ProcessHotlistIDCond(cond, alias, _spare_alias, snapshot_mode):
693 """Convert hotlist_id=IDS cond to SQL."""
694 if snapshot_mode:
695 join_str = (
696 'IssueSnapshot2Hotlist AS {alias} '
697 'ON IssueSnapshot.id = {alias}.issuesnapshot_id'.format(alias=alias))
698 else:
699 join_str = (
700 'Hotlist2Issue AS {alias} ON Issue.id = {alias}.issue_id'.format(
701 alias=alias))
702
703 field_type, field_values = _GetFieldTypeAndValues(cond)
704 if not field_values and cond.op == ast_pb2.QueryOp.NE:
705 return [], [], []
706 cond_str, cond_args = _Compare(
707 alias, ast_pb2.QueryOp.EQ, field_type, 'hotlist_id', field_values)
708 left_joins = [(join_str + ' AND ' + cond_str, cond_args)]
709 where = [_CompareAlreadyJoined(alias, cond.op, 'hotlist_id')]
710
711 return left_joins, where, []
712
713
714def _ProcessHotlistCond(cond, alias, _spare_alias, snapshot_mode):
715 """Convert hotlist=user:hotlist-name to SQL"""
716 # hotlist conditions that reach this function definitely have invalid
717 # user_name/id/email. This validity was determined in
718 # ast2ast._PreprocessHotlistCond. Any possible user identification is ignored.
719 hotlist_substrings = []
720 for val in cond.str_values:
721 substring = val.split(':')[-1]
722 if substring:
723 hotlist_substrings.append(substring)
724 hotlist_cond_str, hotlist_cond_args = _Compare(
725 alias, ast_pb2.QueryOp.TEXT_HAS, tracker_pb2.FieldTypes.STR_TYPE,
726 'name', hotlist_substrings)
727 if snapshot_mode:
728 left_joins = [(
729 '(IssueSnapshot2Hotlist JOIN Hotlist AS {alias} '
730 'ON IssueSnapshot2Hotlist.hotlist_id = {alias}.id AND {hotlist_cond}) '
731 'ON IssueSnapshot.id = IssueSnapshot2Hotlist.issuesnapshot_id'.format(
732 alias=alias, hotlist_cond=hotlist_cond_str), hotlist_cond_args)]
733 else:
734 left_joins = [(
735 '(Hotlist2Issue JOIN Hotlist AS {alias} '
736 'ON Hotlist2Issue.hotlist_id = {alias}.id AND {hotlist_cond}) '
737 'ON Issue.id = Hotlist2Issue.issue_id'.format(
738 alias=alias, hotlist_cond=hotlist_cond_str), hotlist_cond_args)]
739 where = [_CompareAlreadyJoined(alias, cond.op, 'name')]
740
741 return left_joins, where, []
742
743
744def _ProcessPhaseCond(cond, alias, phase_alias, _snapshot_mode):
745 """Convert gate:<phase_name> to SQL."""
746
747 op = cond.op
748 if cond.op == ast_pb2.QueryOp.NE:
749 op = ast_pb2.QueryOp.EQ
750 elif cond.op == ast_pb2.QueryOp.NOT_TEXT_HAS:
751 op = ast_pb2.QueryOp.TEXT_HAS
752
753 cond_str, cond_args = _Compare(
754 phase_alias, op, tracker_pb2.FieldTypes.STR_TYPE,
755 'name', cond.str_values)
756 left_joins = [(
757 '(Issue2ApprovalValue AS {alias} JOIN IssuePhaseDef AS {phase_alias} '
758 'ON {alias}.phase_id = {phase_alias}.id AND {name_cond}) '
759 'ON Issue.id = {alias}.issue_id'.format(
760 alias=alias, phase_alias=phase_alias, name_cond=cond_str),
761 cond_args)]
762 where = [_CompareAlreadyJoined(phase_alias, cond.op, 'name')]
763
764 return left_joins, where, []
765
766
767_PROCESSORS = {
768 'owner': _ProcessOwnerCond,
769 'owner_id': _ProcessOwnerIDCond,
770 'ownerlastvisit': _ProcessOwnerLastVisitCond,
771 'ownerbouncing': _ProcessIsOwnerBouncing,
772 'reporter': _ProcessReporterCond,
773 'reporter_id': _ProcessReporterIDCond,
774 'cc': _ProcessCcCond,
775 'cc_id': _ProcessCcIDCond,
776 'starredby': _ProcessStarredByCond,
777 'starredby_id': _ProcessStarredByIDCond,
778 'commentby': _ProcessCommentByCond,
779 'commentby_id': _ProcessCommentByIDCond,
780 'status_id': _ProcessStatusIDCond,
781 'summary': _ProcessSummaryCond,
782 'label_id': _ProcessLabelIDCond,
783 'component_id': _ProcessComponentIDCond,
784 'blockedon_id': _ProcessBlockedOnIDCond,
785 'blocking_id': _ProcessBlockingIDCond,
786 'mergedinto_id': _ProcessMergedIntoIDCond,
787 'attachment': _ProcessAttachmentCond,
788 'hotlist_id': _ProcessHotlistIDCond,
789 'hotlist': _ProcessHotlistCond,
790 }
791
792
793def _ProcessCond(cond_num, cond, snapshot_mode):
794 """Translate one term of the user's search into an SQL query.
795
796 Args:
797 cond_num: integer cond number used to make distinct local variable names.
798 cond: user query cond parsed by query2ast.py.
799
800 Returns:
801 A pair of lists (left_joins, where) to use when building the SQL SELECT
802 statement. Each of them is a list of (str, [val, ...]) pairs.
803 """
804 alias = 'Cond%d' % cond_num
805 spare_alias = 'Spare%d' % cond_num
806 # Note: a condition like [x=y] has field_name "x", there may be multiple
807 # field definitions that match "x", but they will all have field_name "x".
808 field_def = cond.field_defs[0]
809 assert all(field_def.field_name == fd.field_name for fd in cond.field_defs)
810
811 if field_def.field_name in NATIVE_SEARCHABLE_FIELDS:
812 # TODO(jeffcarp): Support local_id search here.
813 if snapshot_mode:
814 return [], [], [cond]
815 else:
816 col = NATIVE_SEARCHABLE_FIELDS[field_def.field_name]
817 where = [_Compare(
818 'Issue', cond.op, field_def.field_type, col,
819 cond.str_values or cond.int_values)]
820 return [], where, []
821
822 elif field_def.field_name in _PROCESSORS:
823 proc = _PROCESSORS[field_def.field_name]
824 return proc(cond, alias, spare_alias, snapshot_mode)
825
826 # Any phase conditions use the sql.SHORTHAND['phase_cond'], which expects a
827 # 'Phase' alias. 'phase_cond' cannot expect a 'Spare' alias because
828 # _ProcessCustomFieldCond also creates a phase_cond string where it uses the
829 # 'Phase' alias because it needs the 'Spare' alias for other conditions.
830 elif field_def.field_name == 'gate':
831 phase_alias = 'Phase%d' % cond_num
832 return _ProcessPhaseCond(cond, alias, phase_alias, snapshot_mode)
833
834 elif field_def.field_id: # it is a search on a custom field
835 phase_alias = 'Phase%d' % cond_num
836 if field_def.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE:
837 return _ProcessApprovalFieldCond(cond, alias, spare_alias, snapshot_mode)
838 return _ProcessCustomFieldCond(
839 cond, alias, spare_alias, phase_alias, snapshot_mode)
840
841 elif (cond.op in (ast_pb2.QueryOp.TEXT_HAS, ast_pb2.QueryOp.NOT_TEXT_HAS) and
842 (field_def.field_name in tracker_fulltext.ISSUE_FULLTEXT_FIELDS or
843 field_def.field_name == 'any_field')):
844 if snapshot_mode:
845 return [], [], [cond]
846 # This case handled by full-text search.
847
848 else:
849 logging.error('untranslated search cond %r', cond)
850
851 return [], [], []
852
853
854def _Compare(alias, op, val_type, col, vals):
855 """Return an SQL comparison for the given values. For use in WHERE or ON.
856
857 Args:
858 alias: String name of the table or alias defined in a JOIN clause.
859 op: One of the operators defined in ast_pb2.py.
860 val_type: One of the value types defined in ast_pb2.py.
861 col: string column name to compare to vals.
862 vals: list of values that the user is searching for.
863
864 Returns:
865 (cond_str, cond_args) where cond_str is a SQL condition that may contain
866 some %s placeholders, and cond_args is the list of values that fill those
867 placeholders. If the condition string contains any AND or OR operators,
868 the whole expression is put inside parens.
869
870 Raises:
871 NoPossibleResults: The user's query is impossible to ever satisfy, e.g.,
872 it requires matching an empty set of labels.
873 """
874 vals_ph = sql.PlaceHolders(vals)
875 if col in ['label', 'status', 'email', 'name']:
876 alias_col = 'LOWER(%s.%s)' % (alias, col)
877 else:
878 alias_col = '%s.%s' % (alias, col)
879
880 def Fmt(cond_str):
881 return cond_str.format(alias_col=alias_col, vals_ph=vals_ph)
882
883 no_value = (0 if val_type in [tracker_pb2.FieldTypes.DATE_TYPE,
884 tracker_pb2.FieldTypes.INT_TYPE] else '')
885 if op == ast_pb2.QueryOp.IS_DEFINED:
886 return Fmt('({alias_col} IS NOT NULL AND {alias_col} != %s)'), [no_value]
887 if op == ast_pb2.QueryOp.IS_NOT_DEFINED:
888 return Fmt('({alias_col} IS NULL OR {alias_col} = %s)'), [no_value]
889
890 if val_type in [tracker_pb2.FieldTypes.DATE_TYPE,
891 tracker_pb2.FieldTypes.INT_TYPE]:
892 if op == ast_pb2.QueryOp.TEXT_HAS:
893 op = ast_pb2.QueryOp.EQ
894 if op == ast_pb2.QueryOp.NOT_TEXT_HAS:
895 op = ast_pb2.QueryOp.NE
896
897 if op == ast_pb2.QueryOp.EQ:
898 if not vals:
899 raise NoPossibleResults('Column %s has no possible value' % alias_col)
900 elif len(vals) == 1:
901 cond_str = Fmt('{alias_col} = %s')
902 else:
903 cond_str = Fmt('{alias_col} IN ({vals_ph})')
904 return cond_str, vals
905
906 if op == ast_pb2.QueryOp.NE:
907 if not vals:
908 return 'TRUE', [] # a no-op that matches every row.
909 elif len(vals) == 1:
910 comp = Fmt('{alias_col} != %s')
911 else:
912 comp = Fmt('{alias_col} NOT IN ({vals_ph})')
913 return '(%s IS NULL OR %s)' % (alias_col, comp), vals
914
915 wild_vals = ['%%%s%%' % val for val in vals]
916 if op == ast_pb2.QueryOp.TEXT_HAS:
917 cond_str = ' OR '.join(Fmt('{alias_col} LIKE %s') for v in vals)
918 return ('(%s)' % cond_str), wild_vals
919 if op == ast_pb2.QueryOp.NOT_TEXT_HAS:
920 cond_str = (Fmt('{alias_col} IS NULL OR ') +
921 ' AND '.join(Fmt('{alias_col} NOT LIKE %s') for v in vals))
922 return ('(%s)' % cond_str), wild_vals
923
924
925 # Note: These operators do not support quick-OR
926 val = vals[0]
927
928 if op == ast_pb2.QueryOp.GT:
929 return Fmt('{alias_col} > %s'), [val]
930 if op == ast_pb2.QueryOp.LT:
931 return Fmt('{alias_col} < %s'), [val]
932 if op == ast_pb2.QueryOp.GE:
933 return Fmt('{alias_col} >= %s'), [val]
934 if op == ast_pb2.QueryOp.LE:
935 return Fmt('{alias_col} <= %s'), [val]
936
937 logging.error('unknown op: %r', op)
938
939
940def _CompareAlreadyJoined(alias, op, col):
941 """Return a WHERE clause comparison that checks that a join succeeded."""
942 def Fmt(cond_str):
943 return cond_str.format(alias_col='%s.%s' % (alias, col))
944
945 if op in (ast_pb2.QueryOp.NE, ast_pb2.QueryOp.NOT_TEXT_HAS,
946 ast_pb2.QueryOp.IS_NOT_DEFINED):
947 return Fmt('{alias_col} IS NULL'), []
948 else:
949 return Fmt('{alias_col} IS NOT NULL'), []
950
951
952class Error(Exception):
953 """Base class for errors from this module."""
954
955
956class NoPossibleResults(Error):
957 """The query could never match any rows from the database, so don't try.."""