icfs 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.
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