gmail_search_syntax 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ require_relative "test_helper"
2
+
3
+ class PostgresVisitorTest < Minitest::Test
4
+ def parse_and_visit(query, current_user_email: nil)
5
+ ast = GmailSearchSyntax.parse!(query)
6
+ visitor = GmailSearchSyntax::PostgresVisitor.new(current_user_email: current_user_email)
7
+ visitor.visit(ast)
8
+ visitor.to_query.to_sql
9
+ end
10
+
11
+ def test_from_operator
12
+ sql, params = parse_and_visit("from:alice@example.com")
13
+
14
+ assert_includes sql, "INNER JOIN message_addresses"
15
+ assert_includes sql, "ma1.address_type = ?"
16
+ assert_includes sql, "ma1.email_address = ?"
17
+ assert_equal ["from", "cc", "bcc", "alice@example.com"], params
18
+ end
19
+
20
+ def test_subject_operator
21
+ sql, params = parse_and_visit("subject:meeting")
22
+
23
+ assert_includes sql, "m0.subject LIKE ?"
24
+ assert_equal ["%meeting%"], params
25
+ end
26
+
27
+ def test_older_than_relative_postgres_syntax
28
+ sql, params = parse_and_visit("older_than:1y")
29
+
30
+ assert_includes sql, "m0.internal_date < (NOW() - ?::interval)"
31
+ assert_equal ["1 years"], params
32
+ end
33
+
34
+ def test_newer_than_relative_postgres_syntax
35
+ sql, params = parse_and_visit("newer_than:2d")
36
+
37
+ assert_includes sql, "m0.internal_date > (NOW() - ?::interval)"
38
+ assert_equal ["2 days"], params
39
+ end
40
+
41
+ def test_older_than_7_days_postgres
42
+ sql, params = parse_and_visit("older_than:7d")
43
+
44
+ assert_includes sql, "m0.internal_date < (NOW() - ?::interval)"
45
+ assert_equal ["7 days"], params
46
+ end
47
+
48
+ def test_newer_than_3_months_postgres
49
+ sql, params = parse_and_visit("newer_than:3m")
50
+
51
+ assert_includes sql, "m0.internal_date > (NOW() - ?::interval)"
52
+ assert_equal ["3 months"], params
53
+ end
54
+
55
+ def test_older_than_2_years_postgres
56
+ sql, params = parse_and_visit("older_than:2y")
57
+
58
+ assert_includes sql, "m0.internal_date < (NOW() - ?::interval)"
59
+ assert_equal ["2 years"], params
60
+ end
61
+
62
+ def test_or_operator
63
+ sql, params = parse_and_visit("from:alice@example.com OR from:bob@example.com")
64
+
65
+ assert_includes sql, "OR"
66
+ assert_includes sql, ".email_address = ?"
67
+ assert_equal ["from", "cc", "bcc", "alice@example.com", "from", "cc", "bcc", "bob@example.com"], params
68
+ end
69
+
70
+ def test_and_operator
71
+ sql, params = parse_and_visit("from:alice@example.com AND subject:meeting")
72
+
73
+ assert_includes sql, "AND"
74
+ assert_includes sql, "ma1.email_address = ?"
75
+ assert_includes sql, "m0.subject LIKE ?"
76
+ assert_equal ["from", "cc", "bcc", "alice@example.com", "%meeting%"], params
77
+ end
78
+
79
+ def test_not_operator
80
+ sql, params = parse_and_visit("-from:spam@example.com")
81
+
82
+ assert_includes sql, "NOT"
83
+ assert_includes sql, "ma1.email_address = ?"
84
+ assert_equal ["from", "cc", "bcc", "spam@example.com"], params
85
+ end
86
+
87
+ def test_complex_query
88
+ sql, params = parse_and_visit("from:alice@example.com subject:meeting has:attachment")
89
+
90
+ assert_includes sql, "ma1.email_address = ?"
91
+ assert_includes sql, "m0.subject LIKE ?"
92
+ assert_includes sql, "m0.has_attachment = 1"
93
+ assert_equal ["from", "cc", "bcc", "alice@example.com", "%meeting%"], params
94
+ end
95
+
96
+ def test_label_operator
97
+ sql, params = parse_and_visit("label:important")
98
+
99
+ assert_includes sql, "INNER JOIN message_labels"
100
+ assert_includes sql, "INNER JOIN labels"
101
+ assert_includes sql, ".name = ?"
102
+ assert_equal ["important"], params
103
+ end
104
+
105
+ def test_is_starred
106
+ sql, params = parse_and_visit("is:starred")
107
+
108
+ assert_includes sql, "m0.is_starred = 1"
109
+ assert_equal [], params
110
+ end
111
+
112
+ def test_has_attachment
113
+ sql, params = parse_and_visit("has:attachment")
114
+
115
+ assert_includes sql, "m0.has_attachment = 1"
116
+ assert_equal [], params
117
+ end
118
+
119
+ def test_larger_operator
120
+ sql, params = parse_and_visit("larger:10M")
121
+
122
+ assert_includes sql, "m0.size_bytes > ?"
123
+ assert_equal [10 * 1024 * 1024], params
124
+ end
125
+
126
+ def test_filename_operator
127
+ sql, params = parse_and_visit("filename:report.pdf")
128
+
129
+ assert_includes sql, "INNER JOIN attachments"
130
+ assert_includes sql, ".filename = ?"
131
+ assert_equal ["report.pdf"], params
132
+ end
133
+
134
+ def test_backward_compatibility_with_older_than_and_newer_than
135
+ # Test that we properly inherit all other behavior from SQLiteVisitor
136
+ sql1, params1 = parse_and_visit("from:alice@example.com")
137
+
138
+ ast = GmailSearchSyntax.parse!("from:alice@example.com")
139
+ sqlite_visitor = GmailSearchSyntax::SQLiteVisitor.new
140
+ sqlite_visitor.visit(ast)
141
+ sql2, params2 = sqlite_visitor.to_query.to_sql
142
+
143
+ # Structure should be the same (just the relative date handling differs)
144
+ assert_equal params1, params2
145
+ # The SQL should be identical for non-date queries
146
+ assert_equal sql1, sql2
147
+ end
148
+
149
+ def test_combined_relative_dates_and_other_operators
150
+ sql, params = parse_and_visit("from:alice@example.com newer_than:7d")
151
+
152
+ assert_includes sql, "ma1.email_address = ?"
153
+ assert_includes sql, "m0.internal_date > (NOW() - ?::interval)"
154
+ assert_equal ["from", "cc", "bcc", "alice@example.com", "7 days"], params
155
+ end
156
+ end
@@ -0,0 +1,346 @@
1
+ require "test_helper"
2
+
3
+ class SqlVisitorTest < Minitest::Test
4
+ include GmailSearchSyntax::AST
5
+
6
+ def parse_and_visit(query_string, current_user_email: nil)
7
+ ast = GmailSearchSyntax.parse!(query_string)
8
+ visitor = GmailSearchSyntax::SqlVisitor.new(current_user_email: current_user_email)
9
+ visitor.visit(ast)
10
+ visitor.to_query.to_sql
11
+ end
12
+
13
+ def test_simple_from_operator
14
+ sql, params = parse_and_visit("from:amy@example.com")
15
+
16
+ assert_includes sql, "INNER JOIN message_addresses"
17
+ assert_includes sql, "address_type = ?"
18
+ assert_includes sql, "email_address = ?"
19
+ assert_equal ["from", "cc", "bcc", "amy@example.com"], params
20
+ end
21
+
22
+ def test_from_with_prefix_match
23
+ sql, params = parse_and_visit("from:amy@")
24
+
25
+ assert_includes sql, "email_address LIKE ?"
26
+ assert_equal ["from", "cc", "bcc", "amy@%"], params
27
+ end
28
+
29
+ def test_from_with_suffix_match
30
+ sql, params = parse_and_visit("from:@example.com")
31
+
32
+ assert_includes sql, "email_address LIKE ?"
33
+ assert_equal ["from", "cc", "bcc", "%@example.com"], params
34
+ end
35
+
36
+ def test_to_operator
37
+ sql, params = parse_and_visit("to:john@example.com")
38
+
39
+ assert_includes sql, "INNER JOIN message_addresses"
40
+ assert_includes sql, "address_type = ?"
41
+ assert_equal ["to", "cc", "bcc", "john@example.com"], params
42
+ end
43
+
44
+ def test_subject_operator
45
+ sql, params = parse_and_visit("subject:dinner")
46
+
47
+ assert_includes sql, "m0.subject LIKE ?"
48
+ assert_equal ["%dinner%"], params
49
+ end
50
+
51
+ def test_after_date_operator
52
+ sql, params = parse_and_visit("after:2004/04/16")
53
+
54
+ assert_includes sql, "m0.internal_date > ?"
55
+ assert_equal ["2004-04-16"], params
56
+ end
57
+
58
+ def test_before_date_operator
59
+ sql, params = parse_and_visit("before:2004/04/18")
60
+
61
+ assert_includes sql, "m0.internal_date < ?"
62
+ assert_equal ["2004-04-18"], params
63
+ end
64
+
65
+ def test_older_than_relative
66
+ sql, params = parse_and_visit("older_than:1y")
67
+
68
+ assert_includes sql, "m0.internal_date < datetime('now', ?)"
69
+ assert_equal ["-1 years"], params
70
+ end
71
+
72
+ def test_newer_than_relative
73
+ sql, params = parse_and_visit("newer_than:2d")
74
+
75
+ assert_includes sql, "m0.internal_date > datetime('now', ?)"
76
+ assert_equal ["-2 days"], params
77
+ end
78
+
79
+ def test_or_operator
80
+ sql, params = parse_and_visit("from:amy OR from:david")
81
+
82
+ assert_includes sql, "OR"
83
+ assert_equal ["from", "cc", "bcc", "amy", "from", "cc", "bcc", "david"], params
84
+ end
85
+
86
+ def test_or_operator_uses_unique_table_aliases
87
+ sql, _params = parse_and_visit("from:alice@example.com OR from:bob@example.com")
88
+
89
+ # Ensure we have two different aliases (ma1 and ma3, not reused)
90
+ # This was a bug where subvisitors would reuse the same alias counter
91
+ # (ma1 and ma3 because messages table uses fixed m0)
92
+ assert_includes sql, "message_addresses AS ma1"
93
+ assert_includes sql, "message_addresses AS ma3"
94
+
95
+ # Count occurrences of each alias in the SQL
96
+ ma1_count = sql.scan(/\bma1\b/).size
97
+ ma3_count = sql.scan(/\bma3\b/).size
98
+
99
+ # Each alias should appear multiple times (in JOIN and WHERE clauses)
100
+ assert ma1_count > 1, "ma1 should appear multiple times"
101
+ assert ma3_count > 1, "ma3 should appear multiple times"
102
+ end
103
+
104
+ def test_and_operator
105
+ sql, params = parse_and_visit("from:amy AND to:david")
106
+
107
+ assert_includes sql, "AND"
108
+ assert_equal ["from", "cc", "bcc", "amy", "to", "cc", "bcc", "david"], params
109
+ end
110
+
111
+ def test_implicit_and
112
+ sql, params = parse_and_visit("from:amy to:david")
113
+
114
+ assert_includes sql, "AND"
115
+ assert_equal ["from", "cc", "bcc", "amy", "to", "cc", "bcc", "david"], params
116
+ end
117
+
118
+ def test_negation
119
+ sql, _ = parse_and_visit("dinner -movie")
120
+
121
+ assert_includes sql, "NOT"
122
+ assert_includes sql, "m0.subject LIKE ?"
123
+ assert_includes sql, "m0.body LIKE ?"
124
+ end
125
+
126
+ def test_label_operator
127
+ sql, params = parse_and_visit("label:friends")
128
+
129
+ assert_includes sql, "INNER JOIN message_labels"
130
+ assert_includes sql, "INNER JOIN labels"
131
+ assert_includes sql, "name = ?"
132
+ assert_equal ["friends"], params
133
+ end
134
+
135
+ def test_category_operator
136
+ sql, params = parse_and_visit("category:primary")
137
+
138
+ assert_includes sql, "m0.category = ?"
139
+ assert_equal ["primary"], params
140
+ end
141
+
142
+ def test_has_attachment
143
+ sql, params = parse_and_visit("has:attachment")
144
+
145
+ assert_includes sql, "m0.has_attachment = 1"
146
+ assert_equal [], params
147
+ end
148
+
149
+ def test_has_yellow_star
150
+ sql, params = parse_and_visit('has:"yellow-star"')
151
+
152
+ assert_includes sql, "m0.has_yellow_star = 1"
153
+ assert_equal [], params
154
+ end
155
+
156
+ def test_has_userlabels
157
+ sql, params = parse_and_visit("has:userlabels")
158
+
159
+ assert_includes sql, "INNER JOIN message_labels"
160
+ assert_includes sql, "is_system_label = 0"
161
+ assert_equal [], params
162
+ end
163
+
164
+ def test_has_nouserlabels
165
+ sql, params = parse_and_visit("has:nouserlabels")
166
+
167
+ assert_includes sql, "NOT EXISTS"
168
+ assert_includes sql, "is_system_label = 0"
169
+ assert_equal [], params
170
+ end
171
+
172
+ def test_filename_extension
173
+ sql, params = parse_and_visit("filename:pdf")
174
+
175
+ assert_includes sql, "INNER JOIN attachments"
176
+ assert_includes sql, "filename LIKE ?"
177
+ assert_equal ["%.pdf", "pdf%"], params
178
+ end
179
+
180
+ def test_filename_exact
181
+ sql, params = parse_and_visit("filename:homework.txt")
182
+
183
+ assert_includes sql, "INNER JOIN attachments"
184
+ assert_includes sql, "filename = ?"
185
+ assert_equal ["homework.txt"], params
186
+ end
187
+
188
+ def test_in_inbox
189
+ sql, params = parse_and_visit("in:inbox")
190
+
191
+ assert_includes sql, "m0.in_inbox = 1"
192
+ assert_equal [], params
193
+ end
194
+
195
+ def test_in_anywhere
196
+ sql, _ = parse_and_visit("in:anywhere")
197
+
198
+ refute_includes sql, "in_inbox"
199
+ refute_includes sql, "in_archive"
200
+ end
201
+
202
+ def test_is_starred
203
+ sql, params = parse_and_visit("is:starred")
204
+
205
+ assert_includes sql, "m0.is_starred = 1"
206
+ assert_equal [], params
207
+ end
208
+
209
+ def test_is_unread
210
+ sql, params = parse_and_visit("is:unread")
211
+
212
+ assert_includes sql, "m0.is_unread = 1"
213
+ assert_equal [], params
214
+ end
215
+
216
+ def test_size_operator
217
+ sql, params = parse_and_visit("size:1000000")
218
+
219
+ assert_includes sql, "m0.size_bytes = ?"
220
+ assert_equal [1000000], params
221
+ end
222
+
223
+ def test_larger_operator_with_m_suffix
224
+ sql, params = parse_and_visit("larger:10M")
225
+
226
+ assert_includes sql, "m0.size_bytes > ?"
227
+ assert_equal [10 * 1024 * 1024], params
228
+ end
229
+
230
+ def test_smaller_operator
231
+ sql, params = parse_and_visit("smaller:1M")
232
+
233
+ assert_includes sql, "m0.size_bytes < ?"
234
+ assert_equal [1 * 1024 * 1024], params
235
+ end
236
+
237
+ def test_rfc822msgid_operator
238
+ sql, params = parse_and_visit("rfc822msgid:200503292@example.com")
239
+
240
+ assert_includes sql, "m0.rfc822_message_id = ?"
241
+ assert_equal ["200503292@example.com"], params
242
+ end
243
+
244
+ def test_plain_text_search
245
+ sql, params = parse_and_visit("meeting")
246
+
247
+ assert_includes sql, "m0.subject LIKE ?"
248
+ assert_includes sql, "m0.body LIKE ?"
249
+ assert_equal ["meeting", "%meeting%", "%meeting%"], params
250
+ end
251
+
252
+ def test_complex_query
253
+ sql, _ = parse_and_visit("from:amy subject:meeting has:attachment")
254
+
255
+ assert_includes sql, "AND"
256
+ assert_includes sql, "INNER JOIN message_addresses"
257
+ assert_includes sql, "m0.subject LIKE ?"
258
+ assert_includes sql, "m0.has_attachment = 1"
259
+ end
260
+
261
+ def test_or_with_parentheses
262
+ sql, _ = parse_and_visit("from:(amy@example.com OR bob@example.com)")
263
+
264
+ assert_includes sql, "OR"
265
+ assert_includes sql, "INNER JOIN message_addresses"
266
+ end
267
+
268
+ def test_braces_as_or
269
+ sql, _ = parse_and_visit("from:{amy@example.com bob@example.com}")
270
+
271
+ assert_includes sql, "OR"
272
+ end
273
+
274
+ def test_negation_with_operator
275
+ sql, params = parse_and_visit("-from:spam@example.com")
276
+
277
+ assert_includes sql, "NOT"
278
+ assert_includes sql, "INNER JOIN message_addresses"
279
+ assert_equal ["from", "cc", "bcc", "spam@example.com"], params
280
+ end
281
+
282
+ def test_around_operator_generates_noop
283
+ sql, params = parse_and_visit("holiday AROUND 10 vacation")
284
+
285
+ assert_includes sql, "(1 = 0)"
286
+ assert_equal [], params
287
+ end
288
+
289
+ def test_list_operator
290
+ sql, params = parse_and_visit("list:info@example.com")
291
+
292
+ assert_includes sql, "m0.mailing_list = ?"
293
+ assert_equal ["info@example.com"], params
294
+ end
295
+
296
+ def test_list_with_suffix_match
297
+ sql, params = parse_and_visit("list:@example.com")
298
+
299
+ assert_includes sql, "m0.mailing_list LIKE ?"
300
+ assert_equal ["%@example.com"], params
301
+ end
302
+
303
+ def test_deliveredto_operator
304
+ sql, params = parse_and_visit("deliveredto:username@example.com")
305
+
306
+ assert_includes sql, "INNER JOIN message_addresses"
307
+ assert_includes sql, "address_type = ?"
308
+ assert_equal ["delivered_to", "username@example.com"], params
309
+ end
310
+
311
+ def test_cc_operator
312
+ sql, params = parse_and_visit("cc:john@example.com")
313
+
314
+ assert_includes sql, "address_type = ?"
315
+ assert_equal ["cc", "john@example.com"], params
316
+ end
317
+
318
+ def test_bcc_operator
319
+ sql, params = parse_and_visit("bcc:david@example.com")
320
+
321
+ assert_includes sql, "address_type = ?"
322
+ assert_equal ["bcc", "david@example.com"], params
323
+ end
324
+
325
+ def test_from_me_with_current_user
326
+ sql, params = parse_and_visit("from:me", current_user_email: "test@example.com")
327
+
328
+ assert_includes sql, "email_address = ?"
329
+ assert_equal ["from", "cc", "bcc", "test@example.com"], params
330
+ end
331
+
332
+ def test_nested_conditions
333
+ sql, _ = parse_and_visit("from:amy (subject:meeting OR subject:call)")
334
+
335
+ assert_includes sql, "AND"
336
+ assert_includes sql, "OR"
337
+ assert_includes sql, "m0.subject LIKE ?"
338
+ end
339
+
340
+ def test_multiple_joins_with_same_table
341
+ sql, _ = parse_and_visit("from:amy to:bob")
342
+
343
+ join_count = sql.scan("INNER JOIN message_addresses").length
344
+ assert_equal 2, join_count
345
+ end
346
+ end
@@ -0,0 +1,27 @@
1
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
2
+ require "gmail_search_syntax"
3
+ require "minitest/autorun"
4
+
5
+ # Helper module for Gmail message ID generation
6
+ module GmailMessageIdHelper
7
+ # Generates a Gmail-style message ID from a timestamp
8
+ # Based on: https://www.metaspike.com/dates-gmail-message-id-thread-id-timestamps/
9
+ #
10
+ # Gmail Message IDs encode the timestamp in the first part (all but last 5 hex digits)
11
+ # To generate: take timestamp in milliseconds, convert to hex, append 5 random hex digits
12
+ #
13
+ # @param time [Time] The timestamp to encode in the message ID
14
+ # @param random [Random, nil] Optional Random instance for reproducible testing
15
+ # @return [String] A hexadecimal Gmail message ID (15-16 digits)
16
+ def generate_gmail_message_id(time, random: Random.new)
17
+ # Get timestamp in milliseconds since epoch
18
+ timestamp_ms = (time.to_f * 1000).to_i
19
+ # Convert timestamp to hex and append first 5 hex digits from random bytes
20
+ timestamp_hex = timestamp_ms.to_s(16)
21
+ # Generate 3 random bytes (24 bits = 6 hex digits, we'll use first 5)
22
+ # Using bytes is much faster than generating individual hex digits
23
+ random_hex = random.bytes(3).unpack1("H*")[0, 5]
24
+
25
+ timestamp_hex + random_hex
26
+ end
27
+ end