search_index_backend.rb 25 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004
  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'),
  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 = response.data['hits']['hits'].pluck('_id')
  386. # in lower ES 6 versions, we get total count directly, in higher
  387. # versions we need to pick it from total has
  388. count = response.data['hits']['total']
  389. if response.data['hits']['total'].class != Integer
  390. count = response.data['hits']['total']['value']
  391. end
  392. return {
  393. count: count,
  394. object_ids: object_ids,
  395. }
  396. end
  397. response.data
  398. end
  399. def self.selector2query(index, selector, options, aggs_interval)
  400. Selector::SearchIndex.new(selector: selector, options: options.merge(aggs_interval: aggs_interval), target_class: index.constantize).get
  401. end
  402. =begin
  403. return true if backend is configured
  404. result = SearchIndexBackend.enabled?
  405. =end
  406. def self.enabled?
  407. return false if Setting.get('es_url').blank?
  408. true
  409. end
  410. def self.build_index_name(index = nil)
  411. local_index = "#{Setting.get('es_index')}_#{Rails.env}"
  412. return local_index if index.blank?
  413. "#{local_index}_#{index.underscore.tr('/', '_')}"
  414. end
  415. =begin
  416. generate url for index or document access (only for internal use)
  417. # url to access single document in index (in case with_pipeline or not)
  418. url = SearchIndexBackend.build_url(type: 'User', object_id: 123, with_pipeline: true)
  419. # url to access whole index
  420. url = SearchIndexBackend.build_url(type: 'User')
  421. # url to access document definition in index (only es6 and higher)
  422. url = SearchIndexBackend.build_url(type: 'User', with_pipeline: false, with_document_type: true)
  423. # base url
  424. url = SearchIndexBackend.build_url
  425. =end
  426. def self.build_url(type: nil, action: nil, object_id: nil, with_pipeline: true, with_document_type: true, url_params: {})
  427. return if !SearchIndexBackend.enabled?
  428. # set index
  429. index = build_index_name(type)
  430. # add pipeline if needed
  431. if index && with_pipeline == true
  432. url_pipline = Setting.get('es_pipeline')
  433. if url_pipline.present?
  434. url_params['pipeline'] = url_pipline
  435. end
  436. end
  437. # prepare url params
  438. params_string = ''
  439. if url_params.present?
  440. params_string = "?#{URI.encode_www_form(url_params)}"
  441. end
  442. url = Setting.get('es_url')
  443. return "#{url}#{params_string}" if index.blank?
  444. # add type information
  445. url = "#{url}/#{index}"
  446. # add document type
  447. if with_document_type
  448. url = "#{url}/_doc"
  449. end
  450. # add action
  451. if action
  452. url = "#{url}/#{action}"
  453. end
  454. # add object id
  455. if object_id.present?
  456. url = "#{url}/#{object_id}"
  457. end
  458. "#{url}#{params_string}"
  459. end
  460. def self.humanized_error(verb:, url:, response:, payload: nil)
  461. prefix = "Unable to process #{verb} request to elasticsearch URL '#{url}'."
  462. suffix = "\n\nResponse:\n#{response.inspect}\n\n"
  463. if payload.respond_to?(:to_json)
  464. suffix += "Payload:\n#{payload.to_json}"
  465. suffix += "\n\nPayload size: #{payload.to_json.bytesize / 1024 / 1024}M"
  466. else
  467. suffix += "Payload:\n#{payload.inspect}"
  468. end
  469. message = if response&.error&.match?('Connection refused') # rubocop:disable Zammad/DetectTranslatableString
  470. __("Elasticsearch is not reachable. It's possible that it's not running. Please check whether it is installed.")
  471. elsif url.end_with?('pipeline/zammad-attachment', 'pipeline=zammad-attachment') && response.code == 400
  472. __('The installed attachment plugin could not handle the request payload. Ensure that the correct attachment plugin is installed (ingest-attachment).')
  473. else
  474. __('Check the response and payload for detailed information:')
  475. end
  476. result = "#{prefix} #{message}#{suffix}"
  477. Rails.logger.error result.first(40_000)
  478. result
  479. end
  480. # add * on simple query like "somephrase23"
  481. def self.append_wildcard_to_simple_query(query)
  482. query = query.strip
  483. query += '*' if query.exclude?(':')
  484. query
  485. end
  486. =begin
  487. @param condition [Hash] search condition
  488. @param options [Hash] search options
  489. @option options [Integer] :from
  490. @option options [Integer] :limit
  491. @option options [Hash] :query_extension applied to ElasticSearch query
  492. @option options [Array<String>] :order_by ordering directions, desc or asc
  493. @option options [Array<String>] :sort_by fields to sort by
  494. @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.
  495. =end
  496. DEFAULT_QUERY_OPTIONS = {
  497. from: 0,
  498. limit: 10
  499. }.freeze
  500. def self.build_query(index, condition, options = {})
  501. options = DEFAULT_QUERY_OPTIONS.merge(options.deep_symbolize_keys)
  502. data = {
  503. from: options[:from],
  504. size: options[:limit],
  505. sort: search_by_index_sort(index: index, sort_by: options[:sort_by], order_by: options[:order_by], fulltext: options[:fulltext]),
  506. query: {
  507. bool: {
  508. must: []
  509. }
  510. }
  511. }
  512. if (extension = options[:query_extension])
  513. data[:query].deep_merge! extension.deep_dup
  514. end
  515. data[:query][:bool][:must].push condition
  516. if options[:ids].present?
  517. data[:query][:bool][:must].push({ ids: { values: options[:ids] } })
  518. end
  519. data
  520. end
  521. =begin
  522. refreshes all indexes to make previous request data visible in future requests
  523. SearchIndexBackend.refresh
  524. =end
  525. def self.refresh
  526. return if !enabled?
  527. url = "#{Setting.get('es_url')}/_all/_refresh"
  528. make_request_and_validate(url, method: :post)
  529. end
  530. =begin
  531. helper method for making HTTP calls
  532. @param url [String] url
  533. @option params [Hash] :data is a payload hash
  534. @option params [Symbol] :method is a HTTP method
  535. @option params [Integer] :open_timeout is HTTP request open timeout
  536. @option params [Integer] :read_timeout is HTTP request read timeout
  537. @return UserAgent response
  538. =end
  539. def self.make_request(url, data: {}, method: :get, open_timeout: 8, read_timeout: 180)
  540. Rails.logger.debug { "# curl -X #{method} \"#{url}\" " }
  541. Rails.logger.debug { "-d '#{data.to_json}'" } if data.present?
  542. options = {
  543. json: true,
  544. open_timeout: open_timeout,
  545. read_timeout: read_timeout,
  546. total_timeout: (open_timeout + read_timeout + 60),
  547. open_socket_tries: 3,
  548. user: Setting.get('es_user'),
  549. password: Setting.get('es_password'),
  550. verify_ssl: Setting.get('es_ssl_verify'),
  551. }
  552. response = UserAgent.send(method, url, data, options)
  553. Rails.logger.debug { "# #{response.code}" }
  554. response
  555. end
  556. =begin
  557. helper method for making HTTP calls and raising error if response was not success
  558. @param url [String] url
  559. @option args [Hash] see {make_request}
  560. @return [Boolean] always returns true. Raises error if something went wrong.
  561. =end
  562. def self.make_request_and_validate(url, **args)
  563. response = make_request(url, **args)
  564. return true if response.success?
  565. raise humanized_error(
  566. verb: args[:method],
  567. url: url,
  568. payload: args[:data],
  569. response: response
  570. )
  571. end
  572. =begin
  573. This function will return a index mapping based on the
  574. attributes of the database table of the existing object.
  575. mapping = SearchIndexBackend.get_mapping_properties_object(Ticket)
  576. Returns:
  577. mapping = {
  578. User: {
  579. properties: {
  580. firstname: {
  581. type: 'keyword',
  582. },
  583. }
  584. }
  585. }
  586. =end
  587. def self.get_mapping_properties_object(object)
  588. result = {
  589. properties: {}
  590. }
  591. store_columns = %w[preferences data]
  592. # for elasticsearch 6.x and later
  593. string_type = 'text'
  594. string_raw = { type: 'keyword', ignore_above: 5012 }
  595. boolean_raw = { type: 'boolean' }
  596. object.columns_hash.each do |key, value|
  597. if value.type == :string && value.limit && value.limit <= 5000 && store_columns.exclude?(key)
  598. result[:properties][key] = {
  599. type: string_type,
  600. fields: {
  601. keyword: string_raw,
  602. }
  603. }
  604. elsif value.type == :integer
  605. result[:properties][key] = {
  606. type: 'integer',
  607. }
  608. elsif value.type == :datetime || value.type == :date
  609. result[:properties][key] = {
  610. type: 'date',
  611. }
  612. elsif value.type == :boolean
  613. result[:properties][key] = {
  614. type: 'boolean',
  615. fields: {
  616. keyword: boolean_raw,
  617. }
  618. }
  619. elsif value.type == :binary
  620. result[:properties][key] = {
  621. type: 'binary',
  622. }
  623. elsif value.type == :bigint
  624. result[:properties][key] = {
  625. type: 'long',
  626. }
  627. elsif value.type == :decimal
  628. result[:properties][key] = {
  629. type: 'float',
  630. }
  631. end
  632. end
  633. case object.name
  634. when 'Ticket'
  635. result[:properties][:article] = {
  636. type: 'nested',
  637. include_in_parent: true,
  638. }
  639. end
  640. result
  641. end
  642. # get es version
  643. def self.version
  644. @version ||= SearchIndexBackend.info&.dig('version', 'number')
  645. end
  646. def self.configured?
  647. Setting.get('es_url').present?
  648. end
  649. def self.model_indexable?(model_name)
  650. Models.indexable.any? { |m| m.name == model_name }
  651. end
  652. def self.default_model_settings
  653. {
  654. 'index.mapping.total_fields.limit' => 2000,
  655. }
  656. end
  657. def self.model_settings(model)
  658. settings = Setting.get('es_model_settings')[model.name] || {}
  659. default_model_settings.merge(settings)
  660. end
  661. def self.all_settings
  662. Models.indexable.each_with_object({}).to_h { |m| [m.name, model_settings(m)] }
  663. end
  664. def self.set_setting(model_name, key, value)
  665. raise "It is not possible to configure settings for the non-indexable model '#{model_name}'." if !model_indexable?(model_name)
  666. raise __("The required parameter 'key' is missing.") if key.blank?
  667. raise __("The required parameter 'value' is missing.") if value.blank?
  668. config = Setting.get('es_model_settings')
  669. config[model_name] ||= {}
  670. config[model_name][key] = value
  671. Setting.set('es_model_settings', config)
  672. end
  673. def self.unset_setting(model_name, key)
  674. raise "It is not possible to configure settings for the non-indexable model '#{model_name}'." if !model_indexable?(model_name)
  675. raise __("The required parameter 'key' is missing.") if key.blank?
  676. config = Setting.get('es_model_settings')
  677. config[model_name] ||= {}
  678. config[model_name].delete(key)
  679. Setting.set('es_model_settings', config)
  680. end
  681. def self.create_index(models = Models.indexable)
  682. models.each do |local_object|
  683. SearchIndexBackend.index(
  684. action: 'create',
  685. name: local_object.name,
  686. data: {
  687. mappings: SearchIndexBackend.get_mapping_properties_object(local_object),
  688. settings: model_settings(local_object),
  689. }
  690. )
  691. end
  692. end
  693. def self.drop_index(models = Models.indexable)
  694. models.each do |local_object|
  695. SearchIndexBackend.index(
  696. action: 'delete',
  697. name: local_object.name,
  698. )
  699. end
  700. end
  701. def self.create_object_index(object)
  702. models = Models.indexable.select { |c| c.to_s == object }
  703. create_index(models)
  704. end
  705. def self.drop_object_index(object)
  706. models = Models.indexable.select { |c| c.to_s == object }
  707. drop_index(models)
  708. end
  709. def self.pipeline(create: false)
  710. pipeline = Setting.get('es_pipeline')
  711. if create && pipeline.blank?
  712. pipeline = "zammad#{SecureRandom.uuid}"
  713. Setting.set('es_pipeline', pipeline)
  714. end
  715. pipeline
  716. end
  717. def self.pipeline_settings
  718. {
  719. ignore_failure: true,
  720. ignore_missing: true,
  721. }
  722. end
  723. def self.create_pipeline
  724. SearchIndexBackend.processors(
  725. "_ingest/pipeline/#{pipeline(create: true)}": [
  726. {
  727. action: 'delete',
  728. },
  729. {
  730. action: 'create',
  731. description: __('Extract zammad-attachment information from arrays'),
  732. processors: [
  733. {
  734. foreach: {
  735. field: 'article',
  736. processor: {
  737. foreach: {
  738. field: '_ingest._value.attachment',
  739. processor: {
  740. attachment: {
  741. target_field: '_ingest._value',
  742. field: '_ingest._value._content',
  743. }.merge(pipeline_settings),
  744. }
  745. }.merge(pipeline_settings),
  746. }
  747. }.merge(pipeline_settings),
  748. },
  749. {
  750. foreach: {
  751. field: 'attachment',
  752. processor: {
  753. attachment: {
  754. target_field: '_ingest._value',
  755. field: '_ingest._value._content',
  756. }.merge(pipeline_settings),
  757. }
  758. }.merge(pipeline_settings),
  759. }
  760. ]
  761. }
  762. ]
  763. )
  764. end
  765. def self.drop_pipeline
  766. return if pipeline.blank?
  767. SearchIndexBackend.processors(
  768. "_ingest/pipeline/#{pipeline}": [
  769. {
  770. action: 'delete',
  771. },
  772. ]
  773. )
  774. end
  775. end