search_index_backend.rb 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859
  1. # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
  2. class SearchIndexBackend
  3. =begin
  4. info about used search index machine
  5. SearchIndexBackend.info
  6. =end
  7. def self.info
  8. url = Setting.get('es_url').to_s
  9. return if url.blank?
  10. Rails.logger.info "# curl -X GET \"#{url}\""
  11. response = UserAgent.get(
  12. url,
  13. {},
  14. {
  15. json: true,
  16. open_timeout: 8,
  17. read_timeout: 12,
  18. user: Setting.get('es_user'),
  19. password: Setting.get('es_password'),
  20. }
  21. )
  22. Rails.logger.info "# #{response.code}"
  23. if response.success?
  24. installed_version = response.data.dig('version', 'number')
  25. raise "Unable to get elasticsearch version from response: #{response.inspect}" if installed_version.blank?
  26. version_supported = Gem::Version.new(installed_version) < Gem::Version.new('8')
  27. raise "Version #{installed_version} of configured elasticsearch is not supported." if !version_supported
  28. version_supported = Gem::Version.new(installed_version) > Gem::Version.new('2.3')
  29. raise "Version #{installed_version} of configured elasticsearch is not supported." if !version_supported
  30. return response.data
  31. end
  32. raise humanized_error(
  33. verb: 'GET',
  34. url: url,
  35. response: response,
  36. )
  37. end
  38. =begin
  39. update processors
  40. SearchIndexBackend.processors(
  41. _ingest/pipeline/attachment: {
  42. description: 'Extract attachment information from arrays',
  43. processors: [
  44. {
  45. foreach: {
  46. field: 'ticket.articles.attachments',
  47. processor: {
  48. attachment: {
  49. target_field: '_ingest._value.attachment',
  50. field: '_ingest._value.data'
  51. }
  52. }
  53. }
  54. }
  55. ]
  56. }
  57. )
  58. =end
  59. def self.processors(data)
  60. data.each do |key, items|
  61. url = "#{Setting.get('es_url')}/#{key}"
  62. items.each do |item|
  63. if item[:action] == 'delete'
  64. Rails.logger.info "# curl -X DELETE \"#{url}\""
  65. response = UserAgent.delete(
  66. url,
  67. {
  68. json: true,
  69. open_timeout: 8,
  70. read_timeout: 12,
  71. user: Setting.get('es_user'),
  72. password: Setting.get('es_password'),
  73. }
  74. )
  75. Rails.logger.info "# #{response.code}"
  76. next if response.success?
  77. next if response.code.to_s == '404'
  78. raise humanized_error(
  79. verb: 'DELETE',
  80. url: url,
  81. response: response,
  82. )
  83. end
  84. Rails.logger.info "# curl -X PUT \"#{url}\" \\"
  85. Rails.logger.debug { "-d '#{data.to_json}'" }
  86. item.delete(:action)
  87. response = UserAgent.put(
  88. url,
  89. item,
  90. {
  91. json: true,
  92. open_timeout: 8,
  93. read_timeout: 12,
  94. user: Setting.get('es_user'),
  95. password: Setting.get('es_password'),
  96. }
  97. )
  98. Rails.logger.info "# #{response.code}"
  99. next if response.success?
  100. raise humanized_error(
  101. verb: 'PUT',
  102. url: url,
  103. payload: item,
  104. response: response,
  105. )
  106. end
  107. end
  108. true
  109. end
  110. =begin
  111. create/update/delete index
  112. SearchIndexBackend.index(
  113. :action => 'create', # create/update/delete
  114. :name => 'Ticket',
  115. :data => {
  116. :mappings => {
  117. :Ticket => {
  118. :properties => {
  119. :articles => {
  120. :type => 'nested',
  121. :properties => {
  122. 'attachment' => { :type => 'attachment' }
  123. }
  124. }
  125. }
  126. }
  127. }
  128. }
  129. )
  130. SearchIndexBackend.index(
  131. :action => 'delete', # create/update/delete
  132. :name => 'Ticket',
  133. )
  134. =end
  135. def self.index(data)
  136. url = build_url(data[:name], nil, false, false)
  137. return if url.blank?
  138. if data[:action] && data[:action] == 'delete'
  139. return SearchIndexBackend.remove(data[:name])
  140. end
  141. Rails.logger.info "# curl -X PUT \"#{url}\" \\"
  142. Rails.logger.debug { "-d '#{data[:data].to_json}'" }
  143. # note that we use a high read timeout here because
  144. # otherwise the request will be retried (underhand)
  145. # which leads to an "index_already_exists_exception"
  146. # HTTP 400 status error
  147. # see: https://github.com/ankane/the-ultimate-guide-to-ruby-timeouts/issues/8
  148. # Improving the Elasticsearch config is probably the proper solution
  149. response = UserAgent.put(
  150. url,
  151. data[:data],
  152. {
  153. json: true,
  154. open_timeout: 8,
  155. read_timeout: 30,
  156. user: Setting.get('es_user'),
  157. password: Setting.get('es_password'),
  158. }
  159. )
  160. Rails.logger.info "# #{response.code}"
  161. return true if response.success?
  162. raise humanized_error(
  163. verb: 'PUT',
  164. url: url,
  165. payload: data[:data],
  166. response: response,
  167. )
  168. end
  169. =begin
  170. add new object to search index
  171. SearchIndexBackend.add('Ticket', some_data_object)
  172. =end
  173. def self.add(type, data)
  174. url = build_url(type, data['id'])
  175. return if url.blank?
  176. Rails.logger.info "# curl -X POST \"#{url}\" \\"
  177. Rails.logger.debug { "-d '#{data.to_json}'" }
  178. response = UserAgent.post(
  179. url,
  180. data,
  181. {
  182. json: true,
  183. open_timeout: 8,
  184. read_timeout: 16,
  185. user: Setting.get('es_user'),
  186. password: Setting.get('es_password'),
  187. }
  188. )
  189. Rails.logger.info "# #{response.code}"
  190. return true if response.success?
  191. raise humanized_error(
  192. verb: 'POST',
  193. url: url,
  194. payload: data,
  195. response: response,
  196. )
  197. end
  198. =begin
  199. remove whole data from index
  200. SearchIndexBackend.remove('Ticket', 123)
  201. SearchIndexBackend.remove('Ticket')
  202. =end
  203. def self.remove(type, o_id = nil)
  204. url = build_url(type, o_id, false, false)
  205. return if url.blank?
  206. Rails.logger.info "# curl -X DELETE \"#{url}\""
  207. response = UserAgent.delete(
  208. url,
  209. {
  210. open_timeout: 8,
  211. read_timeout: 16,
  212. user: Setting.get('es_user'),
  213. password: Setting.get('es_password'),
  214. }
  215. )
  216. Rails.logger.info "# #{response.code}"
  217. return true if response.success?
  218. return true if response.code.to_s == '400'
  219. humanized_error = humanized_error(
  220. verb: 'DELETE',
  221. url: url,
  222. response: response,
  223. )
  224. Rails.logger.info "NOTICE: can't delete index: #{humanized_error}"
  225. false
  226. end
  227. =begin
  228. @param query [String] search query
  229. @param index [String, Array<String>] indexes to search in (see search_by_index)
  230. @param options [Hash] search options (see build_query)
  231. @return search result
  232. @example Sample queries
  233. result = SearchIndexBackend.search('search query', ['User', 'Organization'], limit: limit)
  234. result = SearchIndexBackend.search('search query', 'User', limit: limit)
  235. result = SearchIndexBackend.search('search query', 'User', limit: limit, sort_by: ['updated_at'], order_by: ['desc'])
  236. result = [
  237. {
  238. :id => 123,
  239. :type => 'User',
  240. },
  241. {
  242. :id => 125,
  243. :type => 'User',
  244. },
  245. {
  246. :id => 15,
  247. :type => 'Organization',
  248. }
  249. ]
  250. =end
  251. def self.search(query, index, options = {})
  252. if !index.is_a? Array
  253. return search_by_index(query, index, options)
  254. end
  255. index
  256. .map { |local_index| search_by_index(query, local_index, options) }
  257. .compact
  258. .flatten(1)
  259. end
  260. =begin
  261. @param query [String] search query
  262. @param index [String] index name
  263. @param options [Hash] search options (see build_query)
  264. @return search result
  265. =end
  266. def self.search_by_index(query, index, options = {})
  267. return [] if query.blank?
  268. url = build_url
  269. return if url.blank?
  270. url += build_search_url(index)
  271. # real search condition
  272. condition = {
  273. 'query_string' => {
  274. 'query' => append_wildcard_to_simple_query(query),
  275. 'default_operator' => 'AND',
  276. 'analyze_wildcard' => true,
  277. }
  278. }
  279. if (fields = options.dig(:highlight_fields_by_indexes, index.to_sym))
  280. condition['query_string']['fields'] = fields
  281. end
  282. query_data = build_query(condition, options)
  283. if (fields = options.dig(:highlight_fields_by_indexes, index.to_sym))
  284. fields_for_highlight = fields.each_with_object({}) { |elem, memo| memo[elem] = {} }
  285. query_data[:highlight] = { fields: fields_for_highlight }
  286. end
  287. Rails.logger.info "# curl -X POST \"#{url}\" \\"
  288. Rails.logger.debug { " -d'#{query_data.to_json}'" }
  289. response = UserAgent.get(
  290. url,
  291. query_data,
  292. {
  293. json: true,
  294. open_timeout: 5,
  295. read_timeout: 14,
  296. user: Setting.get('es_user'),
  297. password: Setting.get('es_password'),
  298. }
  299. )
  300. Rails.logger.info "# #{response.code}"
  301. if !response.success?
  302. Rails.logger.error humanized_error(
  303. verb: 'GET',
  304. url: url,
  305. payload: query_data,
  306. response: response,
  307. )
  308. return []
  309. end
  310. data = response.data&.dig('hits', 'hits')
  311. return [] if !data
  312. data.map do |item|
  313. Rails.logger.info "... #{item['_type']} #{item['_id']}"
  314. output = {
  315. id: item['_id'],
  316. type: index,
  317. }
  318. if options.dig(:highlight_fields_by_indexes, index.to_sym)
  319. output[:highlight] = item['highlight']
  320. end
  321. output
  322. end
  323. end
  324. def self.search_by_index_sort(sort_by = nil, order_by = nil)
  325. result = []
  326. sort_by&.each_with_index do |value, index|
  327. next if value.blank?
  328. next if order_by&.at(index).blank?
  329. # for sorting values use .raw values (no analyzer is used - plain values)
  330. if value !~ /\./ && value !~ /_(time|date|till|id|ids|at)$/
  331. value += '.raw'
  332. end
  333. result.push(
  334. value => {
  335. order: order_by[index],
  336. },
  337. )
  338. end
  339. if result.blank?
  340. result.push(
  341. updated_at: {
  342. order: 'desc',
  343. },
  344. )
  345. end
  346. result.push('_score')
  347. result
  348. end
  349. =begin
  350. get count of tickets and tickets which match on selector
  351. result = SearchIndexBackend.selectors(index, selector)
  352. example with a simple search:
  353. result = SearchIndexBackend.selectors('Ticket', { 'category' => { 'operator' => 'is', 'value' => 'aa::ab' } })
  354. result = [
  355. { id: 1, type: 'Ticket' },
  356. { id: 2, type: 'Ticket' },
  357. { id: 3, type: 'Ticket' },
  358. ]
  359. you also can get aggregations
  360. result = SearchIndexBackend.selectors(index, selector, options, aggs_interval)
  361. example for aggregations within one year
  362. aggs_interval = {
  363. from: '2015-01-01',
  364. to: '2015-12-31',
  365. interval: 'month', # year, quarter, month, week, day, hour, minute, second
  366. field: 'created_at',
  367. }
  368. options = {
  369. limit: 123,
  370. current_user: User.find(123),
  371. }
  372. result = SearchIndexBackend.selectors('Ticket', { 'category' => { 'operator' => 'is', 'value' => 'aa::ab' } }, options, aggs_interval)
  373. result = {
  374. hits:{
  375. total:4819,
  376. },
  377. aggregations:{
  378. time_buckets:{
  379. buckets:[
  380. {
  381. key_as_string:"2014-10-01T00:00:00.000Z",
  382. key:1412121600000,
  383. doc_count:420
  384. },
  385. {
  386. key_as_string:"2014-11-01T00:00:00.000Z",
  387. key:1414800000000,
  388. doc_count:561
  389. },
  390. ...
  391. ]
  392. }
  393. }
  394. }
  395. =end
  396. def self.selectors(index, selectors = nil, options = {}, aggs_interval = nil)
  397. raise 'no selectors given' if !selectors
  398. url = build_url(nil, nil, false, false)
  399. return if url.blank?
  400. url += build_search_url(index)
  401. data = selector2query(selectors, options, aggs_interval)
  402. Rails.logger.info "# curl -X POST \"#{url}\" \\"
  403. Rails.logger.debug { " -d'#{data.to_json}'" }
  404. response = UserAgent.get(
  405. url,
  406. data,
  407. {
  408. json: true,
  409. open_timeout: 5,
  410. read_timeout: 14,
  411. user: Setting.get('es_user'),
  412. password: Setting.get('es_password'),
  413. }
  414. )
  415. Rails.logger.info "# #{response.code}"
  416. if !response.success?
  417. raise humanized_error(
  418. verb: 'GET',
  419. url: url,
  420. payload: data,
  421. response: response,
  422. )
  423. end
  424. Rails.logger.debug { response.data.to_json }
  425. if aggs_interval.blank? || aggs_interval[:interval].blank?
  426. ticket_ids = []
  427. response.data['hits']['hits'].each do |item|
  428. ticket_ids.push item['_id']
  429. end
  430. return {
  431. count: response.data['hits']['total'],
  432. ticket_ids: ticket_ids,
  433. }
  434. end
  435. response.data
  436. end
  437. DEFAULT_SELECTOR_OPTIONS = {
  438. limit: 10
  439. }.freeze
  440. def self.selector2query(selector, options, aggs_interval)
  441. options = DEFAULT_QUERY_OPTIONS.merge(options.deep_symbolize_keys)
  442. query_must = []
  443. query_must_not = []
  444. relative_map = {
  445. day: 'd',
  446. year: 'y',
  447. month: 'M',
  448. hour: 'h',
  449. minute: 'm',
  450. }
  451. if selector.present?
  452. selector.each do |key, data|
  453. key_tmp = key.sub(/^.+?\./, '')
  454. t = {}
  455. # use .raw in cases where query contains ::
  456. if data['value'].is_a?(Array)
  457. data['value'].each do |value|
  458. if value.is_a?(String) && value =~ /::/
  459. key_tmp += '.raw'
  460. break
  461. end
  462. end
  463. elsif data['value'].is_a?(String)
  464. if /::/.match?(data['value'])
  465. key_tmp += '.raw'
  466. end
  467. end
  468. # is/is not/contains/contains not
  469. if data['operator'] == 'is' || data['operator'] == 'is not' || data['operator'] == 'contains' || data['operator'] == 'contains not'
  470. if data['value'].is_a?(Array)
  471. t[:terms] = {}
  472. t[:terms][key_tmp] = data['value']
  473. else
  474. t[:term] = {}
  475. t[:term][key_tmp] = data['value']
  476. end
  477. if data['operator'] == 'is' || data['operator'] == 'contains'
  478. query_must.push t
  479. elsif data['operator'] == 'is not' || data['operator'] == 'contains not'
  480. query_must_not.push t
  481. end
  482. elsif data['operator'] == 'contains all' || data['operator'] == 'contains one' || data['operator'] == 'contains all not' || data['operator'] == 'contains one not'
  483. values = data['value'].split(',').map(&:strip)
  484. t[:query_string] = {}
  485. if data['operator'] == 'contains all'
  486. t[:query_string][:query] = "#{key_tmp}:\"#{values.join('" AND "')}\""
  487. query_must.push t
  488. elsif data['operator'] == 'contains one not'
  489. t[:query_string][:query] = "#{key_tmp}:\"#{values.join('" OR "')}\""
  490. query_must_not.push t
  491. elsif data['operator'] == 'contains one'
  492. t[:query_string][:query] = "#{key_tmp}:\"#{values.join('" OR "')}\""
  493. query_must.push t
  494. elsif data['operator'] == 'contains all not'
  495. t[:query_string][:query] = "#{key_tmp}:\"#{values.join('" AND "')}\""
  496. query_must_not.push t
  497. end
  498. # within last/within next (relative)
  499. elsif data['operator'] == 'within last (relative)' || data['operator'] == 'within next (relative)'
  500. range = relative_map[data['range'].to_sym]
  501. if range.blank?
  502. raise "Invalid relative_map for range '#{data['range']}'."
  503. end
  504. t[:range] = {}
  505. t[:range][key_tmp] = {}
  506. if data['operator'] == 'within last (relative)'
  507. t[:range][key_tmp][:gte] = "now-#{data['value']}#{range}"
  508. else
  509. t[:range][key_tmp][:lt] = "now+#{data['value']}#{range}"
  510. end
  511. query_must.push t
  512. # before/after (relative)
  513. elsif data['operator'] == 'before (relative)' || data['operator'] == 'after (relative)'
  514. range = relative_map[data['range'].to_sym]
  515. if range.blank?
  516. raise "Invalid relative_map for range '#{data['range']}'."
  517. end
  518. t[:range] = {}
  519. t[:range][key_tmp] = {}
  520. if data['operator'] == 'before (relative)'
  521. t[:range][key_tmp][:lt] = "now-#{data['value']}#{range}"
  522. else
  523. t[:range][key_tmp][:gt] = "now+#{data['value']}#{range}"
  524. end
  525. query_must.push t
  526. # before/after (absolute)
  527. elsif data['operator'] == 'before (absolute)' || data['operator'] == 'after (absolute)'
  528. t[:range] = {}
  529. t[:range][key_tmp] = {}
  530. if data['operator'] == 'before (absolute)'
  531. t[:range][key_tmp][:lt] = (data['value'])
  532. else
  533. t[:range][key_tmp][:gt] = (data['value'])
  534. end
  535. query_must.push t
  536. else
  537. raise "unknown operator '#{data['operator']}' for #{key}"
  538. end
  539. end
  540. end
  541. data = {
  542. query: {},
  543. size: options[:limit],
  544. }
  545. # add aggs to filter
  546. if aggs_interval.present?
  547. if aggs_interval[:interval].present?
  548. data[:size] = 0
  549. data[:aggs] = {
  550. time_buckets: {
  551. date_histogram: {
  552. field: aggs_interval[:field],
  553. interval: aggs_interval[:interval],
  554. }
  555. }
  556. }
  557. if aggs_interval[:timezone].present?
  558. data[:aggs][:time_buckets][:date_histogram][:time_zone] = aggs_interval[:timezone]
  559. end
  560. end
  561. r = {}
  562. r[:range] = {}
  563. r[:range][aggs_interval[:field]] = {
  564. from: aggs_interval[:from],
  565. to: aggs_interval[:to],
  566. }
  567. query_must.push r
  568. end
  569. data[:query][:bool] ||= {}
  570. if query_must.present?
  571. data[:query][:bool][:must] = query_must
  572. end
  573. if query_must_not.present?
  574. data[:query][:bool][:must_not] = query_must_not
  575. end
  576. # add sort
  577. if aggs_interval.present? && aggs_interval[:field].present? && aggs_interval[:interval].blank?
  578. sort = []
  579. sort[0] = {}
  580. sort[0][aggs_interval[:field]] = {
  581. order: 'desc'
  582. }
  583. sort[1] = '_score'
  584. data['sort'] = sort
  585. end
  586. data
  587. end
  588. =begin
  589. return true if backend is configured
  590. result = SearchIndexBackend.enabled?
  591. =end
  592. def self.enabled?
  593. return false if Setting.get('es_url').blank?
  594. true
  595. end
  596. def self.build_index_name(index)
  597. local_index = "#{Setting.get('es_index')}_#{Rails.env}"
  598. "#{local_index}_#{index.underscore.tr('/', '_')}"
  599. end
  600. def self.build_url(type = nil, o_id = nil, pipeline = true, with_type = true)
  601. return if !SearchIndexBackend.enabled?
  602. # for elasticsearch 5.6 and lower
  603. index = "#{Setting.get('es_index')}_#{Rails.env}"
  604. if Setting.get('es_multi_index') == false
  605. url = Setting.get('es_url')
  606. url = if type
  607. url_pipline = Setting.get('es_pipeline')
  608. if url_pipline.present?
  609. url_pipline = "?pipeline=#{url_pipline}"
  610. end
  611. if o_id
  612. "#{url}/#{index}/#{type}/#{o_id}#{url_pipline}"
  613. else
  614. "#{url}/#{index}/#{type}#{url_pipline}"
  615. end
  616. else
  617. "#{url}/#{index}"
  618. end
  619. return url
  620. end
  621. # for elasticsearch 6.x and higher
  622. url = Setting.get('es_url')
  623. if pipeline == true
  624. url_pipline = Setting.get('es_pipeline')
  625. if url_pipline.present?
  626. url_pipline = "?pipeline=#{url_pipline}"
  627. end
  628. end
  629. if type
  630. index = build_index_name(type)
  631. if with_type == false
  632. return "#{url}/#{index}"
  633. end
  634. if o_id
  635. return "#{url}/#{index}/_doc/#{o_id}#{url_pipline}"
  636. end
  637. return "#{url}/#{index}/_doc#{url_pipline}"
  638. end
  639. "#{url}/"
  640. end
  641. def self.build_search_url(index)
  642. # for elasticsearch 5.6 and lower
  643. if Setting.get('es_multi_index') == false
  644. if index
  645. return "/#{index}/_search"
  646. end
  647. return '/_search'
  648. end
  649. # for elasticsearch 6.x and higher
  650. "#{build_index_name(index)}/_doc/_search"
  651. end
  652. def self.humanized_error(verb:, url:, payload: nil, response:)
  653. prefix = "Unable to process #{verb} request to elasticsearch URL '#{url}'."
  654. suffix = "\n\nResponse:\n#{response.inspect}\n\nPayload:\n#{payload.inspect}"
  655. if payload.respond_to?(:to_json)
  656. suffix += "\n\nPayload size: #{payload.to_json.bytesize / 1024 / 1024}M"
  657. end
  658. message = if response&.error&.match?('Connection refused')
  659. "Elasticsearch is not reachable, probably because it's not running or even installed."
  660. elsif url.end_with?('pipeline/zammad-attachment', 'pipeline=zammad-attachment') && response.code == 400
  661. 'The installed attachment plugin could not handle the request payload. Ensure that the correct attachment plugin is installed (5.6 => ingest-attachment, 2.4 - 5.5 => mapper-attachments).'
  662. else
  663. 'Check the response and payload for detailed information: '
  664. end
  665. result = "#{prefix} #{message}#{suffix}"
  666. Rails.logger.error result.first(40_000)
  667. result
  668. end
  669. # add * on simple query like "somephrase23"
  670. def self.append_wildcard_to_simple_query(query)
  671. query.strip!
  672. query += '*' if !query.match?(/:/)
  673. query
  674. end
  675. =begin
  676. @param condition [Hash] search condition
  677. @param options [Hash] search options
  678. @option options [Integer] :from
  679. @option options [Integer] :limit
  680. @option options [Hash] :query_extension applied to ElasticSearch query
  681. @option options [Array<String>] :order_by ordering directions, desc or asc
  682. @option options [Array<String>] :sort_by fields to sort by
  683. =end
  684. DEFAULT_QUERY_OPTIONS = {
  685. from: 0,
  686. limit: 10
  687. }.freeze
  688. def self.build_query(condition, options = {})
  689. options = DEFAULT_QUERY_OPTIONS.merge(options.deep_symbolize_keys)
  690. data = {
  691. from: options[:from],
  692. size: options[:limit],
  693. sort: search_by_index_sort(options[:sort_by], options[:order_by]),
  694. query: {
  695. bool: {
  696. must: []
  697. }
  698. }
  699. }
  700. if (extension = options.dig(:query_extension))
  701. data[:query].deep_merge! extension.deep_dup
  702. end
  703. data[:query][:bool][:must].push condition
  704. data
  705. end
  706. end