search_index_backend.rb 24 KB

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