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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +342 -0
- data/lib/sitegoose/analytics.rb +113 -0
- data/lib/sitegoose/challenge.rb +177 -0
- data/lib/sitegoose/core.rb +523 -0
- data/lib/sitegoose/crawlers.rb +195 -0
- data/lib/sitegoose/errors.rb +456 -0
- data/lib/sitegoose/firewall.rb +417 -0
- data/lib/sitegoose/inventory.rb +131 -0
- data/lib/sitegoose/ip.rb +159 -0
- data/lib/sitegoose/middleware.rb +52 -0
- data/lib/sitegoose/performance.rb +114 -0
- data/lib/sitegoose/platform.rb +557 -0
- data/lib/sitegoose/posture.rb +272 -0
- data/lib/sitegoose/proxy.rb +260 -0
- data/lib/sitegoose/queries.rb +399 -0
- data/lib/sitegoose/query_text.rb +215 -0
- data/lib/sitegoose/railtie.rb +124 -0
- data/lib/sitegoose/request_rules.rb +390 -0
- data/lib/sitegoose/resources.rb +449 -0
- data/lib/sitegoose/sidekiq.rb +95 -0
- data/lib/sitegoose/small_modules.rb +250 -0
- data/lib/sitegoose/version.rb +5 -0
- data/lib/sitegoose.rb +261 -0
- metadata +92 -0
|
@@ -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
|