icfs 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/icfs/cache.rb ADDED
@@ -0,0 +1,254 @@
1
+ #
2
+ # Investigative Case File System
3
+ #
4
+ # Copyright 2019 by Graham A. Field
5
+ #
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License version 3.
8
+ #
9
+ # This program is distributed WITHOUT ANY WARRANTY; without even the
10
+ # implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11
+
12
+ #
13
+ module ICFS
14
+
15
+ ##########################################################################
16
+ # Stores current items and provides a standard search interface.
17
+ #
18
+ # Stores:
19
+ # * Case - current version
20
+ # * Log - all
21
+ # * Entry - current version
22
+ # * Action - current version
23
+ # * Current - for each case
24
+ # * Index - current version
25
+ #
26
+ # Provides locking and searching interface.
27
+ #
28
+ # @abstract
29
+ #
30
+ class Cache
31
+
32
+ ###############################################
33
+ # What searching options are supported
34
+ #
35
+ # @return [Hash] Supported searching options
36
+ #
37
+ def supports; raise NotImplementedError; end
38
+
39
+
40
+ ###############################################
41
+ # Take a case lock
42
+ #
43
+ # @param cid [String] caseid
44
+ #
45
+ def lock_take(cid); raise NotImplementedError; end
46
+
47
+
48
+ ###############################################
49
+ # Release a case lock
50
+ #
51
+ # @param cid [String] caseid
52
+ #
53
+ def lock_release(cid); raise NotImplementedError; end
54
+
55
+
56
+ ###############################################
57
+ # Read current
58
+ #
59
+ # @param cid [String] caseid
60
+ # @return [String] JSON encoded item
61
+ #
62
+ def current_read(cid); raise NotImplementedError; end
63
+
64
+
65
+ ###############################################
66
+ # Write current
67
+ #
68
+ # @param cid [String] caseid
69
+ # @param item [String] JSON encoded item
70
+ #
71
+ def current_write(cid, item); raise NotImplementedError; end
72
+
73
+
74
+ ###############################################
75
+ # Read a case
76
+ #
77
+ # @param cid [String] caseid
78
+ # @return [String] JSON encoded item
79
+ #
80
+ def case_read(cid); raise NotImplementedError; end
81
+
82
+
83
+ ###############################################
84
+ # Write a case
85
+ #
86
+ # @param cid [String] caseid
87
+ # @param item [String] JSON encoded item
88
+ #
89
+ def case_write(cid, item); raise NotImplementedError; end
90
+
91
+
92
+ ###############################################
93
+ # Search for cases
94
+ #
95
+ # @param query [Hash] the query
96
+ #
97
+ def case_search(query); raise NotImplementedError; end
98
+
99
+
100
+ ###############################################
101
+ # Get list of tags for cases
102
+ #
103
+ # @param query [Hash] the query
104
+ #
105
+ def case_tags(query); raise NotImplementedError;end
106
+
107
+
108
+ ###############################################
109
+ # Read an entry
110
+ #
111
+ # @param cid [String] caseid
112
+ # @param enum [Integer] the entry number
113
+ # @return [String] JSON encoded item
114
+ #
115
+ def entry_read(cid, enum); raise NotImplementedError; end
116
+
117
+
118
+ ###############################################
119
+ # Write an entry
120
+ #
121
+ # @param cid [String] caseid
122
+ # @param enum [Integer] the entry number
123
+ # @param item [String] JSON encoded item
124
+ #
125
+ def entry_write(cid, enum, item); raise NotImplementedError; end
126
+
127
+
128
+ ###############################################
129
+ # Search for entries
130
+ #
131
+ # @param query [Hash] the query
132
+ #
133
+ def entry_search(query); raise NotImplementedError; end
134
+
135
+
136
+ ###############################################
137
+ # List tags used on Entries
138
+ #
139
+ # @param query [Hash] the query
140
+ #
141
+ def entry_tags(query); raise NotImplementedError; end
142
+
143
+
144
+ ###############################################
145
+ # Read an action
146
+ #
147
+ # @param cid [String] caseid
148
+ # @param anum [Integer] the action number
149
+ # @return [String] JSON encoded item
150
+ #
151
+ def action_read(cid, anum); raise NotImplementedError; end
152
+
153
+
154
+ ###############################################
155
+ # Write an action
156
+ #
157
+ # @param cid [String] caseid
158
+ # @param anum [Integer] the action number
159
+ # @param item [String] JSON encoded item
160
+ #
161
+ def action_write(cid, anum, item); raise NotImplementedError; end
162
+
163
+
164
+ ###############################################
165
+ # Search for actions
166
+ #
167
+ # @param query [Hash] the query
168
+ #
169
+ def action_search(query); raise NotImplementedError; end
170
+
171
+
172
+ ###############################################
173
+ # List tags used on action tasks
174
+ #
175
+ # @param query [Hash] the query
176
+ #
177
+ def action_tags(query); raise NotImplementedError; end
178
+
179
+
180
+ ###############################################
181
+ # Read an Index
182
+ #
183
+ # @param cid [String] caseid
184
+ # @param xnum [Integer] the index number
185
+ # @return [String] JSON encoded item
186
+ #
187
+ def index_read(cid, xnum); raise NotImplementedError; end
188
+
189
+
190
+ ###############################################
191
+ # Write an Index
192
+ #
193
+ # @param cid [String] caseid
194
+ # @param xnum [Integer] the index number
195
+ # @param item [String] JSON encoded item
196
+ #
197
+ def index_write(cid, xnum, item); raise NotImplementedError; end
198
+
199
+
200
+ ###############################################
201
+ # Search for Indexes
202
+ #
203
+ # @param query [Hash] the query
204
+ #
205
+ def index_search(query); raise NotImplementedError; end
206
+
207
+
208
+ ###############################################
209
+ # List tags used in indexes
210
+ #
211
+ # @param query [Hash] the query
212
+ #
213
+ def index_tags(query); raise NotImplementedError; end
214
+
215
+
216
+ ###############################################
217
+ # Read a log
218
+ #
219
+ # @param cid [String] caseid
220
+ # @param lnum [Integer] the log number
221
+ # @return [String] JSON encoded item
222
+ #
223
+ def log_read(cid, lnum); raise NotImplementedError; end
224
+
225
+
226
+ ###############################################
227
+ # Write a log
228
+ #
229
+ # @param cid [String] caseid
230
+ # @param lnum [Integer] the log number
231
+ # @param item [String] JSON encoded item
232
+ #
233
+ def log_write(cid, lnum, item); raise NotImplementedError; end
234
+
235
+
236
+ ###############################################
237
+ # Search for a log
238
+ #
239
+ # @param query [Hash] the query
240
+ #
241
+ def log_search(query); raise NotImplementedError; end
242
+
243
+
244
+ ###############################################
245
+ # Analyze stats
246
+ #
247
+ # @param query [Hash] the query
248
+ #
249
+ def stats(query); raise NotImplementedError; end
250
+
251
+
252
+ end # class ICFS::Cache
253
+
254
+ end # module ICFS
@@ -0,0 +1,1154 @@
1
+ #
2
+ # Investigative Case File System
3
+ #
4
+ # Copyright 2019 by Graham A. Field
5
+ #
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License version 3.
8
+ #
9
+ # This program is distributed WITHOUT ANY WARRANTY; without even the
10
+ # implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11
+
12
+ require_relative 'elastic'
13
+
14
+
15
+ module ICFS
16
+
17
+ ##########################################################################
18
+ # Implements {ICFS::Cache Cache} using Elasticsearch
19
+ #
20
+ class CacheElastic < Cache
21
+
22
+ include Elastic
23
+
24
+ private
25
+
26
+ ###############################################
27
+ # The ES mappings for all of the indexes
28
+ Maps = {
29
+ :case => '{
30
+ "mappings": { "_doc": { "properties": {
31
+ "icfs": { "enabled": false },
32
+ "caseid": { "type": "keyword" },
33
+ "log": { "enabled": false },
34
+ "template": { "type": "boolean" },
35
+ "status": { "type": "boolean" },
36
+ "title": { "type": "text" },
37
+ "tags": { "type": "keyword" },
38
+ "access": { "type": "nested", "properties": {
39
+ "perm": { "type": "keyword" },
40
+ "grant": { "type": "keyword" }
41
+ }}}
42
+ }}}
43
+ }'.freeze,
44
+
45
+ :log => '{
46
+ "mappings": { "_doc": { "properties": {
47
+ "icfs": { "enabled": false },
48
+ "caseid": {"type": "keyword" },
49
+ "log": { "type": "integer" },
50
+ "prev": { "enabled": false },
51
+ "time": { "type": "date", "format": "epoch_second" },
52
+ "user": { "type": "keyword" },
53
+ "entry": { "properties": {
54
+ "num": { "type": "integer" },
55
+ "hash": { "enabled": false }
56
+ }},
57
+ "index": { "properties": {
58
+ "num": { "type": "integer" },
59
+ "hash": { "enabled": false }
60
+ }},
61
+ "action": { "properties": {
62
+ "num": { "type": "integer" },
63
+ "hash": { "enabled": false }
64
+ }},
65
+ "case_hash": { "enabled": false },
66
+ "files_hash": { "enabled": false }
67
+ }}}
68
+ }'.freeze,
69
+
70
+ :entry => '{
71
+ "mappings": { "_doc": { "properties": {
72
+ "icfs": { "enabled": false },
73
+ "caseid": { "type": "keyword" },
74
+ "entry": { "type": "integer" },
75
+ "log": { "enabled": false },
76
+ "user": { "type": "keyword" },
77
+ "time": { "type": "date", "format": "epoch_second" },
78
+ "title": { "type": "text" },
79
+ "content": { "type": "text" },
80
+ "tags": { "type": "keyword" },
81
+ "index": { "type": "integer" },
82
+ "action": { "type": "integer" },
83
+ "perms": { "type": "keyword" },
84
+ "stats": { "type": "nested", "properties": {
85
+ "name": { "type": "keyword" },
86
+ "value": { "type": "double" },
87
+ "credit": { "type": "keyword" }
88
+ }},
89
+ "files": { "properties": {
90
+ "log": { "enabled": false },
91
+ "num": { "enabled": false },
92
+ "name": { "type": "text" }
93
+ }}
94
+ }}}
95
+ }'.freeze,
96
+
97
+ :action => '{
98
+ "mappings": { "_doc": { "properties": {
99
+ "icfs": { "enabled": false },
100
+ "caseid": { "type": "keyword" },
101
+ "action": { "type": "integer" },
102
+ "log": { "enabled": false },
103
+ "tasks": { "type": "nested","properties": {
104
+ "assigned": { "type": "keyword" },
105
+ "title": { "type": "text" },
106
+ "status": { "type": "boolean" },
107
+ "flag": { "type": "boolean" },
108
+ "time": { "type": "date", "format": "epoch_second" },
109
+ "tags": { "type": "keyword" }
110
+ }}
111
+ }}}
112
+ }'.freeze,
113
+
114
+ :index => '{
115
+ "mappings": { "_doc": { "properties": {
116
+ "icfs": { "enabled": false },
117
+ "caseid": { "type": "keyword" },
118
+ "index": { "type": "integer" },
119
+ "log": { "enabled": false },
120
+ "title": {
121
+ "type": "text",
122
+ "fields": { "raw": { "type": "keyword" }}
123
+ },
124
+ "content": { "type": "text" },
125
+ "tags": { "type": "keyword" }
126
+ }}}
127
+ }'.freeze,
128
+
129
+ :current => '{
130
+ "mappings": {"_doc": {
131
+ "enabled": false
132
+ }}
133
+ }'.freeze,
134
+
135
+ :lock => '{
136
+ "mappings": { "_doc": {
137
+ "enabled": false
138
+ }}
139
+ }'.freeze,
140
+ }.freeze
141
+
142
+
143
+ public
144
+
145
+
146
+ ###############################################
147
+ # New instance
148
+ #
149
+ # @param map [Hash] Symbol to String of the indexes. Must provide
150
+ # :case, :log, :entry, :action, :current, and :lock
151
+ # @param es [Faraday] Faraday instance to the Elasticsearch cluster
152
+ #
153
+ def initialize(map, es)
154
+ @map = map
155
+ @es = es
156
+ end
157
+
158
+
159
+ ###############################################
160
+ # (see Cache#supports)
161
+ #
162
+ #def supports; raise NotImplementedError; end
163
+
164
+
165
+ ###############################################
166
+ # (see Cache#lock_take)
167
+ #
168
+ # @todo Include client info to help with debugging
169
+ #
170
+ def lock_take(cid)
171
+
172
+ json = '{"client":"TODO"}'.freeze
173
+ url = '%s/_doc/%s/_create'.freeze % [@map[:lock], CGI.escape(cid)]
174
+ head = {'Content-Type'.freeze => 'application/json'.freeze}.freeze
175
+
176
+ # try to take
177
+ tries = 5
178
+ while tries > 0
179
+ resp = @es.run_request(:put, url, json, head)
180
+ return true if resp.success?
181
+ tries = tries - 1
182
+ sleep(0.1)
183
+ end
184
+
185
+ # failed to take lock
186
+ raise('Elasticsearch lock take failed: %s'.freeze % cid)
187
+ end
188
+
189
+
190
+ ###############################################
191
+ # (see Cache#lock_release)
192
+ #
193
+ def lock_release(cid)
194
+ url = '%s/_doc/%s'.freeze % [@map[:lock], CGI.escape(cid)]
195
+ resp = @es.run_request(:delete, url, '', {})
196
+ if !resp.success?
197
+ raise('Elasticsearch lock release failed: %s'.freeze % cid)
198
+ end
199
+ end
200
+
201
+
202
+ ###############################################
203
+ # (see Cache#current_read)
204
+ #
205
+ def current_read(cid)
206
+ _read(:current, cid)
207
+ end
208
+
209
+
210
+ ###############################################
211
+ # (see Cache#current_write)
212
+ #
213
+ def current_write(cid, item)
214
+ _write(:current, cid, item)
215
+ end
216
+
217
+
218
+ ###############################################
219
+ # (see Cache#case_read)
220
+ #
221
+ def case_read(cid)
222
+ _read(:case, cid)
223
+ end
224
+
225
+
226
+ ###############################################
227
+ # (see Cache#case_write)
228
+ #
229
+ def case_write(cid, item)
230
+ _write(:case, cid, item)
231
+ end
232
+
233
+
234
+ ###############################################
235
+ # match query
236
+ #
237
+ def _query_match(field, val)
238
+ return nil if !val
239
+ { 'match' => { field => { 'query' => val } } }
240
+ end # def _query_match()
241
+
242
+
243
+ ###############################################
244
+ # match all query
245
+ #
246
+ def _query_all()
247
+ { 'match_all' => {} }
248
+ end # def _query_all()
249
+
250
+
251
+ ###############################################
252
+ # (see Cache#case_search)
253
+ #
254
+ def case_search(query)
255
+
256
+ # build the query
257
+ must = [
258
+ _query_match('title'.freeze, query[:title]),
259
+ ].compact
260
+ filter = [
261
+ _query_term('tags'.freeze, query[:tags]),
262
+ _query_term('status'.freeze, query[:status]),
263
+ _query_term('template'.freeze, query[:template]),
264
+ ].compact
265
+ access = [
266
+ _query_term('access.grant'.freeze, query[:grantee]),
267
+ _query_term('access.perm'.freeze, query[:perm]),
268
+ ].compact
269
+ unless access.empty?
270
+ qu = (access.size == 1) ? access[0] : _query_bool(nil, access, nil, nil)
271
+ filter << _query_nested('access'.freeze, qu)
272
+ end
273
+ req = { 'query' => _query_bool(must, filter, nil, nil) }
274
+
275
+ # highlight
276
+ hl = {}
277
+ hl['title'] = {} if query[:title]
278
+ req['highlight'] = { 'fields' => hl } unless hl.empty?
279
+
280
+ # sort
281
+ unless query[:title]
282
+ req['sort'] = { 'caseid' => 'asc' }
283
+ end
284
+
285
+ # paging
286
+ _page(query, req)
287
+
288
+ # run the search
289
+ url = @map[:case] + '/_search'.freeze
290
+ body = JSON.generate(req)
291
+ head = { 'Content-Type' => 'application/json' }
292
+ resp = @es.run_request(:get, url, body, head)
293
+ raise 'search failed' if !resp.success?
294
+
295
+ return _results(resp, query, ResultsCase)
296
+ end # def case_search
297
+
298
+
299
+ # the Case results fields
300
+ ResultsCase = {
301
+ caseid: 'caseid'.freeze,
302
+ template: 'template'.freeze,
303
+ status: 'status'.freeze,
304
+ title: 'title'.freeze,
305
+ tags: 'tags'.freeze,
306
+ }.freeze
307
+
308
+
309
+ ###############################################
310
+ # Process search results
311
+ #
312
+ # @param resp [Hash] the response from Elasticsearch
313
+ # @param query [Hash] the original request
314
+ # @param fields [Hash] Fields to return
315
+ # @yield [src] The source object
316
+ # @yieldreturn [Hash] the search result object
317
+ #
318
+ def _results(resp, query, fields=nil)
319
+
320
+ # size defaults to 25
321
+ size = query[:size] ? query[:size].to_i : 0
322
+ size = DefaultSize if size == 0
323
+
324
+ rh = JSON.parse(resp.body)
325
+ results = {
326
+ query: query,
327
+ hits: rh['hits']['total'],
328
+ size: size,
329
+ }
330
+
331
+ # process each result
332
+ results[:list] = rh['hits']['hits'].map do |hh|
333
+
334
+ src = hh['_source']
335
+ hl = hh['highlight']
336
+
337
+ if hl
338
+ snip = ''
339
+ hl.each{|fn, ary| ary.each{|ht| snip << ht}}
340
+ else
341
+ snip = nil
342
+ end
343
+
344
+ # fields provided
345
+ if fields
346
+ obj = {}
347
+ fields.each do |aa, bb|
348
+ if bb.is_a?(Array)
349
+ case bb[1]
350
+
351
+ # a sub value
352
+ when :sub
353
+ val = src[bb[0]]
354
+ obj[aa] = val.nil? ? 0 : val[bb[2]]
355
+
356
+ # size of a value
357
+ when :size
358
+ val = src[bb[0]]
359
+ obj[aa] = val.nil? ? 0 : val.size
360
+
361
+ # zero for nil
362
+ when :zero
363
+ val = src[bb[0]]
364
+ obj[aa] = val.nil? ? 0 : val
365
+
366
+ # empty array for nil
367
+ when :empty
368
+ val = src[bb[0]]
369
+ obj[aa] = val.nil? ? [] : val
370
+
371
+ else
372
+ raise(ArgumentError, 'Not a valid field option'.freeze)
373
+ end
374
+ else
375
+ obj[aa] = src[bb]
376
+ end
377
+ end
378
+
379
+ # pass the source to the block to generate the search object
380
+ else
381
+ obj = yield src
382
+ end
383
+
384
+ # and provide each result
385
+ {
386
+ score: hh['_score'],
387
+ snippet: snip,
388
+ object: obj,
389
+ }
390
+ end
391
+
392
+ return results
393
+ end # def _results()
394
+ private :_results
395
+
396
+
397
+ # default page size
398
+ DefaultSize = 25
399
+
400
+
401
+ ###############################################
402
+ # Do paging
403
+ #
404
+ # @param query [Hash] the query
405
+ # @param req [Hash] the constructed ES request
406
+ #
407
+ def _page(query, req)
408
+
409
+ # size defaults
410
+ size = query[:size] ? query[:size].to_i : 0
411
+ size = DefaultSize if size == 0
412
+
413
+ # page defaults to 1
414
+ page = query[:page] ? query[:page].to_i : 0
415
+ page = 1 if page == 0
416
+
417
+ req['size'] = size
418
+ req['from'] = (page - 1) * size
419
+
420
+ end # def _page()
421
+ private :_page
422
+
423
+
424
+
425
+ ###############################################
426
+ # (see Cache#entry_read)
427
+ #
428
+ def entry_read(cid, enum)
429
+ _read(:entry, '%s.%d'.freeze % [cid, enum])
430
+ end
431
+
432
+
433
+ ###############################################
434
+ # (see Cache#entry_write)
435
+ #
436
+ def entry_write(cid, enum, item)
437
+ _write(:entry, '%s.%d'.freeze % [cid, enum], item)
438
+ end
439
+
440
+
441
+ ###############################################
442
+ # Nested query
443
+ #
444
+ def _query_nested(field, query)
445
+ {
446
+ 'nested' => {
447
+ 'path' => field,
448
+ 'query' => query
449
+ }
450
+ }
451
+ end # def _query_nested()
452
+
453
+
454
+ ###############################################
455
+ # (see Cache#entry_search)
456
+ #
457
+ def entry_search(query)
458
+
459
+ # build the query
460
+ must = [
461
+ _query_match('title'.freeze, query[:title]),
462
+ _query_match('content'.freeze, query[:content]),
463
+ ].compact
464
+ filter = [
465
+ _query_term('tags'.freeze, query[:tags]),
466
+ _query_term('caseid'.freeze, query[:caseid]),
467
+ _query_times('time'.freeze, query[:after], query[:before]),
468
+ _query_term('action'.freeze, query[:action]),
469
+ _query_term('index'.freeze, query[:index]),
470
+ ].compact
471
+ stats = [
472
+ _query_term('stats.name'.freeze, query[:stat]),
473
+ _query_term('stats.credit'.freeze, query[:credit]),
474
+ ].compact
475
+ unless stats.empty?
476
+ qu = (stats.size == 1) ? stats[0] : _query_bool(nil, stats, nil, nil)
477
+ filter << _query_nested('stats'.freeze, qu)
478
+ end
479
+ req = { 'query' => _query_bool(must, filter, nil, nil) }
480
+
481
+ # highlight
482
+ hl = {}
483
+ hl['title'] = {} if query[:title]
484
+ hl['content'] = {} if query[:content]
485
+ req['highlight'] = { 'fields' => hl } unless hl.empty?
486
+
487
+ # sort
488
+ case query[:sort]
489
+ when 'time_desc'
490
+ req['sort'] = [
491
+ { 'time' => 'desc' },
492
+ { '_id' => 'desc' },
493
+ ]
494
+ when 'time_asc'
495
+ req['sort'] = [
496
+ { 'time' => 'asc' },
497
+ { '_id' => 'desc' },
498
+ ]
499
+ when nil
500
+ if !query[:title] && !query[:content]
501
+ req['sort'] = [
502
+ { 'time' => 'desc' },
503
+ { '_id' => 'desc' },
504
+ ]
505
+ end
506
+ end
507
+
508
+ # paging
509
+ _page(query, req)
510
+
511
+ # run the search
512
+ url = @map[:entry] + '/_search'.freeze
513
+ body = JSON.generate(req)
514
+ head = { 'Content-Type' => 'application/json' }
515
+ resp = @es.run_request(:get, url, body, head)
516
+ raise 'search failed' if !resp.success?
517
+
518
+ return _results(resp, query, ResultsEntry)
519
+ end # def entry_search()
520
+
521
+
522
+ # Entry search results fields
523
+ ResultsEntry = {
524
+ caseid: 'caseid'.freeze,
525
+ entry: 'entry'.freeze,
526
+ time: 'time'.freeze,
527
+ title: 'title'.freeze,
528
+ tags: 'tags'.freeze,
529
+ perms: ['perms'.freeze, :empty],
530
+ action: ['action'.freeze, :zero],
531
+ index: ['index'.freeze, :size],
532
+ files: ['files'.freeze, :size],
533
+ stats: ['stats'.freeze, :size],
534
+ }.freeze
535
+
536
+
537
+
538
+ ###############################################
539
+ # (see Cache#action_read)
540
+ #
541
+ def action_read(cid, anum)
542
+ _read(:action, '%s.%d'.freeze % [cid, anum])
543
+ end
544
+
545
+
546
+ ###############################################
547
+ # (see Cache#action_write)
548
+ #
549
+ def action_write(cid, anum, item)
550
+ _write(:action, '%s.%d'.freeze % [cid, anum], item)
551
+ end
552
+
553
+
554
+ ###############################################
555
+ # (see Cache#action_search)
556
+ #
557
+ def action_search(query)
558
+
559
+ # build the query
560
+ task_must = [
561
+ _query_match('tasks.title'.freeze, query[:title])
562
+ ].compact
563
+ task_filter = [
564
+ _query_term('tasks.assigned'.freeze, query[:assigned]),
565
+ _query_term('tasks.status'.freeze, query[:status]),
566
+ _query_term('tasks.flag'.freeze, query[:flag]),
567
+ _query_times('tasks.time'.freeze, query[:after], query[:before]),
568
+ _query_term('tasks.tags'.freeze, query[:tags]),
569
+ ].compact
570
+ must = [
571
+ _query_nested(
572
+ 'tasks'.freeze,
573
+ _query_bool(task_must, task_filter, nil, nil)
574
+ )
575
+ ]
576
+ filter = [
577
+ _query_term('caseid'.freeze, query[:caseid])
578
+ ].compact
579
+ req = { 'query' => _query_bool(must, filter, nil, nil) }
580
+
581
+ # sort
582
+ case query[:sort]
583
+ when 'time_desc'
584
+ srt = 'desc'
585
+ when 'time_asc'
586
+ srt = 'asc'
587
+ else
588
+ srt = query[:title] ? nil : 'desc'
589
+ end
590
+ if srt
591
+ req['sort'] = [
592
+ {
593
+ 'tasks.time' => {
594
+ 'order' => srt,
595
+ 'nested' => {
596
+ 'path' => 'tasks'.freeze,
597
+ 'filter' => _query_term(
598
+ 'tasks.assigned'.freeze, query[:assigned])
599
+ }
600
+ }
601
+ },
602
+ { '_id' => { 'order' => 'desc' } }
603
+ ]
604
+ end
605
+
606
+ # paging
607
+ _page(query, req)
608
+
609
+ # run the search
610
+ url = @map[:action] + '/_search'.freeze
611
+ body = JSON.generate(req)
612
+ head = { 'Content-Type' => 'application/json' }
613
+ resp = @es.run_request(:get, url, body, head)
614
+ raise 'search failed' if !resp.success?
615
+
616
+ return _results(resp, query) do |src|
617
+ tsk = src['tasks'].select{|tk| tk['assigned'] == query[:assigned]}.first
618
+ {
619
+ caseid: src['caseid'],
620
+ action: src['action'],
621
+ status: tsk['status'],
622
+ flag: tsk['flag'],
623
+ title: tsk['title'],
624
+ time: tsk['time'],
625
+ tags: tsk['tags'],
626
+ }
627
+ end
628
+ end # def action_search()
629
+
630
+
631
+ ###############################################
632
+ # (see Cache#index_write)
633
+ #
634
+ def index_write(cid, xnum, item)
635
+ _write(:index, '%s.%d'.freeze % [cid, xnum], item)
636
+ end
637
+
638
+
639
+ ###############################################
640
+ # (see Cache#index_read)
641
+ #
642
+ def index_read(cid, xnum)
643
+ _read(:index, '%s.%d'.freeze % [cid, xnum])
644
+ end
645
+
646
+
647
+ # (see Cache#index_search)
648
+ #
649
+ def index_search(query)
650
+
651
+ # build the query
652
+ must = [
653
+ _query_match('title'.freeze, query[:title]),
654
+ _query_match('content'.freeze, query[:content]),
655
+ ].compact
656
+ filter = [
657
+ _query_term('caseid'.freeze, query[:caseid]),
658
+ _query_term('tags'.freeze, query[:tags]),
659
+ _query_prefix('title'.freeze, query[:prefix]),
660
+ ].compact
661
+ req = { 'query' => _query_bool(must, filter, nil, nil) }
662
+
663
+ # highlight
664
+ hl = {}
665
+ hl['title'] = {} if query[:title]
666
+ hl['content'] = {} if query[:content]
667
+ req['highlight'] = { 'fields' => hl } unless hl.empty?
668
+
669
+ # sort
670
+ case query[:sort]
671
+ when 'index_asc'
672
+ req['sort'] = [
673
+ { 'index' => 'asc' },
674
+ { '_id' => 'desc' },
675
+ ]
676
+ when 'index_desc'
677
+ req['sort'] = [
678
+ { 'index' => 'desc' },
679
+ { '_id' => 'desc' },
680
+ ]
681
+ when 'title_desc'
682
+ req['sort'] = [
683
+ { 'title.raw' => 'desc' },
684
+ { '_id' => 'desc' },
685
+ ]
686
+ when 'title_asc', nil
687
+ req['sort'] = [
688
+ { 'title.raw' => 'asc' },
689
+ { '_id' => 'desc' },
690
+ ]
691
+ end
692
+
693
+ # paging
694
+ _page(query, req)
695
+
696
+ # run the search
697
+ url = @map[:index] + '/_search'.freeze
698
+ body = JSON.generate(req)
699
+ head = { 'Content-Type' => 'application/json' }
700
+ resp = @es.run_request(:get, url, body, head)
701
+ raise 'search failed' if !resp.success?
702
+
703
+ return _results(resp, query, ResultsIndex)
704
+ end # end index_search()
705
+
706
+
707
+ # Index search results fields
708
+ ResultsIndex = {
709
+ caseid: 'caseid'.freeze,
710
+ index: 'index'.freeze,
711
+ title: 'title'.freeze,
712
+ tags: 'tags'.freeze,
713
+ }.freeze
714
+
715
+
716
+ ###############################################
717
+ # (see Cache#index_tags)
718
+ #
719
+ def index_tags(query)
720
+
721
+ # build the query
722
+ ag = _agg_terms('tags'.freeze, 'tags'.freeze, nil)
723
+ qu = _query_term('caseid'.freeze, query[:caseid])
724
+ qu = _query_constant(qu)
725
+ req = {
726
+ 'query' => qu,
727
+ 'aggs' => ag,
728
+ 'size' => 0
729
+ }
730
+
731
+ # run the search
732
+ url = @map[:index] + '/_search'.freeze
733
+ body = JSON.generate(req)
734
+ head = { 'Content-Type' => 'application/json'.freeze }
735
+ resp = @es.run_request(:get, url, body, head)
736
+ raise 'search failed'.freeze if !resp.success?
737
+
738
+ # extract tags
739
+ rh = JSON.parse(resp.body)
740
+ rh = rh['aggregations']['tags']['buckets']
741
+ list = rh.map do |hh|
742
+ {
743
+ object: {
744
+ caseid: query[:caseid],
745
+ tag: hh['key'],
746
+ count: hh['doc_count'],
747
+ }
748
+ }
749
+ end
750
+
751
+ return {
752
+ query: query,
753
+ list: list.sort{|aa, bb| aa[:object][:tag] <=> bb[:object][:tag]}
754
+ }
755
+ end # def index_tags()
756
+
757
+
758
+ ###############################################
759
+ # (see Cache#log_read)
760
+ #
761
+ def log_read(cid, lnum)
762
+ _read(:log, '%s.%d'.freeze % [cid, lnum])
763
+ end
764
+
765
+
766
+ ###############################################
767
+ # (see Cache#log_write)
768
+ #
769
+ def log_write(cid, lnum, item)
770
+ _write(:log, '%s.%d'.freeze % [cid, lnum], item)
771
+ end
772
+
773
+
774
+ # Log search results fields
775
+ ResultsLog = {
776
+ caseid: 'caseid'.freeze,
777
+ log: 'log'.freeze,
778
+ time: 'time'.freeze,
779
+ user: 'user'.freeze,
780
+ entry: ['entry'.freeze, :sub, 'num'.freeze].freeze,
781
+ index: ['index'.freeze, :sub, 'num'.freeze].freeze,
782
+ action: ['action'.freeze, :sub, 'num'.freeze].freeze,
783
+ files: ['files_hash'.freeze, :size].freeze,
784
+ }.freeze
785
+
786
+
787
+ ###############################################
788
+ # (see Cache#log_search)
789
+ #
790
+ def log_search(query)
791
+
792
+ # build the query
793
+ filter = [
794
+ _query_term('caseid'.freeze, query[:caseid]),
795
+ _query_times('times'.freeze, query[:after], query[:before]),
796
+ _query_term('user'.freeze, query[:user]),
797
+ _query_term('entry.num'.freeze, query[:entry]),
798
+ _query_term('index.num'.freeze, query[:index]),
799
+ _query_term('action.num'.freeze, query[:action]),
800
+ ].compact
801
+ req = { 'query' => _query_bool(nil, filter, nil, nil) }
802
+
803
+ # sort
804
+ case query[:sort]
805
+ when 'time_desc', nil
806
+ req['sort'] = [
807
+ { 'time' => 'desc' },
808
+ { '_id' => 'desc' },
809
+ ]
810
+ when 'time_asc'
811
+ req['sort'] = [
812
+ { 'time' => 'asc' },
813
+ { '_id' => 'desc' },
814
+ ]
815
+ end
816
+
817
+ # paging
818
+ _page(query, req)
819
+
820
+ # run the search
821
+ url = @map[:log] + '/_search'.freeze
822
+ body = JSON.generate(req)
823
+ head = { 'Content-Type' => 'application/json' }
824
+ resp = @es.run_request(:get, url, body, head)
825
+ raise 'search failed' if !resp.success?
826
+
827
+ return _results(resp, query, ResultsLog)
828
+ end # def log_search()
829
+
830
+
831
+ ###############################################
832
+ # stats metric aggregation
833
+ #
834
+ def _agg_stats(name, field)
835
+ { name => { 'stats' => { 'field' => field } } }
836
+ end # def _agg_stats()
837
+
838
+
839
+ ###############################################
840
+ # terms bucket aggregation
841
+ #
842
+ def _agg_terms(name, field, sub)
843
+ ag = { name => { 'terms' => { 'field' => field } } }
844
+ ag[name]['aggs'] = sub if sub
845
+ return ag
846
+ end # def _agg_terms()
847
+
848
+
849
+ ###############################################
850
+ # filter bucket aggregation
851
+ #
852
+ def _agg_filter(name, qu, sub)
853
+ ag = { name => { 'filter' => qu } }
854
+ ag[name]['aggs'] = sub if sub
855
+ return ag
856
+ end # def _agg_filter()
857
+
858
+
859
+ ###############################################
860
+ # nested bucket aggregation
861
+ #
862
+ def _agg_nested(name, field, sub)
863
+ ag = { name => { 'nested' => { 'path' => field } } }
864
+ ag[name]['aggs'] = sub if sub
865
+ return ag
866
+ end # def _agg_nested()
867
+
868
+
869
+ ###############################################
870
+ # Term query
871
+ #
872
+ def _query_term(field, val)
873
+ return nil if val.nil?
874
+ { 'term' => { field => val } }
875
+ end # def _query_term()
876
+
877
+
878
+ ###############################################
879
+ # Exists query
880
+ #
881
+ def _query_exists(field, val)
882
+ return nil if val.nil?
883
+ { 'exists' => { 'field' => field } }
884
+ end # def _query_exists()
885
+
886
+
887
+ ###############################################
888
+ # keyword query
889
+ def _query_keyw(field, val)
890
+ return nil if val.nil?
891
+ if val.is_a?(Array)
892
+ qu = { 'terms' => { field => val } }
893
+ else
894
+ qu = {'term' => { field => val } }
895
+ end
896
+ return qu
897
+ end # def _query_keyw()
898
+
899
+
900
+ ###############################################
901
+ # times query
902
+ def _query_times(field, val_gt, val_lt)
903
+ return nil if( val_gt.nil? && val_lt.nil? )
904
+ tq = {}
905
+ tq['gt'] = val_gt if val_gt
906
+ tq['lt'] = val_lt if val_lt
907
+ return {'range' => { field => tq } }
908
+ end # def _query_times()
909
+
910
+
911
+ ###############################################
912
+ # prefix string query
913
+ def _query_prefix(field, val)
914
+ return nil if val.nil?
915
+ return { 'prefix' => { field => val } }
916
+ end # def _query_prefix()
917
+
918
+
919
+ ###############################################
920
+ # bool query
921
+ def _query_bool(must, filter, should, must_not)
922
+ qu = {}
923
+ qu['must'] = must if(must && !must.empty?)
924
+ qu['filter'] = filter if(filter && !filter.empty?)
925
+ qu['should'] = should if(should && !should.empty?)
926
+ qu['must_not'] = must_not if(must_not && !must_not.empty?)
927
+ if qu.empty?
928
+ return { 'match_all' => {} }
929
+ else
930
+ return { 'bool' => qu }
931
+ end
932
+ end # def _query_bool()
933
+
934
+
935
+ ###############################################
936
+ # (see Cache#stats)
937
+ #
938
+ def stats(query)
939
+
940
+ # aggs
941
+ ag = _agg_stats('vals'.freeze, 'stats.value'.freeze)
942
+ ag = _agg_terms('stats'.freeze, 'stats.name'.freeze, ag)
943
+ if query[:credit]
944
+ cd = _query_term('stats.credit'.freeze, query[:credit])
945
+ ag = _agg_filter('credit'.freeze, cd, ag)
946
+ end
947
+ ag = _agg_nested('nested'.freeze, 'stats'.freeze, ag)
948
+
949
+ # build the query
950
+ filt = [
951
+ _query_term('caseid'.freeze, query[:caseid]),
952
+ _query_times('time'.freeze, query[:after], query[:before]),
953
+ ].compact
954
+ qu = _query_bool(nil, filt, nil, nil)
955
+
956
+ # the request
957
+ req = {
958
+ 'query' => qu,
959
+ 'aggs' => ag,
960
+ 'size' => 0,
961
+ }
962
+
963
+ # run the search
964
+ url = @map[:entry] + '/_search'.freeze
965
+ body = JSON.generate(req)
966
+ head = { 'Content-Type' => 'application/json' }
967
+ resp = @es.run_request(:get, url, body, head)
968
+ raise 'search failed' if !resp.success?
969
+
970
+ # extract stats
971
+ rh = JSON.parse(resp.body)
972
+ if query[:credit]
973
+ rh = rh['aggregations']['nested']['credit']['stats']['buckets']
974
+ else
975
+ rh = rh['aggregations']['nested']['stats']['buckets']
976
+ end
977
+ list = rh.map do |hh|
978
+ {
979
+ object: {
980
+ stat: hh['key'],
981
+ sum: hh['vals']['sum'],
982
+ count: hh['vals']['count'],
983
+ min: hh['vals']['min'],
984
+ max: hh['vals']['max'],
985
+ }
986
+ }
987
+ end
988
+
989
+ # return the results
990
+ return {
991
+ query: query,
992
+ list: list
993
+ }
994
+ end # def stats()
995
+
996
+
997
+ ###############################################
998
+ # constant score
999
+ #
1000
+ def _query_constant(filter)
1001
+ {'constant_score' => { 'filter' => filter } }
1002
+ end # def _query_constant()
1003
+
1004
+
1005
+ ###############################################
1006
+ # (see Cache#entry_tags)
1007
+ #
1008
+ def entry_tags(query)
1009
+
1010
+ # build the query
1011
+ ag = _agg_terms('tags'.freeze, 'tags'.freeze, nil)
1012
+ qu = _query_term('caseid'.freeze, query[:caseid])
1013
+ qu = _query_constant(qu)
1014
+ req = {
1015
+ 'query' => qu,
1016
+ 'aggs' => ag,
1017
+ 'size' => 0
1018
+ }
1019
+
1020
+ # run the search
1021
+ url = @map[:entry] + '/_search'.freeze
1022
+ body = JSON.generate(req)
1023
+ head = { 'Content-Type' => 'application/json' }
1024
+ resp = @es.run_request(:get, url, body, head)
1025
+ raise 'search failed' if !resp.success?
1026
+
1027
+ # extract tags
1028
+ rh = JSON.parse(resp.body)
1029
+ rh = rh['aggregations']['tags']['buckets']
1030
+ list = rh.map do |hh|
1031
+ {
1032
+ object: {
1033
+ caseid: query[:caseid],
1034
+ tag: hh['key'],
1035
+ count: hh['doc_count'],
1036
+ }
1037
+ }
1038
+ end
1039
+
1040
+ return {
1041
+ query: query,
1042
+ list: list.sort{|aa, bb| aa[:object][:tag] <=> bb[:object][:tag]}
1043
+ }
1044
+ end # def entry_tags()
1045
+
1046
+
1047
+ ###############################################
1048
+ # (see Cache#case_tags)
1049
+ #
1050
+ def case_tags(query)
1051
+
1052
+ # build the query
1053
+ filter = [
1054
+ _query_term('status'.freeze, query[:status]),
1055
+ _query_term('template'.freeze, query[:template]),
1056
+ ].compact
1057
+ access = [
1058
+ _query_term('access.grant'.freeze, query[:grantee]),
1059
+ _query_term('access.perm'.freeze, query[:perm]),
1060
+ ].compact
1061
+ unless access.empty?
1062
+ qu = (access.size == 1) ? access[0] : _query_bool(nil, access, nil, nil)
1063
+ filter << _query_nested('access'.freeze, qu)
1064
+ end
1065
+ qu = _query_bool(nil, filter, nil, nil)
1066
+ ag = _agg_terms('tags'.freeze, 'tags'.freeze, nil)
1067
+ req = {
1068
+ 'query' => qu,
1069
+ 'aggs' => ag,
1070
+ 'size' => 0
1071
+ }
1072
+
1073
+ # run the search
1074
+ url = @map[:case] + '/_search'.freeze
1075
+ body = JSON.generate(req)
1076
+ head = { 'Content-Type' => 'application/json' }
1077
+ resp = @es.run_request(:get, url, body, head)
1078
+ raise 'search failed' if !resp.success?
1079
+
1080
+ # extract tags
1081
+ rh = JSON.parse(resp.body)
1082
+ rh = rh['aggregations']['tags']['buckets']
1083
+ list = rh.map do |hh|
1084
+ {
1085
+ object: {
1086
+ tag: hh['key'],
1087
+ count: hh['doc_count'],
1088
+ }
1089
+ }
1090
+ end
1091
+
1092
+ return {
1093
+ query: query,
1094
+ list: list.sort{|aa, bb| aa[:object][:tag] <=> bb[:object][:tag] }
1095
+ }
1096
+ end # def case_tags()
1097
+
1098
+
1099
+ ###############################################
1100
+ # (see Cache#action_tags)
1101
+ #
1102
+ def action_tags(query)
1103
+
1104
+ # build the query
1105
+ task_filter = [
1106
+ _query_term('tasks.assigned'.freeze, query[:assigned]),
1107
+ _query_term('tasks.status'.freeze, query[:status]),
1108
+ _query_term('tasks.flag'.freeze, query[:flag]),
1109
+ _query_times('tasks.time'.freeze, query[:after], query[:before]),
1110
+ ].compact
1111
+ qu_filt = _query_bool(nil, task_filter, nil, nil)
1112
+ ag = _agg_terms('tags'.freeze, 'tasks.tags'.freeze, nil)
1113
+ ag = _agg_filter('filt'.freeze, qu_filt, ag)
1114
+ ag = _agg_nested('nest'.freeze, 'tasks'.freeze, ag)
1115
+ if query[:caseid]
1116
+ qu = _query_term('caseid'.freeze, query[:caseid])
1117
+ else
1118
+ qu = _query_all()
1119
+ end
1120
+ req = {
1121
+ 'query' => qu,
1122
+ 'aggs' => ag,
1123
+ 'size' => 0
1124
+ }
1125
+
1126
+ # run the search
1127
+ url = @map[:action] + '/_search'.freeze
1128
+ body = JSON.generate(req)
1129
+ head = { 'Content-Type' => 'application/json' }
1130
+ resp = @es.run_request(:get, url, body, head)
1131
+ raise 'search failed' if !resp.success?
1132
+
1133
+ # extract tags
1134
+ rh = JSON.parse(resp.body)
1135
+ rh = rh['aggregations']['nest']['filt']['tags']['buckets']
1136
+ list = rh.map do |hh|
1137
+ {
1138
+ object: {
1139
+ tag: hh['key'],
1140
+ count: hh['doc_count'],
1141
+ }
1142
+ }
1143
+ end
1144
+
1145
+ return {
1146
+ query: query,
1147
+ list: list.sort{|aa, bb| aa[:object][:tag] <=> bb[:object][:tag]}
1148
+ }
1149
+ end # def action_tags()
1150
+
1151
+
1152
+ end # class ICFS::CacheElastic
1153
+
1154
+ end # module ICFS