search_index_backend.rb 25 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007
  1. # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. class SearchIndexBackend
  3. SUPPORTED_ES_VERSION_MINIMUM = '7.8'.freeze
  4. SUPPORTED_ES_VERSION_LESS_THAN = '9'.freeze
  5. =begin
  6. info about used search index machine
  7. SearchIndexBackend.info
  8. =end
  9. def self.info
  10. url = Setting.get('es_url').to_s
  11. return if url.blank?
  12. response = make_request(url)
  13. if response.success?
  14. installed_version = response.data.dig('version', 'number')
  15. raise "Unable to get elasticsearch version from response: #{response.inspect}" if installed_version.blank?
  16. installed_version_parsed = Gem::Version.new(installed_version)
  17. if (installed_version_parsed >= Gem::Version.new(SUPPORTED_ES_VERSION_LESS_THAN)) ||
  18. (installed_version_parsed < Gem::Version.new(SUPPORTED_ES_VERSION_MINIMUM))
  19. raise "Version #{installed_version} of configured elasticsearch is not supported."
  20. end
  21. return response.data
  22. end
  23. raise humanized_error(
  24. verb: 'GET',
  25. url: url,
  26. response: response,
  27. )
  28. end
  29. =begin
  30. update processors
  31. SearchIndexBackend.processors(
  32. _ingest/pipeline/attachment: {
  33. description: 'Extract attachment information from arrays',
  34. processors: [
  35. {
  36. foreach: {
  37. field: 'ticket.articles.attachments',
  38. processor: {
  39. attachment: {
  40. target_field: '_ingest._value.attachment',
  41. field: '_ingest._value.data'
  42. }
  43. }
  44. }
  45. }
  46. ]
  47. }
  48. )
  49. =end
  50. def self.processors(data)
  51. data.each do |key, items|
  52. url = "#{Setting.get('es_url')}/#{key}"
  53. items.each do |item|
  54. if item[:action] == 'delete'
  55. response = make_request(url, method: :delete)
  56. next if response.success?
  57. next if response.code.to_s == '404'
  58. raise humanized_error(
  59. verb: 'DELETE',
  60. url: url,
  61. response: response,
  62. )
  63. end
  64. item.delete(:action)
  65. make_request_and_validate(url, data: item, method: :put)
  66. end
  67. end
  68. true
  69. end
  70. =begin
  71. create/update/delete index
  72. SearchIndexBackend.index(
  73. :action => 'create', # create/update/delete
  74. :name => 'Ticket',
  75. :data => {
  76. :mappings => {
  77. :Ticket => {
  78. :properties => {
  79. :articles => {
  80. :type => 'nested',
  81. :properties => {
  82. 'attachment' => { :type => 'attachment' }
  83. }
  84. }
  85. }
  86. }
  87. }
  88. }
  89. )
  90. SearchIndexBackend.index(
  91. :action => 'delete', # create/update/delete
  92. :name => 'Ticket',
  93. )
  94. =end
  95. def self.index(data)
  96. url = build_url(type: data[:name], with_pipeline: false, with_document_type: false)
  97. return if url.blank?
  98. if data[:action] && data[:action] == 'delete'
  99. return if !SearchIndexBackend.index_exists?(data[:name])
  100. return SearchIndexBackend.remove(data[:name])
  101. end
  102. make_request_and_validate(url, data: data[:data], method: :put)
  103. end
  104. =begin
  105. add new object to search index
  106. SearchIndexBackend.add('Ticket', some_data_object)
  107. =end
  108. def self.add(type, data)
  109. url = build_url(type: type, object_id: data['id'])
  110. return if url.blank?
  111. make_request_and_validate(url, data: data, method: :post)
  112. end
  113. =begin
  114. get object of search index by id
  115. SearchIndexBackend.get('Ticket', 123)
  116. =end
  117. def self.get(type, data)
  118. url = build_url(type: type, object_id: data, with_pipeline: false)
  119. return if url.blank?
  120. make_request(url, method: :get).try(:data)
  121. end
  122. =begin
  123. Check if an index exists.
  124. SearchIndexBackend.index_exists?('Ticket')
  125. =end
  126. def self.index_exists?(type)
  127. url = build_url(type: type, with_pipeline: false, with_document_type: false)
  128. return if url.blank?
  129. response = make_request(url)
  130. return true if response.success?
  131. return true if response.code.to_s != '404'
  132. false
  133. end
  134. =begin
  135. This function updates specifc attributes of an index based on a query.
  136. It should get used in batches to prevent performance issues on entities which have millions of objects in it.
  137. data = {
  138. organization: {
  139. name: "Zammad Foundation"
  140. }
  141. }
  142. where = {
  143. term: {
  144. organization_id: 1
  145. }
  146. }
  147. SearchIndexBackend.update_by_query('Ticket', data, where)
  148. =end
  149. def self.update_by_query(type, data, where)
  150. return if data.blank?
  151. return if where.blank?
  152. url_params = {
  153. conflicts: 'proceed',
  154. slices: 'auto',
  155. max_docs: 1_000,
  156. }
  157. url = build_url(type: type, action: '_update_by_query', with_pipeline: false, with_document_type: false, url_params: url_params)
  158. return if url.blank?
  159. script_list = []
  160. data.each_key do |key|
  161. script_list.push("ctx._source.#{key}=params.#{key}")
  162. end
  163. data = {
  164. script: {
  165. lang: 'painless',
  166. source: script_list.join(';'),
  167. params: data,
  168. },
  169. query: where,
  170. sort: {
  171. id: 'desc',
  172. },
  173. }
  174. response = make_request(url, data: data, method: :post, read_timeout: 10.minutes)
  175. if !response.success?
  176. Rails.logger.error humanized_error(
  177. verb: 'GET',
  178. url: url,
  179. payload: data,
  180. response: response,
  181. )
  182. return []
  183. end
  184. response.data
  185. end
  186. =begin
  187. remove whole data from index
  188. SearchIndexBackend.remove('Ticket', 123)
  189. SearchIndexBackend.remove('Ticket')
  190. =end
  191. def self.remove(type, o_id = nil)
  192. url = if o_id
  193. build_url(type: type, object_id: o_id, with_pipeline: false, with_document_type: true)
  194. else
  195. build_url(type: type, object_id: o_id, with_pipeline: false, with_document_type: false)
  196. end
  197. return if url.blank?
  198. response = make_request(url, method: :delete)
  199. return true if response.success?
  200. return true if response.code.to_s == '400'
  201. humanized_error = humanized_error(
  202. verb: 'DELETE',
  203. url: url,
  204. response: response,
  205. )
  206. Rails.logger.warn "Can't delete index: #{humanized_error}"
  207. false
  208. end
  209. =begin
  210. @param query [String] search query
  211. @param index [String, Array<String>] indexes to search in (see search_by_index)
  212. @param options [Hash] search options (see build_query)
  213. @return search result
  214. @example Sample queries
  215. result = SearchIndexBackend.search('search query', ['User', 'Organization'], limit: limit)
  216. - result = SearchIndexBackend.search('search query', 'User', limit: limit)
  217. result = SearchIndexBackend.search('search query', 'User', limit: limit, sort_by: ['updated_at'], order_by: ['desc'])
  218. result = SearchIndexBackend.search('search query', 'User', limit: limit, sort_by: ['active', updated_at'], order_by: ['desc', 'desc'])
  219. result = [
  220. {
  221. :id => 123,
  222. :type => 'User',
  223. },
  224. {
  225. :id => 125,
  226. :type => 'User',
  227. },
  228. {
  229. :id => 15,
  230. :type => 'Organization',
  231. }
  232. ]
  233. =end
  234. def self.search(query, index, options = {})
  235. if !index.is_a? Array
  236. return search_by_index(query, index, options)
  237. end
  238. index
  239. .filter_map { |local_index| search_by_index(query, local_index, options) }
  240. .flatten(1)
  241. end
  242. =begin
  243. @param query [String] search query
  244. @param index [String] index name
  245. @param options [Hash] search options (see build_query)
  246. @return search result
  247. =end
  248. def self.search_by_index(query, index, options = {})
  249. return [] if query.blank?
  250. url = build_url(type: index, action: '_search', with_pipeline: false, with_document_type: false)
  251. return [] if url.blank?
  252. # real search condition
  253. condition = {
  254. 'query_string' => {
  255. 'query' => append_wildcard_to_simple_query(query),
  256. 'time_zone' => Setting.get('timezone_default_sanitized'),
  257. 'default_operator' => 'AND',
  258. 'analyze_wildcard' => true,
  259. }
  260. }
  261. if (fields = options.dig(:query_fields_by_indexes, index.to_sym))
  262. condition['query_string']['fields'] = fields
  263. end
  264. query_data = build_query(index, condition, options)
  265. if (fields = options.dig(:highlight_fields_by_indexes, index.to_sym))
  266. fields_for_highlight = fields.index_with { |_elem| {} }
  267. query_data[:highlight] = { fields: fields_for_highlight }
  268. end
  269. response = make_request(url, data: query_data, method: :post)
  270. if !response.success?
  271. Rails.logger.error humanized_error(
  272. verb: 'GET',
  273. url: url,
  274. payload: query_data,
  275. response: response,
  276. )
  277. return []
  278. end
  279. data = response.data&.dig('hits', 'hits')
  280. return [] if !data
  281. data.map do |item|
  282. Rails.logger.debug { "... #{item['_type']} #{item['_id']}" }
  283. output = {
  284. id: item['_id'],
  285. type: index,
  286. }
  287. if options.dig(:highlight_fields_by_indexes, index.to_sym)
  288. output[:highlight] = item['highlight']
  289. end
  290. output
  291. end
  292. end
  293. def self.search_by_index_sort(index:, sort_by: nil, order_by: nil, fulltext: false)
  294. result = (sort_by || [])
  295. .map(&:to_s)
  296. .each_with_object([])
  297. .with_index do |(elem, memo), idx|
  298. next if elem.blank?
  299. next if order_by&.at(idx).blank?
  300. # for sorting values use .keyword values (no analyzer is used - plain values)
  301. is_keyword = get_mapping_properties_object(Array.wrap(index).first.constantize).dig(:properties, elem, :fields, :keyword, :type) == 'keyword'
  302. if is_keyword
  303. elem += '.keyword'
  304. end
  305. memo.push(
  306. elem => {
  307. order: order_by[idx],
  308. },
  309. )
  310. end
  311. # if we have no fulltext search then the primary default sort is updated at else score
  312. if result.blank? && !fulltext
  313. result.push(
  314. updated_at: {
  315. order: 'desc',
  316. },
  317. )
  318. end
  319. result.push('_score')
  320. result
  321. end
  322. =begin
  323. get count of tickets and tickets which match on selector
  324. result = SearchIndexBackend.selectors(index, selector)
  325. example with a simple search:
  326. result = SearchIndexBackend.selectors('Ticket', { 'category' => { 'operator' => 'is', 'value' => 'aa::ab' } })
  327. result = [
  328. { id: 1, type: 'Ticket' },
  329. { id: 2, type: 'Ticket' },
  330. { id: 3, type: 'Ticket' },
  331. ]
  332. you also can get aggregations
  333. result = SearchIndexBackend.selectors(index, selector, options, aggs_interval)
  334. example for aggregations within one year
  335. aggs_interval = {
  336. from: '2015-01-01',
  337. to: '2015-12-31',
  338. interval: 'month', # year, quarter, month, week, day, hour, minute, second
  339. field: 'created_at',
  340. }
  341. options = {
  342. limit: 123,
  343. current_user: User.find(123),
  344. }
  345. result = SearchIndexBackend.selectors('Ticket', { 'category' => { 'operator' => 'is', 'value' => 'aa::ab' } }, options, aggs_interval)
  346. result = {
  347. hits:{
  348. total:4819,
  349. },
  350. aggregations:{
  351. time_buckets:{
  352. buckets:[
  353. {
  354. key_as_string:"2014-10-01T00:00:00.000Z",
  355. key:1412121600000,
  356. doc_count:420
  357. },
  358. {
  359. key_as_string:"2014-11-01T00:00:00.000Z",
  360. key:1414800000000,
  361. doc_count:561
  362. },
  363. ...
  364. ]
  365. }
  366. }
  367. }
  368. =end
  369. def self.selectors(index, selectors = nil, options = {}, aggs_interval = nil)
  370. raise 'no selectors given' if !selectors
  371. url = build_url(type: index, action: '_search', with_pipeline: false, with_document_type: false)
  372. return if url.blank?
  373. data = selector2query(index, selectors, options, aggs_interval)
  374. response = make_request(url, data: data, method: :post)
  375. if !response.success?
  376. raise humanized_error(
  377. verb: 'GET',
  378. url: url,
  379. payload: data,
  380. response: response,
  381. )
  382. end
  383. Rails.logger.debug { response.data.to_json }
  384. if aggs_interval.blank? || aggs_interval[:interval].blank?
  385. object_ids = []
  386. response.data['hits']['hits'].each do |item|
  387. object_ids.push item['_id']
  388. end
  389. # in lower ES 6 versions, we get total count directly, in higher
  390. # versions we need to pick it from total has
  391. count = response.data['hits']['total']
  392. if response.data['hits']['total'].class != Integer
  393. count = response.data['hits']['total']['value']
  394. end
  395. return {
  396. count: count,
  397. object_ids: object_ids,
  398. }
  399. end
  400. response.data
  401. end
  402. def self.selector2query(index, selector, options, aggs_interval)
  403. Selector::SearchIndex.new(selector: selector, options: options.merge(aggs_interval: aggs_interval), target_class: index.constantize).get
  404. end
  405. =begin
  406. return true if backend is configured
  407. result = SearchIndexBackend.enabled?
  408. =end
  409. def self.enabled?
  410. return false if Setting.get('es_url').blank?
  411. true
  412. end
  413. def self.build_index_name(index = nil)
  414. local_index = "#{Setting.get('es_index')}_#{Rails.env}"
  415. return local_index if index.blank?
  416. "#{local_index}_#{index.underscore.tr('/', '_')}"
  417. end
  418. =begin
  419. generate url for index or document access (only for internal use)
  420. # url to access single document in index (in case with_pipeline or not)
  421. url = SearchIndexBackend.build_url(type: 'User', object_id: 123, with_pipeline: true)
  422. # url to access whole index
  423. url = SearchIndexBackend.build_url(type: 'User')
  424. # url to access document definition in index (only es6 and higher)
  425. url = SearchIndexBackend.build_url(type: 'User', with_pipeline: false, with_document_type: true)
  426. # base url
  427. url = SearchIndexBackend.build_url
  428. =end
  429. def self.build_url(type: nil, action: nil, object_id: nil, with_pipeline: true, with_document_type: true, url_params: {})
  430. return if !SearchIndexBackend.enabled?
  431. # set index
  432. index = build_index_name(type)
  433. # add pipeline if needed
  434. if index && with_pipeline == true
  435. url_pipline = Setting.get('es_pipeline')
  436. if url_pipline.present?
  437. url_params['pipeline'] = url_pipline
  438. end
  439. end
  440. # prepare url params
  441. params_string = ''
  442. if url_params.present?
  443. params_string = "?#{URI.encode_www_form(url_params)}"
  444. end
  445. url = Setting.get('es_url')
  446. return "#{url}#{params_string}" if index.blank?
  447. # add type information
  448. url = "#{url}/#{index}"
  449. # add document type
  450. if with_document_type
  451. url = "#{url}/_doc"
  452. end
  453. # add action
  454. if action
  455. url = "#{url}/#{action}"
  456. end
  457. # add object id
  458. if object_id.present?
  459. url = "#{url}/#{object_id}"
  460. end
  461. "#{url}#{params_string}"
  462. end
  463. def self.humanized_error(verb:, url:, response:, payload: nil)
  464. prefix = "Unable to process #{verb} request to elasticsearch URL '#{url}'."
  465. suffix = "\n\nResponse:\n#{response.inspect}\n\n"
  466. if payload.respond_to?(:to_json)
  467. suffix += "Payload:\n#{payload.to_json}"
  468. suffix += "\n\nPayload size: #{payload.to_json.bytesize / 1024 / 1024}M"
  469. else
  470. suffix += "Payload:\n#{payload.inspect}"
  471. end
  472. message = if response&.error&.match?('Connection refused') # rubocop:disable Zammad/DetectTranslatableString
  473. __("Elasticsearch is not reachable. It's possible that it's not running. Please check whether it is installed.")
  474. elsif url.end_with?('pipeline/zammad-attachment', 'pipeline=zammad-attachment') && response.code == 400
  475. __('The installed attachment plugin could not handle the request payload. Ensure that the correct attachment plugin is installed (ingest-attachment).')
  476. else
  477. __('Check the response and payload for detailed information:')
  478. end
  479. result = "#{prefix} #{message}#{suffix}"
  480. Rails.logger.error result.first(40_000)
  481. result
  482. end
  483. # add * on simple query like "somephrase23"
  484. def self.append_wildcard_to_simple_query(query)
  485. query = query.strip
  486. query += '*' if query.exclude?(':')
  487. query
  488. end
  489. =begin
  490. @param condition [Hash] search condition
  491. @param options [Hash] search options
  492. @option options [Integer] :from
  493. @option options [Integer] :limit
  494. @option options [Hash] :query_extension applied to ElasticSearch query
  495. @option options [Array<String>] :order_by ordering directions, desc or asc
  496. @option options [Array<String>] :sort_by fields to sort by
  497. @option options [Array<String>] :fulltext If no sorting is defined the current fallback is the sorting by updated_at. But for fulltext searches it makes more sense to search by _score as default. This parameter allows to change to the fallback to _score.
  498. =end
  499. DEFAULT_QUERY_OPTIONS = {
  500. from: 0,
  501. limit: 10
  502. }.freeze
  503. def self.build_query(index, condition, options = {})
  504. options = DEFAULT_QUERY_OPTIONS.merge(options.deep_symbolize_keys)
  505. data = {
  506. from: options[:from],
  507. size: options[:limit],
  508. sort: search_by_index_sort(index: index, sort_by: options[:sort_by], order_by: options[:order_by], fulltext: options[:fulltext]),
  509. query: {
  510. bool: {
  511. must: []
  512. }
  513. }
  514. }
  515. if (extension = options[:query_extension])
  516. data[:query].deep_merge! extension.deep_dup
  517. end
  518. data[:query][:bool][:must].push condition
  519. if options[:ids].present?
  520. data[:query][:bool][:must].push({ ids: { values: options[:ids] } })
  521. end
  522. data
  523. end
  524. =begin
  525. refreshes all indexes to make previous request data visible in future requests
  526. SearchIndexBackend.refresh
  527. =end
  528. def self.refresh
  529. return if !enabled?
  530. url = "#{Setting.get('es_url')}/_all/_refresh"
  531. make_request_and_validate(url, method: :post)
  532. end
  533. =begin
  534. helper method for making HTTP calls
  535. @param url [String] url
  536. @option params [Hash] :data is a payload hash
  537. @option params [Symbol] :method is a HTTP method
  538. @option params [Integer] :open_timeout is HTTP request open timeout
  539. @option params [Integer] :read_timeout is HTTP request read timeout
  540. @return UserAgent response
  541. =end
  542. def self.make_request(url, data: {}, method: :get, open_timeout: 8, read_timeout: 180)
  543. Rails.logger.debug { "# curl -X #{method} \"#{url}\" " }
  544. Rails.logger.debug { "-d '#{data.to_json}'" } if data.present?
  545. options = {
  546. json: true,
  547. open_timeout: open_timeout,
  548. read_timeout: read_timeout,
  549. total_timeout: (open_timeout + read_timeout + 60),
  550. open_socket_tries: 3,
  551. user: Setting.get('es_user'),
  552. password: Setting.get('es_password'),
  553. verify_ssl: Setting.get('es_ssl_verify'),
  554. }
  555. response = UserAgent.send(method, url, data, options)
  556. Rails.logger.debug { "# #{response.code}" }
  557. response
  558. end
  559. =begin
  560. helper method for making HTTP calls and raising error if response was not success
  561. @param url [String] url
  562. @option args [Hash] see {make_request}
  563. @return [Boolean] always returns true. Raises error if something went wrong.
  564. =end
  565. def self.make_request_and_validate(url, **args)
  566. response = make_request(url, **args)
  567. return true if response.success?
  568. raise humanized_error(
  569. verb: args[:method],
  570. url: url,
  571. payload: args[:data],
  572. response: response
  573. )
  574. end
  575. =begin
  576. This function will return a index mapping based on the
  577. attributes of the database table of the existing object.
  578. mapping = SearchIndexBackend.get_mapping_properties_object(Ticket)
  579. Returns:
  580. mapping = {
  581. User: {
  582. properties: {
  583. firstname: {
  584. type: 'keyword',
  585. },
  586. }
  587. }
  588. }
  589. =end
  590. def self.get_mapping_properties_object(object)
  591. result = {
  592. properties: {}
  593. }
  594. store_columns = %w[preferences data]
  595. # for elasticsearch 6.x and later
  596. string_type = 'text'
  597. string_raw = { type: 'keyword', ignore_above: 5012 }
  598. boolean_raw = { type: 'boolean' }
  599. object.columns_hash.each do |key, value|
  600. if value.type == :string && value.limit && value.limit <= 5000 && store_columns.exclude?(key)
  601. result[:properties][key] = {
  602. type: string_type,
  603. fields: {
  604. keyword: string_raw,
  605. }
  606. }
  607. elsif value.type == :integer
  608. result[:properties][key] = {
  609. type: 'integer',
  610. }
  611. elsif value.type == :datetime || value.type == :date
  612. result[:properties][key] = {
  613. type: 'date',
  614. }
  615. elsif value.type == :boolean
  616. result[:properties][key] = {
  617. type: 'boolean',
  618. fields: {
  619. keyword: boolean_raw,
  620. }
  621. }
  622. elsif value.type == :binary
  623. result[:properties][key] = {
  624. type: 'binary',
  625. }
  626. elsif value.type == :bigint
  627. result[:properties][key] = {
  628. type: 'long',
  629. }
  630. elsif value.type == :decimal
  631. result[:properties][key] = {
  632. type: 'float',
  633. }
  634. end
  635. end
  636. case object.name
  637. when 'Ticket'
  638. result[:properties][:article] = {
  639. type: 'nested',
  640. include_in_parent: true,
  641. }
  642. end
  643. result
  644. end
  645. # get es version
  646. def self.version
  647. @version ||= SearchIndexBackend.info&.dig('version', 'number')
  648. end
  649. def self.configured?
  650. Setting.get('es_url').present?
  651. end
  652. def self.model_indexable?(model_name)
  653. Models.indexable.any? { |m| m.name == model_name }
  654. end
  655. def self.default_model_settings
  656. {
  657. 'index.mapping.total_fields.limit' => 2000,
  658. }
  659. end
  660. def self.model_settings(model)
  661. settings = Setting.get('es_model_settings')[model.name] || {}
  662. default_model_settings.merge(settings)
  663. end
  664. def self.all_settings
  665. Models.indexable.each_with_object({}).to_h { |m| [m.name, model_settings(m)] }
  666. end
  667. def self.set_setting(model_name, key, value)
  668. raise "It is not possible to configure settings for the non-indexable model '#{model_name}'." if !model_indexable?(model_name)
  669. raise __("The required parameter 'key' is missing.") if key.blank?
  670. raise __("The required parameter 'value' is missing.") if value.blank?
  671. config = Setting.get('es_model_settings')
  672. config[model_name] ||= {}
  673. config[model_name][key] = value
  674. Setting.set('es_model_settings', config)
  675. end
  676. def self.unset_setting(model_name, key)
  677. raise "It is not possible to configure settings for the non-indexable model '#{model_name}'." if !model_indexable?(model_name)
  678. raise __("The required parameter 'key' is missing.") if key.blank?
  679. config = Setting.get('es_model_settings')
  680. config[model_name] ||= {}
  681. config[model_name].delete(key)
  682. Setting.set('es_model_settings', config)
  683. end
  684. def self.create_index(models = Models.indexable)
  685. models.each do |local_object|
  686. SearchIndexBackend.index(
  687. action: 'create',
  688. name: local_object.name,
  689. data: {
  690. mappings: SearchIndexBackend.get_mapping_properties_object(local_object),
  691. settings: model_settings(local_object),
  692. }
  693. )
  694. end
  695. end
  696. def self.drop_index(models = Models.indexable)
  697. models.each do |local_object|
  698. SearchIndexBackend.index(
  699. action: 'delete',
  700. name: local_object.name,
  701. )
  702. end
  703. end
  704. def self.create_object_index(object)
  705. models = Models.indexable.select { |c| c.to_s == object }
  706. create_index(models)
  707. end
  708. def self.drop_object_index(object)
  709. models = Models.indexable.select { |c| c.to_s == object }
  710. drop_index(models)
  711. end
  712. def self.pipeline(create: false)
  713. pipeline = Setting.get('es_pipeline')
  714. if create && pipeline.blank?
  715. pipeline = "zammad#{SecureRandom.uuid}"
  716. Setting.set('es_pipeline', pipeline)
  717. end
  718. pipeline
  719. end
  720. def self.pipeline_settings
  721. {
  722. ignore_failure: true,
  723. ignore_missing: true,
  724. }
  725. end
  726. def self.create_pipeline
  727. SearchIndexBackend.processors(
  728. "_ingest/pipeline/#{pipeline(create: true)}": [
  729. {
  730. action: 'delete',
  731. },
  732. {
  733. action: 'create',
  734. description: __('Extract zammad-attachment information from arrays'),
  735. processors: [
  736. {
  737. foreach: {
  738. field: 'article',
  739. processor: {
  740. foreach: {
  741. field: '_ingest._value.attachment',
  742. processor: {
  743. attachment: {
  744. target_field: '_ingest._value',
  745. field: '_ingest._value._content',
  746. }.merge(pipeline_settings),
  747. }
  748. }.merge(pipeline_settings),
  749. }
  750. }.merge(pipeline_settings),
  751. },
  752. {
  753. foreach: {
  754. field: 'attachment',
  755. processor: {
  756. attachment: {
  757. target_field: '_ingest._value',
  758. field: '_ingest._value._content',
  759. }.merge(pipeline_settings),
  760. }
  761. }.merge(pipeline_settings),
  762. }
  763. ]
  764. }
  765. ]
  766. )
  767. end
  768. def self.drop_pipeline
  769. return if pipeline.blank?
  770. SearchIndexBackend.processors(
  771. "_ingest/pipeline/#{pipeline}": [
  772. {
  773. action: 'delete',
  774. },
  775. ]
  776. )
  777. end
  778. end