sitegoose 0.3.3

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,399 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Database queries (sdk/protocols/queries.md), part of Server speed: per
4
+ # minute, request method and route, database and query, how many ran, how
5
+ # many failed, their total and slowest time and a count per time bucket, sent
6
+ # to POST /events/server-queries. Every value in a query is replaced by "?"
7
+ # before it is counted (QueryText); bound parameters are never read.
8
+ #
9
+ # Caught through each library's public hook, one layer per query:
10
+ # Rails (Active Record): the sql.active_record notification, automatic.
11
+ # SCHEMA and cached queries are left out.
12
+ # Sequel: SiteGoose.watch_database(DB), one line. A Sequel query
13
+ # run on Active Record's connection is counted once, as Sequel's.
14
+ # Mongo (the mongo gem, Mongoid): a global command subscriber, automatic.
15
+ #
16
+ # A query is added to the request (or job) it ran in, and the request's
17
+ # queries go into the minute's rows when it finishes, under the method and
18
+ # route Server speed records for it. A query outside any request or job is
19
+ # "JOB" "(background)". Rides on the performance module: with Server speed
20
+ # off nothing is recorded; queries: false keeps Server speed without them.
21
+
22
+ module SiteGoose
23
+ class Queries
24
+ FLUSH_EVERY = Performance::FLUSH_EVERY
25
+ MAX_ROWS = 500
26
+ # Distinct statements one request keeps; past it new ones are "(other)".
27
+ MAX_PER_REQUEST = 50
28
+ # Keys in a minute: past MAX_KEYS new statements are "(other)", past
29
+ # HARD_MAX_KEYS nothing new is kept.
30
+ MAX_KEYS = 200
31
+ HARD_MAX_KEYS = 300
32
+ # Minutes kept unsent (a platform that can't send flushes by hand).
33
+ MAX_MINUTES = 5
34
+ OTHER = QueryText::OTHER_STATEMENT
35
+ BACKGROUND = ["JOB", "(background)"].freeze
36
+ # The request's own queries, fiber-local beside Context's fields: Context.with
37
+ # starts each request or job with none.
38
+ REQUEST = Context::QUERIES
39
+ # Set while an outer layer times a query, so an inner one leaves it.
40
+ TIMING = :sitegoose_query_timing
41
+ CACHE_MAX = 2000
42
+ CACHE_TEXT = 4000
43
+
44
+ @open = []
45
+ @lock = Mutex.new
46
+ @cache = {}
47
+ @hooked = {}
48
+
49
+ class << self
50
+ # The platform's Queries that hooks report to: the newest still open.
51
+ def active
52
+ @lock.synchronize { @open.last }
53
+ end
54
+
55
+ def opened(queries)
56
+ @lock.synchronize { @open << queries }
57
+ end
58
+
59
+ def closed(queries)
60
+ @lock.synchronize { @open.delete(queries) }
61
+ end
62
+
63
+ # The statement as stored ("" to leave it out); the same query text
64
+ # comes round again and again, so the answer is kept.
65
+ def statement(text, db)
66
+ return QueryText.normalize_statement(text, db) if !text.is_a?(String) || text.size > CACHE_TEXT
67
+
68
+ key = [db, text]
69
+ found = @lock.synchronize { @cache[key] }
70
+ return found if found
71
+
72
+ shaped = QueryText.normalize_statement(text, db).freeze
73
+ @lock.synchronize do
74
+ @cache.clear if @cache.size >= CACHE_MAX
75
+ @cache[key.map { |part| part.dup.freeze }] = shaped
76
+ end
77
+ shaped
78
+ end
79
+
80
+ def timing?
81
+ Thread.current[TIMING] ? true : false
82
+ end
83
+
84
+ # Runs the block with this thread's queries marked as timed by an outer layer.
85
+ def timing
86
+ saved = Thread.current[TIMING]
87
+ Thread.current[TIMING] = true
88
+ yield
89
+ ensure
90
+ Thread.current[TIMING] = saved
91
+ end
92
+
93
+ # One finished query, from any hook. db: one of QueryText::DATABASES.
94
+ def record(db, text, ms, failed: false)
95
+ active&.record(db, text, ms, failed: failed)
96
+ rescue StandardError
97
+ nil
98
+ end
99
+
100
+ def blank
101
+ { count: 0, errors: 0, sum_ms: 0.0, max_ms: 0.0, buckets: Array.new(Performance::BUCKET_EDGES_MS.size + 1, 0) }
102
+ end
103
+
104
+ def merge(into, from)
105
+ into[:count] += from[:count]
106
+ into[:errors] += from[:errors]
107
+ into[:sum_ms] += from[:sum_ms]
108
+ into[:max_ms] = from[:max_ms] if from[:max_ms] > into[:max_ms]
109
+ from[:buckets].each_with_index { |n, i| into[:buckets][i] += n }
110
+ into
111
+ end
112
+
113
+ def add(into, ms, failed)
114
+ into[:count] += 1
115
+ into[:errors] += 1 if failed
116
+ into[:sum_ms] += ms
117
+ into[:max_ms] = ms if ms > into[:max_ms]
118
+ into[:buckets][Performance.bucket_of(ms)] += 1
119
+ into
120
+ end
121
+
122
+ # ---- Hooks: installed once per process, for every platform ----
123
+
124
+ def install
125
+ hook_active_record
126
+ hook_mongo
127
+ end
128
+
129
+ # Active Record's sql.active_record notification (Rails, or Active
130
+ # Record on its own). Monotonic times where Rails has them.
131
+ def hook_active_record
132
+ return if @hooked[:active_record] || !defined?(::ActiveSupport::Notifications)
133
+
134
+ @hooked[:active_record] = true
135
+ handler = lambda do |_name, started, finished, _id, payload|
136
+ Queries.active_record(started, finished, payload)
137
+ end
138
+ if ::ActiveSupport::Notifications.respond_to?(:monotonic_subscribe)
139
+ ::ActiveSupport::Notifications.monotonic_subscribe("sql.active_record", &handler)
140
+ else
141
+ ::ActiveSupport::Notifications.subscribe("sql.active_record", &handler)
142
+ end
143
+ rescue StandardError
144
+ nil
145
+ end
146
+
147
+ def active_record(started, finished, payload)
148
+ return if !payload.is_a?(Hash) || timing? || active.nil?
149
+
150
+ name = payload[:name].to_s
151
+ return if name == "SCHEMA" || name == "CACHE" || payload[:cached]
152
+
153
+ failed = payload[:exception] || payload[:exception_object] ? true : false
154
+ record(active_record_db(payload[:connection]), payload[:sql], (finished - started).to_f * 1000, failed: failed)
155
+ rescue StandardError
156
+ nil
157
+ end
158
+
159
+ # The connection's adapter, as a database name.
160
+ def active_record_db(connection)
161
+ name = connection.respond_to?(:adapter_name) ? connection.adapter_name.to_s.downcase : ""
162
+ case name
163
+ when /postg/ then "postgresql"
164
+ when "mysql", "mysql2", "trilogy" then "mysql"
165
+ when /sqlite/ then "sqlite"
166
+ when /sqlserver|mssql/ then "mssql"
167
+ when /oracle/ then "oracle"
168
+ else "other"
169
+ end
170
+ end
171
+
172
+ # The mongo gem's global command monitoring (Mongoid uses it too).
173
+ def hook_mongo
174
+ return if @hooked[:mongo] || !defined?(::Mongo::Monitoring::Global)
175
+
176
+ @hooked[:mongo] = true
177
+ ::Mongo::Monitoring::Global.subscribe(::Mongo::Monitoring::COMMAND, MongoSubscriber.new)
178
+ rescue StandardError
179
+ nil
180
+ end
181
+
182
+ def sequel_db(database)
183
+ case database.respond_to?(:database_type) ? database.database_type.to_s : ""
184
+ when "postgres" then "postgresql"
185
+ when "mysql" then "mysql"
186
+ when "sqlite" then "sqlite"
187
+ when "mssql" then "mssql"
188
+ when "oracle" then "oracle"
189
+ else "other"
190
+ end
191
+ end
192
+
193
+ # Times every query a Sequel::Database runs.
194
+ def watch_sequel(database)
195
+ database.instance_variable_set(:@__sitegoose_db, sequel_db(database))
196
+ database.extend(SequelHook) unless database.singleton_class.include?(SequelHook)
197
+ database
198
+ end
199
+ end
200
+
201
+ # Extends one Sequel::Database (Sequel's own extensions wrap the same
202
+ # public method); nothing else of Sequel is changed.
203
+ module SequelHook
204
+ def log_connection_yield(sql, conn, args = nil)
205
+ return super if Queries.timing? || Queries.active.nil?
206
+
207
+ started = SiteGoose.clock
208
+ failed = false
209
+ begin
210
+ Queries.timing { super }
211
+ rescue StandardError
212
+ failed = true
213
+ raise
214
+ ensure
215
+ Queries.record(@__sitegoose_db, sql, (SiteGoose.clock - started) * 1000, failed: failed)
216
+ end
217
+ end
218
+ end
219
+
220
+ # Mongo::Monitoring's command subscriber: the statement is built when a
221
+ # command starts, recorded when it ends.
222
+ class MongoSubscriber
223
+ CHATTER = %w[hello isMaster ismaster ping buildInfo saslStart saslContinue getMore endSessions killCursors
224
+ getLastError].freeze
225
+ FILTER = { "find" => "filter", "aggregate" => "pipeline", "count" => "query", "distinct" => "query",
226
+ "findAndModify" => "query" }.freeze
227
+ LISTS = { "delete" => "deletes", "update" => "updates" }.freeze
228
+ WAITING_MAX = 1000
229
+
230
+ def initialize
231
+ @waiting = {}
232
+ @lock = Mutex.new
233
+ end
234
+
235
+ # "find users {...}" for a command (a hash).
236
+ def self.text(name, command)
237
+ collection = command.is_a?(Hash) ? command[name] : nil
238
+ return name if collection.nil?
239
+
240
+ filter = if FILTER.key?(name) then command[FILTER[name]]
241
+ elsif LISTS.key?(name)
242
+ first = command[LISTS[name]]
243
+ first = first.first if first.is_a?(Array)
244
+ first.is_a?(Hash) ? (first["q"] || first[:q]) : nil
245
+ end
246
+ text = "#{name} #{collection}"
247
+ filter.nil? ? text : "#{text} #{JSON.generate(QueryText.mongo_shape(filter))}"
248
+ rescue StandardError
249
+ name
250
+ end
251
+
252
+ def started(event)
253
+ name = event.command_name.to_s
254
+ return if CHATTER.include?(name) || Queries.active.nil?
255
+
256
+ text = MongoSubscriber.text(name, event.command)
257
+ @lock.synchronize do
258
+ @waiting.delete(@waiting.keys.first) while @waiting.size >= WAITING_MAX
259
+ @waiting[event.request_id] = text
260
+ end
261
+ rescue StandardError
262
+ nil
263
+ end
264
+
265
+ def succeeded(event)
266
+ finished(event, false)
267
+ end
268
+
269
+ def failed(event)
270
+ finished(event, true)
271
+ end
272
+
273
+ def finished(event, failed)
274
+ text = @lock.synchronize { @waiting.delete(event.request_id) }
275
+ Queries.record("mongodb", text, event.duration.to_f * 1000, failed: failed) if text
276
+ rescue StandardError
277
+ nil
278
+ end
279
+ end
280
+
281
+ attr_reader :rows
282
+
283
+ def initialize(core, server_name: nil)
284
+ @core = core
285
+ @server_name = SiteGoose.text(server_name || SiteGoose.hostname, 100)
286
+ @rows = {}
287
+ @lock = Mutex.new
288
+ @job = core.valid? ? Periodic.new(FLUSH_EVERY) { flush(everything: false); FLUSH_EVERY } : nil
289
+ Forking.register(self)
290
+ Queries.opened(self)
291
+ Queries.install
292
+ end
293
+
294
+ # In a forked worker the parent's rows are the parent's to send.
295
+ def after_fork_in_child
296
+ @lock = Mutex.new
297
+ @rows = {}
298
+ end
299
+
300
+ def on?
301
+ @core.on?("performance")
302
+ end
303
+
304
+ # One finished query: into its request's (or job's) queries, or, with
305
+ # none around it, straight into the minute's rows as background.
306
+ def record(db, text, ms, failed: false)
307
+ return unless on?
308
+
309
+ db = QueryText.database_of(db)
310
+ statement = Queries.statement(text, db)
311
+ return if statement.empty?
312
+
313
+ ms = ms.to_f
314
+ ms = 0.0 if ms.negative? || ms.nan?
315
+ if Context.request
316
+ mine = (Thread.current[REQUEST] ||= {})
317
+ key = [db, statement]
318
+ key = [db, OTHER] if !mine.key?(key) && mine.size >= MAX_PER_REQUEST
319
+ Queries.add(mine[key] ||= Queries.blank, ms, failed)
320
+ else
321
+ one = Queries.add(Queries.blank, ms, failed)
322
+ @lock.synchronize { add_row(Queries.minute, *BACKGROUND, db, statement, one) }
323
+ end
324
+ end
325
+
326
+ def self.minute
327
+ (Time.now.to_i / 60) * 60
328
+ end
329
+
330
+ # The request (or job) finished, and Server speed recorded it under
331
+ # method and route: its queries go into this minute's rows.
332
+ def finish(method, route)
333
+ mine = Thread.current[REQUEST]
334
+ Thread.current[REQUEST] = nil
335
+ return if mine.nil? || mine.empty? || !on?
336
+
337
+ minute = Queries.minute
338
+ @lock.synchronize do
339
+ mine.each { |(db, statement), agg| add_row(minute, method, route, db, statement, agg) }
340
+ end
341
+ end
342
+
343
+ # Under @lock.
344
+ def add_row(minute, method, route, db, statement, agg)
345
+ keys = @rows[minute]
346
+ unless keys
347
+ @rows.delete(@rows.keys.min) while @rows.size >= MAX_MINUTES
348
+ keys = @rows[minute] = {}
349
+ end
350
+ key = [method, route, db, statement]
351
+ key = [method, route, db, OTHER] if !keys.key?(key) && keys.size >= MAX_KEYS
352
+ return if !keys.key?(key) && keys.size >= HARD_MAX_KEYS
353
+
354
+ Queries.merge(keys[key] ||= Queries.blank, agg)
355
+ end
356
+ private :add_row
357
+
358
+ def flush(everything: true)
359
+ current = Queries.minute
360
+ ready = @lock.synchronize do
361
+ taken = @rows.select { |minute, _| everything || minute < current }
362
+ taken.each_key { |minute| @rows.delete(minute) }
363
+ taken
364
+ end
365
+ rows = ready.flat_map do |minute, keys|
366
+ at = Time.at(minute).utc.iso8601(3)
367
+ keys.map do |(method, route, db, statement), v|
368
+ { "minute" => at, "method" => method, "route" => route, "db" => db, "statement" => statement,
369
+ "count" => v[:count], "errors" => v[:errors], "sum_ms" => v[:sum_ms].round(1),
370
+ "max_ms" => v[:max_ms].round(1), "buckets" => v[:buckets] }
371
+ end
372
+ end
373
+ rows.each_slice(MAX_ROWS) do |batch|
374
+ @core.request("queries", "/events/server-queries", auth: :bearer,
375
+ body: { "site_id" => @core.site_id, "server_name" => @server_name,
376
+ "runtime" => RUNTIME, "rows" => batch })
377
+ end
378
+ end
379
+
380
+ def close
381
+ @job&.stop
382
+ Forking.unregister(self)
383
+ Queries.closed(self)
384
+ flush
385
+ end
386
+ end
387
+
388
+ # Times every query this Sequel database runs (Health -> Server speed ->
389
+ # Queries): SiteGoose.watch_database(DB). Rails' Active Record and
390
+ # the mongo gem are caught on their own.
391
+ def self.watch_database(database)
392
+ if defined?(::Sequel::Database) && database.is_a?(::Sequel::Database)
393
+ Queries.watch_sequel(database)
394
+ else
395
+ warn "[analytics-platform] watch_database: #{database.class} is not a Sequel::Database; Active Record and Mongo are timed on their own"
396
+ end
397
+ database
398
+ end
399
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ # Database queries with their values taken out (sdk/protocols/queries.md):
6
+ # the one rule every server package applies before a query leaves the
7
+ # customer's server. A port of expresscollect/Analytics/queryText.js, which
8
+ # is the reference; the tests run sdk/protocols/query-cases.json against it.
9
+ # Lengths are code points, and "whitespace" is what JavaScript's \s and
10
+ # trim() mean, so the two give the same text character for character.
11
+ #
12
+ # SELECT * FROM users WHERE email = 'a@b.c' AND id IN (1, 2, 3)
13
+ # -> SELECT * FROM users WHERE email = ? AND id IN (?)
14
+
15
+ module SiteGoose
16
+ module QueryText
17
+ MAX_STATEMENT = 1000
18
+ OTHER_STATEMENT = "(other)"
19
+ # The databases a row may name; anything else is "other".
20
+ DATABASES = %w[postgresql mysql mariadb sqlite mssql oracle mongodb other].freeze
21
+
22
+ # JavaScript's \s (and what its trim() removes).
23
+ SPACE = "\\t\\n\\v\\f\\r \\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff"
24
+ SPACE_CHAR = /\A[#{SPACE}]\z/.freeze
25
+ TRIM = /\A[#{SPACE}]+|[#{SPACE}]+\z/.freeze
26
+ # A digit right after one of these is part of a name or a placeholder
27
+ # (table2, $1, @p1, :1), not a value. ASCII only, in every package.
28
+ WORD = /\A[A-Za-z0-9_$@:]\z/.freeze
29
+ DIGIT = /\A[0-9]\z/.freeze
30
+ PREFIX_LETTER = /\A[EeNnXxBb]\z/.freeze
31
+ NAME_CHAR = /\A[A-Za-z0-9_]\z/.freeze
32
+ NUMBER = /\A(?:0[xX][0-9a-fA-F]+|(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)/.freeze
33
+ # The only characters NUMBER can match.
34
+ NUMBER_CHAR = /\A[0-9a-fA-FxX.eE+-]\z/.freeze
35
+ DOLLAR_TAG = /\A\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/.freeze
36
+ PLACEHOLDER = "(?:\\?|\\$[0-9]+|:[A-Za-z0-9_]+|@[A-Za-z0-9_]+)"
37
+ LIST = /\([#{SPACE}]*#{PLACEHOLDER}(?:[#{SPACE}]*,[#{SPACE}]*#{PLACEHOLDER})*[#{SPACE}]*\)/.freeze
38
+ ROWS = /\(\?\)(?:[#{SPACE}]*,[#{SPACE}]*\(\?\))+/.freeze
39
+ MONGO = /\A([^#{SPACE}]+)(?: ([^#{SPACE}]+))?(?: (.*))?\z/m.freeze
40
+ # A JavaScript object lists keys that are array indexes first, in order.
41
+ INDEX_KEY = /\A(?:0|[1-9][0-9]*)\z/.freeze
42
+
43
+ module_function
44
+
45
+ def trim(text)
46
+ text.gsub(TRIM, "")
47
+ end
48
+
49
+ def database_of(db)
50
+ name = db.nil? ? "" : SiteGoose.text(db).downcase
51
+ DATABASES.include?(name) ? name : "other"
52
+ end
53
+
54
+ # The statement to store, or "" when nothing is left of it.
55
+ def normalize_statement(text, db)
56
+ return "" unless text.is_a?(String)
57
+
58
+ text = SiteGoose.text(text)
59
+ return text if text == OTHER_STATEMENT
60
+
61
+ db = database_of(db)
62
+ cut(db == "mongodb" ? mongo_statement(text) : sql_shape(text, db))
63
+ end
64
+
65
+ def cut(text)
66
+ text.length > MAX_STATEMENT ? trim(text[0, MAX_STATEMENT]) : text
67
+ end
68
+
69
+ # The first index at or after from where chars holds seq (an array of
70
+ # characters), or nil.
71
+ def index_of(chars, seq, from)
72
+ last = chars.size - seq.size
73
+ i = from
74
+ while i <= last
75
+ return i if chars[i, seq.size] == seq
76
+
77
+ i += 1
78
+ end
79
+ nil
80
+ end
81
+
82
+ # The end of a quoted run starting at start (the opening quote): the
83
+ # index after the closing quote, or the text's length. A doubled quote
84
+ # and a backslash both escape.
85
+ def quote_end(chars, start, quote)
86
+ i = start + 1
87
+ while i < chars.size
88
+ c = chars[i]
89
+ if c == "\\"
90
+ i += 2
91
+ elsif c == quote
92
+ return i + 1 unless chars[i + 1] == quote
93
+
94
+ i += 2
95
+ else
96
+ i += 1
97
+ end
98
+ end
99
+ chars.size
100
+ end
101
+
102
+ NEWLINE = ["\n"].freeze
103
+ COMMENT_END = ["*", "/"].freeze
104
+
105
+ def sql_shape(sql, db)
106
+ mysql = %w[mysql mariadb].include?(db)
107
+ chars = sql.chars
108
+ out = []
109
+ space = -> { out << " " if !out.empty? && out[-1] != " " }
110
+ i = 0
111
+ while i < chars.size
112
+ c = chars[i]
113
+ nxt = chars[i + 1]
114
+ prev = i.positive? ? chars[i - 1] : ""
115
+ if (c == "-" && nxt == "-") || (c == "#" && mysql)
116
+ i = index_of(chars, NEWLINE, i) || chars.size
117
+ space.call
118
+ elsif c == "/" && nxt == "*"
119
+ ends = index_of(chars, COMMENT_END, i + 2)
120
+ i = ends ? ends + 2 : chars.size
121
+ space.call
122
+ elsif c == "'" || (c == '"' && mysql)
123
+ out.pop if out[-1]&.match?(PREFIX_LETTER) && (out.size == 1 || !out[-2].match?(NAME_CHAR))
124
+ i = quote_end(chars, i, c)
125
+ out << "?"
126
+ elsif c == '"' || c == "`"
127
+ ends = quote_end(chars, i, c)
128
+ out.concat(chars[i...ends])
129
+ i = ends
130
+ elsif c == "$" && !prev.match?(WORD) && (tag = chars[i, 64].join[DOLLAR_TAG])
131
+ tag_chars = tag.chars
132
+ ends = index_of(chars, tag_chars, i + tag_chars.size)
133
+ i = ends ? ends + tag_chars.size : chars.size
134
+ out << "?"
135
+ elsif (c.match?(DIGIT) || (c == "." && nxt.to_s.match?(DIGIT))) && !prev.match?(WORD)
136
+ run = +""
137
+ j = i
138
+ while j < chars.size && j < i + 400 && chars[j].match?(NUMBER_CHAR)
139
+ run << chars[j]
140
+ j += 1
141
+ end
142
+ i += run[NUMBER].length
143
+ out << "?"
144
+ elsif c.match?(SPACE_CHAR)
145
+ i += 1
146
+ space.call
147
+ else
148
+ out << c
149
+ i += 1
150
+ end
151
+ end
152
+ trim(out.join.gsub(LIST, "(?)").gsub(ROWS, "(?)"))
153
+ end
154
+
155
+ # A Mongo filter or pipeline's shape: keys kept, every value "?", and an
156
+ # array whose items all have the same shape kept as one.
157
+ def mongo_shape(value, depth = 0)
158
+ return "?" if depth >= 6
159
+
160
+ case value
161
+ when Array
162
+ items = value.map { |item| mongo_shape(item, depth + 1) }
163
+ items.map { |item| JSON.generate(item) }.uniq.size == 1 ? [items[0]] : items
164
+ when Hash
165
+ pairs = value.map { |key, item| [key.to_s, item] }
166
+ index, named = pairs.partition { |key, _| key.match?(INDEX_KEY) && key.to_i < 4_294_967_295 }
167
+ (index.sort_by { |key, _| key.to_i } + named).each_with_object({}) do |(key, item), out|
168
+ out[key] = mongo_shape(item, depth + 1)
169
+ end
170
+ else "?"
171
+ end
172
+ end
173
+
174
+ # Ruby's parser takes comments and raw control characters that
175
+ # JavaScript's JSON.parse refuses: text with either is not JSON here.
176
+ def strict_json?(text)
177
+ quoted = false
178
+ escaped = false
179
+ text.each_char do |c|
180
+ if quoted
181
+ if escaped then escaped = false
182
+ elsif c == "\\" then escaped = true
183
+ elsif c == '"' then quoted = false
184
+ elsif c.ord < 0x20 then return false
185
+ end
186
+ elsif c == '"' then quoted = true
187
+ elsif c == "/" then return false
188
+ end
189
+ end
190
+ true
191
+ end
192
+
193
+ # "find users {...}": the command, the collection, and the filter's shape.
194
+ def mongo_statement(text)
195
+ match = MONGO.match(trim(text))
196
+ return "" unless match
197
+
198
+ command, collection, rest = match.captures
199
+ # A "collection" with a brace, bracket or quote in it is the filter's start.
200
+ if collection&.match?(/[{}\[\]"']/)
201
+ rest = rest.nil? ? collection : "#{collection} #{rest}"
202
+ collection = nil
203
+ end
204
+ shape = ""
205
+ if rest && strict_json?(rest)
206
+ begin
207
+ shape = JSON.generate(mongo_shape(JSON.parse(rest, max_nesting: false)))
208
+ rescue JSON::ParserError, JSON::GeneratorError, EncodingError, TypeError
209
+ shape = ""
210
+ end
211
+ end
212
+ [command, collection, shape].reject { |part| part.nil? || part.empty? }.join(" ")
213
+ end
214
+ end
215
+ end